From 827e4a1c6ce9f62a7a8082db5a8f4889e0e18036 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:00:48 -0700 Subject: [PATCH 1/8] feat: toggle structured sessions between chat and TUI --- .../app/h/[hostId]/session/[worktreeId].tsx | 30 ++ .../MobileStructuredAgentSessionView.test.tsx | 144 +++++- .../MobileStructuredAgentSessionView.tsx | 136 +++++- ...le-structured-agent-session-view-styles.ts | 27 +- .../mobile-structured-tui-send.test.ts | 78 +++ .../src/session/mobile-structured-tui-send.ts | 108 ++++ .../use-mobile-structured-agent-session.ts | 19 +- .../use-mobile-structured-session-entry.ts | 3 +- ...use-mobile-structured-session-mutations.ts | 33 +- .../use-mobile-structured-session-writes.ts | 2 + ...tui-resume-real-binary.integration.test.ts | 160 ++++++ .../codex/codex-tui-rollout-proof.test.ts | 98 ++++ src/main/codex/codex-tui-rollout-proof.ts | 144 ++++++ src/main/ipc/pty.ts | 12 + .../structured-agent-session-adapter.ts | 2 + ...tructured-agent-session-handoff-forward.ts | 165 +++++++ ...d-agent-session-handoff-operation-guard.ts | 36 ++ .../structured-agent-session-handoff-queue.ts | 32 ++ ...structured-agent-session-handoff-result.ts | 32 ++ ...tructured-agent-session-handoff-reverse.ts | 95 ++++ ...structured-agent-session-handoff-status.ts | 138 ++++++ .../structured-agent-session-handoff-types.ts | 64 +++ .../structured-agent-session-handoff.test.ts | 461 ++++++++++++++++++ .../structured-agent-session-handoff.ts | 316 ++++++++++++ .../structured-agent-session-host-handoff.ts | 120 +++++ ...ctured-agent-session-host-runtime-state.ts | 55 +++ .../structured-agent-session-host.ts | 115 ++--- ...tructured-agent-session-restart-restore.ts | 1 + .../structured-agent-session-subscribers.ts | 47 +- ...-session-handoff-lease-transitions.test.ts | 40 ++ ...agent-session-handoff-lease-transitions.ts | 95 ++++ ...gent-session-handoff-record-transitions.ts | 72 +++ .../agent-session-lease-transitions.ts | 18 +- .../agent-session-pty-write-gate.test.ts | 43 ++ .../runtime/agent-session-pty-write-gate.ts | 16 + .../agent-session-record-store.test.ts | 5 +- .../runtime/agent-session-record-store.ts | 15 +- src/main/runtime/orca-runtime.ts | 201 +++++++- .../structured-agent-session-schemas.ts | 11 + .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 12 + src/main/runtime/runtime-rpc.ts | 2 + ...ructured-agent-session-pty-binding.test.ts | 69 +++ .../structured-agent-session-runtime.ts | 47 +- .../runtime/structured-tui-exit-proof.test.ts | 66 +++ src/main/runtime/structured-tui-exit-proof.ts | 26 + .../NativeChatStructuredSession.tsx | 12 +- .../StructuredAgentSessionHandoffChrome.tsx | 215 ++++++++ .../StructuredAgentSessionStatusBridge.tsx | 42 +- .../use-structured-agent-session-read.ts | 21 +- .../use-structured-agent-session.ts | 23 +- ...cturedAgentSessionTerminalReturnButton.tsx | 25 + .../components/terminal-pane/TerminalPane.tsx | 37 ++ .../TerminalPaneHeaderOverlay.tsx | 9 + src/renderer/src/i18n/locales/en.json | 31 ++ .../structured-agent-session-handoff-store.ts | 50 ++ .../agent-session-lease-adjudication.ts | 7 +- src/shared/agent-session-mutation-envelope.ts | 8 +- src/shared/agent-session-wire.ts | 51 +- src/shared/runtime-types.ts | 2 + .../structured-agent-session-reducer.ts | 29 +- ...ss-version-agent-session-wire.unit.test.ts | 10 +- 62 files changed, 3814 insertions(+), 171 deletions(-) create mode 100644 mobile/src/session/mobile-structured-tui-send.test.ts create mode 100644 mobile/src/session/mobile-structured-tui-send.ts create mode 100644 src/main/codex/codex-tui-resume-real-binary.integration.test.ts create mode 100644 src/main/codex/codex-tui-rollout-proof.test.ts create mode 100644 src/main/codex/codex-tui-rollout-proof.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts create mode 100644 src/main/runtime/agent-session-handoff-lease-transitions.test.ts create mode 100644 src/main/runtime/agent-session-handoff-lease-transitions.ts create mode 100644 src/main/runtime/agent-session-handoff-record-transitions.ts create mode 100644 src/main/runtime/structured-agent-session-pty-binding.test.ts create mode 100644 src/main/runtime/structured-tui-exit-proof.test.ts create mode 100644 src/main/runtime/structured-tui-exit-proof.ts create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx create mode 100644 src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx create mode 100644 src/renderer/src/runtime/structured-agent-session-handoff-store.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c3caa93f12d..7f0226db109 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -228,6 +228,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 { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry' import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create' import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' @@ -4659,6 +4660,33 @@ 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({ + client, + terminal, + deviceToken: deviceTokenRef.current, + text, + attachments: [...restored, ...structuredSessionEntry.attachments.attachments] + }) + 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} @@ -4668,6 +4696,8 @@ export default function SessionScreen() { onAttachImage={() => void structuredSessionEntry.attachments.attach('library')} onRemoveAttachment={structuredSessionEntry.attachments.remove} onCancel={structuredSessionEntry.writes.cancel} + handoff={structuredSessionEntry.session.handoff} + onRequestHandoff={structuredSessionEntry.writes.requestHandoff} /> ) : activePendingTerminalTab ? ( diff --git a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx index 4c9e809a371..eb44743de63 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, type ComponentProps } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' @@ -50,34 +50,48 @@ function options(): MobileNativeChatSessionOptionsController { describe('MobileStructuredAgentSessionView command seam', () => { let renderer: ReactTestRenderer | null = null const onSend = vi.fn(async () => true) + const onTuiSend = vi.fn(async () => true) let controller: MobileNativeChatSessionOptionsController + function props( + overrides: Partial> = {} + ): ComponentProps { + return { + items: [], + status: 'ready', + hasOlder: false, + loadingOlder: false, + onLoadOlder: async () => false, + outbox: [], + onSend, + onTuiSend, + onTakeQueuedForEdit: async () => null, + onRetry: async () => {}, + onRespondToPrompt: async () => true, + sessionOptions: controller, + attachments: [], + isAttaching: false, + onAttachImage: () => {}, + onRemoveAttachment: () => {}, + onCancel: async () => true, + handoff: null, + onRequestHandoff: async () => true, + ...overrides + } + } + + function render(overrides = {}): void { + act(() => { + renderer = create(createElement(MobileStructuredAgentSessionView, props(overrides))) + }) + } + beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true onSend.mockClear() + onTuiSend.mockClear() controller = options() - act(() => { - renderer = create( - createElement(MobileStructuredAgentSessionView, { - items: [], - status: 'ready', - hasOlder: false, - loadingOlder: false, - onLoadOlder: async () => false, - outbox: [], - onSend, - onTakeQueuedForEdit: async () => null, - onRetry: async () => {}, - onRespondToPrompt: async () => true, - sessionOptions: controller, - attachments: [], - isAttaching: false, - onAttachImage: () => {}, - onRemoveAttachment: () => {}, - onCancel: async () => true - }) - ) - }) + render() }) afterEach(() => { @@ -108,4 +122,88 @@ describe('MobileStructuredAgentSessionView command seam', () => { renderer!.root.findByProps({ accessibilityRole: 'alert' }).findByType('Text').children ).toEqual(['/review is not available in chat sessions.']) }) + + it('keeps the composer enabled and routes stable TUI ownership through its bridge', async () => { + act(() => { + renderer!.update( + createElement( + MobileStructuredAgentSessionView, + props({ + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' } + } + }) + ) + ) + }) + + expect(renderer!.root.findByType('MobileNativeChatComposer').props.disabled).toBe(false) + await submit('hello from mobile') + expect(onTuiSend).toHaveBeenCalledWith('hello from mobile', []) + expect(onSend).not.toHaveBeenCalled() + }) + + it('routes stable TUI slash commands to typed terminal dispatch', async () => { + act(() => { + renderer!.update( + createElement( + MobileStructuredAgentSessionView, + props({ + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' } + } + }) + ) + ) + }) + + await submit('/model') + expect(onTuiSend).toHaveBeenCalledWith('/model', []) + expect(controller.invokeAction).not.toHaveBeenCalled() + expect(onSend).not.toHaveBeenCalled() + }) + + it('disables and refuses composer sends while ownership is switching', async () => { + act(() => { + renderer!.update( + createElement( + MobileStructuredAgentSessionView, + props({ + handoff: { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'handoff-1' + } + }) + ) + ) + }) + + const composer = renderer!.root.findByType('MobileNativeChatComposer') + expect(composer.props.disabled).toBe(true) + await act(async () => { + expect(await composer.props.onSend('racing send')).toBe(false) + }) + expect(onSend).not.toHaveBeenCalled() + expect(onTuiSend).not.toHaveBeenCalled() + }) + + it('keeps native ownership on the structured agent-session send path', async () => { + await submit('native message') + + expect(onSend).toHaveBeenCalledWith('native message', []) + expect(onTuiSend).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index a8733f55306..0379071e2a0 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -11,6 +11,11 @@ import { } from 'react-native' import { Square } from 'lucide-react-native' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffStatus +} from '../../../src/shared/agent-session-wire' import { colors } from '../theme/mobile-theme' import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatMessage } from './MobileNativeChatMessage' @@ -54,6 +59,7 @@ type Props = { onOpenFile?: (path: string) => void outbox: MobileStructuredOutboxEntry[] onSend: (text: string, restored: readonly PendingNativeChatImage[]) => Promise + onTuiSend?: (text: string, restored: readonly PendingNativeChatImage[]) => Promise onTakeQueuedForEdit: (clientMessageId: string) => Promise onRetry: (clientMessageId: string) => Promise onRespondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise @@ -63,6 +69,12 @@ type Props = { onAttachImage: () => void onRemoveAttachment: (id: string) => void onCancel: (turnId: string) => Promise + handoff: AgentSessionHandoffStatus | null + onRequestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' + ) => Promise } export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element { @@ -75,6 +87,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen const [commandError, setCommandError] = useState(null) const turnId = activeMobileStructuredTurnId(props.items) const allAttachments = [...restored, ...props.attachments] + const stableTuiOwner = + props.handoff?.owner === 'tui' && + props.handoff.phase === 'idle' && + Boolean(props.handoff.terminal?.handle) && + Boolean(props.onTuiSend) + const stableNativeOwner = + props.handoff === null || + props.handoff === undefined || + (props.handoff.owner === 'native' && props.handoff.phase === 'idle') + const composerEnabled = props.status === 'ready' && (stableNativeOwner || stableTuiOwner) const rows = useMemo( () => buildMobileStructuredTimeline(props.items, props.outbox), [props.items, props.outbox] @@ -103,6 +125,11 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen return ( + { + if (!composerEnabled) { + setCommandError('Session ownership is changing. Try again when switching finishes.') + return false + } if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text)) { setCommandError('Remove attachments before using a chat-session command.') return false } + if (stableTuiOwner) { + setCommandError(null) + const accepted = await props.onTuiSend!(text, restored) + if (accepted) { + setComposerText('') + setRestored([]) + } + return accepted + } const command = await dispatchMobileStructuredComposerCommand(text, props.sessionOptions) if (command.handled) { setCommandError(command.error) @@ -239,9 +279,101 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen } }} isAttaching={props.isAttaching} - disabled={props.status !== 'ready'} - placeholder={props.status === 'ready' ? 'Message Codex' : 'Reconnecting…'} + disabled={!composerEnabled} + placeholder={ + props.handoff?.owner === 'tui' && !stableTuiOwner + ? 'Agent is open in terminal' + : props.status === 'ready' + ? 'Message Codex' + : 'Reconnecting…' + } /> ) } + +function MobileStructuredHandoffBanner(props: { + status: AgentSessionHandoffStatus | null + isWorking: boolean + onRequest: Props['onRequestHandoff'] +}): React.JSX.Element | null { + const status = props.status + if (!status) { + return null + } + if (status.owner === 'native' && status.phase === 'idle') { + return ( + + {props.isWorking ? ( + <> + [styles.handoffButton, pressed && styles.pressed]} + onPress={() => void props.onRequest('to-tui', 'after-turn')} + > + Switch after this turn + + [styles.handoffButton, pressed && styles.pressed]} + onPress={() => void props.onRequest('to-tui', 'stop-turn')} + > + Stop turn and switch + + + ) : ( + [styles.handoffButton, pressed && styles.pressed]} + onPress={() => void props.onRequest('to-tui', 'now')} + > + Open agent TUI + + )} + + ) + } + const copy = + status.phase === 'waiting-for-exit' + ? 'Exit the agent terminal to continue in chat.' + : status.phase === 'switching' + ? mobileHandoffStageCopy(status) + : (status.error?.message ?? + `Agent is open in terminal${status.hostLabel ? ` on ${status.hostLabel}` : ''}`) + return ( + + {copy} + {status.phase === 'queued' && status.direction ? ( + void props.onRequest(status.direction!, 'after-turn', 'cancel-queued')} + > + Cancel + + ) : status.owner === 'tui' && status.phase === 'idle' ? ( + void props.onRequest('to-native', 'after-turn')}> + Return to chat + + ) : status.phase === 'failed' && + status.direction && + status.error?.recoverableOwner !== 'none' ? ( + void props.onRequest(status.direction!, 'now', 'retry')}> + Retry + + ) : null} + + ) +} + +function mobileHandoffStageCopy(status: AgentSessionHandoffStatus): string { + switch (status.stage) { + case 'preparing': + return status.direction === 'to-tui' ? 'Finishing chat session…' : 'Finishing agent terminal…' + case 'old-owner-stopped': + return status.direction === 'to-tui' ? 'Opening agent terminal…' : 'Resuming chat session…' + case 'new-owner-proving': + return status.direction === 'to-tui' ? 'Verifying agent terminal…' : 'Verifying chat session…' + case 'recovering': + return 'Recovering agent session…' + case 'manual-recovery': + return 'Agent session needs recovery' + default: + return 'Switching session owner…' + } +} diff --git a/mobile/src/session/mobile-structured-agent-session-view-styles.ts b/mobile/src/session/mobile-structured-agent-session-view-styles.ts index 4e4f0022e9f..6ef7fddd5b8 100644 --- a/mobile/src/session/mobile-structured-agent-session-view-styles.ts +++ b/mobile/src/session/mobile-structured-agent-session-view-styles.ts @@ -41,5 +41,30 @@ export const styles = StyleSheet.create({ paddingHorizontal: spacing.lg, paddingVertical: spacing.sm }, - writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize } + writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize }, + handoffBar: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.xs + }, + handoffBanner: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + handoffError: { backgroundColor: colors.bgRaised }, + handoffText: { flex: 1, color: colors.textSecondary, fontSize: typography.metaSize }, + handoffErrorText: { flex: 1, color: colors.statusRed, fontSize: typography.metaSize }, + handoffButton: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs }, + handoffButtonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' } }) diff --git a/mobile/src/session/mobile-structured-tui-send.test.ts b/mobile/src/session/mobile-structured-tui-send.test.ts new file mode 100644 index 00000000000..2a440ddc4f0 --- /dev/null +++ b/mobile/src/session/mobile-structured-tui-send.test.ts @@ -0,0 +1,78 @@ +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' + +function acceptingClient(): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + } as unknown as RpcClient +} + +afterEach(() => { + vi.useRealTimers() + resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() +}) + +describe('sendMobileStructuredTuiMessage', () => { + it('routes stable TUI composer text through terminal.send', async () => { + const client = acceptingClient() + + await expect( + sendMobileStructuredTuiMessage({ + client, + terminal: 'term-tui', + deviceToken: 'mobile-1', + text: 'hello', + attachments: [] + }) + ).resolves.toBe('accepted') + + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term-tui', + text: '\x15hello', + enter: true, + client: { id: 'mobile-1', type: 'mobile' } + }, + expect.objectContaining({ budgetSpansConnect: true }) + ) + }) + + it('types stable TUI slash commands one key at a time', async () => { + vi.useFakeTimers() + const client = acceptingClient() + + const sending = sendMobileStructuredTuiMessage({ + client, + terminal: 'term-tui', + deviceToken: null, + text: '/model', + 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', '/', 'm', 'o', 'd', 'e', 'l', '\r']) + expect( + vi + .mocked(client.sendRequest) + .mock.calls.every( + ([method, params]) => + method === 'terminal.send' && (params as { enter: boolean }).enter === false + ) + ).toBe(true) + }) +}) diff --git a/mobile/src/session/mobile-structured-tui-send.ts b/mobile/src/session/mobile-structured-tui-send.ts new file mode 100644 index 00000000000..8c6bb51b458 --- /dev/null +++ b/mobile/src/session/mobile-structured-tui-send.ts @@ -0,0 +1,108 @@ +import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' +import type { RpcClient } from '../transport/rpc-client' +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, + pasteMobileNativeChatImagePaths +} from './mobile-native-chat-image-send' +import { + openMobileNativeChatSendBudget, + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, + type MobileNativeChatSendOutcome +} from './mobile-native-chat-send' +import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' +import { + clearMobileNativeChatInputStale, + healMobileNativeChatStaleInput, + markMobileNativeChatInputStale +} from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' + +type MobileStructuredTuiSendArgs = { + client: RpcClient + terminal: string + deviceToken: string | null + text: string + attachments: readonly PendingNativeChatImage[] + sleep?: (ms: number) => Promise +} + +const wait = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +/** Sends the structured transcript composer through its proven TUI owner. */ +export async function sendMobileStructuredTuiMessage( + args: MobileStructuredTuiSendArgs +): Promise { + if (!acquireMobileNativeChatTerminalWrite(args.terminal)) { + return 'rejected' + } + try { + const deadline = openMobileNativeChatSendBudget() + const mobileClient = args.deviceToken + ? { id: args.deviceToken, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client: args.client, + terminal: args.terminal, + deviceToken: args.deviceToken, + deadline + })) + ) { + return 'rejected' + } + if (args.attachments.length > 0) { + const pasted = await pasteMobileNativeChatImagePaths({ + client: args.client, + terminal: args.terminal, + deviceToken: args.deviceToken, + imagePaths: args.attachments.map((attachment) => attachment.path), + deadline + }) + if (!pasted) { + markMobileNativeChatInputStale(args.terminal) + return 'rejected' + } + clearMobileNativeChatInputStale(args.terminal) + await (args.sleep ?? wait)(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS) + } + const textDeadline = + args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline + const classification = classifyMobileNativeChatSend('codex', args.text) + const outcome = + classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0 + ? await typeMobileNativeChatCommandWithOutcome({ + client: args.client, + terminal: args.terminal, + command: args.text, + ...(mobileClient ? { mobileClient } : {}), + deadline: textDeadline + }) + : await sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: args.text, + clearInputFirst: args.attachments.length === 0, + ...(mobileClient ? { mobileClient } : {}), + deadline: textDeadline + }) + if (args.attachments.length > 0 && outcome !== 'accepted') { + markMobileNativeChatInputStale(args.terminal) + } + return outcome + } catch { + if (args.attachments.length > 0) { + markMobileNativeChatInputStale(args.terminal) + } + return 'rejected' + } finally { + releaseMobileNativeChatTerminalWrite(args.terminal) + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index cf8fe08522c..aaa46cd4854 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'r import { AppState, type AppStateStatus } from 'react-native' import type { AgentSessionHistoryResult, + AgentSessionHandoffStatus, AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types' @@ -57,6 +58,7 @@ export function useMobileStructuredAgentSession(args: { hasOlder: boolean loadingOlder: boolean loadOlder: () => Promise + handoff: AgentSessionHandoffStatus | null } { const { client, sessionId } = args const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION) @@ -134,15 +136,23 @@ export function useMobileStructuredAgentSession(args: { } }) } - void client - .sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }) - .then((response) => { + void Promise.all([ + client.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }), + client.sendRequest('agentSession.handoffStatus', { sessionId }).catch(() => null) + ]) + .then(([response, handoffResponse]) => { if (closed) { return } if (!response.ok) { throw new Error(response.error.message) } + if (handoffResponse?.ok) { + dispatch({ + type: 'handoff', + handoff: handoffResponse.result as AgentSessionHandoffStatus + }) + } const result = response.result as AgentSessionHistoryResult if (result.ok) { dispatch({ type: 'tail-page', page: result.page }) @@ -240,6 +250,7 @@ export function useMobileStructuredAgentSession(args: { error: state.error || undefined, hasOlder: state.hasOlder, loadingOlder, - loadOlder + loadOlder, + handoff: state.handoff } } diff --git a/mobile/src/session/use-mobile-structured-session-entry.ts b/mobile/src/session/use-mobile-structured-session-entry.ts index e8720b02ec8..b8c12a30bbd 100644 --- a/mobile/src/session/use-mobile-structured-session-entry.ts +++ b/mobile/src/session/use-mobile-structured-session-entry.ts @@ -49,7 +49,8 @@ export function useMobileStructuredSessionEntry(args: { connected, sessionId, fence: session.fence, - submissions: session.submissions + submissions: session.submissions, + handoffOperationId: session.handoff?.operationId }) const sessionOptions = useMobileStructuredSessionOptions({ client, diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index ed23de9d5c5..20fb2b72590 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -6,6 +6,8 @@ import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, AgentSessionMutationResult, AgentSessionPromptResult } from '../../../src/shared/agent-session-wire' @@ -25,12 +27,18 @@ export type MobileStructuredSessionMutations = { respondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise setOption: (key: string, value: string) => Promise cancel: (turnId: string) => Promise + requestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' + ) => Promise } export function useMobileStructuredSessionMutations(args: { client: RpcClient | null sessionId: string | null fence: number | null + handoffOperationId?: string | null onRefusal: (message: string | null) => void }): MobileStructuredSessionMutations { const operationIdsRef = useRef(new Map()) @@ -44,12 +52,18 @@ export function useMobileStructuredSessionMutations(args: { activeContextRef.current = { client, fence, sessionId } }, [client, fence, sessionId]) const mutate = useCallback( - async (method: string, fingerprintMethod: string, fields: Record) => { + async ( + method: string, + fingerprintMethod: string, + fields: Record, + operationIdOverride?: string | null + ) => { if (!client || !sessionId || fence === null || client.getState() !== 'connected') { return null } const mutationKey = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}` const clientOperationId = + operationIdOverride ?? operationIdsRef.current.get(mutationKey) ?? createStructuredAgentSessionOperationId(() => ExpoCrypto.randomUUID()) operationIdsRef.current.set(mutationKey, clientOperationId) @@ -117,9 +131,22 @@ export function useMobileStructuredSessionMutations(args: { setOption: async (key: string, value: string) => Boolean(await mutate('agentSession.setOption', 'agentSession.setOption', { key, value })), cancel: async (turnId: string) => - Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })) + Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })), + requestHandoff: async ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action: 'start' | 'cancel-queued' | 'retry' = 'start' + ) => + Boolean( + await mutate( + 'agentSession.requestHandoff', + 'agentSession.requestHandoff', + { direction, mode, action }, + action === 'retry' ? args.handoffOperationId : null + ) + ) }), - [mutate] + [args.handoffOperationId, mutate] ) } diff --git a/mobile/src/session/use-mobile-structured-session-writes.ts b/mobile/src/session/use-mobile-structured-session-writes.ts index bdc4b4afd5b..072495ff2aa 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.ts @@ -31,6 +31,7 @@ export function useMobileStructuredSessionWrites(args: { sessionId: string | null fence: number | null submissions: readonly AgentJournalSubmission[] + handoffOperationId?: string | null }): MobileStructuredSessionWrites { const { client, connected, sessionId, fence, submissions } = args const [outbox, setOutbox] = useState([]) @@ -48,6 +49,7 @@ export function useMobileStructuredSessionWrites(args: { client, sessionId, fence, + handoffOperationId: args.handoffOperationId, onRefusal: setError }) diff --git a/src/main/codex/codex-tui-resume-real-binary.integration.test.ts b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts new file mode 100644 index 00000000000..cada4249591 --- /dev/null +++ b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts @@ -0,0 +1,160 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' +import { resolveCodexCommand } from '../codex-cli/command' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { openCodexThread } from './codex-structured-thread-open' +import { proveCodexTuiRollout } from './codex-tui-rollout-proof' + +const codexCommand = resolveCodexCommand() +const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0 +const itWithCodex = codexAvailable ? it : it.skip +const tempHomes: string[] = [] + +async function waitForTuiStart(proc: pty.IPty): Promise { + let output = '' + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)), + 15_000 + ) + proc.onData((data) => { + output += data + if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) { + clearTimeout(timeout) + resolve(output) + } + }) + proc.onExit(({ exitCode }) => { + clearTimeout(timeout) + reject( + new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`) + ) + }) + }) +} + +async function waitForRollout(path: string, threadId: string): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + const rollout = await readFile(path, 'utf8') + if (rollout.includes(threadId)) { + return rollout + } + } catch { + // The rollout is created asynchronously after thread/start. + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error('Codex did not materialize the resumed rollout') +} + +afterEach(async () => { + await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) +}) + +describe('real Codex structured-to-TUI resume', () => { + itWithCodex( + 'resumes the exact isolated rollout and reaches the initial TUI screen', + async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-')) + tempHomes.push(codexHome) + await writeFile( + join(codexHome, 'config.toml'), + [ + 'model_provider = "orca-integration"', + 'model = "gpt-5"', + '', + '[model_providers.orca-integration]', + 'name = "Orca integration"', + 'base_url = "http://127.0.0.1:9/v1"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + `[projects.${JSON.stringify(process.cwd())}]`, + 'trust_level = "trusted"', + '' + ].join('\n') + ) + const connection = await openCodexAppServerConnection({ + command: codexCommand, + args: ['app-server'], + env: { CODEX_HOME: codexHome } + }) + const opened = await openCodexThread( + connection, + { cwd: process.cwd(), resumeThreadId: null }, + 15_000 + ) + await connection.request( + 'turn/start', + { + threadId: opened.threadId, + clientUserMessageId: 'real-binary-resume-fixture', + input: [{ type: 'text', text: 'materialize the isolated resume fixture' }] + }, + { timeoutMs: 15_000 } + ) + expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId) + await connection.close() + + expect(opened.historyPath).toContain(opened.threadId) + expect(opened.historyPath).toContain(join(codexHome, 'sessions')) + const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { + ...process.env, + CODEX_HOME: codexHome, + ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof', + TERM: 'xterm-256color' + } + }) + const tuiExit = new Promise((resolve) => + tui.onExit(({ exitCode }) => resolve(exitCode)) + ) + const kittyKeyboard = new TerminalKittyKeyboardModeTracker() + let tuiOutput = '' + let lastOutputAt: number | null = null + tui.onData((data) => { + tuiOutput += data + lastOutputAt = Date.now() + kittyKeyboard.scan(data) + }) + try { + await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i) + const proof = await proveCodexTuiRollout({ + codexHome, + threadId: opened.threadId, + kittyKeyboardFlags: kittyKeyboard.flags, + readOutput: () => ({ text: tuiOutput, lastOutputAt }), + write: (data) => { + tui.write(data) + return true + } + }) + expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!)) + } finally { + try { + tui.kill() + } catch { + // Already exited. + } + await Promise.race([ + tuiExit, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000) + ) + ]) + } + }, + 30_000 + ) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts new file mode 100644 index 00000000000..0ec6943d4db --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + codexTuiStatusProbeInput, + parseCodexTuiStatusSessionId, + proveCodexTuiRollout, + resolvePinnedCodexRolloutProof +} from './codex-tui-rollout-proof' + +const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70' +const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71' + +describe('Codex TUI rollout proof', () => { + it('parses the exact session shown by status', () => { + expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull() + }) + + it('uses Kitty Enter only while the TUI negotiated Kitty input', () => { + expect(codexTuiStatusProbeInput(0)).toEqual({ + command: '\u001b[200~/status\u001b[201~', + submit: '\r' + }) + expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u') + expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u') + }) + + it('resolves only the exact session_meta rollout under the pinned account home', async () => { + const files = async function* (): AsyncGenerator { + yield '/pinned/sessions/scratch/rollout-wrong.jsonl' + yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + const readSessionMetaId = vi.fn(async () => THREAD) + + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId }) + ).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`) + expect(readSessionMetaId).toHaveBeenCalledTimes(1) + }) + + it('rejects a rollout whose session_meta names another thread', async () => { + const files = async function* (): AsyncGenerator { + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { + listFiles: files, + readSessionMetaId: async () => OTHER_THREAD + }) + ).resolves.toBeNull() + }) + + it('rejects a different status session after filesystem proof', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: `Session: ${OTHER_THREAD}`, + lastOutputAt: reads++ > 0 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).rejects.toThrow('resumed a different Codex session') + expect(write).toHaveBeenCalledTimes(2) + }) + + it('dismisses status only after the exact session is observed', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts new file mode 100644 index 00000000000..190d33a2ebe --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -0,0 +1,144 @@ +import { open } from 'node:fs/promises' +import { join } from 'node:path' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { relativePathInsideRoot } from '../../shared/cross-platform-path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + +const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const STATUS_SESSION_RE = new RegExp(`\\bSession(?: ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi') +const ROLLOUT_READ_LIMIT = 64 * 1024 +const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' +const KITTY_ENTER = '\u001b[13u' + +export type CodexTuiProofOutput = { + text: string + lastOutputAt: number | null +} + +export type CodexTuiRolloutProofOptions = { + listFiles?: (sessionsRoot: string) => AsyncIterable + readSessionMetaId?: (filePath: string) => Promise +} + +export function parseCodexTuiStatusSessionId(output: string): string | null { + let sessionId: string | null = null + for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) { + sessionId = match[1] ?? null + } + return sessionId +} + +export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string { + return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r' +} + +export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): { + command: string + submit: string +} { + return { + command: STATUS_COMMAND_PASTE, + submit: codexTuiStatusSubmitInput(kittyKeyboardFlags) + } +} + +export async function resolvePinnedCodexRolloutProof( + codexHome: string, + threadId: string, + options: CodexTuiRolloutProofOptions = {} +): Promise { + const sessionsRoot = join(codexHome, 'sessions') + const listFiles = + options.listFiles ?? + ((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 })) + const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId + const expectedSuffix = `-${threadId}.jsonl`.toLowerCase() + + for await (const filePath of listFiles(sessionsRoot)) { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/') + if ( + !relativePath || + !/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) || + !relativePath.toLowerCase().endsWith(expectedSuffix) + ) { + continue + } + if ((await readSessionMetaId(filePath)) === threadId) { + return filePath + } + } + return null +} + +export async function proveCodexTuiRollout(input: { + codexHome: string + threadId: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ transcriptPath: string }> { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + const transcriptPath = await resolveRollout(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const output = input.readOutput() + if (output.lastOutputAt !== baselineOutputAt) { + const observedThreadId = parseCodexTuiStatusSessionId(output.text) + if (observedThreadId && observedThreadId !== input.threadId) { + throw new Error('The agent terminal resumed a different Codex session.') + } + if (observedThreadId === input.threadId) { + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + return { transcriptPath } + } + } + await delay(100) + } + throw new Error('The agent terminal did not prove the expected Codex rollout.') +} + +async function readCodexRolloutSessionMetaId(filePath: string): Promise { + const file = await open(filePath, 'r') + try { + const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim() + if (!firstLine) { + return null + } + const record = JSON.parse(firstLine) as { + type?: unknown + id?: unknown + payload?: { id?: unknown } + } + if (record.type !== 'session_meta') { + return null + } + const id = record.payload?.id ?? record.id + return typeof id === 'string' && id.length > 0 ? id : null + } catch { + return null + } finally { + await file.close() + } +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 03453c3aeeb..40e88f6b719 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5298,6 +5298,7 @@ export function registerPtyHandlers( } const response = { id: result.id, + ...(result.pid ? { pid: result.pid } : {}), ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle) ? { @@ -5349,6 +5350,17 @@ export function registerPtyHandlers( return false } }, + writeAgentSessionProof: (ptyId, data, authority) => { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + return false + } + try { + getProviderForPty(ptyId).write(ptyId, data) + return true + } catch { + return false + } + }, probePtyLiveness: async (ptyId) => { try { // Why: no locally routed provider can authoritatively answer for a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 002a5f47292..c5e34e48e79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -90,4 +90,6 @@ export type StructuredAgentSessionAdapter = { /** Transcript path for journal recovery. Omit to let the existing session-file * resolver discover it from the provider session id. */ historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise + /** Gracefully stops the structured owner after its event stream is drained. */ + closeSession?(sessionId: string): Promise } 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 new file mode 100644 index 00000000000..ede43215076 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + reserveStoredAgentSessionHandoffOwner, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import type { + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToTui( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + if (retry && record.lease.handoffStage === 'old-owner-stopped') { + record = await recoverNativeAfterTuiFailure(context, sessionId, operationId) + } + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-tui') + } else if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + record = context.requireRecord(sessionId) + await deps.suspendNative(sessionId) + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + let owner: StructuredTuiOwner | null = null + try { + 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() + }) + record = await deps.store.proveOwner({ + sessionId, + fence: record.lease.runtimeFence, + link: owner.link, + now: deps.now() + }) + } catch (error) { + if (owner) { + if (!deps.transport?.stopFailedTuiLaunch) { + await markManualRecovery(context, sessionId, operationId) + throw error + } + try { + await deps.transport.stopFailedTuiLaunch(owner) + } catch (stopError) { + await markManualRecovery(context, sessionId, operationId) + throw new AggregateError( + [error, stopError], + 'The failed terminal launch could not be proven stopped.' + ) + } + } + await recoverNativeAfterTuiFailure(context, sessionId, operationId) + throw error + } + context.retainOwner(sessionId, owner) + context.setStatus(sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) +} + +async function markManualRecovery( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +): Promise { + const { deps } = context + const record = context.requireRecord(sessionId) + await setStoredAgentSessionHandoffStage(deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: operationId, + now: deps.now() + }) +} + +async function recoverNativeAfterTuiFailure( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +) { + const { deps } = context + let record = context.requireRecord(sessionId) + if (record.lease.handoffStage === 'new-owner-proving') { + record = await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + try { + return await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts new file mode 100644 index 00000000000..c24bca96070 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -0,0 +1,36 @@ +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' + +type ActiveOperation = { operationId: string; fingerprint: string } + +export class StructuredAgentSessionHandoffOperationGuard { + private readonly activeBySession = new Map() + + check(input: { + sessionId: string + operationId: string + fingerprint: string + action: 'start' | 'cancel-queued' | 'retry' + status?: AgentSessionHandoffStatus + }): 'new' | 'replay' | 'conflict' { + const active = this.activeBySession.get(input.sessionId) + if ( + (input.status?.operationId === input.operationId && + input.status.phase !== 'idle' && + input.action === 'start') || + (active?.operationId === input.operationId && active.fingerprint === input.fingerprint) + ) { + return 'replay' + } + return active?.operationId === input.operationId ? 'conflict' : 'new' + } + + start(sessionId: string, operation: ActiveOperation): void { + this.activeBySession.set(sessionId, operation) + } + + finish(sessionId: string, operationId: string): void { + if (this.activeBySession.get(sessionId)?.operationId === operationId) { + this.activeBySession.delete(sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts new file mode 100644 index 00000000000..53781aecd49 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -0,0 +1,32 @@ +export class StructuredAgentSessionHandoffQueue { + private readonly tokens = new Map() + + cancel(sessionId: string): void { + this.tokens.delete(sessionId) + } + + enqueue(sessionId: string, isIdle: () => boolean, onReady: () => void): void { + const token = Symbol(sessionId) + this.tokens.set(sessionId, token) + void this.waitUntilIdle(sessionId, token, isIdle).then((ready) => { + if (ready) { + onReady() + } + }) + } + + private async waitUntilIdle( + sessionId: string, + token: symbol, + isIdle: () => boolean + ): Promise { + while (this.tokens.get(sessionId) === token) { + if (isIdle()) { + this.tokens.delete(sessionId) + return true + } + await new Promise((resolve) => setTimeout(resolve, 150)) + } + return false + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts new file mode 100644 index 00000000000..fe480d6359c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts @@ -0,0 +1,32 @@ +import type { + AgentSessionHandoffResult, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredHandoffRefusal( + code: AgentSessionWireRefusal['code'], + message: string +): AgentSessionWireRefusal { + return { code, message } +} + +export function structuredHandoffSuccess( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + replayed: boolean, + status: AgentSessionHandoffResult['status'] +): AgentSessionMutationResult { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return { + ok: true, + replayed, + fence: record.lease.runtimeFence, + cursor: deps.session(sessionId).journal.cursor(), + value: { status } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts new file mode 100644 index 00000000000..0f10cfe2019 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + reserveStoredAgentSessionHandoffOwner, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToNative( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + const owner = context.owner(sessionId) + let transcriptPath = owner?.transcriptPath + if (!retry || record.lease.handoffStage === 'preparing') { + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-native') + } + if (!owner) { + throw new Error('The owning agent terminal could not be identified.') + } + context.setStatus(sessionId, { + owner: 'tui', + direction: 'to-native', + phase: 'waiting-for-exit', + stage: 'preparing', + operationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) + const exited = await deps.transport!.waitForTuiExit(owner) + transcriptPath = exited.transcriptPath ?? owner.transcriptPath + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + } else if ( + record.lease.handoffStage !== 'old-owner-stopped' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + await deps.importTuiHistory({ + sessionId, + fence: record.lease.runtimeFence, + ...(transcriptPath ? { transcriptPath } : {}) + }) + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + try { + record = await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: deps.now() + }) + } + throw error + } + context.releaseOwner(sessionId) + context.setStatus(sessionId, { + owner: 'native', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts new file mode 100644 index 00000000000..62e46fa2df7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -0,0 +1,138 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus { + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.handoffOperationId + ) { + return persistedFailedStructuredHandoffStatus(record) + } + if (record.lease.handoffStage === 'manual-recovery') { + return { + owner: 'none', + direction: null, + 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' + } + } + } + if (record.lease.handoffStage) { + const direction = + record.lease.handoffStage === 'preparing' + ? record.lease.runtimeKind === 'native' + ? 'to-tui' + : 'to-native' + : record.lease.runtimeKind === 'tui' + ? 'to-tui' + : 'to-native' + return { + owner: + record.lease.claimStatus === 'live' && record.lease.ownerProcess + ? record.lease.runtimeKind + : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } + } + return { + owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } +} + +function persistedFailedStructuredHandoffStatus( + record: AgentSessionRecord +): AgentSessionHandoffStatus { + const recoverableOwner = record.lease.runtimeKind + const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native' + return { + owner: recoverableOwner, + direction, + phase: 'failed', + stage: 'old-owner-stopped', + operationId: record.lease.handoffOperationId, + error: { + message: + direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + recoverableOwner + } + } +} + +export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean { + return journal + .snapshot() + .items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export function switchingStructuredHandoffStatus( + record: AgentSessionRecord, + direction: 'to-tui' | 'to-native', + hostLabel?: string +): AgentSessionHandoffStatus { + return { + owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + ...(hostLabel ? { hostLabel } : {}) + } +} + +export function failedStructuredHandoffStatus( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest, + error: unknown, + hostLabel?: string +): AgentSessionHandoffStatus { + const recoverableOwner = + record.lease.handoffStage === 'manual-recovery' + ? 'none' + : record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released' + ? record.lease.runtimeKind + : record.lease.ownerProcess + ? record.lease.runtimeKind + : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' + ? 'native' + : 'none' + return { + owner: recoverableOwner, + direction: params.direction, + phase: 'failed', + stage: record.lease.handoffStage, + operationId: params.envelope.clientOperationId, + ...(hostLabel ? { hostLabel } : {}), + error: { + message: + recoverableOwner === 'none' + ? "Couldn't verify which runtime owns this session — manual recovery is required" + : params.direction === 'to-tui' + ? "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 + } + } +} 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 new file mode 100644 index 00000000000..acb3d92bfe0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -0,0 +1,64 @@ +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredTuiOwner = { + terminal: { handle: string; tabId: string; paneKey: string } + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink + transcriptPath?: string +} + +export type StructuredAgentSessionHandoffTransport = { + hostLabel: string + launchTui(input: { + record: AgentSessionRecord + fence: number + spawnToken: string + }): Promise + waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> + tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' + stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise +} + +export type StructuredAgentSessionHandoffDeps = { + store: AgentSessionRecordStore + claimKeyId: string + transport?: StructuredAgentSessionHandoffTransport + session: (sessionId: string) => { journal: AgentSessionJournal; fence: number } + suspendNative: (sessionId: string) => Promise + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise + importTuiHistory: (input: { + sessionId: string + fence: number + transcriptPath?: string + }) => Promise + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + schedule: (sessionId: string, task: () => Promise) => Promise + now: () => number +} + +export type StructuredAgentSessionHandoffFlowContext = { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + enterPreparing: ( + record: AgentSessionRecord, + operationId: string, + direction: 'to-tui' | 'to-native' + ) => Promise + publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void + requireRecord: (sessionId: string) => 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 new file mode 100644 index 00000000000..155a064ae00 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -0,0 +1,461 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-handoff' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let store: AgentSessionRecordStore +let journal: Awaited> +let coordinator: StructuredAgentSessionHandoffCoordinator +let statuses: AgentSessionHandoffStatus[] +let tuiIdle: boolean +let importFailure: Error | null +let nativeAcquireFailure: Error | null +let launchTui: ReturnType> +let waitForTuiExit: ReturnType< + typeof vi.fn +> +let stopFailedTuiLaunch: ReturnType< + typeof vi.fn> +> +let acquireNativeStop: ReturnType Promise>> +let acquireNativeCalls: number +let operations: number + +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function request( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + options: { action?: 'start' | 'cancel-queued' | 'retry'; operationId?: string } = {} +): AgentSessionHandoffRequest { + const action = options.action ?? 'start' + const fields = { direction, mode, action } + return { + envelope: { + sessionId: SESSION, + clientOperationId: options.operationId ?? operationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: SESSION, + fields + }) + }, + ...fields + } +} + +function process(spawnToken: string, pid: number) { + return { + hostId: 'local', + pid, + processStartTimeMs: NOW - 1_000, + spawnToken + } +} + +function link(fence: number, id: string) { + return { + linkId: id, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } +} + +async function establishNativeOwner(): Promise { + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'native-initial', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: process('native-initial', 4100), + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { ...link(fence, 'initial-link'), origin: 'created' }, + now: NOW + }) +} + +function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'tab-tui:leaf-tui' }, + process: process(spawnToken, 4200), + link: link(fence, `tui-link-${fence}`), + transcriptPath: join(root, 'rollout.jsonl') + } +} + +async function acquireNative(input: { + sessionId: string + fence: number + spawnToken: string +}): Promise { + acquireNativeCalls += 1 + if (nativeAcquireFailure) { + const error = nativeAcquireFailure + nativeAcquireFailure = null + throw error + } + await store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: process(input.spawnToken, 4300 + acquireNativeCalls), + now: NOW + }) + return store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: link(input.fence, `native-link-${input.fence}`), + now: NOW + }) +} + +function createCoordinator(): StructuredAgentSessionHandoffCoordinator { + return new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui, + waitForTuiExit, + tuiStatus: () => (tuiIdle ? 'idle' : 'busy'), + stopFailedTuiLaunch + }, + session: () => ({ + journal, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + }), + suspendNative: vi.fn(async () => undefined), + acquireNative, + acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), + importTuiHistory: async ({ fence }) => { + if (importFailure) { + const error = importFailure + importFailure = null + throw error + } + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'tui-turn', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, + { fence, recovered: true } + ) + }, + publish: (_sessionId, status) => statuses.push(status), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) +} + +async function appendStatus(state: 'running' | 'completed'): Promise { + await journal.appendItem( + { provider: 'orca', clientMessageId: 'turn-status' }, + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + ) +} + +async function waitForPhase(phase: AgentSessionHandoffStatus['phase']): Promise { + await vi.waitFor(() => expect(coordinator.status(SESSION).phase).toBe(phase)) +} + +async function moveToTui(): Promise { + expect(coordinator.request(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui' })) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + operations = 0 + statuses = [] + tuiIdle = true + importFailure = null + nativeAcquireFailure = null + acquireNativeCalls = 0 + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await establishNativeOwner() + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) + waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) + stopFailedTuiLaunch = vi.fn(async () => undefined) + acquireNativeStop = vi.fn(async () => true) + coordinator = createCoordinator() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('structured session ownership handoff', () => { + it('completes native to TUI to native with one owner and journal continuity', async () => { + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'native-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'from native' }] }, + { fence: 1 } + ) + await moveToTui() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + + expect(coordinator.request(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native' })) + + const messages = journal.snapshot().items.filter((item) => item.body.kind === 'message') + expect(messages).toHaveLength(2) + expect(new Set(messages.map((item) => item.itemId)).size).toBe(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('queues a busy native turn, supports cancel, and starts only after idle', async () => { + await appendStatus('running') + expect(coordinator.request(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + expect( + coordinator.request(request('to-tui', 'after-turn', { action: 'cancel-queued' })) + ).toMatchObject({ ok: true }) + await appendStatus('completed') + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(launchTui).not.toHaveBeenCalled() + + await appendStatus('running') + expect(coordinator.request(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + await appendStatus('completed') + await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce()) + }) + + it('replays a duplicate active handoff without launching a second owner', async () => { + let finishLaunch!: () => void + launchTui.mockImplementationOnce( + ({ fence, spawnToken }) => + new Promise((resolve) => { + finishLaunch = () => resolve(makeTuiOwner(fence, spawnToken)) + }) + ) + const operation = operationId() + const first = request('to-tui', 'now', { operationId: operation }) + expect(coordinator.request(first)).toMatchObject({ ok: true, replayed: false }) + await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce()) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving' + }) + expect(coordinator.status(SESSION).owner).not.toBe('tui') + expect(coordinator.request(first)).toMatchObject({ ok: true, replayed: true }) + finishLaunch() + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + expect(launchTui).toHaveBeenCalledOnce() + }) + + it('rejects cancellation once a queued handoff is no longer pending', async () => { + expect( + coordinator.request(request('to-tui', 'after-turn', { action: 'cancel-queued' })) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) + expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) + }) + + it('requires acknowledged cancellation before stopping a native turn', async () => { + await appendStatus('running') + expect(coordinator.request(request('to-tui', 'stop-turn'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + expect(acquireNativeStop).toHaveBeenCalledWith('turn-1') + expect(acquireNativeStop.mock.invocationCallOrder[0]).toBeLessThan( + launchTui.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('queues a busy TUI from host status instead of trusting a stale journal', async () => { + await moveToTui() + tuiIdle = false + expect(coordinator.request(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + expect(waitForTuiExit).not.toHaveBeenCalled() + tuiIdle = true + await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce()) + }) + + it('recovers native ownership when TUI launch or proof fails', async () => { + launchTui.mockRejectedValueOnce(new Error('resume proof failed')) + expect(coordinator.request(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'native', + error: { recoverableOwner: 'native' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('stops a launched but uncommitted TUI before recovering native', async () => { + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + ...makeTuiOwner(fence, spawnToken), + link: link(fence + 1, 'bad-proof') + })) + expect(coordinator.request(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native') + }) + + it('requires manual recovery when a failed TUI launch cannot be proven stopped', async () => { + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + ...makeTuiOwner(fence, spawnToken), + link: link(fence + 1, 'bad-proof') + })) + stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved')) + const failedOperation = operationId() + expect( + coordinator.request(request('to-tui', 'now', { operationId: failedOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'none', + stage: 'manual-recovery', + error: { recoverableOwner: 'none' } + }) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') + expect( + coordinator.request( + 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 () => { + await moveToTui() + importFailure = new Error('history unavailable') + const retryOperation = operationId() + expect( + coordinator.request(request('to-native', 'after-turn', { operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + operationId: retryOperation, + error: { recoverableOwner: 'tui' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'released', + handoffStage: 'old-owner-stopped' + }) + + expect( + coordinator.request( + request('to-native', 'now', { action: 'retry', operationId: retryOperation }) + ) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + expect(waitForTuiExit).toHaveBeenCalledOnce() + }) + + it('abandons a failed native acquisition and retries from the stopped TUI', async () => { + await moveToTui() + nativeAcquireFailure = new Error('native proof unavailable') + const retryOperation = operationId() + expect( + coordinator.request(request('to-native', 'after-turn', { operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + operationId: retryOperation, + error: { recoverableOwner: 'tui' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'released', + handoffStage: 'old-owner-stopped' + }) + + coordinator = createCoordinator() + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + direction: 'to-native', + phase: 'failed', + operationId: retryOperation + }) + expect( + coordinator.request( + request('to-native', 'now', { action: 'retry', operationId: retryOperation }) + ) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + expect(waitForTuiExit).toHaveBeenCalledOnce() + expect(acquireNativeCalls).toBe(2) + }) + + it('publishes every durable stage so concurrent clients see the same transfer', async () => { + await moveToTui() + expect(statuses.map((status) => status.stage)).toEqual( + expect.arrayContaining(['preparing', 'old-owner-stopped', 'new-owner-proving', null]) + ) + expect(statuses.filter((status) => status.phase === 'switching')).toHaveLength(3) + }) +}) 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 new file mode 100644 index 00000000000..e394a4f37d0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -0,0 +1,316 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { + structuredHandoffRefusal as refusal, + structuredHandoffSuccess +} from './structured-agent-session-handoff-result' +import { + failedStructuredHandoffStatus, + idleStructuredHandoffStatus, + structuredSessionHasPendingPrompt, + switchingStructuredHandoffStatus +} from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffCoordinator { + private readonly statuses = new Map() + private readonly queue = new StructuredAgentSessionHandoffQueue() + private readonly tuiOwners = new Map() + private readonly operationGuard = new StructuredAgentSessionHandoffOperationGuard() + + constructor(private readonly deps: StructuredAgentSessionHandoffDeps) {} + + status(sessionId: string): AgentSessionHandoffStatus { + return ( + this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.requireRecord(sessionId)) + ) + } + + request( + params: AgentSessionHandoffRequest + ): AgentSessionMutationResult { + const record = this.requireRecord(params.envelope.sessionId) + const fields = { + direction: params.direction, + mode: params.mode, + action: params.action ?? 'start' + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: record.sessionId, + fields + }) + const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) + if (conflict) { + return { ok: false, refusal: conflict } + } + const currentStatus = this.statuses.get(record.sessionId) + const operationDecision = this.operationGuard.check({ + sessionId: record.sessionId, + operationId: params.envelope.clientOperationId, + fingerprint, + action: fields.action, + ...(currentStatus ? { status: currentStatus } : {}) + }) + if (operationDecision === 'replay') { + return this.success(record.sessionId, true) + } + if (operationDecision === 'conflict') { + return { + ok: false, + refusal: refusal( + 'agent_session_operation_conflict', + 'This handoff operation is already running with different parameters.' + ) + } + } + if (params.envelope.expectedRuntimeFence !== record.lease.runtimeFence) { + return { + ok: false, + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'The session owner changed before the handoff request arrived.', + currentFence: record.lease.runtimeFence + } + } + } + if (fields.action === 'cancel-queued') { + if (currentStatus?.phase !== 'queued' || currentStatus.direction !== params.direction) { + return { + ok: false, + refusal: refusal('agent_session_operation_conflict', 'No matching queued handoff exists.') + } + } + this.queue.cancel(record.sessionId) + this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + return this.success(record.sessionId, false) + } + if (!this.deps.transport) { + return { + ok: false, + refusal: refusal( + 'structured_agent_session_unsupported', + 'Agent TUI handoff is unavailable on this host.' + ) + } + } + if (fields.action === 'retry') { + return this.retry(params) + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { + return { + ok: false, + refusal: refusal( + 'agent_session_conflict', + `The ${expectedOwner} runtime does not own this session.` + ) + } + } + if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { + return { + ok: false, + refusal: refusal( + 'agent_session_conflict', + 'Resolve the pending question or approval before switching.' + ) + } + } + const turnId = activeStructuredAgentSessionTurnId( + this.deps.session(record.sessionId).journal.snapshot().items + ) + const busy = + expectedOwner === 'native' ? turnId !== null : this.tuiStatus(record.sessionId) !== 'idle' + if (busy && params.mode === 'now') { + return { + ok: false, + refusal: refusal('agent_session_conflict', 'The current turn must finish before switching.') + } + } + if (busy && params.mode === 'after-turn') { + this.queueAfterTurn(params) + return this.success(record.sessionId, false) + } + if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { + return { + ok: false, + refusal: refusal( + 'structured_agent_session_unsupported', + 'Exit the agent terminal after this turn to continue in chat.' + ) + } + } + this.begin(params, turnId, fingerprint) + return this.success(record.sessionId, false) + } + + private retry( + params: AgentSessionHandoffRequest + ): AgentSessionMutationResult { + const status = this.status(params.envelope.sessionId) + if ( + status?.phase !== 'failed' || + status.direction !== params.direction || + status.operationId !== params.envelope.clientOperationId || + status.error?.recoverableOwner === 'none' + ) { + return { + ok: false, + refusal: refusal('agent_session_operation_conflict', 'This handoff is no longer retryable.') + } + } + this.begin(params, null, params.envelope.payloadFingerprint) + return this.success(params.envelope.sessionId, false) + } + + private success( + sessionId: string, + replayed: boolean + ): AgentSessionMutationResult { + return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) + } + + private queueAfterTurn(params: AgentSessionHandoffRequest): void { + const sessionId = params.envelope.sessionId + 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 + }) + this.queue.enqueue( + sessionId, + () => + params.direction === 'to-tui' + ? !activeStructuredAgentSessionTurnId( + this.deps.session(sessionId).journal.snapshot().items + ) + : this.tuiStatus(sessionId) === 'idle', + () => { + const next = { ...params, mode: 'now' as const } + this.begin(next, null, params.envelope.payloadFingerprint) + } + ) + } + + private begin( + params: AgentSessionHandoffRequest, + turnId: string | null, + fingerprint: string + ): void { + const sessionId = params.envelope.sessionId + this.operationGuard.start(sessionId, { + operationId: params.envelope.clientOperationId, + fingerprint + }) + void this.deps + .schedule(sessionId, async () => { + if (turnId && params.mode === 'stop-turn') { + const stopped = await this.stopNativeTurn(sessionId, turnId) + if (!stopped) { + throw new Error('The current turn did not acknowledge cancellation.') + } + } + await (params.direction === 'to-tui' + ? handoffStructuredSessionToTui(this.flowContext(), params, params.action === 'retry') + : handoffStructuredSessionToNative(this.flowContext(), params, params.action === 'retry')) + }) + .catch((error) => this.fail(params, error)) + .finally(() => this.operationGuard.finish(sessionId, params.envelope.clientOperationId)) + } + + private tuiStatus(sessionId: string): 'idle' | 'busy' { + const owner = this.tuiOwners.get(sessionId) + return owner ? (this.deps.transport?.tuiStatus(owner) ?? 'busy') : 'busy' + } + + private flowContext(): StructuredAgentSessionHandoffFlowContext { + return { + deps: this.deps, + owner: (sessionId) => this.tuiOwners.get(sessionId), + retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner), + releaseOwner: (sessionId) => { + this.tuiOwners.delete(sessionId) + }, + setStatus: (sessionId, status) => this.setStatus(sessionId, status), + enterPreparing: (record, operationId, direction) => + this.enterPreparing(record, operationId, direction), + publishStage: (record, direction) => this.publishStage(record, direction), + requireRecord: (sessionId) => this.requireRecord(sessionId) + } + } + + private async stopNativeTurn(sessionId: string, turnId: string): Promise { + const record = this.requireRecord(sessionId) + if (record.lease.runtimeKind !== 'native') { + return false + } + const session = this.deps.session(sessionId) + const result = await this.deps.acquireNativeStop?.(sessionId, turnId, session.fence) + return result ?? false + } + + private async enterPreparing( + record: AgentSessionRecord, + operationId: string, + direction: 'to-tui' | 'to-native' + ): Promise { + const prepared = await setStoredAgentSessionHandoffStage(this.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: 'preparing', + handoffOperationId: operationId, + now: this.deps.now() + }) + this.publishStage(prepared, direction) + } + + private publishStage(record: AgentSessionRecord, direction: 'to-tui' | 'to-native'): void { + this.setStatus( + record.sessionId, + switchingStructuredHandoffStatus(record, direction, this.deps.transport?.hostLabel) + ) + } + + private fail(params: AgentSessionHandoffRequest, error: unknown): void { + const record = this.requireRecord(params.envelope.sessionId) + this.setStatus( + record.sessionId, + failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) + ) + } + + private setStatus(sessionId: string, status: AgentSessionHandoffStatus): void { + this.statuses.set(sessionId, status) + this.deps.publish(sessionId, status) + } + + private requireRecord(sessionId: string): AgentSessionRecord { + const record = this.deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return record + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts new file mode 100644 index 00000000000..9131b02a306 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -0,0 +1,120 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { journalIdentityFor } from './structured-agent-session-attach' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +type HostHandoffAccess = { + session: (sessionId: string) => StructuredAgentSessionHostSession + eventSink: (sessionId: string) => DeferredStructuredAgentSessionEventSink + flush: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + subscribers: AgentSessionSubscribers + now: () => number +} + +export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator + +export function createStructuredAgentSessionHostHandoff( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess +): StructuredAgentSessionHostHandoff { + return new StructuredAgentSessionHandoffCoordinator({ + store: deps.store, + claimKeyId: deps.claimKeyId, + ...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}), + session: host.session, + suspendNative: async (sessionId) => { + await deps.adapter.closeSession?.(sessionId) + await host.flush(sessionId) + host.eventSink(sessionId).unbind() + }, + acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input), + acquireNativeStop: async (sessionId, turnId, fence) => + (await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled, + importTuiHistory: (input) => importTuiHistory(deps, host, input), + publish: (sessionId, status) => { + const session = host.session(sessionId) + const fence = deps.store.getRecord(sessionId)?.lease.runtimeFence ?? session.fence + host.subscribers.handoff(sessionId, session.journal, fence, status) + }, + schedule: host.serialize, + now: host.now + }) +} + +async function importTuiHistory( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; transcriptPath?: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + const head = record?.providerHandleChain.at(-1) + if (!record || head?.handle.provider !== 'codex') { + throw new Error('agent_session_identity_required') + } + const imported = await importLegacyTranscriptIntoJournal({ + journal: session.journal, + agent: 'codex', + sessionId: head.handle.threadId, + fence: input.fence, + ...(input.transcriptPath ? { options: { filePath: input.transcriptPath } } : {}) + }) + if (!imported.ok) { + throw new Error(imported.error) + } + host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) +} + +async function acquireNativeHandoffOwner( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; spawnToken: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const eventSink = host.eventSink(input.sessionId) + eventSink.unbind() + await eventSink.drained() + const acquired = await deps.adapter.acquire({ + identity: journalIdentityFor(record, session.params), + fence: input.fence, + spawnToken: input.spawnToken, + events: eventSink.sink + }) + let proved: AgentSessionRecord + try { + await deps.store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: acquired.process, + now: host.now() + }) + proved = await deps.store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: acquired.link, + now: host.now() + }) + } catch (error) { + await deps.adapter.releaseAcquisition?.({ sessionId: input.sessionId }) + throw error + } + session.fence = proved.lease.runtimeFence + eventSink.bind({ + journal: session.journal, + fence: proved.lease.runtimeFence, + publish: () => host.subscribers.publish(input.sessionId, session.journal) + }) + host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence) + return proved +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts new file mode 100644 index 00000000000..70569ffa37d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -0,0 +1,55 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + createDeferredStructuredAgentSessionEventSink, + type DeferredStructuredAgentSessionEventSink +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' + +export class StructuredAgentSessionHostRuntimeState { + private readonly eventSinks = new Map() + + constructor(private readonly deps: StructuredAgentSessionHostDeps) {} + + eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { + const existing = this.eventSinks.get(sessionId) + if (existing) { + return existing + } + const created = createDeferredStructuredAgentSessionEventSink({ + onError: (error) => this.deps.onEventSinkError?.({ sessionId, error }) + }) + this.eventSinks.set(sessionId, created) + return created + } + + discardEventSink(sessionId: string): void { + this.eventSinks.delete(sessionId) + } + + flushEventSink(sessionId: string): Promise { + return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve() + } + + async flushAllEventSinks(): Promise { + await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained())) + } + + probeOwner(sessionId: string): Promise { + const record = this.deps.store.getRecord(sessionId) + if (!record || record.lease.ownerProcess === null) { + return Promise.resolve({ outcome: 'reservation-unused' }) + } + return this.probeRecord(record) + } + + probeRecord(record: AgentSessionRecord): Promise { + return ( + this.deps.probeOwner?.(record) ?? + Promise.resolve({ + outcome: 'indeterminate', + reason: 'This host cannot probe structured session owners.' + }) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index f40270637b2..80bd58109af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -8,10 +8,7 @@ // single-file too or two clients could both read one prompt as pending. import { randomUUID } from 'node:crypto' -import type { - AgentJournalCursor, - AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation, @@ -22,6 +19,9 @@ import type { AgentSessionCancelResult, AgentSessionHistoryRequest, AgentSessionHistoryResult, + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionOptionResult, @@ -36,10 +36,6 @@ 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 { - createDeferredStructuredAgentSessionEventSink, - type DeferredStructuredAgentSessionEventSink -} from './structured-agent-session-event-sink' import { admitAndRunAgentSessionMutation, AGENT_SESSION_NOT_ATTACHED, @@ -55,10 +51,16 @@ import { } from './structured-agent-session-mutation-plans' import { AgentSessionSubscribers, - type AgentSessionSubscriberEmit + type AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' +import { + createStructuredAgentSessionHostHandoff, + type StructuredAgentSessionHostHandoff +} from './structured-agent-session-host-handoff' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' export type StructuredAgentSessionCaller = { /** Stable per-client identity; scopes the operation ledger and records who @@ -79,27 +81,38 @@ export type StructuredAgentSessionHostDeps = { * drops it: the alternative is throwing inside the provider's notification * callback, which would take the connection down over one lost row. */ onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport } -type SessionState = { +export type StructuredAgentSessionHostSession = { journal: AgentSessionJournal params: AgentSessionAttachParams fence: number } export class StructuredAgentSessionHost { - private readonly sessions = new Map() + private readonly sessions = new Map() private readonly subscribers = new AgentSessionSubscribers() private readonly tasks = new StructuredAgentSessionTaskQueue() - private readonly eventSinks = new Map() + private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: (sessionId: string) => Promise + private readonly handoffs: StructuredAgentSessionHostHandoff constructor(readonly deps: StructuredAgentSessionHostDeps) { + this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps) this.reconcileLeases = createRestartReconciler({ store: deps.store, - probe: (record) => this.probeRecord(record), + probe: (record) => this.runtimeState.probeRecord(record), now: () => this.now() }) + this.handoffs = createStructuredAgentSessionHostHandoff(deps, { + session: (sessionId) => this.requireSession(sessionId), + eventSink: (sessionId) => this.runtimeState.eventSinkFor(sessionId), + flush: (sessionId) => this.flushStreamedEvents(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + subscribers: this.subscribers, + now: this.now + }) } private now = (): number => this.deps.now?.() ?? Date.now() @@ -160,7 +173,7 @@ export class StructuredAgentSessionHost { if (unreconciled) { return refuseAgentSessionMutation(unreconciled) } - const eventSink = this.eventSinkFor(sessionId) + const eventSink = this.runtimeState.eventSinkFor(sessionId) const attached = await performAttach({ store: this.deps.store, adapter: this.deps.adapter, @@ -175,7 +188,7 @@ export class StructuredAgentSessionHost { spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), claimKeyId: this.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, - probe: await this.probeOwner(sessionId) + probe: await this.runtimeState.probeOwner(sessionId) }, callerKey: caller.callerKey, params, @@ -205,7 +218,7 @@ export class StructuredAgentSessionHost { // writes nothing will ever drain. if (!attached.ok && !this.sessions.has(sessionId)) { eventSink.close() - this.eventSinks.delete(sessionId) + this.runtimeState.discardEventSink(sessionId) } return attached }) @@ -215,52 +228,14 @@ export class StructuredAgentSessionHost { /** Settles every row the provider streamed. Callers that must read the * journal immediately after provider activity await this first. */ flushStreamedEvents(sessionId: string): Promise { - return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve() + return this.runtimeState.flushEventSink(sessionId) } /** Settles final provider rows after every child has stopped producing them. */ async flushAllStreamedEvents(): Promise { // An acquired provider can still be opening its journal; bind before draining. await this.tasks.drainAttaches() - await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained())) - } - - /** One sink per session, rebound on every attach. A single identity lets an - * adapter hold it across a re-acquire without noticing the new journal. */ - private eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { - const existing = this.eventSinks.get(sessionId) - if (existing) { - return existing - } - const created = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => this.deps.onEventSinkError?.({ sessionId, error }) - }) - this.eventSinks.set(sessionId, created) - return created - } - - /** - * Liveness of whatever currently owns the session. Never client-asserted: a - * client that could claim the owner is gone could evict a live one. Without a - * host prober the answer is `indeterminate`, which routes to manual recovery - * rather than a silent takeover. - */ - private probeOwner(sessionId: string): Promise { - const record = this.deps.store.getRecord(sessionId) - if (!record || record.lease.ownerProcess === null) { - return Promise.resolve({ outcome: 'reservation-unused' }) - } - return this.probeRecord(record) - } - - private probeRecord(record: AgentSessionRecord): Promise { - return ( - this.deps.probeOwner?.(record) ?? - Promise.resolve({ - outcome: 'indeterminate', - reason: 'This host cannot probe structured session owners.' - }) - ) + await this.runtimeState.flushAllEventSinks() } send( @@ -331,6 +306,18 @@ export class StructuredAgentSessionHost { ) } + requestHandoff( + params: AgentSessionHandoffRequest + ): AgentSessionMutationResult { + this.requireSession(params.envelope.sessionId) + return this.handoffs.request(params) + } + + handoffStatus(sessionId: string): AgentSessionHandoffStatus { + this.requireSession(sessionId) + return this.handoffs.status(sessionId) + } + /** * Paged read. Throws `agent_session_ownership_unknown` for a session this host * has not attached — a reset would tell the client to reload something that @@ -345,20 +332,20 @@ export class StructuredAgentSessionHost { return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence } } - subscribe(input: { - id: string - sessionId: string - emit: AgentSessionSubscriberEmit - cursor?: AgentJournalCursor - }): () => void { + subscribe(input: AgentSessionSubscribeInput): () => void { const session = this.requireSession(input.sessionId) const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0 - return this.subscribers.open({ ...input, journal: session.journal, fence }) + return this.subscribers.open({ + ...input, + journal: session.journal, + fence, + handoff: this.handoffs.status(input.sessionId) + }) } unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) - private requireSession(sessionId: string): SessionState { + private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) if (!session) { throw new Error(AGENT_SESSION_NOT_ATTACHED.code) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 9543fea4441..c2d2657e7e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -26,6 +26,7 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { if ( !unreconciled && current?.lease.claimStatus === 'released' && + current.lease.handoffStage === null && (await input.resume( attachParamsForRecord(current, { clientOperationId: input.operationId(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index a3b4e3484ee..9785ac91a34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -8,11 +8,20 @@ import type { AgentJournalCursor, AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionHandoffStatus, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { projectJournalBatch } from './agent-session-journal-batch' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void +export type AgentSessionSubscribeInput = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor +} type Subscriber = { id: string @@ -34,6 +43,7 @@ export class AgentSessionSubscribers { fence: number emit: AgentSessionSubscriberEmit cursor?: AgentJournalCursor + handoff?: AgentSessionHandoffStatus }): () => void { const snapshot = input.journal.snapshot() const subscriber: Subscriber = { @@ -48,13 +58,14 @@ export class AgentSessionSubscribers { this.bySession.set(input.sessionId, session) if (input.cursor) { - this.deliver(subscriber, input.journal) + this.deliver(subscriber, input.journal, input.handoff) } else { this.emit(subscriber, { type: 'snapshot', sessionId: input.sessionId, snapshot, - fence: input.fence + fence: input.fence, + ...(input.handoff ? { handoff: input.handoff } : {}) }) subscriber.cursor = snapshot.cursor } @@ -107,11 +118,29 @@ export class AgentSessionSubscribers { } } + handoff( + sessionId: string, + journal: AgentSessionJournal, + fence: number, + handoff: AgentSessionHandoffStatus + ): void { + const snapshot = journal.snapshot() + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'snapshot', sessionId, snapshot, fence, handoff }) + subscriber.cursor = snapshot.cursor + subscriber.fence = fence + } + } + private subscribers(sessionId: string): Subscriber[] { return [...(this.bySession.get(sessionId)?.values() ?? [])] } - private deliver(subscriber: Subscriber, journal: AgentSessionJournal): void { + private deliver( + subscriber: Subscriber, + journal: AgentSessionJournal, + handoff?: AgentSessionHandoffStatus + ): void { const since = journal.readSince(subscriber.cursor) const snapshot = journal.snapshot() if (!since.ok) { @@ -120,7 +149,8 @@ export class AgentSessionSubscribers { sessionId: subscriber.sessionId, reset: since.reset, snapshot, - fence: subscriber.fence + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) }) subscriber.cursor = snapshot.cursor return @@ -139,7 +169,8 @@ export class AgentSessionSubscribers { sessionId: subscriber.sessionId, reset: projected.reset, snapshot, - fence: subscriber.fence + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) }) subscriber.cursor = snapshot.cursor return @@ -147,7 +178,9 @@ export class AgentSessionSubscribers { this.emit(subscriber, { type: 'batch', sessionId: subscriber.sessionId, - batch: projected.batch + batch: projected.batch, + ...(handoff ? { fence: subscriber.fence } : {}), + ...(handoff ? { handoff } : {}) }) subscriber.cursor = projected.batch.cursor } diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.test.ts b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts new file mode 100644 index 00000000000..d19869ff342 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { applyAgentSessionRestartAdjudication } from './agent-session-lease-transitions' + +describe('agent session handoff restart transitions', () => { + it('preserves the stopped owner and operation for durable retry', () => { + const handoffOperationId = '1800000000000-00000000000000000000000000000001' + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + unreconciled: true + }) + ) + + const next = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'reservation-unused' }, + now: 1_800_000_001_000 + }) + + expect(next.lease).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + unreconciled: false + }) + }) +}) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts new file mode 100644 index 00000000000..4d1cae3bc8b --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -0,0 +1,95 @@ +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { assertFence, reserveAgentSessionOwner, withLease } from './agent-session-lease-transitions' + +export function stopAgentSessionOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: record.lease.runtimeFence + 1, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'observed process exit', + observedAt: args.now + } + }) +} + +export function reserveAgentSessionHandoffOwner(args: { + record: AgentSessionRecord + expectedFence: number + runtimeKind: AgentSessionOwnerRuntimeKind + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs: number +}): AgentSessionRecord { + return reserveAgentSessionOwner({ + record: args.record, + expectedFence: args.expectedFence, + probe: { outcome: 'reservation-unused' }, + reservation: { + runtimeKind: args.runtimeKind, + spawnToken: args.spawnToken, + claimKeyId: args.claimKeyId, + handoffOperationId: args.operationId, + leaseTtlMs: args.leaseTtlMs, + now: args.now + } + }).record +} + +export function abandonAgentSessionHandoffAttempt(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + recoverableRuntimeKind: AgentSessionOwnerRuntimeKind + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.handoffOperationId !== args.operationId + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeKind: args.recoverableRuntimeKind, + runtimeFence: record.lease.runtimeFence + 1, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'handoff launch attempt stopped', + observedAt: args.now + } + }) +} diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts new file mode 100644 index 00000000000..6d53c2ad5fd --- /dev/null +++ b/src/main/runtime/agent-session-handoff-record-transitions.ts @@ -0,0 +1,72 @@ +import type { AgentSessionHandoffStage } from '../../shared/agent-session-record' +import { + abandonAgentSessionHandoffAttempt, + reserveAgentSessionHandoffOwner, + stopAgentSessionOwnerForHandoff +} from './agent-session-handoff-lease-transitions' +import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from './agent-session-record-store' + +export function setStoredAgentSessionHandoffStage( + store: AgentSessionRecordStore, + args: { + sessionId: string + fence: number + stage: AgentSessionHandoffStage | null + handoffOperationId: string | null + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + setAgentSessionHandoffStage({ ...args, record }) + ) +} + +export function stopStoredAgentSessionOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopAgentSessionOwnerForHandoff({ ...args, record }) + ) +} + +export function reserveStoredAgentSessionHandoffOwner( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + runtimeKind: 'native' | 'tui' + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs?: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + reserveAgentSessionHandoffOwner({ + ...args, + record, + leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS + }) + ) +} + +export function abandonStoredAgentSessionHandoffAttempt( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + operationId: string + recoverableRuntimeKind: 'native' | 'tui' + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + abandonAgentSessionHandoffAttempt({ ...args, record }) + ) +} diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 01ec81dbc25..ff3eb1f69e6 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -34,11 +34,14 @@ export type AgentSessionReservation = { now: number } -function withLease(record: AgentSessionRecord, lease: AgentSessionLease): AgentSessionRecord { +export function withLease( + record: AgentSessionRecord, + lease: AgentSessionLease +): AgentSessionRecord { return { ...record, lease, updatedAt: lease.lastRenewedAt } } -function assertFence(lease: AgentSessionLease, fence: number): void { +export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { throw new Error('agent_session_checkpoint_stale') } @@ -242,6 +245,17 @@ export function applyAgentSessionRestartAdjudication(args: { now: number }): AgentSessionRecord { const { record } = args + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.ownerProcess === null + ) { + return withLease(record, { + ...record.lease, + unreconciled: false, + lastRenewedAt: args.now + }) + } const adjudication = adjudicateAgentSessionRestart({ lease: record.lease, probe: args.probe, 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 35294d01fbf..5c289eaba2e 100644 --- a/src/main/runtime/agent-session-pty-write-gate.test.ts +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -91,6 +91,49 @@ describe('admission through the store', () => { }) }) + it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)( + 'refuses TUI writes while handoff stage %s is active', + (handoffStage) => { + publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage })) + const admission = gate.admit(PTY_ID) + expect(admission.admitted).toBe(false) + if (!admission.admitted) { + expect(admission.refusal).toMatchObject({ + code: 'agent_session_conflict', + handoffStage, + ownerRuntimeKind: 'tui' + }) + } + } + ) + + it('admits only the reserved TUI proof token before ownership', () => { + publish( + agentSessionLeaseFixture({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: null, + reservedSpawnToken: 'proof-token' + }) + ) + expect(gate.admit(PTY_ID).admitted).toBe(false) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + expect( + gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' }) + ).toBe(false) + }) + + it('refuses proof input after ownership is live', () => { + publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' })) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-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 95faf8cb2b3..da513cde706 100644 --- a/src/main/runtime/agent-session-pty-write-gate.ts +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -69,6 +69,22 @@ export class AgentSessionPtyWriteGate { return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId)) } + /** Narrow pre-ownership input for the reserved TUI's provider identity probe. */ + admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean { + const binding = this.binding(ptyId) + const lease = binding?.record?.lease + return Boolean( + binding?.sessionId === authority.sessionId && + binding.record?.sessionId === authority.sessionId && + lease?.runtimeKind === 'tui' && + lease.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + lease.ownerProcess === null && + lease.reservedSpawnToken === authority.spawnToken && + !lease.unreconciled + ) + } + /** Re-check a write already in flight against the fence it was admitted under. */ readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission { if (admitted.sessionId === null && !this.enforcing) { diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index e7bf796704c..74d6c6219a3 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -9,6 +9,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS, AgentSessionRecordStore @@ -754,7 +755,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { it('rejects a handoff stage change under a different operation id', async () => { const store = await open() await establishOwner(store) - await store.setHandoffStage({ + await setStoredAgentSessionHandoffStage(store, { sessionId: 'session-alpha', fence: 1, stage: 'preparing', @@ -762,7 +763,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { now: NOW }) await expect( - store.setHandoffStage({ + setStoredAgentSessionHandoffStage(store, { sessionId: 'session-alpha', fence: 1, stage: 'old-owner-stopped', diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index f4d829698ac..de8cb9c771f 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -26,7 +26,6 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session- import { agentSessionScopeKey, type AgentSessionExecutionLocation, - type AgentSessionHandoffStage, type AgentSessionJournalCheckpoint, type AgentSessionRecord } from '../../shared/agent-session-record' @@ -37,7 +36,6 @@ import { evictAgentSessionOwner, proveAgentSessionOwner, renewAgentSessionLease, - setAgentSessionHandoffStage, setAgentSessionJournalCheckpoint, type AgentSessionProcessIdentityCommit } from './agent-session-lease-transitions' @@ -230,14 +228,11 @@ export class AgentSessionRecordStore { return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) } - async setHandoffStage(args: { - sessionId: string - fence: number - stage: AgentSessionHandoffStage | null - handoffOperationId: string | null - now: number - }): Promise { - return this.mutate(args.sessionId, (record) => setAgentSessionHandoffStage({ ...args, record })) + async transitionHandoff( + sessionId: string, + transition: (record: AgentSessionRecord) => AgentSessionRecord + ): Promise { + return this.mutate(sessionId, transition) } async setJournalCheckpoint(args: { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e7e4bd9c582..5227e5f7885 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -74,6 +74,11 @@ 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 } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' +import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof' +import { readProcessStartTimeMs } from './agent-session-process-identity-probe' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { @@ -114,7 +119,7 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { createHash, randomUUID } from 'node:crypto' -import { homedir } from 'node:os' +import { homedir, hostname } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' @@ -1385,6 +1390,7 @@ type TerminalCreateOptions = { // Why: only the host-derived structured resume path may attach provider // identity; opaque terminal.create commands remain ordinary shells. agentSessionClaim?: AgentSessionExecutionClaim + structuredAgentSessionId?: string agentSessionCreateOperationId?: string signal?: AbortSignal // Why: idempotent create operations must retain their fence after the PTY @@ -1700,12 +1706,18 @@ type RuntimePtyController = { } }): Promise<{ id: string + pid?: number incarnationId?: PtyIncarnationId wslDistro?: string stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean + writeAgentSessionProof?( + ptyId: string, + data: string, + authority: { sessionId: string; spawnToken: string } + ): boolean /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -9079,7 +9091,167 @@ export class OrcaRuntimeService { // Resolves folder workspaces as well as git worktrees, so a chat session // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => - (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path + (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + handoffTransport: this.createStructuredAgentSessionHandoffTransport() + }) + } + + private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: hostname(), + launchTui: async ({ record, fence, spawnToken }) => { + const head = record.providerHandleChain.at(-1) + if (head?.handle.provider !== 'codex') { + throw new Error('agent_session_identity_required') + } + const launched = await this.ensureAgentSession( + { + kind: 'explicit', + worktree: `id:${record.location.workspaceId}`, + agent: 'codex', + providerSession: { key: 'session_id', id: head.handle.threadId }, + presentation: 'background' + }, + {}, + { spawnToken, codexHome: record.accountHome.path, sessionId: record.sessionId } + ) + const terminal = launched.terminal + try { + if (!terminal.processId || !terminal.paneKey || !terminal.tabId) { + 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 + }) + await this.focusTerminal(terminal.handle) + return { + terminal: { + handle: terminal.handle, + tabId: terminal.tabId, + paneKey: terminal.paneKey + }, + process: { + hostId: record.location.executionHostId, + pid: terminal.processId, + processStartTimeMs: await readProcessStartTimeMs(terminal.processId), + spawnToken + }, + link: codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence, + observedAt: Date.now() + }), + ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}) + } + } catch (error) { + await this.closeTerminal(terminal.handle) + await this.waitForTerminal(terminal.handle, { + condition: 'exit', + timeoutMs: 15_000 + }) + throw error + } + }, + waitForTuiExit: async (owner) => { + await waitForStructuredTuiExitProof({ + identity: owner.process, + waitForExit: () => this.waitForTerminal(owner.terminal.handle, { condition: 'exit' }) + }) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + }, + tuiStatus: (owner) => this.structuredTuiStatus(owner.terminal.handle), + stopFailedTuiLaunch: async (owner) => { + await this.closeTerminal(owner.terminal.handle) + await this.waitForTerminal(owner.terminal.handle, { + condition: 'exit', + timeoutMs: 15_000 + }) + } + } + } + + private structuredTuiStatus(handle: string): 'idle' | 'busy' { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const text = buildTerminalWaitText( + pty.pty.tailBuffer, + pty.pty.tailPartialLine, + pty.pty.preview + ) + return !detectTerminalWaitBlockedReason(text) && + (pty.pty.lastAgentStatus === 'idle' || + this.getAdoptedPtyExplicitIdleStatus(pty.pty) === 'idle' || + isKnownReadyPromptPreview(text)) + ? 'idle' + : 'busy' + } + try { + const { leaf } = this.getLiveLeafForHandle(handle) + const text = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + const title = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + return !detectTerminalWaitBlockedReason(text) && + (leaf.lastAgentStatus === 'idle' || + (title && detectExplicitIdleStatusFromTitle(title) === 'idle') || + isKnownReadyPromptPreview(text)) + ? 'idle' + : 'busy' + } catch { + return 'busy' + } + } + + private async waitForStructuredTuiProof(input: { + handle: string + paneKey: string + threadId: string + spawnToken: string + codexHome: string + sessionId: string + }): Promise<{ transcriptPath?: string }> { + const readBoundPty = (): RuntimePtyWorktreeRecord => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.launchAgent !== 'codex' || + pty.launchToken !== input.spawnToken + ) { + throw new Error('The resumed terminal lost its launch identity.') + } + return pty + } + const initialPty = readBoundPty() + const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 + return proveCodexTuiRollout({ + codexHome: input.codexHome, + threadId: input.threadId, + kittyKeyboardFlags, + readOutput: () => { + const pty = readBoundPty() + return { + text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), + lastOutputAt: pty.lastOutputAt + } + }, + write: (data) => { + const pty = readBoundPty() + return ( + this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { + sessionId: input.sessionId, + spawnToken: input.spawnToken + }) ?? false + ) + } }) } @@ -13806,6 +13978,7 @@ export class OrcaRuntimeService { this.intentionalHandlelessPtyStops.has(ptyId) && (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) + agentSessionPtyWriteGate.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -25316,7 +25489,8 @@ export class OrcaRuntimeService { async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, - _caller: RuntimeAgentSessionRpcCaller = {} + _caller: RuntimeAgentSessionRpcCaller = {}, + handoffAuthority?: { spawnToken: string; codexHome: string; sessionId: string } ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -25326,7 +25500,11 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const namespace = + resolvedNamespace && handoffAuthority + ? { ...resolvedNamespace, providerRoot: handoffAuthority.codexHome } + : resolvedNamespace if ( !namespace || !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) @@ -25360,7 +25538,10 @@ export class OrcaRuntimeService { request.agentArgs !== undefined ? request.agentArgs : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + agentEnv: { + ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + ...(handoffAuthority ? { CODEX_HOME: handoffAuthority.codexHome } : {}) + }, ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), platform, @@ -25392,6 +25573,12 @@ export class OrcaRuntimeService { leafId: request.placement?.leafId, persistHostSessionBinding: true, agentSessionClaim: claim, + ...(handoffAuthority + ? { + launchToken: handoffAuthority.spawnToken, + structuredAgentSessionId: handoffAuthority.sessionId + } + : {}), signal: _caller.signal }) return { @@ -25895,6 +26082,9 @@ export class OrcaRuntimeService { leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (launchOpts.structuredAgentSessionId) { + agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId) + } const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { if (persistHostSessionBinding) { @@ -25972,6 +26162,7 @@ export class OrcaRuntimeService { title: pty?.title ?? launchOpts.title ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, + ...(result.pid ? { processId: result.pid } : {}), ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 4288c5253c6..541bac37dcb 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -191,3 +191,14 @@ export const UnsubscribeParams = z subscriptionId: Identifier('Invalid subscription id').optional() }) .strict() + +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry']).optional() + }) + .strict() + +export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 40b5cbb1280..b4e4a2fa9de 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -187,7 +187,7 @@ describe('capability gating', () => { for (const method of STRUCTURED_AGENT_SESSION_METHODS) { expect(names).toContain(method.name) } - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(12) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(14) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index d0100b997df..7bb796a20be 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -23,6 +23,8 @@ import { CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, + HandoffStatusParams, OptionsParams, RespondParams, SendParams, @@ -209,6 +211,16 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), + defineMethod({ + name: 'agentSession.requestHandoff', + params: HandoffParams, + handler: async (params, ctx) => requireHost(ctx).requestHandoff(params) + }), + defineMethod({ + name: 'agentSession.handoffStatus', + params: HandoffStatusParams, + handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + }), defineMethod({ name: 'agentSession.options', params: OptionsParams, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 2b07a2e7fd6..d769929af0e 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -190,12 +190,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.create', 'agentSession.createSupport', 'agentSession.cancel', + 'agentSession.handoffStatus', 'agentSession.history', 'agentSession.options', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', 'agentSession.send', 'agentSession.setOption', + 'agentSession.requestHandoff', 'agentSession.subscribe', 'agentSession.unsubscribe', 'browser.back', diff --git a/src/main/runtime/structured-agent-session-pty-binding.test.ts b/src/main/runtime/structured-agent-session-pty-binding.test.ts new file mode 100644 index 00000000000..e6656c025a8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pty-binding.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const SESSION_ID = 'structured-session-1' + +afterEach(() => agentSessionPtyWriteGate.detachRecordLookup()) + +describe('structured handoff PTY binding', () => { + it('binds before runtime writes and unbinds on process exit', async () => { + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + } + vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const write = vi.fn(() => true) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })), + write, + kill: () => true, + getForegroundProcess: async () => null + }) + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION_ID, + runtimeKind: 'native', + handoffStage: 'new-owner-proving' + }) + ) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => + sessionId === SESSION_ID ? record : null + ) + + await runtime.createTerminal('id:worktree-1', { + command: 'codex resume thread-1', + structuredAgentSessionId: SESSION_ID + }) + + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID) + await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false) + expect(write).not.toHaveBeenCalled() + + runtime.onPtyExit('pty-structured', 0) + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 02cb7dffc00..09088144fbd 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -16,9 +16,11 @@ import { type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' /** Sibling of the journal tree rather than inside it: one file adjudicates every * session's lease, while a journal is per session. */ @@ -38,6 +40,7 @@ export type StructuredAgentSessionRuntimeDeps = { * against a scripted app-server; production spawns the real one. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] onError?: (input: { scope: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport } type InstalledRuntime = { @@ -64,6 +67,7 @@ export async function stopStructuredAgentSessionRuntime(): Promise { const pending = installing installing = null setStructuredAgentSessionHost(null) + agentSessionPtyWriteGate.detachRecordLookup() if (!pending) { return } @@ -83,25 +87,32 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise store.getRecord(sessionId)) + try { + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}) + }) + const host = new StructuredAgentSessionHost({ store, - resolveWorkspacePath: deps.resolveWorkspacePath, - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}) - }) - const host = new StructuredAgentSessionHost({ - store, - adapter, - journalRoot: deps.stateDirectory, - claimKeyId: deps.claimKeyId, - probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), - onEventSinkError: ({ sessionId, error }) => - deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }) - }) - setStructuredAgentSessionHost(host) - return { host, adapter } + adapter, + journalRoot: deps.stateDirectory, + claimKeyId: deps.claimKeyId, + probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), + onEventSinkError: ({ sessionId, error }) => + deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + ...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {}) + }) + setStructuredAgentSessionHost(host) + return { host, adapter } + } catch (error) { + agentSessionPtyWriteGate.detachRecordLookup() + throw error + } } /** diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts new file mode 100644 index 00000000000..8dd74d0ef7c --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' + +const identity: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 123, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-token' +} + +describe('structured TUI exit proof', () => { + it('accepts the exact terminal exit event without probing', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe }) + ).resolves.toBeUndefined() + expect(probe).not.toHaveBeenCalled() + }) + + it.each([ + { outcome: 'pid-absent' as const }, + { outcome: 'identity-mismatch' as const, field: 'process-start-time' as const } + ])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof + }) + ).resolves.toBeUndefined() + }) + + it.each([ + { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, + { outcome: 'indeterminate' as const, reason: 'probe unavailable' } + ])('fails closed while the recorded process may still own: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof + }) + ).rejects.toThrow('terminal_handle_stale') + }) + + it('preserves unrelated terminal wait failures', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('timeout') + }, + probe + }) + ).rejects.toThrow('timeout') + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts new file mode 100644 index 00000000000..dcb5e276770 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.ts @@ -0,0 +1,26 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' + +type ExitProofInput = { + identity: AgentSessionProcessIdentity + waitForExit: () => Promise + probe?: (identity: AgentSessionProcessIdentity) => Promise +} + +export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { + try { + await input.waitForExit() + } catch (error) { + if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { + throw error + } + const proof = await ( + input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + )(input.identity) + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return + } + throw error + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 540db7afef8..2a5136b345d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -17,6 +17,7 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useStructuredAgentSession } from './use-structured-agent-session' import { translate } from '@/i18n/i18n' +import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome' function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` @@ -99,6 +100,13 @@ export function NativeChatStructuredSession(props: { tabIndex={-1} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > + { + void controller.requestHandoff(direction, mode, action) + }} + />
{viewState.kind === 'loading' ? ( @@ -194,7 +202,9 @@ export function NativeChatStructuredSession(props: { {controller.error ?? composerError}

) : null} - {prompt ? null : ( + {prompt || + controller.handoff?.owner === 'tui' || + controller.handoff?.phase === 'switching' ? null : ( void +} + +function handoffStageCopy(status: AgentSessionHandoffStatus): string { + if (status.stage === 'preparing') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…') + : translate( + 'components.native-chat.handoff.stage.finishingTerminal', + 'Finishing agent terminal…' + ) + } + if (status.stage === 'old-owner-stopped') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…') + : translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…') + } + if (status.stage === 'new-owner-proving') { + return status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.stage.verifyingTerminal', + 'Verifying agent terminal…' + ) + : translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…') + } + if (status.stage === 'recovering') { + return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…') + } + if (status.stage === 'manual-recovery') { + return translate( + 'components.native-chat.handoff.stage.manualRecovery', + 'Agent session needs recovery' + ) + } + return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…') +} + +export function StructuredAgentSessionHandoffChrome({ + status, + isWorking, + onRequest +}: Props): React.JSX.Element | null { + if (!status) { + return null + } + const owner = status?.owner ?? 'native' + const phase = status?.phase ?? 'idle' + const switching = phase === 'switching' || phase === 'waiting-for-exit' + return ( + <> +
+ + {switching + ? translate('components.native-chat.handoff.mode.switching', 'Switching') + : owner === 'tui' + ? translate('components.native-chat.handoff.mode.terminal', 'Terminal') + : translate('components.native-chat.handoff.mode.chat', 'Chat')} + +
+ {phase === 'queued' && status?.direction ? ( + <> + + {status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.switchingAfterTurn', + 'Switching after this turn' + ) + : translate( + 'components.native-chat.handoff.returningAfterTurn', + 'Returning after this turn' + )} + + + + ) : owner === 'native' && !switching ? ( + isWorking ? ( + <> + + + + ) : ( + + ) + ) : owner === 'tui' && !switching ? ( + + ) : null} +
+
+ {owner === 'tui' && phase === 'idle' ? ( +
+ + {status?.hostLabel + ? translate( + 'components.native-chat.handoff.agentOpenOnHost', + 'Agent is open in terminal on {{value0}}.', + { value0: status.hostLabel } + ) + : translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')} + + +
+ ) : null} + {switching ? ( +
+ {phase === 'waiting-for-exit' + ? translate( + 'components.native-chat.handoff.exitTerminal', + 'Exit the agent terminal to continue in chat.' + ) + : status?.stage + ? handoffStageCopy(status) + : translate( + 'components.native-chat.handoff.switchingOwner', + 'Switching session owner…' + )} +
+ ) : null} + {phase === 'failed' && status?.error ? ( +
+
+ {status.error.message} + {status.direction && status.error.recoverableOwner !== 'none' ? ( + + ) : null} +
+ {status.error.details ? ( +
+ {translate('components.native-chat.handoff.details', 'Details')} +

{status.error.details}

+
+ ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 070b7ef11ea..db40fbc5b74 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { AgentSessionHistoryResult } from '../../../../shared/agent-session-wire' +import type { + AgentSessionHandoffStatus, + AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' import { projectStructuredAgentSessionStatus, structuredAgentSessionPaneKey @@ -19,6 +22,10 @@ import { callStructuredAgentSession, subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + clearStructuredHandoff, + publishStructuredHandoff +} from '@/runtime/structured-agent-session-handoff-store' type StructuredTab = Tab & { contentType: 'agent-session' } @@ -87,6 +94,13 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): target, { sessionId: tab.entityId, ...(state.cursor ? { cursor: state.cursor } : {}) }, (event) => { + if ('handoff' in event && event.handoff) { + publishStructuredHandoff({ + sessionId: tab.entityId, + fence: event.fence ?? state.fence ?? 0, + status: event.handoff + }) + } if ( event.type === 'batch' && !shouldAdvanceStructuredResumeCursor(state.cursor, event.batch.cursor) @@ -127,12 +141,17 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): opening = false } } - void callStructuredAgentSession(target, 'agentSession.history', { - sessionId: tab.entityId, - direction: 'tail', - limit: 40 - }) - .then(async (result) => { + void Promise.all([ + callStructuredAgentSession(target, 'agentSession.history', { + sessionId: tab.entityId, + direction: 'tail', + limit: 40 + }), + callStructuredAgentSession(target, 'agentSession.handoffStatus', { + sessionId: tab.entityId + }).catch(() => null) + ]) + .then(async ([result, handoff]) => { if (stopped) { return } @@ -150,6 +169,14 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): } }) } + if (handoff) { + apply({ type: 'handoff', handoff }) + publishStructuredHandoff({ + sessionId: tab.entityId, + fence: result.ok ? (result.page.fence ?? 0) : (result.fence ?? 0), + status: handoff + }) + } await open() }) .catch(scheduleReconnect) @@ -159,6 +186,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): clearTimeout(reconnectTimer) } unsubscribe() + clearStructuredHandoff(tab.entityId) useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)) } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts index 59f2fcc5001..f775799b646 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useReducer, useRef, useState } from 'react' import type { + AgentSessionHandoffStatus, AgentSessionHistoryResult, AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' @@ -129,11 +130,18 @@ export function useStructuredAgentSessionRead(args: { } } async function refreshTail(): Promise { - const result = await callStructuredAgentSession( - target, - 'agentSession.history', - { sessionId, direction: 'tail', limit: 40 } - ) + const [result, handoff] = await Promise.all([ + callStructuredAgentSession(target, 'agentSession.history', { + sessionId, + direction: 'tail', + limit: 40 + }), + callStructuredAgentSession( + target, + 'agentSession.handoffStatus', + { sessionId } + ).catch(() => null) + ]) if (stopped) { return } @@ -153,6 +161,9 @@ export function useStructuredAgentSessionRead(args: { }) resumeCursorRef.current = result.snapshot.cursor } + if (handoff) { + dispatch({ type: 'handoff', handoff }) + } } const refreshOnFocus = (): void => { if (!document.hasFocus()) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 7c9bb6cf5a5..9a1fc0a8c98 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -3,6 +3,9 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionMutationResult, + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffResult, AgentSessionOptionsResult, AgentSessionPromptResult } from '../../../../shared/agent-session-wire' @@ -61,13 +64,15 @@ export function useStructuredAgentSession(args: { async ( method: string, fingerprintMethod: string, - fields: Record + fields: Record, + operationIdOverride?: string | null ): Promise => { if (stateRef.current.fence === null) { return null } const key = `${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = operationIds.current.get(key) ?? structuredSessionOperationId() + const clientOperationId = + operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId() operationIds.current.set(key, clientOperationId) const result = await callStructuredAgentSession>( target, @@ -204,6 +209,18 @@ export function useStructuredAgentSession(args: { ), optionSnapshot, optionSurface, - setStructuredOption + setStructuredOption, + requestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action: 'start' | 'cancel-queued' | 'retry' = 'start' + ) => + mutate( + 'agentSession.requestHandoff', + 'agentSession.requestHandoff', + { direction, mode, action }, + action === 'retry' ? stateRef.current.handoff?.operationId : null + ), + handoff: state.handoff } } diff --git a/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx b/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx new file mode 100644 index 00000000000..bba08bd914c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx @@ -0,0 +1,25 @@ +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +export function StructuredAgentSessionTerminalReturnButton(props: { + enabled: boolean + onReturn?: () => void +}): React.JSX.Element | null { + if (!props.enabled) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index da6070241f9..99630a6b98a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -243,6 +243,13 @@ import { } from './regular-terminal-focus-ownership' import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts' import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh' +import { useTerminalStructuredHandoff } from '@/runtime/structured-agent-session-handoff-store' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../../shared/structured-agent-session-mutation' type TerminalPaneProps = { tabId: string @@ -340,6 +347,34 @@ function TerminalPane( const isRendererVisible = isVisible && isWorktreeActive const isVisibleRef = useRef(isRendererVisible) isVisibleRef.current = isRendererVisible + const structuredHandoff = useTerminalStructuredHandoff(tabId) + const returnStructuredSession = useCallback(() => { + if (!structuredHandoff) { + return + } + const direction = 'to-native' as const + const mode = 'after-turn' as const + const action = 'start' as const + const sessionId = structuredHandoff.sessionId + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) + const clientOperationId = createStructuredAgentSessionOperationId(() => crypto.randomUUID()) + void callStructuredAgentSession(target, 'agentSession.requestHandoff', { + envelope: { + sessionId, + clientOperationId, + expectedRuntimeFence: structuredHandoff.fence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId, + fields: { direction, mode, action } + }) + }, + direction, + mode, + action + }) + }, [structuredHandoff, worktreeId]) const { nativeChatTranscriptIsLocalReadable, sshReconnectEnvironmentId, @@ -3173,6 +3208,8 @@ function TerminalPane( canToggleNativeChat={activePaneCanToggleChat} isChatViewMode={activePaneIsChatLeaf} onToggleNativeChat={handleToggleNativeChat} + canReturnStructuredSession={structuredHandoff?.status.owner === 'tui'} + onReturnStructuredSession={returnStructuredSession} canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession} onContinueAgentSessionInNewSession={(pane) => contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index a9ed8249179..d78336c17cc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -8,6 +8,7 @@ import { } from 'lucide-react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { Button } from '@/components/ui/button' +import { StructuredAgentSessionTerminalReturnButton } from './StructuredAgentSessionTerminalReturnButton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' @@ -50,6 +51,8 @@ type TerminalPaneHeaderOverlayProps = { isChatViewMode?: boolean /** Flip the active pane between the terminal and the native chat view. */ onToggleNativeChat?: () => void + canReturnStructuredSession?: boolean + onReturnStructuredSession?: () => void canContinueAgentSessionInNewSession?: boolean onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void @@ -88,6 +91,8 @@ export default function TerminalPaneHeaderOverlay({ canToggleNativeChat, isChatViewMode, onToggleNativeChat, + canReturnStructuredSession, + onReturnStructuredSession, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onSplitPane, @@ -244,6 +249,10 @@ export default function TerminalPaneHeaderOverlay({ ) : null}
+ {canContinueAgentSessionInNewSession && isActivePane ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 82acae7cc53..46555f48205 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15101,6 +15101,37 @@ "status": { "responding": "Agent is responding" }, + "handoff": { + "mode": { + "switching": "Switching", + "terminal": "Terminal", + "chat": "Chat" + }, + "switchingAfterTurn": "Switching after this turn", + "returningAfterTurn": "Returning after this turn", + "cancel": "Cancel", + "switchAfterTurn": "Switch after this turn", + "stopTurnAndSwitch": "Stop turn and switch", + "openAgentTui": "Open agent TUI", + "returnAfterTurn": "Return after this turn", + "returnToChat": "Return to chat", + "agentOpen": "Agent is open in terminal.", + "agentOpenOnHost": "Agent is open in terminal on {{value0}}.", + "exitTerminal": "Exit the agent terminal to continue in chat.", + "switchingOwner": "Switching session owner…", + "retry": "Retry", + "details": "Details", + "stage": { + "finishingChat": "Finishing chat session…", + "finishingTerminal": "Finishing agent terminal…", + "openingTerminal": "Opening agent terminal…", + "resumingChat": "Resuming chat session…", + "verifyingTerminal": "Verifying agent terminal…", + "verifyingChat": "Verifying chat session…", + "recovering": "Recovering agent session…", + "manualRecovery": "Agent session needs recovery" + } + }, "jumpToLatest": "Jump to latest", "toggle": { "showTerminal": "Show terminal", diff --git a/src/renderer/src/runtime/structured-agent-session-handoff-store.ts b/src/renderer/src/runtime/structured-agent-session-handoff-store.ts new file mode 100644 index 00000000000..8800f349802 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-handoff-store.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from 'react' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' + +export type TerminalStructuredHandoff = { + sessionId: string + fence: number + status: AgentSessionHandoffStatus +} + +const byTerminalTabId = new Map() +const listeners = new Set<() => void>() + +export function publishStructuredHandoff(input: TerminalStructuredHandoff): void { + for (const [tabId, current] of byTerminalTabId) { + if (current.sessionId === input.sessionId && tabId !== input.status.terminal?.tabId) { + byTerminalTabId.delete(tabId) + } + } + if (input.status.terminal) { + byTerminalTabId.set(input.status.terminal.tabId, input) + } + for (const listener of listeners) { + listener() + } +} + +export function clearStructuredHandoff(sessionId: string): void { + let changed = false + for (const [tabId, current] of byTerminalTabId) { + if (current.sessionId === sessionId) { + byTerminalTabId.delete(tabId) + changed = true + } + } + if (changed) { + for (const listener of listeners) { + listener() + } + } +} + +export function useTerminalStructuredHandoff(tabId: string): TerminalStructuredHandoff | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => byTerminalTabId.get(tabId) ?? null + ) +} diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index c847c473147..9c2e33b00c4 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -137,7 +137,12 @@ export function evaluateAgentSessionAcquisition(args: { // Why: the retry key is operation id + fence + stage; a different id is a different intent. return { decision: 'refused', code: 'agent_session_operation_conflict' } } - if (lease.ownerProcess === null && lease.handoffStage === 'old-owner-stopped') { + if ( + lease.ownerProcess === null && + lease.handoffStage === 'old-owner-stopped' && + lease.claimStatus === 'reserved' && + lease.reservedSpawnToken !== null + ) { // Why: an idempotent re-run of a reservation that already exists at this fence. return { decision: 'retry-reservation', fence: lease.runtimeFence } } diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 40a3ec5fbf6..aebd2618522 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -129,7 +129,7 @@ export function admitAgentSessionMutation(input: { /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { - if (agentSessionLeaseAdmitsWriter(lease)) { + if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) { return null } if (lease.unreconciled) { @@ -144,6 +144,12 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR message: `The session is mid-handoff (${lease.handoffStage}).` } } + if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { + return { + code: 'agent_session_conflict', + message: 'The agent terminal owns this session.' + } + } return { code: 'agent_session_ownership_unknown', message: 'The session has no live owner to accept writes.' diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 2f29691e8a4..29f73a103ca 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -12,6 +12,39 @@ import type { AgentJournalSnapshot, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record' + +export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' +export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' +export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' + +export type AgentSessionHandoffStatus = { + owner: AgentSessionOwnerRuntimeKind | 'none' + direction: AgentSessionHandoffDirection | null + phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' + stage: AgentSessionHandoffStage | null + operationId: string | null + hostLabel?: string + terminal?: { + handle: string + tabId: string + paneKey: string + } + error?: { + message: string + details?: string + recoverableOwner: AgentSessionOwnerRuntimeKind | 'none' + } +} + +export type AgentSessionHandoffRequest = { + envelope: AgentSessionMutationEnvelope + direction: AgentSessionHandoffDirection + mode: AgentSessionHandoffMode + action?: AgentSessionHandoffAction +} + +export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ @@ -80,14 +113,28 @@ export type AgentSessionJournalBatch = { } export type AgentSessionSubscribeEvent = - | { type: 'snapshot'; sessionId: string; snapshot: AgentJournalSnapshot; fence: number } - | { type: 'batch'; sessionId: string; batch: AgentSessionJournalBatch } + | { + type: 'snapshot' + sessionId: string + snapshot: AgentJournalSnapshot + fence: number + handoff?: AgentSessionHandoffStatus + } + | { + type: 'batch' + sessionId: string + batch: AgentSessionJournalBatch + /** Added with handoff state so mixed-version cursors retain the ownership fence. */ + fence?: number + handoff?: AgentSessionHandoffStatus + } | { type: 'reset' sessionId: string reset: AgentJournalResetReason snapshot: AgentJournalSnapshot fence: number + handoff?: AgentSessionHandoffStatus } | { type: 'end' } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a94c7e36ac2..1c7b3e3d56f 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -687,6 +687,8 @@ export type RuntimeTerminalCreate = { agentSessionDisposition?: 'created' | 'adopted' /** The host attached this request to the existing stable pane owner. */ isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number } export type RuntimeTerminalSplit = { diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 2a1abceebeb..8b5c1a5bb0f 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -4,7 +4,11 @@ import type { AgentJournalSnapshot, AgentJournalSubmission } from './agent-session-journal-types' -import type { AgentSessionHistoryPage, AgentSessionSubscribeEvent } from './agent-session-wire' +import type { + AgentSessionHandoffStatus, + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from './agent-session-wire' export type StructuredAgentSessionState = { epoch: string | null @@ -15,11 +19,13 @@ export type StructuredAgentSessionState = { hasOlder: boolean status: 'idle' | 'loading' | 'ready' | 'error' error?: string + handoff: AgentSessionHandoffStatus | null } export type StructuredAgentSessionAction = | { type: 'loading' } | { type: 'error'; message: string } + | { type: 'handoff'; handoff: AgentSessionHandoffStatus } | { type: 'event'; event: AgentSessionSubscribeEvent } | { type: 'tail-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage } @@ -31,12 +37,14 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { items: [], submissions: [], hasOlder: false, - status: 'idle' + status: 'idle', + handoff: null } function replaceSnapshot( snapshot: AgentJournalSnapshot, - fence: number + fence: number, + handoff?: AgentSessionHandoffStatus ): StructuredAgentSessionState { return { epoch: snapshot.cursor.epoch, @@ -45,7 +53,8 @@ function replaceSnapshot( items: [...snapshot.items].sort((left, right) => left.sequence - right.sequence), submissions: snapshot.submissions, hasOlder: snapshot.items.length >= 40, - status: 'ready' + status: 'ready', + handoff: handoff ?? null } } @@ -88,6 +97,9 @@ export function reduceStructuredAgentSession( if (action.type === 'error') { return { ...state, status: 'error', error: action.message } } + if (action.type === 'handoff') { + return { ...state, handoff: action.handoff } + } if (action.type === 'tail-page') { const pageCursor = action.page.liveCursor ?? action.page.window.newest if ( @@ -104,7 +116,8 @@ export function reduceStructuredAgentSession( items: action.page.items, submissions: action.page.submissions, hasOlder: action.page.hasOlder, - status: 'ready' + status: 'ready', + handoff: state.handoff } } if (action.type === 'older-page') { @@ -123,7 +136,7 @@ export function reduceStructuredAgentSession( return state } if (event.type === 'snapshot' || event.type === 'reset') { - return replaceSnapshot(event.snapshot, event.fence) + return replaceSnapshot(event.snapshot, event.fence, event.handoff) } if (state.epoch !== event.batch.cursor.epoch) { return state @@ -134,10 +147,12 @@ export function reduceStructuredAgentSession( return { ...state, cursor: event.batch.cursor, + fence: event.fence ?? state.fence, items: mergeItems(state.items, event.batch.items, event.batch.removedItemIds), submissions: mergeSubmissions(state.submissions, event.batch.submissions), status: 'ready', - error: undefined + error: undefined, + handoff: event.handoff ?? state.handoff } } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 9daf14790c4..7ad5238bc1b 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -46,6 +46,8 @@ const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.requestHandoff', hostMethod: 'requestHandoff' }, + { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus' }, { method: 'agentSession.options', hostMethod: 'readOptions' }, { method: 'agentSession.history', hostMethod: 'history' }, { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, @@ -151,6 +153,10 @@ function paramsFor(method: string): unknown { const fields = { key: 'model', value: 'gpt-5' } return { envelope: envelope({ method, fields, fence }), ...fields } } + case 'agentSession.requestHandoff': { + const fields = { direction: 'to-tui', mode: 'now', action: 'start' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } default: @@ -235,6 +241,8 @@ describe('cross-version structured agent sessions', () => { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), @@ -295,7 +303,7 @@ describe('cross-version structured agent sessions', () => { it('finds no structured method registered on the old build', () => { expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( - 12 + STRUCTURED_CALLS.length + 1 ) }) From 1ab28b5fc0c137fdb3fbbaace37fc93d2facc539 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:24:54 -0700 Subject: [PATCH 2/8] fix: harden structured session handoffs --- ...uctured-agent-session-handoff-admission.ts | 126 ++++++++ ...ured-agent-session-handoff-flow-context.ts | 33 +++ ...tured-agent-session-handoff-flow-runner.ts | 79 +++++ ...d-agent-session-handoff-operation-guard.ts | 55 +++- ...tructured-agent-session-handoff-restart.ts | 211 ++++++++++++++ ...tructured-agent-session-handoff-reverse.ts | 5 +- .../structured-agent-session-handoff-types.ts | 6 + .../structured-agent-session-handoff.test.ts | 229 +++++++++++++-- .../structured-agent-session-handoff.ts | 273 +++++++++--------- ...ctured-agent-session-host-runtime-state.ts | 19 +- .../structured-agent-session-host.test.ts | 2 +- .../structured-agent-session-host.ts | 10 +- ...ctured-agent-session-lease-renewer.test.ts | 130 +++++++++ .../structured-agent-session-lease-renewer.ts | 73 +++++ ...tructured-agent-session-restart-restore.ts | 4 + .../agent-session-lease-transitions.ts | 15 +- ...ent-session-process-identity-probe.test.ts | 11 +- .../agent-session-process-identity-probe.ts | 24 +- .../agent-session-reconciliation-target.ts | 9 + .../runtime/agent-session-record-store.ts | 13 +- ...ssion-restart-handoff-adjudication.test.ts | 79 +++++ ...nt-session-restart-handoff-adjudication.ts | 52 ++++ src/main/runtime/orca-runtime.ts | 127 ++++++-- .../rpc/methods/structured-agent-session.ts | 2 +- .../structured-tui-idle-evidence.test.ts | 27 ++ .../runtime/structured-tui-idle-evidence.ts | 9 + .../structured-tui-process-identity.test.ts | 61 ++++ .../structured-tui-process-identity.ts | 96 ++++++ src/shared/agent-session-operation-ledger.ts | 22 ++ 29 files changed, 1583 insertions(+), 219 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts create mode 100644 src/main/runtime/agent-session-reconciliation-target.ts create mode 100644 src/main/runtime/agent-session-restart-handoff-adjudication.test.ts create mode 100644 src/main/runtime/agent-session-restart-handoff-adjudication.ts create mode 100644 src/main/runtime/structured-tui-idle-evidence.test.ts create mode 100644 src/main/runtime/structured-tui-idle-evidence.ts create mode 100644 src/main/runtime/structured-tui-process-identity.test.ts create mode 100644 src/main/runtime/structured-tui-process-identity.ts 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 new file mode 100644 index 00000000000..d43470a67a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts @@ -0,0 +1,126 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export type StructuredHandoffAdmission = + | { decision: 'continue'; record: AgentSessionRecord; fingerprint: string } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +export async function admitStructuredHandoffRequest(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + record: AgentSessionRecord + status?: AgentSessionHandoffStatus +}): Promise { + const action = input.params.action ?? 'start' + const requestFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction, mode: input.params.mode, action } + }) + const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint) + if (conflict) { + return { decision: 'refused', refusal: conflict } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff.operation', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction } + }) + const operation = await input.operationGuard.check({ + callerKey: input.callerKey, + sessionId: input.record.sessionId, + operationId: input.params.envelope.clientOperationId, + fingerprint, + action, + ...(input.status ? { status: input.status } : {}), + now: input.deps.now() + }) + if (operation.decision === 'replay') { + return { decision: 'replay', outcome: operation.outcome } + } + if (operation.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: operation.code as 'agent_session_operation_conflict', + message: 'This handoff operation could not be admitted.' + } + } + } + if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' } + }) + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'The session owner changed before the handoff request arrived.', + currentFence: input.record.lease.runtimeFence + } + } + } + return { decision: 'continue', record: input.record, fingerprint } +} + +export function replayedStructuredHandoffRefusal( + outcome: AgentSessionOperationOutcome +): AgentSessionWireRefusal | null { + if ( + outcome.status !== 'failed' || + !AGENT_SESSION_WIRE_REFUSAL_CODES.includes( + outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + ) + ) { + return null + } + return { + code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number], + message: 'This handoff request was previously refused.' + } +} + +export async function refuseAdmittedStructuredHandoff(input: { + deps: StructuredAgentSessionHandoffDeps + callerKey: string + params: AgentSessionHandoffRequest + refusal: AgentSessionWireRefusal +}): Promise> { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: input.refusal.code } + }) + return { ok: false, refusal: input.refusal } +} + +export function structuredHandoffRetryIsAdmissible( + status: AgentSessionHandoffStatus, + params: AgentSessionHandoffRequest +): boolean { + return ( + status.phase === 'failed' && + status.direction === params.direction && + status.operationId === params.envelope.clientOperationId && + status.error?.recoverableOwner !== 'none' + ) +} 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 new file mode 100644 index 00000000000..2aa2eb440ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts @@ -0,0 +1,33 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function createStructuredHandoffFlowContext(input: { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + enterPreparing: StructuredAgentSessionHandoffFlowContext['enterPreparing'] + publishStage: StructuredAgentSessionHandoffFlowContext['publishStage'] + requireRecord: (sessionId: string) => AgentSessionRecord +}): StructuredAgentSessionHandoffFlowContext { + return input +} + +export async function stopStructuredNativeTurn( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + turnId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record || record.lease.runtimeKind !== 'native') { + return false + } + const session = deps.session(sessionId) + return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts new file mode 100644 index 00000000000..875d7c92204 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -0,0 +1,79 @@ +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffFlowRunner { + private readonly active = new Set>() + + constructor( + private readonly input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + flowContext: () => StructuredAgentSessionHandoffFlowContext + fail: (params: AgentSessionHandoffRequest, error: unknown) => void + } + ) {} + + async drain(): Promise { + await Promise.allSettled(this.active) + } + + begin(input: { + callerKey: string + params: AgentSessionHandoffRequest + turnId: string | null + fingerprint: string + }): void { + const { callerKey, params, turnId, fingerprint } = input + const sessionId = params.envelope.sessionId + this.input.operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + const flow = this.run(params, turnId) + .then(() => + this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + ) + .catch(async (error) => { + await this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + 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)) + } + + private run(params: AgentSessionHandoffRequest, turnId: string | null): Promise { + const sessionId = params.envelope.sessionId + return this.input.deps.schedule(sessionId, async () => { + if (turnId && params.mode === 'stop-turn') { + const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId) + if (!stopped) { + throw new Error('The current turn did not acknowledge cancellation.') + } + } + await (params.direction === 'to-tui' + ? handoffStructuredSessionToTui(this.input.flowContext(), params, params.action === 'retry') + : handoffStructuredSessionToNative( + this.input.flowContext(), + params, + params.action === 'retry' + )) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts index c24bca96070..00896472a00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -1,27 +1,64 @@ import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -type ActiveOperation = { operationId: string; fingerprint: string } +type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string } + +export type HandoffOperationDecision = + | { decision: 'new' } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'retry' } + | { decision: 'refused'; code: 'agent_session_operation_conflict' | string } export class StructuredAgentSessionHandoffOperationGuard { private readonly activeBySession = new Map() - check(input: { + constructor(private readonly store: AgentSessionRecordStore) {} + + async check(input: { + callerKey: string sessionId: string operationId: string fingerprint: string action: 'start' | 'cancel-queued' | 'retry' status?: AgentSessionHandoffStatus - }): 'new' | 'replay' | 'conflict' { + now: number + }): Promise { const active = this.activeBySession.get(input.sessionId) if ( - (input.status?.operationId === input.operationId && - input.status.phase !== 'idle' && - input.action === 'start') || - (active?.operationId === input.operationId && active.fingerprint === input.fingerprint) + active?.operationId === input.operationId && + (active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey) ) { - return 'replay' + return { decision: 'refused', code: 'agent_session_operation_conflict' } } - return active?.operationId === input.operationId ? 'conflict' : 'new' + const ledger = await this.store.admitOperation({ + callerKey: input.callerKey, + operationId: input.operationId, + fingerprint: input.fingerprint, + now: input.now + }) + if (ledger.decision === 'refused') { + return { decision: 'refused', code: ledger.code } + } + if (ledger.decision === 'admit') { + return { decision: 'new' } + } + if (input.action === 'retry' && ledger.row.outcome.status === 'failed') { + await this.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.operationId, + outcome: { status: 'pending' } + }) + return { decision: 'retry' } + } + if ( + ledger.row.outcome.status === 'pending' && + !active && + input.status?.operationId !== input.operationId + ) { + return { decision: 'new' } + } + return { decision: 'replay', outcome: ledger.row.outcome } } start(sessionId: string, operation: ActiveOperation): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts new file mode 100644 index 00000000000..85dde4a990b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -0,0 +1,211 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +type RestartAccess = { + deps: StructuredAgentSessionHandoffDeps + requireRecord: (sessionId: string) => AgentSessionRecord + flowContext: () => StructuredAgentSessionHandoffFlowContext + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +} + +export async function restoreStructuredAgentSessionHandoff( + input: RestartAccess, + sessionId: string +): Promise { + const initial = input.requireRecord(sessionId) + const operationId = initial.lease.handoffOperationId + const initialStage = initial.lease.handoffStage + if (initialStage === 'recovering' || initialStage === 'manual-recovery') { + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_ownership_unknown' } + }) + } + input.setStatus(sessionId, idleStructuredHandoffStatus(initial)) + return + } + let lastError: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await restoreOnce(input, input.requireRecord(sessionId)) + const settled = input.requireRecord(sessionId) + if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) { + throw new Error('Restart handoff reconciliation did not settle the transfer.') + } + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'succeeded', sessionId } + }) + } + return + } catch (error) { + lastError = error + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } + } + const current = input.requireRecord(sessionId) + const failed = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId, + fence: current.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: current.lease.handoffOperationId, + now: input.deps.now() + }) + const status = idleStructuredHandoffStatus(failed) + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } + input.setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: lastError instanceof Error ? lastError.message : String(lastError) + } + } + : {}) + }) +} + +async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise { + if (!input.deps.transport) { + if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + return + } + if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') { + await restoreLiveTui(input, record) + return + } + if (!record.lease.handoffOperationId) { + return + } + if (record.lease.handoffStage === 'preparing') { + await restorePreparing(input, record) + return + } + if (record.lease.handoffStage === 'new-owner-proving') { + await restoreProving(input, record) + return + } + if (record.lease.handoffStage === 'old-owner-stopped') { + await continueHandoff(input, record) + } +} + +async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise { + const owner = await input.deps.transport!.recoverTuiOwner(record) + input.retainOwner(record.sessionId, owner) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport?.hostLabel + }) +} + +async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise { + if (record.lease.runtimeKind === 'tui') { + const owner = await input.deps.transport!.recoverTuiOwner(record) + await input.deps.transport!.reproveTuiOwner({ record, owner }) + const settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + await restoreLiveTui(input, settled) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise { + const operationId = record.lease.handoffOperationId! + if (record.lease.runtimeKind === 'tui') { + const owner = await input.deps.transport!.recoverTuiOwner(record) + const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner }) + await input.deps.store.proveOwner({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + link: reproved.link, + now: input.deps.now() + }) + input.retainOwner(record.sessionId, reproved) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: reproved.terminal, + hostLabel: input.deps.transport?.hostLabel + }) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { + const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' + const operationId = record.lease.handoffOperationId! + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: record.sessionId, + clientOperationId: operationId, + expectedRuntimeFence: record.lease.runtimeFence, + payloadFingerprint: 'restart-reconciliation' + }, + direction, + mode: 'now', + action: 'retry' + } + await (direction === 'to-tui' + ? handoffStructuredSessionToTui(input.flowContext(), params, true) + : handoffStructuredSessionToNative(input.flowContext(), params, true)) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index 0f10cfe2019..4e86bfcd6b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -16,7 +16,7 @@ export async function handoffStructuredSessionToNative( const sessionId = params.envelope.sessionId const operationId = params.envelope.clientOperationId let record = context.requireRecord(sessionId) - const owner = context.owner(sessionId) + let owner = context.owner(sessionId) let transcriptPath = owner?.transcriptPath if (!retry || record.lease.handoffStage === 'preparing') { if (record.lease.handoffStage === null) { @@ -25,6 +25,9 @@ export async function handoffStructuredSessionToNative( if (!owner) { throw new Error('The owning agent terminal could not be identified.') } + owner = await deps.transport!.reproveTuiOwner({ record, owner }) + context.retainOwner(sessionId, owner) + transcriptPath = owner.transcriptPath ?? transcriptPath context.setStatus(sessionId, { owner: 'tui', direction: 'to-native', 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 acb3d92bfe0..32979dc6460 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 @@ -21,6 +21,12 @@ export type StructuredAgentSessionHandoffTransport = { fence: number spawnToken: string }): Promise + reproveTuiOwner(input: { + record: AgentSessionRecord + owner: StructuredTuiOwner + }): Promise + recoverTuiOwner(record: AgentSessionRecord): Promise + stopRecoveredOwner(record: AgentSessionRecord): Promise waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise 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 155a064ae00..05b6ad2b316 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 @@ -11,6 +11,11 @@ import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + reserveStoredAgentSessionHandoffOwner, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' import { openAgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' import type { @@ -34,11 +39,17 @@ let launchTui: ReturnType > +let reproveTuiOwner: ReturnType< + typeof vi.fn +> let stopFailedTuiLaunch: ReturnType< typeof vi.fn> > let acquireNativeStop: ReturnType Promise>> let acquireNativeCalls: number +let stopRecoveredOwner: ReturnType< + typeof vi.fn +> let operations: number function operationId(): string { @@ -68,6 +79,10 @@ function request( } } +function submit(params: AgentSessionHandoffRequest) { + return coordinator.request('client-1', params) +} + function process(spawnToken: string, pid: number) { return { hostId: 'local', @@ -163,6 +178,15 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator { transport: { hostLabel: 'Test host', launchTui, + reproveTuiOwner, + recoverTuiOwner: async (record) => { + const owner = makeTuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ) + return { ...owner, process: record.lease.ownerProcess ?? owner.process } + }, + stopRecoveredOwner, waitForTuiExit, tuiStatus: () => (tuiIdle ? 'idle' : 'busy'), stopFailedTuiLaunch @@ -205,7 +229,7 @@ async function waitForPhase(phase: AgentSessionHandoffStatus['phase']): Promise< } async function moveToTui(): Promise { - expect(coordinator.request(request('to-tui', 'now'))).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui' })) } @@ -217,6 +241,7 @@ beforeEach(async () => { importFailure = null nativeAcquireFailure = null acquireNativeCalls = 0 + stopRecoveredOwner = vi.fn(async () => undefined) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) await establishNativeOwner() journal = await openAgentSessionJournal({ @@ -231,12 +256,14 @@ beforeEach(async () => { }) launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) + reproveTuiOwner = vi.fn(async ({ owner }) => owner) stopFailedTuiLaunch = vi.fn(async () => undefined) acquireNativeStop = vi.fn(async () => true) coordinator = createCoordinator() }) afterEach(async () => { + await coordinator.drain() await rm(root, { recursive: true, force: true }) }) @@ -254,7 +281,7 @@ describe('structured session ownership handoff', () => { handoffStage: null }) - expect(coordinator.request(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native' })) const messages = journal.snapshot().items.filter((item) => item.body.kind === 'message') @@ -269,21 +296,38 @@ describe('structured session ownership handoff', () => { it('queues a busy native turn, supports cancel, and starts only after idle', async () => { await appendStatus('running') - expect(coordinator.request(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) expect(coordinator.status(SESSION).phase).toBe('queued') expect( - coordinator.request(request('to-tui', 'after-turn', { action: 'cancel-queued' })) + await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' })) ).toMatchObject({ ok: true }) await appendStatus('completed') await new Promise((resolve) => setTimeout(resolve, 200)) expect(launchTui).not.toHaveBeenCalled() await appendStatus('running') - expect(coordinator.request(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) await appendStatus('completed') await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce()) }) + it('durably replays a completed queued cancellation after restart', async () => { + await appendStatus('running') + expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' }) + expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: false }) + await coordinator.drain() + + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + coordinator = createCoordinator() + + expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: true }) + expect(launchTui).not.toHaveBeenCalled() + }) + it('replays a duplicate active handoff without launching a second owner', async () => { let finishLaunch!: () => void launchTui.mockImplementationOnce( @@ -294,7 +338,7 @@ describe('structured session ownership handoff', () => { ) const operation = operationId() const first = request('to-tui', 'now', { operationId: operation }) - expect(coordinator.request(first)).toMatchObject({ ok: true, replayed: false }) + expect(await submit(first)).toMatchObject({ ok: true, replayed: false }) await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce()) expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'tui', @@ -302,22 +346,135 @@ describe('structured session ownership handoff', () => { handoffStage: 'new-owner-proving' }) expect(coordinator.status(SESSION).owner).not.toBe('tui') - expect(coordinator.request(first)).toMatchObject({ ok: true, replayed: true }) + expect(await submit(first)).toMatchObject({ ok: true, replayed: true }) finishLaunch() await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) expect(launchTui).toHaveBeenCalledOnce() }) + it('replays the same completed handoff operation after the coordinator restarts', async () => { + const operation = operationId() + const first = request('to-tui', 'now', { operationId: operation }) + expect(await submit(first)).toMatchObject({ ok: true, replayed: false }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + await coordinator.drain() + + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + coordinator = createCoordinator() + expect(await submit(first)).toMatchObject({ ok: true, replayed: true }) + expect(launchTui).toHaveBeenCalledOnce() + }) + + it('continues a persisted preparing stage after restart instead of stranding it', async () => { + const operation = operationId() + const first = request('to-tui', 'now', { operationId: operation }) + await store.admitOperation({ + callerKey: 'client-1', + operationId: operation, + fingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff.operation', + sessionId: SESSION, + fields: { direction: 'to-tui' } + }), + now: NOW + }) + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + await coordinator.drain() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + coordinator = createCoordinator() + expect(await submit(first)).toMatchObject({ ok: true, replayed: true }) + expect(launchTui).toHaveBeenCalledOnce() + }) + + it('finishes a live new-owner-proving stage after restart', async () => { + const operation = operationId() + let record = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + record = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + operationId: operation, + now: NOW + }) + const spawnToken = 'restarted-tui' + record = await reserveStoredAgentSessionHandoffOwner(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId: operation, + claimKeyId: 'key-1', + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: record.lease.runtimeFence, + process: process(spawnToken, 4400), + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + }) + it('rejects cancellation once a queued handoff is no longer pending', async () => { expect( - coordinator.request(request('to-tui', 'after-turn', { action: 'cancel-queued' })) + await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' })) ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) }) + it('durably replays a queued-cancellation refusal as a refusal', async () => { + const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' }) + expect(await submit(cancellation)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + coordinator = createCoordinator() + + expect(await submit(cancellation)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + expect(launchTui).not.toHaveBeenCalled() + }) + it('requires acknowledged cancellation before stopping a native turn', async () => { await appendStatus('running') - expect(coordinator.request(request('to-tui', 'stop-turn'))).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'stop-turn'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) expect(acquireNativeStop).toHaveBeenCalledWith('turn-1') expect(acquireNativeStop.mock.invocationCallOrder[0]).toBeLessThan( @@ -328,16 +485,42 @@ describe('structured session ownership handoff', () => { it('queues a busy TUI from host status instead of trusting a stale journal', async () => { await moveToTui() tuiIdle = false - expect(coordinator.request(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) expect(coordinator.status(SESSION).phase).toBe('queued') expect(waitForTuiExit).not.toHaveBeenCalled() tuiIdle = true await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce()) }) + it('re-proves the current TUI handle before accepting its exit and importing history', async () => { + await moveToTui() + const reverse = request('to-native', 'now') + expect(await submit(reverse)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + + expect(reproveTuiOwner).toHaveBeenCalledOnce() + expect(reproveTuiOwner.mock.invocationCallOrder[0]).toBeLessThan( + waitForTuiExit.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('does not close or import when the current TUI handle cannot be re-proved', async () => { + await moveToTui() + reproveTuiOwner.mockRejectedValueOnce(new Error('provider handle changed')) + expect(await submit(request('to-native', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(waitForTuiExit).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: 'preparing' + }) + }) + it('recovers native ownership when TUI launch or proof fails', async () => { launchTui.mockRejectedValueOnce(new Error('resume proof failed')) - expect(coordinator.request(request('to-tui', 'now'))).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) await waitForPhase('failed') expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native', @@ -355,7 +538,7 @@ describe('structured session ownership handoff', () => { ...makeTuiOwner(fence, spawnToken), link: link(fence + 1, 'bad-proof') })) - expect(coordinator.request(request('to-tui', 'now'))).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) await waitForPhase('failed') expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native') @@ -368,9 +551,9 @@ describe('structured session ownership handoff', () => { })) stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved')) const failedOperation = operationId() - expect( - coordinator.request(request('to-tui', 'now', { operationId: failedOperation })) - ).toMatchObject({ ok: true }) + expect(await submit(request('to-tui', 'now', { operationId: failedOperation }))).toMatchObject({ + ok: true + }) await waitForPhase('failed') expect(coordinator.status(SESSION)).toMatchObject({ @@ -380,9 +563,7 @@ describe('structured session ownership handoff', () => { }) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') expect( - coordinator.request( - request('to-tui', 'now', { action: 'retry', operationId: failedOperation }) - ) + await submit(request('to-tui', 'now', { action: 'retry', operationId: failedOperation })) ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) }) @@ -391,7 +572,7 @@ describe('structured session ownership handoff', () => { importFailure = new Error('history unavailable') const retryOperation = operationId() expect( - coordinator.request(request('to-native', 'after-turn', { operationId: retryOperation })) + await submit(request('to-native', 'after-turn', { operationId: retryOperation })) ).toMatchObject({ ok: true }) await waitForPhase('failed') expect(coordinator.status(SESSION)).toMatchObject({ @@ -406,9 +587,7 @@ describe('structured session ownership handoff', () => { }) expect( - coordinator.request( - request('to-native', 'now', { action: 'retry', operationId: retryOperation }) - ) + await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation })) ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) expect(waitForTuiExit).toHaveBeenCalledOnce() @@ -419,7 +598,7 @@ describe('structured session ownership handoff', () => { nativeAcquireFailure = new Error('native proof unavailable') const retryOperation = operationId() expect( - coordinator.request(request('to-native', 'after-turn', { operationId: retryOperation })) + await submit(request('to-native', 'after-turn', { operationId: retryOperation })) ).toMatchObject({ ok: true }) await waitForPhase('failed') @@ -442,9 +621,7 @@ describe('structured session ownership handoff', () => { operationId: retryOperation }) expect( - coordinator.request( - request('to-native', 'now', { action: 'retry', operationId: retryOperation }) - ) + await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation })) ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) expect(waitForTuiExit).toHaveBeenCalledOnce() 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 e394a4f37d0..55542d61d38 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 @@ -3,18 +3,22 @@ import type { AgentSessionHandoffRequest, AgentSessionHandoffResult, AgentSessionHandoffStatus, - AgentSessionMutationResult + AgentSessionMutationResult, + AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { - agentSessionFingerprintConflict, - computeAgentSessionPayloadFingerprint -} from '../../../shared/agent-session-mutation-envelope' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' -import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' -import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { + admitStructuredHandoffRequest, + refuseAdmittedStructuredHandoff, + replayedStructuredHandoffRefusal, + structuredHandoffRetryIsAdmissible +} 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 { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' import { structuredHandoffRefusal as refusal, structuredHandoffSuccess @@ -35,9 +39,18 @@ export class StructuredAgentSessionHandoffCoordinator { private readonly statuses = new Map() private readonly queue = new StructuredAgentSessionHandoffQueue() private readonly tuiOwners = new Map() - private readonly operationGuard = new StructuredAgentSessionHandoffOperationGuard() + private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard + private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner - constructor(private readonly deps: StructuredAgentSessionHandoffDeps) {} + constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { + this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) + this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ + deps, + operationGuard: this.operationGuard, + flowContext: () => this.flowContext(), + fail: (params, error) => this.fail(params, error) + }) + } status(sessionId: string): AgentSessionHandoffStatus { return ( @@ -45,95 +58,103 @@ export class StructuredAgentSessionHandoffCoordinator { ) } - request( + async drain(): Promise { + await this.flowRunner.drain() + } + + async restore(sessionId: string): Promise { + await restoreStructuredAgentSessionHandoff( + { + deps: this.deps, + requireRecord: (id) => this.requireRecord(id), + flowContext: () => this.flowContext(), + retainOwner: (id, owner) => this.tuiOwners.set(id, owner), + setStatus: (id, status) => this.setStatus(id, status) + }, + sessionId + ) + } + + async request( + callerKey: string, params: AgentSessionHandoffRequest - ): AgentSessionMutationResult { + ): Promise> { const record = this.requireRecord(params.envelope.sessionId) - const fields = { - direction: params.direction, - mode: params.mode, - action: params.action ?? 'start' - } - const fingerprint = computeAgentSessionPayloadFingerprint({ - method: 'agentSession.requestHandoff', - sessionId: record.sessionId, - fields - }) - const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) - if (conflict) { - return { ok: false, refusal: conflict } - } const currentStatus = this.statuses.get(record.sessionId) - const operationDecision = this.operationGuard.check({ - sessionId: record.sessionId, - operationId: params.envelope.clientOperationId, - fingerprint, - action: fields.action, + const admission = await admitStructuredHandoffRequest({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + record, ...(currentStatus ? { status: currentStatus } : {}) }) - if (operationDecision === 'replay') { + if (admission.decision === 'replay') { + const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome) + if (replayedRefusal) { + return { ok: false, refusal: replayedRefusal } + } return this.success(record.sessionId, true) } - if (operationDecision === 'conflict') { - return { - ok: false, - refusal: refusal( - 'agent_session_operation_conflict', - 'This handoff operation is already running with different parameters.' - ) - } + if (admission.decision === 'refused') { + return { ok: false, refusal: admission.refusal } } - if (params.envelope.expectedRuntimeFence !== record.lease.runtimeFence) { - return { - ok: false, - refusal: { - code: 'agent_session_checkpoint_stale', - message: 'The session owner changed before the handoff request arrived.', - currentFence: record.lease.runtimeFence - } - } - } - if (fields.action === 'cancel-queued') { + const { fingerprint } = admission + const action = params.action ?? 'start' + if (action === 'cancel-queued') { if (currentStatus?.phase !== 'queued' || currentStatus.direction !== params.direction) { - return { - ok: false, - refusal: refusal('agent_session_operation_conflict', 'No matching queued handoff exists.') - } + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'No matching queued handoff exists.' + ) } this.queue.cancel(record.sessionId) this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + await this.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: record.sessionId } + }) return this.success(record.sessionId, false) } if (!this.deps.transport) { - return { - ok: false, - refusal: refusal( - 'structured_agent_session_unsupported', - 'Agent TUI handoff is unavailable on this host.' + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Agent TUI handoff is unavailable on this host.' + ) + } + if (action === 'retry') { + if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer retryable.' ) } - } - if (fields.action === 'retry') { - return this.retry(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') { - return { - ok: false, - refusal: refusal( - 'agent_session_conflict', - `The ${expectedOwner} runtime does not own this session.` - ) - } + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + `The ${expectedOwner} runtime does not own this session.` + ) } if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { - return { - ok: false, - refusal: refusal( - 'agent_session_conflict', - 'Resolve the pending question or approval before switching.' - ) - } + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'Resolve the pending question or approval before switching.' + ) } const turnId = activeStructuredAgentSessionTurnId( this.deps.session(record.sessionId).journal.snapshot().items @@ -141,45 +162,41 @@ export class StructuredAgentSessionHandoffCoordinator { const busy = expectedOwner === 'native' ? turnId !== null : this.tuiStatus(record.sessionId) !== 'idle' if (busy && params.mode === 'now') { - return { - ok: false, - refusal: refusal('agent_session_conflict', 'The current turn must finish before switching.') - } + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'The current turn must finish before switching.' + ) } if (busy && params.mode === 'after-turn') { - this.queueAfterTurn(params) + this.queueAfterTurn(callerKey, params, fingerprint) return this.success(record.sessionId, false) } if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { - return { - ok: false, - refusal: refusal( - 'structured_agent_session_unsupported', - 'Exit the agent terminal after this turn to continue in chat.' - ) - } + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Exit the agent terminal after this turn to continue in chat.' + ) } - this.begin(params, turnId, fingerprint) + this.begin(callerKey, params, turnId, fingerprint) return this.success(record.sessionId, false) } - private retry( - params: AgentSessionHandoffRequest - ): AgentSessionMutationResult { - const status = this.status(params.envelope.sessionId) - if ( - status?.phase !== 'failed' || - status.direction !== params.direction || - status.operationId !== params.envelope.clientOperationId || - status.error?.recoverableOwner === 'none' - ) { - return { - ok: false, - refusal: refusal('agent_session_operation_conflict', 'This handoff is no longer retryable.') - } - } - this.begin(params, null, params.envelope.payloadFingerprint) - return this.success(params.envelope.sessionId, false) + private async refuseAdmitted( + callerKey: string, + params: AgentSessionHandoffRequest, + code: AgentSessionWireRefusal['code'], + message: string + ): Promise> { + return refuseAdmittedStructuredHandoff({ + deps: this.deps, + callerKey, + params, + refusal: refusal(code, message) + }) } private success( @@ -189,7 +206,11 @@ export class StructuredAgentSessionHandoffCoordinator { return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) } - private queueAfterTurn(params: AgentSessionHandoffRequest): void { + private queueAfterTurn( + callerKey: string, + params: AgentSessionHandoffRequest, + fingerprint: string + ): void { const sessionId = params.envelope.sessionId this.setStatus(sessionId, { owner: params.direction === 'to-tui' ? 'native' : 'tui', @@ -209,35 +230,23 @@ export class StructuredAgentSessionHandoffCoordinator { : this.tuiStatus(sessionId) === 'idle', () => { const next = { ...params, mode: 'now' as const } - this.begin(next, null, params.envelope.payloadFingerprint) + this.begin(callerKey, next, null, fingerprint) } ) } private begin( + callerKey: string, params: AgentSessionHandoffRequest, turnId: string | null, fingerprint: string ): void { - const sessionId = params.envelope.sessionId - this.operationGuard.start(sessionId, { - operationId: params.envelope.clientOperationId, + this.flowRunner.begin({ + callerKey, + params, + turnId, fingerprint }) - void this.deps - .schedule(sessionId, async () => { - if (turnId && params.mode === 'stop-turn') { - const stopped = await this.stopNativeTurn(sessionId, turnId) - if (!stopped) { - throw new Error('The current turn did not acknowledge cancellation.') - } - } - await (params.direction === 'to-tui' - ? handoffStructuredSessionToTui(this.flowContext(), params, params.action === 'retry') - : handoffStructuredSessionToNative(this.flowContext(), params, params.action === 'retry')) - }) - .catch((error) => this.fail(params, error)) - .finally(() => this.operationGuard.finish(sessionId, params.envelope.clientOperationId)) } private tuiStatus(sessionId: string): 'idle' | 'busy' { @@ -246,7 +255,7 @@ export class StructuredAgentSessionHandoffCoordinator { } private flowContext(): StructuredAgentSessionHandoffFlowContext { - return { + return createStructuredHandoffFlowContext({ deps: this.deps, owner: (sessionId) => this.tuiOwners.get(sessionId), retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner), @@ -258,17 +267,7 @@ export class StructuredAgentSessionHandoffCoordinator { this.enterPreparing(record, operationId, direction), publishStage: (record, direction) => this.publishStage(record, direction), requireRecord: (sessionId) => this.requireRecord(sessionId) - } - } - - private async stopNativeTurn(sessionId: string, turnId: string): Promise { - const record = this.requireRecord(sessionId) - if (record.lease.runtimeKind !== 'native') { - return false - } - const session = this.deps.session(sessionId) - const result = await this.deps.acquireNativeStop?.(sessionId, turnId, session.fence) - return result ?? false + }) } private async enterPreparing( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 70569ffa37d..c1df075905f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -5,11 +5,28 @@ import { type DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer - constructor(private readonly deps: StructuredAgentSessionHostDeps) {} + constructor(private readonly deps: StructuredAgentSessionHostDeps) { + this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ + store: deps.store, + probe: (record) => this.probeRecord(record), + now: () => deps.now?.() ?? Date.now(), + onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) + }) + } + + startLeaseRenewal(): void { + this.leaseRenewer.start() + } + + stopLeaseRenewal(): void { + this.leaseRenewer.stop() + } eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { const existing = this.eventSinks.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index edbc0114db3..59321c2ecf5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -221,6 +221,7 @@ beforeEach(async () => { }) afterEach(async () => { + await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) @@ -228,7 +229,6 @@ describe('attach', () => { it('reserves the lease, spawns through the adapter, and opens the journal', async () => { const result = await host.attach(CALLER, attachParams()) expect(result).toMatchObject({ ok: true, replayed: false }) - expect(host.hasSession(SESSION)).toBe(true) const record = store.getRecord(SESSION) expect(record?.lease.ownerProcess?.pid).toBe(4242) expect(record?.lease.handoffStage).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 80bd58109af..bd8223bf8f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -113,6 +113,7 @@ export class StructuredAgentSessionHost { subscribers: this.subscribers, now: this.now }) + this.runtimeState.startLeaseRenewal() } private now = (): number => this.deps.now?.() ?? Date.now() @@ -151,7 +152,8 @@ export class StructuredAgentSessionHost { ), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: (sessionId) => this.sessions.has(sessionId), - onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored) + onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored), + restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) }) } @@ -233,6 +235,7 @@ export class StructuredAgentSessionHost { /** Settles final provider rows after every child has stopped producing them. */ async flushAllStreamedEvents(): Promise { + this.runtimeState.stopLeaseRenewal() // An acquired provider can still be opening its journal; bind before draining. await this.tasks.drainAttaches() await this.runtimeState.flushAllEventSinks() @@ -307,10 +310,11 @@ export class StructuredAgentSessionHost { } requestHandoff( + caller: StructuredAgentSessionCaller, params: AgentSessionHandoffRequest - ): AgentSessionMutationResult { + ): Promise> { this.requireSession(params.envelope.sessionId) - return this.handoffs.request(params) + return this.handoffs.request(caller.callerKey, params) } handoffStatus(sessionId: string): AgentSessionHandoffStatus { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts new file mode 100644 index 00000000000..e0dd07c344c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' + +const NOW = 1_800_000_000_000 +const roots: string[] = [] + +async function liveStore(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: 'session-renewal', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: root }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-renewal', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-renewal' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'link-renewal', + handle: { provider: 'codex', threadId: 'thread-renewal' }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + return store +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured agent-session lease renewal', () => { + it('drives renewal on the production interval', async () => { + vi.useFakeTimers() + const store = await liveStore() + let now = NOW + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }), + now: () => now + }) + try { + renewer.start() + now += 10_000 + await vi.advanceTimersByTimeAsync(10_000) + await vi.waitFor(() => + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(now) + ) + } finally { + renewer.stop() + vi.useRealTimers() + } + }) + + it('renews every live owner only after re-proving its child identity', async () => { + const store = await liveStore() + const probe = vi.fn(async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + })) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + now: () => NOW + 10_000 + }) + + await renewer.renewNow() + + expect(probe).toHaveBeenCalledOnce() + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + }) + + it('stops extending the lease when child proof is no longer sufficient', async () => { + const store = await liveStore() + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), + now: () => NOW + 10_000, + onError + }) + + await renewer.renewNow() + + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-renewal', + error: expect.any(Error) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts new file mode 100644 index 00000000000..f9f9b7b6773 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -0,0 +1,73 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from '../../runtime/agent-session-record-store' + +const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) + +export class StructuredAgentSessionLeaseRenewer { + private timer: ReturnType | null = null + private running = false + + constructor( + private readonly input: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + now: () => number + onError?: (input: { sessionId: string; error: unknown }) => void + intervalMs?: number + } + ) {} + + start(): void { + if (this.timer) { + return + } + this.timer = setInterval(() => void this.renewNow(), this.input.intervalMs ?? RENEW_INTERVAL_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + async renewNow(): Promise { + if (this.running) { + return + } + this.running = true + try { + await Promise.all( + this.input.store + .listRecords() + .filter( + (record) => + !record.lease.unreconciled && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null + ) + .map((record) => this.renewRecord(record)) + ) + } finally { + this.running = false + } + } + + private async renewRecord(record: AgentSessionRecord): Promise { + try { + await this.input.store.renewLease({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + childProbe: await this.input.probe(record), + now: this.input.now() + }) + } catch (error) { + this.input.onError?.({ sessionId: record.sessionId, error }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index c2d2657e7e5..082f79ca194 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -18,6 +18,7 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + restoreHandoff: (sessionId: string) => Promise }): Promise { await Promise.all( input.records.map(async ({ sessionId }) => { @@ -49,6 +50,9 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { input.onReadable(sessionId, restored) } }) + if (input.hasSession(sessionId)) { + await input.restoreHandoff(sessionId) + } }) ) } diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index ff3eb1f69e6..3cc134ba9e0 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -6,7 +6,6 @@ * observed process identity, and a proved provider handle — in that order, at one fence. */ -import { isDeepStrictEqual } from 'node:util' import { adjudicateAgentSessionRestart, evaluateAgentSessionAcquisition, @@ -24,6 +23,7 @@ import type { AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' export type AgentSessionReservation = { runtimeKind: AgentSessionOwnerRuntimeKind @@ -50,13 +50,6 @@ export function assertFence(lease: AgentSessionLease, fence: number): void { } } -export function agentSessionReconciliationTargetMatches( - current: AgentSessionRecord, - probed: AgentSessionRecord -): boolean { - return isDeepStrictEqual(current, probed) -} - /** * Compare-and-swap reservation. Writes the intent at fence + 1 before any process exists, so the * loser of a concurrent swap is refused and never spawns. @@ -256,6 +249,12 @@ export function applyAgentSessionRestartAdjudication(args: { lastRenewedAt: args.now }) } + if ( + record.lease.handoffStage === 'preparing' || + record.lease.handoffStage === 'new-owner-proving' + ) { + return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now) + } const adjudication = adjudicateAgentSessionRestart({ lease: record.lease, probe: args.probe, diff --git a/src/main/runtime/agent-session-process-identity-probe.test.ts b/src/main/runtime/agent-session-process-identity-probe.test.ts index 57e0361baa7..5f310a2a6ee 100644 --- a/src/main/runtime/agent-session-process-identity-probe.test.ts +++ b/src/main/runtime/agent-session-process-identity-probe.test.ts @@ -154,13 +154,18 @@ describe('owner identity probe', () => { ).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) }) - it('returns no start time on Windows rather than guessing one', async () => { - await expect(readProcessStartTimeMs(process.pid, 'win32')).resolves.toBeNull() + it('reads a CIM-backed start time on Windows when running there', async () => { + const observed = await readProcessStartTimeMs(process.pid, 'win32') + expect(observed === null).toBe(process.platform !== 'win32') }) it('reads a start time for the current process on this platform', async () => { const observed = await readProcessStartTimeMs(process.pid) - if (process.platform === 'linux' || process.platform === 'darwin') { + if ( + process.platform === 'linux' || + process.platform === 'darwin' || + process.platform === 'win32' + ) { expect(observed).not.toBeNull() expect(Math.abs((observed as number) - (Date.now() - process.uptime() * 1000))).toBeLessThan( 60_000 diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 8d63fd485ac..93d5427f3aa 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -74,9 +74,26 @@ async function readDarwinProcessStartTimeMs(pid: number): Promise } } +async function readWindowsProcessStartTimeMs(pid: number): Promise { + try { + const script = + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` + + 'if ($p) { $p.CreationDate.ToUniversalTime().ToString("o") }' + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { timeout: PROCESS_START_TIME_TIMEOUT_MS, windowsHide: true } + ) + const parsed = Date.parse(stdout.trim()) + return Number.isFinite(parsed) ? parsed : null + } catch { + return null + } +} + /** - * Windows returns null: reading a start time needs a CIM query Orca does not make here, so a - * Windows child without a token echo qualifies for manual recovery only. + * Process start time is the cross-platform PID-reuse guard when no provider hook can echo the + * spawn token back to the owner probe. */ export async function readProcessStartTimeMs( pid: number, @@ -88,6 +105,9 @@ export async function readProcessStartTimeMs( if (platform === 'darwin') { return readDarwinProcessStartTimeMs(pid) } + if (platform === 'win32') { + return readWindowsProcessStartTimeMs(pid) + } return null } diff --git a/src/main/runtime/agent-session-reconciliation-target.ts b/src/main/runtime/agent-session-reconciliation-target.ts new file mode 100644 index 00000000000..03fd77be7dd --- /dev/null +++ b/src/main/runtime/agent-session-reconciliation-target.ts @@ -0,0 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function agentSessionReconciliationTargetMatches( + current: AgentSessionRecord, + probed: AgentSessionRecord +): boolean { + return isDeepStrictEqual(current, probed) +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index de8cb9c771f..1f3922b5732 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -10,8 +10,9 @@ */ import { - pruneAgentSessionOperationRows, agentSessionOperationKey, + pruneAgentSessionOperationRows, + settleAgentSessionOperation, type AgentSessionOperationDecision, type AgentSessionOperationOutcome, type AgentSessionOperationRow @@ -31,7 +32,6 @@ import { } from '../../shared/agent-session-record' import { applyAgentSessionRestartAdjudication, - agentSessionReconciliationTargetMatches, commitAgentSessionProcessIdentity, evictAgentSessionOwner, proveAgentSessionOwner, @@ -39,6 +39,7 @@ import { setAgentSessionJournalCheckpoint, type AgentSessionProcessIdentityCommit } from './agent-session-lease-transitions' +import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' import { setAgentSessionReservationProcesslessProof, type AgentSessionReservationProcesslessProof @@ -291,16 +292,12 @@ export class AgentSessionRecordStore { } async recordOperationOutcome(args: { - callerKey: string + callerKey?: string operationId: string outcome: AgentSessionOperationOutcome }): Promise { await this.transact(() => { - const key = agentSessionOperationKey(args.callerKey, args.operationId) - const row = this.state.operations.get(key) - if (row) { - this.state.operations.set(key, { ...row, outcome: args.outcome }) - } + this.state.operations = settleAgentSessionOperation(this.state.operations, args) }) } diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts new file mode 100644 index 00000000000..a18f55f333f --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' + +const NOW = 1_800_000_000_000 + +function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord { + return { + schemaVersion: 1, + sessionId: 'session-restart', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + lease: { + sessionId: 'session-restart', + runtimeKind: stage === 'preparing' ? 'native' : 'tui', + runtimeFence: 4, + handoffStage: stage, + provenHandleLinkId: null, + ownerProcess: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-restart' + }, + reservedSpawnToken: 'spawn-restart', + leaseDeadlineAt: NOW + 30_000, + lastRenewedAt: NOW, + handoffOperationId: 'handoff-op-1', + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: stage === 'preparing' ? 'live' : 'reserved', + unreconciled: true, + deathEvidence: null + }, + createdAt: NOW, + updatedAt: NOW + } +} + +describe('restarted handoff adjudication', () => { + it('continues a dead preparing owner at old-owner-stopped under the same operation', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('preparing'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1', + claimStatus: 'released', + ownerProcess: null + }) + }) + + it('rolls a dead proving target back to the source kind before retry', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('new-owner-proving'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1' + }) + }) +}) diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.ts new file mode 100644 index 00000000000..86a42d7dbc2 --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.ts @@ -0,0 +1,52 @@ +import { + adjudicateAgentSessionRestart, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function adjudicateRestartedAgentSessionHandoff( + record: AgentSessionRecord, + probe: AgentSessionOwnerProbe, + now: number +): AgentSessionRecord { + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe, + observedAt: now + }) + if (adjudication.disposition === 'readopt') { + return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) + } + if (adjudication.disposition !== 'evicted') { + return updateLease(record, { + ...record.lease, + handoffStage: 'recovering', + claimStatus: + adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + unreconciled: false, + lastRenewedAt: now + }) + } + const provingTarget = record.lease.handoffStage === 'new-owner-proving' + return updateLease(record, { + ...record.lease, + runtimeKind: provingTarget + ? record.lease.runtimeKind === 'native' + ? 'tui' + : 'native' + : record.lease.runtimeKind, + runtimeFence: adjudication.nextFence, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: now, + deathEvidence: adjudication.evidence + }) +} + +function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) { + return { ...record, lease, updatedAt: lease.lastRenewedAt } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5227e5f7885..28a2d68b10f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -75,10 +75,13 @@ import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } 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 } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof' -import { readProcessStartTimeMs } from './agent-session-process-identity-probe' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { @@ -9120,10 +9123,6 @@ export class OrcaRuntimeService { if (!terminal.processId || !terminal.paneKey || !terminal.tabId) { 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, @@ -9139,12 +9138,12 @@ export class OrcaRuntimeService { tabId: terminal.tabId, paneKey: terminal.paneKey }, - process: { + process: await readStructuredTuiProcessIdentity({ hostId: record.location.executionHostId, - pid: terminal.processId, - processStartTimeMs: await readProcessStartTimeMs(terminal.processId), - spawnToken - }, + rootPid: terminal.processId, + spawnToken, + agent: 'codex' + }), link: codexProviderHandleLink({ threadId: head.handle.threadId, resumed: true, @@ -9169,6 +9168,64 @@ export class OrcaRuntimeService { }) return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} }, + reproveTuiOwner: async ({ record, owner }) => { + const proof = await probeAgentSessionProcessIdentity({ identity: owner.process }) + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The owning Codex child process could not be re-proved.') + } + const head = record.providerHandleChain.at(-1) + if (head?.handle.provider !== 'codex') { + throw new Error('agent_session_identity_required') + } + const tuiProof = await this.waitForStructuredTuiProof({ + handle: owner.terminal.handle, + paneKey: owner.terminal.paneKey, + threadId: head.handle.threadId, + spawnToken: owner.process.spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + return { ...owner, transcriptPath: tuiProof.transcriptPath } + }, + recoverTuiOwner: async (record) => { + const identity = record.lease.ownerProcess + const head = record.providerHandleChain.at(-1) + if (!identity || head?.handle.provider !== 'codex') { + throw new Error('agent_session_identity_required') + } + const candidate = [...this.ptysById.values()].find( + (pty) => + pty.connected && + pty.launchToken === identity.spawnToken && + pty.launchAgent === 'codex' && + pty.tabId && + pty.paneKey + ) + const handle = candidate ? this.handleByPtyId.get(candidate.ptyId) : null + if (!candidate?.tabId || !candidate.paneKey || !handle) { + throw new Error('The owning agent terminal could not be recovered.') + } + const proof = await this.waitForStructuredTuiProof({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + spawnToken: identity.spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + return { + terminal: { handle, tabId: candidate.tabId, paneKey: candidate.paneKey }, + process: identity, + link: codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + }, + stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), tuiStatus: (owner) => this.structuredTuiStatus(owner.terminal.handle), stopFailedTuiLaunch: async (owner) => { await this.closeTerminal(owner.terminal.handle) @@ -9180,6 +9237,37 @@ export class OrcaRuntimeService { } } + private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { + const identity = record.lease.ownerProcess + if (!identity) { + return + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return + } + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The recovered owner process could not be stopped safely.') + } + try { + process.kill(identity.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('The recovered owner process did not exit.') + } + private structuredTuiStatus(handle: string): 'idle' | 'busy' { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -9188,21 +9276,22 @@ export class OrcaRuntimeService { pty.pty.tailPartialLine, pty.pty.preview ) - return !detectTerminalWaitBlockedReason(text) && - (pty.pty.lastAgentStatus === 'idle' || - this.getAdoptedPtyExplicitIdleStatus(pty.pty) === 'idle' || - isKnownReadyPromptPreview(text)) + return hasStructuredTuiIdleEvidence({ + blocked: detectTerminalWaitBlockedReason(text) !== null, + status: pty.pty.lastAgentStatus, + statusObservedLive: pty.pty.lastAgentStatusObservedLive + }) ? 'idle' : 'busy' } try { const { leaf } = this.getLiveLeafForHandle(handle) const text = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - const title = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title - return !detectTerminalWaitBlockedReason(text) && - (leaf.lastAgentStatus === 'idle' || - (title && detectExplicitIdleStatusFromTitle(title) === 'idle') || - isKnownReadyPromptPreview(text)) + return hasStructuredTuiIdleEvidence({ + blocked: detectTerminalWaitBlockedReason(text) !== null, + status: leaf.lastAgentStatus, + statusObservedLive: leaf.lastAgentStatusObservedLive + }) ? 'idle' : 'busy' } catch { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 7bb796a20be..3501f9a0a65 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -214,7 +214,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'agentSession.requestHandoff', params: HandoffParams, - handler: async (params, ctx) => requireHost(ctx).requestHandoff(params) + handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.handoffStatus', diff --git a/src/main/runtime/structured-tui-idle-evidence.test.ts b/src/main/runtime/structured-tui-idle-evidence.test.ts new file mode 100644 index 00000000000..7177cb9bfc3 --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' + +describe('structured TUI idle evidence', () => { + it('does not treat a ready prompt preview as proof that a turn is idle', () => { + const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace' + expect(readyPreview).toContain('OpenAI Codex') + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false }) + ).toBe(false) + }) + + it('requires an explicit idle state and still rejects blocked prompts', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true }) + ).toBe(true) + expect( + hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true }) + ).toBe(false) + }) + + it('does not authorize a restored idle status before live observation', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-tui-idle-evidence.ts b/src/main/runtime/structured-tui-idle-evidence.ts new file mode 100644 index 00000000000..d91518cdd4c --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.ts @@ -0,0 +1,9 @@ +import type { AgentStatus } from '../../shared/agent-title-core' + +export function hasStructuredTuiIdleEvidence(input: { + blocked: boolean + status: AgentStatus | null + statusObservedLive: boolean +}): boolean { + return !input.blocked && input.status === 'idle' && input.statusObservedLive +} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts new file mode 100644 index 00000000000..59a06833fa6 --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' + +describe('structured TUI process identity', () => { + it('binds the direct Codex child instead of the PTY shell pid', async () => { + const readStartTime = vi.fn(async () => 1_700_000_000_000) + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' }, + { pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' } + ], + readStartTime + }) + ).resolves.toEqual({ + hostId: 'local', + pid: 101, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-1' + }) + expect(readStartTime).toHaveBeenCalledWith(101, 'darwin') + }) + + it('fails closed when sibling Codex children make the owner ambiguous', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'win32', + readWindowsRows: async () => [ + { pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' }, + { pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' }, + { pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' } + ] + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it('fails closed when the process snapshot omitted the PTY root', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' } + ] + }) + ).rejects.toThrow('root process was not present') + }) +}) diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts new file mode 100644 index 00000000000..0566ec20eba --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -0,0 +1,96 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + getFreshProcessTableSnapshot, + type ProcessTableRow +} from '../../shared/process-table-snapshot' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows' +import { readProcessStartTimeMs } from './agent-session-process-identity-probe' + +type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean } + +function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { + const children = new Map() + for (const row of rows) { + children.set(row.ppid, [...(children.get(row.ppid) ?? []), row]) + } + const found: (ProcessRow & { depth: number })[] = [] + const pending = [{ pid: rootPid, depth: 0 }] + const seen = new Set() + while (pending.length > 0) { + const current = pending.pop()! + if (seen.has(current.pid)) { + continue + } + seen.add(current.pid) + const row = rows.find((candidate) => candidate.pid === current.pid) + if (row) { + found.push({ ...row, depth: current.depth }) + } + for (const child of children.get(current.pid) ?? []) { + pending.push({ pid: child.pid, depth: current.depth + 1 }) + } + } + return found +} + +export function resolveStructuredTuiChildPid( + rows: ProcessRow[], + rootPid: number, + agent: 'codex' +): number | null { + const candidates = descendants(rows, rootPid).filter( + (row) => recognizeAgentProcessFromCommandLine(row.command)?.agent === agent + ) + const foreground = candidates.filter((row) => row.foreground) + const eligible = foreground.length > 0 ? foreground : candidates + eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid) + if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) { + return null + } + return eligible[0]!.pid +} + +function posixRows(rows: ProcessTableRow[]): ProcessRow[] { + return rows.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: row.stat.includes('+') + })) +} + +export async function readStructuredTuiProcessIdentity(input: { + hostId: string + rootPid: number + spawnToken: string + agent: 'codex' + platform?: NodeJS.Platform + readPosixRows?: () => Promise + readWindowsRows?: typeof queryWindowsProcessRowsFresh + readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise +}): Promise { + const platform = input.platform ?? process.platform + const rows: ProcessRow[] = + platform === 'win32' + ? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: false + })) + : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) + if (!rows.some((row) => row.pid === input.rootPid)) { + throw new Error('The terminal root process was not present in the process snapshot.') + } + const pid = resolveStructuredTuiChildPid(rows, input.rootPid, input.agent) + if (pid === null) { + throw new Error('The resumed terminal did not expose one exact Codex child process.') + } + return { + hostId: input.hostId, + pid, + processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform), + spawnToken: input.spawnToken + } +} diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 864c2c66485..ceddfd1d14b 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -52,6 +52,28 @@ export function agentSessionOperationKey(callerKey: string, operationId: string) return `${callerKey}${OPERATION_KEY_SEPARATOR}${operationId}` } +export function settleAgentSessionOperation( + rows: ReadonlyMap, + args: { + /** Restart reconciliation omits this because the lease persists no client identity. */ + callerKey?: string + operationId: string + outcome: AgentSessionOperationOutcome + } +): Map { + const targetKey = args.callerKey + ? agentSessionOperationKey(args.callerKey, args.operationId) + : null + return new Map( + [...rows].map(([key, row]) => [ + key, + (targetKey ? key === targetKey : row.operationId === args.operationId) + ? { ...row, outcome: args.outcome } + : row + ]) + ) +} + /** * Retention floor. The tombstone must outlive the window in which its id could still be admitted * as new, plus the accepted future skew — otherwise a retry arriving in the gap becomes a second From 6bc01085d0511a669930703e69dc059d41ac30e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:04:15 -0700 Subject: [PATCH 3/8] fix: bind structured TUI before rollout proof --- ...runtime-structured-tui-tab-binding.test.ts | 126 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 8 +- .../web-session-structured-tab-focus.test.ts | 104 +++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 12 ++ 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts create mode 100644 src/renderer/src/runtime/web-session-structured-tab-focus.test.ts 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 new file mode 100644 index 00000000000..8b941ca265c --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { OrcaRuntimeService } from './orca-runtime' + +const readStructuredTuiProcessIdentity = vi.hoisted(() => vi.fn()) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) + +const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' + +function notifier(revealTerminalSession: ReturnType) { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +describe('structured TUI launch tab binding', () => { + it('proves the published launch tab before returning its revealed renderer binding', async () => { + const revealTerminalSession = vi.fn( + (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => + Promise.resolve({ tabId: 'tab-renderer' }) + ) + const runtime = new OrcaRuntimeService({ + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never) + runtime.setNotifier(notifier(revealTerminalSession) as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + markLocalWorkspaceTrustedForAgent(): void + waitForTerminal(): Promise + waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }> + } + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.markLocalWorkspaceTrustedForAgent = vi.fn() + const waitForTerminal = vi.fn(async () => ({})) + internal.waitForTerminal = waitForTerminal + const waitForStructuredTuiProof = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toContainEqual( + expect.objectContaining({ + type: 'terminal', + parentTabId: expect.any(String), + leafId: expect.any(String), + ptyId: 'pty-structured', + terminal: expect.any(String) + }) + ) + expect(revealTerminalSession).not.toHaveBeenCalled() + return { transcriptPath: '/tmp/rollout.jsonl' } + }) + internal.waitForStructuredTuiProof = waitForStructuredTuiProof + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const owner = await transport.launchTui({ + record: { + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 } + ] + } as never, + fence: 3, + spawnToken: 'spawn-token' + }) + + const reveal = revealTerminalSession.mock.calls[0]?.[1] as { + tabId: string + leafId: string + } + expect(owner.terminal).toMatchObject({ + tabId: 'tab-renderer', + paneKey: `${reveal.tabId}:${reveal.leafId}` + }) + expect(waitForTerminal).toHaveBeenCalledWith( + owner.terminal.handle, + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( + revealTerminalSession.mock.invocationCallOrder[0]! + ) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 28a2d68b10f..2fafd5a8c08 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9123,6 +9123,10 @@ export class OrcaRuntimeService { if (!terminal.processId || !terminal.paneKey || !terminal.tabId) { 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, @@ -9131,11 +9135,11 @@ export class OrcaRuntimeService { codexHome: record.accountHome.path, sessionId: record.sessionId }) - await this.focusTerminal(terminal.handle) + const revealed = await this.focusTerminal(terminal.handle) return { terminal: { handle: terminal.handle, - tabId: terminal.tabId, + tabId: revealed.tabId, paneKey: terminal.paneKey }, process: await readStructuredTuiProcessIdentity({ diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts new file mode 100644 index 00000000000..a42a3fb9a90 --- /dev/null +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::/worktree' +const GROUP_ID = 'group-1' + +function structuredTab(sessionId: string, sortOrder: number): Tab { + return { + id: `structured-agent-session-${sessionId}`, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +describe('web session structured tab focus', () => { + it('keeps the exact active structured tab across a host snapshot', () => { + const first = structuredTab('session-1', 0) + const second = structuredTab('session-2', 1) + const state = { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: second.id, + tabOrder: [first.id, second.id] + } + ] + }, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } as WebSessionTabsSyncState + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: GROUP_ID, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: GROUP_ID, + activeTabId: 'agent-session:session-1', + tabOrder: ['agent-session:session-1', 'agent-session:session-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-2', + title: 'Codex Chat', + sessionId: 'session-2', + agent: 'codex', + isActive: false + } + ] + } + + const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10) + + expect(patch.groupsByWorktree?.[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 39cdbe4d4dc..60341eb4e20 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -2004,6 +2004,18 @@ function findCurrentVisibleUnifiedTabId(args: { if (!nextUnifiedTabs) { return null } + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const activeGroupTabId = state.groupsByWorktree[worktreeId]?.find( + (group) => group.id === activeGroupId + )?.activeTabId + if ( + activeGroupTabId && + nextUnifiedTabs.some( + (tab) => tab.id === activeGroupTabId && tab.contentType === 'agent-session' + ) + ) { + return activeGroupTabId + } const currentVisibleType = state.activeTabTypeByWorktree[worktreeId] ?? (state.activeWorktreeId === worktreeId ? state.activeTabType : null) From 9d8857c20ae90cb6dfc7087bf34b164164bd8f6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:12:59 -0700 Subject: [PATCH 4/8] fix: complete structured chat round trips --- .../structured-session-ownership.test.ts | 6 ++- .../structured-agent-session-handoff-queue.ts | 36 ++++++++++++------ .../structured-agent-session-handoff-types.ts | 1 + .../structured-agent-session-handoff.test.ts | 38 +++++++++++++++++-- .../structured-agent-session-handoff.ts | 12 +++--- ...runtime-structured-tui-tab-binding.test.ts | 30 ++++++++++++++- src/main/runtime/orca-runtime.ts | 35 ++++++++++++----- .../tab-group/AiVaultSessionDropLayer.tsx | 7 +++- src/renderer/src/i18n/locales/en.json | 3 ++ .../activate-ai-vault-structured-session.ts | 8 +++- 10 files changed, 139 insertions(+), 37 deletions(-) diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 80098a82518..f260e985e7c 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' -import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { assertLegacyAiVaultResumeAllowed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts index 53781aecd49..bee387a9a68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -1,14 +1,20 @@ export class StructuredAgentSessionHandoffQueue { - private readonly tokens = new Map() + private readonly controllers = new Map() cancel(sessionId: string): void { - this.tokens.delete(sessionId) + this.controllers.get(sessionId)?.abort() + this.controllers.delete(sessionId) } - enqueue(sessionId: string, isIdle: () => boolean, onReady: () => void): void { - const token = Symbol(sessionId) - this.tokens.set(sessionId, token) - void this.waitUntilIdle(sessionId, token, isIdle).then((ready) => { + enqueue( + sessionId: string, + isIdle: (signal: AbortSignal) => boolean | Promise, + onReady: () => void + ): void { + this.cancel(sessionId) + const controller = new AbortController() + this.controllers.set(sessionId, controller) + void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => { if (ready) { onReady() } @@ -17,13 +23,19 @@ export class StructuredAgentSessionHandoffQueue { private async waitUntilIdle( sessionId: string, - token: symbol, - isIdle: () => boolean + controller: AbortController, + isIdle: (signal: AbortSignal) => boolean | Promise ): Promise { - while (this.tokens.get(sessionId) === token) { - if (isIdle()) { - this.tokens.delete(sessionId) - return true + while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) { + try { + if (await isIdle(controller.signal)) { + this.controllers.delete(sessionId) + return true + } + } catch { + if (controller.signal.aborted) { + return false + } } await new Promise((resolve) => setTimeout(resolve, 150)) } 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 32979dc6460..ea57ff8c995 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 @@ -28,6 +28,7 @@ export type StructuredAgentSessionHandoffTransport = { recoverTuiOwner(record: AgentSessionRecord): Promise stopRecoveredOwner(record: AgentSessionRecord): Promise waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> + waitForTuiIdle(owner: StructuredTuiOwner, signal: AbortSignal): Promise tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise } 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 05b6ad2b316..4fd9d3154a1 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 @@ -33,12 +33,16 @@ let journal: Awaited> let coordinator: StructuredAgentSessionHandoffCoordinator let statuses: AgentSessionHandoffStatus[] let tuiIdle: boolean +let tuiTurnComplete: boolean let importFailure: Error | null let nativeAcquireFailure: Error | null let launchTui: ReturnType> let waitForTuiExit: ReturnType< typeof vi.fn > +let waitForTuiIdle: ReturnType< + typeof vi.fn +> let reproveTuiOwner: ReturnType< typeof vi.fn > @@ -188,6 +192,7 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator { }, stopRecoveredOwner, waitForTuiExit, + waitForTuiIdle, tuiStatus: () => (tuiIdle ? 'idle' : 'busy'), stopFailedTuiLaunch }, @@ -238,6 +243,7 @@ beforeEach(async () => { operations = 0 statuses = [] tuiIdle = true + tuiTurnComplete = true importFailure = null nativeAcquireFailure = null acquireNativeCalls = 0 @@ -256,7 +262,14 @@ beforeEach(async () => { }) launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) - reproveTuiOwner = vi.fn(async ({ owner }) => owner) + waitForTuiIdle = vi.fn(async () => tuiTurnComplete) + reproveTuiOwner = vi.fn(async ({ record, owner }) => { + expect(record.lease.ownerProcess).toEqual(owner.process) + if (record.lease.provenHandleLinkId !== null) { + expect(record.lease.provenHandleLinkId).toBe(owner.link.linkId) + } + return owner + }) stopFailedTuiLaunch = vi.fn(async () => undefined) acquireNativeStop = vi.fn(async () => true) coordinator = createCoordinator() @@ -275,7 +288,8 @@ describe('structured session ownership handoff', () => { { fence: 1 } ) await moveToTui() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ + const forwardRecord = store.getRecord(SESSION)! + expect(forwardRecord.lease).toMatchObject({ runtimeKind: 'tui', claimStatus: 'live', handoffStage: null @@ -284,6 +298,19 @@ describe('structured session ownership handoff', () => { expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native' })) + expect(reproveTuiOwner).toHaveBeenCalledWith({ + record: expect.objectContaining({ + lease: expect.objectContaining({ + ownerProcess: forwardRecord.lease.ownerProcess, + provenHandleLinkId: forwardRecord.lease.provenHandleLinkId + }) + }), + owner: expect.objectContaining({ + process: forwardRecord.lease.ownerProcess, + link: expect.objectContaining({ linkId: forwardRecord.lease.provenHandleLinkId }) + }) + }) + const messages = journal.snapshot().items.filter((item) => item.body.kind === 'message') expect(messages).toHaveLength(2) expect(new Set(messages.map((item) => item.itemId)).size).toBe(2) @@ -482,14 +509,17 @@ describe('structured session ownership handoff', () => { ) }) - it('queues a busy TUI from host status instead of trusting a stale journal', async () => { + it('fires a queued TUI transfer from the turn-completion waiter', async () => { await moveToTui() tuiIdle = false + tuiTurnComplete = false expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) expect(coordinator.status(SESSION).phase).toBe('queued') expect(waitForTuiExit).not.toHaveBeenCalled() - tuiIdle = true + tuiTurnComplete = true await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce()) + expect(tuiIdle).toBe(false) + expect(waitForTuiIdle).toHaveBeenCalled() }) it('re-proves the current TUI handle before accepting its exit and importing history', 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 55542d61d38..3b8ab8ff6a0 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 @@ -199,10 +199,7 @@ export class StructuredAgentSessionHandoffCoordinator { }) } - private success( - sessionId: string, - replayed: boolean - ): AgentSessionMutationResult { + private success(sessionId: string, replayed: boolean) { return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) } @@ -220,14 +217,17 @@ export class StructuredAgentSessionHandoffCoordinator { operationId: params.envelope.clientOperationId, hostLabel: this.deps.transport?.hostLabel }) + const tuiOwner = this.tuiOwners.get(sessionId) this.queue.enqueue( sessionId, - () => + (signal) => params.direction === 'to-tui' ? !activeStructuredAgentSessionTurnId( this.deps.session(sessionId).journal.snapshot().items ) - : this.tuiStatus(sessionId) === 'idle', + : tuiOwner + ? (this.deps.transport?.waitForTuiIdle(tuiOwner, signal) ?? false) + : false, () => { const next = { ...params, mode: 'now' as const } this.begin(callerKey, next, null, fingerprint) 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 8b941ca265c..4d5f20158b7 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 @@ -2,9 +2,16 @@ import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { OrcaRuntimeService } from './orca-runtime' -const readStructuredTuiProcessIdentity = vi.hoisted(() => vi.fn()) +const { probeAgentSessionProcessIdentity, readStructuredTuiProcessIdentity } = vi.hoisted(() => ({ + probeAgentSessionProcessIdentity: vi.fn(), + readStructuredTuiProcessIdentity: vi.fn() +})) vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) +vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentSessionProcessIdentity +})) const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' @@ -91,6 +98,10 @@ describe('structured TUI launch tab binding', () => { processStartTimeMs: 10, spawnToken: 'spawn-token' }) + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) const transport = internal.createStructuredAgentSessionHandoffTransport() const owner = await transport.launchTui({ @@ -122,5 +133,22 @@ describe('structured TUI launch tab binding', () => { expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( revealTerminalSession.mock.invocationCallOrder[0]! ) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-structured')! + pty.launchToken = null + const persistedRecord = { + sessionId: 'session-1', + providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], + lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId } + } as never + + await expect(transport.reproveTuiOwner({ record: persistedRecord, owner })).resolves.toEqual( + owner + ) + expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2fafd5a8c08..08d02a052b1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9172,24 +9172,39 @@ export class OrcaRuntimeService { }) return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} }, + waitForTuiIdle: async (owner, signal) => + ( + await this.waitForTerminal(owner.terminal.handle, { + condition: 'tui-idle', + signal + }) + ).satisfied, reproveTuiOwner: async ({ record, owner }) => { + const persisted = record.lease.ownerProcess + if ( + !persisted || + persisted.hostId !== owner.process.hostId || + persisted.pid !== owner.process.pid || + persisted.processStartTimeMs !== owner.process.processStartTimeMs || + persisted.spawnToken !== owner.process.spawnToken + ) { + throw new Error('The owning terminal does not match the persisted launch identity.') + } const proof = await probeAgentSessionProcessIdentity({ identity: owner.process }) if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { throw new Error('The owning Codex child process could not be re-proved.') } const head = record.providerHandleChain.at(-1) - if (head?.handle.provider !== 'codex') { + if ( + head?.handle.provider !== 'codex' || + (record.lease.provenHandleLinkId !== null && + owner.link.linkId !== record.lease.provenHandleLinkId) || + owner.link.handle.provider !== 'codex' || + owner.link.handle.threadId !== head.handle.threadId + ) { throw new Error('agent_session_identity_required') } - const tuiProof = await this.waitForStructuredTuiProof({ - handle: owner.terminal.handle, - paneKey: owner.terminal.paneKey, - threadId: head.handle.threadId, - spawnToken: owner.process.spawnToken, - codexHome: record.accountHome.path, - sessionId: record.sessionId - }) - return { ...owner, transcriptPath: tuiProof.transcriptPath } + return owner }, recoverTuiOwner: async (record) => { const identity = record.lease.ownerProcess diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index f05bd81e657..55be0effcf7 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -179,7 +179,12 @@ export default function AiVaultSessionDropLayer({ if (payload.structuredSession) { const { sessionId, workspaceId } = payload.structuredSession if (!activateStructuredAgentSessionById({ worktreeId: workspaceId, sessionId })) { - toast.error('The structured agent session is not available yet. Retry in a moment.') + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) } return true } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 46555f48205..2db8e1032ff 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -769,6 +769,9 @@ "additionalErrors": "{{count}} additional images could not be attached." } } + }, + "activateAiVaultStructuredSession": { + "unavailable": "The structured agent session is not available yet. Retry in a moment." } }, "hooks": { diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index bffcee51b8b..23a33ce93de 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' import { activateAndRevealWorktree } from './worktree-activation' import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation' import { useAppStore } from '@/store' @@ -15,7 +16,12 @@ export function activateAiVaultStructuredSession(session: AiVaultSession): boole sessionId: structured.sessionId }) ) { - toast.error('The structured agent session is not available yet. Retry in a moment.') + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) return true } if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) { From 34635aed0f7a16b5e5a10ecbc5c5e57b835b5366 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:45:21 -0700 Subject: [PATCH 5/8] fix: align structured TUI return readiness --- ...runtime-structured-tui-tab-binding.test.ts | 45 +++++++++++++++---- src/main/runtime/orca-runtime.ts | 15 +++++-- .../runtime/structured-tui-exit-proof.test.ts | 27 ++++++++++- src/main/runtime/structured-tui-exit-proof.ts | 32 ++++++++++--- 4 files changed, 100 insertions(+), 19 deletions(-) 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 4d5f20158b7..f7fd45e6c89 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 @@ -34,18 +34,32 @@ function notifier(revealTerminalSession: ReturnType) { describe('structured TUI launch tab binding', () => { it('proves the published launch tab before returning its revealed renderer binding', async () => { + let explicitStatus: { + state: 'working' | 'done' + prompt: string + receivedAt: number + stateStartedAt: number + paneKey: string + terminalHandle: string + } | null = null const revealTerminalSession = vi.fn( (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => Promise.resolve({ tabId: 'tab-renderer' }) ) - const runtime = new OrcaRuntimeService({ - getSettings: () => ({ - disabledTuiAgents: [], - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {} - }) - } as never) + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never, + undefined, + { + getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : []) + } + ) runtime.setNotifier(notifier(revealTerminalSession) as never) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }), @@ -134,6 +148,21 @@ describe('structured TUI launch tab binding', () => { revealTerminalSession.mock.invocationCallOrder[0]! ) + explicitStatus = { + state: 'working', + prompt: '', + receivedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: owner.terminal.paneKey, + terminalHandle: owner.terminal.handle + } + expect(transport.tuiStatus(owner)).toBe('busy') + await expect(transport.waitForTuiIdle(owner, new AbortController().signal)).resolves.toBe(false) + + explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdle(owner, new AbortController().signal)).resolves.toBe(true) + const pty = ( runtime as unknown as { ptysById: Map diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 08d02a052b1..a1210a41ae3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9172,13 +9172,18 @@ export class OrcaRuntimeService { }) return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} }, - waitForTuiIdle: async (owner, signal) => - ( + waitForTuiIdle: async (owner, signal) => { + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle) + if (explicit) { + return explicit.status === 'idle' + } + return ( await this.waitForTerminal(owner.terminal.handle, { condition: 'tui-idle', signal }) - ).satisfied, + ).satisfied + }, reproveTuiOwner: async ({ record, owner }) => { const persisted = record.lease.ownerProcess if ( @@ -9288,6 +9293,10 @@ export class OrcaRuntimeService { } private structuredTuiStatus(handle: string): 'idle' | 'busy' { + const explicit = this.getFreshExplicitAgentStatusForHandle(handle) + if (explicit) { + return explicit.status === 'idle' ? 'idle' : 'busy' + } const pty = this.getLivePtyForHandle(handle) if (pty) { const text = buildTerminalWaitText( diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts index 8dd74d0ef7c..3652aae1880 100644 --- a/src/main/runtime/structured-tui-exit-proof.test.ts +++ b/src/main/runtime/structured-tui-exit-proof.test.ts @@ -44,11 +44,36 @@ describe('structured TUI exit proof', () => { waitForExit: async () => { throw new Error('terminal_handle_stale') }, - probe: async () => proof + probe: async () => proof, + staleHandleProbeAttempts: 1 }) ).rejects.toThrow('terminal_handle_stale') }) + it('retries the persisted process identity when handle retirement wins the exit race', async () => { + const probe = vi + .fn() + .mockResolvedValueOnce({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + }) + .mockResolvedValueOnce({ outcome: 'pid-absent' as const }) + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe, + staleHandleProbeAttempts: 2, + staleHandleProbeIntervalMs: 0 + }) + ).resolves.toBeUndefined() + expect(probe).toHaveBeenNthCalledWith(1, identity) + expect(probe).toHaveBeenNthCalledWith(2, identity) + }) + it('preserves unrelated terminal wait failures', async () => { const probe = vi.fn() diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts index dcb5e276770..c9c733f4ef2 100644 --- a/src/main/runtime/structured-tui-exit-proof.ts +++ b/src/main/runtime/structured-tui-exit-proof.ts @@ -6,6 +6,30 @@ type ExitProofInput = { identity: AgentSessionProcessIdentity waitForExit: () => Promise probe?: (identity: AgentSessionProcessIdentity) => Promise + staleHandleProbeAttempts?: number + staleHandleProbeIntervalMs?: number +} + +const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50 +const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100 + +function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' +} + +async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise { + const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS + const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (provesRecordedProcessExited(await probe(input.identity))) { + return + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + throw staleError } export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { @@ -15,12 +39,6 @@ export async function waitForStructuredTuiExitProof(input: ExitProofInput): Prom if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { throw error } - const proof = await ( - input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) - )(input.identity) - if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { - return - } - throw error + await waitForRecordedProcessExit(input, error) } } From c0f6e427d31a79c49bc2875204283e6642cdc95e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:01:52 -0700 Subject: [PATCH 6/8] fix(native-chat): make reverse handoff transactional --- ...tructured-agent-session-handoff-reverse.ts | 54 ++++-- .../structured-agent-session-handoff-types.ts | 2 +- .../structured-agent-session-handoff.test.ts | 61 ++++++- ...agent-session-handoff-lease-transitions.ts | 24 +++ ...gent-session-handoff-record-transitions.ts | 10 ++ ...runtime-structured-tui-tab-binding.test.ts | 36 +++- src/main/runtime/orca-runtime.ts | 162 +++++++++++------- 7 files changed, 266 insertions(+), 83 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index 4e86bfcd6b9..0f166249a11 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -3,6 +3,7 @@ import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-w import { abandonStoredAgentSessionHandoffAttempt, reserveStoredAgentSessionHandoffOwner, + rollbackStoredAgentSessionHandoffPreparation, stopStoredAgentSessionOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' @@ -18,27 +19,46 @@ export async function handoffStructuredSessionToNative( let record = context.requireRecord(sessionId) let owner = context.owner(sessionId) let transcriptPath = owner?.transcriptPath - if (!retry || record.lease.handoffStage === 'preparing') { + if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) { if (record.lease.handoffStage === null) { await context.enterPreparing(record, operationId, 'to-native') } - if (!owner) { - throw new Error('The owning agent terminal could not be identified.') + record = context.requireRecord(sessionId) + try { + if (!owner) { + throw new Error('The owning agent terminal could not be identified.') + } + owner = await deps.transport!.reproveTuiOwner({ record, owner }) + context.retainOwner(sessionId, owner) + transcriptPath = owner.transcriptPath ?? transcriptPath + context.setStatus(sessionId, { + owner: 'tui', + direction: 'to-native', + phase: 'waiting-for-exit', + stage: 'preparing', + operationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) + const exited = await deps.transport!.waitForTuiExit(owner) + transcriptPath = exited.transcriptPath ?? owner.transcriptPath + } catch (error) { + const current = context.requireRecord(sessionId) + if ( + current.lease.handoffStage === 'preparing' && + current.lease.handoffOperationId === operationId && + current.lease.claimStatus === 'live' && + current.lease.ownerProcess + ) { + await rollbackStoredAgentSessionHandoffPreparation(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + now: deps.now() + }) + } + throw error } - owner = await deps.transport!.reproveTuiOwner({ record, owner }) - context.retainOwner(sessionId, owner) - transcriptPath = owner.transcriptPath ?? transcriptPath - context.setStatus(sessionId, { - owner: 'tui', - direction: 'to-native', - phase: 'waiting-for-exit', - stage: 'preparing', - operationId, - terminal: owner.terminal, - hostLabel: deps.transport?.hostLabel - }) - const exited = await deps.transport!.waitForTuiExit(owner) - transcriptPath = exited.transcriptPath ?? owner.transcriptPath record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { sessionId, expectedFence: record.lease.runtimeFence, 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 ea57ff8c995..7f4cc01e5d6 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 @@ -8,7 +8,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredTuiOwner = { - terminal: { handle: string; tabId: string; paneKey: string } + terminal: { handle: string; tabId: string; paneKey: string; ptyId: string } process: AgentSessionProcessIdentity link: AgentSessionProviderHandleLink transcriptPath?: string 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 4fd9d3154a1..1e25b0f80e9 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 @@ -143,7 +143,12 @@ async function establishNativeOwner(): Promise { function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { return { - terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'tab-tui:leaf-tui' }, + terminal: { + handle: 'term-tui', + tabId: 'tab-tui', + paneKey: 'tab-tui:leaf-tui', + ptyId: 'pty-tui' + }, process: process(spawnToken, 4200), link: link(fence, `tui-link-${fence}`), transcriptPath: join(root, 'rollout.jsonl') @@ -544,7 +549,59 @@ describe('structured session ownership handoff', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'tui', claimStatus: 'live', - handoffStage: 'preparing' + handoffStage: null + }) + }) + + it('rolls a stale reverse handle back to one durable TUI owner and retries', async () => { + await moveToTui() + waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale')) + const retryOperation = operationId() + + expect( + await submit(request('to-native', 'now', { operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + operationId: retryOperation, + error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + + expect( + await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + expect(waitForTuiExit).toHaveBeenCalledTimes(2) + }) + + it('keeps one recoverable owner when a queued reverse auto-fire fails', async () => { + await moveToTui() + tuiIdle = false + tuiTurnComplete = false + waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale')) + + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + tuiTurnComplete = true + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null }) }) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts index 4d1cae3bc8b..4f1c16189a9 100644 --- a/src/main/runtime/agent-session-handoff-lease-transitions.ts +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -36,6 +36,30 @@ export function stopAgentSessionOwnerForHandoff(args: { }) } +export function rollbackAgentSessionHandoffPreparation(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: args.now + }) +} + export function reserveAgentSessionHandoffOwner(args: { record: AgentSessionRecord expectedFence: number diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts index 6d53c2ad5fd..9588f2b61c0 100644 --- a/src/main/runtime/agent-session-handoff-record-transitions.ts +++ b/src/main/runtime/agent-session-handoff-record-transitions.ts @@ -2,6 +2,7 @@ import type { AgentSessionHandoffStage } from '../../shared/agent-session-record import { abandonAgentSessionHandoffAttempt, reserveAgentSessionHandoffOwner, + rollbackAgentSessionHandoffPreparation, stopAgentSessionOwnerForHandoff } from './agent-session-handoff-lease-transitions' import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' @@ -34,6 +35,15 @@ export function stopStoredAgentSessionOwnerForHandoff( ) } +export function rollbackStoredAgentSessionHandoffPreparation( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + rollbackAgentSessionHandoffPreparation({ ...args, record }) + ) +} + export function reserveStoredAgentSessionHandoffOwner( store: AgentSessionRecordStore, args: { 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 f7fd45e6c89..be198b31274 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 @@ -80,6 +80,16 @@ describe('structured TUI launch tab binding', () => { markLocalWorkspaceTrustedForAgent(): void waitForTerminal(): Promise waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }> + waitForStructuredTuiPtyExit(): Promise + handles: Map< + string, + { + rendererGraphEpoch: number + tabId: string + leafId: string + } + > + graphStatus: 'ready' } internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ id: WORKTREE_ID, @@ -106,6 +116,8 @@ describe('structured TUI launch tab binding', () => { return { transcriptPath: '/tmp/rollout.jsonl' } }) internal.waitForStructuredTuiProof = waitForStructuredTuiProof + const waitForStructuredTuiPtyExit = vi.fn(async () => {}) + internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit readStructuredTuiProcessIdentity.mockResolvedValue({ hostId: 'local', pid: 4243, @@ -137,10 +149,11 @@ describe('structured TUI launch tab binding', () => { } expect(owner.terminal).toMatchObject({ tabId: 'tab-renderer', - paneKey: `${reveal.tabId}:${reveal.leafId}` + paneKey: `${reveal.tabId}:${reveal.leafId}`, + ptyId: 'pty-structured' }) expect(waitForTerminal).toHaveBeenCalledWith( - owner.terminal.handle, + expect.any(String), expect.objectContaining({ condition: 'tui-idle' }) ) expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() @@ -148,6 +161,13 @@ describe('structured TUI launch tab binding', () => { revealTerminalSession.mock.invocationCallOrder[0]! ) + Object.assign(internal.handles.get(owner.terminal.handle)!, { + rendererGraphEpoch: -1, + tabId: 'tab-retired', + leafId: 'leaf-retired' + }) + internal.graphStatus = 'ready' + explicitStatus = { state: 'working', prompt: '', @@ -175,9 +195,15 @@ describe('structured TUI launch tab binding', () => { lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId } } as never - await expect(transport.reproveTuiOwner({ record: persistedRecord, owner })).resolves.toEqual( - owner - ) + const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner }) + expect(rebound.terminal).toMatchObject({ + ptyId: 'pty-structured', + tabId: owner.terminal.tabId, + paneKey: owner.terminal.paneKey + }) + expect(rebound.terminal.handle).not.toBe(owner.terminal.handle) + await transport.waitForTuiExit(rebound) + expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a1210a41ae3..523a843859d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -74,7 +74,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 } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof' @@ -9120,7 +9123,7 @@ export class OrcaRuntimeService { ) const terminal = launched.terminal try { - if (!terminal.processId || !terminal.paneKey || !terminal.tabId) { + 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, { @@ -9136,11 +9139,12 @@ export class OrcaRuntimeService { sessionId: record.sessionId }) const revealed = await this.focusTerminal(terminal.handle) - return { + return this.refreshStructuredTuiOwnerBinding({ terminal: { handle: terminal.handle, tabId: revealed.tabId, - paneKey: terminal.paneKey + paneKey: terminal.paneKey, + ptyId: terminal.ptyId }, process: await readStructuredTuiProcessIdentity({ hostId: record.location.executionHostId, @@ -9155,7 +9159,7 @@ export class OrcaRuntimeService { observedAt: Date.now() }), ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}) - } + }) } catch (error) { await this.closeTerminal(terminal.handle) await this.waitForTerminal(terminal.handle, { @@ -9166,36 +9170,29 @@ export class OrcaRuntimeService { } }, waitForTuiExit: async (owner) => { + const current = this.refreshStructuredTuiOwnerBinding(owner) await waitForStructuredTuiExitProof({ - identity: owner.process, - waitForExit: () => this.waitForTerminal(owner.terminal.handle, { condition: 'exit' }) + identity: current.process, + waitForExit: () => this.waitForStructuredTuiPtyExit(current.terminal.ptyId) }) - return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + return current.transcriptPath ? { transcriptPath: current.transcriptPath } : {} }, waitForTuiIdle: async (owner, signal) => { - const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle) - if (explicit) { - return explicit.status === 'idle' - } - return ( - await this.waitForTerminal(owner.terminal.handle, { - condition: 'tui-idle', - signal - }) - ).satisfied + return this.waitForStructuredTuiIdle(owner, signal) }, reproveTuiOwner: async ({ record, owner }) => { + const current = this.refreshStructuredTuiOwnerBinding(owner) const persisted = record.lease.ownerProcess if ( !persisted || - persisted.hostId !== owner.process.hostId || - persisted.pid !== owner.process.pid || - persisted.processStartTimeMs !== owner.process.processStartTimeMs || - persisted.spawnToken !== owner.process.spawnToken + persisted.hostId !== current.process.hostId || + persisted.pid !== current.process.pid || + persisted.processStartTimeMs !== current.process.processStartTimeMs || + persisted.spawnToken !== current.process.spawnToken ) { throw new Error('The owning terminal does not match the persisted launch identity.') } - const proof = await probeAgentSessionProcessIdentity({ identity: owner.process }) + const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { throw new Error('The owning Codex child process could not be re-proved.') } @@ -9203,13 +9200,13 @@ export class OrcaRuntimeService { if ( head?.handle.provider !== 'codex' || (record.lease.provenHandleLinkId !== null && - owner.link.linkId !== record.lease.provenHandleLinkId) || - owner.link.handle.provider !== 'codex' || - owner.link.handle.threadId !== head.handle.threadId + current.link.linkId !== record.lease.provenHandleLinkId) || + current.link.handle.provider !== 'codex' || + current.link.handle.threadId !== head.handle.threadId ) { throw new Error('agent_session_identity_required') } - return owner + return current }, recoverTuiOwner: async (record) => { const identity = record.lease.ownerProcess @@ -9225,7 +9222,7 @@ export class OrcaRuntimeService { pty.tabId && pty.paneKey ) - const handle = candidate ? this.handleByPtyId.get(candidate.ptyId) : null + const handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null if (!candidate?.tabId || !candidate.paneKey || !handle) { throw new Error('The owning agent terminal could not be recovered.') } @@ -9238,7 +9235,12 @@ export class OrcaRuntimeService { sessionId: record.sessionId }) return { - terminal: { handle, tabId: candidate.tabId, paneKey: candidate.paneKey }, + terminal: { + handle, + tabId: candidate.tabId, + paneKey: candidate.paneKey, + ptyId: candidate.ptyId + }, process: identity, link: codexProviderHandleLink({ threadId: head.handle.threadId, @@ -9250,10 +9252,11 @@ export class OrcaRuntimeService { } }, stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), - tuiStatus: (owner) => this.structuredTuiStatus(owner.terminal.handle), + tuiStatus: (owner) => this.structuredTuiStatus(owner), stopFailedTuiLaunch: async (owner) => { - await this.closeTerminal(owner.terminal.handle) - await this.waitForTerminal(owner.terminal.handle, { + const current = this.refreshStructuredTuiOwnerBinding(owner) + await this.closeTerminal(current.terminal.handle) + await this.waitForTerminal(current.terminal.handle, { condition: 'exit', timeoutMs: 15_000 }) @@ -9261,6 +9264,61 @@ export class OrcaRuntimeService { } } + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { + const pty = this.ptysById.get(owner.terminal.ptyId) + if (!pty?.connected) { + throw new Error('The owning agent terminal lost its launch identity.') + } + const handle = this.issueStructuredTuiPtyHandle(pty) + if (handle === owner.terminal.handle) { + return owner + } + return { ...owner, terminal: { ...owner.terminal, handle } } + } + + private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.syntheticTerminalHandles.add(handle) + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private async waitForStructuredTuiPtyExit(ptyId: string): Promise { + while (this.ptysById.get(ptyId)?.connected === true) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + private async waitForStructuredTuiIdle( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise { + const deadline = Date.now() + 250 + while (!signal.aborted && Date.now() < deadline) { + if (this.structuredTuiStatus(owner) === 'idle') { + return true + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return false + } + private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { const identity = record.lease.ownerProcess if (!identity) { @@ -9292,39 +9350,24 @@ export class OrcaRuntimeService { throw new Error('The recovered owner process did not exit.') } - private structuredTuiStatus(handle: string): 'idle' | 'busy' { - const explicit = this.getFreshExplicitAgentStatusForHandle(handle) + private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { + const pty = this.ptysById.get(owner.terminal.ptyId) + const paneKey = pty?.paneKey ?? owner.terminal.paneKey + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) if (explicit) { return explicit.status === 'idle' ? 'idle' : 'busy' } - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const text = buildTerminalWaitText( - pty.pty.tailBuffer, - pty.pty.tailPartialLine, - pty.pty.preview - ) + if (pty?.connected) { + const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) return hasStructuredTuiIdleEvidence({ blocked: detectTerminalWaitBlockedReason(text) !== null, - status: pty.pty.lastAgentStatus, - statusObservedLive: pty.pty.lastAgentStatusObservedLive + status: pty.lastAgentStatus, + statusObservedLive: pty.lastAgentStatusObservedLive }) ? 'idle' : 'busy' } - try { - const { leaf } = this.getLiveLeafForHandle(handle) - const text = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - return hasStructuredTuiIdleEvidence({ - blocked: detectTerminalWaitBlockedReason(text) !== null, - status: leaf.lastAgentStatus, - statusObservedLive: leaf.lastAgentStatusObservedLive - }) - ? 'idle' - : 'busy' - } catch { - return 'busy' - } + return 'busy' } private async waitForStructuredTuiProof(input: { @@ -17623,11 +17666,14 @@ export class OrcaRuntimeService { return true } - private getFreshExplicitAgentStatusForHandle(handle: string): { + private getFreshExplicitAgentStatusForHandle( + handle: string, + paneKeyOverride?: string | null + ): { status: NonNullable updatedAt: number } | null { - const paneKey = this.getPaneKeyForTerminalHandle(handle) + const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle) const now = Date.now() let bestStatus: NonNullable | null = null let bestUpdatedAt = -1 From c18e81c8d2e152f6a39117c0894bd145ff729ac0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:46:33 -0700 Subject: [PATCH 7/8] fix(native-chat): clear sticky handoff recovery --- ...structured-agent-session-coalescer.test.ts | 36 +++++ ...e-structured-agent-session-reducer.test.ts | 85 ++++++++++ ...tructured-agent-session-handoff-restart.ts | 48 +++++- .../structured-agent-session-handoff.test.ts | 33 ++++ .../structured-agent-session-host-handoff.ts | 19 ++- ...ctured-agent-session-host-runtime-state.ts | 6 +- .../structured-agent-session-host-tabs.ts | 15 ++ ...structured-agent-session-host-test-data.ts | 63 ++++++++ .../structured-agent-session-host-types.ts | 10 ++ .../structured-agent-session-host.test.ts | 147 +++++++----------- .../structured-agent-session-host.ts | 85 ++++------ ...ctured-agent-session-lease-renewer.test.ts | 7 +- .../structured-agent-session-lease-renewer.ts | 4 +- .../structured-agent-session-read-restore.ts | 13 +- ...tructured-agent-session-restart-restore.ts | 3 +- .../structured-agent-session-subscribers.ts | 14 ++ .../rpc/methods/structured-agent-session.ts | 6 +- .../structured-agent-session-coalescer.ts | 6 +- 18 files changed, 433 insertions(+), 167 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts diff --git a/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts b/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts index da03cdb5202..dffc0bb45d7 100644 --- a/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts @@ -38,6 +38,42 @@ describe('mobile structured event coalescer', () => { vi.useRealTimers() }) + it('retains ownership projection while merging a reconnect batch', () => { + vi.useFakeTimers() + const seen: AgentSessionSubscribeEvent[] = [] + const coalescer = createMobileStructuredEventCoalescer((event) => seen.push(event)) + coalescer.push({ + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 1 }, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 3, + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' } + } + }) + coalescer.push(batch(2, 'continued')) + vi.advanceTimersByTime(48) + + expect(seen).toEqual([ + expect.objectContaining({ + type: 'batch', + fence: 3, + handoff: expect.objectContaining({ owner: 'tui', phase: 'idle' }) + }) + ]) + vi.useRealTimers() + }) + it('flushes pending text before a lifecycle row', () => { vi.useFakeTimers() const seen: AgentSessionSubscribeEvent[] = [] diff --git a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts index a647a5013fb..31a1a589470 100644 --- a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire' import { EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, reduceMobileStructuredAgentSession @@ -25,6 +26,90 @@ function snapshot(epoch: string, items: AgentJournalRenderItem[]) { } describe('mobile structured session reducer', () => { + it('projects every stage across transfer, recovery, and return', () => { + const handoffs: AgentSessionHandoffStatus[] = [ + { owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null }, + { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'forward' + }, + { + owner: 'none', + direction: 'to-tui', + phase: 'switching', + stage: 'old-owner-stopped', + operationId: 'forward' + }, + { + owner: 'none', + direction: 'to-tui', + phase: 'switching', + stage: 'new-owner-proving', + operationId: 'forward' + }, + { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' } + }, + { + owner: 'none', + direction: null, + phase: 'failed', + stage: 'manual-recovery', + operationId: null, + error: { message: 'Recovery required', recoverableOwner: 'none' } + }, + { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-2', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' } + }, + { + owner: 'tui', + direction: 'to-native', + phase: 'switching', + stage: 'preparing', + operationId: 'reverse' + }, + { owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null } + ] + let state = EMPTY_MOBILE_STRUCTURED_AGENT_SESSION + const projected: Array> = [] + + for (const [index, handoff] of handoffs.entries()) { + state = reduceMobileStructuredAgentSession(state, { + type: 'event', + event: { + ...snapshot('epoch-a', []), + fence: index + 1, + snapshot: { + ...snapshot('epoch-a', []).snapshot, + cursor: { epoch: 'epoch-a', sequence: index } + }, + handoff + } + }) + projected.push({ + owner: state.handoff!.owner, + phase: state.handoff!.phase, + stage: state.handoff!.stage + }) + } + + expect(projected).toEqual(handoffs.map(({ owner, phase, stage }) => ({ owner, phase, stage }))) + expect(state.handoff).toMatchObject({ owner: 'native', phase: 'idle', stage: null }) + }) + it('boots from a bounded tail page and retains its live resume cursor', () => { const state = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, { type: 'tail-page', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts index 85dde4a990b..79038dc058b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -32,7 +32,10 @@ export async function restoreStructuredAgentSessionHandoff( const initial = input.requireRecord(sessionId) const operationId = initial.lease.handoffOperationId const initialStage = initial.lease.handoffStage - if (initialStage === 'recovering' || initialStage === 'manual-recovery') { + if ( + (initialStage === 'recovering' || initialStage === 'manual-recovery') && + !canRestoreLiveTuiOwner(initial) + ) { if (operationId) { await input.deps.store.recordOperationOutcome({ operationId, @@ -93,6 +96,10 @@ export async function restoreStructuredAgentSessionHandoff( } async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise { + if (canRestoreLiveTuiOwner(record)) { + await restoreRecoverableLiveTui(input, record) + return + } if (!input.deps.transport) { if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') { throw new Error('Agent TUI handoff recovery is unavailable on this host.') @@ -119,6 +126,45 @@ async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Pr } } +export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean { + return ( + (record.lease.handoffStage === 'recovering' || + record.lease.handoffStage === 'manual-recovery') && + record.lease.runtimeKind === 'tui' && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null && + record.lease.handoffOperationId === null + ) +} + +async function restoreRecoverableLiveTui( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + if (!input.deps.transport) { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + const recovered = await input.deps.transport.recoverTuiOwner(record) + const owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) + await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + input.retainOwner(record.sessionId, owner) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport.hostLabel + }) +} + async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise { const owner = await input.deps.transport!.recoverTuiOwner(record) input.retainOwner(record.sessionId, owner) 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 1e25b0f80e9..53b4c03d1a2 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 @@ -480,6 +480,34 @@ describe('structured session ownership handoff', () => { expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) }) + it('clears stale recovery after re-proving the live TUI owner', async () => { + await moveToTui() + const record = store.getRecord(SESSION)! + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + phase: 'idle', + stage: null, + terminal: { handle: 'term-tui' } + }) + expect(reproveTuiOwner).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + it('rejects cancellation once a queued handoff is no longer pending', async () => { expect( await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' })) @@ -649,6 +677,11 @@ describe('structured session ownership handoff', () => { error: { recoverableOwner: 'none' } }) 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' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index 9131b02a306..de77ba98b48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -1,11 +1,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' import { journalIdentityFor } from './structured-agent-session-attach' +import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { - StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession -} from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -20,6 +19,18 @@ type HostHandoffAccess = { export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator +export async function refreshRecoverableStructuredHandoffStatus( + handoff: StructuredAgentSessionHostHandoff, + store: StructuredAgentSessionHostDeps['store'], + sessionId: string +) { + const record = store.getRecord(sessionId) + if (record && canRestoreLiveTuiOwner(record)) { + await handoff.restore(sessionId) + } + return handoff.status(sessionId) +} + export function createStructuredAgentSessionHostHandoff( deps: StructuredAgentSessionHostDeps, host: HostHandoffAccess diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index c1df075905f..e2f74f719f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -11,11 +11,15 @@ export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer - constructor(private readonly deps: StructuredAgentSessionHostDeps) { + constructor( + private readonly deps: StructuredAgentSessionHostDeps, + onLeaseRenewed?: (record: AgentSessionRecord) => Promise + ) { this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ store: deps.store, probe: (record) => this.probeRecord(record), now: () => deps.now?.() ?? Date.now(), + ...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}), onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts new file mode 100644 index 00000000000..c2947891c43 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -0,0 +1,15 @@ +export type StructuredAgentSessionTab = { + sessionId: string + workspaceId: string + agent: 'codex' +} + +export function listStructuredAgentSessionTabs( + sessions: ReadonlyMap +): StructuredAgentSessionTab[] { + return [...sessions.entries()].map(([sessionId, session]) => ({ + sessionId, + workspaceId: session.params.location.workspaceId, + agent: 'codex' + })) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts new file mode 100644 index 00000000000..69e6d029d60 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -0,0 +1,63 @@ +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +export const HOST_TEST_NOW = 1_800_000_000_000 +export const HOST_TEST_SESSION = 'session-alpha' +export const HOST_TEST_THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +export const HOST_TEST_LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +let operations = 0 + +export function resetHostTestOperationIds(): void { + operations = 0 +} + +export function hostTestOperationId(): string { + operations += 1 + return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}` +} + +export function hostTestMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export function hostTestAttachParams( + expectedRuntimeFence: number | null, + overrides: Partial = {} +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence, + payloadFingerprint: '0'.repeat(64) + }, + location: HOST_TEST_LOCATION, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: HOST_TEST_THREAD }, + ...overrides + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts new file mode 100644 index 00000000000..e0aa61067c7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -0,0 +1,10 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +export type StructuredAgentSessionCaller = { callerKey: string } + +export type StructuredAgentSessionHostSession = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 59321c2ecf5..39206eeace0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,19 +1,11 @@ -// The host against a real record store and a real journal, with only the -// provider adapter stubbed — admission, idempotency, and the journal writes are -// exactly the ones that ship. - import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -28,35 +20,20 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { attachFingerprintFields } from './structured-agent-session-attach' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { StructuredAgentSessionHost } from './structured-agent-session-host' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const LOCATION: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -let operations = 0 - -/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ -function operationId(): string { - operations += 1 - return `${NOW}-${operations.toString(16).padStart(32, '0')}` -} - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - function envelope( method: string, fields: Record, @@ -64,9 +41,7 @@ function envelope( ): AgentSessionMutationEnvelope { return { sessionId: SESSION, - clientOperationId: operationId(), - // A mutation names the fence it believes it is writing under; only attach - // may leave it null. + clientOperationId: hostTestOperationId(), expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, @@ -78,44 +53,11 @@ function envelope( } function attachParams(overrides: Partial = {}): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: null, - payloadFingerprint: '0'.repeat(64) - }, - location: LOCATION, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: THREAD }, - ...overrides - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: params.envelope.sessionId, - fields: attachFingerprintFields(params) - }) - } - } + return hostTestAttachParams(null, overrides) } -/** `ensure`: the same transition as create, from a fence the client already holds. */ function ensureParams(fence: number): AgentSessionAttachParams { - return attachParams({ - envelope: { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '0'.repeat(64) - } - }) + return hostTestAttachParams(fence) } let root: string @@ -185,7 +127,7 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) - operations = 0 + resetHostTestOperationIds() ordinal = 0 acquire = vi.fn(async ({ fence }) => ({ process: { @@ -197,8 +139,6 @@ beforeEach(async () => { link: { linkId: `link-${fence}`, handle: { provider: 'codex', threadId: THREAD }, - // A restarted host re-proves the thread it inherited; only the first - // owner of a session may claim to have created it. origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW @@ -409,7 +349,7 @@ describe('attach', () => { describe('send', () => { it('writes the submission before dispatching and resolves it accepted', async () => { await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const result = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body @@ -427,7 +367,7 @@ describe('send', () => { it('settles a thrown dispatch as unknown, never as a rejection', async () => { await attach() dispatch.mockRejectedValueOnce(new Error('socket closed')) - const body = message('add a retry') + const body = hostTestMessage('add a retry') const result = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body @@ -437,7 +377,7 @@ describe('send', () => { it('replays a retried send from the journal without dispatching twice', async () => { await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) const retry = await host.send(CALLER, params) @@ -450,7 +390,7 @@ describe('send', () => { dispatch .mockRejectedValueOnce(new Error('socket closed')) .mockImplementationOnce(async () => accepted()) - const body = message('possibly delivered') + const body = hostTestMessage('possibly delivered') const params = { envelope: envelope('agentSession.send', { body }), body } const first = await host.send(CALLER, params) @@ -472,7 +412,7 @@ describe('send', () => { it('refuses a stale fence and hands back the current one', async () => { const record = await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const result = await host.send(CALLER, { envelope: envelope( 'agentSession.send', @@ -489,7 +429,7 @@ describe('send', () => { it('does not let a refused call leave a ledger row that replays past the fence', async () => { const record = await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const params = { envelope: envelope( 'agentSession.send', @@ -507,7 +447,7 @@ describe('send', () => { }) it('refuses any mutation against a session this host has not attached', async () => { - const body = message('add a retry') + const body = hostTestMessage('add a retry') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) @@ -738,7 +678,7 @@ describe('restart', () => { it('restores durable journals for read-only history without acquiring a provider', async () => { await attach() - const body = message('persisted conversation') + const body = hostTestMessage('persisted conversation') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) acquire.mockClear() @@ -753,13 +693,40 @@ describe('restart', () => { expect(acquire).not.toHaveBeenCalled() }) + it('clears stale TUI recovery when restart successfully reacquires the native owner', async () => { + await attach() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + runtimeKind: 'tui', + handoffStage: 'manual-recovery' + } + })) + await reboot(async () => ({ outcome: 'pid-absent' })) + acquire.mockClear() + + await host.restoreReadableSessions() + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + owner: 'native', + phase: 'idle', + stage: null + }) + }) + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) - // Nothing proved the previous process dead, so the lease routes to manual - // recovery rather than letting a second writer in behind it. expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } @@ -776,7 +743,6 @@ describe('restart', () => { await reboot(probe) await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') - // One unlucky startup must not strand the session for this host's lifetime. const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) expect(reattached).toMatchObject({ ok: true }) expect(probe).toHaveBeenCalledTimes(2) @@ -792,7 +758,7 @@ describe('subscribe', () => { sessionId: SESSION, emit: (event) => events.push(event) }) - const body = message('add a retry') + const body = hostTestMessage('add a retry') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) expect(events[0]?.type).toBe('snapshot') @@ -807,7 +773,7 @@ describe('subscribe', () => { it('resumes from a client cursor with only the rows it missed', async () => { await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const first = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body @@ -823,10 +789,9 @@ describe('subscribe', () => { emit: (event) => events.push(event), cursor: first.cursor }) - // Nothing new since that cursor: a resume is silent, not a redundant replay. - expect(events).toHaveLength(0) + expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } }) - const second = message('and a timeout') + const second = hostTestMessage('and a timeout') await host.send(CALLER, { envelope: envelope('agentSession.send', { body: second }), body: second @@ -850,7 +815,7 @@ describe('subscribe', () => { sessionId: SESSION, emit: (event) => events.push(event) }) - const body = message('survive subscriber failure') + const body = hostTestMessage('survive subscriber failure') const result = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index bd8223bf8f5..cbf8408f33e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,11 +1,5 @@ -// The structured agent-session host: one place where the lease, the journal, -// and the provider adapter meet. -// -// Every mutating call takes the same route — recompute the fingerprint, admit -// through the durable operation ledger, check the lease, then act — so no -// method can grow its own admission rules. Calls against one session are -// serialized; the journal's own queue orders writes, but admission has to be -// single-file too or two clients could both read one prompt as pending. +// Structured agent-session host: where the lease, journal, and provider adapter meet. +// Mutations share one durable admission path and serialize per session. import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' @@ -31,7 +25,6 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' @@ -58,38 +51,28 @@ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-sess import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' import { createStructuredAgentSessionHostHandoff, + refreshRecoverableStructuredHandoffStatus, type StructuredAgentSessionHostHandoff } from './structured-agent-session-host-handoff' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' - -export type StructuredAgentSessionCaller = { - /** Stable per-client identity; scopes the operation ledger and records who - * answered a prompt. */ - callerKey: string -} +import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' export type StructuredAgentSessionHostDeps = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter journalRoot: string - /** Key id the host's claims are minted under. */ claimKeyId: string probeOwner?: (record: AgentSessionRecord) => Promise mintSpawnToken?: () => string now?: () => number - /** A journal append the provider streamed that could not be written. Unset - * drops it: the alternative is throwing inside the provider's notification - * callback, which would take the connection down over one lost row. */ onEventSinkError?: (input: { sessionId: string; error: unknown }) => void handoffTransport?: StructuredAgentSessionHandoffTransport } -export type StructuredAgentSessionHostSession = { - journal: AgentSessionJournal - params: AgentSessionAttachParams - fence: number -} - export class StructuredAgentSessionHost { private readonly sessions = new Map() private readonly subscribers = new AgentSessionSubscribers() @@ -99,7 +82,9 @@ export class StructuredAgentSessionHost { private readonly handoffs: StructuredAgentSessionHostHandoff constructor(readonly deps: StructuredAgentSessionHostDeps) { - this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps) + this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) => + this.restoreRenewedHandoff(record.sessionId) + ) this.reconcileLeases = createRestartReconciler({ store: deps.store, probe: (record) => this.runtimeState.probeRecord(record), @@ -118,24 +103,14 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId) - } + hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean { return agent === 'codex' && (this.deps.adapter.supportsLocation?.(location) ?? false) } - listSessionTabs(): { - sessionId: string - workspaceId: string - agent: 'codex' - }[] { - return [...this.sessions.entries()].map(([sessionId, session]) => ({ - sessionId, - workspaceId: session.params.location.workspaceId, - agent: 'codex' as const - })) + listSessionTabs() { + return listStructuredAgentSessionTabs(this.sessions) } async restoreReadableSessions(): Promise { @@ -161,10 +136,14 @@ export class StructuredAgentSessionHost { return this.tasks.serialize(sessionId, task) } - /** - * `create` and `ensure` in one: a null expected fence means the session must - * not exist yet, any other value is a compare-and-swap against the lease. - */ + private restoreRenewedHandoff(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + if (this.sessions.has(sessionId)) { + await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + } + }) + } + attach( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams @@ -206,9 +185,6 @@ export class StructuredAgentSessionHost { } else { this.subscribers.publish(sessionId, attached.journal) } - // Bound last: everything the provider streamed while the journal was - // opening lands after the attach result the client is about to read, - // never interleaved with it. eventSink.bind({ journal: attached.journal, fence, @@ -216,8 +192,6 @@ export class StructuredAgentSessionHost { }) } }) - // A refused attach that never reached a journal leaves the sink holding - // writes nothing will ever drain. if (!attached.ok && !this.sessions.has(sessionId)) { eventSink.close() this.runtimeState.discardEventSink(sessionId) @@ -227,16 +201,12 @@ export class StructuredAgentSessionHost { return this.tasks.trackAttach(attaching) } - /** Settles every row the provider streamed. Callers that must read the - * journal immediately after provider activity await this first. */ flushStreamedEvents(sessionId: string): Promise { return this.runtimeState.flushEventSink(sessionId) } - /** Settles final provider rows after every child has stopped producing them. */ async flushAllStreamedEvents(): Promise { this.runtimeState.stopLeaseRenewal() - // An acquired provider can still be opening its journal; bind before draining. await this.tasks.drainAttaches() await this.runtimeState.flushAllEventSinks() } @@ -317,16 +287,13 @@ export class StructuredAgentSessionHost { return this.handoffs.request(caller.callerKey, params) } - handoffStatus(sessionId: string): AgentSessionHandoffStatus { + async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) - return this.handoffs.status(sessionId) + return this.serialize(sessionId, () => + refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + ) } - /** - * Paged read. Throws `agent_session_ownership_unknown` for a session this host - * has not attached — a reset would tell the client to reload something that - * does not exist here. - */ history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request) const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index e0dd07c344c..17bfff24c8b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -97,16 +97,21 @@ describe('structured agent-session lease renewal', () => { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] })) + const onRenewed = vi.fn() const renewer = new StructuredAgentSessionLeaseRenewer({ store, probe, - now: () => NOW + 10_000 + now: () => NOW + 10_000, + onRenewed }) await renewer.renewNow() expect(probe).toHaveBeenCalledOnce() expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + expect(onRenewed).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-renewal' }) + ) }) it('stops extending the lease when child proof is no longer sufficient', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index f9f9b7b6773..dcec74d1d1a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -16,6 +16,7 @@ export class StructuredAgentSessionLeaseRenewer { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise now: () => number + onRenewed?: (record: AgentSessionRecord) => Promise onError?: (input: { sessionId: string; error: unknown }) => void intervalMs?: number } @@ -60,12 +61,13 @@ export class StructuredAgentSessionLeaseRenewer { private async renewRecord(record: AgentSessionRecord): Promise { try { - await this.input.store.renewLease({ + const renewed = await this.input.store.renewLease({ sessionId: record.sessionId, fence: record.lease.runtimeFence, childProbe: await this.input.probe(record), now: this.input.now() }) + await this.input.onRenewed?.(renewed) } catch (error) { this.input.onError?.({ sessionId: record.sessionId, error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 82d3116c9ad..01a2578bcbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,4 +1,7 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { loadJournal } from '../agent-session-journal/journal-open' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' @@ -49,7 +52,11 @@ export async function restoreStructuredAgentSessionRead( export function attachParamsForRecord( record: AgentSessionRecord, - input: { clientOperationId: string; expectedRuntimeFence: number } + input: { + clientOperationId: string + expectedRuntimeFence: number + runtimeKind?: AgentSessionOwnerRuntimeKind + } ): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { @@ -62,7 +69,7 @@ export function attachParamsForRecord( provider: 'codex', agent: 'codex', accountHome: record.accountHome, - runtimeKind: record.lease.runtimeKind + runtimeKind: input.runtimeKind ?? record.lease.runtimeKind } return { ...params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 082f79ca194..9a45c53f5bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -31,7 +31,8 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { (await input.resume( attachParamsForRecord(current, { clientOperationId: input.operationId(), - expectedRuntimeFence: current.lease.runtimeFence + expectedRuntimeFence: current.lease.runtimeFence, + runtimeKind: 'native' }) )) ) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 9785ac91a34..a5262de99b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -156,6 +156,20 @@ export class AgentSessionSubscribers { return } if (since.rows.length === 0) { + if (handoff) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: snapshot.cursor, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: subscriber.fence, + handoff + }) + } return } const projected = projectJournalBatch({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 3501f9a0a65..1225eeb7ea2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -12,10 +12,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { - StructuredAgentSessionCaller, - StructuredAgentSessionHost -} from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { AttachParams, diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index e1af3549097..51bc7fa0537 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -31,7 +31,11 @@ function mergeBatch( items: [...items.values()], removedItemIds: [...new Set([...left.batch.removedItemIds, ...right.batch.removedItemIds])], submissions: [...submissions.values()] - } + }, + ...(right.fence !== undefined || left.fence !== undefined + ? { fence: right.fence ?? left.fence } + : {}), + ...(right.handoff || left.handoff ? { handoff: right.handoff ?? left.handoff } : {}) } } From 7316ac3926caca813cb9cb94e50197508101fe6f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:35:12 -0700 Subject: [PATCH 8/8] fix(native-chat): complete mobile reverse after TUI exit --- ...tured-agent-session-handoff-flow-runner.ts | 14 ++- ...tructured-agent-session-handoff-restart.ts | 30 ++++- ...tructured-agent-session-handoff-reverse.ts | 9 +- ...structured-agent-session-handoff-status.ts | 11 ++ .../structured-agent-session-handoff-types.ts | 5 +- .../structured-agent-session-handoff.test.ts | 103 ++++++++++++++++-- .../structured-agent-session-handoff.ts | 47 ++++---- ...agent-session-handoff-lease-transitions.ts | 36 ++++++ ...gent-session-handoff-record-transitions.ts | 12 +- ...runtime-structured-tui-tab-binding.test.ts | 17 ++- src/main/runtime/orca-runtime.ts | 48 +++++--- .../methods/structured-agent-session.test.ts | 18 +++ 12 files changed, 283 insertions(+), 67 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts index 875d7c92204..bfd1b1f0e05 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -29,15 +29,16 @@ export class StructuredAgentSessionHandoffFlowRunner { params: AgentSessionHandoffRequest turnId: string | null fingerprint: string + tuiAlreadyExited?: boolean }): void { - const { callerKey, params, turnId, fingerprint } = input + const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input const sessionId = params.envelope.sessionId this.input.operationGuard.start(sessionId, { callerKey, operationId: params.envelope.clientOperationId, fingerprint }) - const flow = this.run(params, turnId) + const flow = this.run(params, turnId, tuiAlreadyExited) .then(() => this.input.deps.store.recordOperationOutcome({ callerKey, @@ -58,7 +59,11 @@ export class StructuredAgentSessionHandoffFlowRunner { void flow.finally(() => this.active.delete(flow)) } - private run(params: AgentSessionHandoffRequest, turnId: string | null): Promise { + private run( + params: AgentSessionHandoffRequest, + turnId: string | null, + tuiAlreadyExited: boolean + ): Promise { const sessionId = params.envelope.sessionId return this.input.deps.schedule(sessionId, async () => { if (turnId && params.mode === 'stop-turn') { @@ -72,7 +77,8 @@ export class StructuredAgentSessionHandoffFlowRunner { : handoffStructuredSessionToNative( this.input.flowContext(), params, - params.action === 'retry' + params.action === 'retry', + tuiAlreadyExited )) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts index 79038dc058b..ab21fe630ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -1,12 +1,15 @@ +import { randomUUID } from 'node:crypto' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionHandoffRequest, AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' import { abandonStoredAgentSessionHandoffAttempt, setStoredAgentSessionHandoffStage, - stopStoredAgentSessionOwnerForHandoff + stopStoredAgentSessionOwnerForHandoff, + stopStoredRecoveringTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' @@ -126,6 +129,21 @@ async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Pr } } +async function recoverUnavailableTuiAsNative( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + await input.deps.transport!.stopRecoveredOwner(record) + const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now()) + const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean { return ( (record.lease.handoffStage === 'recovering' || @@ -144,8 +162,14 @@ async function restoreRecoverableLiveTui( if (!input.deps.transport) { throw new Error('Agent TUI handoff recovery is unavailable on this host.') } - const recovered = await input.deps.transport.recoverTuiOwner(record) - const owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) + let owner: StructuredTuiOwner + try { + const recovered = await input.deps.transport.recoverTuiOwner(record) + owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) + } catch { + await recoverUnavailableTuiAsNative(input, record) + return + } await setStoredAgentSessionHandoffStage(input.deps.store, { sessionId: record.sessionId, fence: record.lease.runtimeFence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index 0f166249a11..8596813d041 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -11,7 +11,8 @@ import type { StructuredAgentSessionHandoffFlowContext } from './structured-agen export async function handoffStructuredSessionToNative( context: StructuredAgentSessionHandoffFlowContext, params: AgentSessionHandoffRequest, - retry: boolean + retry: boolean, + tuiAlreadyExited = false ): Promise { const { deps } = context const sessionId = params.envelope.sessionId @@ -28,8 +29,10 @@ export async function handoffStructuredSessionToNative( if (!owner) { throw new Error('The owning agent terminal could not be identified.') } - owner = await deps.transport!.reproveTuiOwner({ record, owner }) - context.retainOwner(sessionId, owner) + if (!tuiAlreadyExited) { + owner = await deps.transport!.reproveTuiOwner({ record, owner }) + context.retainOwner(sessionId, owner) + } transcriptPath = owner.transcriptPath ?? transcriptPath context.setStatus(sessionId, { owner: 'tui', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts index 62e46fa2df7..f159a9a51c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -4,6 +4,17 @@ import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function structuredTuiStatus( + owner: StructuredTuiOwner | undefined, + transport: StructuredAgentSessionHandoffTransport | undefined +): 'idle' | 'busy' { + return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy' +} export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus { if ( 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 7f4cc01e5d6..bc554c2b0ca 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 @@ -28,7 +28,10 @@ export type StructuredAgentSessionHandoffTransport = { recoverTuiOwner(record: AgentSessionRecord): Promise stopRecoveredOwner(record: AgentSessionRecord): Promise waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> - waitForTuiIdle(owner: StructuredTuiOwner, signal: AbortSignal): Promise + waitForTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise } 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 53b4c03d1a2..46f8785b027 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 @@ -33,15 +33,15 @@ let journal: Awaited> let coordinator: StructuredAgentSessionHandoffCoordinator let statuses: AgentSessionHandoffStatus[] let tuiIdle: boolean -let tuiTurnComplete: boolean +let tuiReadiness: 'idle' | 'exited' | null let importFailure: Error | null let nativeAcquireFailure: Error | null let launchTui: ReturnType> let waitForTuiExit: ReturnType< typeof vi.fn > -let waitForTuiIdle: ReturnType< - typeof vi.fn +let waitForTuiIdleOrExit: ReturnType< + typeof vi.fn > let reproveTuiOwner: ReturnType< typeof vi.fn @@ -197,7 +197,7 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator { }, stopRecoveredOwner, waitForTuiExit, - waitForTuiIdle, + waitForTuiIdleOrExit, tuiStatus: () => (tuiIdle ? 'idle' : 'busy'), stopFailedTuiLaunch }, @@ -248,7 +248,7 @@ beforeEach(async () => { operations = 0 statuses = [] tuiIdle = true - tuiTurnComplete = true + tuiReadiness = 'idle' importFailure = null nativeAcquireFailure = null acquireNativeCalls = 0 @@ -267,7 +267,7 @@ beforeEach(async () => { }) launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) - waitForTuiIdle = vi.fn(async () => tuiTurnComplete) + waitForTuiIdleOrExit = vi.fn(async () => tuiReadiness) reproveTuiOwner = vi.fn(async ({ record, owner }) => { expect(record.lease.ownerProcess).toEqual(owner.process) if (record.lease.provenHandleLinkId !== null) { @@ -508,6 +508,40 @@ describe('structured session ownership handoff', () => { }) }) + it('recovers native ownership when a stressed TUI recovery process is gone', async () => { + await moveToTui() + const record = store.getRecord(SESSION)! + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + reproveTuiOwner.mockRejectedValueOnce(new Error('The owning terminal was killed.')) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION, + lease: expect.objectContaining({ runtimeKind: 'tui' }) + }) + ) + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'native', + phase: 'idle', + stage: null + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + it('rejects cancellation once a queued handoff is no longer pending', async () => { expect( await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' })) @@ -545,14 +579,34 @@ describe('structured session ownership handoff', () => { it('fires a queued TUI transfer from the turn-completion waiter', async () => { await moveToTui() tuiIdle = false - tuiTurnComplete = false + tuiReadiness = null expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) expect(coordinator.status(SESSION).phase).toBe('queued') expect(waitForTuiExit).not.toHaveBeenCalled() - tuiTurnComplete = true + tuiReadiness = 'idle' await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce()) expect(tuiIdle).toBe(false) - expect(waitForTuiIdle).toHaveBeenCalled() + expect(waitForTuiIdleOrExit).toHaveBeenCalled() + }) + + it('completes a mobile-originated queued reverse after the owning TUI exits', async () => { + await moveToTui() + tuiIdle = false + tuiReadiness = null + const reverse = request('to-native', 'after-turn') + + expect(await coordinator.request('mobile-device-a', reverse)).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + tuiReadiness = 'exited' + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + + expect(reproveTuiOwner).not.toHaveBeenCalled() + expect(waitForTuiExit).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) }) it('re-proves the current TUI handle before accepting its exit and importing history', async () => { @@ -613,12 +667,12 @@ describe('structured session ownership handoff', () => { it('keeps one recoverable owner when a queued reverse auto-fire fails', async () => { await moveToTui() tuiIdle = false - tuiTurnComplete = false + tuiReadiness = null waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale')) expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) expect(coordinator.status(SESSION).phase).toBe('queued') - tuiTurnComplete = true + tuiReadiness = 'idle' await waitForPhase('failed') expect(coordinator.status(SESSION)).toMatchObject({ @@ -659,6 +713,33 @@ describe('structured session ownership handoff', () => { expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native') }) + it('keeps a recoverable native owner after repeated transfers and a dead TUI relaunch', async () => { + for (let cycle = 0; cycle < 2; cycle += 1) { + await moveToTui() + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + } + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + ...makeTuiOwner(fence, spawnToken), + link: link(fence + 1, 'dead-relaunch') + })) + + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'native', + error: { recoverableOwner: 'native' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + it('requires manual recovery when a failed TUI launch cannot be proven stopped', async () => { launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ ...makeTuiOwner(fence, spawnToken), 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 3b8ab8ff6a0..6a15bbdd285 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 @@ -27,6 +27,7 @@ import { failedStructuredHandoffStatus, idleStructuredHandoffStatus, structuredSessionHasPendingPrompt, + structuredTuiStatus, switchingStructuredHandoffStatus } from './structured-agent-session-handoff-status' import type { @@ -58,9 +59,7 @@ export class StructuredAgentSessionHandoffCoordinator { ) } - async drain(): Promise { - await this.flowRunner.drain() - } + drain = (): Promise => this.flowRunner.drain() async restore(sessionId: string): Promise { await restoreStructuredAgentSessionHandoff( @@ -159,8 +158,11 @@ export class StructuredAgentSessionHandoffCoordinator { const turnId = activeStructuredAgentSessionTurnId( this.deps.session(record.sessionId).journal.snapshot().items ) + const tuiOwner = this.tuiOwners.get(record.sessionId) const busy = - expectedOwner === 'native' ? turnId !== null : this.tuiStatus(record.sessionId) !== 'idle' + expectedOwner === 'native' + ? turnId !== null + : structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle' if (busy && params.mode === 'now') { return this.refuseAdmitted( callerKey, @@ -209,6 +211,7 @@ export class StructuredAgentSessionHandoffCoordinator { 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, @@ -220,17 +223,20 @@ export class StructuredAgentSessionHandoffCoordinator { const tuiOwner = this.tuiOwners.get(sessionId) this.queue.enqueue( sessionId, - (signal) => - params.direction === 'to-tui' - ? !activeStructuredAgentSessionTurnId( - this.deps.session(sessionId).journal.snapshot().items - ) - : tuiOwner - ? (this.deps.transport?.waitForTuiIdle(tuiOwner, signal) ?? false) - : false, + 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) + this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited') } ) } @@ -239,29 +245,24 @@ export class StructuredAgentSessionHandoffCoordinator { callerKey: string, params: AgentSessionHandoffRequest, turnId: string | null, - fingerprint: string + fingerprint: string, + tuiAlreadyExited = false ): void { this.flowRunner.begin({ callerKey, params, turnId, - fingerprint + fingerprint, + tuiAlreadyExited }) } - private tuiStatus(sessionId: string): 'idle' | 'busy' { - const owner = this.tuiOwners.get(sessionId) - return owner ? (this.deps.transport?.tuiStatus(owner) ?? 'busy') : 'busy' - } - private flowContext(): StructuredAgentSessionHandoffFlowContext { return createStructuredHandoffFlowContext({ deps: this.deps, owner: (sessionId) => this.tuiOwners.get(sessionId), retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner), - releaseOwner: (sessionId) => { - this.tuiOwners.delete(sessionId) - }, + releaseOwner: (sessionId) => void this.tuiOwners.delete(sessionId), setStatus: (sessionId, status) => this.setStatus(sessionId, status), enterPreparing: (record, operationId, direction) => this.enterPreparing(record, operationId, direction), diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts index 4f1c16189a9..255102cde33 100644 --- a/src/main/runtime/agent-session-handoff-lease-transitions.ts +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -60,6 +60,42 @@ export function rollbackAgentSessionHandoffPreparation(args: { }) } +export function stopRecoveringTuiOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + (record.lease.handoffStage !== 'recovering' && + record.lease.handoffStage !== 'manual-recovery') || + record.lease.runtimeKind !== 'tui' || + record.lease.handoffOperationId !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: record.lease.runtimeFence + 1, + handoffStage: 'old-owner-stopped', + handoffOperationId: args.operationId, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'recovery proved TUI process exit', + observedAt: args.now + } + }) +} + export function reserveAgentSessionHandoffOwner(args: { record: AgentSessionRecord expectedFence: number diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts index 9588f2b61c0..8d27065d4a0 100644 --- a/src/main/runtime/agent-session-handoff-record-transitions.ts +++ b/src/main/runtime/agent-session-handoff-record-transitions.ts @@ -3,7 +3,8 @@ import { abandonAgentSessionHandoffAttempt, reserveAgentSessionHandoffOwner, rollbackAgentSessionHandoffPreparation, - stopAgentSessionOwnerForHandoff + stopAgentSessionOwnerForHandoff, + stopRecoveringTuiOwnerForHandoff } from './agent-session-handoff-lease-transitions' import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' import { @@ -44,6 +45,15 @@ export function rollbackStoredAgentSessionHandoffPreparation( ) } +export function stopStoredRecoveringTuiOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopRecoveringTuiOwnerForHandoff({ ...args, record }) + ) +} + export function reserveStoredAgentSessionHandoffOwner( store: AgentSessionRecordStore, args: { 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 be198b31274..551886c16cd 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 @@ -177,15 +177,19 @@ describe('structured TUI launch tab binding', () => { terminalHandle: owner.terminal.handle } expect(transport.tuiStatus(owner)).toBe('busy') - await expect(transport.waitForTuiIdle(owner, new AbortController().signal)).resolves.toBe(false) + await expect( + transport.waitForTuiIdleOrExit(owner, new AbortController().signal) + ).resolves.toBeNull() explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } expect(transport.tuiStatus(owner)).toBe('idle') - await expect(transport.waitForTuiIdle(owner, new AbortController().signal)).resolves.toBe(true) + await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( + 'idle' + ) const pty = ( runtime as unknown as { - ptysById: Map + ptysById: Map } ).ptysById.get('pty-structured')! pty.launchToken = null @@ -205,5 +209,12 @@ describe('structured TUI launch tab binding', () => { await transport.waitForTuiExit(rebound) expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + + explicitStatus = null + pty.connected = false + await expect( + transport.waitForTuiIdleOrExit(rebound, new AbortController().signal) + ).resolves.toBe('exited') + await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined() }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 523a843859d..d2dedf812b9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9170,15 +9170,11 @@ export class OrcaRuntimeService { } }, waitForTuiExit: async (owner) => { - const current = this.refreshStructuredTuiOwnerBinding(owner) - await waitForStructuredTuiExitProof({ - identity: current.process, - waitForExit: () => this.waitForStructuredTuiPtyExit(current.terminal.ptyId) - }) - return current.transcriptPath ? { transcriptPath: current.transcriptPath } : {} + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} }, - waitForTuiIdle: async (owner, signal) => { - return this.waitForStructuredTuiIdle(owner, signal) + waitForTuiIdleOrExit: async (owner, signal) => { + return this.waitForStructuredTuiIdleOrExit(owner, signal) }, reproveTuiOwner: async ({ record, owner }) => { const current = this.refreshStructuredTuiOwnerBinding(owner) @@ -9254,12 +9250,17 @@ export class OrcaRuntimeService { stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), tuiStatus: (owner) => this.structuredTuiStatus(owner), stopFailedTuiLaunch: async (owner) => { - const current = this.refreshStructuredTuiOwnerBinding(owner) - await this.closeTerminal(current.terminal.handle) - await this.waitForTerminal(current.terminal.handle, { - condition: 'exit', - timeoutMs: 15_000 - }) + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + const current = this.refreshStructuredTuiOwnerBinding(owner) + try { + await this.closeTerminal(current.terminal.handle) + } catch (error) { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + throw error + } + } + } + await this.waitForStructuredTuiOwnerExit(owner) } } } @@ -9305,18 +9306,29 @@ export class OrcaRuntimeService { } } - private async waitForStructuredTuiIdle( + private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { + await waitForStructuredTuiExitProof({ + identity: owner.process, + waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) + }) + } + + private async waitForStructuredTuiIdleOrExit( owner: StructuredTuiOwner, signal: AbortSignal - ): Promise { + ): Promise<'idle' | 'exited' | null> { const deadline = Date.now() + 250 while (!signal.aborted && Date.now() < deadline) { + if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { + await this.waitForStructuredTuiOwnerExit(owner) + return 'exited' + } if (this.structuredTuiStatus(owner) === 'idle') { - return true + return 'idle' } await new Promise((resolve) => setTimeout(resolve, 50)) } - return false + return null } private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index b4e4a2fa9de..e241af0cbb0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -93,6 +93,8 @@ function hostStub(): StructuredAgentSessionHost { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ ok: true, replayed: false })), + handoffStatus: vi.fn(async () => ({ owner: 'tui', phase: 'queued' })), readOptions: vi.fn(async () => ({ models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], current: { model: 'gpt-live' } @@ -311,6 +313,22 @@ describe('method routing', () => { 'question' ]) }) + + it('routes a mobile reverse request through the host-owned handoff coordinator', async () => { + const params = { + envelope: envelope(), + direction: 'to-native', + mode: 'after-turn', + action: 'start' + } + const response = await call('agentSession.requestHandoff', params, { + ...STRUCTURED_CLIENT, + clientId: 'mobile-device-a' + }) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith({ callerKey: 'mobile-device-a' }, params) + }) }) describe('mobile image provenance', () => {