From 4f20e4b4b030ce36d6fb3dcdc9eb01ad6c5a3597 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:16:34 -0700 Subject: [PATCH 01/49] fix(native-chat): decide Claude restart delivery by the message's own frame id A send left unconfirmed by a crash was judged by looking for its text in the transcript after the last completed turn. A message before that point (the point moves on with every later turn) or one starting with a harness-looking tag was missed, declared not delivered, hidden, and Retry sent it again. The hand-over now records the uuid Orca stamps on the Claude frame, which Claude keeps as the transcript record's uuid. A restart looks that id up in the whole transcript. Sends recorded by older builds carry no id and are never declared undelivered; they stay unconfirmed. Linear: STA-7416, STA-7453 --- .../claude-structured-dispatch-content.ts | 35 ++++- ...laude-structured-dispatch-identity.test.ts | 116 +++++++++++++++ src/main/claude/claude-structured-dispatch.ts | 10 +- .../claude-structured-history-window.test.ts | 92 +++++++++++- .../claude-structured-history-window.ts | 131 +++++++++++++++-- .../claude-structured-session-adapter.ts | 18 ++- .../journal-crash-boundary.test.ts | 24 ++- .../journal-dispatch-reducer.ts | 6 + .../journal-restart-reconciliation.test.ts | 137 +++++++++++++++--- .../journal-restart-reconciliation.ts | 18 ++- .../journal-row-builders.ts | 3 + .../journal-row-schema.ts | 3 + .../journal-store-contracts.ts | 6 +- .../journal-submission-reconciler.ts | 91 ++++++++++-- ...-agent-session-acquisition-options.test.ts | 9 +- ...structured-agent-session-adapter-router.ts | 4 + .../structured-agent-session-adapter.ts | 8 + ...gent-session-attach-reconciliation.test.ts | 19 ++- ...ssion-restart-interruption-test-harness.ts | 13 +- .../structured-agent-session-turns.ts | 25 +++- src/shared/agent-session-journal-types.ts | 3 + 21 files changed, 692 insertions(+), 79 deletions(-) create mode 100644 src/main/claude/claude-structured-dispatch-identity.test.ts diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 035b0a594926..6430cec07564 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -1,7 +1,10 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { open } from 'node:fs/promises' import { extname } from 'node:path' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import { agentSessionFailureFact, @@ -257,3 +260,31 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { const key = `v1:${summary.slice(0, 128)}:${digest.digest('hex')}` return key.slice(0, MAX_REPLAY_CONTENT_KEY_BYTES) } + +/** + * Claude records a user frame under the frame's own `uuid` (measured against the real CLI), so the + * id is chosen before the hand-over is journaled. A command is left to content: what Claude writes + * for one is not the prompt Orca sent. + */ +export function mintClaudeDispatchIdentity( + providerSessionId: string, + body: AgentJournalMessageItem +): AgentJournalItemIdentity | null { + const prompt = body.blocks.flatMap((block) => + block.type === 'text' && block.text ? [block.text] : [] + ) + if (claudeDispatchInvokesSlashCommand([{ type: 'text', text: prompt.join('\n') }])) { + return null + } + return { provider: 'claude', sessionId: providerSessionId, uuid: randomUUID() } +} + +/** The recorded id's uuid when it names this session; a stale one names a record Claude never wrote. */ +export function claudeRecordedFrameUuid( + providerSessionId: string, + identity: AgentJournalItemIdentity | undefined +): string | undefined { + return identity?.provider === 'claude' && identity.sessionId === providerSessionId + ? identity.uuid + : undefined +} diff --git a/src/main/claude/claude-structured-dispatch-identity.test.ts b/src/main/claude/claude-structured-dispatch-identity.test.ts new file mode 100644 index 000000000000..1e78a574e45c --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-identity.test.ts @@ -0,0 +1,116 @@ +// The id a restart decides delivery by: chosen before the hand-over, durable with it, and the +// exact id the frame Claude records carries. + +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 { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { handOverSubmission } from '../native-chat/agent-session-wire/structured-agent-session-turns' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { + claudeRecordedFrameUuid, + mintClaudeDispatchIdentity +} from './claude-structured-dispatch-content' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-session', leafUuid: null } +} + +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-dispatch-identity-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the Claude dispatch identity', () => { + it('names a plain prompt by a fresh uuid in the live session', () => { + const identity = mintClaudeDispatchIdentity( + 'provider-session', + userMessage([{ type: 'text', text: 'ship it' }]) + ) + expect(identity).toMatchObject({ provider: 'claude', sessionId: 'provider-session' }) + }) + + it('leaves a slash command to content, since Claude records no prompt for it', () => { + expect( + mintClaudeDispatchIdentity( + 'provider-session', + userMessage([{ type: 'text', text: '/compact' }]) + ) + ).toBeNull() + }) + + it('stamps the recorded uuid on the frame, and only for the session it names', async () => { + const send = vi.fn().mockResolvedValue(undefined) + const session = sessionFor(send) + const recorded = { provider: 'claude', sessionId: 'provider-session', uuid: 'frame-1' } as const + const sentUuid = claudeRecordedFrameUuid(session.providerSessionId, recorded) + + await dispatchClaudeTurn(session, { + body: userMessage([{ type: 'text', text: 'ship it' }]), + sentUuid + }) + + expect(send.mock.calls[0]?.[0]).toMatchObject({ uuid: 'frame-1' }) + expect(claudeRecordedFrameUuid('another-session', recorded)).toBeUndefined() + }) +}) + +describe('handing a send over under its identity', () => { + it('records the id before dispatch and keeps it through a crash', async () => { + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + await journal.appendSubmission({ + clientMessageId: 'cm-1', + payloadFingerprint: 'fingerprint', + body: userMessage([{ type: 'text', text: 'ship it' }]), + fence: 1, + handoverRecorded: true + }) + const identity = { provider: 'claude', sessionId: 'provider-session', uuid: 'frame-1' } as const + let recordedAtDispatch: string | undefined + const dispatch = vi.fn(async () => { + recordedAtDispatch = journal.submissions()[0]?.handedOverItemId + return { state: 'admitted' as const } + }) + const adapter: Pick = { + dispatch, + mintDispatchIdentity: () => identity + } + + await handOverSubmission( + { + sessionId: 'session-1', + journal, + fence: 1, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A hand-over calls only these two members. + adapter: adapter as StructuredAgentSessionAdapter + }, + journal.submissions()[0]! + ) + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ providerIdentity: identity })) + expect(recordedAtDispatch).toBe(agentJournalItemKey(identity)) + await journal.close() + const reopened = await journals.open({ identity: IDENTITY, journalDir: root }) + await reopened.markPendingSubmissionsUnknown(2) + expect(reopened.submissions()[0]).toMatchObject({ + dispatchState: 'unknown', + handedOverItemId: agentJournalItemKey(identity) + }) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 220542fab91b..9a4e71a69da5 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -239,7 +239,13 @@ function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchReject export async function dispatchClaudeTurn( session: ClaudeSession, - input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, + input: { + clientMessageId?: string + body: AgentJournalMessageItem + requestedAt?: number + /** The frame's uuid, when the caller already recorded it. */ + sentUuid?: string + }, beforeDispatch?: () => Promise ): Promise { let content: unknown[] @@ -258,7 +264,7 @@ export async function dispatchClaudeTurn( // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) - const sentUuid = randomUUID() + const sentUuid = input.sentUuid ?? randomUUID() const arm = () => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index 851831b86f19..c23df513aca8 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -9,6 +9,8 @@ import { structuredAgentSessionSendBody } from '../../shared/structured-agent-se import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { claudeProviderHistoryWindowFromJsonl, resolveClaudeProviderHistoryWindow @@ -157,7 +159,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { it('reports no window and an inconsistent boundary without a durable anchor', () => { const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') - expect(read(contents, null)).toEqual({ + expect(read(contents, null)).toMatchObject({ items: [], boundaryConsistent: false, turnInFlight: false @@ -191,7 +193,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { }) it('keeps the boundary consistent and the window empty when nothing followed the anchor', () => { - expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toEqual({ + expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toMatchObject({ items: [], boundaryConsistent: true, turnInFlight: false @@ -349,3 +351,89 @@ describe('a crash between Claude saving a prompt and Orca recording its echo', ( expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo']) }) }) + +describe('a send handed over under its own frame id', () => { + const sentKey = agentJournalItemKey({ + provider: 'claude', + sessionId: PROVIDER_SESSION, + uuid: 'sent' + }) + + function handedOver(text: string): AgentJournalSubmission { + return { + clientMessageId: 'cm-sent', + fence: 1, + payloadFingerprint: sendFingerprint(text), + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 0, + resolvedAt: null, + handedOverItemId: sentKey + } + } + + function verdict(contents: string, previousLeafUuid: string | null, text: string) { + const [outcome] = reconcileSubmissions({ + history: read(contents, previousLeafUuid), + submissions: [handedOver(text)] + }) + return outcome + } + + it('is accepted when Claude holds it before the anchor a later turn advanced', () => { + // The send stayed unconfirmed while a later turn completed and moved the durable leaf. + const contents = jsonl( + [ + ANCHOR, + prompt('sent', 'anchor', 'ship it'), + prompt('later', 'sent', 'and then this'), + prompt('leaf', 'later', 'latest') + ], + 'leaf' + ) + + expect(read(contents, 'later').items.map((item) => item.providerItemId)).toEqual(['leaf']) + expect(verdict(contents, 'later', 'ship it')).toMatchObject({ + outcome: 'accepted', + providerItemId: sentKey + }) + }) + + it('is accepted when its text starts with a harness tag', () => { + const text = 'why does this parse wrong' + const contents = jsonl([ANCHOR, prompt('sent', 'anchor', [{ type: 'text', text }])], 'sent') + + expect(verdict(contents, 'anchor', text)).toMatchObject({ + outcome: 'accepted', + providerItemId: sentKey + }) + }) + + it('is accepted before any turn completed, when there is no anchor at all', () => { + const contents = jsonl([prompt('sent', null, 'ship it')], 'sent') + + expect(verdict(contents, null, 'ship it')).toMatchObject({ outcome: 'accepted' }) + }) + + it('is not delivered when the whole file lacks it', () => { + const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'something else')], 'u-1') + + expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'rejected' }) + }) + + it('stays unknown when a line of the file does not parse', () => { + const contents = `${jsonl([ANCHOR], 'anchor')}{"type":"user","uuid":"sent"` + + expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'unknown' }) + }) + + it('stays unknown when a record under another id holds the same text', () => { + const contents = jsonl([ANCHOR, prompt('minted', 'anchor', 'ship it')], 'minted') + + expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ + outcome: 'unknown', + reason: 'ambiguous_match' + }) + }) +}) diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index 00ae86920251..83ab2705d9de 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -14,13 +14,20 @@ // of those makes absence meaningless. Failing it reports an inconsistent // boundary rather than an empty window, because the two decide opposite things. +import { createReadStream } from 'node:fs' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { ProviderHistoryItem, - ProviderHistoryWindow + ProviderHistoryWindow, + ProviderRecordedHistory } from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../shared/agent-session-journal-item-key' +import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' import { claudeContentBlocks } from '../native-chat/transcript-record-blocks' import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -86,9 +93,9 @@ function rawTextParts(content: unknown): string[] | null { } /** - * A user record Orca could itself have submitted. Everything the harness injects - * is excluded, because a fingerprint computed over machinery would claim a - * history slot the user's message should have had. + * A plain-text user record Orca could itself have submitted. Rows the harness + * marks as its own are excluded, because a fingerprint computed over machinery + * would claim a history slot the user's message should have had. * * Attachments are excluded too, and deliberately: the transcript keeps a pasted * image as base64 with no path, so the `image-ref` block the submission was @@ -124,15 +131,15 @@ function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null if (rawTexts === null || rawTexts.length !== blocks.length) { return null } - const preservedBlocks = blocks.map((block, index) => ({ - ...block, - text: rawTexts[index]! - })) - const [first] = preservedBlocks - if (first?.type !== 'text' || isKnownHarnessInjectedUserTurnText(first.text)) { - return null - } - return preservedBlocks + return blocks.map((block, index) => ({ ...block, text: rawTexts[index]! })) +} + +/** The anchored window's prompts: harness-shaped text is left out, so machinery cannot claim a + * send by content. A send handed over under an id never reaches this filter. */ +function claudeUserPromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null { + const blocks = claudePromptBlocks(record) + const [first] = blocks ?? [] + return first?.type === 'text' && !isKnownHarnessInjectedUserTurnText(first.text) ? blocks : null } /** @@ -171,7 +178,7 @@ function createWindowCollector(input: HistoryWindowInput) { return { byUuid, onAncestorRecord } function onAncestorRecord(record: TranscriptRecord, uuid: string): void { - const blocks = claudePromptBlocks(record) + const blocks = claudeUserPromptBlocks(record) if (!blocks) { return } @@ -190,6 +197,63 @@ function createWindowCollector(input: HistoryWindowInput) { } } +/** + * Every user record in the file, wherever it sits: a send handed over under an id is found by + * that id, so no anchor is needed. Absence counts only when every line of this session's file + * was read; any line that does not parse may be the missing record. + */ +function createRecordedCollector(input: HistoryWindowInput) { + const itemIds = new Set() + const itemIdsByFingerprint = new Map() + let whole = true + return { add, finish } + + function add(line: string): void { + if (!line.trim()) { + return + } + let record: unknown + try { + record = JSON.parse(line) + } catch { + whole = false + return + } + const uuid = stringField(record, 'uuid') + if (!uuid || !record || typeof record !== 'object' || Array.isArray(record)) { + return + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A non-array object, checked above. + const row = record as TranscriptRecord + if (row.type !== 'user') { + return + } + const sessionId = stringField(row, 'sessionId') ?? input.providerSessionId + const itemId = agentJournalItemKey({ provider: 'claude', sessionId, uuid }) + itemIds.add(itemId) + const blocks = claudePromptBlocks(row) + if (blocks) { + const fingerprint = promptFingerprint(input.sessionId, blocks) + itemIdsByFingerprint.set(fingerprint, [ + ...(itemIdsByFingerprint.get(fingerprint) ?? []), + itemId + ]) + } + } + + function finish(): ProviderRecordedHistory { + const provable = whole + return { + itemIds, + itemIdsByFingerprint, + provesAbsenceOf: (itemId) => { + const identity = provable ? parseAgentJournalItemKey(itemId) : null + return identity?.provider === 'claude' && identity.sessionId === input.providerSessionId + } + } + } +} + /** The chain is leaf first; a history window is oldest first. */ function windowFromChain( chain: readonly string[], @@ -208,6 +272,16 @@ function windowFromChain( export function claudeProviderHistoryWindowFromJsonl( input: HistoryWindowInput & { contents: string } +): ProviderHistoryWindow { + const recorded = createRecordedCollector(input) + for (const line of input.contents.split('\n')) { + recorded.add(line) + } + return { ...anchoredWindowFromJsonl(input), recorded: recorded.finish() } +} + +function anchoredWindowFromJsonl( + input: HistoryWindowInput & { contents: string } ): ProviderHistoryWindow { const ancestryAnchorUuid = input.previousLeafUuid if (!ancestryAnchorUuid) { @@ -251,13 +325,38 @@ export async function resolveClaudeProviderHistoryWindow(input: { if (!transcriptPath) { return null } - return readClaudeProviderHistoryWindow({ + const read = { transcriptPath, providerSessionId: handle.sessionId, previousLeafUuid: handle.leafUuid, sessionId: input.identity.sessionId, turnInFlight: input.hasLiveSession - }) + } + const window = await readClaudeProviderHistoryWindow(read) + return { ...window, recorded: await readClaudeRecordedHistory(read) } +} + +async function readClaudeRecordedHistory( + input: HistoryWindowInput & { transcriptPath: string } +): Promise { + const recorded = createRecordedCollector(input) + try { + const stream = createReadStream(input.transcriptPath) + for await (const { line } of splitTranscriptStreamLines( + stream, + MAX_HISTORY_WINDOW_RECORD_BYTES + )) { + recorded.add(line) + } + } catch (error) { + console.warn('[claude-history-window] transcript unreadable; sends stay unconfirmed:', { + transcriptPath: input.transcriptPath, + sessionId: input.sessionId, + error + }) + return null + } + return recorded.finish() } export async function readClaudeProviderHistoryWindow( diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae9..80d563159ed0 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,6 +7,10 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { + claudeRecordedFrameUuid, + mintClaudeDispatchIdentity +} from './claude-structured-dispatch-content' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' @@ -189,8 +193,18 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) } - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) + mintDispatchIdentity: NonNullable = ( + input + ) => { + const session = this.sessions.get(input.sessionId) + return session ? mintClaudeDispatchIdentity(session.providerSessionId, input.body) : null + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { + const session = this.session(input.sessionId) + const sentUuid = claudeRecordedFrameUuid(session.providerSessionId, input.providerIdentity) + return dispatchClaudeTurn(session, { ...input, sentUuid }, input.beforeDispatch) + } compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 6cc267a90547..7b94fbe1561b 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -22,7 +22,8 @@ import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, type ProviderHistoryItem, - type ProviderHistoryWindow + type ProviderHistoryWindow, + type ProviderRecordedHistory } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' @@ -83,6 +84,11 @@ function history(input: { } } +/** A history read whole, holding exactly these item keys. */ +function wholeHistory(itemIds: string[]): ProviderRecordedHistory { + return { itemIds: new Set(itemIds), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => true } +} + function window( items: ProviderHistoryItem[], overrides: Partial = {} @@ -235,12 +241,19 @@ describe('crash between provider accept and journal commit', () => { body: userMessage('never landed'), fence: 1 }) + // Handed over under an id the provider would have recorded it by. + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'pending', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) const [outcome] = reconcileSubmissions({ submissions: restarted.submissions(), - history: window([]) + history: window([], { recorded: wholeHistory([]) }) }) expect(outcome).toEqual({ clientMessageId: 'cm_1', @@ -397,7 +410,8 @@ describe('reconciliation matching', () => { } ]) }) - expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) + // Absent from a window that starts at the last completed turn, not at this send. + expect(outcome).toMatchObject({ outcome: 'unknown', reason: 'no_dispatch_identity' }) }) it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { @@ -408,7 +422,7 @@ describe('reconciliation matching', () => { ]) }) expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), + expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'unknown' }), expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) ]) }) @@ -438,7 +452,7 @@ describe('reconciliation matching', () => { }) expect(outcomes).toEqual([ expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), - expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) + expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'unknown' }) ]) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 470ed3dfcb24..694414904a9c 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -39,6 +39,12 @@ export function applyJournalDispatchRow( submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts + // Each hand-over names its own frame; a row without one leaves the send decided by content. + if (typeof row.handedOverItemId === 'string' && row.handedOverItemId) { + submission.handedOverItemId = row.handedOverItemId + } else { + delete submission.handedOverItemId + } } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 4428e13953e1..e7e06d800ef4 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -13,7 +13,11 @@ import type { } from '../../../shared/agent-session-journal-types' import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' -import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' +import type { + ProviderHistoryItem, + ProviderHistoryWindow, + ProviderRecordedHistory +} from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -70,18 +74,55 @@ function window( return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } } -/** A host that wrote the submission row and died before learning its outcome. */ -async function reopenAfterCrash( +/** The whole transcript, holding these records; each is `[uuid, text]`. */ +function wholeHistory(records: [string, string][]): ProviderRecordedHistory { + const itemIdsByFingerprint = new Map() + for (const [uuid, text] of records) { + const key = agentJournalItemKey(claudeIdentity(uuid)) + itemIdsByFingerprint.set(digestPayload(text), [ + ...(itemIdsByFingerprint.get(digestPayload(text)) ?? []), + key + ]) + } + return { + itemIds: new Set(records.map(([uuid]) => agentJournalItemKey(claudeIdentity(uuid)))), + itemIdsByFingerprint, + provesAbsenceOf: () => true + } +} + +/** Appends a send and, when `handedOverAs` names one, hands it over under that frame id. */ +async function appendSend( + journal: Awaited>, + clientMessageId: string, + handedOverAs: string | null, body: AgentJournalMessageItem = userMessage('deploy the thing'), text = 'deploy the thing' -) { - const journal = await open() +): Promise { await journal.appendSubmission({ - clientMessageId: 'cm_1', + clientMessageId, payloadFingerprint: digestPayload(text), body, fence: 1 }) + if (handedOverAs) { + await journal.resolveDispatch({ + clientMessageId, + state: 'pending', + providerIdentity: claudeIdentity(handedOverAs), + fence: 1 + }) + } +} + +/** A host that wrote the submission row and died before learning its outcome. */ +async function reopenAfterCrash( + body: AgentJournalMessageItem = userMessage('deploy the thing'), + text = 'deploy the thing', + handedOverAs: string | null = null +) { + const journal = await open() + await appendSend(journal, 'cm_1', handedOverAs, body, text) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) return restarted @@ -114,12 +155,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { }) it('settles a message provably absent from history as rejected: not_delivered', async () => { - const journal = await reopenAfterCrash() + const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, - history: window([]) + history: window([], { recorded: wholeHistory([['uuid-other', 'something else']]) }) }) expect(settled).toEqual(['cm_1']) @@ -217,19 +258,16 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { fence: 1 }) - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) + await appendSend(journal, 'cm_1', 'uuid-sent') const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) await reconcileJournalSubmissionsAgainstHistory({ journal: restarted, fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) + history: window([history('uuid-old', 'deploy the thing')], { + recorded: wholeHistory([['uuid-old', 'deploy the thing']]) + }) }) expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') @@ -249,19 +287,16 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { providerIdentity: claudeIdentity('uuid-old'), fence: 1 }) - await journal.appendSubmission({ - clientMessageId: 'cm_new', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) + await appendSend(journal, 'cm_new', 'uuid-new') const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) await reconcileJournalSubmissionsAgainstHistory({ journal: restarted, fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) + history: window([history('uuid-old', 'deploy the thing')], { + recorded: wholeHistory([['uuid-old', 'deploy the thing']]) + }) }) expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ @@ -295,4 +330,62 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'unknown' ]) }) + it('accepts a send found by its frame id even where the anchored window cannot see it', async () => { + // The durable anchor moved past the send, so the window after it is empty. + const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { recorded: wholeHistory([['uuid-sent', 'deploy the thing']]) }) + }) + + expect(settled).toEqual(['cm_1']) + expect(journal.submissions()[0]).toMatchObject({ + dispatchState: 'accepted', + providerItemId: agentJournalItemKey(claudeIdentity('uuid-sent')) + }) + }) + + it('never rejects a send recorded without a frame id, which older builds wrote', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { recorded: wholeHistory([]) }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('leaves a send unknown when an unclaimed record holds its text under another id', async () => { + // What a provider build that chose its own id would leave behind. + const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { recorded: wholeHistory([['uuid-minted', 'deploy the thing']]) }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('leaves a send unknown when the history was not read whole', async () => { + const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { + recorded: { ...wholeHistory([]), provesAbsenceOf: () => false } + }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 7b36fc88dc63..48e5f4ef54f0 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -2,9 +2,9 @@ // // Runs once per journal open, after the crash boundary has already settled every // survivor to `unknown`. It only ever narrows that answer: `accepted` when the -// provider's own history holds the message, `rejected` when a boundary we can -// vouch for proves it never arrived. Anything the reconciler leaves `unknown` -// is left exactly as the crash boundary wrote it. +// provider's own history holds the message, `rejected` when a history read whole +// lacks the id the send was handed over under. Anything the reconciler leaves +// `unknown` is left exactly as the crash boundary wrote it. // // Nothing here dispatches. A `rejected` submission becomes re-sendable only // through the user's Retry, which rotates the client message id; Orca still @@ -73,9 +73,19 @@ function unseenHistory( committed.add(submission.providerItemId) } } + const recorded = history.recorded return { ...history, - items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) + items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))), + recorded: recorded && { + ...recorded, + itemIdsByFingerprint: new Map( + [...recorded.itemIdsByFingerprint].map(([fingerprint, itemIds]) => [ + fingerprint, + itemIds.filter((itemId) => !committed.has(itemId)) + ]) + ) + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index ee56f28f8a25..e795579d09b1 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -85,6 +85,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'pending' && input.providerIdentity + ? { handedOverItemId: agentJournalItemKey(input.providerIdentity) } + : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 786d422876cb..1de92a812118 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -93,6 +93,9 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact + /** On `pending`: the provider item key this send was handed over under, for a provider that + * records it under the id Orca chose. No `v` bump: older readers ignore the key. */ + handedOverItemId?: string } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2210efb253b..48f49fca35ad 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -31,7 +31,11 @@ export type ResolveDispatchInput = { recovered?: true } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } - | { state: 'pending' } + | { + state: 'pending' + /** The id the provider will record this send under, for one that adopts a client id. */ + providerIdentity?: AgentJournalItemIdentity + } /** `reason` is what released clients print, `rejection` what newer ones read: both from * `agentSessionFailureWords`, never written by hand. */ | ({ state: 'rejected' } & AgentJournalDispatchRejection) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 4647ba1e0e37..999b7114f6f0 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -4,10 +4,14 @@ // what it TRIED to send but not whether the provider took it. Every surviving // `pending` becomes `unknown` and is then matched against provider history. // -// Matching is by identity only — an echoed client message id, else a provider -// item id the journal already adopted, else the payload fingerprint when it -// picks out exactly one unclaimed item. Never by text equality: the same -// question asked twice is two messages, and collapsing them silently loses one. +// A send handed over under an id the provider adopts is decided by that id alone, +// looked up across the whole history: present means delivered, absent from a +// history read whole means not. Older sends carry no such id and fall back to +// the anchored window — an echoed client message id, else a provider item id the +// journal already adopted, else the payload fingerprint when it picks out +// exactly one unclaimed item. That window is not scoped to the send, so absence +// from it proves nothing and those sends stay `unknown`. Never by text equality: +// the same question asked twice is two messages, and collapsing them loses one. // // Orca never re-sends on the user's behalf. An unresolved submission stays // `unknown` — a displayed state meaning "delivery unconfirmed", neither sent nor @@ -17,7 +21,10 @@ import type { AgentJournalItemIdentity, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { @@ -44,6 +51,19 @@ export type ProviderHistoryWindow = { boundaryConsistent: boolean /** The provider reports a turn still running: absence proves nothing yet. */ turnInFlight: boolean + /** Every user item the history holds, not only those after the anchor. Absent when the + * provider has no whole-history read; sends handed over under an id then stay `unknown`. */ + recorded?: ProviderRecordedHistory | null +} + +export type ProviderRecordedHistory = { + /** Journal keys of every user item in the history. */ + itemIds: ReadonlySet + /** Keys of the plain-text ones, by payload fingerprint. A provider build that recorded a send + * under an id of its own leaves only its content behind, so a same-content item vetoes absence. */ + itemIdsByFingerprint: ReadonlyMap + /** The history was read whole and is where an item with this key would have been recorded. */ + provesAbsenceOf: (itemId: string) => boolean } export type SubmissionReconciliation = @@ -62,6 +82,7 @@ export type SubmissionUnknownReason = | 'history_boundary_inconsistent' | 'turn_in_flight' | 'ambiguous_match' + | 'no_dispatch_identity' /** * Resolve every unsettled submission against provider history. @@ -77,13 +98,21 @@ export function reconcileSubmissions(input: { const unsettled = input.submissions.filter( (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' ) + const recorded = input.history.recorded + const identified = new Set( + unsettled.flatMap(({ handedOverItemId: id }) => (id && recorded?.itemIds.has(id) ? [id] : [])) + ) + const legacy = unsettled.filter((submission) => !submission.handedOverItemId) + const items = input.history.items.filter( + (item) => !identified.has(agentJournalItemKey(item.identity)) + ) const claimed = new Set() const matched = new Map() const ambiguous = new Set() claimBy( - unsettled, - input.history.items, + legacy, + items, claimed, matched, (submission, item) => @@ -93,8 +122,8 @@ export function reconcileSubmissions(input: { submission.providerItemId === agentJournalItemKey(item.identity) ) claimBy( - unsettled, - input.history.items, + legacy, + items, claimed, matched, (submission, item) => @@ -105,7 +134,7 @@ export function reconcileSubmissions(input: { // the first identical submission would make the later one look absent even // though either submission could be the delivered one. const submissionsByFingerprint = new Map() - for (const submission of unsettled) { + for (const submission of legacy) { if (matched.has(submission.clientMessageId) || !submission.payloadFingerprint) { continue } @@ -114,7 +143,7 @@ export function reconcileSubmissions(input: { submissionsByFingerprint.set(submission.payloadFingerprint, sameFingerprint) } for (const [fingerprint, fingerprintSubmissions] of submissionsByFingerprint) { - const candidates = input.history.items.filter( + const candidates = items.filter( (item) => !claimed.has(item.providerItemId) && item.payloadFingerprint === fingerprint ) if (fingerprintSubmissions.length === 1 && candidates.length === 1) { @@ -133,7 +162,37 @@ export function reconcileSubmissions(input: { } } - return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history)) + return unsettled.map((submission) => + submission.handedOverItemId + ? resolveByIdentity(submission, submission.handedOverItemId, identified, input.history) + : resolveOne(submission, matched, ambiguous, input.history) + ) +} + +function resolveByIdentity( + submission: AgentJournalSubmission, + itemId: string, + identified: ReadonlySet, + history: ProviderHistoryWindow +): SubmissionReconciliation { + const { clientMessageId } = submission + if (identified.has(itemId)) { + const identity = parseAgentJournalItemKey(itemId) + return identity + ? { clientMessageId, outcome: 'accepted', providerItemId: itemId, identity } + : { clientMessageId, outcome: 'unknown', reason: 'history_boundary_inconsistent' } + } + if (history.turnInFlight) { + return { clientMessageId, outcome: 'unknown', reason: 'turn_in_flight' } + } + if (!history.recorded?.provesAbsenceOf(itemId)) { + return { clientMessageId, outcome: 'unknown', reason: 'history_boundary_inconsistent' } + } + const sameContent = history.recorded.itemIdsByFingerprint.get(submission.payloadFingerprint) + if (sameContent?.some((id) => !identified.has(id))) { + return { clientMessageId, outcome: 'unknown', reason: 'ambiguous_match' } + } + return { clientMessageId, outcome: 'rejected', reason: DISPATCH_REJECTED_NOT_DELIVERED } } function claimBy( @@ -193,11 +252,11 @@ function resolveOne( reason: 'turn_in_flight' } } - // Absent from a history we can trust the boundary of, with nothing running: - // the provider never took it. + // The anchor is the last completed turn, not this send's hand-over, so the send may sit + // before it: absence here cannot say the provider never took it. return { clientMessageId: submission.clientMessageId, - outcome: 'rejected', - reason: DISPATCH_REJECTED_NOT_DELIVERED + outcome: 'unknown', + reason: 'no_dispatch_identity' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 7c12909e0363..29b98c942937 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -123,7 +123,8 @@ describe('structured session acquisition options', () => { const historyWindow = (): ProviderHistoryWindow => ({ items: [], boundaryConsistent: true, - turnInFlight: childAcquired + turnInFlight: childAcquired, + recorded: { itemIds: new Set(), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => true } }) const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { const sessionAdapter = adapter({ origin }) @@ -180,6 +181,12 @@ describe('structured session acquisition options', () => { } satisfies AgentJournalMessageItem, fence: 1 }) + await firstJournal!.resolveDispatch({ + clientMessageId: 'crashed-send', + state: 'pending', + providerIdentity: { provider: 'claude', sessionId: 'provider-1', uuid: 'crashed-frame' }, + fence: 1 + }) await firstJournal!.close() const store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index bb003f93427d..c9c17ce961dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -59,6 +59,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return released } + mintDispatchIdentity: NonNullable = ( + input + ) => this.liveOwnerOrNull(input.sessionId)?.mintDispatchIdentity?.(input) ?? null + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) 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 bc3c27ca5b13..c043e65f20a3 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 @@ -230,6 +230,12 @@ export type StructuredAgentSessionAdapter = { * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise + /** The id the provider will record the next send under, for one that adopts a client-chosen + * id. Durable before the hand-over, so a restart can look the send up instead of guessing. */ + mintDispatchIdentity?(input: { + sessionId: string + body: AgentJournalMessageItem + }): AgentJournalItemIdentity | null dispatch(input: { sessionId: string clientMessageId: string @@ -240,6 +246,8 @@ export type StructuredAgentSessionAdapter = { requestedAt?: number /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise + /** From `mintDispatchIdentity`, already durable: the frame must carry exactly this id. */ + providerIdentity?: AgentJournalItemIdentity }): Promise /** `agent` answers for a session with no child running, from the provider alone. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 36f6ebc79e85..554696c87c76 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -46,8 +46,15 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +/** A transcript read whole that holds none of the sends below. */ function window(overrides: Partial = {}): ProviderHistoryWindow { - return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides } + return { + items: [], + boundaryConsistent: true, + turnInFlight: false, + recorded: { itemIds: new Set(), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => true }, + ...overrides + } } /** Only the surface `attachJournal` touches; every send-shaped method is a spy @@ -79,6 +86,16 @@ async function crashedJournal(clientMessageId = 'cm_1', text = 'deploy the thing body: userMessage(text), fence: RECORD.lease.runtimeFence }) + await journal.resolveDispatch({ + clientMessageId, + state: 'pending', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-1', + uuid: `${clientMessageId}-frame` + }, + fence: RECORD.lease.runtimeFence + }) await journal.close() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88a6330e789..5223763214e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -66,6 +66,12 @@ export async function interruptedRestart( } if (work === 'submission' || work === 'send-after-reply') { previous.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + // Handed over under a frame id, so the restarted host can look it up. + previous.host.deps.adapter.mintDispatchIdentity = () => ({ + provider: 'claude', + sessionId: 'provider-session', + uuid: 'handed-over-frame' + }) const body = hostTestMessage('Perform the original task') await previous.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) // Accepted first, handed over after: the work in flight is a send the provider took. @@ -127,7 +133,12 @@ export async function interruptedRestart( providerHistoryWindow: async () => ({ items: [], boundaryConsistent: historyBoundaryConsistent, - turnInFlight: false + turnInFlight: false, + recorded: { + itemIds: new Set(), + itemIdsByFingerprint: new Map(), + provesAbsenceOf: () => historyBoundaryConsistent + } }) } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49642b9a897e..a747d4c31a00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -12,6 +12,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { + AgentJournalItemIdentity, AgentJournalMessageItem, AgentJournalStatusItem, AgentJournalSubmission @@ -75,7 +76,8 @@ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number + requestedAt: number, + providerIdentity: AgentJournalItemIdentity | null ): Promise { try { return await ctx.adapter.dispatch({ @@ -83,7 +85,8 @@ async function dispatchSafely( clientMessageId, body, fence: ctx.fence, - requestedAt + requestedAt, + ...(providerIdentity ? { providerIdentity } : {}) }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { @@ -178,10 +181,24 @@ export async function handOverSubmission( }) return } - await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) + // Durable with the hand-over, so a restart decides delivery by this id, not by content. + const providerIdentity = + ctx.adapter.mintDispatchIdentity?.({ sessionId: ctx.sessionId, body }) ?? null + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + ...(providerIdentity ? { providerIdentity } : {}) + }) // The row written at acceptance is the send's instant on the host clock; the turn this // dispatch opens records it so the live counter never re-anchors at turn-open. - const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) + const outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + submission.submittedAt, + providerIdentity + ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a17561e6b810..1f2de289bd00 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -396,6 +396,9 @@ export type AgentJournalSubmission = { handoverRecorded?: true /** When the host handed it to the provider (its `dispatch{pending}` row). */ handedOverAt?: number + /** Host-only: the provider item key the hand-over stamped on the frame, so a restart can look + * the send up by identity. Absent for providers that choose their own ids, and on older rows. */ + handedOverItemId?: string /** Host-only: the submission row's sequence, which tells which host process accepted it. */ acceptedSequence?: number } From 07368bb4494d28f729f61c3d8d9c2b43b26e92d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:56:21 -0700 Subject: [PATCH 02/49] perf(native-chat): read Claude history on restart only for sends that need it Opening a structured chat read the whole Claude transcript up to three times (branch proof, anchored replay, and the new whole-file id scan) on every attach, even when the journal had nothing to decide. The adapter now samples liveness at attach and hands back a history source; reconciliation reads the recorded history only for a stranded send handed over under an id, and the anchored window only for one without. A normal chat open reads no transcript. --- .../claude-structured-history-window.test.ts | 12 +++-- .../claude-structured-history-window.ts | 50 ++++++++++--------- .../claude-structured-session-adapter.ts | 8 ++- .../journal-restart-reconciliation.test.ts | 45 +++++++++++++++-- .../journal-restart-reconciliation.ts | 30 +++++++++-- .../journal-submission-reconciler.ts | 13 +++++ ...-agent-session-accept-then-deliver.test.ts | 6 +-- ...-agent-session-acquisition-options.test.ts | 15 +++--- ...structured-agent-session-adapter-router.ts | 4 +- .../structured-agent-session-adapter.ts | 15 +++--- .../structured-agent-session-attach-flow.ts | 24 +++++---- ...gent-session-attach-reconciliation.test.ts | 17 ++++--- .../structured-agent-session-attach.ts | 22 ++++---- ...ssion-restart-interruption-test-harness.ts | 12 +++-- 14 files changed, 181 insertions(+), 92 deletions(-) diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index c23df513aca8..8ab85e692c7f 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -13,7 +13,7 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { claudeProviderHistoryWindowFromJsonl, - resolveClaudeProviderHistoryWindow + openClaudeProviderHistory } from './claude-structured-history-window' const PROVIDER_SESSION = 'provider-1' @@ -78,7 +78,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { 'utf8' ) - const window = await resolveClaudeProviderHistoryWindow({ + const history = openClaudeProviderHistory({ identity: { sessionId: ORCA_SESSION, workspaceId: 'workspace-1', @@ -90,7 +90,13 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { hasLiveSession: false }) - expect(window?.items.map((item) => item.providerItemId)).toEqual(['u-1']) + expect((await history?.readWindow())?.items.map((item) => item.providerItemId)).toEqual(['u-1']) + const recorded = await history?.readRecorded() + expect( + recorded?.itemIds.has( + agentJournalItemKey({ provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'u-1' }) + ) + ).toBe(true) }) it('pins the renderer and host fingerprint functions to the same digest', () => { diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index 83ab2705d9de..3d2b1e6ddd9a 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -20,6 +20,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { ProviderHistoryItem, + ProviderHistorySource, ProviderHistoryWindow, ProviderRecordedHistory } from '../native-chat/agent-session-journal/journal-submission-reconciler' @@ -168,6 +169,8 @@ type HistoryWindowInput = { turnInFlight: boolean } +type HistoryReadInput = HistoryWindowInput & { transcriptPath: string } + /** * Sink for the ancestry replay. Only the fingerprinted item survives the call, * so the window holds at most one small row per anchor..leaf record — never the @@ -234,20 +237,17 @@ function createRecordedCollector(input: HistoryWindowInput) { const blocks = claudePromptBlocks(row) if (blocks) { const fingerprint = promptFingerprint(input.sessionId, blocks) - itemIdsByFingerprint.set(fingerprint, [ - ...(itemIdsByFingerprint.get(fingerprint) ?? []), - itemId - ]) + const sameText = itemIdsByFingerprint.get(fingerprint) ?? [] + itemIdsByFingerprint.set(fingerprint, [...sameText, itemId]) } } function finish(): ProviderRecordedHistory { - const provable = whole return { itemIds, itemIdsByFingerprint, provesAbsenceOf: (itemId) => { - const identity = provable ? parseAgentJournalItemKey(itemId) : null + const identity = whole ? parseAgentJournalItemKey(itemId) : null return identity?.provider === 'claude' && identity.sessionId === input.providerSessionId } } @@ -305,39 +305,41 @@ function anchoredWindowFromJsonl( } /** - * The window for one attached session: resolve the provider's transcript, then - * read it against the handle's durable leaf. A live child means a send queued - * behind its running turn is not in the file yet, so liveness is carried in - * rather than assumed — only the adapter's session map can answer it. + * History for one attached session. Liveness is sampled now, because only the adapter's session + * map can answer it and a live child means a send queued behind its running turn is not in the file + * yet. The transcript is resolved and read only when a stranded send needs it. */ -export async function resolveClaudeProviderHistoryWindow(input: { +export function openClaudeProviderHistory(input: { identity: AgentSessionJournalIdentity accountHomePath: string hasLiveSession: boolean -}): Promise { +}): ProviderHistorySource | null { const handle = input.identity.providerHandle if (handle.kind !== 'claude') { return null } - const transcriptPath = await resolveSessionFilePath('claude', handle.sessionId, { - claudeProjectsDir: join(input.accountHomePath, 'projects') - }) - if (!transcriptPath) { - return null - } - const read = { - transcriptPath, + const scope: HistoryWindowInput = { providerSessionId: handle.sessionId, previousLeafUuid: handle.leafUuid, sessionId: input.identity.sessionId, turnInFlight: input.hasLiveSession } - const window = await readClaudeProviderHistoryWindow(read) - return { ...window, recorded: await readClaudeRecordedHistory(read) } + let transcriptPath: Promise | undefined + const readWith = async (read: (input: HistoryReadInput) => Promise, fallback: T) => { + const path = await (transcriptPath ??= resolveSessionFilePath('claude', handle.sessionId, { + claudeProjectsDir: join(input.accountHomePath, 'projects') + })) + return path ? read({ ...scope, transcriptPath: path }) : fallback + } + return { + turnInFlight: input.hasLiveSession, + readWindow: () => readWith(readClaudeProviderHistoryWindow, INCONSISTENT), + readRecorded: () => readWith(readClaudeRecordedHistory, null) + } } async function readClaudeRecordedHistory( - input: HistoryWindowInput & { transcriptPath: string } + input: HistoryReadInput ): Promise { const recorded = createRecordedCollector(input) try { @@ -360,7 +362,7 @@ async function readClaudeRecordedHistory( } export async function readClaudeProviderHistoryWindow( - input: HistoryWindowInput & { transcriptPath: string } + input: HistoryReadInput ): Promise { const ancestryAnchorUuid = input.previousLeafUuid if (!ancestryAnchorUuid) { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 80d563159ed0..a82f6724560f 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -38,7 +38,7 @@ import { type ClaudeExitLifecycle } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { openClaudeProviderHistory } from './claude-structured-history-window' import { drainClaudeChildWork } from './claude-child-work-evidence' import { admitClaudePromptCancellation, @@ -135,10 +135,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ - providerHistoryWindow: NonNullable = ( - input - ) => - resolveClaudeProviderHistoryWindow({ + providerHistory: NonNullable = async (input) => + openClaudeProviderHistory({ identity: input.identity, accountHomePath: input.accountHome.path, hasLiveSession: diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index e7e06d800ef4..a6697fa214f1 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemIdentity, @@ -15,6 +15,7 @@ import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, + ProviderHistorySource, ProviderHistoryWindow, ProviderRecordedHistory } from './journal-submission-reconciler' @@ -67,11 +68,17 @@ function history(uuid: string, text: string): ProviderHistoryItem { } } +/** History the reconciler reads on demand; `recorded` is what the whole-file read finds. */ function window( items: ProviderHistoryItem[], overrides: Partial = {} -): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } +): ProviderHistorySource { + const { boundaryConsistent = true, turnInFlight = false, recorded = null } = overrides + return { + turnInFlight, + readWindow: vi.fn(async () => ({ items, boundaryConsistent })), + readRecorded: vi.fn(async () => recorded) + } } /** The whole transcript, holding these records; each is `[uuid, text]`. */ @@ -347,6 +354,38 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { }) }) + it('reads no provider history when nothing is left to decide', async () => { + const journal = await open() + const history = window([]) + + expect(await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history })).toEqual( + [] + ) + expect(history.readWindow).not.toHaveBeenCalled() + expect(history.readRecorded).not.toHaveBeenCalled() + }) + + it('reads only the recorded history when every stranded send carries a frame id', async () => { + const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') + const history = window([], { recorded: wholeHistory([['uuid-sent', 'deploy the thing']]) }) + + await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history }) + + expect(history.readWindow).not.toHaveBeenCalled() + expect(journal.submissions()[0]?.dispatchState).toBe('accepted') + }) + + it('leaves a send unknown when the recorded history cannot be read', async () => { + const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') + const history = window([]) + vi.mocked(history.readRecorded).mockRejectedValue(new Error('EACCES')) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + it('never rejects a send recorded without a frame id, which older builds wrote', async () => { const journal = await reopenAfterCrash() diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 48e5f4ef54f0..8f2ca8bce3bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -22,7 +22,11 @@ import { } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from './journal-store' -import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' +import { + reconcileSubmissions, + type ProviderHistorySource, + type ProviderHistoryWindow +} from './journal-submission-reconciler' /** * Only a text-only body can be compared against provider content. A submission @@ -89,6 +93,25 @@ function unseenHistory( } } +const UNREAD_WINDOW = { items: [], boundaryConsistent: false } as const + +/** Read only what these sends are decided by: the recorded history for a send handed over under + * an id, the anchored window for one without. A failed read decides nothing. */ +async function readHistoryFor( + history: ProviderHistorySource, + submissions: readonly AgentJournalSubmission[] +): Promise { + const [window, recorded] = await Promise.all([ + submissions.some((submission) => !submission.handedOverItemId) + ? history.readWindow().catch(() => UNREAD_WINDOW) + : UNREAD_WINDOW, + submissions.some((submission) => submission.handedOverItemId) + ? history.readRecorded().catch(() => null) + : null + ]) + return { ...window, turnInFlight: history.turnInFlight, recorded } +} + /** * Decide what the crash boundary could only doubt. Returns the client message * ids this pass settled, so the attach result stops reporting them unconfirmed. @@ -96,16 +119,17 @@ function unseenHistory( export async function reconcileJournalSubmissionsAgainstHistory(input: { journal: AgentSessionJournal fence: number - history: ProviderHistoryWindow + history: ProviderHistorySource }): Promise { const submissions = comparableSubmissions(input.journal) if (submissions.length === 0) { return [] } + const history = await readHistoryFor(input.history, submissions) const settled: string[] = [] for (const outcome of reconcileSubmissions({ submissions, - history: unseenHistory(input.journal, input.history) + history: unseenHistory(input.journal, history) })) { if (outcome.outcome === 'unknown') { // Narrowing failed: the submission stays unconfirmed, so record why. diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 999b7114f6f0..cefa1cd2f888 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -66,6 +66,19 @@ export type ProviderRecordedHistory = { provesAbsenceOf: (itemId: string) => boolean } +/** + * Provider history as an attach samples it. Liveness is fixed at the sample, before a new child + * starts; each read runs only when an unsettled send is decided by it, so an open with nothing to + * decide reads no transcript. A read that fails leaves its sends `unknown`. + */ +export type ProviderHistorySource = { + turnInFlight: boolean + /** The anchored window: only sends handed over with no id are decided by it. */ + readWindow: () => Promise> + /** Every recorded user item: only sends handed over under an id are decided by it. */ + readRecorded: () => Promise +} + export type SubmissionReconciliation = | { clientMessageId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb4ee607e38d..585afc74b46e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -540,8 +540,8 @@ describe('what an earlier host process left behind', () => { }) it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => { - const providerHistoryWindow = vi.fn(async () => null) - adapterExtras = { providerHistoryWindow } + const providerHistory = vi.fn(async () => null) + adapterExtras = { providerHistory } await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) @@ -555,7 +555,7 @@ describe('what an earlier host process left behind', () => { expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) // Deciding them from provider history waits for a won lease (W4′d). - expect(providerHistoryWindow).not.toHaveBeenCalled() + expect(providerHistory).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 29b98c942937..4f01aa081a37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -10,7 +10,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' import { attachFingerprintFields, type AgentSessionAttachParams @@ -120,11 +120,14 @@ describe('structured session acquisition options', () => { hostId: 'local' }) let childAcquired = false - const historyWindow = (): ProviderHistoryWindow => ({ - items: [], - boundaryConsistent: true, + const historyWindow = (): ProviderHistorySource => ({ turnInFlight: childAcquired, - recorded: { itemIds: new Set(), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => true } + readWindow: async () => ({ items: [], boundaryConsistent: true }), + readRecorded: async () => ({ + itemIds: new Set(), + itemIdsByFingerprint: new Map(), + provesAbsenceOf: () => true + }) }) const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { const sessionAdapter = adapter({ origin }) @@ -147,7 +150,7 @@ describe('structured session acquisition options', () => { } } }) - sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) + sessionAdapter.providerHistory = vi.fn(async () => historyWindow()) return sessionAdapter } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index c9c17ce961dc..ab92de1c270d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -144,10 +144,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) - providerHistoryWindow = (input: { + providerHistory = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) + }) => this.requireAgent(input.identity).providerHistory?.(input) ?? Promise.resolve(null) closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) 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 c043e65f20a3..9f5cb7689e30 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 @@ -36,7 +36,7 @@ import { import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' @@ -340,15 +340,14 @@ 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 - /** Provider history for restart reconciliation, bounded to what the provider - * recorded after the journal's last committed item. Only the adapter can say - * whether the read has a proven start and whether a turn is still running, so - * it owns both flags. Omit where the provider records no boundary-consistent - * history; an omitted window leaves every unsettled submission `unknown`. */ - providerHistoryWindow?(input: { + /** Provider history for restart reconciliation. Only the adapter can say whether a read has a + * proven start and whether a turn is still running, so it owns both; liveness is sampled by + * this call, the history read only when a stranded send needs it. Omit where the provider + * records no usable history; that leaves every unsettled submission `unknown`. */ + providerHistory?(input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }): Promise + }): Promise /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8fdee5c62bdb..d66bec89cfbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -42,7 +42,7 @@ import { withAgentSessionCreatePhase, type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type AttachFlowInput = { @@ -108,7 +108,7 @@ export async function performAttach( let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false - let providerHistoryWindow: ProviderHistoryWindow | null = null + let providerHistory: ProviderHistorySource | null = null const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) @@ -161,8 +161,10 @@ export async function performAttach( } // Sample provider history before a new child is acquired. Once acquireOwner // starts the child, the adapter's liveness signal intentionally becomes - // conservative and an absent prompt can no longer prove non-delivery. - providerHistoryWindow = await readProviderHistoryWindow({ + // conservative and an absent prompt can no longer prove non-delivery. The + // transcript is read later, and only for a stranded send: the new child is + // sent nothing first, so nothing it appends can be one. + providerHistory = await sampleProviderHistory({ adapter: input.adapter, identity: journalIdentityFor(record, params), accountHome: record.accountHome, @@ -230,7 +232,7 @@ export async function performAttach( journalRoot: input.journalRoot, adapter: input.adapter, openConversation: input.openConversation, - providerHistoryWindow + providerHistory }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) @@ -260,19 +262,19 @@ export async function performAttach( } } -async function readProviderHistoryWindow(input: { +async function sampleProviderHistory(input: { adapter: StructuredAgentSessionAdapter identity: AgentSessionJournalIdentity accountHome: AgentSessionRecord['accountHome'] ownerAlreadyAdmitted: boolean -}): Promise { - const read = input.adapter.providerHistoryWindow - if (!read) { +}): Promise { + const sample = input.adapter.providerHistory + if (!sample) { return null } - let history: ProviderHistoryWindow | null + let history: ProviderHistorySource | null try { - history = await read({ identity: input.identity, accountHome: input.accountHome }) + history = await sample({ identity: input.identity, accountHome: input.accountHome }) } catch { return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 554696c87c76..b07689e9faec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -11,7 +11,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-jour import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' @@ -47,26 +47,29 @@ function userMessage(text: string): AgentJournalMessageItem { } /** A transcript read whole that holds none of the sends below. */ -function window(overrides: Partial = {}): ProviderHistoryWindow { +function window(overrides: Partial = {}): ProviderHistorySource { return { - items: [], - boundaryConsistent: true, turnInFlight: false, - recorded: { itemIds: new Set(), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => true }, + readWindow: async () => ({ items: [], boundaryConsistent: true }), + readRecorded: async () => ({ + itemIds: new Set(), + itemIdsByFingerprint: new Map(), + provesAbsenceOf: () => true + }), ...overrides } } /** Only the surface `attachJournal` touches; every send-shaped method is a spy * so a re-delivery would be visible rather than silent. */ -function adapterWith(providerHistoryWindow?: () => Promise): { +function adapterWith(providerHistory?: () => Promise): { adapter: StructuredAgentSessionAdapter dispatch: ReturnType } { const dispatch = vi.fn() const adapter = { dispatch, - ...(providerHistoryWindow ? { providerHistoryWindow } : {}) + ...(providerHistory ? { providerHistory } : {}) } as unknown as StructuredAgentSessionAdapter return { adapter, dispatch } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 10c4866cea3f..fd7b7ae65836 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -41,7 +41,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' @@ -173,9 +173,9 @@ export type AttachedJournal = { * open — with provider history deciding the submissions that boundary could only doubt. * * Why the reconciliation belongs HERE and nowhere else: this runs after the - * record store handed this host the lease and before `onAttached` starts a - * provider child, so nothing can be appending to the provider's history while it - * is read, and the window stays valid until the resume consumes it. Every other + * record store handed this host the lease and before `onAttached` lets a send + * reach the provider child, so nothing appended to the provider's history while it + * is read can be a stranded send, and the read stays valid until the resume consumes it. Every other * settlement site — a proven child exit — runs while the host * may still start another child, and a read there could be overtaken before it * is acted on. Orca still never re-sends: this decides state only. A queued @@ -189,8 +189,8 @@ export async function attachJournal(input: { /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise /** Provider history sampled before a new child is acquired. `null` means the - * adapter had no usable history; omit to read lazily for direct callers. */ - providerHistoryWindow?: ProviderHistoryWindow | null + * adapter had no usable history; omit to sample here for direct callers. */ + providerHistory?: ProviderHistorySource | null }): Promise { const identity = journalIdentityFor(input.record, input.params) const fence = input.record.lease.runtimeFence @@ -201,9 +201,7 @@ export async function attachJournal(input: { journal, fence, accountHome: input.record.accountHome, - ...(Object.hasOwn(input, 'providerHistoryWindow') - ? { history: input.providerHistoryWindow } - : {}) + ...(Object.hasOwn(input, 'providerHistory') ? { history: input.providerHistory } : {}) }) return { journal, @@ -229,15 +227,15 @@ async function reconcileAgainstProviderHistory(input: { journal: AgentSessionJournal fence: number accountHome: AgentSessionAccountHome - history?: ProviderHistoryWindow | null + history?: ProviderHistorySource | null }): Promise { let history = input.history if (history === undefined) { - if (!input.adapter.providerHistoryWindow) { + if (!input.adapter.providerHistory) { return [] } try { - history = await input.adapter.providerHistoryWindow({ + history = await input.adapter.providerHistory({ identity: input.identity, accountHome: input.accountHome }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 5223763214e5..7c80b2c933af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -130,15 +130,17 @@ export async function interruptedRestart( closeSession, ...(work === 'submission' || work === 'send-after-reply' ? { - providerHistoryWindow: async () => ({ - items: [], - boundaryConsistent: historyBoundaryConsistent, + providerHistory: async () => ({ turnInFlight: false, - recorded: { + readWindow: async () => ({ + items: [], + boundaryConsistent: historyBoundaryConsistent + }), + readRecorded: async () => ({ itemIds: new Set(), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => historyBoundaryConsistent - } + }) }) } : {}) From f1ba66793ecf701d29f970402b8b53e0a34a6dbd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:07:33 -0700 Subject: [PATCH 03/49] fix(native-chat): find a Claude send folded or merged into a running turn Claude records a frame that arrives while a turn runs in one of two ways besides its own row: folded into the turn, it gets only a queued_command attachment naming it by source_uuid; queued with others behind the turn, it is merged into one row under the last frame's uuid. The id scan read only type:user rows by uuid, so a delivered mid-turn send was judged not delivered, hidden, and offered for a Retry that sends it twice. The scan now takes a queued_command attachment's source_uuid as the frame's id, as Claude's own readers do, and indexes every text block of every recorded frame. A send whose id is absent stays unconfirmed while any copy of its text is left over once each send found by id takes its own, which covers the merged row. The scan moves out of the anchored-window module, which returns to its previous shape. --- .../claude-structured-history-window.test.ts | 116 ++++++++-- .../claude-structured-history-window.ts | 172 ++------------- .../claude-structured-provider-history.ts | 201 ++++++++++++++++++ .../claude-structured-session-adapter.ts | 2 +- .../journal-submission-reconciler.ts | 42 +++- 5 files changed, 356 insertions(+), 177 deletions(-) create mode 100644 src/main/claude/claude-structured-provider-history.ts diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index 8ab85e692c7f..ccfaabbd7b49 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -11,10 +11,11 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { claudeProviderHistoryWindowFromJsonl } from './claude-structured-history-window' import { - claudeProviderHistoryWindowFromJsonl, + claudeRecordedHistoryFromJsonl, openClaudeProviderHistory -} from './claude-structured-history-window' +} from './claude-structured-provider-history' const PROVIDER_SESSION = 'provider-1' const ORCA_SESSION = 'session-1' @@ -165,7 +166,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { it('reports no window and an inconsistent boundary without a durable anchor', () => { const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') - expect(read(contents, null)).toMatchObject({ + expect(read(contents, null)).toEqual({ items: [], boundaryConsistent: false, turnInFlight: false @@ -199,7 +200,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { }) it('keeps the boundary consistent and the window empty when nothing followed the anchor', () => { - expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toMatchObject({ + expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toEqual({ items: [], boundaryConsistent: true, turnInFlight: false @@ -359,15 +360,13 @@ describe('a crash between Claude saving a prompt and Orca recording its echo', ( }) describe('a send handed over under its own frame id', () => { - const sentKey = agentJournalItemKey({ - provider: 'claude', - sessionId: PROVIDER_SESSION, - uuid: 'sent' - }) + const key = (uuid: string) => + agentJournalItemKey({ provider: 'claude', sessionId: PROVIDER_SESSION, uuid }) + const sentKey = key('sent') - function handedOver(text: string): AgentJournalSubmission { + function handedOver(text: string, uuid = 'sent'): AgentJournalSubmission { return { - clientMessageId: 'cm-sent', + clientMessageId: `cm-${uuid}`, fence: 1, payloadFingerprint: sendFingerprint(text), dispatchState: 'unknown', @@ -375,16 +374,48 @@ describe('a send handed over under its own frame id', () => { reason: null, submittedAt: 0, resolvedAt: null, - handedOverItemId: sentKey + handedOverItemId: key(uuid) } } - function verdict(contents: string, previousLeafUuid: string | null, text: string) { - const [outcome] = reconcileSubmissions({ - history: read(contents, previousLeafUuid), - submissions: [handedOver(text)] + function verdicts( + contents: string, + previousLeafUuid: string | null, + submissions: AgentJournalSubmission[] + ) { + const input = { + contents, + providerSessionId: PROVIDER_SESSION, + previousLeafUuid, + sessionId: ORCA_SESSION, + turnInFlight: false + } + return reconcileSubmissions({ + history: { + ...read(contents, previousLeafUuid), + recorded: claudeRecordedHistoryFromJsonl(input) + }, + submissions }) - return outcome + } + + function verdict(contents: string, previousLeafUuid: string | null, text: string) { + return verdicts(contents, previousLeafUuid, [handedOver(text)])[0] + } + + /** What Claude writes for a frame it folds into a running turn instead of a row of its own. */ + function folded(rowUuid: string, parentUuid: string, sourceUuid: string, text: string): Row { + return { + type: 'attachment', + uuid: rowUuid, + parentUuid, + sessionId: PROVIDER_SESSION, + attachment: { + type: 'queued_command', + prompt: [{ type: 'text', text }], + source_uuid: sourceUuid + } + } } it('is accepted when Claude holds it before the anchor a later turn advanced', () => { @@ -422,6 +453,57 @@ describe('a send handed over under its own frame id', () => { expect(verdict(contents, null, 'ship it')).toMatchObject({ outcome: 'accepted' }) }) + it('is accepted when Claude folded it into a running turn under a row id of its own', () => { + const contents = jsonl( + [ + ANCHOR, + prompt('turn', 'anchor', 'run the tests'), + folded('fold-row', 'turn', 'sent', 'ship it') + ], + 'fold-row' + ) + + expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ + outcome: 'accepted', + providerItemId: sentKey + }) + }) + + it('stays unknown when Claude merged it into the record of a send queued after it', () => { + const merged = [ + { type: 'text', text: 'ship it' }, + { type: 'text', text: 'and test it' } + ] + const contents = jsonl([ANCHOR, prompt('next', 'anchor', merged)], 'next') + + expect( + verdicts(contents, 'anchor', [handedOver('ship it'), handedOver('and test it', 'next')]) + ).toMatchObject([ + { outcome: 'unknown', reason: 'ambiguous_match' }, + { outcome: 'accepted', providerItemId: key('next') } + ]) + }) + + it('stays unknown when the send merged with it had the same text', () => { + const merged = [ + { type: 'text', text: 'yes' }, + { type: 'text', text: 'yes' } + ] + const contents = jsonl([ANCHOR, prompt('next', 'anchor', merged)], 'next') + + expect( + verdicts(contents, 'anchor', [handedOver('yes'), handedOver('yes', 'next')]) + ).toMatchObject([{ outcome: 'unknown' }, { outcome: 'accepted' }]) + }) + + it('is not delivered when the only copy of its text is an identical send found by id', () => { + const contents = jsonl([ANCHOR, prompt('first', 'anchor', 'yes')], 'first') + + expect( + verdicts(contents, 'anchor', [handedOver('yes', 'first'), handedOver('yes')]) + ).toMatchObject([{ outcome: 'accepted' }, { outcome: 'rejected' }]) + }) + it('is not delivered when the whole file lacks it', () => { const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'something else')], 'u-1') diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index 3d2b1e6ddd9a..fc7991cc3d69 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -14,21 +14,10 @@ // of those makes absence meaningless. Failing it reports an inconsistent // boundary rather than an empty window, because the two decide opposite things. -import { createReadStream } from 'node:fs' -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { ProviderHistoryItem, - ProviderHistorySource, - ProviderHistoryWindow, - ProviderRecordedHistory + ProviderHistoryWindow } from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../shared/agent-session-journal-item-key' -import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' import { claudeContentBlocks } from '../native-chat/transcript-record-blocks' import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -41,7 +30,7 @@ import { /** The legacy-import bound, now applied PER RECORD rather than per file. The * line framer buffers one record at a time, so this is the only thing standing * between a pathological row and the whole file being resident. */ -const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024 +export const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024 const INCONSISTENT: ProviderHistoryWindow = { items: [], @@ -94,9 +83,9 @@ function rawTextParts(content: unknown): string[] | null { } /** - * A plain-text user record Orca could itself have submitted. Rows the harness - * marks as its own are excluded, because a fingerprint computed over machinery - * would claim a history slot the user's message should have had. + * A user record Orca could itself have submitted. Everything the harness injects + * is excluded, because a fingerprint computed over machinery would claim a + * history slot the user's message should have had. * * Attachments are excluded too, and deliberately: the transcript keeps a pasted * image as base64 with no path, so the `image-ref` block the submission was @@ -132,15 +121,15 @@ function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null if (rawTexts === null || rawTexts.length !== blocks.length) { return null } - return blocks.map((block, index) => ({ ...block, text: rawTexts[index]! })) -} - -/** The anchored window's prompts: harness-shaped text is left out, so machinery cannot claim a - * send by content. A send handed over under an id never reaches this filter. */ -function claudeUserPromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null { - const blocks = claudePromptBlocks(record) - const [first] = blocks ?? [] - return first?.type === 'text' && !isKnownHarnessInjectedUserTurnText(first.text) ? blocks : null + const preservedBlocks = blocks.map((block, index) => ({ + ...block, + text: rawTexts[index]! + })) + const [first] = preservedBlocks + if (first?.type !== 'text' || isKnownHarnessInjectedUserTurnText(first.text)) { + return null + } + return preservedBlocks } /** @@ -151,7 +140,7 @@ function claudeUserPromptBlocks(record: TranscriptRecord): NativeChatBlock[] | n * produced the envelope. Matching here is therefore an equality between two runs * of one function, not a guess about two encodings agreeing. */ -function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { +export function claudePromptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { return computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId, @@ -159,7 +148,7 @@ function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string }) } -type HistoryWindowInput = { +export type HistoryWindowInput = { providerSessionId: string previousLeafUuid: string | null /** Orca session id: the fingerprint a submission carries is scoped to it. */ @@ -169,8 +158,6 @@ type HistoryWindowInput = { turnInFlight: boolean } -type HistoryReadInput = HistoryWindowInput & { transcriptPath: string } - /** * Sink for the ancestry replay. Only the fingerprinted item survives the call, * so the window holds at most one small row per anchor..leaf record — never the @@ -181,7 +168,7 @@ function createWindowCollector(input: HistoryWindowInput) { return { byUuid, onAncestorRecord } function onAncestorRecord(record: TranscriptRecord, uuid: string): void { - const blocks = claudeUserPromptBlocks(record) + const blocks = claudePromptBlocks(record) if (!blocks) { return } @@ -190,7 +177,7 @@ function createWindowCollector(input: HistoryWindowInput) { // Claude echoes no client message id, so identity matching reduces to the // fingerprint pass; the reconciler treats that as the weakest evidence. clientMessageId: null, - payloadFingerprint: promptFingerprint(input.sessionId, blocks), + payloadFingerprint: claudePromptFingerprint(input.sessionId, blocks), identity: { provider: 'claude', sessionId: stringField(record, 'sessionId') ?? input.providerSessionId, @@ -200,60 +187,6 @@ function createWindowCollector(input: HistoryWindowInput) { } } -/** - * Every user record in the file, wherever it sits: a send handed over under an id is found by - * that id, so no anchor is needed. Absence counts only when every line of this session's file - * was read; any line that does not parse may be the missing record. - */ -function createRecordedCollector(input: HistoryWindowInput) { - const itemIds = new Set() - const itemIdsByFingerprint = new Map() - let whole = true - return { add, finish } - - function add(line: string): void { - if (!line.trim()) { - return - } - let record: unknown - try { - record = JSON.parse(line) - } catch { - whole = false - return - } - const uuid = stringField(record, 'uuid') - if (!uuid || !record || typeof record !== 'object' || Array.isArray(record)) { - return - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A non-array object, checked above. - const row = record as TranscriptRecord - if (row.type !== 'user') { - return - } - const sessionId = stringField(row, 'sessionId') ?? input.providerSessionId - const itemId = agentJournalItemKey({ provider: 'claude', sessionId, uuid }) - itemIds.add(itemId) - const blocks = claudePromptBlocks(row) - if (blocks) { - const fingerprint = promptFingerprint(input.sessionId, blocks) - const sameText = itemIdsByFingerprint.get(fingerprint) ?? [] - itemIdsByFingerprint.set(fingerprint, [...sameText, itemId]) - } - } - - function finish(): ProviderRecordedHistory { - return { - itemIds, - itemIdsByFingerprint, - provesAbsenceOf: (itemId) => { - const identity = whole ? parseAgentJournalItemKey(itemId) : null - return identity?.provider === 'claude' && identity.sessionId === input.providerSessionId - } - } - } -} - /** The chain is leaf first; a history window is oldest first. */ function windowFromChain( chain: readonly string[], @@ -272,16 +205,6 @@ function windowFromChain( export function claudeProviderHistoryWindowFromJsonl( input: HistoryWindowInput & { contents: string } -): ProviderHistoryWindow { - const recorded = createRecordedCollector(input) - for (const line of input.contents.split('\n')) { - recorded.add(line) - } - return { ...anchoredWindowFromJsonl(input), recorded: recorded.finish() } -} - -function anchoredWindowFromJsonl( - input: HistoryWindowInput & { contents: string } ): ProviderHistoryWindow { const ancestryAnchorUuid = input.previousLeafUuid if (!ancestryAnchorUuid) { @@ -304,65 +227,8 @@ function anchoredWindowFromJsonl( } } -/** - * History for one attached session. Liveness is sampled now, because only the adapter's session - * map can answer it and a live child means a send queued behind its running turn is not in the file - * yet. The transcript is resolved and read only when a stranded send needs it. - */ -export function openClaudeProviderHistory(input: { - identity: AgentSessionJournalIdentity - accountHomePath: string - hasLiveSession: boolean -}): ProviderHistorySource | null { - const handle = input.identity.providerHandle - if (handle.kind !== 'claude') { - return null - } - const scope: HistoryWindowInput = { - providerSessionId: handle.sessionId, - previousLeafUuid: handle.leafUuid, - sessionId: input.identity.sessionId, - turnInFlight: input.hasLiveSession - } - let transcriptPath: Promise | undefined - const readWith = async (read: (input: HistoryReadInput) => Promise, fallback: T) => { - const path = await (transcriptPath ??= resolveSessionFilePath('claude', handle.sessionId, { - claudeProjectsDir: join(input.accountHomePath, 'projects') - })) - return path ? read({ ...scope, transcriptPath: path }) : fallback - } - return { - turnInFlight: input.hasLiveSession, - readWindow: () => readWith(readClaudeProviderHistoryWindow, INCONSISTENT), - readRecorded: () => readWith(readClaudeRecordedHistory, null) - } -} - -async function readClaudeRecordedHistory( - input: HistoryReadInput -): Promise { - const recorded = createRecordedCollector(input) - try { - const stream = createReadStream(input.transcriptPath) - for await (const { line } of splitTranscriptStreamLines( - stream, - MAX_HISTORY_WINDOW_RECORD_BYTES - )) { - recorded.add(line) - } - } catch (error) { - console.warn('[claude-history-window] transcript unreadable; sends stay unconfirmed:', { - transcriptPath: input.transcriptPath, - sessionId: input.sessionId, - error - }) - return null - } - return recorded.finish() -} - export async function readClaudeProviderHistoryWindow( - input: HistoryReadInput + input: HistoryWindowInput & { transcriptPath: string } ): Promise { const ancestryAnchorUuid = input.previousLeafUuid if (!ancestryAnchorUuid) { diff --git a/src/main/claude/claude-structured-provider-history.ts b/src/main/claude/claude-structured-provider-history.ts new file mode 100644 index 000000000000..d1c3293a6d39 --- /dev/null +++ b/src/main/claude/claude-structured-provider-history.ts @@ -0,0 +1,201 @@ +// What restart reconciliation reads from Claude: the anchored window for sends handed over with no +// id, and every user frame the transcript holds for sends handed over under one. +// +// A frame is found by its id wherever it sits, so the id read needs no start point. What it does +// need is Claude's own recording rules: a frame folded into a running turn gets no row of its own, +// only a `queued_command` attachment naming it by `source_uuid`; and frames queued together behind +// a turn are merged into ONE row under the last frame's uuid, so the others leave only their text. + +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../shared/agent-session-journal-item-key' +import type { + ProviderHistorySource, + ProviderRecordedHistory +} from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' +import { + claudePromptFingerprint, + MAX_HISTORY_WINDOW_RECORD_BYTES, + readClaudeProviderHistoryWindow, + type HistoryWindowInput +} from './claude-structured-history-window' + +type HistoryReadInput = HistoryWindowInput & { transcriptPath: string } + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A non-array object, checked on this line. + (value as Record) + : null +} + +function stringField(source: Record | null, key: string): string | null { + const value = source?.[key] + return typeof value === 'string' && value.trim() ? value : null +} + +/** The id and content a user frame was recorded under, as Claude's own readers take them. */ +function recordedUserFrame( + row: Record +): { uuid: string | null; content: unknown } | null { + if (row.isSidechain === true) { + return null + } + if (row.type === 'user') { + return { uuid: stringField(row, 'uuid'), content: asRecord(row.message)?.content } + } + const attachment = row.type === 'attachment' ? asRecord(row.attachment) : null + return attachment?.type === 'queued_command' + ? { + uuid: stringField(attachment, 'source_uuid') ?? stringField(row, 'uuid'), + content: attachment.prompt + } + : null +} + +function textPart(part: unknown): string | null { + if (typeof part === 'string') { + return part + } + const block = asRecord(part) + return block?.type === 'text' && typeof block.text === 'string' ? block.text : null +} + +/** Each text part on its own: a frame merged into another's row keeps its text there as a block. */ +function textParts(content: unknown): string[] { + const parts = + typeof content === 'string' ? [content] : Array.isArray(content) ? content.map(textPart) : [] + return parts.filter((text): text is string => typeof text === 'string' && text.trim() !== '') +} + +/** + * Every user frame in the file. Absence counts only when every line of this session's file was + * read; any line that does not parse may be the missing record. + */ +function createRecordedCollector(input: HistoryWindowInput) { + const itemIds = new Set() + const itemIdsByFingerprint = new Map() + let whole = true + return { add, finish } + + function add(line: string): void { + if (!line.trim()) { + return + } + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + whole = false + return + } + const row = asRecord(parsed) + const frame = row ? recordedUserFrame(row) : null + if (!row || !frame?.uuid) { + return + } + const sessionId = stringField(row, 'sessionId') ?? input.providerSessionId + const itemId = agentJournalItemKey({ provider: 'claude', sessionId, uuid: frame.uuid }) + itemIds.add(itemId) + // Keyed as a one-text-block send is fingerprinted: the only kind reconciled (`comparableBody`). + for (const text of textParts(frame.content)) { + const fingerprint = claudePromptFingerprint(input.sessionId, [{ type: 'text', text }]) + const copies = itemIdsByFingerprint.get(fingerprint) + if (copies) { + copies.push(itemId) + } else { + itemIdsByFingerprint.set(fingerprint, [itemId]) + } + } + } + + function finish(): ProviderRecordedHistory { + return { + itemIds, + itemIdsByFingerprint, + provesAbsenceOf: (itemId) => { + const identity = whole ? parseAgentJournalItemKey(itemId) : null + return identity?.provider === 'claude' && identity.sessionId === input.providerSessionId + } + } + } +} + +/** The string-source twin of `readClaudeRecordedHistory`. */ +export function claudeRecordedHistoryFromJsonl( + input: HistoryWindowInput & { contents: string } +): ProviderRecordedHistory { + const recorded = createRecordedCollector(input) + for (const line of input.contents.split('\n')) { + recorded.add(line) + } + return recorded.finish() +} + +async function readClaudeRecordedHistory( + input: HistoryReadInput +): Promise { + const recorded = createRecordedCollector(input) + try { + const stream = createReadStream(input.transcriptPath) + for await (const { line } of splitTranscriptStreamLines( + stream, + MAX_HISTORY_WINDOW_RECORD_BYTES + )) { + recorded.add(line) + } + } catch (error) { + console.warn('[claude-history] transcript unreadable; sends stay unconfirmed:', { + transcriptPath: input.transcriptPath, + sessionId: input.sessionId, + error + }) + return null + } + return recorded.finish() +} + +/** + * History for one attached session. Liveness is sampled now, because only the adapter's session + * map can answer it and a live child means a send queued behind its running turn is not in the + * file yet. The transcript is resolved and read only when a stranded send needs it. + */ +export function openClaudeProviderHistory(input: { + identity: AgentSessionJournalIdentity + accountHomePath: string + hasLiveSession: boolean +}): ProviderHistorySource | null { + const handle = input.identity.providerHandle + if (handle.kind !== 'claude') { + return null + } + const scope: HistoryWindowInput = { + providerSessionId: handle.sessionId, + previousLeafUuid: handle.leafUuid, + sessionId: input.identity.sessionId, + turnInFlight: input.hasLiveSession + } + let transcriptPath: Promise | undefined + const readWith = async (read: (input: HistoryReadInput) => Promise, fallback: T) => { + const path = await (transcriptPath ??= resolveSessionFilePath('claude', handle.sessionId, { + claudeProjectsDir: join(input.accountHomePath, 'projects') + })) + return path ? read({ ...scope, transcriptPath: path }) : fallback + } + return { + turnInFlight: input.hasLiveSession, + readWindow: () => + readWith(readClaudeProviderHistoryWindow, { + items: [], + boundaryConsistent: false, + turnInFlight: input.hasLiveSession + }), + readRecorded: () => readWith(readClaudeRecordedHistory, null) + } +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index a82f6724560f..c3778100685b 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -38,7 +38,7 @@ import { type ClaudeExitLifecycle } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { openClaudeProviderHistory } from './claude-structured-history-window' +import { openClaudeProviderHistory } from './claude-structured-provider-history' import { drainClaudeChildWork } from './claude-child-work-evidence' import { admitClaudePromptCancellation, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index cefa1cd2f888..3dee1a1a526f 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -6,7 +6,8 @@ // // A send handed over under an id the provider adopts is decided by that id alone, // looked up across the whole history: present means delivered, absent from a -// history read whole means not. Older sends carry no such id and fall back to +// history read whole means not — unless a copy of its text no other send's id +// accounts for could be it. Older sends carry no such id and fall back to // the anchored window — an echoed client message id, else a provider item id the // journal already adopted, else the payload fingerprint when it picks out // exactly one unclaimed item. That window is not scoped to the send, so absence @@ -59,8 +60,9 @@ export type ProviderHistoryWindow = { export type ProviderRecordedHistory = { /** Journal keys of every user item in the history. */ itemIds: ReadonlySet - /** Keys of the plain-text ones, by payload fingerprint. A provider build that recorded a send - * under an id of its own leaves only its content behind, so a same-content item vetoes absence. */ + /** Keys of the items holding each text block, once per copy, by that block's payload + * fingerprint. A send recorded under an id Orca did not choose, or merged into another send's + * item, leaves only its text behind, so a copy no send found by id accounts for vetoes absence. */ itemIdsByFingerprint: ReadonlyMap /** The history was read whole and is where an item with this key would have been recorded. */ provesAbsenceOf: (itemId: string) => boolean @@ -175,17 +177,46 @@ export function reconcileSubmissions(input: { } } + const unclaimed = unclaimedCopies(recorded, unsettled, identified) return unsettled.map((submission) => submission.handedOverItemId - ? resolveByIdentity(submission, submission.handedOverItemId, identified, input.history) + ? resolveByIdentity( + submission, + submission.handedOverItemId, + identified, + unclaimed, + input.history + ) : resolveOne(submission, matched, ambiguous, input.history) ) } +/** Copies of each text left once every send found by its id takes its own: a leftover copy may be + * a send whose id the history does not hold. */ +function unclaimedCopies( + recorded: ProviderRecordedHistory | null | undefined, + submissions: readonly AgentJournalSubmission[], + identified: ReadonlySet +): Map { + const copies = new Map( + [...(recorded?.itemIdsByFingerprint ?? [])].map(([key, itemIds]) => [key, [...itemIds]]) + ) + for (const submission of submissions) { + const own = submission.handedOverItemId + const sameText = + own && identified.has(own) ? copies.get(submission.payloadFingerprint) : undefined + if (own && sameText?.includes(own)) { + sameText.splice(sameText.indexOf(own), 1) + } + } + return new Map([...copies].map(([key, itemIds]) => [key, itemIds.length])) +} + function resolveByIdentity( submission: AgentJournalSubmission, itemId: string, identified: ReadonlySet, + unclaimed: ReadonlyMap, history: ProviderHistoryWindow ): SubmissionReconciliation { const { clientMessageId } = submission @@ -201,8 +232,7 @@ function resolveByIdentity( if (!history.recorded?.provesAbsenceOf(itemId)) { return { clientMessageId, outcome: 'unknown', reason: 'history_boundary_inconsistent' } } - const sameContent = history.recorded.itemIdsByFingerprint.get(submission.payloadFingerprint) - if (sameContent?.some((id) => !identified.has(id))) { + if ((unclaimed.get(submission.payloadFingerprint) ?? 0) > 0) { return { clientMessageId, outcome: 'unknown', reason: 'ambiguous_match' } } return { clientMessageId, outcome: 'rejected', reason: DISPATCH_REJECTED_NOT_DELIVERED } From 2f34ca0ba8d4c1f6b6fa48ecaf2059247d4ec25c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:07:38 -0700 Subject: [PATCH 04/49] test(native-chat): pin a running turn keeping a Claude send unconfirmed With no durable anchor yet, the anchored read reports an inconsistent window that carries no liveness. The attach's sampled liveness now decides the id path, so a send whose record Claude has not written yet stays unconfirmed instead of being called not delivered mid-turn. --- ...claude-structured-provider-history.test.ts | 101 ++++++++++++++++++ ...gent-session-attach-reconciliation.test.ts | 7 +- 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 src/main/claude/claude-structured-provider-history.test.ts diff --git a/src/main/claude/claude-structured-provider-history.test.ts b/src/main/claude/claude-structured-provider-history.test.ts new file mode 100644 index 000000000000..416baf5e8db5 --- /dev/null +++ b/src/main/claude/claude-structured-provider-history.test.ts @@ -0,0 +1,101 @@ +// The Claude history an attach samples, read end to end: transcript file, liveness, journal verdict. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { reconcileJournalSubmissionsAgainstHistory } from '../native-chat/agent-session-journal/journal-restart-reconciliation' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { openClaudeProviderHistory } from './claude-structured-provider-history' + +const PROVIDER_SESSION = 'provider-1' + +// No turn has completed yet, so there is no durable anchor. +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } +} + +const journals = createTrackedJournalOpener() +let root: string + +function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +/** A transcript holding only a turn Claude is still running, and a send handed over during it. */ +async function strandedMidTurn() { + const projectDir = join(root, 'account', 'projects', 'work') + await mkdir(projectDir, { recursive: true }) + const running = { + type: 'user', + uuid: 'running-turn', + parentUuid: null, + sessionId: PROVIDER_SESSION, + message: { role: 'user', content: 'run the tests' } + } + await writeFile(join(projectDir, `${PROVIDER_SESSION}.jsonl`), `${JSON.stringify(running)}\n`) + const journalDir = join(root, 'journal') + const journal = await journals.open({ identity: IDENTITY, journalDir }) + await journal.appendSubmission({ + clientMessageId: 'cm-sent', + payloadFingerprint: digestPayload('ship it'), + body: userMessage('ship it'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm-sent', + state: 'pending', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'sent' }, + fence: 1 + }) + const reopened = await journals.open({ identity: IDENTITY, journalDir }) + await reopened.markPendingSubmissionsUnknown(2) + return reopened +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-provider-history-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('openClaudeProviderHistory', () => { + it('leaves a send unknown while a turn runs, even with no anchor to read a window from', async () => { + const journal = await strandedMidTurn() + const history = openClaudeProviderHistory({ + identity: IDENTITY, + accountHomePath: join(root, 'account'), + hasLiveSession: true + }) + + await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history: history! }) + + // Claude may not have written the record yet, so its absence proves nothing. + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('decides the same send not delivered once no child can still be writing', async () => { + const journal = await strandedMidTurn() + const history = openClaudeProviderHistory({ + identity: IDENTITY, + accountHomePath: join(root, 'account'), + hasLiveSession: false + }) + + await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history: history! }) + + expect(journal.submissions()[0]?.dispatchState).toBe('rejected') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index b07689e9faec..0e053cbd4b59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -67,10 +67,9 @@ function adapterWith(providerHistory?: () => Promise } { const dispatch = vi.fn() - const adapter = { - dispatch, - ...(providerHistory ? { providerHistory } : {}) - } as unknown as StructuredAgentSessionAdapter + const surface = { dispatch, ...(providerHistory ? { providerHistory } : {}) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attachJournal reads only these members. + const adapter = surface as unknown as StructuredAgentSessionAdapter return { adapter, dispatch } } From f3c6bf1b91a73ff59e155bc08c2df62ed4a9be4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:10:46 -0700 Subject: [PATCH 05/49] docs(native-chat): say where Claude keeps a folded frame's id The mint comment claimed Claude always records a frame under its own row uuid; a frame folded into a running turn keeps it as the attachment's source_uuid instead. --- src/main/claude/claude-structured-dispatch-content.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 6430cec07564..9d7b283163a6 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -262,9 +262,9 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { } /** - * Claude records a user frame under the frame's own `uuid` (measured against the real CLI), so the - * id is chosen before the hand-over is journaled. A command is left to content: what Claude writes - * for one is not the prompt Orca sent. + * Claude records a user frame under the frame's own `uuid` — its row's, or for a frame folded into + * a running turn its attachment's `source_uuid` — so the id is chosen before the hand-over is + * journaled. A command is left to content: what Claude writes for one is not the prompt Orca sent. */ export function mintClaudeDispatchIdentity( providerSessionId: string, From 4e261d0ffd03ba6d1c47e663bfbd238c5ed6d40c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:50:03 -0700 Subject: [PATCH 06/49] fix(native-chat): keep a merged Claude send unconfirmed once the row's owner is accepted Restart reconciliation dropped every id the journal had committed from every copy of text in the transcript. When Claude records two queued sends in one row under the later send's id and that later send was already accepted, this also dropped the only copy of the earlier send's text, so the earlier send was called not delivered and offered for Retry, which sent it twice. A committed id now takes only one copy: the text it was committed with. A row that holds another send's text still vetoes that send's rejection, and a crashed repeat of an older delivered message is still decided not delivered. --- ...claude-structured-provider-history.test.ts | 127 +++++++++++++----- .../journal-restart-reconciliation.test.ts | 61 ++++++++- .../journal-restart-reconciliation.ts | 43 ++++-- .../journal-submission-reconciler.ts | 39 ++++-- 4 files changed, 208 insertions(+), 62 deletions(-) diff --git a/src/main/claude/claude-structured-provider-history.test.ts b/src/main/claude/claude-structured-provider-history.test.ts index 416baf5e8db5..5c5d1aca1e7d 100644 --- a/src/main/claude/claude-structured-provider-history.test.ts +++ b/src/main/claude/claude-structured-provider-history.test.ts @@ -8,9 +8,9 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from '../native-chat/agent-session-journal/journal-restart-reconciliation' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { claudePromptFingerprint } from './claude-structured-history-window' import { openClaudeProviderHistory } from './claude-structured-provider-history' const PROVIDER_SESSION = 'provider-1' @@ -31,35 +31,74 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } -/** A transcript holding only a turn Claude is still running, and a send handed over during it. */ -async function strandedMidTurn() { +async function writeTranscript(rows: Record[]): Promise { const projectDir = join(root, 'account', 'projects', 'work') await mkdir(projectDir, { recursive: true }) - const running = { + await writeFile( + join(projectDir, `${PROVIDER_SESSION}.jsonl`), + rows.map((row) => `${JSON.stringify(row)}\n`).join('') + ) +} + +function userRow(uuid: string, content: unknown): Record { + return { type: 'user', - uuid: 'running-turn', + uuid, parentUuid: null, sessionId: PROVIDER_SESSION, - message: { role: 'user', content: 'run the tests' } + message: { role: 'user', content } } - await writeFile(join(projectDir, `${PROVIDER_SESSION}.jsonl`), `${JSON.stringify(running)}\n`) - const journalDir = join(root, 'journal') - const journal = await journals.open({ identity: IDENTITY, journalDir }) +} + +function frameIdentity(uuid: string) { + return { provider: 'claude' as const, sessionId: PROVIDER_SESSION, uuid } +} + +type Journal = Awaited> + +/** A send handed over under frame `uuid`, fingerprinted as the send path does. */ +async function handOver(journal: Journal, clientMessageId: string, uuid: string, text: string) { await journal.appendSubmission({ - clientMessageId: 'cm-sent', - payloadFingerprint: digestPayload('ship it'), - body: userMessage('ship it'), + clientMessageId, + payloadFingerprint: claudePromptFingerprint(IDENTITY.sessionId, [{ type: 'text', text }]), + body: userMessage(text), fence: 1 }) await journal.resolveDispatch({ - clientMessageId: 'cm-sent', + clientMessageId, state: 'pending', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'sent' }, + providerIdentity: frameIdentity(uuid), fence: 1 }) +} + +/** Writes the journal as `write` leaves it, then restarts the host over it with no child running. */ +async function restartAfter(write: (journal: Journal) => Promise, hasLiveSession = false) { + const journalDir = join(root, 'journal') + await write(await journals.open({ identity: IDENTITY, journalDir })) const reopened = await journals.open({ identity: IDENTITY, journalDir }) await reopened.markPendingSubmissionsUnknown(2) - return reopened + const history = openClaudeProviderHistory({ + identity: IDENTITY, + accountHomePath: join(root, 'account'), + hasLiveSession + }) + await reconcileJournalSubmissionsAgainstHistory({ + journal: reopened, + fence: 2, + history: history! + }) + return Object.fromEntries( + reopened + .submissions() + .map((submission) => [submission.clientMessageId, submission.dispatchState]) + ) +} + +/** A transcript holding only a turn Claude is still running, and a send handed over during it. */ +async function strandedMidTurn(hasLiveSession: boolean) { + await writeTranscript([userRow('running-turn', 'run the tests')]) + return restartAfter((journal) => handOver(journal, 'cm-sent', 'sent', 'ship it'), hasLiveSession) } beforeEach(async () => { @@ -73,29 +112,51 @@ afterEach(async () => { describe('openClaudeProviderHistory', () => { it('leaves a send unknown while a turn runs, even with no anchor to read a window from', async () => { - const journal = await strandedMidTurn() - const history = openClaudeProviderHistory({ - identity: IDENTITY, - accountHomePath: join(root, 'account'), - hasLiveSession: true - }) - - await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history: history! }) - // Claude may not have written the record yet, so its absence proves nothing. - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + expect(await strandedMidTurn(true)).toEqual({ 'cm-sent': 'unknown' }) }) it('decides the same send not delivered once no child can still be writing', async () => { - const journal = await strandedMidTurn() - const history = openClaudeProviderHistory({ - identity: IDENTITY, - accountHomePath: join(root, 'account'), - hasLiveSession: false - }) + expect(await strandedMidTurn(false)).toEqual({ 'cm-sent': 'rejected' }) + }) + + it.each([false, true])( + 'leaves a send unknown when Claude merged it into the row of the send after it (that send accepted: %s)', + async (secondAccepted) => { + // Frames queued together behind a turn share one row under the later frame's uuid. + await writeTranscript([ + userRow('second', [ + { type: 'text', text: 'ship it' }, + { type: 'text', text: 'and test it' } + ]) + ]) + + const states = await restartAfter(async (journal) => { + await handOver(journal, 'cm-first', 'first', 'ship it') + await handOver(journal, 'cm-second', 'second', 'and test it') + if (secondAccepted) { + await journal.resolveDispatch({ + clientMessageId: 'cm-second', + state: 'accepted', + providerIdentity: frameIdentity('second'), + fence: 1 + }) + } + }) - await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history: history! }) + expect(states).toEqual({ 'cm-first': 'unknown', 'cm-second': 'accepted' }) + } + ) + + it('decides a repeat of an older message the journal holds not delivered', async () => { + await writeTranscript([userRow('older', 'ship it')]) + + const states = await restartAfter(async (journal) => { + await journal.appendItem(frameIdentity('older'), userMessage('ship it'), { fence: 1 }) + await handOver(journal, 'cm-repeat', 'repeat', 'ship it') + }) - expect(journal.submissions()[0]?.dispatchState).toBe('rejected') + // The only copy of the text is the older row's own, so the repeat is provably missing. + expect(states).toEqual({ 'cm-repeat': 'rejected' }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index a6697fa214f1..f954d21d26f2 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -11,7 +11,6 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, @@ -23,6 +22,7 @@ import { createTrackedJournalOpener } from './journal-store-test-open' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -48,6 +48,15 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +/** The fingerprint a send of this text carries, and the key history indexes its copies by. */ +function sendFingerprint(text: string): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body: userMessage(text) } + }) +} + const journals = createTrackedJournalOpener() async function open() { @@ -63,7 +72,7 @@ function history(uuid: string, text: string): ProviderHistoryItem { return { providerItemId: uuid, clientMessageId: null, - payloadFingerprint: digestPayload(text), + payloadFingerprint: sendFingerprint(text), identity: claudeIdentity(uuid) } } @@ -86,8 +95,8 @@ function wholeHistory(records: [string, string][]): ProviderRecordedHistory { const itemIdsByFingerprint = new Map() for (const [uuid, text] of records) { const key = agentJournalItemKey(claudeIdentity(uuid)) - itemIdsByFingerprint.set(digestPayload(text), [ - ...(itemIdsByFingerprint.get(digestPayload(text)) ?? []), + itemIdsByFingerprint.set(sendFingerprint(text), [ + ...(itemIdsByFingerprint.get(sendFingerprint(text)) ?? []), key ]) } @@ -108,7 +117,7 @@ async function appendSend( ): Promise { await journal.appendSubmission({ clientMessageId, - payloadFingerprint: digestPayload(text), + payloadFingerprint: sendFingerprint(text), body, fence: 1 }) @@ -284,7 +293,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_old', - payloadFingerprint: digestPayload('deploy the thing'), + payloadFingerprint: sendFingerprint('deploy the thing'), body: userMessage('deploy the thing'), fence: 1 }) @@ -313,12 +322,50 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) + it.each([ + ['a different text', 'ship it', 'and test it'], + ['the same text', 'yes', 'yes'] + ])( + 'leaves a send unknown when merged into the row of an accepted later send with %s', + async (_case, first, second) => { + // Frames queued together share the later frame's row; that frame settled live. + const journal = await open() + await appendSend(journal, 'cm_first', 'uuid-first', userMessage(first), first) + await appendSend(journal, 'cm_second', 'uuid-second', userMessage(second), second) + await journal.resolveDispatch({ + clientMessageId: 'cm_second', + state: 'accepted', + providerIdentity: claudeIdentity('uuid-second'), + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([], { + recorded: wholeHistory([ + ['uuid-second', first], + ['uuid-second', second] + ]) + }) + }) + + expect(settled).toEqual([]) + expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'unknown', + 'accepted' + ]) + } + ) + it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { const journal = await open() for (const id of ['cm_1', 'cm_2']) { await journal.appendSubmission({ clientMessageId: id, - payloadFingerprint: digestPayload('ping'), + payloadFingerprint: sendFingerprint('ping'), body: userMessage('ping'), fence: 1 }) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 8f2ca8bce3bd..f5670ec36655 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -21,9 +21,11 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import type { AgentSessionJournal } from './journal-store' import { reconcileSubmissions, + withoutOwnCopies, type ProviderHistorySource, type ProviderHistoryWindow } from './journal-submission-reconciler' @@ -61,6 +63,15 @@ function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmis }) } +/** The fingerprint a send of this body carries, the key the reducer aliases an echo by. */ +function sendFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + /** Items the journal already committed are not new evidence: leaving them * claimable would let an undelivered message match an older identical one. */ function unseenHistory( @@ -68,25 +79,37 @@ function unseenHistory( history: ProviderHistoryWindow ): ProviderHistoryWindow { const snapshot = journal.snapshot() - const committed = new Set(snapshot.items.map((item) => item.itemId)) // Accepted submissions alias their provider item to the optimistic `orca:*` // row, so the rendered item id alone does not identify the provider history // already consumed by the journal. - for (const submission of snapshot.submissions) { - if (submission.dispatchState === 'accepted' && submission.providerItemId) { - committed.add(submission.providerItemId) - } - } + const accepted = snapshot.submissions.flatMap( + ({ dispatchState, providerItemId, payloadFingerprint }) => + dispatchState === 'accepted' && providerItemId + ? [[providerItemId, payloadFingerprint] as const] + : [] + ) + const committed = new Set([ + ...snapshot.items.map((item) => item.itemId), + ...accepted.map(([itemId]) => itemId) + ]) const recorded = history.recorded return { ...history, items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))), + // A committed id takes only the copy of the text it was committed with, never its whole row: + // Claude records frames queued together in the last one's row, which then holds the only + // copy of an earlier send's text. recorded: recorded && { ...recorded, - itemIdsByFingerprint: new Map( - [...recorded.itemIdsByFingerprint].map(([fingerprint, itemIds]) => [ - fingerprint, - itemIds.filter((itemId) => !committed.has(itemId)) + itemIdsByFingerprint: withoutOwnCopies( + recorded.itemIdsByFingerprint, + new Map([ + ...snapshot.items.flatMap(({ itemId, body }) => + recorded.itemIds.has(itemId) && body.kind === 'message' && body.role === 'user' + ? [[itemId, sendFingerprint(snapshot.sessionId, body)] as const] + : [] + ), + ...accepted ]) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 3dee1a1a526f..9d07468a44a6 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -191,24 +191,39 @@ export function reconcileSubmissions(input: { ) } -/** Copies of each text left once every send found by its id takes its own: a leftover copy may be - * a send whose id the history does not hold. */ +/** + * The copies of each text left once every id known to be a send takes ONE copy: its own text. A + * row can hold other sends' text too (Claude merges frames queued together into the last one's + * row), so an id never takes the whole row, or the only copy of a merged send would go with it. + */ +export function withoutOwnCopies( + itemIdsByFingerprint: ReadonlyMap, + owners: ReadonlyMap +): Map { + const copies = new Map([...itemIdsByFingerprint].map(([key, itemIds]) => [key, [...itemIds]])) + for (const [itemId, fingerprint] of owners) { + const sameText = copies.get(fingerprint) + const at = sameText?.indexOf(itemId) ?? -1 + if (sameText && at >= 0) { + sameText.splice(at, 1) + } + } + return copies +} + +/** Copies of each text no send found by its id accounts for: one may be a send whose id the + * history does not hold. */ function unclaimedCopies( recorded: ProviderRecordedHistory | null | undefined, submissions: readonly AgentJournalSubmission[], identified: ReadonlySet ): Map { - const copies = new Map( - [...(recorded?.itemIdsByFingerprint ?? [])].map(([key, itemIds]) => [key, [...itemIds]]) + const owners = new Map( + submissions.flatMap(({ handedOverItemId: own, payloadFingerprint }) => + own && identified.has(own) ? [[own, payloadFingerprint] as const] : [] + ) ) - for (const submission of submissions) { - const own = submission.handedOverItemId - const sameText = - own && identified.has(own) ? copies.get(submission.payloadFingerprint) : undefined - if (own && sameText?.includes(own)) { - sameText.splice(sameText.indexOf(own), 1) - } - } + const copies = withoutOwnCopies(recorded?.itemIdsByFingerprint ?? new Map(), owners) return new Map([...copies].map(([key, itemIds]) => [key, itemIds.length])) } From 2aeb8b0887ab71c17a982872e0295db5fd762715 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:51:30 -0700 Subject: [PATCH 07/49] test(native-chat): pin the Claude adapter sending a frame under its recorded id --- ...laude-structured-dispatch-identity.test.ts | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-structured-dispatch-identity.test.ts b/src/main/claude/claude-structured-dispatch-identity.test.ts index 1e78a574e45c..f6e59a2f4bd1 100644 --- a/src/main/claude/claude-structured-dispatch-identity.test.ts +++ b/src/main/claude/claude-structured-dispatch-identity.test.ts @@ -5,7 +5,10 @@ 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 { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../shared/agent-session-journal-item-key' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -16,6 +19,12 @@ import { mintClaudeDispatchIdentity } from './claude-structured-dispatch-content' import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' +import { + acquired, + fakeClaude, + PROVIDER_SESSION_ID, + USER_MESSAGE +} from './claude-structured-session-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -114,3 +123,31 @@ describe('handing a send over under its identity', () => { }) }) }) + +describe('the Claude adapter handing a send over', () => { + it('writes the frame under the id the journal recorded for it', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await acquired(claude) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + await journal.appendSubmission({ + clientMessageId: 'cm-1', + payloadFingerprint: 'fingerprint', + body: USER_MESSAGE, + fence: 7, + handoverRecorded: true + }) + + await handOverSubmission( + { sessionId: 'session-1', journal, fence: 7, adapter }, + journal.submissions()[0]! + ) + + const recorded = parseAgentJournalItemKey(journal.submissions()[0]?.handedOverItemId ?? '') + const uuid = + recorded?.provider === 'claude' && recorded.sessionId === PROVIDER_SESSION_ID + ? recorded.uuid + : null + expect(uuid).toEqual(expect.any(String)) + expect(claude.connections[0]?.sent[0]).toMatchObject({ uuid }) + }) +}) From 50b9bf2bfa917afa6e8f292ad858397e6a54b59b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:03:37 -0700 Subject: [PATCH 08/49] fix(native-chat): decide a restarted Claude send only by finding its frame id A send found under its own frame id is delivered; one that is not found stays unconfirmed. Claude records frames queued together under the last one's id, so a delivered send can leave no record of its own, and calling it not delivered hid it and offered a Retry that sent it twice. Restart reconciliation no longer decides `rejected` at all, which removes the same-content check, the per-block text index and the whole-file flag that only served that verdict. --- .../claude-structured-history-window.test.ts | 43 ++---- .../claude-structured-history-window.ts | 17 +-- ...claude-structured-provider-history.test.ts | 32 ++-- .../claude-structured-provider-history.ts | 105 +++---------- .../journal-crash-boundary.test.ts | 29 +--- .../journal-restart-reconciliation.test.ts | 143 ++++++------------ .../journal-restart-reconciliation.ts | 93 +++--------- .../journal-submission-reconciler.ts | 107 +++---------- ...-agent-session-acquisition-options.test.ts | 32 ++-- .../structured-agent-session-attach-flow.ts | 9 +- ...gent-session-attach-reconciliation.test.ts | 43 ++---- ...ssion-restart-interruption-test-harness.ts | 12 +- ...ed-agent-session-restart-ownership.test.ts | 10 +- 13 files changed, 182 insertions(+), 493 deletions(-) diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index ccfaabbd7b49..685100caa63d 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -479,49 +479,36 @@ describe('a send handed over under its own frame id', () => { expect( verdicts(contents, 'anchor', [handedOver('ship it'), handedOver('and test it', 'next')]) ).toMatchObject([ - { outcome: 'unknown', reason: 'ambiguous_match' }, + { outcome: 'unknown', reason: 'not_found' }, { outcome: 'accepted', providerItemId: key('next') } ]) }) - it('stays unknown when the send merged with it had the same text', () => { - const merged = [ - { type: 'text', text: 'yes' }, - { type: 'text', text: 'yes' } - ] - const contents = jsonl([ANCHOR, prompt('next', 'anchor', merged)], 'next') - - expect( - verdicts(contents, 'anchor', [handedOver('yes'), handedOver('yes', 'next')]) - ).toMatchObject([{ outcome: 'unknown' }, { outcome: 'accepted' }]) - }) - - it('is not delivered when the only copy of its text is an identical send found by id', () => { - const contents = jsonl([ANCHOR, prompt('first', 'anchor', 'yes')], 'first') - - expect( - verdicts(contents, 'anchor', [handedOver('yes', 'first'), handedOver('yes')]) - ).toMatchObject([{ outcome: 'accepted' }, { outcome: 'rejected' }]) - }) - - it('is not delivered when the whole file lacks it', () => { + it('stays unknown, never not delivered, when the whole file lacks it', () => { const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'something else')], 'u-1') - expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'rejected' }) + expect(verdict(contents, 'anchor', 'ship it')).toEqual({ + clientMessageId: 'cm-sent', + outcome: 'unknown', + reason: 'not_found' + }) }) - it('stays unknown when a line of the file does not parse', () => { - const contents = `${jsonl([ANCHOR], 'anchor')}{"type":"user","uuid":"sent"` + it('is found past a line that does not parse', () => { + const contents = `${jsonl([ANCHOR], 'anchor')}{"type":"user","uuid":"torn"\n${jsonl( + [prompt('sent', 'anchor', 'ship it')], + 'sent' + )}` - expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'unknown' }) + expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'accepted' }) }) - it('stays unknown when a record under another id holds the same text', () => { + it('is not accepted by a record of its text under another id', () => { const contents = jsonl([ANCHOR, prompt('minted', 'anchor', 'ship it')], 'minted') expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'unknown', - reason: 'ambiguous_match' + reason: 'not_found' }) }) }) diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index fc7991cc3d69..850bd5c156ea 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -1,18 +1,16 @@ // Provider history for restart reconciliation, read from the Claude project JSONL. // // Why this file is the source of truth for "did Claude take it": a resume replays -// this transcript by session id, so a message absent from it is absent from the -// conversation Orca is about to resume. Absence here is not an inference about a -// dead child — it is the content of the next turn's context. +// this transcript by session id, so a message found in it is in the conversation +// Orca is about to resume. // // The window is anchored on the leaf uuid Orca durably recorded for the session // and walks back to it from the file's last transcript row, which is where a // resume by session id continues; Claude's marker lags a crash mid-turn. // Without that anchor the read has no proven start, and the branch proof is what // decides whether the file we just read still descends from it: a fork, a -// compaction, a sibling branch, or a torn tail all fail the proof, and every one -// of those makes absence meaningless. Failing it reports an inconsistent -// boundary rather than an empty window, because the two decide opposite things. +// compaction, a sibling branch, or a torn tail all fail the proof, and report an +// inconsistent boundary rather than an empty window. import type { ProviderHistoryItem, @@ -140,7 +138,7 @@ function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null * produced the envelope. Matching here is therefore an equality between two runs * of one function, not a guess about two encodings agreeing. */ -export function claudePromptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { +function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { return computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId, @@ -153,8 +151,7 @@ export type HistoryWindowInput = { previousLeafUuid: string | null /** Orca session id: the fingerprint a submission carries is scoped to it. */ sessionId: string - /** The caller must PROVE no provider child can be appending; absence proves - * nothing while a turn is running. */ + /** A provider child may still be appending to the file. */ turnInFlight: boolean } @@ -177,7 +174,7 @@ function createWindowCollector(input: HistoryWindowInput) { // Claude echoes no client message id, so identity matching reduces to the // fingerprint pass; the reconciler treats that as the weakest evidence. clientMessageId: null, - payloadFingerprint: claudePromptFingerprint(input.sessionId, blocks), + payloadFingerprint: promptFingerprint(input.sessionId, blocks), identity: { provider: 'claude', sessionId: stringField(record, 'sessionId') ?? input.providerSessionId, diff --git a/src/main/claude/claude-structured-provider-history.test.ts b/src/main/claude/claude-structured-provider-history.test.ts index 5c5d1aca1e7d..84f5c9238765 100644 --- a/src/main/claude/claude-structured-provider-history.test.ts +++ b/src/main/claude/claude-structured-provider-history.test.ts @@ -10,7 +10,7 @@ import type { } from '../../shared/agent-session-journal-types' import { reconcileJournalSubmissionsAgainstHistory } from '../native-chat/agent-session-journal/journal-restart-reconciliation' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import { claudePromptFingerprint } from './claude-structured-history-window' +import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' import { openClaudeProviderHistory } from './claude-structured-provider-history' const PROVIDER_SESSION = 'provider-1' @@ -56,11 +56,11 @@ function frameIdentity(uuid: string) { type Journal = Awaited> -/** A send handed over under frame `uuid`, fingerprinted as the send path does. */ +/** A send handed over under frame `uuid`. */ async function handOver(journal: Journal, clientMessageId: string, uuid: string, text: string) { await journal.appendSubmission({ clientMessageId, - payloadFingerprint: claudePromptFingerprint(IDENTITY.sessionId, [{ type: 'text', text }]), + payloadFingerprint: digestPayload(text), body: userMessage(text), fence: 1 }) @@ -111,14 +111,12 @@ afterEach(async () => { }) describe('openClaudeProviderHistory', () => { - it('leaves a send unknown while a turn runs, even with no anchor to read a window from', async () => { - // Claude may not have written the record yet, so its absence proves nothing. - expect(await strandedMidTurn(true)).toEqual({ 'cm-sent': 'unknown' }) - }) - - it('decides the same send not delivered once no child can still be writing', async () => { - expect(await strandedMidTurn(false)).toEqual({ 'cm-sent': 'rejected' }) - }) + it.each([true, false])( + 'leaves a send its id is missing from unknown, never not delivered (child still running: %s)', + async (hasLiveSession) => { + expect(await strandedMidTurn(hasLiveSession)).toEqual({ 'cm-sent': 'unknown' }) + } + ) it.each([false, true])( 'leaves a send unknown when Claude merged it into the row of the send after it (that send accepted: %s)', @@ -147,16 +145,4 @@ describe('openClaudeProviderHistory', () => { expect(states).toEqual({ 'cm-first': 'unknown', 'cm-second': 'accepted' }) } ) - - it('decides a repeat of an older message the journal holds not delivered', async () => { - await writeTranscript([userRow('older', 'ship it')]) - - const states = await restartAfter(async (journal) => { - await journal.appendItem(frameIdentity('older'), userMessage('ship it'), { fence: 1 }) - await handOver(journal, 'cm-repeat', 'repeat', 'ship it') - }) - - // The only copy of the text is the older row's own, so the repeat is provably missing. - expect(states).toEqual({ 'cm-repeat': 'rejected' }) - }) }) diff --git a/src/main/claude/claude-structured-provider-history.ts b/src/main/claude/claude-structured-provider-history.ts index d1c3293a6d39..e9b8a00cbe8c 100644 --- a/src/main/claude/claude-structured-provider-history.ts +++ b/src/main/claude/claude-structured-provider-history.ts @@ -1,18 +1,15 @@ // What restart reconciliation reads from Claude: the anchored window for sends handed over with no -// id, and every user frame the transcript holds for sends handed over under one. +// id, and the id of every user frame the transcript holds for sends handed over under one. // -// A frame is found by its id wherever it sits, so the id read needs no start point. What it does -// need is Claude's own recording rules: a frame folded into a running turn gets no row of its own, -// only a `queued_command` attachment naming it by `source_uuid`; and frames queued together behind -// a turn are merged into ONE row under the last frame's uuid, so the others leave only their text. +// A frame is found by its id wherever it sits, so the id read needs no start point. A frame folded +// into a running turn gets no row of its own, only a `queued_command` attachment naming it by +// `source_uuid`. Frames queued together behind a turn are merged into ONE row under the last +// frame's uuid, so the others cannot be found and stay unconfirmed. import { createReadStream } from 'node:fs' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../shared/agent-session-journal-item-key' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { ProviderHistorySource, ProviderRecordedHistory @@ -20,7 +17,6 @@ import type { import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' import { - claudePromptFingerprint, MAX_HISTORY_WINDOW_RECORD_BYTES, readClaudeProviderHistoryWindow, type HistoryWindowInput @@ -40,90 +36,39 @@ function stringField(source: Record | null, key: string): strin return typeof value === 'string' && value.trim() ? value : null } -/** The id and content a user frame was recorded under, as Claude's own readers take them. */ -function recordedUserFrame( - row: Record -): { uuid: string | null; content: unknown } | null { +/** The id a user frame was recorded under, as Claude's own readers take it. */ +function recordedFrameUuid(row: Record): string | null { if (row.isSidechain === true) { return null } if (row.type === 'user') { - return { uuid: stringField(row, 'uuid'), content: asRecord(row.message)?.content } + return stringField(row, 'uuid') } const attachment = row.type === 'attachment' ? asRecord(row.attachment) : null return attachment?.type === 'queued_command' - ? { - uuid: stringField(attachment, 'source_uuid') ?? stringField(row, 'uuid'), - content: attachment.prompt - } + ? (stringField(attachment, 'source_uuid') ?? stringField(row, 'uuid')) : null } -function textPart(part: unknown): string | null { - if (typeof part === 'string') { - return part - } - const block = asRecord(part) - return block?.type === 'text' && typeof block.text === 'string' ? block.text : null -} - -/** Each text part on its own: a frame merged into another's row keeps its text there as a block. */ -function textParts(content: unknown): string[] { - const parts = - typeof content === 'string' ? [content] : Array.isArray(content) ? content.map(textPart) : [] - return parts.filter((text): text is string => typeof text === 'string' && text.trim() !== '') -} - -/** - * Every user frame in the file. Absence counts only when every line of this session's file was - * read; any line that does not parse may be the missing record. - */ +/** Every user frame in the file. A line that does not parse is skipped: it can only hide a send, + * which then stays unconfirmed. */ function createRecordedCollector(input: HistoryWindowInput) { const itemIds = new Set() - const itemIdsByFingerprint = new Map() - let whole = true - return { add, finish } - - function add(line: string): void { - if (!line.trim()) { - return - } - let parsed: unknown - try { - parsed = JSON.parse(line) - } catch { - whole = false - return - } - const row = asRecord(parsed) - const frame = row ? recordedUserFrame(row) : null - if (!row || !frame?.uuid) { - return - } - const sessionId = stringField(row, 'sessionId') ?? input.providerSessionId - const itemId = agentJournalItemKey({ provider: 'claude', sessionId, uuid: frame.uuid }) - itemIds.add(itemId) - // Keyed as a one-text-block send is fingerprinted: the only kind reconciled (`comparableBody`). - for (const text of textParts(frame.content)) { - const fingerprint = claudePromptFingerprint(input.sessionId, [{ type: 'text', text }]) - const copies = itemIdsByFingerprint.get(fingerprint) - if (copies) { - copies.push(itemId) - } else { - itemIdsByFingerprint.set(fingerprint, [itemId]) + return { + add(line: string): void { + let row: Record | null = null + try { + row = line.trim() ? asRecord(JSON.parse(line)) : null + } catch { + return } - } - } - - function finish(): ProviderRecordedHistory { - return { - itemIds, - itemIdsByFingerprint, - provesAbsenceOf: (itemId) => { - const identity = whole ? parseAgentJournalItemKey(itemId) : null - return identity?.provider === 'claude' && identity.sessionId === input.providerSessionId + const uuid = row && recordedFrameUuid(row) + if (row && uuid) { + const sessionId = stringField(row, 'sessionId') ?? input.providerSessionId + itemIds.add(agentJournalItemKey({ provider: 'claude', sessionId, uuid })) } - } + }, + finish: (): ProviderRecordedHistory => ({ itemIds }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 7b94fbe1561b..553f56bfe331 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -86,7 +86,7 @@ function history(input: { /** A history read whole, holding exactly these item keys. */ function wholeHistory(itemIds: string[]): ProviderRecordedHistory { - return { itemIds: new Set(itemIds), itemIdsByFingerprint: new Map(), provesAbsenceOf: () => true } + return { itemIds: new Set(itemIds) } } function window( @@ -233,7 +233,7 @@ describe('crash between provider accept and journal commit', () => { }) }) - it('reports a rejected submission as never delivered, and never re-sends it', async () => { + it('leaves a handed-over send unconfirmed when history lacks it, and never re-sends it', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -255,26 +255,11 @@ describe('crash between provider accept and journal commit', () => { submissions: restarted.submissions(), history: window([], { recorded: wholeHistory([]) }) }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'rejected', - reason: 'not_delivered' - }) - - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: 2, - recovered: true - }) - // The bubble survives with an explicit terminal state — the message is not - // silently retried and not silently dropped. + // A merged frame leaves no record under its own id, so a missing id is not proof of loss. + expect(outcome).toEqual({ clientMessageId: 'cm_1', outcome: 'unknown', reason: 'not_found' }) + // The bubble survives, unconfirmed: the message is not silently retried or dropped. expect(restarted.snapshot().items).toHaveLength(1) - expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') - expect(restarted.receiptFor('cm_1')).toBeNull() + expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('unknown') }) it('survives replay of an already-reconciled journal without changing the answer', async () => { @@ -411,7 +396,7 @@ describe('reconciliation matching', () => { ]) }) // Absent from a window that starts at the last completed turn, not at this send. - expect(outcome).toMatchObject({ outcome: 'unknown', reason: 'no_dispatch_identity' }) + expect(outcome).toMatchObject({ outcome: 'unknown', reason: 'not_found' }) }) it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index f954d21d26f2..c59c89c87a15 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -11,6 +11,7 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, @@ -19,10 +20,6 @@ import type { ProviderRecordedHistory } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -48,15 +45,6 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } -/** The fingerprint a send of this text carries, and the key history indexes its copies by. */ -function sendFingerprint(text: string): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: IDENTITY.sessionId, - fields: { body: userMessage(text) } - }) -} - const journals = createTrackedJournalOpener() async function open() { @@ -72,7 +60,7 @@ function history(uuid: string, text: string): ProviderHistoryItem { return { providerItemId: uuid, clientMessageId: null, - payloadFingerprint: sendFingerprint(text), + payloadFingerprint: digestPayload(text), identity: claudeIdentity(uuid) } } @@ -90,21 +78,9 @@ function window( } } -/** The whole transcript, holding these records; each is `[uuid, text]`. */ -function wholeHistory(records: [string, string][]): ProviderRecordedHistory { - const itemIdsByFingerprint = new Map() - for (const [uuid, text] of records) { - const key = agentJournalItemKey(claudeIdentity(uuid)) - itemIdsByFingerprint.set(sendFingerprint(text), [ - ...(itemIdsByFingerprint.get(sendFingerprint(text)) ?? []), - key - ]) - } - return { - itemIds: new Set(records.map(([uuid]) => agentJournalItemKey(claudeIdentity(uuid)))), - itemIdsByFingerprint, - provesAbsenceOf: () => true - } +/** The whole transcript, holding these frame ids. */ +function wholeHistory(uuids: string[]): ProviderRecordedHistory { + return { itemIds: new Set(uuids.map((uuid) => agentJournalItemKey(claudeIdentity(uuid)))) } } /** Appends a send and, when `handedOverAs` names one, hands it over under that frame id. */ @@ -117,7 +93,7 @@ async function appendSend( ): Promise { await journal.appendSubmission({ clientMessageId, - payloadFingerprint: sendFingerprint(text), + payloadFingerprint: digestPayload(text), body, fence: 1 }) @@ -170,30 +146,18 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) }) - it('settles a message provably absent from history as rejected: not_delivered', async () => { + it('leaves a send its frame id is missing from unconfirmed, never not delivered', async () => { const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, - history: window([], { recorded: wholeHistory([['uuid-other', 'something else']]) }) + history: window([], { recorded: wholeHistory(['uuid-other']) }) }) - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - // A sentence, since released clients print the reason as it is, and the fact beside it. - expect(submission?.reason).toBe( - agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) - .reason - ) - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) - // Nobody failed: the crash stranded it before the provider took it. - expect(submission && classifyDispatchRejection(submission)).toEqual({ - category: 'undelivered', - verdict: null, - kind: 'notDelivered' - }) + expect(settled).toEqual([]) + expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'unknown' }) + expect(journal.submissions()[0]?.rejection).toBeUndefined() }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { @@ -222,7 +186,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(journal.submissions()[0]?.dispatchState).toBe('unknown') }) - it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { + it('leaves a submission carrying an attachment it cannot fingerprint unknown', async () => { const journal = await reopenAfterCrash( { kind: 'message', @@ -274,26 +238,24 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { fence: 1 }) - await appendSend(journal, 'cm_1', 'uuid-sent') + await appendSend(journal, 'cm_1', null) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) await reconcileJournalSubmissionsAgainstHistory({ journal: restarted, fence: 2, - history: window([history('uuid-old', 'deploy the thing')], { - recorded: wholeHistory([['uuid-old', 'deploy the thing']]) - }) + history: window([history('uuid-old', 'deploy the thing')]) }) - expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') + expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') }) it('does not let an older accepted provider item stand in for a new identical send', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_old', - payloadFingerprint: sendFingerprint('deploy the thing'), + payloadFingerprint: digestPayload('deploy the thing'), body: userMessage('deploy the thing'), fence: 1 }) @@ -303,56 +265,50 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { providerIdentity: claudeIdentity('uuid-old'), fence: 1 }) - await appendSend(journal, 'cm_new', 'uuid-new') + await appendSend(journal, 'cm_new', null) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) await reconcileJournalSubmissionsAgainstHistory({ journal: restarted, fence: 2, - history: window([history('uuid-old', 'deploy the thing')], { - recorded: wholeHistory([['uuid-old', 'deploy the thing']]) - }) + history: window([history('uuid-old', 'deploy the thing')]) }) expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ 'accepted', - 'rejected' + 'unknown' ]) - expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) it.each([ - ['a different text', 'ship it', 'and test it'], - ['the same text', 'yes', 'yes'] + ['accepted live, with a different text', true, 'ship it', 'and test it'], + ['accepted live, with the same text', true, 'yes', 'yes'], + ['also stranded', false, 'ship it', 'and test it'] ])( - 'leaves a send unknown when merged into the row of an accepted later send with %s', - async (_case, first, second) => { - // Frames queued together share the later frame's row; that frame settled live. + 'leaves a send unknown when merged into the row of a later send %s', + async (_case, secondAcceptedLive, first, second) => { + // Frames queued together share the later frame's row, which holds both texts. const journal = await open() await appendSend(journal, 'cm_first', 'uuid-first', userMessage(first), first) await appendSend(journal, 'cm_second', 'uuid-second', userMessage(second), second) - await journal.resolveDispatch({ - clientMessageId: 'cm_second', - state: 'accepted', - providerIdentity: claudeIdentity('uuid-second'), - fence: 1 - }) + if (secondAcceptedLive) { + await journal.resolveDispatch({ + clientMessageId: 'cm_second', + state: 'accepted', + providerIdentity: claudeIdentity('uuid-second'), + fence: 1 + }) + } const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) - const settled = await reconcileJournalSubmissionsAgainstHistory({ + await reconcileJournalSubmissionsAgainstHistory({ journal: restarted, fence: 2, - history: window([], { - recorded: wholeHistory([ - ['uuid-second', first], - ['uuid-second', second] - ]) - }) + history: window([], { recorded: wholeHistory(['uuid-second']) }) }) - expect(settled).toEqual([]) expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ 'unknown', 'accepted' @@ -365,7 +321,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { for (const id of ['cm_1', 'cm_2']) { await journal.appendSubmission({ clientMessageId: id, - payloadFingerprint: sendFingerprint('ping'), + payloadFingerprint: digestPayload('ping'), body: userMessage('ping'), fence: 1 }) @@ -384,6 +340,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'unknown' ]) }) + it('accepts a send found by its frame id even where the anchored window cannot see it', async () => { // The durable anchor moved past the send, so the window after it is empty. const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') @@ -391,7 +348,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, - history: window([], { recorded: wholeHistory([['uuid-sent', 'deploy the thing']]) }) + history: window([], { recorded: wholeHistory(['uuid-sent']) }) }) expect(settled).toEqual(['cm_1']) @@ -414,7 +371,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { it('reads only the recorded history when every stranded send carries a frame id', async () => { const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') - const history = window([], { recorded: wholeHistory([['uuid-sent', 'deploy the thing']]) }) + const history = window([], { recorded: wholeHistory(['uuid-sent']) }) await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history }) @@ -439,35 +396,21 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, - history: window([], { recorded: wholeHistory([]) }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a send unknown when an unclaimed record holds its text under another id', async () => { - // What a provider build that chose its own id would leave behind. - const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { recorded: wholeHistory([['uuid-minted', 'deploy the thing']]) }) + history: window([]) }) expect(settled).toEqual([]) expect(journal.submissions()[0]?.dispatchState).toBe('unknown') }) - it('leaves a send unknown when the history was not read whole', async () => { + it('never accepts a send handed over under an id by a record of its text under another', async () => { const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, - history: window([], { - recorded: { ...wholeHistory([]), provesAbsenceOf: () => false } + history: window([history('uuid-minted', 'deploy the thing')], { + recorded: wholeHistory(['uuid-minted']) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index f5670ec36655..c399be7f5d0a 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -1,17 +1,12 @@ // The production caller for `reconcileSubmissions`. // // Runs once per journal open, after the crash boundary has already settled every -// survivor to `unknown`. It only ever narrows that answer: `accepted` when the -// provider's own history holds the message, `rejected` when a history read whole -// lacks the id the send was handed over under. Anything the reconciler leaves -// `unknown` is left exactly as the crash boundary wrote it. -// -// Nothing here dispatches. A `rejected` submission becomes re-sendable only -// through the user's Retry, which rotates the client message id; Orca still -// never puts a message back on the wire on the user's behalf. +// survivor to `unknown`. It only ever narrows that answer, to `accepted` when the +// provider's own history holds the message. It never decides `rejected`: history +// cannot show a send never arrived, and a wrong "not delivered" invites a Retry +// that sends a delivered message twice. Anything the reconciler leaves `unknown` +// is left exactly as the crash boundary wrote it, and nothing here dispatches. -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -21,11 +16,9 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import type { AgentSessionJournal } from './journal-store' import { reconcileSubmissions, - withoutOwnCopies, type ProviderHistorySource, type ProviderHistoryWindow } from './journal-submission-reconciler' @@ -33,9 +26,8 @@ import { /** * Only a text-only body can be compared against provider content. A submission * carrying an attachment was fingerprinted over an `image-ref` path the - * transcript does not keep, so its absence from history would be an artefact of - * the encoding rather than evidence — and `rejected` is the one outcome that - * costs the user a duplicate if it is wrong. Those stay `unknown`. + * transcript does not keep, so matching it against history would test the + * encoding rather than the message. Those stay `unknown`. */ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { return ( @@ -63,15 +55,6 @@ function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmis }) } -/** The fingerprint a send of this body carries, the key the reducer aliases an echo by. */ -function sendFingerprint(sessionId: string, body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - /** Items the journal already committed are not new evidence: leaving them * claimable would let an undelivered message match an older identical one. */ function unseenHistory( @@ -79,40 +62,18 @@ function unseenHistory( history: ProviderHistoryWindow ): ProviderHistoryWindow { const snapshot = journal.snapshot() + const committed = new Set(snapshot.items.map((item) => item.itemId)) // Accepted submissions alias their provider item to the optimistic `orca:*` // row, so the rendered item id alone does not identify the provider history // already consumed by the journal. - const accepted = snapshot.submissions.flatMap( - ({ dispatchState, providerItemId, payloadFingerprint }) => - dispatchState === 'accepted' && providerItemId - ? [[providerItemId, payloadFingerprint] as const] - : [] - ) - const committed = new Set([ - ...snapshot.items.map((item) => item.itemId), - ...accepted.map(([itemId]) => itemId) - ]) - const recorded = history.recorded + for (const submission of snapshot.submissions) { + if (submission.dispatchState === 'accepted' && submission.providerItemId) { + committed.add(submission.providerItemId) + } + } return { ...history, - items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))), - // A committed id takes only the copy of the text it was committed with, never its whole row: - // Claude records frames queued together in the last one's row, which then holds the only - // copy of an earlier send's text. - recorded: recorded && { - ...recorded, - itemIdsByFingerprint: withoutOwnCopies( - recorded.itemIdsByFingerprint, - new Map([ - ...snapshot.items.flatMap(({ itemId, body }) => - recorded.itemIds.has(itemId) && body.kind === 'message' && body.role === 'user' - ? [[itemId, sendFingerprint(snapshot.sessionId, body)] as const] - : [] - ), - ...accepted - ]) - ) - } + items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) } } @@ -163,25 +124,13 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { }) continue } - await input.journal.resolveDispatch( - outcome.outcome === 'accepted' - ? { - clientMessageId: outcome.clientMessageId, - state: 'accepted', - providerIdentity: outcome.identity, - fence: input.fence, - recovered: true - } - : { - clientMessageId: outcome.clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: input.fence, - recovered: true - } - ) + await input.journal.resolveDispatch({ + clientMessageId: outcome.clientMessageId, + state: 'accepted', + providerIdentity: outcome.identity, + fence: input.fence, + recovered: true + }) settled.push(outcome.clientMessageId) } return settled diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 9d07468a44a6..c9c06071aa48 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -5,14 +5,14 @@ // `pending` becomes `unknown` and is then matched against provider history. // // A send handed over under an id the provider adopts is decided by that id alone, -// looked up across the whole history: present means delivered, absent from a -// history read whole means not — unless a copy of its text no other send's id -// accounts for could be it. Older sends carry no such id and fall back to -// the anchored window — an echoed client message id, else a provider item id the -// journal already adopted, else the payload fingerprint when it picks out -// exactly one unclaimed item. That window is not scoped to the send, so absence -// from it proves nothing and those sends stay `unknown`. Never by text equality: -// the same question asked twice is two messages, and collapsing them loses one. +// looked up across the whole history: found means delivered. Not found decides +// nothing, because a delivered frame can leave no record under its own id — +// Claude records frames queued together under the last one's id — so after a +// restart no send is ever called not delivered. Older sends carry no such id and +// fall back to the anchored window — an echoed client message id, else a +// provider item id the journal already adopted, else the payload fingerprint +// when it picks out exactly one unclaimed item. Never by text equality: the +// same question asked twice is two messages, and collapsing them loses one. // // Orca never re-sends on the user's behalf. An unresolved submission stays // `unknown` — a displayed state meaning "delivery unconfirmed", neither sent nor @@ -26,7 +26,6 @@ import { agentJournalItemKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { /** The provider's own id for this item. Used to claim it at most once; the @@ -46,11 +45,10 @@ export type ProviderHistoryWindow = { items: readonly ProviderHistoryItem[] /** * The history read actually started at the journal's last committed item. A - * fork, a compacted provider log, or a truncated read makes absence - * meaningless, so a missing submission cannot be called "not delivered". + * fork, a compacted provider log, or a truncated read breaks that. */ boundaryConsistent: boolean - /** The provider reports a turn still running: absence proves nothing yet. */ + /** The provider reports a turn still running: a send may not be recorded yet. */ turnInFlight: boolean /** Every user item the history holds, not only those after the anchor. Absent when the * provider has no whole-history read; sends handed over under an id then stay `unknown`. */ @@ -60,12 +58,6 @@ export type ProviderHistoryWindow = { export type ProviderRecordedHistory = { /** Journal keys of every user item in the history. */ itemIds: ReadonlySet - /** Keys of the items holding each text block, once per copy, by that block's payload - * fingerprint. A send recorded under an id Orca did not choose, or merged into another send's - * item, leaves only its text behind, so a copy no send found by id accounts for vetoes absence. */ - itemIdsByFingerprint: ReadonlyMap - /** The history was read whole and is where an item with this key would have been recorded. */ - provesAbsenceOf: (itemId: string) => boolean } /** @@ -88,16 +80,13 @@ export type SubmissionReconciliation = providerItemId: string identity: AgentJournalItemIdentity } - | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } -export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED - export type SubmissionUnknownReason = | 'history_boundary_inconsistent' | 'turn_in_flight' | 'ambiguous_match' - | 'no_dispatch_identity' + | 'not_found' /** * Resolve every unsettled submission against provider history. @@ -177,80 +166,22 @@ export function reconcileSubmissions(input: { } } - const unclaimed = unclaimedCopies(recorded, unsettled, identified) return unsettled.map((submission) => submission.handedOverItemId - ? resolveByIdentity( - submission, - submission.handedOverItemId, - identified, - unclaimed, - input.history - ) + ? resolveByIdentity(submission.clientMessageId, submission.handedOverItemId, identified) : resolveOne(submission, matched, ambiguous, input.history) ) } -/** - * The copies of each text left once every id known to be a send takes ONE copy: its own text. A - * row can hold other sends' text too (Claude merges frames queued together into the last one's - * row), so an id never takes the whole row, or the only copy of a merged send would go with it. - */ -export function withoutOwnCopies( - itemIdsByFingerprint: ReadonlyMap, - owners: ReadonlyMap -): Map { - const copies = new Map([...itemIdsByFingerprint].map(([key, itemIds]) => [key, [...itemIds]])) - for (const [itemId, fingerprint] of owners) { - const sameText = copies.get(fingerprint) - const at = sameText?.indexOf(itemId) ?? -1 - if (sameText && at >= 0) { - sameText.splice(at, 1) - } - } - return copies -} - -/** Copies of each text no send found by its id accounts for: one may be a send whose id the - * history does not hold. */ -function unclaimedCopies( - recorded: ProviderRecordedHistory | null | undefined, - submissions: readonly AgentJournalSubmission[], - identified: ReadonlySet -): Map { - const owners = new Map( - submissions.flatMap(({ handedOverItemId: own, payloadFingerprint }) => - own && identified.has(own) ? [[own, payloadFingerprint] as const] : [] - ) - ) - const copies = withoutOwnCopies(recorded?.itemIdsByFingerprint ?? new Map(), owners) - return new Map([...copies].map(([key, itemIds]) => [key, itemIds.length])) -} - function resolveByIdentity( - submission: AgentJournalSubmission, + clientMessageId: string, itemId: string, - identified: ReadonlySet, - unclaimed: ReadonlyMap, - history: ProviderHistoryWindow + identified: ReadonlySet ): SubmissionReconciliation { - const { clientMessageId } = submission - if (identified.has(itemId)) { - const identity = parseAgentJournalItemKey(itemId) - return identity - ? { clientMessageId, outcome: 'accepted', providerItemId: itemId, identity } - : { clientMessageId, outcome: 'unknown', reason: 'history_boundary_inconsistent' } - } - if (history.turnInFlight) { - return { clientMessageId, outcome: 'unknown', reason: 'turn_in_flight' } - } - if (!history.recorded?.provesAbsenceOf(itemId)) { - return { clientMessageId, outcome: 'unknown', reason: 'history_boundary_inconsistent' } - } - if ((unclaimed.get(submission.payloadFingerprint) ?? 0) > 0) { - return { clientMessageId, outcome: 'unknown', reason: 'ambiguous_match' } - } - return { clientMessageId, outcome: 'rejected', reason: DISPATCH_REJECTED_NOT_DELIVERED } + const identity = identified.has(itemId) ? parseAgentJournalItemKey(itemId) : null + return identity + ? { clientMessageId, outcome: 'accepted', providerItemId: itemId, identity } + : { clientMessageId, outcome: 'unknown', reason: 'not_found' } } function claimBy( @@ -315,6 +246,6 @@ function resolveOne( return { clientMessageId: submission.clientMessageId, outcome: 'unknown', - reason: 'no_dispatch_identity' + reason: 'not_found' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 4f01aa081a37..e5cb408ca888 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -120,15 +120,15 @@ describe('structured session acquisition options', () => { hostId: 'local' }) let childAcquired = false - const historyWindow = (): ProviderHistorySource => ({ - turnInFlight: childAcquired, - readWindow: async () => ({ items: [], boundaryConsistent: true }), - readRecorded: async () => ({ - itemIds: new Set(), - itemIdsByFingerprint: new Map(), - provesAbsenceOf: () => true - }) - }) + const sampledWithChild: boolean[] = [] + const historyWindow = (): ProviderHistorySource => { + sampledWithChild.push(childAcquired) + return { + turnInFlight: childAcquired, + readWindow: async () => ({ items: [], boundaryConsistent: true }), + readRecorded: async () => null + } + } const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { const sessionAdapter = adapter({ origin }) const acquire = vi.mocked(sessionAdapter.acquire) @@ -184,12 +184,6 @@ describe('structured session acquisition options', () => { } satisfies AgentJournalMessageItem, fence: 1 }) - await firstJournal!.resolveDispatch({ - clientMessageId: 'crashed-send', - state: 'pending', - providerIdentity: { provider: 'claude', sessionId: 'provider-1', uuid: 'crashed-frame' }, - fence: 1 - }) await firstJournal!.close() const store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), @@ -219,12 +213,12 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) - expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) expect(second).toMatchObject({ - value: { - page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } - } + ok: true, + value: { unconfirmedClientMessageIds: ['crashed-send'] } }) + // Both attaches sampled liveness before their own child started. + expect(sampledWithChild).toEqual([false, false]) }) it('persists create defaults before the first provider acquisition', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index d66bec89cfbf..36ce77747963 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -159,11 +159,10 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - // Sample provider history before a new child is acquired. Once acquireOwner - // starts the child, the adapter's liveness signal intentionally becomes - // conservative and an absent prompt can no longer prove non-delivery. The - // transcript is read later, and only for a stranded send: the new child is - // sent nothing first, so nothing it appends can be one. + // Sample provider history before a new child is acquired, so liveness describes + // the child the crash left, not the one this attach starts. The transcript is + // read later, and only for a stranded send: the new child is sent nothing + // first, so nothing it appends can be one. providerHistory = await sampleProviderHistory({ adapter: input.adapter, identity: journalIdentityFor(record, params), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 0e053cbd4b59..cd1121613c74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -7,8 +7,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' @@ -46,16 +46,14 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } -/** A transcript read whole that holds none of the sends below. */ +const SENT_FRAME = { provider: 'claude', sessionId: 'provider-1', uuid: 'cm_1-frame' } as const + +/** A transcript that holds `cm_1`'s frame and nothing else. */ function window(overrides: Partial = {}): ProviderHistorySource { return { turnInFlight: false, readWindow: async () => ({ items: [], boundaryConsistent: true }), - readRecorded: async () => ({ - itemIds: new Set(), - itemIdsByFingerprint: new Map(), - provesAbsenceOf: () => true - }), + readRecorded: async () => ({ itemIds: new Set([agentJournalItemKey(SENT_FRAME)]) }), ...overrides } } @@ -123,40 +121,28 @@ afterEach(async () => { }) describe('attachJournal restart reconciliation', () => { - it('settles a provably undelivered submission and stops reporting it unconfirmed', async () => { + it('settles a send found by its frame id and stops reporting it unconfirmed', async () => { await crashedJournal() const { adapter, dispatch } = adapterWith(async () => window()) const attached = await attach(adapter) expect(attached.unconfirmedClientMessageIds).toEqual([]) - const submission = attached.journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + expect(attached.journal.submissions()[0]).toMatchObject({ + dispatchState: 'accepted', + providerItemId: agentJournalItemKey(SENT_FRAME) + }) // Deciding is not sending: nothing here puts the message back on the wire. expect(dispatch).not.toHaveBeenCalled() }) - it('gives a send the provider never received no verdict and no listing', async () => { - await crashedJournal() - const { adapter } = adapterWith(async () => window()) - - const attached = await attach(adapter) - - // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. - const { items, submissions } = attached.journal.snapshot() - expect( - projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) - ).toMatchObject({ summary: { status: null }, latestRequest: null }) - }) - - it('still reports a submission unconfirmed when the window cannot decide it', async () => { - await crashedJournal() - const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) + it('still reports a submission unconfirmed when history does not hold it', async () => { + await crashedJournal('cm_2') + const { adapter, dispatch } = adapterWith(async () => window()) const attached = await attach(adapter) - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) + expect(attached.unconfirmedClientMessageIds).toEqual(['cm_2']) expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') expect(dispatch).not.toHaveBeenCalled() }) @@ -198,7 +184,6 @@ describe('attachJournal restart reconciliation', () => { fence: RECORD.lease.runtimeFence, handoverRecorded: true }) - // History that holds nothing: absence would prove a handed-over message undelivered. const { adapter, dispatch } = adapterWith(async () => window()) const attached = await attachJournal({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 7c80b2c933af..f4318bb516d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -66,12 +66,6 @@ export async function interruptedRestart( } if (work === 'submission' || work === 'send-after-reply') { previous.dispatch.mockResolvedValueOnce({ state: 'admitted' }) - // Handed over under a frame id, so the restarted host can look it up. - previous.host.deps.adapter.mintDispatchIdentity = () => ({ - provider: 'claude', - sessionId: 'provider-session', - uuid: 'handed-over-frame' - }) const body = hostTestMessage('Perform the original task') await previous.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) // Accepted first, handed over after: the work in flight is a send the provider took. @@ -136,11 +130,7 @@ export async function interruptedRestart( items: [], boundaryConsistent: historyBoundaryConsistent }), - readRecorded: async () => ({ - itemIds: new Set(), - itemIdsByFingerprint: new Map(), - provesAbsenceOf: () => historyBoundaryConsistent - }) + readRecorded: async () => null }) } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index b45e08314a0d..2e09850a7b38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -91,16 +91,14 @@ it.each(['turn', 'submission'] as const)( } ) -// Teardown judged the chat working. A send the provider proves it never received is still the chat -// Orca stopped; the continuation asks the agent -// to check what finished rather than refusing. -it('still continues a chat whose last send acquisition proves was never delivered', async () => { +// Teardown judged the chat working. A send history cannot confirm is still the chat Orca stopped; +// the continuation asks the agent to check what finished rather than refusing. +it('still continues a chat whose last send history cannot confirm', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'notDelivered' } + dispatchState: 'unknown' }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) From bfd12f7e40b7a4497a5bd5e0effc95e9275624d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:00:20 -0700 Subject: [PATCH 09/49] revert(native-chat): drop the transcript id lookup for restarted sends The restart decision moves to the session level; nothing reads the provider transcript to decide delivery any more. --- .../claude-structured-dispatch-content.ts | 35 +-- ...laude-structured-dispatch-identity.test.ts | 153 ------------- src/main/claude/claude-structured-dispatch.ts | 10 +- .../claude-structured-history-window.test.ts | 173 +------------- .../claude-structured-history-window.ts | 50 +++- ...claude-structured-provider-history.test.ts | 148 ------------ .../claude-structured-provider-history.ts | 146 ------------ .../claude-structured-session-adapter.ts | 26 +-- .../journal-crash-boundary.test.ts | 49 ++-- .../journal-dispatch-reducer.ts | 6 - .../journal-restart-reconciliation.test.ts | 214 ++++-------------- .../journal-restart-reconciliation.ts | 76 +++---- .../journal-row-builders.ts | 3 - .../journal-row-schema.ts | 3 - .../journal-store-contracts.ts | 6 +- .../journal-submission-reconciler.ts | 94 ++------ ...-agent-session-accept-then-deliver.test.ts | 6 +- ...-agent-session-acquisition-options.test.ts | 26 +-- ...structured-agent-session-adapter-router.ts | 8 +- .../structured-agent-session-adapter.ts | 23 +- .../structured-agent-session-attach-flow.ts | 27 ++- ...gent-session-attach-reconciliation.test.ts | 66 +++--- .../structured-agent-session-attach.ts | 22 +- ...ssion-restart-interruption-test-harness.ts | 11 +- ...ed-agent-session-restart-ownership.test.ts | 10 +- .../structured-agent-session-turns.ts | 25 +- src/shared/agent-session-journal-types.ts | 3 - 27 files changed, 283 insertions(+), 1136 deletions(-) delete mode 100644 src/main/claude/claude-structured-dispatch-identity.test.ts delete mode 100644 src/main/claude/claude-structured-provider-history.test.ts delete mode 100644 src/main/claude/claude-structured-provider-history.ts diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 9d7b283163a6..035b0a594926 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -1,10 +1,7 @@ -import { createHash, randomUUID } from 'node:crypto' +import { createHash } from 'node:crypto' import { open } from 'node:fs/promises' import { extname } from 'node:path' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import { agentSessionFailureFact, @@ -260,31 +257,3 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { const key = `v1:${summary.slice(0, 128)}:${digest.digest('hex')}` return key.slice(0, MAX_REPLAY_CONTENT_KEY_BYTES) } - -/** - * Claude records a user frame under the frame's own `uuid` — its row's, or for a frame folded into - * a running turn its attachment's `source_uuid` — so the id is chosen before the hand-over is - * journaled. A command is left to content: what Claude writes for one is not the prompt Orca sent. - */ -export function mintClaudeDispatchIdentity( - providerSessionId: string, - body: AgentJournalMessageItem -): AgentJournalItemIdentity | null { - const prompt = body.blocks.flatMap((block) => - block.type === 'text' && block.text ? [block.text] : [] - ) - if (claudeDispatchInvokesSlashCommand([{ type: 'text', text: prompt.join('\n') }])) { - return null - } - return { provider: 'claude', sessionId: providerSessionId, uuid: randomUUID() } -} - -/** The recorded id's uuid when it names this session; a stale one names a record Claude never wrote. */ -export function claudeRecordedFrameUuid( - providerSessionId: string, - identity: AgentJournalItemIdentity | undefined -): string | undefined { - return identity?.provider === 'claude' && identity.sessionId === providerSessionId - ? identity.uuid - : undefined -} diff --git a/src/main/claude/claude-structured-dispatch-identity.test.ts b/src/main/claude/claude-structured-dispatch-identity.test.ts deleted file mode 100644 index f6e59a2f4bd1..000000000000 --- a/src/main/claude/claude-structured-dispatch-identity.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// The id a restart decides delivery by: chosen before the hand-over, durable with it, and the -// exact id the frame Claude records carries. - -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 { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../shared/agent-session-journal-item-key' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { handOverSubmission } from '../native-chat/agent-session-wire/structured-agent-session-turns' -import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { - claudeRecordedFrameUuid, - mintClaudeDispatchIdentity -} from './claude-structured-dispatch-content' -import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' -import { - acquired, - fakeClaude, - PROVIDER_SESSION_ID, - USER_MESSAGE -} from './claude-structured-session-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-session', leafUuid: null } -} - -const journals = createTrackedJournalOpener() -let root: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-dispatch-identity-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('the Claude dispatch identity', () => { - it('names a plain prompt by a fresh uuid in the live session', () => { - const identity = mintClaudeDispatchIdentity( - 'provider-session', - userMessage([{ type: 'text', text: 'ship it' }]) - ) - expect(identity).toMatchObject({ provider: 'claude', sessionId: 'provider-session' }) - }) - - it('leaves a slash command to content, since Claude records no prompt for it', () => { - expect( - mintClaudeDispatchIdentity( - 'provider-session', - userMessage([{ type: 'text', text: '/compact' }]) - ) - ).toBeNull() - }) - - it('stamps the recorded uuid on the frame, and only for the session it names', async () => { - const send = vi.fn().mockResolvedValue(undefined) - const session = sessionFor(send) - const recorded = { provider: 'claude', sessionId: 'provider-session', uuid: 'frame-1' } as const - const sentUuid = claudeRecordedFrameUuid(session.providerSessionId, recorded) - - await dispatchClaudeTurn(session, { - body: userMessage([{ type: 'text', text: 'ship it' }]), - sentUuid - }) - - expect(send.mock.calls[0]?.[0]).toMatchObject({ uuid: 'frame-1' }) - expect(claudeRecordedFrameUuid('another-session', recorded)).toBeUndefined() - }) -}) - -describe('handing a send over under its identity', () => { - it('records the id before dispatch and keeps it through a crash', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendSubmission({ - clientMessageId: 'cm-1', - payloadFingerprint: 'fingerprint', - body: userMessage([{ type: 'text', text: 'ship it' }]), - fence: 1, - handoverRecorded: true - }) - const identity = { provider: 'claude', sessionId: 'provider-session', uuid: 'frame-1' } as const - let recordedAtDispatch: string | undefined - const dispatch = vi.fn(async () => { - recordedAtDispatch = journal.submissions()[0]?.handedOverItemId - return { state: 'admitted' as const } - }) - const adapter: Pick = { - dispatch, - mintDispatchIdentity: () => identity - } - - await handOverSubmission( - { - sessionId: 'session-1', - journal, - fence: 1, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A hand-over calls only these two members. - adapter: adapter as StructuredAgentSessionAdapter - }, - journal.submissions()[0]! - ) - - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ providerIdentity: identity })) - expect(recordedAtDispatch).toBe(agentJournalItemKey(identity)) - await journal.close() - const reopened = await journals.open({ identity: IDENTITY, journalDir: root }) - await reopened.markPendingSubmissionsUnknown(2) - expect(reopened.submissions()[0]).toMatchObject({ - dispatchState: 'unknown', - handedOverItemId: agentJournalItemKey(identity) - }) - }) -}) - -describe('the Claude adapter handing a send over', () => { - it('writes the frame under the id the journal recorded for it', async () => { - const claude = fakeClaude({ replayUuid: null }) - const adapter = await acquired(claude) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendSubmission({ - clientMessageId: 'cm-1', - payloadFingerprint: 'fingerprint', - body: USER_MESSAGE, - fence: 7, - handoverRecorded: true - }) - - await handOverSubmission( - { sessionId: 'session-1', journal, fence: 7, adapter }, - journal.submissions()[0]! - ) - - const recorded = parseAgentJournalItemKey(journal.submissions()[0]?.handedOverItemId ?? '') - const uuid = - recorded?.provider === 'claude' && recorded.sessionId === PROVIDER_SESSION_ID - ? recorded.uuid - : null - expect(uuid).toEqual(expect.any(String)) - expect(claude.connections[0]?.sent[0]).toMatchObject({ uuid }) - }) -}) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 9a4e71a69da5..220542fab91b 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -239,13 +239,7 @@ function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchReject export async function dispatchClaudeTurn( session: ClaudeSession, - input: { - clientMessageId?: string - body: AgentJournalMessageItem - requestedAt?: number - /** The frame's uuid, when the caller already recorded it. */ - sentUuid?: string - }, + input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, beforeDispatch?: () => Promise ): Promise { let content: unknown[] @@ -264,7 +258,7 @@ export async function dispatchClaudeTurn( // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) - const sentUuid = input.sentUuid ?? randomUUID() + const sentUuid = randomUUID() const arm = () => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index 685100caa63d..851831b86f19 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -9,13 +9,10 @@ import { structuredAgentSessionSendBody } from '../../shared/structured-agent-se import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' -import { claudeProviderHistoryWindowFromJsonl } from './claude-structured-history-window' import { - claudeRecordedHistoryFromJsonl, - openClaudeProviderHistory -} from './claude-structured-provider-history' + claudeProviderHistoryWindowFromJsonl, + resolveClaudeProviderHistoryWindow +} from './claude-structured-history-window' const PROVIDER_SESSION = 'provider-1' const ORCA_SESSION = 'session-1' @@ -79,7 +76,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { 'utf8' ) - const history = openClaudeProviderHistory({ + const window = await resolveClaudeProviderHistoryWindow({ identity: { sessionId: ORCA_SESSION, workspaceId: 'workspace-1', @@ -91,13 +88,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { hasLiveSession: false }) - expect((await history?.readWindow())?.items.map((item) => item.providerItemId)).toEqual(['u-1']) - const recorded = await history?.readRecorded() - expect( - recorded?.itemIds.has( - agentJournalItemKey({ provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'u-1' }) - ) - ).toBe(true) + expect(window?.items.map((item) => item.providerItemId)).toEqual(['u-1']) }) it('pins the renderer and host fingerprint functions to the same digest', () => { @@ -358,157 +349,3 @@ describe('a crash between Claude saving a prompt and Orca recording its echo', ( expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo']) }) }) - -describe('a send handed over under its own frame id', () => { - const key = (uuid: string) => - agentJournalItemKey({ provider: 'claude', sessionId: PROVIDER_SESSION, uuid }) - const sentKey = key('sent') - - function handedOver(text: string, uuid = 'sent'): AgentJournalSubmission { - return { - clientMessageId: `cm-${uuid}`, - fence: 1, - payloadFingerprint: sendFingerprint(text), - dispatchState: 'unknown', - providerItemId: null, - reason: null, - submittedAt: 0, - resolvedAt: null, - handedOverItemId: key(uuid) - } - } - - function verdicts( - contents: string, - previousLeafUuid: string | null, - submissions: AgentJournalSubmission[] - ) { - const input = { - contents, - providerSessionId: PROVIDER_SESSION, - previousLeafUuid, - sessionId: ORCA_SESSION, - turnInFlight: false - } - return reconcileSubmissions({ - history: { - ...read(contents, previousLeafUuid), - recorded: claudeRecordedHistoryFromJsonl(input) - }, - submissions - }) - } - - function verdict(contents: string, previousLeafUuid: string | null, text: string) { - return verdicts(contents, previousLeafUuid, [handedOver(text)])[0] - } - - /** What Claude writes for a frame it folds into a running turn instead of a row of its own. */ - function folded(rowUuid: string, parentUuid: string, sourceUuid: string, text: string): Row { - return { - type: 'attachment', - uuid: rowUuid, - parentUuid, - sessionId: PROVIDER_SESSION, - attachment: { - type: 'queued_command', - prompt: [{ type: 'text', text }], - source_uuid: sourceUuid - } - } - } - - it('is accepted when Claude holds it before the anchor a later turn advanced', () => { - // The send stayed unconfirmed while a later turn completed and moved the durable leaf. - const contents = jsonl( - [ - ANCHOR, - prompt('sent', 'anchor', 'ship it'), - prompt('later', 'sent', 'and then this'), - prompt('leaf', 'later', 'latest') - ], - 'leaf' - ) - - expect(read(contents, 'later').items.map((item) => item.providerItemId)).toEqual(['leaf']) - expect(verdict(contents, 'later', 'ship it')).toMatchObject({ - outcome: 'accepted', - providerItemId: sentKey - }) - }) - - it('is accepted when its text starts with a harness tag', () => { - const text = 'why does this parse wrong' - const contents = jsonl([ANCHOR, prompt('sent', 'anchor', [{ type: 'text', text }])], 'sent') - - expect(verdict(contents, 'anchor', text)).toMatchObject({ - outcome: 'accepted', - providerItemId: sentKey - }) - }) - - it('is accepted before any turn completed, when there is no anchor at all', () => { - const contents = jsonl([prompt('sent', null, 'ship it')], 'sent') - - expect(verdict(contents, null, 'ship it')).toMatchObject({ outcome: 'accepted' }) - }) - - it('is accepted when Claude folded it into a running turn under a row id of its own', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('turn', 'anchor', 'run the tests'), - folded('fold-row', 'turn', 'sent', 'ship it') - ], - 'fold-row' - ) - - expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ - outcome: 'accepted', - providerItemId: sentKey - }) - }) - - it('stays unknown when Claude merged it into the record of a send queued after it', () => { - const merged = [ - { type: 'text', text: 'ship it' }, - { type: 'text', text: 'and test it' } - ] - const contents = jsonl([ANCHOR, prompt('next', 'anchor', merged)], 'next') - - expect( - verdicts(contents, 'anchor', [handedOver('ship it'), handedOver('and test it', 'next')]) - ).toMatchObject([ - { outcome: 'unknown', reason: 'not_found' }, - { outcome: 'accepted', providerItemId: key('next') } - ]) - }) - - it('stays unknown, never not delivered, when the whole file lacks it', () => { - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'something else')], 'u-1') - - expect(verdict(contents, 'anchor', 'ship it')).toEqual({ - clientMessageId: 'cm-sent', - outcome: 'unknown', - reason: 'not_found' - }) - }) - - it('is found past a line that does not parse', () => { - const contents = `${jsonl([ANCHOR], 'anchor')}{"type":"user","uuid":"torn"\n${jsonl( - [prompt('sent', 'anchor', 'ship it')], - 'sent' - )}` - - expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ outcome: 'accepted' }) - }) - - it('is not accepted by a record of its text under another id', () => { - const contents = jsonl([ANCHOR, prompt('minted', 'anchor', 'ship it')], 'minted') - - expect(verdict(contents, 'anchor', 'ship it')).toMatchObject({ - outcome: 'unknown', - reason: 'not_found' - }) - }) -}) diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index 850bd5c156ea..00ae86920251 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -1,17 +1,22 @@ // Provider history for restart reconciliation, read from the Claude project JSONL. // // Why this file is the source of truth for "did Claude take it": a resume replays -// this transcript by session id, so a message found in it is in the conversation -// Orca is about to resume. +// this transcript by session id, so a message absent from it is absent from the +// conversation Orca is about to resume. Absence here is not an inference about a +// dead child — it is the content of the next turn's context. // // The window is anchored on the leaf uuid Orca durably recorded for the session // and walks back to it from the file's last transcript row, which is where a // resume by session id continues; Claude's marker lags a crash mid-turn. // Without that anchor the read has no proven start, and the branch proof is what // decides whether the file we just read still descends from it: a fork, a -// compaction, a sibling branch, or a torn tail all fail the proof, and report an -// inconsistent boundary rather than an empty window. +// compaction, a sibling branch, or a torn tail all fail the proof, and every one +// of those makes absence meaningless. Failing it reports an inconsistent +// boundary rather than an empty window, because the two decide opposite things. +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { ProviderHistoryItem, ProviderHistoryWindow @@ -28,7 +33,7 @@ import { /** The legacy-import bound, now applied PER RECORD rather than per file. The * line framer buffers one record at a time, so this is the only thing standing * between a pathological row and the whole file being resident. */ -export const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024 +const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024 const INCONSISTENT: ProviderHistoryWindow = { items: [], @@ -146,12 +151,13 @@ function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string }) } -export type HistoryWindowInput = { +type HistoryWindowInput = { providerSessionId: string previousLeafUuid: string | null /** Orca session id: the fingerprint a submission carries is scoped to it. */ sessionId: string - /** A provider child may still be appending to the file. */ + /** The caller must PROVE no provider child can be appending; absence proves + * nothing while a turn is running. */ turnInFlight: boolean } @@ -224,6 +230,36 @@ export function claudeProviderHistoryWindowFromJsonl( } } +/** + * The window for one attached session: resolve the provider's transcript, then + * read it against the handle's durable leaf. A live child means a send queued + * behind its running turn is not in the file yet, so liveness is carried in + * rather than assumed — only the adapter's session map can answer it. + */ +export async function resolveClaudeProviderHistoryWindow(input: { + identity: AgentSessionJournalIdentity + accountHomePath: string + hasLiveSession: boolean +}): Promise { + const handle = input.identity.providerHandle + if (handle.kind !== 'claude') { + return null + } + const transcriptPath = await resolveSessionFilePath('claude', handle.sessionId, { + claudeProjectsDir: join(input.accountHomePath, 'projects') + }) + if (!transcriptPath) { + return null + } + return readClaudeProviderHistoryWindow({ + transcriptPath, + providerSessionId: handle.sessionId, + previousLeafUuid: handle.leafUuid, + sessionId: input.identity.sessionId, + turnInFlight: input.hasLiveSession + }) +} + export async function readClaudeProviderHistoryWindow( input: HistoryWindowInput & { transcriptPath: string } ): Promise { diff --git a/src/main/claude/claude-structured-provider-history.test.ts b/src/main/claude/claude-structured-provider-history.test.ts deleted file mode 100644 index 84f5c9238765..000000000000 --- a/src/main/claude/claude-structured-provider-history.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// The Claude history an attach samples, read end to end: transcript file, liveness, journal verdict. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../shared/agent-session-journal-types' -import { reconcileJournalSubmissionsAgainstHistory } from '../native-chat/agent-session-journal/journal-restart-reconciliation' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' -import { openClaudeProviderHistory } from './claude-structured-provider-history' - -const PROVIDER_SESSION = 'provider-1' - -// No turn has completed yet, so there is no durable anchor. -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } -} - -const journals = createTrackedJournalOpener() -let root: string - -function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -async function writeTranscript(rows: Record[]): Promise { - const projectDir = join(root, 'account', 'projects', 'work') - await mkdir(projectDir, { recursive: true }) - await writeFile( - join(projectDir, `${PROVIDER_SESSION}.jsonl`), - rows.map((row) => `${JSON.stringify(row)}\n`).join('') - ) -} - -function userRow(uuid: string, content: unknown): Record { - return { - type: 'user', - uuid, - parentUuid: null, - sessionId: PROVIDER_SESSION, - message: { role: 'user', content } - } -} - -function frameIdentity(uuid: string) { - return { provider: 'claude' as const, sessionId: PROVIDER_SESSION, uuid } -} - -type Journal = Awaited> - -/** A send handed over under frame `uuid`. */ -async function handOver(journal: Journal, clientMessageId: string, uuid: string, text: string) { - await journal.appendSubmission({ - clientMessageId, - payloadFingerprint: digestPayload(text), - body: userMessage(text), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId, - state: 'pending', - providerIdentity: frameIdentity(uuid), - fence: 1 - }) -} - -/** Writes the journal as `write` leaves it, then restarts the host over it with no child running. */ -async function restartAfter(write: (journal: Journal) => Promise, hasLiveSession = false) { - const journalDir = join(root, 'journal') - await write(await journals.open({ identity: IDENTITY, journalDir })) - const reopened = await journals.open({ identity: IDENTITY, journalDir }) - await reopened.markPendingSubmissionsUnknown(2) - const history = openClaudeProviderHistory({ - identity: IDENTITY, - accountHomePath: join(root, 'account'), - hasLiveSession - }) - await reconcileJournalSubmissionsAgainstHistory({ - journal: reopened, - fence: 2, - history: history! - }) - return Object.fromEntries( - reopened - .submissions() - .map((submission) => [submission.clientMessageId, submission.dispatchState]) - ) -} - -/** A transcript holding only a turn Claude is still running, and a send handed over during it. */ -async function strandedMidTurn(hasLiveSession: boolean) { - await writeTranscript([userRow('running-turn', 'run the tests')]) - return restartAfter((journal) => handOver(journal, 'cm-sent', 'sent', 'ship it'), hasLiveSession) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-claude-provider-history-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('openClaudeProviderHistory', () => { - it.each([true, false])( - 'leaves a send its id is missing from unknown, never not delivered (child still running: %s)', - async (hasLiveSession) => { - expect(await strandedMidTurn(hasLiveSession)).toEqual({ 'cm-sent': 'unknown' }) - } - ) - - it.each([false, true])( - 'leaves a send unknown when Claude merged it into the row of the send after it (that send accepted: %s)', - async (secondAccepted) => { - // Frames queued together behind a turn share one row under the later frame's uuid. - await writeTranscript([ - userRow('second', [ - { type: 'text', text: 'ship it' }, - { type: 'text', text: 'and test it' } - ]) - ]) - - const states = await restartAfter(async (journal) => { - await handOver(journal, 'cm-first', 'first', 'ship it') - await handOver(journal, 'cm-second', 'second', 'and test it') - if (secondAccepted) { - await journal.resolveDispatch({ - clientMessageId: 'cm-second', - state: 'accepted', - providerIdentity: frameIdentity('second'), - fence: 1 - }) - } - }) - - expect(states).toEqual({ 'cm-first': 'unknown', 'cm-second': 'accepted' }) - } - ) -}) diff --git a/src/main/claude/claude-structured-provider-history.ts b/src/main/claude/claude-structured-provider-history.ts deleted file mode 100644 index e9b8a00cbe8c..000000000000 --- a/src/main/claude/claude-structured-provider-history.ts +++ /dev/null @@ -1,146 +0,0 @@ -// What restart reconciliation reads from Claude: the anchored window for sends handed over with no -// id, and the id of every user frame the transcript holds for sends handed over under one. -// -// A frame is found by its id wherever it sits, so the id read needs no start point. A frame folded -// into a running turn gets no row of its own, only a `queued_command` attachment naming it by -// `source_uuid`. Frames queued together behind a turn are merged into ONE row under the last -// frame's uuid, so the others cannot be found and stay unconfirmed. - -import { createReadStream } from 'node:fs' -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - ProviderHistorySource, - ProviderRecordedHistory -} from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' -import { - MAX_HISTORY_WINDOW_RECORD_BYTES, - readClaudeProviderHistoryWindow, - type HistoryWindowInput -} from './claude-structured-history-window' - -type HistoryReadInput = HistoryWindowInput & { transcriptPath: string } - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A non-array object, checked on this line. - (value as Record) - : null -} - -function stringField(source: Record | null, key: string): string | null { - const value = source?.[key] - return typeof value === 'string' && value.trim() ? value : null -} - -/** The id a user frame was recorded under, as Claude's own readers take it. */ -function recordedFrameUuid(row: Record): string | null { - if (row.isSidechain === true) { - return null - } - if (row.type === 'user') { - return stringField(row, 'uuid') - } - const attachment = row.type === 'attachment' ? asRecord(row.attachment) : null - return attachment?.type === 'queued_command' - ? (stringField(attachment, 'source_uuid') ?? stringField(row, 'uuid')) - : null -} - -/** Every user frame in the file. A line that does not parse is skipped: it can only hide a send, - * which then stays unconfirmed. */ -function createRecordedCollector(input: HistoryWindowInput) { - const itemIds = new Set() - return { - add(line: string): void { - let row: Record | null = null - try { - row = line.trim() ? asRecord(JSON.parse(line)) : null - } catch { - return - } - const uuid = row && recordedFrameUuid(row) - if (row && uuid) { - const sessionId = stringField(row, 'sessionId') ?? input.providerSessionId - itemIds.add(agentJournalItemKey({ provider: 'claude', sessionId, uuid })) - } - }, - finish: (): ProviderRecordedHistory => ({ itemIds }) - } -} - -/** The string-source twin of `readClaudeRecordedHistory`. */ -export function claudeRecordedHistoryFromJsonl( - input: HistoryWindowInput & { contents: string } -): ProviderRecordedHistory { - const recorded = createRecordedCollector(input) - for (const line of input.contents.split('\n')) { - recorded.add(line) - } - return recorded.finish() -} - -async function readClaudeRecordedHistory( - input: HistoryReadInput -): Promise { - const recorded = createRecordedCollector(input) - try { - const stream = createReadStream(input.transcriptPath) - for await (const { line } of splitTranscriptStreamLines( - stream, - MAX_HISTORY_WINDOW_RECORD_BYTES - )) { - recorded.add(line) - } - } catch (error) { - console.warn('[claude-history] transcript unreadable; sends stay unconfirmed:', { - transcriptPath: input.transcriptPath, - sessionId: input.sessionId, - error - }) - return null - } - return recorded.finish() -} - -/** - * History for one attached session. Liveness is sampled now, because only the adapter's session - * map can answer it and a live child means a send queued behind its running turn is not in the - * file yet. The transcript is resolved and read only when a stranded send needs it. - */ -export function openClaudeProviderHistory(input: { - identity: AgentSessionJournalIdentity - accountHomePath: string - hasLiveSession: boolean -}): ProviderHistorySource | null { - const handle = input.identity.providerHandle - if (handle.kind !== 'claude') { - return null - } - const scope: HistoryWindowInput = { - providerSessionId: handle.sessionId, - previousLeafUuid: handle.leafUuid, - sessionId: input.identity.sessionId, - turnInFlight: input.hasLiveSession - } - let transcriptPath: Promise | undefined - const readWith = async (read: (input: HistoryReadInput) => Promise, fallback: T) => { - const path = await (transcriptPath ??= resolveSessionFilePath('claude', handle.sessionId, { - claudeProjectsDir: join(input.accountHomePath, 'projects') - })) - return path ? read({ ...scope, transcriptPath: path }) : fallback - } - return { - turnInFlight: input.hasLiveSession, - readWindow: () => - readWith(readClaudeProviderHistoryWindow, { - items: [], - boundaryConsistent: false, - turnInFlight: input.hasLiveSession - }), - readRecorded: () => readWith(readClaudeRecordedHistory, null) - } -} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index c3778100685b..29b51e730ae9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,10 +7,6 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { - claudeRecordedFrameUuid, - mintClaudeDispatchIdentity -} from './claude-structured-dispatch-content' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' @@ -38,7 +34,7 @@ import { type ClaudeExitLifecycle } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { openClaudeProviderHistory } from './claude-structured-provider-history' +import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' import { drainClaudeChildWork } from './claude-child-work-evidence' import { admitClaudePromptCancellation, @@ -135,8 +131,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ - providerHistory: NonNullable = async (input) => - openClaudeProviderHistory({ + providerHistoryWindow: NonNullable = ( + input + ) => + resolveClaudeProviderHistoryWindow({ identity: input.identity, accountHomePath: input.accountHome.path, hasLiveSession: @@ -191,18 +189,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) } - mintDispatchIdentity: NonNullable = ( - input - ) => { - const session = this.sessions.get(input.sessionId) - return session ? mintClaudeDispatchIdentity(session.providerSessionId, input.body) : null - } - - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { - const session = this.session(input.sessionId) - const sentUuid = claudeRecordedFrameUuid(session.providerSessionId, input.providerIdentity) - return dispatchClaudeTurn(session, { ...input, sentUuid }, input.beforeDispatch) - } + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 553f56bfe331..6cc267a90547 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -22,8 +22,7 @@ import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, type ProviderHistoryItem, - type ProviderHistoryWindow, - type ProviderRecordedHistory + type ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' @@ -84,11 +83,6 @@ function history(input: { } } -/** A history read whole, holding exactly these item keys. */ -function wholeHistory(itemIds: string[]): ProviderRecordedHistory { - return { itemIds: new Set(itemIds) } -} - function window( items: ProviderHistoryItem[], overrides: Partial = {} @@ -233,7 +227,7 @@ describe('crash between provider accept and journal commit', () => { }) }) - it('leaves a handed-over send unconfirmed when history lacks it, and never re-sends it', async () => { + it('reports a rejected submission as never delivered, and never re-sends it', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -241,25 +235,33 @@ describe('crash between provider accept and journal commit', () => { body: userMessage('never landed'), fence: 1 }) - // Handed over under an id the provider would have recorded it by. - await journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'pending', - providerIdentity: ACCEPTED_IDENTITY, - fence: 1 - }) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) const [outcome] = reconcileSubmissions({ submissions: restarted.submissions(), - history: window([], { recorded: wholeHistory([]) }) + history: window([]) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'rejected', + reason: 'not_delivered' + }) + + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 2, + recovered: true }) - // A merged frame leaves no record under its own id, so a missing id is not proof of loss. - expect(outcome).toEqual({ clientMessageId: 'cm_1', outcome: 'unknown', reason: 'not_found' }) - // The bubble survives, unconfirmed: the message is not silently retried or dropped. + // The bubble survives with an explicit terminal state — the message is not + // silently retried and not silently dropped. expect(restarted.snapshot().items).toHaveLength(1) - expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('unknown') + expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') + expect(restarted.receiptFor('cm_1')).toBeNull() }) it('survives replay of an already-reconciled journal without changing the answer', async () => { @@ -395,8 +397,7 @@ describe('reconciliation matching', () => { } ]) }) - // Absent from a window that starts at the last completed turn, not at this send. - expect(outcome).toMatchObject({ outcome: 'unknown', reason: 'not_found' }) + expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) }) it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { @@ -407,7 +408,7 @@ describe('reconciliation matching', () => { ]) }) expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'unknown' }), + expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) ]) }) @@ -437,7 +438,7 @@ describe('reconciliation matching', () => { }) expect(outcomes).toEqual([ expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), - expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'unknown' }) + expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) ]) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 694414904a9c..470ed3dfcb24 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -39,12 +39,6 @@ export function applyJournalDispatchRow( submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts - // Each hand-over names its own frame; a row without one leaves the send decided by content. - if (typeof row.handedOverItemId === 'string' && row.handedOverItemId) { - submission.handedOverItemId = row.handedOverItemId - } else { - delete submission.handedOverItemId - } } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index c59c89c87a15..4428e13953e1 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -4,7 +4,7 @@ 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 { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemIdentity, @@ -13,13 +13,11 @@ import type { } from '../../../shared/agent-session-journal-types' import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' -import type { - ProviderHistoryItem, - ProviderHistorySource, - ProviderHistoryWindow, - ProviderRecordedHistory -} from './journal-submission-reconciler' +import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -65,56 +63,25 @@ function history(uuid: string, text: string): ProviderHistoryItem { } } -/** History the reconciler reads on demand; `recorded` is what the whole-file read finds. */ function window( items: ProviderHistoryItem[], overrides: Partial = {} -): ProviderHistorySource { - const { boundaryConsistent = true, turnInFlight = false, recorded = null } = overrides - return { - turnInFlight, - readWindow: vi.fn(async () => ({ items, boundaryConsistent })), - readRecorded: vi.fn(async () => recorded) - } +): ProviderHistoryWindow { + return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } } -/** The whole transcript, holding these frame ids. */ -function wholeHistory(uuids: string[]): ProviderRecordedHistory { - return { itemIds: new Set(uuids.map((uuid) => agentJournalItemKey(claudeIdentity(uuid)))) } -} - -/** Appends a send and, when `handedOverAs` names one, hands it over under that frame id. */ -async function appendSend( - journal: Awaited>, - clientMessageId: string, - handedOverAs: string | null, +/** A host that wrote the submission row and died before learning its outcome. */ +async function reopenAfterCrash( body: AgentJournalMessageItem = userMessage('deploy the thing'), text = 'deploy the thing' -): Promise { +) { + const journal = await open() await journal.appendSubmission({ - clientMessageId, + clientMessageId: 'cm_1', payloadFingerprint: digestPayload(text), body, fence: 1 }) - if (handedOverAs) { - await journal.resolveDispatch({ - clientMessageId, - state: 'pending', - providerIdentity: claudeIdentity(handedOverAs), - fence: 1 - }) - } -} - -/** A host that wrote the submission row and died before learning its outcome. */ -async function reopenAfterCrash( - body: AgentJournalMessageItem = userMessage('deploy the thing'), - text = 'deploy the thing', - handedOverAs: string | null = null -) { - const journal = await open() - await appendSend(journal, 'cm_1', handedOverAs, body, text) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) return restarted @@ -146,18 +113,30 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) }) - it('leaves a send its frame id is missing from unconfirmed, never not delivered', async () => { - const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') + it('settles a message provably absent from history as rejected: not_delivered', async () => { + const journal = await reopenAfterCrash() const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, - history: window([], { recorded: wholeHistory(['uuid-other']) }) + history: window([]) }) - expect(settled).toEqual([]) - expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'unknown' }) - expect(journal.submissions()[0]?.rejection).toBeUndefined() + expect(settled).toEqual(['cm_1']) + const submission = journal.submissions()[0] + expect(submission?.dispatchState).toBe('rejected') + // A sentence, since released clients print the reason as it is, and the fact beside it. + expect(submission?.reason).toBe( + agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + .reason + ) + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + // Nobody failed: the crash stranded it before the provider took it. + expect(submission && classifyDispatchRejection(submission)).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { @@ -186,7 +165,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(journal.submissions()[0]?.dispatchState).toBe('unknown') }) - it('leaves a submission carrying an attachment it cannot fingerprint unknown', async () => { + it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { const journal = await reopenAfterCrash( { kind: 'message', @@ -238,7 +217,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { fence: 1 }) - await appendSend(journal, 'cm_1', null) + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) @@ -248,7 +232,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { history: window([history('uuid-old', 'deploy the thing')]) }) - expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') }) it('does not let an older accepted provider item stand in for a new identical send', async () => { @@ -265,7 +249,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { providerIdentity: claudeIdentity('uuid-old'), fence: 1 }) - await appendSend(journal, 'cm_new', null) + await journal.appendSubmission({ + clientMessageId: 'cm_new', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) const restarted = await open() await restarted.markPendingSubmissionsUnknown(2) @@ -277,45 +266,11 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ 'accepted', - 'unknown' + 'rejected' ]) + expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) - it.each([ - ['accepted live, with a different text', true, 'ship it', 'and test it'], - ['accepted live, with the same text', true, 'yes', 'yes'], - ['also stranded', false, 'ship it', 'and test it'] - ])( - 'leaves a send unknown when merged into the row of a later send %s', - async (_case, secondAcceptedLive, first, second) => { - // Frames queued together share the later frame's row, which holds both texts. - const journal = await open() - await appendSend(journal, 'cm_first', 'uuid-first', userMessage(first), first) - await appendSend(journal, 'cm_second', 'uuid-second', userMessage(second), second) - if (secondAcceptedLive) { - await journal.resolveDispatch({ - clientMessageId: 'cm_second', - state: 'accepted', - providerIdentity: claudeIdentity('uuid-second'), - fence: 1 - }) - } - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([], { recorded: wholeHistory(['uuid-second']) }) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'unknown', - 'accepted' - ]) - } - ) - it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { const journal = await open() for (const id of ['cm_1', 'cm_2']) { @@ -340,81 +295,4 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'unknown' ]) }) - - it('accepts a send found by its frame id even where the anchored window cannot see it', async () => { - // The durable anchor moved past the send, so the window after it is empty. - const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { recorded: wholeHistory(['uuid-sent']) }) - }) - - expect(settled).toEqual(['cm_1']) - expect(journal.submissions()[0]).toMatchObject({ - dispatchState: 'accepted', - providerItemId: agentJournalItemKey(claudeIdentity('uuid-sent')) - }) - }) - - it('reads no provider history when nothing is left to decide', async () => { - const journal = await open() - const history = window([]) - - expect(await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history })).toEqual( - [] - ) - expect(history.readWindow).not.toHaveBeenCalled() - expect(history.readRecorded).not.toHaveBeenCalled() - }) - - it('reads only the recorded history when every stranded send carries a frame id', async () => { - const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') - const history = window([], { recorded: wholeHistory(['uuid-sent']) }) - - await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history }) - - expect(history.readWindow).not.toHaveBeenCalled() - expect(journal.submissions()[0]?.dispatchState).toBe('accepted') - }) - - it('leaves a send unknown when the recorded history cannot be read', async () => { - const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') - const history = window([]) - vi.mocked(history.readRecorded).mockRejectedValue(new Error('EACCES')) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ journal, fence: 2, history }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('never rejects a send recorded without a frame id, which older builds wrote', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('never accepts a send handed over under an id by a record of its text under another', async () => { - const journal = await reopenAfterCrash(undefined, undefined, 'uuid-sent') - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-minted', 'deploy the thing')], { - recorded: wholeHistory(['uuid-minted']) - }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index c399be7f5d0a..7b36fc88dc63 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -1,12 +1,17 @@ // The production caller for `reconcileSubmissions`. // // Runs once per journal open, after the crash boundary has already settled every -// survivor to `unknown`. It only ever narrows that answer, to `accepted` when the -// provider's own history holds the message. It never decides `rejected`: history -// cannot show a send never arrived, and a wrong "not delivered" invites a Retry -// that sends a delivered message twice. Anything the reconciler leaves `unknown` -// is left exactly as the crash boundary wrote it, and nothing here dispatches. +// survivor to `unknown`. It only ever narrows that answer: `accepted` when the +// provider's own history holds the message, `rejected` when a boundary we can +// vouch for proves it never arrived. Anything the reconciler leaves `unknown` +// is left exactly as the crash boundary wrote it. +// +// Nothing here dispatches. A `rejected` submission becomes re-sendable only +// through the user's Retry, which rotates the client message id; Orca still +// never puts a message back on the wire on the user's behalf. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -17,17 +22,14 @@ import { } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from './journal-store' -import { - reconcileSubmissions, - type ProviderHistorySource, - type ProviderHistoryWindow -} from './journal-submission-reconciler' +import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' /** * Only a text-only body can be compared against provider content. A submission * carrying an attachment was fingerprinted over an `image-ref` path the - * transcript does not keep, so matching it against history would test the - * encoding rather than the message. Those stay `unknown`. + * transcript does not keep, so its absence from history would be an artefact of + * the encoding rather than evidence — and `rejected` is the one outcome that + * costs the user a duplicate if it is wrong. Those stay `unknown`. */ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { return ( @@ -77,25 +79,6 @@ function unseenHistory( } } -const UNREAD_WINDOW = { items: [], boundaryConsistent: false } as const - -/** Read only what these sends are decided by: the recorded history for a send handed over under - * an id, the anchored window for one without. A failed read decides nothing. */ -async function readHistoryFor( - history: ProviderHistorySource, - submissions: readonly AgentJournalSubmission[] -): Promise { - const [window, recorded] = await Promise.all([ - submissions.some((submission) => !submission.handedOverItemId) - ? history.readWindow().catch(() => UNREAD_WINDOW) - : UNREAD_WINDOW, - submissions.some((submission) => submission.handedOverItemId) - ? history.readRecorded().catch(() => null) - : null - ]) - return { ...window, turnInFlight: history.turnInFlight, recorded } -} - /** * Decide what the crash boundary could only doubt. Returns the client message * ids this pass settled, so the attach result stops reporting them unconfirmed. @@ -103,17 +86,16 @@ async function readHistoryFor( export async function reconcileJournalSubmissionsAgainstHistory(input: { journal: AgentSessionJournal fence: number - history: ProviderHistorySource + history: ProviderHistoryWindow }): Promise { const submissions = comparableSubmissions(input.journal) if (submissions.length === 0) { return [] } - const history = await readHistoryFor(input.history, submissions) const settled: string[] = [] for (const outcome of reconcileSubmissions({ submissions, - history: unseenHistory(input.journal, history) + history: unseenHistory(input.journal, input.history) })) { if (outcome.outcome === 'unknown') { // Narrowing failed: the submission stays unconfirmed, so record why. @@ -124,13 +106,25 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { }) continue } - await input.journal.resolveDispatch({ - clientMessageId: outcome.clientMessageId, - state: 'accepted', - providerIdentity: outcome.identity, - fence: input.fence, - recovered: true - }) + await input.journal.resolveDispatch( + outcome.outcome === 'accepted' + ? { + clientMessageId: outcome.clientMessageId, + state: 'accepted', + providerIdentity: outcome.identity, + fence: input.fence, + recovered: true + } + : { + clientMessageId: outcome.clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: input.fence, + recovered: true + } + ) settled.push(outcome.clientMessageId) } return settled diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index e795579d09b1..ee56f28f8a25 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -85,9 +85,6 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), - ...(input.state === 'pending' && input.providerIdentity - ? { handedOverItemId: agentJournalItemKey(input.providerIdentity) } - : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 1de92a812118..786d422876cb 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -93,9 +93,6 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact - /** On `pending`: the provider item key this send was handed over under, for a provider that - * records it under the id Orca chose. No `v` bump: older readers ignore the key. */ - handedOverItemId?: string } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 48f49fca35ad..c2210efb253b 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -31,11 +31,7 @@ export type ResolveDispatchInput = { recovered?: true } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } - | { - state: 'pending' - /** The id the provider will record this send under, for one that adopts a client id. */ - providerIdentity?: AgentJournalItemIdentity - } + | { state: 'pending' } /** `reason` is what released clients print, `rejection` what newer ones read: both from * `agentSessionFailureWords`, never written by hand. */ | ({ state: 'rejected' } & AgentJournalDispatchRejection) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index c9c06071aa48..4647ba1e0e37 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -4,15 +4,10 @@ // what it TRIED to send but not whether the provider took it. Every surviving // `pending` becomes `unknown` and is then matched against provider history. // -// A send handed over under an id the provider adopts is decided by that id alone, -// looked up across the whole history: found means delivered. Not found decides -// nothing, because a delivered frame can leave no record under its own id — -// Claude records frames queued together under the last one's id — so after a -// restart no send is ever called not delivered. Older sends carry no such id and -// fall back to the anchored window — an echoed client message id, else a -// provider item id the journal already adopted, else the payload fingerprint -// when it picks out exactly one unclaimed item. Never by text equality: the -// same question asked twice is two messages, and collapsing them loses one. +// Matching is by identity only — an echoed client message id, else a provider +// item id the journal already adopted, else the payload fingerprint when it +// picks out exactly one unclaimed item. Never by text equality: the same +// question asked twice is two messages, and collapsing them silently loses one. // // Orca never re-sends on the user's behalf. An unresolved submission stays // `unknown` — a displayed state meaning "delivery unconfirmed", neither sent nor @@ -22,10 +17,8 @@ import type { AgentJournalItemIdentity, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { /** The provider's own id for this item. Used to claim it at most once; the @@ -45,32 +38,12 @@ export type ProviderHistoryWindow = { items: readonly ProviderHistoryItem[] /** * The history read actually started at the journal's last committed item. A - * fork, a compacted provider log, or a truncated read breaks that. + * fork, a compacted provider log, or a truncated read makes absence + * meaningless, so a missing submission cannot be called "not delivered". */ boundaryConsistent: boolean - /** The provider reports a turn still running: a send may not be recorded yet. */ + /** The provider reports a turn still running: absence proves nothing yet. */ turnInFlight: boolean - /** Every user item the history holds, not only those after the anchor. Absent when the - * provider has no whole-history read; sends handed over under an id then stay `unknown`. */ - recorded?: ProviderRecordedHistory | null -} - -export type ProviderRecordedHistory = { - /** Journal keys of every user item in the history. */ - itemIds: ReadonlySet -} - -/** - * Provider history as an attach samples it. Liveness is fixed at the sample, before a new child - * starts; each read runs only when an unsettled send is decided by it, so an open with nothing to - * decide reads no transcript. A read that fails leaves its sends `unknown`. - */ -export type ProviderHistorySource = { - turnInFlight: boolean - /** The anchored window: only sends handed over with no id are decided by it. */ - readWindow: () => Promise> - /** Every recorded user item: only sends handed over under an id are decided by it. */ - readRecorded: () => Promise } export type SubmissionReconciliation = @@ -80,13 +53,15 @@ export type SubmissionReconciliation = providerItemId: string identity: AgentJournalItemIdentity } + | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } +export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED + export type SubmissionUnknownReason = | 'history_boundary_inconsistent' | 'turn_in_flight' | 'ambiguous_match' - | 'not_found' /** * Resolve every unsettled submission against provider history. @@ -102,21 +77,13 @@ export function reconcileSubmissions(input: { const unsettled = input.submissions.filter( (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' ) - const recorded = input.history.recorded - const identified = new Set( - unsettled.flatMap(({ handedOverItemId: id }) => (id && recorded?.itemIds.has(id) ? [id] : [])) - ) - const legacy = unsettled.filter((submission) => !submission.handedOverItemId) - const items = input.history.items.filter( - (item) => !identified.has(agentJournalItemKey(item.identity)) - ) const claimed = new Set() const matched = new Map() const ambiguous = new Set() claimBy( - legacy, - items, + unsettled, + input.history.items, claimed, matched, (submission, item) => @@ -126,8 +93,8 @@ export function reconcileSubmissions(input: { submission.providerItemId === agentJournalItemKey(item.identity) ) claimBy( - legacy, - items, + unsettled, + input.history.items, claimed, matched, (submission, item) => @@ -138,7 +105,7 @@ export function reconcileSubmissions(input: { // the first identical submission would make the later one look absent even // though either submission could be the delivered one. const submissionsByFingerprint = new Map() - for (const submission of legacy) { + for (const submission of unsettled) { if (matched.has(submission.clientMessageId) || !submission.payloadFingerprint) { continue } @@ -147,7 +114,7 @@ export function reconcileSubmissions(input: { submissionsByFingerprint.set(submission.payloadFingerprint, sameFingerprint) } for (const [fingerprint, fingerprintSubmissions] of submissionsByFingerprint) { - const candidates = items.filter( + const candidates = input.history.items.filter( (item) => !claimed.has(item.providerItemId) && item.payloadFingerprint === fingerprint ) if (fingerprintSubmissions.length === 1 && candidates.length === 1) { @@ -166,22 +133,7 @@ export function reconcileSubmissions(input: { } } - return unsettled.map((submission) => - submission.handedOverItemId - ? resolveByIdentity(submission.clientMessageId, submission.handedOverItemId, identified) - : resolveOne(submission, matched, ambiguous, input.history) - ) -} - -function resolveByIdentity( - clientMessageId: string, - itemId: string, - identified: ReadonlySet -): SubmissionReconciliation { - const identity = identified.has(itemId) ? parseAgentJournalItemKey(itemId) : null - return identity - ? { clientMessageId, outcome: 'accepted', providerItemId: itemId, identity } - : { clientMessageId, outcome: 'unknown', reason: 'not_found' } + return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history)) } function claimBy( @@ -241,11 +193,11 @@ function resolveOne( reason: 'turn_in_flight' } } - // The anchor is the last completed turn, not this send's hand-over, so the send may sit - // before it: absence here cannot say the provider never took it. + // Absent from a history we can trust the boundary of, with nothing running: + // the provider never took it. return { clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'not_found' + outcome: 'rejected', + reason: DISPATCH_REJECTED_NOT_DELIVERED } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 585afc74b46e..fb4ee607e38d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -540,8 +540,8 @@ describe('what an earlier host process left behind', () => { }) it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => { - const providerHistory = vi.fn(async () => null) - adapterExtras = { providerHistory } + const providerHistoryWindow = vi.fn(async () => null) + adapterExtras = { providerHistoryWindow } await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) @@ -555,7 +555,7 @@ describe('what an earlier host process left behind', () => { expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) // Deciding them from provider history waits for a won lease (W4′d). - expect(providerHistory).not.toHaveBeenCalled() + expect(providerHistoryWindow).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index e5cb408ca888..7c12909e0363 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -10,7 +10,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { attachFingerprintFields, type AgentSessionAttachParams @@ -120,15 +120,11 @@ describe('structured session acquisition options', () => { hostId: 'local' }) let childAcquired = false - const sampledWithChild: boolean[] = [] - const historyWindow = (): ProviderHistorySource => { - sampledWithChild.push(childAcquired) - return { - turnInFlight: childAcquired, - readWindow: async () => ({ items: [], boundaryConsistent: true }), - readRecorded: async () => null - } - } + const historyWindow = (): ProviderHistoryWindow => ({ + items: [], + boundaryConsistent: true, + turnInFlight: childAcquired + }) const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { const sessionAdapter = adapter({ origin }) const acquire = vi.mocked(sessionAdapter.acquire) @@ -150,7 +146,7 @@ describe('structured session acquisition options', () => { } } }) - sessionAdapter.providerHistory = vi.fn(async () => historyWindow()) + sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) return sessionAdapter } @@ -213,12 +209,12 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) + expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) expect(second).toMatchObject({ - ok: true, - value: { unconfirmedClientMessageIds: ['crashed-send'] } + value: { + page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } + } }) - // Both attaches sampled liveness before their own child started. - expect(sampledWithChild).toEqual([false, false]) }) it('persists create defaults before the first provider acquisition', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index ab92de1c270d..bb003f93427d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -59,10 +59,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return released } - mintDispatchIdentity: NonNullable = ( - input - ) => this.liveOwnerOrNull(input.sessionId)?.mintDispatchIdentity?.(input) ?? null - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) @@ -144,10 +140,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) - providerHistory = (input: { + providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }) => this.requireAgent(input.identity).providerHistory?.(input) ?? Promise.resolve(null) + }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) 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 9f5cb7689e30..bc3c27ca5b13 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 @@ -36,7 +36,7 @@ import { import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' -import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' @@ -230,12 +230,6 @@ export type StructuredAgentSessionAdapter = { * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise - /** The id the provider will record the next send under, for one that adopts a client-chosen - * id. Durable before the hand-over, so a restart can look the send up instead of guessing. */ - mintDispatchIdentity?(input: { - sessionId: string - body: AgentJournalMessageItem - }): AgentJournalItemIdentity | null dispatch(input: { sessionId: string clientMessageId: string @@ -246,8 +240,6 @@ export type StructuredAgentSessionAdapter = { requestedAt?: number /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise - /** From `mintDispatchIdentity`, already durable: the frame must carry exactly this id. */ - providerIdentity?: AgentJournalItemIdentity }): Promise /** `agent` answers for a session with no child running, from the provider alone. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport @@ -340,14 +332,15 @@ 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 - /** Provider history for restart reconciliation. Only the adapter can say whether a read has a - * proven start and whether a turn is still running, so it owns both; liveness is sampled by - * this call, the history read only when a stranded send needs it. Omit where the provider - * records no usable history; that leaves every unsettled submission `unknown`. */ - providerHistory?(input: { + /** Provider history for restart reconciliation, bounded to what the provider + * recorded after the journal's last committed item. Only the adapter can say + * whether the read has a proven start and whether a turn is still running, so + * it owns both flags. Omit where the provider records no boundary-consistent + * history; an omitted window leaves every unsettled submission `unknown`. */ + providerHistoryWindow?(input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }): Promise + }): Promise /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 36ce77747963..8fdee5c62bdb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -42,7 +42,7 @@ import { withAgentSessionCreatePhase, type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type AttachFlowInput = { @@ -108,7 +108,7 @@ export async function performAttach( let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false - let providerHistory: ProviderHistorySource | null = null + let providerHistoryWindow: ProviderHistoryWindow | null = null const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) @@ -159,11 +159,10 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - // Sample provider history before a new child is acquired, so liveness describes - // the child the crash left, not the one this attach starts. The transcript is - // read later, and only for a stranded send: the new child is sent nothing - // first, so nothing it appends can be one. - providerHistory = await sampleProviderHistory({ + // Sample provider history before a new child is acquired. Once acquireOwner + // starts the child, the adapter's liveness signal intentionally becomes + // conservative and an absent prompt can no longer prove non-delivery. + providerHistoryWindow = await readProviderHistoryWindow({ adapter: input.adapter, identity: journalIdentityFor(record, params), accountHome: record.accountHome, @@ -231,7 +230,7 @@ export async function performAttach( journalRoot: input.journalRoot, adapter: input.adapter, openConversation: input.openConversation, - providerHistory + providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) @@ -261,19 +260,19 @@ export async function performAttach( } } -async function sampleProviderHistory(input: { +async function readProviderHistoryWindow(input: { adapter: StructuredAgentSessionAdapter identity: AgentSessionJournalIdentity accountHome: AgentSessionRecord['accountHome'] ownerAlreadyAdmitted: boolean -}): Promise { - const sample = input.adapter.providerHistory - if (!sample) { +}): Promise { + const read = input.adapter.providerHistoryWindow + if (!read) { return null } - let history: ProviderHistorySource | null + let history: ProviderHistoryWindow | null try { - history = await sample({ identity: input.identity, accountHome: input.accountHome }) + history = await read({ identity: input.identity, accountHome: input.accountHome }) } catch { return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index cd1121613c74..36f6ebc79e85 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -7,11 +7,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' @@ -46,28 +46,21 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } -const SENT_FRAME = { provider: 'claude', sessionId: 'provider-1', uuid: 'cm_1-frame' } as const - -/** A transcript that holds `cm_1`'s frame and nothing else. */ -function window(overrides: Partial = {}): ProviderHistorySource { - return { - turnInFlight: false, - readWindow: async () => ({ items: [], boundaryConsistent: true }), - readRecorded: async () => ({ itemIds: new Set([agentJournalItemKey(SENT_FRAME)]) }), - ...overrides - } +function window(overrides: Partial = {}): ProviderHistoryWindow { + return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides } } /** Only the surface `attachJournal` touches; every send-shaped method is a spy * so a re-delivery would be visible rather than silent. */ -function adapterWith(providerHistory?: () => Promise): { +function adapterWith(providerHistoryWindow?: () => Promise): { adapter: StructuredAgentSessionAdapter dispatch: ReturnType } { const dispatch = vi.fn() - const surface = { dispatch, ...(providerHistory ? { providerHistory } : {}) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attachJournal reads only these members. - const adapter = surface as unknown as StructuredAgentSessionAdapter + const adapter = { + dispatch, + ...(providerHistoryWindow ? { providerHistoryWindow } : {}) + } as unknown as StructuredAgentSessionAdapter return { adapter, dispatch } } @@ -86,16 +79,6 @@ async function crashedJournal(clientMessageId = 'cm_1', text = 'deploy the thing body: userMessage(text), fence: RECORD.lease.runtimeFence }) - await journal.resolveDispatch({ - clientMessageId, - state: 'pending', - providerIdentity: { - provider: 'claude', - sessionId: 'provider-1', - uuid: `${clientMessageId}-frame` - }, - fence: RECORD.lease.runtimeFence - }) await journal.close() } @@ -121,28 +104,40 @@ afterEach(async () => { }) describe('attachJournal restart reconciliation', () => { - it('settles a send found by its frame id and stops reporting it unconfirmed', async () => { + it('settles a provably undelivered submission and stops reporting it unconfirmed', async () => { await crashedJournal() const { adapter, dispatch } = adapterWith(async () => window()) const attached = await attach(adapter) expect(attached.unconfirmedClientMessageIds).toEqual([]) - expect(attached.journal.submissions()[0]).toMatchObject({ - dispatchState: 'accepted', - providerItemId: agentJournalItemKey(SENT_FRAME) - }) + const submission = attached.journal.submissions()[0] + expect(submission?.dispatchState).toBe('rejected') + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) // Deciding is not sending: nothing here puts the message back on the wire. expect(dispatch).not.toHaveBeenCalled() }) - it('still reports a submission unconfirmed when history does not hold it', async () => { - await crashedJournal('cm_2') - const { adapter, dispatch } = adapterWith(async () => window()) + it('gives a send the provider never received no verdict and no listing', async () => { + await crashedJournal() + const { adapter } = adapterWith(async () => window()) const attached = await attach(adapter) - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_2']) + // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. + const { items, submissions } = attached.journal.snapshot() + expect( + projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) + ).toMatchObject({ summary: { status: null }, latestRequest: null }) + }) + + it('still reports a submission unconfirmed when the window cannot decide it', async () => { + await crashedJournal() + const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) + + const attached = await attach(adapter) + + expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') expect(dispatch).not.toHaveBeenCalled() }) @@ -184,6 +179,7 @@ describe('attachJournal restart reconciliation', () => { fence: RECORD.lease.runtimeFence, handoverRecorded: true }) + // History that holds nothing: absence would prove a handed-over message undelivered. const { adapter, dispatch } = adapterWith(async () => window()) const attached = await attachJournal({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index fd7b7ae65836..10c4866cea3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -41,7 +41,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' -import type { ProviderHistorySource } from '../agent-session-journal/journal-submission-reconciler' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' @@ -173,9 +173,9 @@ export type AttachedJournal = { * open — with provider history deciding the submissions that boundary could only doubt. * * Why the reconciliation belongs HERE and nowhere else: this runs after the - * record store handed this host the lease and before `onAttached` lets a send - * reach the provider child, so nothing appended to the provider's history while it - * is read can be a stranded send, and the read stays valid until the resume consumes it. Every other + * record store handed this host the lease and before `onAttached` starts a + * provider child, so nothing can be appending to the provider's history while it + * is read, and the window stays valid until the resume consumes it. Every other * settlement site — a proven child exit — runs while the host * may still start another child, and a read there could be overtaken before it * is acted on. Orca still never re-sends: this decides state only. A queued @@ -189,8 +189,8 @@ export async function attachJournal(input: { /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise /** Provider history sampled before a new child is acquired. `null` means the - * adapter had no usable history; omit to sample here for direct callers. */ - providerHistory?: ProviderHistorySource | null + * adapter had no usable history; omit to read lazily for direct callers. */ + providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { const identity = journalIdentityFor(input.record, input.params) const fence = input.record.lease.runtimeFence @@ -201,7 +201,9 @@ export async function attachJournal(input: { journal, fence, accountHome: input.record.accountHome, - ...(Object.hasOwn(input, 'providerHistory') ? { history: input.providerHistory } : {}) + ...(Object.hasOwn(input, 'providerHistoryWindow') + ? { history: input.providerHistoryWindow } + : {}) }) return { journal, @@ -227,15 +229,15 @@ async function reconcileAgainstProviderHistory(input: { journal: AgentSessionJournal fence: number accountHome: AgentSessionAccountHome - history?: ProviderHistorySource | null + history?: ProviderHistoryWindow | null }): Promise { let history = input.history if (history === undefined) { - if (!input.adapter.providerHistory) { + if (!input.adapter.providerHistoryWindow) { return [] } try { - history = await input.adapter.providerHistory({ + history = await input.adapter.providerHistoryWindow({ identity: input.identity, accountHome: input.accountHome }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index f4318bb516d9..d88a6330e789 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -124,13 +124,10 @@ export async function interruptedRestart( closeSession, ...(work === 'submission' || work === 'send-after-reply' ? { - providerHistory: async () => ({ - turnInFlight: false, - readWindow: async () => ({ - items: [], - boundaryConsistent: historyBoundaryConsistent - }), - readRecorded: async () => null + providerHistoryWindow: async () => ({ + items: [], + boundaryConsistent: historyBoundaryConsistent, + turnInFlight: false }) } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 2e09850a7b38..b45e08314a0d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -91,14 +91,16 @@ it.each(['turn', 'submission'] as const)( } ) -// Teardown judged the chat working. A send history cannot confirm is still the chat Orca stopped; -// the continuation asks the agent to check what finished rather than refusing. -it('still continues a chat whose last send history cannot confirm', async () => { +// Teardown judged the chat working. A send the provider proves it never received is still the chat +// Orca stopped; the continuation asks the agent +// to check what finished rather than refusing. +it('still continues a chat whose last send acquisition proves was never delivered', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ - dispatchState: 'unknown' + dispatchState: 'rejected', + rejection: { kind: 'notDelivered' } }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index a747d4c31a00..49642b9a897e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -12,7 +12,6 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { - AgentJournalItemIdentity, AgentJournalMessageItem, AgentJournalStatusItem, AgentJournalSubmission @@ -76,8 +75,7 @@ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number, - providerIdentity: AgentJournalItemIdentity | null + requestedAt: number ): Promise { try { return await ctx.adapter.dispatch({ @@ -85,8 +83,7 @@ async function dispatchSafely( clientMessageId, body, fence: ctx.fence, - requestedAt, - ...(providerIdentity ? { providerIdentity } : {}) + requestedAt }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { @@ -181,24 +178,10 @@ export async function handOverSubmission( }) return } - // Durable with the hand-over, so a restart decides delivery by this id, not by content. - const providerIdentity = - ctx.adapter.mintDispatchIdentity?.({ sessionId: ctx.sessionId, body }) ?? null - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'pending', - fence: ctx.fence, - ...(providerIdentity ? { providerIdentity } : {}) - }) + await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) // The row written at acceptance is the send's instant on the host clock; the turn this // dispatch opens records it so the live counter never re-anchors at turn-open. - const outcome = await dispatchSafely( - ctx, - clientMessageId, - body, - submission.submittedAt, - providerIdentity - ) + const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 1f2de289bd00..a17561e6b810 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -396,9 +396,6 @@ export type AgentJournalSubmission = { handoverRecorded?: true /** When the host handed it to the provider (its `dispatch{pending}` row). */ handedOverAt?: number - /** Host-only: the provider item key the hand-over stamped on the frame, so a restart can look - * the send up by identity. Absent for providers that choose their own ids, and on older rows. */ - handedOverItemId?: string /** Host-only: the submission row's sequence, which tells which host process accepted it. */ acceptedSequence?: number } From 460cd0f8bb4f281bcbc48f390a95c6e48820a879 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:46:57 -0700 Subject: [PATCH 10/49] fix(native-chat): end a restarted session with one row instead of reading the transcript The crash boundary no longer decides delivery from the provider's saved transcript. A send an earlier process handed over and never heard back about stays in doubt, and the chat's open writes one status row saying the session did not survive the restart. The row comes from the stale settlement that already writes the exit row, so one boundary never shows two; an acquisition that is first to open the chat now settles there too, with the death evidence it read before its reserve. Deletes the anchored history window, the submission reconciler, the attach-time reconcile and its pre-acquire sampling, and the adapter hook that fed them. The attach result keeps an always-empty unconfirmedClientMessageIds for older parsers. --- ...laude-history-window-source-budget.test.ts | 247 ------------ .../claude-structured-history-window.test.ts | 351 ------------------ .../claude-structured-history-window.ts | 292 --------------- .../claude-structured-session-adapter.ts | 12 - .../journal-crash-boundary.test.ts | 282 +------------- .../journal-restart-reconciliation.test.ts | 298 --------------- .../journal-restart-reconciliation.ts | 131 ------- .../journal-submission-reconciler.ts | 203 ---------- ...-agent-session-accept-then-deliver.test.ts | 49 ++- ...-agent-session-acquisition-options.test.ts | 29 +- ...structured-agent-session-adapter-router.ts | 10 +- .../structured-agent-session-adapter.ts | 11 - .../structured-agent-session-attach-flow.ts | 46 +-- ...ured-agent-session-attach-orchestration.ts | 2 +- ...gent-session-attach-reconciliation.test.ts | 199 ---------- ...-agent-session-attach-test-conversation.ts | 2 +- .../structured-agent-session-attach.ts | 89 +---- ...uctured-agent-session-conversation-open.ts | 57 +-- ...gent-session-dead-generation-settlement.ts | 49 ++- ...ssion-restart-interruption-test-harness.ts | 15 +- ...t-session-restart-offer-withdrawal.test.ts | 2 +- ...ed-agent-session-restart-ownership.test.ts | 17 +- ...d-agent-session-stale-turn-verdict.test.ts | 93 +++++ ...ructured-conversation-command-admission.ts | 2 +- 24 files changed, 253 insertions(+), 2235 deletions(-) delete mode 100644 src/main/claude/claude-history-window-source-budget.test.ts delete mode 100644 src/main/claude/claude-structured-history-window.test.ts delete mode 100644 src/main/claude/claude-structured-history-window.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts diff --git a/src/main/claude/claude-history-window-source-budget.test.ts b/src/main/claude/claude-history-window-source-budget.test.ts deleted file mode 100644 index 90d74b94e373..000000000000 --- a/src/main/claude/claude-history-window-source-budget.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -// The streaming contract the history window now reads under: two bounded passes -// over ONE pinned snapshot, and no whole-file buffer at any point. The previous -// contract was a single bounded read, which made an oversized transcript report -// an inconsistent boundary — that answer left reconciliation permanently -// unresolved for a session whose transcript simply grew, so what is asserted -// here is the opposite: it resolves, and it never holds the file. - -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { FileHandle } from 'node:fs/promises' -import type * as FsPromises from 'node:fs/promises' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const state = vi.hoisted(() => ({ - path: '', - growth: '', - readError: false, - bytesRead: 0, - /** Largest single chunk handed to the framer across every pass. */ - peakChunkBytes: 0, - streams: 0, - closes: 0, - opens: 0, - observedStatBytes: 0 -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const fs = await importOriginal() - return { - ...fs, - open: async (path: string, flags: string) => { - const handle = await fs.open(path, flags) - if (path !== state.path) { - return handle - } - state.opens += 1 - return { - stat: async () => { - const snapshot = await handle.stat() - state.observedStatBytes = snapshot.size - if (state.growth) { - const growth = state.growth - state.growth = '' - await fs.appendFile(path, growth) - } - return snapshot - }, - createReadStream: (options: Parameters[0]) => { - state.streams += 1 - const stream = handle.createReadStream(options) - if (state.readError) { - queueMicrotask(() => stream.destroy(new Error('Injected read failure'))) - return stream - } - stream.on('data', (chunk: Buffer | string) => { - const size = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk) - state.bytesRead += size - state.peakChunkBytes = Math.max(state.peakChunkBytes, size) - }) - return stream - }, - close: async () => { - state.closes += 1 - await handle.close() - } - } - } - } -}) - -import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { readClaudeProviderHistoryWindow } from './claude-structured-history-window' - -const LEGACY_LIMIT = 16 * 1024 * 1024 -const SOURCE = `${[ - { - type: 'user', - uuid: 'anchor', - parentUuid: null, - sessionId: 'provider', - message: { role: 'user', content: 'before' } - }, - { - type: 'user', - uuid: 'latest', - parentUuid: 'anchor', - sessionId: 'provider', - message: { role: 'user', content: 'after' } - }, - { type: 'last-prompt', sessionId: 'provider', leafUuid: 'latest' } -] - .map((row) => JSON.stringify(row)) - .join('\n')}\n` -let directory = '' - -const read = (previousLeafUuid: string | null = 'anchor') => - readClaudeProviderHistoryWindow({ - transcriptPath: state.path, - providerSessionId: 'provider', - previousLeafUuid, - sessionId: 'orca', - turnInFlight: false - }) - -/** Filler rows, so the padding a size test needs is still valid JSONL. */ -function padTo(bytes: number): string { - const filler = `${JSON.stringify({ type: 'comment', note: 'x'.repeat(4096) })}\n` - const rows = Math.ceil((bytes - Buffer.byteLength(SOURCE)) / Buffer.byteLength(filler)) - return filler.repeat(Math.max(rows, 0)) + SOURCE -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-history-source-budget-')) - Object.assign(state, { - path: join(directory, 'session.jsonl'), - growth: '', - readError: false, - bytesRead: 0, - peakChunkBytes: 0, - streams: 0, - opens: 0, - closes: 0, - observedStatBytes: 0 - }) - await writeFile(state.path, SOURCE) -}) - -afterEach(async (context) => { - try { - const output = process.env.ORCA_HISTORY_BUDGET_PROOF_OUTPUT - if (output) { - await appendFile( - output, - `${JSON.stringify({ - test: context.task.name, - bytesRead: state.bytesRead, - peakChunkBytes: state.peakChunkBytes, - observedStatBytes: state.observedStatBytes, - streams: state.streams, - opens: state.opens, - closes: state.closes - })}\n` - ) - } - expect(state.closes).toBe(state.opens) - } finally { - await rm(directory, { recursive: true, force: true }) - } -}) - -describe('Claude provider history source budget', () => { - it('reads a stable history without changing prompt evidence', async () => { - const result = await read() - expect(result.boundaryConsistent).toBe(true) - expect(result.items.map((item) => item.providerItemId)).toEqual(['latest']) - }) - - it('streams two passes over one descriptor rather than one whole-file read', async () => { - await read() - // One open for both passes: pass 2 must see the bytes pass 1 vouched for. - expect(state.opens).toBe(1) - expect(state.streams).toBe(2) - expect(state.bytesRead).toBe(2 * Buffer.byteLength(SOURCE)) - }) - - it('opens no second pass when nothing followed the anchor', async () => { - await writeFile( - state.path, - SOURCE.split('\n') - // Only the record — `"latest"` alone would take the marker row with it, - // leaving an unprovable transcript that satisfies this test vacuously. - .filter((line) => !line.includes('"uuid":"latest"')) - .join('\n') - .replace('"leafUuid":"latest"', '"leafUuid":"anchor"') - ) - - const result = await read() - - expect(result.boundaryConsistent).toBe(true) - expect(result.items).toEqual([]) - expect(state.streams).toBe(1) - }) - - it('resolves a source past the legacy whole-file limit instead of refusing it', async () => { - // The old bounded read returned an inconsistent boundary here, which is the - // one answer reconciliation can never act on. - await writeFile(state.path, padTo(LEGACY_LIMIT + 1)) - expect(state.observedStatBytes).toBe(0) - - const result = await read() - - expect(result.boundaryConsistent).toBe(true) - expect(result.items.map((item) => item.providerItemId)).toEqual(['latest']) - expect(state.bytesRead).toBeGreaterThan(2 * LEGACY_LIMIT) - }) - - it('keeps resident bytes bounded by the chunk size, not the file size', async () => { - await writeFile(state.path, padTo(LEGACY_LIMIT + 1)) - expect((await read()).boundaryConsistent).toBe(true) - // A whole-file read would show one chunk the size of the transcript. - expect(state.peakChunkBytes).toBeLessThan(1024 * 1024) - }) - - it('refuses a single record too large to frame', async () => { - // Per-record, not per-file: the framer buffers one line, so an unbounded - // record is the only remaining way for the source to become resident. - const huge = `${JSON.stringify({ type: 'comment', note: 'x'.repeat(LEGACY_LIMIT) })}\n` - await writeFile(state.path, huge + SOURCE) - expect((await read()).boundaryConsistent).toBe(false) - }) - - it('replays a concurrent repair at the grown size, not the pinned one', async () => { - const grown = `${JSON.stringify({ - type: 'user', - uuid: 'grown', - parentUuid: 'latest', - sessionId: 'provider', - message: { role: 'user', content: 'appended' } - })}\n` - const torn = Math.floor(grown.length / 2) - // A torn last record: the proof's first attempt fails, and the retry is what - // sees both the repair AND the record the window has to report. - await writeFile(state.path, SOURCE + grown.slice(0, torn)) - state.growth = grown.slice(torn) - - const result = await read() - - // The retry re-runs BOTH passes at the new size: a pass 2 left at the old - // size would silently drop the record the proof just accepted. - expect(result.boundaryConsistent).toBe(true) - expect(result.items.map((item) => item.providerItemId)).toEqual(['latest', 'grown']) - // Failed graph pass, re-run graph pass, replay pass — all on the one descriptor. - expect(state.streams).toBe(3) - expect(state.opens).toBe(1) - }) - - it('preserves the inconsistent result on a read error', async () => { - state.readError = true - expect((await read()).boundaryConsistent).toBe(false) - }) - - it('does not open a source without an anchor', async () => { - expect((await read(null)).boundaryConsistent).toBe(false) - expect(state.opens).toBe(0) - expect(state.bytesRead).toBe(0) - }) -}) diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts deleted file mode 100644 index 851831b86f19..000000000000 --- a/src/main/claude/claude-structured-history-window.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -// The Claude half of restart reconciliation: which transcript records become -// evidence, and when the read may be called boundary-consistent at all. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-outbox' -import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { - claudeProviderHistoryWindowFromJsonl, - resolveClaudeProviderHistoryWindow -} from './claude-structured-history-window' - -const PROVIDER_SESSION = 'provider-1' -const ORCA_SESSION = 'session-1' - -let accountHome: string - -type Row = Record - -function prompt(uuid: string, parentUuid: string | null, content: unknown, extra: Row = {}): Row { - return { - type: 'user', - uuid, - parentUuid, - sessionId: PROVIDER_SESSION, - message: { role: 'user', content }, - ...extra - } -} - -function jsonl(rows: Row[], leafUuid: string): string { - const lines = [...rows, { type: 'last-prompt', sessionId: PROVIDER_SESSION, leafUuid }] - return `${lines.map((row) => JSON.stringify(row)).join('\n')}\n` -} - -function read(contents: string, previousLeafUuid: string | null, turnInFlight = false) { - return claudeProviderHistoryWindowFromJsonl({ - contents, - providerSessionId: PROVIDER_SESSION, - previousLeafUuid, - sessionId: ORCA_SESSION, - turnInFlight - }) -} - -/** The digest the submission row carries for a plain typed send. */ -function sendFingerprint(text: string): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: ORCA_SESSION, - fields: { body: structuredAgentSessionSendBody(text, []) } - }) -} - -const ANCHOR = prompt('anchor', null, 'earlier turn') - -beforeEach(async () => { - accountHome = await mkdtemp(join(tmpdir(), 'orca-claude-history-window-')) -}) - -afterEach(async () => { - await rm(accountHome, { recursive: true, force: true }) -}) - -describe('claudeProviderHistoryWindowFromJsonl', () => { - it('resolves history from the session account home, not the process default', async () => { - const transcriptPath = join(accountHome, 'projects', 'work', `${PROVIDER_SESSION}.jsonl`) - await mkdir(join(accountHome, 'projects', 'work'), { recursive: true }) - await writeFile( - transcriptPath, - jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), - 'utf8' - ) - - const window = await resolveClaudeProviderHistoryWindow({ - identity: { - sessionId: ORCA_SESSION, - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: 'anchor' } - }, - accountHomePath: accountHome, - hasLiveSession: false - }) - - expect(window?.items.map((item) => item.providerItemId)).toEqual(['u-1']) - }) - - it('pins the renderer and host fingerprint functions to the same digest', () => { - // The renderer computes a send's fingerprint with one, the host admission gate - // validates it with the other, and the window matches with the host's. A - // divergence would refuse every send long before it reached here — but it - // would also silently turn every reconciliation into `not_delivered`. - const input = { - method: 'agentSession.send', - sessionId: ORCA_SESSION, - fields: { body: structuredAgentSessionSendBody('ship it', []) } - } - - expect(structuredAgentSessionPayloadFingerprint(input)).toBe( - computeAgentSessionPayloadFingerprint(input) - ) - }) - - it('fingerprints a prompt after the anchor exactly as the send that produced it', () => { - const contents = jsonl( - [ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: 'ship it' }])], - 'u-1' - ) - - const window = read(contents, 'anchor') - - expect(window.boundaryConsistent).toBe(true) - expect(window.items).toEqual([ - { - providerItemId: 'u-1', - clientMessageId: null, - payloadFingerprint: sendFingerprint('ship it'), - identity: { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'u-1' } - } - ]) - }) - - it('fingerprints a string-content prompt the same as a block-content one', () => { - const asString = read(jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), 'anchor') - - expect(asString.items[0]?.payloadFingerprint).toBe(sendFingerprint('ship it')) - }) - - it('preserves leading whitespace when fingerprinting a text block', () => { - const contents = jsonl( - [ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: ' ship it' }])], - 'u-1' - ) - - expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe(sendFingerprint(' ship it')) - }) - - it('excludes everything before the anchor', () => { - const contents = jsonl( - [ - prompt('root', null, 'first'), - prompt('anchor', 'root', 'second'), - prompt('u-1', 'anchor', 'third') - ], - 'u-1' - ) - - expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-1']) - }) - - it('reports no window and an inconsistent boundary without a durable anchor', () => { - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') - - expect(read(contents, null)).toEqual({ - items: [], - boundaryConsistent: false, - turnInFlight: false - }) - }) - - it('reports an inconsistent boundary when the anchor is gone from the file', () => { - // What a compaction or a fresh session file leaves behind. - const contents = jsonl([prompt('u-1', null, 'ship it')], 'u-1') - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('reports an inconsistent boundary when the leaf is on a sibling branch', () => { - const contents = jsonl( - [ - prompt('root', null, 'first'), - prompt('anchor', 'root', 'second'), - prompt('u-1', 'root', 'branched') - ], - 'u-1' - ) - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('reports an inconsistent boundary on a torn tail', () => { - const contents = `${jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1')}{"type":"user"` - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('keeps the boundary consistent and the window empty when nothing followed the anchor', () => { - expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toEqual({ - items: [], - boundaryConsistent: true, - turnInFlight: false - }) - }) - - it('excludes harness-injected turns, meta turns, tool results and sidechains', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('u-reminder', 'anchor', [ - { type: 'text', text: 'be careful' } - ]), - prompt('u-meta', 'u-reminder', [{ type: 'text', text: 'injected' }], { isMeta: true }), - prompt('u-tool', 'u-meta', [{ type: 'tool_result', content: 'ok' }]), - prompt('u-real', 'u-tool', 'ship it') - ], - 'u-real' - ) - - expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-real']) - }) - - it('excludes a prompt carrying an image, whose path the transcript does not keep', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('u-img', 'anchor', [ - { type: 'text', text: 'look at this' }, - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } } - ]) - ], - 'u-img' - ) - - expect(read(contents, 'anchor').items).toEqual([]) - expect(read(contents, 'anchor').boundaryConsistent).toBe(true) - }) - - it('keeps the FIRST record for a repeated uuid, as the whole-file index did', () => { - // An append-only transcript can repeat a uuid. Preferring the later copy - // would silently swap one turn's evidence for another's. - const contents = jsonl( - [ - ANCHOR, - prompt('u-1', 'anchor', 'first copy'), - prompt('u-1', 'anchor', 'second copy'), - prompt('u-2', 'u-1', 'next') - ], - 'u-2' - ) - - expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe( - sendFingerprint('first copy') - ) - }) - - it('drops a repeated uuid whose first copy is not a prompt', () => { - // The de-dupe runs BEFORE the prompt filter, so a later prompt-shaped copy - // cannot promote a uuid the index had already resolved to a non-prompt. - const contents = jsonl( - [ - ANCHOR, - prompt('u-1', 'anchor', 'injected', { isMeta: true }), - prompt('u-1', 'anchor', 'real prompt') - ], - 'u-1' - ) - - expect(read(contents, 'anchor').items).toEqual([]) - expect(read(contents, 'anchor').boundaryConsistent).toBe(true) - }) - - it('refuses a malformed line rather than skipping past it into the window', () => { - // The branch proof runs first and throws on any unparseable record; the - // replay that follows only tolerates them because that pass already ran. - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1').replace( - '{"type":"last-prompt"', - 'not json\n{"type":"last-prompt"' - ) - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('carries the caller-proven turn-in-flight fact through to the window', () => { - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') - - expect(read(contents, 'anchor', true).turnInFlight).toBe(true) - }) -}) - -describe('a crash between Claude saving a prompt and Orca recording its echo', () => { - const row = (type: string, uuid: string, parentUuid: string | null, extra: Row = {}): Row => ({ - type, - uuid, - parentUuid, - isSidechain: false, - sessionId: PROVIDER_SESSION, - ...extra - }) - const marker = (leafUuid: string): Row => ({ - type: 'last-prompt', - sessionId: PROVIDER_SESSION, - leafUuid - }) - const side = (type: string): Row => ({ type, sessionId: PROVIDER_SESSION }) - // Shaped like a real 2.1.280 transcript: after a turn, Claude's marker names its stop-hook - // summary, and a crash mid-turn leaves the next prompt after the marker with no newer marker. - const CRASHED_MID_TURN = [ - side('queue-operation'), - row('attachment', 'hook-start', null, { attachment: { type: 'hook_success' } }), - prompt('alpha', 'hook-start', [{ type: 'text', text: 'ALPHA' }]), - row('attachment', 'alpha-context', 'alpha', { attachment: { type: 'date' } }), - marker('alpha-context'), - side('ai-title'), - row('assistant', 'alpha-reply', 'alpha-context', { - message: { role: 'assistant', content: [{ type: 'text', text: 'ALPHA' }] } - }), - row('attachment', 'alpha-hook', 'alpha-reply', { attachment: { type: 'hook_success' } }), - row('system', 'alpha-stop-summary', 'alpha-hook', { subtype: 'stop_hook_summary' }), - marker('alpha-stop-summary'), - side('queue-operation'), - prompt('bravo', 'alpha-stop-summary', [{ type: 'text', text: 'BRAVO' }]) - ] - .map((entry) => JSON.stringify(entry)) - .join('\n') - - it('reconciles the prompt Claude already holds as accepted, not undelivered', () => { - // The durable anchor is Orca's last completed turn: the reply it saw on the live stream. - const window = read(`${CRASHED_MID_TURN}\n`, 'alpha-reply') - expect(window).toMatchObject({ boundaryConsistent: true }) - expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo']) - - const [verdict] = reconcileSubmissions({ - history: window, - submissions: [ - { - clientMessageId: 'bravo-send', - fence: 1, - payloadFingerprint: sendFingerprint('BRAVO'), - dispatchState: 'unknown', - providerItemId: null, - reason: null, - submittedAt: 0, - resolvedAt: null - } - ] - }) - expect(verdict).toMatchObject({ clientMessageId: 'bravo-send', outcome: 'accepted' }) - }) - - it('ends the conversation at the last main-chain row, never a trailing sidechain row', () => { - const subagent = row('assistant', 'subagent-reply', null, { isSidechain: true }) - const window = read(`${CRASHED_MID_TURN}\n${JSON.stringify(subagent)}\n`, 'alpha-reply') - expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo']) - }) -}) diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts deleted file mode 100644 index 00ae86920251..000000000000 --- a/src/main/claude/claude-structured-history-window.ts +++ /dev/null @@ -1,292 +0,0 @@ -// Provider history for restart reconciliation, read from the Claude project JSONL. -// -// Why this file is the source of truth for "did Claude take it": a resume replays -// this transcript by session id, so a message absent from it is absent from the -// conversation Orca is about to resume. Absence here is not an inference about a -// dead child — it is the content of the next turn's context. -// -// The window is anchored on the leaf uuid Orca durably recorded for the session -// and walks back to it from the file's last transcript row, which is where a -// resume by session id continues; Claude's marker lags a crash mid-turn. -// Without that anchor the read has no proven start, and the branch proof is what -// decides whether the file we just read still descends from it: a fork, a -// compaction, a sibling branch, or a torn tail all fail the proof, and every one -// of those makes absence meaningless. Failing it reports an inconsistent -// boundary rather than an empty window, because the two decide opposite things. - -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import type { - ProviderHistoryItem, - ProviderHistoryWindow -} from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { claudeContentBlocks } from '../native-chat/transcript-record-blocks' -import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import type { NativeChatBlock } from '../../shared/native-chat-types' -import { - replayClaudeTranscriptBranchAncestry, - replayClaudeTranscriptBranchAncestryFromJsonl -} from './claude-transcript-branch-proof' - -/** The legacy-import bound, now applied PER RECORD rather than per file. The - * line framer buffers one record at a time, so this is the only thing standing - * between a pathological row and the whole file being resident. */ -const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024 - -const INCONSISTENT: ProviderHistoryWindow = { - items: [], - boundaryConsistent: false, - turnInFlight: false -} - -type TranscriptRecord = Record - -function stringField(source: unknown, key: string): string | null { - if (!source || typeof source !== 'object') { - return null - } - const value = (source as Record)[key] - return typeof value === 'string' && value.trim() ? value : null -} - -function isPlainTextPart(part: unknown): boolean { - if (typeof part === 'string') { - return true - } - return Boolean(part) && typeof part === 'object' && (part as TranscriptRecord).type === 'text' -} - -/** Recover the text bytes Claude received; the shared decoder trims for display. */ -function rawTextParts(content: unknown): string[] | null { - if (typeof content === 'string') { - return content.trim() ? [content] : [] - } - if (!Array.isArray(content)) { - return [] - } - const parts: string[] = [] - for (const part of content) { - if (typeof part === 'string') { - if (part.trim()) { - parts.push(part) - } - continue - } - const record = part && typeof part === 'object' ? (part as TranscriptRecord) : null - if (!record || record.type !== 'text' || typeof record.text !== 'string') { - return null - } - if (record.text.trim()) { - parts.push(record.text) - } - } - return parts -} - -/** - * A user record Orca could itself have submitted. Everything the harness injects - * is excluded, because a fingerprint computed over machinery would claim a - * history slot the user's message should have had. - * - * Attachments are excluded too, and deliberately: the transcript keeps a pasted - * image as base64 with no path, so the `image-ref` block the submission was - * fingerprinted from cannot be reconstructed. Measured over 2,764 genuine prompt - * records in 12 local transcripts, every multi-block prompt was exactly - * text + image; none carried injected content. - */ -function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null { - if ( - record.type !== 'user' || - record.isSidechain === true || - record.parent_tool_use_id != null || - record.isMeta === true || - record.isSynthetic === true || - record.isCompactSummary === true - ) { - return null - } - const message = record.message - const content = - message && typeof message === 'object' ? (message as TranscriptRecord).content : undefined - // Read the RAW parts, not the decoded ones: a base64 image decodes to nothing - // at all, so a prompt with an attachment would otherwise pass as text-only and - // be fingerprinted as if the attachment had never been sent. - if (Array.isArray(content) && !content.every((part) => isPlainTextPart(part))) { - return null - } - const blocks = claudeContentBlocks(content) - if (blocks.length === 0 || blocks.some((block) => block.type !== 'text')) { - return null - } - const rawTexts = rawTextParts(content) - if (rawTexts === null || rawTexts.length !== blocks.length) { - return null - } - const preservedBlocks = blocks.map((block, index) => ({ - ...block, - text: rawTexts[index]! - })) - const [first] = preservedBlocks - if (first?.type !== 'text' || isKnownHarnessInjectedUserTurnText(first.text)) { - return null - } - return preservedBlocks -} - -/** - * The digest the submission row is GUARANTEED to carry. `admitAndRunAgentSessionMutation` - * recomputes this exact call over the send's own body and refuses the send on a - * mismatch, and `performSend` is the only writer of a submission row — so the - * stored fingerprint is this function's output over the stored body, whoever - * produced the envelope. Matching here is therefore an equality between two runs - * of one function, not a guess about two encodings agreeing. - */ -function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { - return computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body: { kind: 'message', role: 'user', blocks } } - }) -} - -type HistoryWindowInput = { - providerSessionId: string - previousLeafUuid: string | null - /** Orca session id: the fingerprint a submission carries is scoped to it. */ - sessionId: string - /** The caller must PROVE no provider child can be appending; absence proves - * nothing while a turn is running. */ - turnInFlight: boolean -} - -/** - * Sink for the ancestry replay. Only the fingerprinted item survives the call, - * so the window holds at most one small row per anchor..leaf record — never the - * prompt bodies it was computed from. - */ -function createWindowCollector(input: HistoryWindowInput) { - const byUuid = new Map() - return { byUuid, onAncestorRecord } - - function onAncestorRecord(record: TranscriptRecord, uuid: string): void { - const blocks = claudePromptBlocks(record) - if (!blocks) { - return - } - byUuid.set(uuid, { - providerItemId: uuid, - // Claude echoes no client message id, so identity matching reduces to the - // fingerprint pass; the reconciler treats that as the weakest evidence. - clientMessageId: null, - payloadFingerprint: promptFingerprint(input.sessionId, blocks), - identity: { - provider: 'claude', - sessionId: stringField(record, 'sessionId') ?? input.providerSessionId, - uuid - } - }) - } -} - -/** The chain is leaf first; a history window is oldest first. */ -function windowFromChain( - chain: readonly string[], - byUuid: Map, - turnInFlight: boolean -): ProviderHistoryWindow { - const items: ProviderHistoryItem[] = [] - for (const uuid of chain.toReversed()) { - const item = byUuid.get(uuid) - if (item) { - items.push(item) - } - } - return { items, boundaryConsistent: true, turnInFlight } -} - -export function claudeProviderHistoryWindowFromJsonl( - input: HistoryWindowInput & { contents: string } -): ProviderHistoryWindow { - const ancestryAnchorUuid = input.previousLeafUuid - if (!ancestryAnchorUuid) { - return INCONSISTENT - } - const collector = createWindowCollector(input) - try { - const { chain } = replayClaudeTranscriptBranchAncestryFromJsonl({ - contents: input.contents, - providerSessionId: input.providerSessionId, - previousLeafUuid: ancestryAnchorUuid, - ancestryAnchorUuid, - onAncestorRecord: collector.onAncestorRecord - }) - return windowFromChain(chain, collector.byUuid, input.turnInFlight) - } catch { - // Every failure mode here — missing ancestor, sibling branch, compacted - // cursor, torn tail — is a boundary we cannot vouch for. - return INCONSISTENT - } -} - -/** - * The window for one attached session: resolve the provider's transcript, then - * read it against the handle's durable leaf. A live child means a send queued - * behind its running turn is not in the file yet, so liveness is carried in - * rather than assumed — only the adapter's session map can answer it. - */ -export async function resolveClaudeProviderHistoryWindow(input: { - identity: AgentSessionJournalIdentity - accountHomePath: string - hasLiveSession: boolean -}): Promise { - const handle = input.identity.providerHandle - if (handle.kind !== 'claude') { - return null - } - const transcriptPath = await resolveSessionFilePath('claude', handle.sessionId, { - claudeProjectsDir: join(input.accountHomePath, 'projects') - }) - if (!transcriptPath) { - return null - } - return readClaudeProviderHistoryWindow({ - transcriptPath, - providerSessionId: handle.sessionId, - previousLeafUuid: handle.leafUuid, - sessionId: input.identity.sessionId, - turnInFlight: input.hasLiveSession - }) -} - -export async function readClaudeProviderHistoryWindow( - input: HistoryWindowInput & { transcriptPath: string } -): Promise { - const ancestryAnchorUuid = input.previousLeafUuid - if (!ancestryAnchorUuid) { - return INCONSISTENT - } - const collector = createWindowCollector(input) - try { - const { chain } = await replayClaudeTranscriptBranchAncestry({ - transcriptPath: input.transcriptPath, - providerSessionId: input.providerSessionId, - previousLeafUuid: ancestryAnchorUuid, - ancestryAnchorUuid, - maxRecordBytes: MAX_HISTORY_WINDOW_RECORD_BYTES, - onAncestorRecord: collector.onAncestorRecord - }) - return windowFromChain(chain, collector.byUuid, input.turnInFlight) - } catch (error) { - // Unreadable, unprovable, or a single record too large to frame: all of them - // leave the boundary unvouched for, which is not the same as an empty window. - // Oversize is no longer among them, so only the log separates what is left. - console.warn('[claude-history-window] transcript unprovable; boundary inconsistent:', { - transcriptPath: input.transcriptPath, - sessionId: input.sessionId, - error - }) - return INCONSISTENT - } -} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae9..81b0f4e5c9e2 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -34,7 +34,6 @@ import { type ClaudeExitLifecycle } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' import { drainClaudeChildWork } from './claude-child-work-evidence' import { admitClaudePromptCancellation, @@ -130,17 +129,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda return claudeStartupFailureFact(session) ?? undefined } - /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ - providerHistoryWindow: NonNullable = ( - input - ) => - resolveClaudeProviderHistoryWindow({ - identity: input.identity, - accountHomePath: input.accountHome.path, - hasLiveSession: - this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) - }) - private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { if (event.type === 'ended') { session?.childWork.clear() diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 6cc267a90547..b854c5b83cfd 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -1,6 +1,6 @@ // The crash boundary: the host wrote a submission row, dispatched, and died -// before it learned whether the provider took the message. Replay must reconcile -// without duplicating the user's message and without losing it. +// before it learned whether the provider took the message. The send stays in +// doubt for good — never re-sent, never dropped from the conversation. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -19,11 +19,6 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/str import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { digestPayload } from './journal-payload-bounds' -import { - reconcileSubmissions, - type ProviderHistoryItem, - type ProviderHistoryWindow -} from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' const IDENTITY: AgentSessionJournalIdentity = { @@ -68,28 +63,6 @@ async function open() { }) } -/** A Codex `userMessage` history item; `clientId` is the echoed client message id. */ -function history(input: { - itemId: string - clientId: string | null - text: string - ordinal: number -}): ProviderHistoryItem { - return { - providerItemId: input.itemId, - clientMessageId: input.clientId, - payloadFingerprint: digestPayload(input.text), - identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal } - } -} - -function window( - items: ProviderHistoryItem[], - overrides: Partial = {} -): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } -} - beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-')) clock = 1_000 @@ -101,7 +74,7 @@ afterEach(async () => { }) describe('crash between provider accept and journal commit', () => { - it('reconciles the echo into the existing bubble instead of duplicating it', async () => { + it('leaves the send in doubt, and in the conversation, never re-sent', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -109,7 +82,7 @@ describe('crash between provider accept and journal commit', () => { body: userMessage('deploy the thing'), fence: 1 }) - // Host dies here: the provider accepted, but no dispatch row was written. + // Host dies here: the provider may have the message, but no dispatch row was written. const restarted = await open() expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) @@ -117,33 +90,10 @@ describe('crash between provider accept and journal commit', () => { expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') // Marks the send as outlived by its writer, so no reader reports it as still working. expect(restarted.submissions()[0]?.recovered).toBe(true) - - const [outcome] = reconcileSubmissions({ - submissions: restarted.submissions(), - history: window([ - history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) - - if (outcome?.outcome !== 'accepted') { - throw new Error('expected the echoed submission to reconcile as accepted') - } - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'accepted', - providerIdentity: outcome.identity, - fence: 2, - recovered: true - }) - // The provider's own copy of the message arrives next, under the identity - // reconciliation adopted. It must land in the bubble the user already sees. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) - - const items = restarted.snapshot().items - expect(items).toHaveLength(1) - expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) - expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(outcome.identity)) + expect(restarted.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('cm_1') + ]) + expect(restarted.pendingSubmissions()).toHaveLength(0) }) it('retires an ack timeout on restart without changing its delivery verdict', async () => { @@ -227,44 +177,7 @@ describe('crash between provider accept and journal commit', () => { }) }) - it('reports a rejected submission as never delivered, and never re-sends it', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('never landed'), - body: userMessage('never landed'), - fence: 1 - }) - - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - const [outcome] = reconcileSubmissions({ - submissions: restarted.submissions(), - history: window([]) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'rejected', - reason: 'not_delivered' - }) - - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: 2, - recovered: true - }) - // The bubble survives with an explicit terminal state — the message is not - // silently retried and not silently dropped. - expect(restarted.snapshot().items).toHaveLength(1) - expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') - expect(restarted.receiptFor('cm_1')).toBeNull() - }) - - it('survives replay of an already-reconciled journal without changing the answer', async () => { + it('survives replay of a settled journal without changing the answer', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -283,9 +196,6 @@ describe('crash between provider accept and journal commit', () => { const reopened = await open() expect(reopened.snapshot()).toEqual(settled) expect(reopened.pendingSubmissions()).toHaveLength(0) - expect( - reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) }) - ).toEqual([]) }) it('keeps the receipt across a reopen', async () => { @@ -308,177 +218,3 @@ describe('crash between provider accept and journal commit', () => { expect(reopened.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(ACCEPTED_IDENTITY)) }) }) - -describe('reconciliation matching', () => { - const submissions = [ - { - clientMessageId: 'cm_1', - fence: 1, - payloadFingerprint: digestPayload('same text'), - dispatchState: 'unknown' as const, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - }, - { - clientMessageId: 'cm_2', - fence: 1, - payloadFingerprint: digestPayload('same text'), - dispatchState: 'unknown' as const, - providerItemId: null, - reason: null, - submittedAt: 2, - resolvedAt: null - } - ] - - it('matches each submission to its own echo when the provider carries client ids', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }), - history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }), - expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' }) - ]) - }) - - it('refuses to guess between two identical payloads with no id to tell them apart', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), - history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 }) - ]) - }) - expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) - expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) - }) - - it('does not assign one matching item to the first of two identical sends', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }) - ]) - }) - - expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) - expect(outcomes.map((outcome) => ('reason' in outcome ? outcome.reason : null))).toEqual([ - 'ambiguous_match', - 'ambiguous_match' - ]) - }) - - it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), - history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) - }) - - it('never matches on text alone when the fingerprint disagrees', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([ - { - providerItemId: 'item-1', - clientMessageId: null, - payloadFingerprint: digestPayload('different payload, same rendered text'), - identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } - } - ]) - }) - expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) - }) - - it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), - expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) - ]) - }) - - it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => { - const [outcome] = reconcileSubmissions({ - submissions: [{ ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }], - history: window([ - // The provider renumbered its raw id; the identity-derived key did not move. - history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' }) - }) - - it('never hands one provider item to two submissions', () => { - const outcomes = reconcileSubmissions({ - submissions: [ - { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }, - submissions[1]! - ], - // One item, wanted by both passes: cm_1 adopted its key, cm_2 is echoed on - // it. Adopting it twice would render the same provider message twice. - history: window([ - history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), - expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) - ]) - }) - - it('stays unknown when the history boundary cannot be trusted', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([], { boundaryConsistent: false }) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'unknown', - reason: 'history_boundary_inconsistent' - }) - }) - - it('stays unknown while a turn is still running', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([], { turnInFlight: true }) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'unknown', - reason: 'turn_in_flight' - }) - }) - - it('leaves settled submissions alone', () => { - expect( - reconcileSubmissions({ - submissions: [ - { - ...submissions[0]!, - dispatchState: 'accepted', - providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) - }, - { ...submissions[1]!, dispatchState: 'rejected' } - ], - history: window([]) - }) - ).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts deleted file mode 100644 index 4428e13953e1..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -// Wiring the restart reconciler: what provider history is allowed to decide -// about a submission the crash boundary could only doubt. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { digestPayload } from './journal-payload-bounds' -import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' -import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-store-test-open' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } -} - -function claudeIdentity(uuid: string): AgentJournalItemIdentity { - return { provider: 'claude', sessionId: 'provider-1', uuid } -} - -let root: string -let clock = 1_000 - -function tick(): number { - clock += 1 - return clock -} - -function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -const journals = createTrackedJournalOpener() - -async function open() { - return journals.open({ - identity: IDENTITY, - journalDir: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) -} - -function history(uuid: string, text: string): ProviderHistoryItem { - return { - providerItemId: uuid, - clientMessageId: null, - payloadFingerprint: digestPayload(text), - identity: claudeIdentity(uuid) - } -} - -function window( - items: ProviderHistoryItem[], - overrides: Partial = {} -): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } -} - -/** A host that wrote the submission row and died before learning its outcome. */ -async function reopenAfterCrash( - body: AgentJournalMessageItem = userMessage('deploy the thing'), - text = 'deploy the thing' -) { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload(text), - body, - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - return restarted -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-restart-reconcile-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('reconcileJournalSubmissionsAgainstHistory', () => { - it('settles a message found in provider history as accepted on its provider identity', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-1', 'deploy the thing')]) - }) - - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('accepted') - expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) - }) - - it('settles a message provably absent from history as rejected: not_delivered', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - // A sentence, since released clients print the reason as it is, and the fact beside it. - expect(submission?.reason).toBe( - agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) - .reason - ) - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) - // Nobody failed: the crash stranded it before the provider took it. - expect(submission && classifyDispatchRejection(submission)).toEqual({ - category: 'undelivered', - verdict: null, - kind: 'notDelivered' - }) - }) - - it('leaves a submission unknown while the provider reports a turn in flight', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { turnInFlight: true }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a submission unknown when the history boundary is inconsistent', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { boundaryConsistent: false }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { - const journal = await reopenAfterCrash( - { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'look at this' }, - { type: 'image-ref', path: '/tmp/shot.png' } - ] - }, - 'look at this' - ) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves multi-block text sends unknown because Claude joins them before recording history', async () => { - const journal = await reopenAfterCrash( - { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'second' } - ] - }, - 'first\nsecond' - ) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-1', 'first\nsecond')]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('does not let an item the journal already committed stand in for a new send', async () => { - const journal = await open() - // An identical message, delivered and committed BEFORE the one that crashed. - await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1 - }) - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) - }) - - expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') - }) - - it('does not let an older accepted provider item stand in for a new identical send', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_old', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_old', - state: 'accepted', - providerIdentity: claudeIdentity('uuid-old'), - fence: 1 - }) - await journal.appendSubmission({ - clientMessageId: 'cm_new', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'accepted', - 'rejected' - ]) - expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) - }) - - it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { - const journal = await open() - for (const id of ['cm_1', 'cm_2']) { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: digestPayload('ping'), - body: userMessage('ping'), - fence: 1 - }) - } - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-1', 'ping'), history('uuid-2', 'ping')]) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'unknown', - 'unknown' - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts deleted file mode 100644 index 7b36fc88dc63..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ /dev/null @@ -1,131 +0,0 @@ -// The production caller for `reconcileSubmissions`. -// -// Runs once per journal open, after the crash boundary has already settled every -// survivor to `unknown`. It only ever narrows that answer: `accepted` when the -// provider's own history holds the message, `rejected` when a boundary we can -// vouch for proves it never arrived. Anything the reconciler leaves `unknown` -// is left exactly as the crash boundary wrote it. -// -// Nothing here dispatches. A `rejected` submission becomes re-sendable only -// through the user's Retry, which rotates the client message id; Orca still -// never puts a message back on the wire on the user's behalf. - -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { - AgentJournalMessageItem, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../shared/agent-session-journal-item-key' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentSessionJournal } from './journal-store' -import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' - -/** - * Only a text-only body can be compared against provider content. A submission - * carrying an attachment was fingerprinted over an `image-ref` path the - * transcript does not keep, so its absence from history would be an artefact of - * the encoding rather than evidence — and `rejected` is the one outcome that - * costs the user a duplicate if it is wrong. Those stay `unknown`. - */ -function comparableBody(body: AgentJournalMessageItem | undefined): boolean { - return ( - body?.kind === 'message' && - body.role === 'user' && - body.blocks.length === 1 && - body.blocks[0]?.type === 'text' && - body.blocks[0].text.trim().length > 0 - ) -} - -function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmission[] { - const { items, submissions } = journal.snapshot() - const bodies = new Map(items.map((item) => [item.itemId, item.body])) - return submissions.filter((submission) => { - if ( - (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') || - // Never handed over, so provider history cannot hold it. - isQueuedAgentJournalSubmission(submission) - ) { - return false - } - const body = bodies.get(agentJournalSubmissionKey(submission.clientMessageId)) - return comparableBody(body?.kind === 'message' ? body : undefined) - }) -} - -/** Items the journal already committed are not new evidence: leaving them - * claimable would let an undelivered message match an older identical one. */ -function unseenHistory( - journal: AgentSessionJournal, - history: ProviderHistoryWindow -): ProviderHistoryWindow { - const snapshot = journal.snapshot() - const committed = new Set(snapshot.items.map((item) => item.itemId)) - // Accepted submissions alias their provider item to the optimistic `orca:*` - // row, so the rendered item id alone does not identify the provider history - // already consumed by the journal. - for (const submission of snapshot.submissions) { - if (submission.dispatchState === 'accepted' && submission.providerItemId) { - committed.add(submission.providerItemId) - } - } - return { - ...history, - items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) - } -} - -/** - * Decide what the crash boundary could only doubt. Returns the client message - * ids this pass settled, so the attach result stops reporting them unconfirmed. - */ -export async function reconcileJournalSubmissionsAgainstHistory(input: { - journal: AgentSessionJournal - fence: number - history: ProviderHistoryWindow -}): Promise { - const submissions = comparableSubmissions(input.journal) - if (submissions.length === 0) { - return [] - } - const settled: string[] = [] - for (const outcome of reconcileSubmissions({ - submissions, - history: unseenHistory(input.journal, input.history) - })) { - if (outcome.outcome === 'unknown') { - // Narrowing failed: the submission stays unconfirmed, so record why. - console.warn('[journal-reconcile] submission left unconfirmed:', { - clientMessageId: outcome.clientMessageId, - reason: outcome.reason, - fence: input.fence - }) - continue - } - await input.journal.resolveDispatch( - outcome.outcome === 'accepted' - ? { - clientMessageId: outcome.clientMessageId, - state: 'accepted', - providerIdentity: outcome.identity, - fence: input.fence, - recovered: true - } - : { - clientMessageId: outcome.clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: input.fence, - recovered: true - } - ) - settled.push(outcome.clientMessageId) - } - return settled -} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts deleted file mode 100644 index 4647ba1e0e37..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ /dev/null @@ -1,203 +0,0 @@ -// Restart reconciliation for the crash boundary. -// -// A submission row is durable before dispatch, so after a crash the host knows -// what it TRIED to send but not whether the provider took it. Every surviving -// `pending` becomes `unknown` and is then matched against provider history. -// -// Matching is by identity only — an echoed client message id, else a provider -// item id the journal already adopted, else the payload fingerprint when it -// picks out exactly one unclaimed item. Never by text equality: the same -// question asked twice is two messages, and collapsing them silently loses one. -// -// Orca never re-sends on the user's behalf. An unresolved submission stays -// `unknown` — a displayed state meaning "delivery unconfirmed", neither sent nor -// failed — and the user chooses to resend or discard. - -import type { - AgentJournalItemIdentity, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' - -export type ProviderHistoryItem = { - /** The provider's own id for this item. Used to claim it at most once; the - * journal key comes from `identity`, because a provider id is not stable. */ - providerItemId: string - /** The client message id when the provider echoes one (Codex carries it on - * user messages); null for providers that drop it. */ - clientMessageId: string | null - /** Fingerprint of the submitted payload, when the caller can compute one from - * provider content. Used only to break an otherwise unique tie. */ - payloadFingerprint: string | null - identity: AgentJournalItemIdentity -} - -export type ProviderHistoryWindow = { - /** Provider items observed at or after the journal's last committed item. */ - items: readonly ProviderHistoryItem[] - /** - * The history read actually started at the journal's last committed item. A - * fork, a compacted provider log, or a truncated read makes absence - * meaningless, so a missing submission cannot be called "not delivered". - */ - boundaryConsistent: boolean - /** The provider reports a turn still running: absence proves nothing yet. */ - turnInFlight: boolean -} - -export type SubmissionReconciliation = - | { - clientMessageId: string - outcome: 'accepted' - providerItemId: string - identity: AgentJournalItemIdentity - } - | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } - | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } - -export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED - -export type SubmissionUnknownReason = - | 'history_boundary_inconsistent' - | 'turn_in_flight' - | 'ambiguous_match' - -/** - * Resolve every unsettled submission against provider history. - * - * Passes run strongest-first across ALL submissions before the next begins, so - * a weak fingerprint tie can never claim an item that an echoed client message - * id would have matched exactly. Each history item is claimable once. - */ -export function reconcileSubmissions(input: { - submissions: readonly AgentJournalSubmission[] - history: ProviderHistoryWindow -}): SubmissionReconciliation[] { - const unsettled = input.submissions.filter( - (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' - ) - const claimed = new Set() - const matched = new Map() - const ambiguous = new Set() - - claimBy( - unsettled, - input.history.items, - claimed, - matched, - (submission, item) => - // A submission that already adopted a key re-matches on that key, not on the - // provider's raw id — the raw id renumbers, the identity-derived key does not. - Boolean(submission.providerItemId) && - submission.providerItemId === agentJournalItemKey(item.identity) - ) - claimBy( - unsettled, - input.history.items, - claimed, - matched, - (submission, item) => - Boolean(item.clientMessageId) && item.clientMessageId === submission.clientMessageId - ) - - // Resolve fingerprint candidates as a batch. Assigning the sole candidate to - // the first identical submission would make the later one look absent even - // though either submission could be the delivered one. - const submissionsByFingerprint = new Map() - for (const submission of unsettled) { - if (matched.has(submission.clientMessageId) || !submission.payloadFingerprint) { - continue - } - const sameFingerprint = submissionsByFingerprint.get(submission.payloadFingerprint) ?? [] - sameFingerprint.push(submission) - submissionsByFingerprint.set(submission.payloadFingerprint, sameFingerprint) - } - for (const [fingerprint, fingerprintSubmissions] of submissionsByFingerprint) { - const candidates = input.history.items.filter( - (item) => !claimed.has(item.providerItemId) && item.payloadFingerprint === fingerprint - ) - if (fingerprintSubmissions.length === 1 && candidates.length === 1) { - const [submission] = fingerprintSubmissions - const [only] = candidates - claimed.add(only!.providerItemId) - matched.set(submission!.clientMessageId, only!) - continue - } - if (candidates.length > 0) { - // Equal payloads without an id cannot be assigned safely, including when - // fewer provider items exist than unsettled submissions. - for (const submission of fingerprintSubmissions) { - ambiguous.add(submission.clientMessageId) - } - } - } - - return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history)) -} - -function claimBy( - submissions: readonly AgentJournalSubmission[], - items: readonly ProviderHistoryItem[], - claimed: Set, - matched: Map, - matches: (submission: AgentJournalSubmission, item: ProviderHistoryItem) => boolean -): void { - for (const submission of submissions) { - if (matched.has(submission.clientMessageId)) { - continue - } - const item = items.find((candidate) => { - return !claimed.has(candidate.providerItemId) && matches(submission, candidate) - }) - if (item) { - claimed.add(item.providerItemId) - matched.set(submission.clientMessageId, item) - } - } -} - -function resolveOne( - submission: AgentJournalSubmission, - matched: Map, - ambiguous: Set, - history: ProviderHistoryWindow -): SubmissionReconciliation { - const item = matched.get(submission.clientMessageId) - if (item) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'accepted', - providerItemId: item.providerItemId, - identity: item.identity - } - } - if (ambiguous.has(submission.clientMessageId)) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'ambiguous_match' - } - } - if (!history.boundaryConsistent) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'history_boundary_inconsistent' - } - } - if (history.turnInFlight) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'turn_in_flight' - } - } - // Absent from a history we can trust the boundary of, with nothing running: - // the provider never took it. - return { - clientMessageId: submission.clientMessageId, - outcome: 'rejected', - reason: DISPATCH_REJECTED_NOT_DELIVERED - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb4ee607e38d..b224ec7759c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -186,6 +186,16 @@ async function errorFailures(): Promise<(AgentSessionFailureFact | undefined)[]> ) } +/** The failure kind of every row saying the session ended; one per crash boundary. */ +async function restartRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && + (item.body.failure?.kind === 'hostRestarted' || item.body.failure?.kind === 'providerExited') + ? [item.body.failure.kind] + : [] + ) +} + let subscriptions = 0 async function subscribe(): Promise { @@ -540,8 +550,6 @@ describe('what an earlier host process left behind', () => { }) it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => { - const providerHistoryWindow = vi.fn(async () => null) - adapterExtras = { providerHistoryWindow } await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) @@ -554,8 +562,41 @@ describe('what an earlier host process left behind', () => { expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) - // Deciding them from provider history waits for a won lease (W4′d). - expect(providerHistoryWindow).not.toHaveBeenCalled() + expect(await restartRows()).toEqual(['hostRestarted']) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('says once that the session did not survive, however often the chat reopens', async () => { + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) + await journal.resolveDispatch({ clientMessageId: 'handed', state: 'pending', fence }) + }) + await host.flushAllStreamedEvents() + await startHost() + await host.revealSession(SESSION) + await host.flushAllStreamedEvents() + await startHost() + await host.revealSession(SESSION) + + expect(await restartRows()).toEqual(['hostRestarted']) + }) + + it('writes the one row when an acquisition is the first to open the chat', async () => { + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) + await journal.resolveDispatch({ clientMessageId: 'handed', state: 'pending', fence }) + }) + await host.flushAllStreamedEvents() + await startHost() + + const fence = store.getRecord(SESSION)!.lease.runtimeFence + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: true, + value: { unconfirmedClientMessageIds: [] } + }) + + expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await restartRows()).toEqual(['hostRestarted']) expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 7c12909e0363..dc4c3f8589b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -10,7 +10,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { attachFingerprintFields, type AgentSessionAttachParams @@ -113,23 +112,16 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { } describe('structured session acquisition options', () => { - it('samples provider history before acquiring a replacement child', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) + it("leaves a crashed send in doubt at an acquisition's first open, and says so once", async () => { + root = await mkdtemp(join(tmpdir(), 'orca-doubt-at-acquire-')) const initialStore = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - let childAcquired = false - const historyWindow = (): ProviderHistoryWindow => ({ - items: [], - boundaryConsistent: true, - turnInFlight: childAcquired - }) const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { const sessionAdapter = adapter({ origin }) const acquire = vi.mocked(sessionAdapter.acquire) acquire.mockImplementation(async (input) => { - childAcquired = true return { process: { hostId: 'local', @@ -146,7 +138,6 @@ describe('structured session acquisition options', () => { } } }) - sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) return sessionAdapter } @@ -189,7 +180,6 @@ describe('structured session acquisition options', () => { probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) - childAcquired = false const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const second = await performAttach({ @@ -209,12 +199,23 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) - expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) expect(second).toMatchObject({ + ok: true, value: { - page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } + unconfirmedClientMessageIds: [], + page: { + submissions: [ + { clientMessageId: 'crashed-send', dispatchState: 'unknown', recovered: true } + ] + } } }) + const rows = second.ok + ? second.value.page.items.flatMap((item) => + item.body.kind === 'status' ? [item.body.failure?.kind] : [] + ) + : [] + expect(rows).toEqual(['hostRestarted']) }) it('persists create defaults before the first provider acquisition', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index bb003f93427d..b8154a0fda13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,9 +1,6 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionAccountHome, - AgentSessionExecutionLocation -} from '../../../shared/agent-session-record' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' @@ -140,11 +137,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) - providerHistoryWindow = (input: { - identity: AgentSessionJournalIdentity - accountHome: AgentSessionAccountHome - }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) - closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) 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 bc3c27ca5b13..c27b7a09e9b1 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 @@ -19,7 +19,6 @@ import type { } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import type { - AgentSessionAccountHome, AgentSessionExecutionLocation, AgentSessionProcessIdentity } from '../../../shared/agent-session-record' @@ -36,7 +35,6 @@ import { import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' @@ -332,15 +330,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 - /** Provider history for restart reconciliation, bounded to what the provider - * recorded after the journal's last committed item. Only the adapter can say - * whether the read has a proven start and whether a turn is still running, so - * it owns both flags. Omit where the provider records no boundary-consistent - * history; an omitted window leaves every unsettled submission `unknown`. */ - providerHistoryWindow?(input: { - identity: AgentSessionJournalIdentity - accountHome: AgentSessionAccountHome - }): Promise /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8fdee5c62bdb..c6fdc3af358b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -16,13 +16,11 @@ import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { admitAttachOrRefuse, attachJournal, classifyStoreFailure, - journalIdentityFor, reserveRequestFor, type AgentSessionAttachAuthority, type AgentSessionAttachParams, @@ -42,7 +40,6 @@ import { withAgentSessionCreatePhase, type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type AttachFlowInput = { @@ -108,7 +105,6 @@ export async function performAttach( let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false - let providerHistoryWindow: ProviderHistoryWindow | null = null const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) @@ -159,15 +155,6 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - // Sample provider history before a new child is acquired. Once acquireOwner - // starts the child, the adapter's liveness signal intentionally becomes - // conservative and an absent prompt can no longer prove non-delivery. - providerHistoryWindow = await readProviderHistoryWindow({ - adapter: input.adapter, - identity: journalIdentityFor(record, params), - accountHome: record.accountHome, - ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) - }) if (!agentSessionLeaseAdmitsWriter(record.lease)) { const acquired = await withAgentSessionCreatePhase('acquire_owner', input.recordPhase, () => acquireOwner(input, record) @@ -224,14 +211,7 @@ export async function performAttach( let attached: AttachedJournal try { await input.beforeJournalOpen?.() - attached = await attachJournal({ - record, - params, - journalRoot: input.journalRoot, - adapter: input.adapter, - openConversation: input.openConversation, - providerHistoryWindow - }) + attached = await attachJournal({ record, openConversation: input.openConversation }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) await store.recordOperationOutcome({ @@ -254,33 +234,13 @@ export async function performAttach( sessionId, fence, page: readAgentSessionHydrationPage(attached.journal, fence), - unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds, + // No host decides delivery after a restart any more; kept for older clients' parsers. + unconfirmedClientMessageIds: [], ...(tabId ? { tabId } : {}) } } } -async function readProviderHistoryWindow(input: { - adapter: StructuredAgentSessionAdapter - identity: AgentSessionJournalIdentity - accountHome: AgentSessionRecord['accountHome'] - ownerAlreadyAdmitted: boolean -}): Promise { - const read = input.adapter.providerHistoryWindow - if (!read) { - return null - } - let history: ProviderHistoryWindow | null - try { - history = await read({ identity: input.identity, accountHome: input.accountHome }) - } catch { - return null - } - // A lease that was already live may belong to a provider child this process - // has not indexed yet. Preserve the safe unknown outcome in that case. - return history && input.ownerAlreadyAdmitted ? { ...history, turnInFlight: true } : history -} - async function settleUnsupportedReservation( input: AttachFlowInput, record: AgentSessionRecord diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 40804bf55e2e..4e8a76d4629d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -160,7 +160,7 @@ async function runAttach( ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { - acquisition: true + acquisition: { deathEvidence: priorDeathEvidence } }) if (!conversation) { throw new Error('agent_session_identity_required') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts deleted file mode 100644 index 36f6ebc79e85..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Attach is where the restart reconciler runs. These cover the wiring itself: -// that the window the adapter reports reaches the journal, that what it settles -// stops being reported unconfirmed, and that deciding never sends. - -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 { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' -import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { - attachJournal, - journalIdentityFor, - type AgentSessionAttachParams -} from './structured-agent-session-attach' - -const RECORD = agentSessionRecordFixture() - -const PARAMS = { - envelope: { - sessionId: RECORD.sessionId, - clientOperationId: 'op-1', - expectedRuntimeFence: RECORD.lease.runtimeFence, - payloadFingerprint: 'fp' - }, - location: RECORD.location, - provider: 'claude', - agent: 'claude', - accountHome: RECORD.accountHome, - runtimeKind: 'native' -} as unknown as AgentSessionAttachParams - -const IDENTITY = journalIdentityFor(RECORD, PARAMS) - -let root: string -const journals = createTrackedJournalOpener() - -function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -function window(overrides: Partial = {}): ProviderHistoryWindow { - return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides } -} - -/** Only the surface `attachJournal` touches; every send-shaped method is a spy - * so a re-delivery would be visible rather than silent. */ -function adapterWith(providerHistoryWindow?: () => Promise): { - adapter: StructuredAgentSessionAdapter - dispatch: ReturnType -} { - const dispatch = vi.fn() - const adapter = { - dispatch, - ...(providerHistoryWindow ? { providerHistoryWindow } : {}) - } as unknown as StructuredAgentSessionAdapter - return { adapter, dispatch } -} - -/** A previous process wrote the submission row and died before its outcome. */ -async function crashedJournal(clientMessageId = 'cm_1', text = 'deploy the thing') { - const journal = await journals.open({ - identity: IDENTITY, - journalDir: journalDirectoryFor(root, { - workspaceId: IDENTITY.workspaceId, - sessionId: IDENTITY.sessionId - }) - }) - await journal.appendSubmission({ - clientMessageId, - payloadFingerprint: digestPayload(text), - body: userMessage(text), - fence: RECORD.lease.runtimeFence - }) - await journal.close() -} - -async function attach(adapter: StructuredAgentSessionAdapter) { - const attached = await attachJournal({ - record: RECORD, - params: PARAMS, - journalRoot: root, - openConversation: openTestAttachConversation(root), - adapter - }) - journals.track(attached.journal) - return attached -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-attach-reconcile-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('attachJournal restart reconciliation', () => { - it('settles a provably undelivered submission and stops reporting it unconfirmed', async () => { - await crashedJournal() - const { adapter, dispatch } = adapterWith(async () => window()) - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual([]) - const submission = attached.journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) - // Deciding is not sending: nothing here puts the message back on the wire. - expect(dispatch).not.toHaveBeenCalled() - }) - - it('gives a send the provider never received no verdict and no listing', async () => { - await crashedJournal() - const { adapter } = adapterWith(async () => window()) - - const attached = await attach(adapter) - - // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. - const { items, submissions } = attached.journal.snapshot() - expect( - projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) - ).toMatchObject({ summary: { status: null }, latestRequest: null }) - }) - - it('still reports a submission unconfirmed when the window cannot decide it', async () => { - await crashedJournal() - const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) - expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') - expect(dispatch).not.toHaveBeenCalled() - }) - - it('leaves the crash boundary untouched for an adapter that reports no history', async () => { - await crashedJournal() - const { adapter } = adapterWith() - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) - expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('does not fail the attach when reading provider history throws', async () => { - await crashedJournal() - const { adapter } = adapterWith(async () => { - throw new Error('transcript unreadable') - }) - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) - expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a message the open conversation still has queued alone (W4′e)', async () => { - const journal = await journals.open({ - identity: IDENTITY, - journalDir: journalDirectoryFor(root, { - workspaceId: IDENTITY.workspaceId, - sessionId: IDENTITY.sessionId - }) - }) - await journal.appendSubmission({ - clientMessageId: 'queued', - payloadFingerprint: digestPayload('still queued'), - body: userMessage('still queued'), - fence: RECORD.lease.runtimeFence, - handoverRecorded: true - }) - // History that holds nothing: absence would prove a handed-over message undelivered. - const { adapter, dispatch } = adapterWith(async () => window()) - - const attached = await attachJournal({ - record: RECORD, - params: PARAMS, - journalRoot: root, - - adapter, - openConversation: async () => journal - }) - - expect(attached.journal).toBe(journal) - expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'pending' }) - expect(journal.submissions()[0]?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index 1e2322cbc6d8..61c89e342f3a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -12,7 +12,7 @@ export function openTestAttachConversation( return async (record) => ( await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record, { - acquisition: true + acquisition: { deathEvidence: record.lease.deathEvidence ?? null } }) ).session.journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 10c4866cea3f..5dab3ba981f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -40,9 +40,6 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' /** @@ -164,95 +161,17 @@ export function journalIdentityFor( export type AttachedJournal = { journal: AgentSessionJournal - /** Submissions the crash boundary left `unknown` that provider history could not decide. */ - unconfirmedClientMessageIds: string[] } -/** - * The conversation's journal — opened, and its crash boundary settled, by the conversation's own - * open — with provider history deciding the submissions that boundary could only doubt. - * - * Why the reconciliation belongs HERE and nowhere else: this runs after the - * record store handed this host the lease and before `onAttached` starts a - * provider child, so nothing can be appending to the provider's history while it - * is read, and the window stays valid until the resume consumes it. Every other - * settlement site — a proven child exit — runs while the host - * may still start another child, and a read there could be overtaken before it - * is acted on. Orca still never re-sends: this decides state only. A queued - * submission is left alone: it was never handed over, so history cannot hold it. - */ +/** The conversation's journal, opened — and its crash boundary settled — by the conversation's + * own open. Nothing here reads provider history: a send a gone generation left in doubt is drawn + * as sent, and the open's one session row says the session did not survive. */ export async function attachJournal(input: { record: AgentSessionRecord - params: AgentSessionAttachParams - journalRoot: string - adapter: StructuredAgentSessionAdapter /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise - /** Provider history sampled before a new child is acquired. `null` means the - * adapter had no usable history; omit to read lazily for direct callers. */ - providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { - const identity = journalIdentityFor(input.record, input.params) - const fence = input.record.lease.runtimeFence - const journal = await input.openConversation(input.record) - const settled = await reconcileAgainstProviderHistory({ - adapter: input.adapter, - identity, - journal, - fence, - accountHome: input.record.accountHome, - ...(Object.hasOwn(input, 'providerHistoryWindow') - ? { history: input.providerHistoryWindow } - : {}) - }) - return { - journal, - unconfirmedClientMessageIds: journal - .submissions() - .filter( - (entry) => - entry.dispatchState === 'unknown' && - entry.recovered === true && - !settled.includes(entry.clientMessageId) - ) - .map((entry) => entry.clientMessageId) - } -} - -/** Reading provider history is best effort: a provider that reports none, or a - * read that fails, leaves every submission exactly as the crash boundary wrote - * it. The journal writes the outcome implies are NOT caught here — a failed - * write must reach the caller that retains the journal handle. */ -async function reconcileAgainstProviderHistory(input: { - adapter: StructuredAgentSessionAdapter - identity: AgentSessionJournalIdentity - journal: AgentSessionJournal - fence: number - accountHome: AgentSessionAccountHome - history?: ProviderHistoryWindow | null -}): Promise { - let history = input.history - if (history === undefined) { - if (!input.adapter.providerHistoryWindow) { - return [] - } - try { - history = await input.adapter.providerHistoryWindow({ - identity: input.identity, - accountHome: input.accountHome - }) - } catch { - return [] - } - } - if (!history) { - return [] - } - return reconcileJournalSubmissionsAgainstHistory({ - journal: input.journal, - fence: input.fence, - history - }) + return { journal: await input.openConversation(input.record) } } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index bce79ca71f87..cde3783c4b17 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -1,18 +1,21 @@ // The one way a conversation's journal becomes open on this host: for a send, for a reader, and // for an attach that finds none open. // -// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it marks what -// an earlier host process handed over and left unanswered as in doubt, and settles what it left -// running — the crash boundary. That -// needs no lease: provider history decides such a row later, under a won lease, in the attach. A -// row an earlier process accepted and never handed over is the delivery loop's, which the open -// wakes. Nothing here starts a provider child. +// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it settles +// what an earlier host process left behind — the crash boundary. A send it handed over and never +// heard back about stays in doubt for good and is drawn as sent; what it left running is settled; +// and one row says the session did not survive, so the next message is how the chat continues. +// Nothing reads provider history to decide delivery. A row an earlier process accepted and never +// handed over is the delivery loop's, which the open wakes. Nothing here starts a provider child. import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionDeathEvidence, + AgentSessionRecord +} from '../../../shared/agent-session-record' import { attachFingerprintFields, journalIdentityFor, @@ -40,9 +43,11 @@ export type StructuredAgentSessionConversationOpenDeps = { onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] } -/** An acquisition's own open: its reserve cleared the record's death evidence, so it settles - * what the gone generation left running itself, from what it read before. */ -export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean } +/** An acquisition's own open: its reserve cleared the record's death evidence, so it passes what + * it read before. */ +export type StructuredAgentSessionConversationOpenOptions = { + acquisition?: { deathEvidence: AgentSessionDeathEvidence | null } +} export type StructuredAgentSessionConversationOpenContext = { deps: StructuredAgentSessionConversationOpenDeps @@ -96,29 +101,31 @@ export async function openStructuredAgentSessionConversationJournal( fence, historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null }) + // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over + // one stays in doubt. Marked first: the row below is written at most once, never twice. + let sendsLeftInDoubt = 0 try { - // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over - // one is only doubt, which provider history decides under a won lease. - await opened.journal.markPendingSubmissionsUnknown(fence) + sendsLeftInDoubt = (await opened.journal.markPendingSubmissionsUnknown(fence)).length } catch (error) { deps.onEventSinkError?.({ sessionId, error }) } try { // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any - // reader or child sees it. - if (!options.acquisition) { - await settleStaleStructuredAgentSessionState({ - journal: opened.journal, - sessionId, - fence, - acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null, - failureTextContext: structuredAgentSessionFailureWordsContext(record) - }) - } + // reader or child sees it — an acquisition's too, before its child's events drain. + await settleStaleStructuredAgentSessionState({ + journal: opened.journal, + sessionId, + fence, + acquisitionGeneration: null, + deathEvidence: options.acquisition + ? options.acquisition.deathEvidence + : (record.lease.deathEvidence ?? null), + failureTextContext: structuredAgentSessionFailureWordsContext(record), + crashBoundary: { sendsLeftInDoubt } + }) } catch (error) { - // Best effort: the next acquire re-derives it. + // Best effort: the next acquire re-derives what was left running. deps.onEventSinkError?.({ sessionId, error }) } return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index f6a166c7c02d..fc1f43a115ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -188,6 +188,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { * death evidence each time, so nothing is owed in between. Only an observed exit earns an end time * and the exit copy. Must run before a new child's buffered events land, or a live turn would be * judged. + * + * Writes at most one row: the exit's, or at a crash boundary the one saying the session did not + * survive. Both come from here so one boundary can never show two. */ export async function settleStaleStructuredAgentSessionState(input: { journal: AgentSessionJournal @@ -195,8 +198,10 @@ export async function settleStaleStructuredAgentSessionState(input: { fence: number acquisitionGeneration: string | null deathEvidence: AgentSessionDeathEvidence | null - /** Who the exit row names. */ + /** Who the row names. */ failureTextContext?: AgentSessionFailureWordsContext + /** Set only by the conversation's open: how many sends it just left in doubt. */ + crashBoundary?: { sendsLeftInDoubt: number } }): Promise { const { journal } = input const items = journal.snapshot().items @@ -212,19 +217,12 @@ export async function settleStaleStructuredAgentSessionState(input: { } } mutations.push(...runningTurnLifecycleRevisions(items, verdict)) - if (verdict.state === 'interrupted' && items.some(isInProgressItem)) { + const row = staleSettlementRow(input, verdict, items.some(isInProgressItem)) + if (row) { mutations.unshift({ kind: 'item', identity: { provider: 'orca', clientMessageId: settlementId }, - // The death evidence is Orca's log text, never a sentence for a person: the row says only - // that the provider stopped. - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...input.failureTextContext, - surface: 'row' - }) - } + body: row }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { @@ -238,6 +236,35 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } +/** The death evidence is Orca's log text, never a sentence for a person: a row says only that + * the provider stopped, or that the session did not survive the restart. No Retry: sending a + * new message is how the chat continues. */ +function staleSettlementRow( + input: Pick< + Parameters[0], + 'failureTextContext' | 'crashBoundary' + >, + verdict: StructuredAgentSessionTurnVerdict, + inProgress: boolean +): AgentJournalItemBody | null { + const context = { ...input.failureTextContext, surface: 'row' as const } + if (verdict.state === 'interrupted' && inProgress) { + return { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), context) + } + } + const boundary = input.crashBoundary + if (boundary && (inProgress || boundary.sendsLeftInDoubt > 0)) { + return { + kind: 'status', + tone: 'notice', + ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), context) + } + } + return null +} + function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { if (item.body.kind === 'tool-call' && item.body.state === 'running') { return { ...item.body, state: 'failed' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88a6330e789..c8fb801dc605 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -44,7 +44,6 @@ export async function startAgent(state: { export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', - historyBoundaryConsistent = true, /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ probeOwner: NonNullable = async () => ({ outcome: 'pid-absent' @@ -119,19 +118,7 @@ export async function interruptedRestart( const clock = { now: NOW + 1 } const host = new StructuredAgentSessionHost({ store, - adapter: { - ...adapter(), - closeSession, - ...(work === 'submission' || work === 'send-after-reply' - ? { - providerHistoryWindow: async () => ({ - items: [], - boundaryConsistent: historyBoundaryConsistent, - turnInFlight: false - }) - } - : {}) - }, + adapter: { ...adapter(), closeSession }, journalRoot: previous.root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index d36e5702d1dc..0186ef9cc2a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -38,7 +38,7 @@ const SUPERSEDED = 'agent_session_restart_work_superseded' const COLD_START = { timeout: 10_000 } async function offered(work: 'turn' | 'submission' = 'turn') { - const state = await interruptedRestart(work, false) + const state = await interruptedRestart(work) expect(await state.host.restartResume.list()).toHaveLength(1) return state } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index b45e08314a0d..770e3a8d5964 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -79,7 +79,7 @@ it('reports a failed attribution note without an installed error sink or private it.each(['turn', 'submission'] as const)( 'does not continue a marked %s after the user submits new work without a provider echo', async (work) => { - const { host, dispatch } = await interruptedRestart(work, false) + const { host, dispatch } = await interruptedRestart(work) expect(await host.restartResume.list()).toHaveLength(1) await startAgent(hostTestState()) dispatch.mockResolvedValueOnce({ state: 'admitted' }) @@ -91,16 +91,15 @@ it.each(['turn', 'submission'] as const)( } ) -// Teardown judged the chat working. A send the provider proves it never received is still the chat -// Orca stopped; the continuation asks the agent -// to check what finished rather than refusing. -it('still continues a chat whose last send acquisition proves was never delivered', async () => { +// Teardown judged the chat working. A send the restart left in doubt is still the chat Orca +// stopped; the continuation asks the agent to check what finished rather than refusing. +it('still continues a chat whose last send the restart left in doubt', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'notDelivered' } + dispatchState: 'unknown', + recovered: true }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) @@ -110,7 +109,7 @@ it('still continues a chat whose last send acquisition proves was never delivere // The send the user made just before quitting, after an earlier exchange had finished: that // finished turn does not withdraw it. it('offers and continues a send made after an earlier completed turn', async () => { - const { host, dispatch, marker } = await interruptedRestart('send-after-reply', false) + const { host, dispatch, marker } = await interruptedRestart('send-after-reply') expect(marker?.work.kind).toBe('submission') const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) @@ -460,7 +459,7 @@ it('logs teardown capsule publication failure and still releases the provider', // on; the details are filed beside it, never in its place. it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => { // The terminal agent that holds the claim is still running, so nothing may take it over. - const { host, store } = await interruptedRestart('turn', true, async () => ({ + const { host, store } = await interruptedRestart('turn', async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] })) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 4ab7b3ad010c..17faa31c49e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -337,4 +337,97 @@ describe('stale session state on a cold acquire', () => { expect.objectContaining({ settlementId: 'stale-session:session-1:14:seq-8' }) ) }) + + describe('the crash boundary writes at most one row', () => { + const boundaryRows = (batch: ReturnType['appendLifecycleBatch']) => + batch.mock.calls.flatMap(([input]) => + (input as { mutations: { kind: string; body?: { kind: string } }[] }).mutations.filter( + (mutation) => mutation.body?.kind === 'status' + ) + ) + + it('says the session did not survive when it left a send in doubt, with no Retry', async () => { + const { journal, appendLifecycleBatch } = journalWith([ + lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) + ]) + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null, + deathEvidence: null, + failureTextContext: { agentName: 'Claude' }, + crashBoundary: { sendsLeftInDoubt: 1 } + }) + expect(boundaryRows(appendLifecycleBatch)).toEqual([ + { + kind: 'item', + identity: { provider: 'orca', clientMessageId: 'stale-session:session-1:14:seq-8' }, + body: { + kind: 'status', + tone: 'notice', + text: "Claude's session didn't survive the restart. Send a message to continue.", + failure: { kind: 'hostRestarted' } + } + } + ]) + }) + + it('says it once for a running turn it could not see end', async () => { + const { journal, appendLifecycleBatch } = journalWith([ + lifecycleItem('turn-2', 'running', 2, { startedAt: 30 }) + ]) + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null, + deathEvidence: null, + crashBoundary: { sendsLeftInDoubt: 2 } + }) + expect(boundaryRows(appendLifecycleBatch)).toMatchObject([ + { body: { failure: { kind: 'hostRestarted' } } } + ]) + }) + + it("keeps the observed exit's row instead of adding the restart's", async () => { + const { journal, appendLifecycleBatch } = journalWith([ + lifecycleItem('turn-2', 'running', 2, { startedAt: 30 }) + ]) + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null, + deathEvidence: { kind: 'exit-observed', detail: 'exit', observedAt: 500 }, + crashBoundary: { sendsLeftInDoubt: 1 } + }) + expect(boundaryRows(appendLifecycleBatch)).toMatchObject([ + { body: { failure: { kind: 'providerExited' } } } + ]) + }) + + it('writes no row with nothing in doubt, or outside a crash boundary', async () => { + const idle = journalWith([]) + await settleStaleStructuredAgentSessionState({ + journal: idle.journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null, + deathEvidence: null, + crashBoundary: { sendsLeftInDoubt: 0 } + }) + expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() + + const reacquired = journalWith([lifecycleItem('turn-2', 'running', 2)]) + await settleStaleStructuredAgentSessionState({ + journal: reacquired.journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: 'generation-2', + deathEvidence: null + }) + expect(boundaryRows(reacquired.appendLifecycleBatch)).toEqual([]) + }) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index cecdbbf0b139..fb96a9bc974b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -81,7 +81,7 @@ export function conversationCommandBlocked( ) { return blocked( 'messagesUnsettled', - 'Resolve pending or unconfirmed messages before using this command.' + 'Wait for your last message to reach the agent before using this command.' ) } return null From f00a880fc13f733e2348e37315dcac9aee60e129 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:47:13 -0700 Subject: [PATCH 11/49] fix(native-chat): word the restart row and retire the unconfirmed-id field's meaning The restart fact gets a row sentence: the agent's session didn't survive the restart, send a message to continue. unconfirmedClientMessageIds is documented as always empty, and an unknown submission as drawn as sent. --- src/shared/agent-session-failure-words.ts | 5 ++++- src/shared/agent-session-journal-types.ts | 6 +++--- src/shared/agent-session-wire.ts | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index f02d02abdea9..ea306a72d9c0 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -189,7 +189,10 @@ const FAILURE_SENTENCES = { writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", cancelled: () => 'This message was withdrawn before the agent started it.', chatClosed: () => 'The chat closed before this message was sent.', - hostRestarted: () => 'Orca restarted before this message was sent.', + hostRestarted: ({ agentName }, _, surface) => + surface === 'row' + ? `${agentName ?? 'The agent'}'s session didn't survive the restart. Send a message to continue.` + : 'Orca restarted before this message was sent.', notDelivered: () => 'This message was not delivered. Send it again to continue.', compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a17561e6b810..d1d62e755330 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -371,8 +371,8 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { export const AGENT_JOURNAL_DISPATCH_STATES = ['pending', 'accepted', 'rejected', 'unknown'] as const export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[number] -/** The write-ahead submission row, projected. `unknown` is a displayed state: - * the turn reads as delivery unconfirmed, never as sent and never as failed. */ +/** The write-ahead submission row, projected. `unknown` means Orca cannot tell whether the + * provider took it; it is never re-sent, and the chat draws it as an ordinary sent message. */ export type AgentJournalSubmission = { clientMessageId: string /** Execution fence of the latest dispatch attempt or recovery. */ @@ -388,7 +388,7 @@ export type AgentJournalSubmission = { rejection?: UnreadAgentSessionFailureFact submittedAt: number resolvedAt: number | null - /** Set when crash reconciliation resolved the dispatch, not the provider. A live + /** Set when a crash boundary or a dead agent resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true /** The host accepted this send to hand over later; absent on sends dispatched as they were diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 445582c4ad31..7b40e3034895 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -87,8 +87,7 @@ export type AgentSessionHistoryPage = { items: AgentJournalRenderItem[] /** Populated by `after` reads so a disconnected client can apply tombstones. */ removedItemIds: string[] - /** Submissions overlapping this page, so an unconfirmed bubble renders with - * its dispatch state instead of as a plain message. */ + /** Submissions overlapping this page, so a bubble renders with its dispatch state. */ submissions: AgentJournalSubmission[] /** Page edges. `nextCursor` is what the client sends back for the same * direction; it equals the request cursor when the page is empty. */ @@ -299,7 +298,8 @@ export type AgentSessionAttachResult = { sessionId: string fence: number page: AgentSessionHistoryPage - /** Submissions a crash boundary left `unknown` that provider history could not decide. */ + /** Always empty: no host decides delivery after a restart. Kept, since the field is required + * on the wire and no client ever read it. */ unconfirmedClientMessageIds: string[] /** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */ tabId?: string From 632e5eb6f9ac3c30c5ed26a4673e03f7fc6aa3a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:47:17 -0700 Subject: [PATCH 12/49] fix(native-chat): draw a send left in doubt as sent, with no barrier or Retry A submission the host settled unknown, or an older host's not-delivered verdict, is an ordinary sent message on desktop, paired web and mobile: the outbox drops it, nothing queues behind it, and there is no per-message Retry. The refused-redelivery path and its untranslated error go. Mobile spends the operation id on a host unknown, so typing the message again sends a new one. The unconfirmed state and its copy stay only for a send whose reply this client lost. The command refusal for an unsettled message no longer offers a retry that does not exist, in every catalog. --- .../mobile-structured-send-delivery.test.ts | 22 +-- .../mobile-structured-send-delivery.ts | 21 +- ...-structured-send-operation-journal.test.ts | 9 +- ...obile-structured-send-operation-journal.ts | 2 +- ...StructuredSession.transport-probe.test.tsx | 12 +- ...tiveChatStructuredSessionDelivery.test.tsx | 12 +- ...d-agent-session-message-projection.test.ts | 27 ++- ...tructured-agent-session-outbox-dispatch.ts | 3 +- ...e-structured-agent-session-outbox.test.tsx | 181 ++++++------------ .../use-structured-agent-session-outbox.ts | 23 +-- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- .../structured-agent-session-launch-prompt.ts | 16 +- src/shared/agent-session-write-notice-copy.ts | 2 +- ...ctured-agent-session-dispatch-rejection.ts | 21 +- ...ctured-agent-session-message-projection.ts | 8 +- src/shared/structured-agent-session-outbox.ts | 21 +- ...red-agent-session-send-disposition.test.ts | 56 ++++-- ...ructured-agent-session-send-disposition.ts | 66 ++----- 24 files changed, 242 insertions(+), 274 deletions(-) diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index dec553693443..ef5cdc8c88cd 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -13,30 +13,30 @@ function accepted( } describe('mobileStructuredSendDelivery', () => { - it('keeps the operation id for every unknown, host-recorded or ack-lost', () => { - // The one answer that may be a delivery. Spending the id here turns the next - // identical send into a second copy in front of the model. + it('keeps the operation id when the reply was lost, so a retry stays a replay', () => { + // The host may never have recorded it; rotating here is what sent one message five times. expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null }) - expect(mobileStructuredSendDelivery(accepted('unknown'))).toEqual({ - outcome: 'unknown', - operationIdSpent: false, - error: null - }) }) - it('reports a written send as sent and spends its id', () => { - // `pending` is written and awaiting the provider's acknowledgement — not doubt. - for (const dispatchState of ['accepted', 'pending'] as const) { + it('reports a written send as sent and spends its id, in doubt or not', () => { + // `pending` awaits the provider's acknowledgement; `unknown` is doubt the host recorded and + // the chat draws as sent. Typing it again is a new message, as on the desktop. + for (const dispatchState of ['accepted', 'pending', 'unknown'] as const) { expect(mobileStructuredSendDelivery(accepted(dispatchState))).toEqual({ outcome: 'accepted', operationIdSpent: true, error: null }) } + expect(mobileStructuredSendDelivery(accepted('rejected', 'not_delivered'))).toEqual({ + outcome: 'accepted', + operationIdSpent: true, + error: null + }) }) it('does not report a retained payload replay as a new accepted send', () => { diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index b8d0246f5e7a..a73b5e96fb80 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -11,19 +11,21 @@ // ledger and never puts it back on the wire. Releasing the id turns that replay // into a genuine second delivery, which is why only a settled answer releases it: // -// accepted/pending — the send happened. The id is spent; a later identical -// message is a new message and must carry a new id. +// accepted/pending/unknown — the host recorded the send, and the chat draws it +// as sent even when a crash or a dead agent left its delivery in doubt. The id +// is spent; a later identical message is a new message, which is how the chat +// continues, and must carry a new id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, // keeps it because neither proves a retained delivery did not happen. -// unknown — the one answer that KEEPS its id, whether it came from the host or -// from an ack-loss on the way back. The message may be with the provider, so -// the retry has to stay a replay. Rotating here is what sent one message to a -// model five times. +// no answer — an ack-loss on the way back KEEPS its id: the host may not have +// recorded the message, so the retry has to stay a replay. Rotating here is +// what sent one message to a model five times. import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { structuredAgentSessionSubmissionDrawsAsSent } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -61,10 +63,13 @@ export function mobileStructuredSendDelivery( } } const submission = result.value.submission as AgentSessionSendResult['submission'] | undefined - if (!submission || submission.dispatchState === 'unknown') { + if (!submission) { return { outcome: 'unknown', operationIdSpent: false, error: null } } - if (submission.dispatchState === 'rejected') { + if ( + submission.dispatchState === 'rejected' && + !structuredAgentSessionSubmissionDrawsAsSent(submission) + ) { return { outcome: 'rejected', operationIdSpent: true, diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index cee58bded950..34a0c8c59f7d 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -171,18 +171,19 @@ describe('mobile structured send operation journal', () => { clientMessageId: operationId, fence: 1, payloadFingerprint, - dispatchState: 'unknown' as const, + dispatchState: 'pending' as const, providerItemId: null, - reason: 'ack lost', + reason: null, submittedAt: NOW, - resolvedAt: NOW + resolvedAt: null } await clearMobileStructuredSettledSendOperations({ submissions: [submission] }) expect(values.size).toBe(1) + // Doubt the host recorded is settled too: the chat draws it as sent. await clearMobileStructuredSettledSendOperations({ - submissions: [{ ...submission, dispatchState: 'accepted', reason: null }] + submissions: [{ ...submission, dispatchState: 'unknown', reason: 'ack lost' }] }) expect(values.size).toBe(0) }) diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 89779a2082cf..a04b398698b4 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -185,7 +185,7 @@ export async function clearMobileStructuredSettledSendOperations(input: { }): Promise { const settled = new Set( input.submissions.flatMap((submission) => - submission.dispatchState === 'accepted' || submission.dispatchState === 'rejected' + submission.dispatchState !== 'pending' ? [`${submission.payloadFingerprint}\u0000${submission.clientMessageId}`] : [] ) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx index ffa8787e7536..d60248ba47ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx @@ -131,7 +131,7 @@ describe('NativeChatStructuredSession transport-unconfirmed sends', () => { ) }, 20000) - it('parks a host-confirmed unknown instead of probing it', async () => { + it('draws a host-confirmed unknown as sent: never re-sent, and the next message goes out', async () => { mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -156,7 +156,7 @@ describe('NativeChatStructuredSession transport-unconfirmed sends', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - // The host now reports an unresolved unknown: another replay is the user's call. + // The host recorded it and cannot tell whether the agent took it: drawn as sent. mocks.submissions = [ { clientMessageId: sent.envelope.clientOperationId, @@ -169,15 +169,17 @@ describe('NativeChatStructuredSession transport-unconfirmed sends', () => { resolvedAt: null } ] - // Queue a second message purely to re-render so the effect observes the - // new submissions; it must stay wedged behind the parked head. + // The next message re-renders with the new submissions, and nothing holds it back. await act(async () => { send?.('second', []) }) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - expect(mocks.call).toHaveBeenCalledOnce() + const texts = mocks.call.mock.calls.map( + (call) => (call[2] as { body?: { blocks?: { text?: string }[] } }).body?.blocks?.[0]?.text + ) + expect(texts).toEqual(['first', 'second']) }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index abb248f719c1..bac58cc79205 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -385,7 +385,7 @@ describe('NativeChatStructuredSession delivery', () => { ) }, 20000) - it('parks a host-confirmed unknown instead of probing it', async () => { + it('draws a host-confirmed unknown as sent: never re-sent, and the next message goes out', async () => { mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -410,7 +410,7 @@ describe('NativeChatStructuredSession delivery', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - // The host now reports an unresolved unknown: another replay is the user's call. + // The host recorded it and cannot tell whether the agent took it: drawn as sent. mocks.submissions = [ { clientMessageId: sent.envelope.clientOperationId, @@ -423,15 +423,17 @@ describe('NativeChatStructuredSession delivery', () => { resolvedAt: null } ] - // Queue a second message purely to re-render so the effect observes the - // new submissions; it must stay wedged behind the parked head. + // The next message re-renders with the new submissions, and nothing holds it back. await act(async () => { send?.('second', []) }) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - expect(mocks.call).toHaveBeenCalledOnce() + const texts = mocks.call.mock.calls.map( + (call) => (call[2] as { body?: { blocks?: { text?: string }[] } }).body?.blocks?.[0]?.text + ) + expect(texts).toEqual(['first', 'second']) }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 3a3562458cb6..2cd57ebfad8b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -4,7 +4,10 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox +} from '../../../../shared/structured-agent-session-outbox' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' function submission(index: number): AgentJournalSubmission { @@ -40,6 +43,28 @@ describe('structured agent session message projection', () => { ).toMatchObject([{ id: acceptedItem.itemId, role: 'user' }]) }) + it.each([ + ['in doubt after a restart', { dispatchState: 'unknown' as const, recovered: true as const }], + [ + "an older host's not-delivered verdict", + { dispatchState: 'rejected' as const, reason: 'not_delivered' } + ] + ])('draws a send %s as an ordinary sent message, with no outbox entry left', (_label, state) => { + const settled = { ...submission(0), providerItemId: null, ...state } + const sentItem = { ...item(0), itemId: agentJournalSubmissionKey(settled.clientMessageId) } + const left = createStructuredAgentSessionOutboxEntry({ + clientMessageId: settled.clientMessageId, + sessionId: 'session-1', + text: 'send 0', + attachments: [], + queuedAt: 1 + }) + expect(projectStructuredAgentSessionMessages([sentItem], [left], [settled])).toEqual([ + expect.objectContaining({ id: sentItem.itemId, role: 'user' }) + ]) + expect(reconcileStructuredAgentSessionOutbox([left], [settled])).toEqual([]) + }) + it('keeps a refused local draft available through its outbox', () => { const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null } const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 9f91b9f9b3b1..c5de608b124b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -15,6 +15,7 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionSubmissionDrawsAsSent } from '../../../../shared/structured-agent-session-dispatch-rejection' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -119,7 +120,7 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { }) ) return result.ok - ? result.value.submission.dispatchState === 'accepted' || + ? structuredAgentSessionSubmissionDrawsAsSent(result.value.submission) || result.value.submission.dispatchState === 'pending' : false } catch (caught) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 04b25f93e8ad..0d1dc970e138 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -451,23 +451,48 @@ describe('useStructuredAgentSessionOutbox', () => { expect(mocks.call).toHaveBeenCalledOnce() }) - it('drains a head the host refuses to redeliver so the queue behind it advances', async () => { - // The guard refuses a retry it cannot prove is a first delivery. That must - // not leave a Retry that does nothing in front of a wedged queue: the entry - // leaves the outbox, the user is told Orca will not send it again, and the - // message queued behind it goes out. - // The second send never settles, so the refusal's error is still on screen - // when the queue behind it advances. + it('draws a host unknown as sent and sends the queued message behind it, with no Retry', async () => { + // Doubt the host recorded is never a barrier: the entry leaves, nothing says it is + // unconfirmed, and the next message goes out on its own. mocks.call.mockImplementation(async (_target, _method, params) => { const request = params as { envelope: { clientOperationId: string } body: { blocks: { text?: string }[] } } - if (request.body.blocks[0]?.text === 'second') { - return new Promise(() => {}) - } - return unknownResultFor(request.envelope.clientOperationId, 10) + return request.body.blocks[0]?.text === 'second' + ? acceptedResultFor(request.envelope.clientOperationId, 11) + : unknownResultFor(request.envelope.clientOperationId, 10) }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + act(() => expect(result.current.send('second')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + + const sent = mocks.call.mock.calls.map( + (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text + ) + expect(sent).toEqual(['first', 'second']) + expect(result.current.error).toBeNull() + expect(result.current.blockedClientMessageId).toBeNull() + }) + + it('releases an entry the journal settles unknown, whatever its last reply said', async () => { + mocks.call.mockImplementation(async (_target, _method, params) => + pendingResultFor( + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId, + 10 + ) + ) const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ @@ -480,28 +505,13 @@ describe('useStructuredAgentSessionOutbox', () => { ) act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) const firstId = result.current.outbox[0]!.clientMessageId - rerender({ submissions: [unknownResultFor(firstId, 10).value.submission] }) - - act(() => expect(result.current.send('second')).toBe(true)) - expect(result.current.outbox).toHaveLength(2) - - act(() => result.current.retry(firstId)) - await waitFor(() => - expect(result.current.outbox.some((entry) => entry.clientMessageId === firstId)).toBe(false) - ) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 200)) + rerender({ + submissions: [{ ...unknownResultFor(firstId, 10).value.submission, recovered: true }] }) - - const sent = mocks.call.mock.calls.map( - (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text - ) - expect(sent).toContain('second') - expect(result.current.error).toBe( - 'Message delivery is unconfirmed and Orca will not send it again' - ) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(result.current.error).toBeNull() }) it('retains a send operation after a pending-admission refusal', async () => { @@ -600,91 +610,16 @@ describe('useStructuredAgentSessionOutbox', () => { }) }) - it('retries an unknown head and advances a queued tail', async () => { - // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test - vi.mocked(globalThis.crypto.randomUUID) - .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') - mocks.call - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return unknownResultFor(clientMessageId, 10) - }) - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return acceptedResultFor(clientMessageId, 11) - }) - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return acceptedResultFor(clientMessageId, 12) - }) - const { result, rerender } = renderHook( - ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions - }), - { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } - ) - - act(() => { - expect(result.current.send('first')).toBe(true) - }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) - const firstId = result.current.outbox[0]!.clientMessageId - rerender({ - submissions: [ - { - clientMessageId: firstId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'unknown', - providerItemId: null, - reason: 'socket closed', - submittedAt: 10, - resolvedAt: 10 - } - ] - }) - act(() => { - expect(result.current.send('second')).toBe(true) + it("drops an older host's not-delivered head so the queued tail advances without a Retry", async () => { + mocks.call.mockImplementation(async (_target, _method, params) => { + const request = params as { + envelope: { clientOperationId: string } + body: { blocks: { text?: string }[] } + } + return request.body.blocks[0]?.text === 'second' + ? acceptedResultFor(request.envelope.clientOperationId, 11) + : pendingResultFor(request.envelope.clientOperationId, 10) }) - expect(result.current.outbox).toHaveLength(2) - - act(() => result.current.retry(firstId)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - const retryParams = mocks.call.mock.calls[1]?.[2] as { retryUnknown?: true } | undefined - expect(retryParams?.retryUnknown).toBeUndefined() - }) - - it('rotates a history-rejected unknown head so the queued tail can advance', async () => { - // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test - vi.mocked(globalThis.crypto.randomUUID) - .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') - .mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc') - mocks.call - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return unknownResultFor(clientMessageId, 10) - }) - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return acceptedResultFor(clientMessageId, 11) - }) - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return acceptedResultFor(clientMessageId, 12) - }) const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ @@ -697,9 +632,8 @@ describe('useStructuredAgentSessionOutbox', () => { ) act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) const firstId = result.current.outbox[0]!.clientMessageId - act(() => expect(result.current.send('second')).toBe(true)) rerender({ submissions: [ { @@ -714,14 +648,11 @@ describe('useStructuredAgentSessionOutbox', () => { } ] }) - - act(() => result.current.retry(firstId)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - const retryParams = mocks.call.mock.calls[1]?.[2] as - | { envelope: { clientOperationId: string } } - | undefined - expect(retryParams?.envelope.clientOperationId).not.toBe(firstId) + act(() => expect(result.current.send('second')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(result.current.error).toBeNull() }) it('rotates the id after a refused write and delivers the message exactly once', async () => { @@ -814,7 +745,7 @@ describe('useStructuredAgentSessionOutbox', () => { ) act(() => expect(result.current.send('first session')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) rerender({ sessionId: 'session-2' }) expect(result.current.outbox).toHaveLength(0) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 254c936cf63b..66e498df3a78 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -97,10 +97,7 @@ export function useStructuredAgentSessionOutbox(args: { const current = outboxRef.current const hostOwns = new Set( submissions - .filter( - (submission) => - submission.dispatchState === 'pending' || submission.dispatchState === 'accepted' - ) + .filter((submission) => submission.dispatchState !== 'rejected') .map((submission) => submission.clientMessageId) ) const next = reconcileStructuredAgentSessionOutbox(current, submissions) @@ -221,10 +218,8 @@ export function useStructuredAgentSessionOutbox(args: { // A transport-side unknown may never have reached the host, and nothing else // moves it out of `unconfirmed`, so one wedges the whole FIFO queue. Re-issuing - // the same envelope without `retryUnknown` is idempotent: the operation ledger - // replays a recorded outcome, or the host performs a genuine first delivery. - // A host-confirmed unknown stays parked until the user explicitly asks Retry - // to replay the same operation. + // the same envelope is idempotent: the operation ledger replays a recorded + // outcome, or the host performs a genuine first delivery. // The first `unconfirmed` entry is the one holding the queue, at whatever index it sits: an // unconfirmed tail behind an admitted head would otherwise wedge until the head cleared, // which is the wedge this probe exists to prevent. @@ -292,9 +287,8 @@ export function useStructuredAgentSessionOutbox(args: { (candidate) => candidate.clientMessageId === clientMessageId ) const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) - // A provider-history reconciliation can settle an earlier unknown as - // rejected before the user presses Retry. Reusing that operation id only - // replays the settled rejection forever, so rotate the id for a safe resend. + // Reusing a rejected send's operation id only replays the settled rejection + // forever, so rotate the id for a safe resend. if ( current && (submission?.dispatchState === 'rejected' || @@ -320,12 +314,7 @@ export function useStructuredAgentSessionOutbox(args: { setOutbox(rotated) return } - const retryAfterUnknownSubmittedAt = - submission?.dispatchState === 'unknown' - ? submission.submittedAt - : current?.state === 'unconfirmed' - ? -1 - : null + const retryAfterUnknownSubmittedAt = current?.state === 'unconfirmed' ? -1 : null const next = outboxRef.current.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1e6ca9ecd659..8a1e3c2751bd 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2750,7 +2750,7 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", - "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "settleEarlierMessage": "Wait for your earlier message to go through.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", "tryAgain": "Try again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 033e0ecbb233..8b64a75e1769 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17514,7 +17514,7 @@ "backgroundTasksRunning": "Background tasks are still running.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", - "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "settleEarlierMessage": "Wait for your earlier message to go through.", "optionRejected": "The agent didn't accept this setting.", "goalsUnsupported": "This agent doesn't support goals.", "agentRefused": "The agent turned this down.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5ce318699948..c88d05fce2f5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14963,7 +14963,7 @@ "backgroundTasksRunning": "Todavía hay tareas en segundo plano en ejecución.", "waitForBackgroundTasks": "Espere a que terminen las tareas en segundo plano.", "messagesUnsettled": "Un mensaje que envió antes todavía no se ha confirmado.", - "settleEarlierMessage": "Espere a que se entregue su mensaje anterior o vuelva a intentarlo.", + "settleEarlierMessage": "Espere a que se entregue su mensaje anterior.", "optionRejected": "El agente no aceptó este ajuste.", "goalsUnsupported": "Este agente no admite objetivos.", "agentRefused": "El agente lo rechazó.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index b8b96da93220..85245caf5dc1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17446,7 +17446,7 @@ "backgroundTasksRunning": "Des tâches en arrière-plan sont encore en cours.", "waitForBackgroundTasks": "Attendez la fin des tâches en arrière-plan.", "messagesUnsettled": "Un message que vous avez envoyé plus tôt n'a pas encore été confirmé.", - "settleEarlierMessage": "Attendez que votre message précédent soit transmis, ou renvoyez-le.", + "settleEarlierMessage": "Attendez que votre message précédent soit transmis.", "optionRejected": "L'agent n'a pas accepté ce réglage.", "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", "agentRefused": "L'agent l'a refusé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c7041073d55b..7738ca388658 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17382,7 +17382,7 @@ "backgroundTasksRunning": "バックグラウンドタスクがまだ実行中です。", "waitForBackgroundTasks": "バックグラウンドタスクが終わるまでお待ちください。", "messagesUnsettled": "以前に送信したメッセージがまだ確認されていません。", - "settleEarlierMessage": "以前のメッセージが届くまで待つか、再試行してください。", + "settleEarlierMessage": "以前のメッセージが届くまで待ってください。", "optionRejected": "Agent はこの設定を受け入れませんでした。", "goalsUnsupported": "この Agent は目標に対応していません。", "agentRefused": "Agent がこれを拒否しました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dfb560ec1ecf..2a7f43ec38e9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17382,7 +17382,7 @@ "backgroundTasksRunning": "백그라운드 작업이 아직 실행 중입니다.", "waitForBackgroundTasks": "백그라운드 작업이 끝날 때까지 기다리세요.", "messagesUnsettled": "앞서 보낸 메시지가 아직 확인되지 않았습니다.", - "settleEarlierMessage": "앞서 보낸 메시지가 전달될 때까지 기다리거나 다시 시도하세요.", + "settleEarlierMessage": "앞서 보낸 메시지가 전달될 때까지 기다리세요.", "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", "agentRefused": "에이전트가 이를 거부했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6433b62a2328..b3929d252cdd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17347,7 +17347,7 @@ "backgroundTasksRunning": "后台任务仍在运行。", "waitForBackgroundTasks": "请等待后台任务完成。", "messagesUnsettled": "您之前发送的一条消息尚未确认。", - "settleEarlierMessage": "请等待之前的消息送达,或重试该消息。", + "settleEarlierMessage": "请等待之前的消息送达。", "optionRejected": "智能体未接受此设置。", "goalsUnsupported": "此智能体不支持目标。", "agentRefused": "智能体拒绝了此请求。", diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 84c8fa6e1910..491ec2b6f3a3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -10,6 +10,7 @@ import { } from '../../../shared/structured-agent-session-outbox' import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionSubmissionDrawsAsSent } from '../../../shared/structured-agent-session-dispatch-rejection' import { mutateStructuredAgentSessionLaunchPrompt, type StructuredAgentSessionLaunchPromptMutation @@ -119,21 +120,18 @@ async function dispatchStructuredLaunchPrompt( ) return false } - const dispatchState = result.value.submission.dispatchState + const { submission } = result.value + // An `unknown` is drawn as sent: the journal shows it, and a new message continues the chat. + const sent = structuredAgentSessionSubmissionDrawsAsSent(submission) mutateEntry(entry, (current) => - dispatchState === 'accepted' + sent ? null : { ...current, - state: - dispatchState === 'unknown' - ? 'unconfirmed' - : dispatchState === 'pending' - ? 'dispatching' - : 'queued' + state: submission.dispatchState === 'pending' ? 'dispatching' : 'queued' } ) - return dispatchState === 'accepted' || dispatchState === 'pending' + return sent || submission.dispatchState === 'pending' } catch { mutateEntry(entry, (current) => ({ ...current, state: 'unconfirmed' })) return false diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index d0c36e27a407..16ba82b687d6 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -43,7 +43,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { backgroundTasksRunning: 'Background tasks are still running.', waitForBackgroundTasks: 'Wait for the background tasks to finish.', messagesUnsettled: "A message you sent earlier hasn't been confirmed yet.", - settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + settleEarlierMessage: 'Wait for your earlier message to go through.', optionRejected: "The agent didn't accept this setting.", goalsUnsupported: "This agent doesn't support goals.", agentRefused: 'The agent turned this down.', diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 3b9ff4fb025e..5d6048849251 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -37,8 +37,8 @@ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' * Read only, like the one above. */ export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery' -/** Restart reconciliation found the send absent from a provider history it could trust. Rows - * written before rows carried a fact hold it as their reason; released clients printed it. */ +/** Legacy marker: an older host's restart verdict, inferred from the send's absence in the + * provider's transcript. Read only; no host writes it now. */ export const DISPATCH_REJECTED_NOT_DELIVERED = 'not_delivered' /** True for the internal transport marker, false for a provider's own words. Legacy-reason half @@ -166,6 +166,23 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } +/** + * A send drawn as an ordinary sent message: the provider took it, or Orca cannot tell — a crash, + * a dead agent or an unproven write left it in doubt, and the next message is how the chat + * continues. An older host's `notDelivered` was only ever that same doubt, inferred from the + * transcript, so it is drawn the same way rather than dropped from history. + */ +export function structuredAgentSessionSubmissionDrawsAsSent( + submission: Pick & { rejection?: unknown } +): boolean { + return ( + submission.dispatchState === 'accepted' || + submission.dispatchState === 'unknown' || + (submission.dispatchState === 'rejected' && + classifyDispatchRejection(submission).kind === 'notDelivered') + ) +} + /** A submission that says Orca never handed it over, in any dispatch state: journals written * before this state moved hold it as `unknown` carrying the marker. */ export function isWriteFailureSubmission( diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 334cde7cfe8c..ac40d1d0f49d 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -7,6 +7,7 @@ import { type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' +import { structuredAgentSessionSubmissionDrawsAsSent } from './structured-agent-session-dispatch-rejection' export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], @@ -16,9 +17,14 @@ export function projectStructuredAgentSessionMessages( ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. + // An older host's inferred "not delivered" is drawn as the sent message it may well have been. const rejected = new Set( submissions - .filter((submission) => submission.dispatchState === 'rejected') + .filter( + (submission) => + submission.dispatchState === 'rejected' && + !structuredAgentSessionSubmissionDrawsAsSent(submission) + ) .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const visibleItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 4211bbb3961d..9c9b5037c84a 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -11,9 +11,14 @@ import { } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + structuredAgentSessionSubmissionDrawsAsSent +} from './structured-agent-session-dispatch-rejection' -/** `rejected`: the host settled the send as not delivered. The drain never sends it again on its +/** `unconfirmed`: this client cannot tell whether the host recorded the send (a lost reply, a + * remount mid-send); a same-id resend asks it, and nothing may overtake it meanwhile. + * `rejected`: the host settled the send as not delivered. The drain never sends it again on its * own and nothing queues behind it; only the user's Retry does. */ export type StructuredAgentSessionOutboxState = | 'queued' @@ -29,6 +34,8 @@ export type StructuredAgentSessionOutboxEntry = { state: StructuredAgentSessionOutboxState queuedAt: number lastAttemptAt: number | null + /** Non-null once the user retried an `unconfirmed` entry: it keeps its id and is not probed + * again. The name is stored; older builds kept a doubted submission's time here. */ retryAfterUnknownSubmittedAt: number | null source?: 'launch' /** Why the last attempt did not go through. Lives on the message so it goes when the message @@ -189,7 +196,8 @@ export function reconcileStructuredAgentSessionOutbox( const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) return entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - if (submission?.dispatchState === 'accepted') { + // The journal draws it as sent, so the entry has nothing left to hold or retry. + if (submission && structuredAgentSessionSubmissionDrawsAsSent(submission)) { return [] } if ( @@ -216,13 +224,6 @@ export function reconcileStructuredAgentSessionOutbox( } ] } - if ( - submission?.dispatchState === 'unknown' && - entry.retryAfterUnknownSubmittedAt !== -1 && - entry.retryAfterUnknownSubmittedAt !== submission.submittedAt - ) { - return [{ ...entry, state: 'unconfirmed' as const }] - } return [entry] }) } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 08fad831af2d..0bd2ca557cad 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -101,9 +101,22 @@ describe('what a rejection shows the user', () => { expect(notice(reason)).toBe(reason) }) - it('never puts the legacy not_delivered marker on screen', () => { - // Released clients printed it as it was; it is a marker, not a sentence. - expect(notice('not_delivered')).toBe('Your message was not sent.') + it("draws an older host's not_delivered verdict as a sent message, with nothing to retry", () => { + // That verdict was only restart doubt inferred from the transcript: no marker, no Retry. + for (const rejection of [undefined, { kind: 'notDelivered' as const }]) { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith('not_delivered', rejection ? { rejection } : {}), + createOperationId: () => 'unused' + }) + expect(disposition).toMatchObject({ + entries: [], + error: null, + retryWithFreshClientMessageId: null + }) + } }) it.each([ @@ -301,7 +314,7 @@ describe('ambiguous operation refusals', () => { expect(disposition.blockedClientMessageId).toBe(entry.clientMessageId) }) - it('parks a recovered missing submission without polling forever', () => { + it('draws a recovered missing submission as sent, neither parked nor polled', () => { const result = rejectedWith(null) if (!result.ok) { throw new Error('expected a send result') @@ -321,12 +334,33 @@ describe('ambiguous operation refusals', () => { createOperationId: () => 'unused' }) - expect(disposition.entries).toMatchObject([ - { - clientMessageId: entry.clientMessageId, - state: 'unconfirmed', - retryAfterUnknownSubmittedAt: -1 - } - ]) + expect(disposition).toMatchObject({ entries: [], error: null, blockedClientMessageId: null }) + }) + + it('draws a live unknown as sent, and holds nothing behind it', () => { + const tail = { ...entry, clientMessageId: 'client-2', queuedAt: 2 } + const result = rejectedWith(null) + if (!result.ok) { + throw new Error('expected a send result') + } + result.value.submission = { + ...result.value.submission, + dispatchState: 'unknown', + reason: 'provider_write_outcome_unknown: socket closed' + } + + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry, tail], + entry, + blockedClientMessageId: null, + result, + createOperationId: () => 'unused' + }) + + expect(disposition).toMatchObject({ + entries: [tail], + error: null, + blockedClientMessageId: null + }) }) }) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 7ffce942b6e6..892f00866fff 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -16,7 +16,10 @@ import { } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' import { agentSessionRefusalFailure } from './agent-session-write-failure' -import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + structuredAgentSessionSubmissionDrawsAsSent +} from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -68,39 +71,13 @@ function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEnt ) } -/** - * The user force-retried a host-confirmed `unknown` and got the same submission - * back. That is now the only answer such a retry can get: `unknown` means the - * host cannot tell whether the provider has the message, and no reason it - * records ever makes a second delivery safe. Parking the entry would offer a - * Retry that does nothing in front of a queue nothing can drain, so it leaves - * the outbox. Nothing is lost from the conversation: the durable submission row - * already renders the message. - * - * A `rejected` submission takes the other path — the message provably did not - * happen, so Retry rotates the id and sends it as a genuinely new message. - */ -function refusedRedelivery( - entry: StructuredAgentSessionOutboxEntry, - submission: AgentSessionSendResult['submission'] -): boolean { - return ( - entry.retryAfterUnknownSubmittedAt !== null && - submission.dispatchState === 'unknown' && - submission.submittedAt === entry.retryAfterUnknownSubmittedAt - ) -} - /** Whether the journal already answers a send still in flight, so its own reply adds nothing: the * host holds the message, or rejected it — a later `pending` reply must not undo that. */ export function journalAnswersInFlightSend( submissions: readonly AgentJournalSubmission[], clientMessageId: string | null ): boolean { - return submissions.some( - (submission) => - submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown' - ) + return submissions.some((submission) => submission.clientMessageId === clientMessageId) } /** @@ -192,15 +169,9 @@ export function disposeStructuredAgentSessionSendResult( } } const submission = result.value.submission - if (refusedRedelivery(input.entry, submission)) { - return { - entries: dropEntry(input), - error: 'Message delivery is unconfirmed and Orca will not send it again', - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null - } - } - if (submission.dispatchState === 'accepted') { + // Sent, or in doubt after a crash or a dead agent: either way the journal draws the message, and + // sending a new one is how the chat continues. + if (structuredAgentSessionSubmissionDrawsAsSent(submission)) { return { entries: dropEntry(input), error: null, @@ -233,29 +204,14 @@ export function disposeStructuredAgentSessionSendResult( retryWithFreshClientMessageId: input.entry.clientMessageId } } - if (submission.dispatchState === 'unknown' && submission.recovered) { - return { - entries: input.entries.map((candidate) => - candidate.clientMessageId === input.entry.clientMessageId - ? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 } - : candidate - ), - error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null - } - } // `pending` is the host saying the message was written and is awaiting the // provider's acknowledgement, which cannot arrive until the turn ahead of it // ends. That is not doubt, and keeping order is no longer the reason to hold // the entry -- the host fixed the order when it wrote the row. It stays - // because a `pending` can still settle `rejected` or `unknown`, and only the - // entry carries the retry state that answer needs. + // because a `pending` can still settle `rejected`, and only the entry carries + // the retry state that answer needs. return { - entries: replaceEntryState( - input, - submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' - ), + entries: replaceEntryState(input, 'dispatching'), error: null, blockedClientMessageId: input.blockedClientMessageId, retryWithFreshClientMessageId: null From 808334387477136a5cced59b988eab4afeb16237 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:21 -0700 Subject: [PATCH 13/49] test(native-chat): read the new cases' calls without casts --- ...tructured-agent-session-stale-turn-verdict.test.ts | 11 +++++++---- ...tiveChatStructuredSession.transport-probe.test.tsx | 4 +--- .../NativeChatStructuredSessionDelivery.test.tsx | 4 +--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 17faa31c49e9..fad4546a0589 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -182,7 +182,12 @@ describe('running turn lifecycle revisions', () => { describe('stale session state on a cold acquire', () => { function journalWith(items: AgentJournalRenderItem[]) { - const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) + const appendLifecycleBatch = vi.fn( + async (_batch: { mutations: readonly { kind: string; body?: { kind: string } }[] }) => ({ + epoch: 'epoch-1', + sequence: 9 + }) + ) const journal = { snapshot: () => ({ items }), cursor: () => ({ epoch: 'epoch-1', sequence: 8 }), @@ -341,9 +346,7 @@ describe('stale session state on a cold acquire', () => { describe('the crash boundary writes at most one row', () => { const boundaryRows = (batch: ReturnType['appendLifecycleBatch']) => batch.mock.calls.flatMap(([input]) => - (input as { mutations: { kind: string; body?: { kind: string } }[] }).mutations.filter( - (mutation) => mutation.body?.kind === 'status' - ) + input.mutations.filter((mutation) => mutation.body?.kind === 'status') ) it('says the session did not survive when it left a send in doubt, with no Retry', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx index d60248ba47ed..11f7fa1abd4d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx @@ -176,9 +176,7 @@ describe('NativeChatStructuredSession transport-unconfirmed sends', () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - const texts = mocks.call.mock.calls.map( - (call) => (call[2] as { body?: { blocks?: { text?: string }[] } }).body?.blocks?.[0]?.text - ) + const texts = mocks.call.mock.calls.map((call) => call[2]?.body?.blocks?.[0]?.text) expect(texts).toEqual(['first', 'second']) }, 20000) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index bac58cc79205..f82e7f8c238e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -430,9 +430,7 @@ describe('NativeChatStructuredSession delivery', () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - const texts = mocks.call.mock.calls.map( - (call) => (call[2] as { body?: { blocks?: { text?: string }[] } }).body?.blocks?.[0]?.text - ) + const texts = mocks.call.mock.calls.map((call) => call[2]?.body?.blocks?.[0]?.text) expect(texts).toEqual(['first', 'second']) }, 20000) From ddd9c155ca9d0fed7c9198de7def4274fb0bd8d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:53:19 -0700 Subject: [PATCH 14/49] test(native-chat): name the drawn-as-sent projection cases plainly --- .../structured-agent-session-message-projection.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 2cd57ebfad8b..815259764da8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -44,9 +44,9 @@ describe('structured agent session message projection', () => { }) it.each([ - ['in doubt after a restart', { dispatchState: 'unknown' as const, recovered: true as const }], + ['left in doubt by a restart', { dispatchState: 'unknown' as const, recovered: true as const }], [ - "an older host's not-delivered verdict", + "rejected by an older host's not-delivered verdict", { dispatchState: 'rejected' as const, reason: 'not_delivered' } ] ])('draws a send %s as an ordinary sent message, with no outbox entry left', (_label, state) => { From 96134dcc640d0e8f4ac2339ba5329e0100921cb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:03:50 -0700 Subject: [PATCH 15/49] test(native-chat): read the sent requests by shape, not through the never-typed mock --- .../NativeChatStructuredSession.transport-probe.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx index 11f7fa1abd4d..a9ba32be86b3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx @@ -176,8 +176,11 @@ describe('NativeChatStructuredSession transport-unconfirmed sends', () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - const texts = mocks.call.mock.calls.map((call) => call[2]?.body?.blocks?.[0]?.text) - expect(texts).toEqual(['first', 'second']) + // An array matches only at equal length, so this also pins that nothing was re-sent. + expect(mocks.call.mock.calls.map((call) => call[2])).toMatchObject([ + { body: { blocks: [{ text: 'first' }] } }, + { body: { blocks: [{ text: 'second' }] } } + ]) }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { From 0140e106137e14588ac2962ff22de88181edff72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:59 -0700 Subject: [PATCH 16/49] fix(native-chat): keep a live unknown send until it settles, so a later rejection keeps its text A live unknown can still turn rejected (an agent that never proves its start rejects it). Dropping the outbox entry on it left that rejection with nothing to draw: the message vanished with no Retry. Only a recovered unknown is settled as sent now; a live one is held like a pending, which is no barrier. --- .../mobile-structured-send-delivery.ts | 4 +- .../structured-agent-session-adapter.ts | 5 ++- .../structured-agent-session-attach-flow.ts | 2 +- ...ured-agent-session-late-settlement.test.ts | 3 +- ...agent-session-turn-completion-feed.test.ts | 2 +- ...tructured-agent-session-outbox-dispatch.ts | 6 +-- ...e-structured-agent-session-outbox.test.tsx | 40 ++++++++++++++----- .../structured-agent-session-launch-prompt.ts | 16 +++----- ...ctured-agent-session-dispatch-rejection.ts | 17 ++++---- ...ctured-agent-session-message-projection.ts | 4 +- src/shared/structured-agent-session-outbox.ts | 9 +++-- ...red-agent-session-send-disposition.test.ts | 5 ++- ...ructured-agent-session-send-disposition.ts | 10 ++--- 13 files changed, 71 insertions(+), 52 deletions(-) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index a73b5e96fb80..28b31fb92761 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -25,7 +25,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' -import { structuredAgentSessionSubmissionDrawsAsSent } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettledAsSent } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -68,7 +68,7 @@ export function mobileStructuredSendDelivery( } if ( submission.dispatchState === 'rejected' && - !structuredAgentSessionSubmissionDrawsAsSent(submission) + !structuredAgentSessionSubmissionSettledAsSent(submission) ) { return { outcome: 'rejected', 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 c27b7a09e9b1..565c2e9827db 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 @@ -7,8 +7,9 @@ import type { // Phase 2 implements this over the Codex app-server and the Claude Agent SDK; // nothing here starts, resumes, or talks to a process. The wire owns the // journal and the lease, so an adapter only has to answer "did the provider -// take this?" — and it answers `unknown` rather than guessing, because the -// journal renders that as delivery unconfirmed instead of as failure. +// take this?" — and it answers `unknown` rather than guessing: an `unknown` is +// never re-sent, where a guess could deliver it twice or report a failure that +// did not happen. import type { AgentJournalItemIdentity, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c6fdc3af358b..7eb2a7abf083 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -234,7 +234,7 @@ export async function performAttach( sessionId, fence, page: readAgentSessionHydrationPage(attached.journal, fence), - // No host decides delivery after a restart any more; kept for older clients' parsers. + // No host decides delivery after a restart any more; kept because the wire type requires it. unconfirmedClientMessageIds: [], ...(tabId ? { tabId } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 2194b992fb0e..f28ec0d4b9dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -224,8 +224,7 @@ describe('settling a send the provider proves it received after the ack window', expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) - // The point of the fix: the client stops rendering Retry, and Retry is what - // was delivering the message to the agent a second time. + // The late echo settles it; nothing sends the message a second time. expect(dispatch).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index 93d4a54cdc92..7fb59543d3b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -433,7 +433,7 @@ describe('a request the agent or its start refused', () => { expect(h.outcomes()).toEqual([['t1', 'success']]) }) - it('never notifies a crash-stranded send that restart reconciliation finds undelivered', () => { + it('never notifies a crash-stranded send an older host settled as not delivered', () => { const h = afterSuccessfulTurn() const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3)] const accepted = sent('m1', { dispatchState: 'accepted' }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index c5de608b124b..1d28e185b34e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -15,7 +15,7 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionSubmissionDrawsAsSent } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettledAsSent } from '../../../../shared/structured-agent-session-dispatch-rejection' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -120,8 +120,8 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { }) ) return result.ok - ? structuredAgentSessionSubmissionDrawsAsSent(result.value.submission) || - result.value.submission.dispatchState === 'pending' + ? structuredAgentSessionSubmissionSettledAsSent(result.value.submission) || + result.value.submission.dispatchState !== 'rejected' : false } catch (caught) { if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 0d1dc970e138..bcd7511a38fe 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -451,9 +451,9 @@ describe('useStructuredAgentSessionOutbox', () => { expect(mocks.call).toHaveBeenCalledOnce() }) - it('draws a host unknown as sent and sends the queued message behind it, with no Retry', async () => { - // Doubt the host recorded is never a barrier: the entry leaves, nothing says it is - // unconfirmed, and the next message goes out on its own. + it('holds a live host unknown like a pending: the next message goes out, with no Retry', async () => { + // Doubt the host recorded is never a barrier and never asks for a Retry. The entry stays, + // because a live `unknown` can still settle `rejected`, and then it keeps the text for Retry. mocks.call.mockImplementation(async (_target, _method, params) => { const request = params as { envelope: { clientOperationId: string } @@ -463,27 +463,45 @@ describe('useStructuredAgentSessionOutbox', () => { ? acceptedResultFor(request.envelope.clientOperationId, 11) : unknownResultFor(request.envelope.clientOperationId, 10) }) - const { result } = renderHook(() => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions: [] - }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } ) act(() => expect(result.current.send('first')).toBe(true)) await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) act(() => expect(result.current.send('second')).toBe(true)) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) const sent = mocks.call.mock.calls.map( (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text ) expect(sent).toEqual(['first', 'second']) + expect(result.current.outbox[0]).toMatchObject({ state: 'dispatching' }) expect(result.current.error).toBeNull() expect(result.current.blockedClientMessageId).toBeNull() + + // The agent then fails to start, which proves the send was never written: the text stays. + const firstId = result.current.outbox[0]!.clientMessageId + rerender({ + submissions: [ + { + ...unknownResultFor(firstId, 10).value.submission, + dispatchState: 'rejected', + reason: "Claude couldn't start.", + rejection: { kind: 'providerStartFailed' } + } + ] + }) + await waitFor(() => expect(result.current.outbox[0]).toMatchObject({ state: 'rejected' })) + expect(result.current.outbox[0]?.body.blocks[0]).toMatchObject({ text: 'first' }) }) it('releases an entry the journal settles unknown, whatever its last reply said', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 491ec2b6f3a3..37b417562c1a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -10,7 +10,7 @@ import { } from '../../../shared/structured-agent-session-outbox' import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' -import { structuredAgentSessionSubmissionDrawsAsSent } from '../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettledAsSent } from '../../../shared/structured-agent-session-dispatch-rejection' import { mutateStructuredAgentSessionLaunchPrompt, type StructuredAgentSessionLaunchPromptMutation @@ -121,17 +121,13 @@ async function dispatchStructuredLaunchPrompt( return false } const { submission } = result.value - // An `unknown` is drawn as sent: the journal shows it, and a new message continues the chat. - const sent = structuredAgentSessionSubmissionDrawsAsSent(submission) + // A recovered `unknown` is settled as sent; a live one is held like a `pending`. + const sent = structuredAgentSessionSubmissionSettledAsSent(submission) + const held = submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' mutateEntry(entry, (current) => - sent - ? null - : { - ...current, - state: submission.dispatchState === 'pending' ? 'dispatching' : 'queued' - } + sent ? null : { ...current, state: held ? 'dispatching' : 'queued' } ) - return sent || submission.dispatchState === 'pending' + return sent || held } catch { mutateEntry(entry, (current) => ({ ...current, state: 'unconfirmed' })) return false diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 5d6048849251..fbc94c15a8db 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -167,17 +167,20 @@ export function classifyDispatchRejection( } /** - * A send drawn as an ordinary sent message: the provider took it, or Orca cannot tell — a crash, - * a dead agent or an unproven write left it in doubt, and the next message is how the chat - * continues. An older host's `notDelivered` was only ever that same doubt, inferred from the - * transcript, so it is drawn the same way rather than dropped from history. + * A send settled as sent, so a client has nothing left to hold or retry: the provider took it, or + * a crash or a dead agent left it in doubt for good and the next message is how the chat + * continues. A live `unknown` is not settled — it can still turn `rejected` — so a client keeps it + * like a `pending`. An older host's `notDelivered` was only ever that same doubt, inferred from + * the transcript, so it is drawn as sent rather than dropped from history. */ -export function structuredAgentSessionSubmissionDrawsAsSent( - submission: Pick & { rejection?: unknown } +export function structuredAgentSessionSubmissionSettledAsSent( + submission: Pick & { + rejection?: unknown + } ): boolean { return ( submission.dispatchState === 'accepted' || - submission.dispatchState === 'unknown' || + (submission.dispatchState === 'unknown' && submission.recovered === true) || (submission.dispatchState === 'rejected' && classifyDispatchRejection(submission).kind === 'notDelivered') ) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index ac40d1d0f49d..fba11a91a736 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -7,7 +7,7 @@ import { type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' -import { structuredAgentSessionSubmissionDrawsAsSent } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettledAsSent } from './structured-agent-session-dispatch-rejection' export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], @@ -23,7 +23,7 @@ export function projectStructuredAgentSessionMessages( .filter( (submission) => submission.dispatchState === 'rejected' && - !structuredAgentSessionSubmissionDrawsAsSent(submission) + !structuredAgentSessionSubmissionSettledAsSent(submission) ) .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 9c9b5037c84a..08c64fac8729 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -13,7 +13,7 @@ import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection, - structuredAgentSessionSubmissionDrawsAsSent + structuredAgentSessionSubmissionSettledAsSent } from './structured-agent-session-dispatch-rejection' /** `unconfirmed`: this client cannot tell whether the host recorded the send (a lost reply, a @@ -196,8 +196,8 @@ export function reconcileStructuredAgentSessionOutbox( const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) return entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - // The journal draws it as sent, so the entry has nothing left to hold or retry. - if (submission && structuredAgentSessionSubmissionDrawsAsSent(submission)) { + // Settled as sent: the journal draws it, and the entry has nothing left to hold or retry. + if (submission && structuredAgentSessionSubmissionSettledAsSent(submission)) { return [] } if ( @@ -206,7 +206,8 @@ export function reconcileStructuredAgentSessionOutbox( ) { return [] } - if (submission?.dispatchState === 'pending') { + // The host holds it and may still reject it: kept, not a barrier, for a Retry that answer needs. + if (submission?.dispatchState === 'pending' || submission?.dispatchState === 'unknown') { return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } // Accepted, then not delivered — the agent never started, or its start was refused. The text diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 0bd2ca557cad..af388e5f5bd8 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -337,7 +337,7 @@ describe('ambiguous operation refusals', () => { expect(disposition).toMatchObject({ entries: [], error: null, blockedClientMessageId: null }) }) - it('draws a live unknown as sent, and holds nothing behind it', () => { + it('holds a live unknown like a pending, with nothing behind it held', () => { const tail = { ...entry, clientMessageId: 'client-2', queuedAt: 2 } const result = rejectedWith(null) if (!result.ok) { @@ -357,8 +357,9 @@ describe('ambiguous operation refusals', () => { createOperationId: () => 'unused' }) + // It can still settle `rejected`; only the entry keeps the text a Retry would need. expect(disposition).toMatchObject({ - entries: [tail], + entries: [{ clientMessageId: entry.clientMessageId, state: 'dispatching' }, tail], error: null, blockedClientMessageId: null }) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 892f00866fff..fcd032e00daf 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -18,7 +18,7 @@ import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-c import { agentSessionRefusalFailure } from './agent-session-write-failure' import { classifyDispatchRejection, - structuredAgentSessionSubmissionDrawsAsSent + structuredAgentSessionSubmissionSettledAsSent } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, @@ -169,9 +169,9 @@ export function disposeStructuredAgentSessionSendResult( } } const submission = result.value.submission - // Sent, or in doubt after a crash or a dead agent: either way the journal draws the message, and + // Sent, or left in doubt for good by a crash or a dead agent: the journal draws the message, and // sending a new one is how the chat continues. - if (structuredAgentSessionSubmissionDrawsAsSent(submission)) { + if (structuredAgentSessionSubmissionSettledAsSent(submission)) { return { entries: dropEntry(input), error: null, @@ -208,8 +208,8 @@ export function disposeStructuredAgentSessionSendResult( // provider's acknowledgement, which cannot arrive until the turn ahead of it // ends. That is not doubt, and keeping order is no longer the reason to hold // the entry -- the host fixed the order when it wrote the row. It stays - // because a `pending` can still settle `rejected`, and only the entry carries - // the retry state that answer needs. + // because a `pending`, or a live `unknown`, can still settle `rejected`, and + // only the entry carries the retry state that answer needs. return { entries: replaceEntryState(input, 'dispatching'), error: null, From ef56226497da1030446f235988a9376f69451973 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:23:36 -0700 Subject: [PATCH 17/49] docs(native-chat): say an older host's not-delivered send carries no notice --- pnpm-lock.yaml | 19 +++++++++++++++++++ ...ctured-agent-session-dispatch-rejection.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d82edc51bfc..8ecb8854a893 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.0.0 + version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -56,6 +59,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.0.0': + resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -87,6 +95,17 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true + '@pnpm/exe@12.0.0': + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index fbc94c15a8db..b6df6527f0f4 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -171,7 +171,8 @@ export function classifyDispatchRejection( * a crash or a dead agent left it in doubt for good and the next message is how the chat * continues. A live `unknown` is not settled — it can still turn `rejected` — so a client keeps it * like a `pending`. An older host's `notDelivered` was only ever that same doubt, inferred from - * the transcript, so it is drawn as sent rather than dropped from history. + * the transcript, so it is drawn as sent rather than dropped from history. Such a host wrote no + * session row, so that message carries no notice; sending again is how the chat continues. */ export function structuredAgentSessionSubmissionSettledAsSent( submission: Pick & { From 9d62068b1051b2583c970a11076b6e50e3b2c1c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:23:47 -0700 Subject: [PATCH 18/49] chore: restore pnpm-lock.yaml --- pnpm-lock.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ecb8854a893..3d82edc51bfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ importers: .: configDependencies: {} packageManagerDependencies: - '@pnpm/exe': - specifier: 12.0.0 - version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -59,11 +56,6 @@ packages: cpu: [x64] os: [win32] - '@pnpm/exe@12.0.0': - resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} - engines: {node: '>=18.*'} - hasBin: true - pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -95,17 +87,6 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true - '@pnpm/exe@12.0.0': - optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 - pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 From d25b69b8300517019afea34c1e2fc94a7093aa1b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:35:46 -0700 Subject: [PATCH 19/49] fix(native-chat): draw an older host's restart-doubted send as sent too Hosts released before the recovered flag reached the wire publish a crash-doubted send as a bare unknown carrying the restart reason. The working indicator already read that reason as recovered, but the settled-as-sent rule did not, so a paired newer client held that send in its outbox for good: kept alive the journal subscription, re-read as unconfirmed on every remount, and could be drawn out of place once its row paged away. Both now read one predicate. --- ...uctured-agent-session-message-projection.test.ts | 4 ++++ .../structured-agent-session-dispatch-rejection.ts | 13 ++++++++++++- .../structured-agent-session-unanswered-dispatch.ts | 5 ++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 815259764da8..9535cd4f3122 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -45,6 +45,10 @@ describe('structured agent session message projection', () => { it.each([ ['left in doubt by a restart', { dispatchState: 'unknown' as const, recovered: true as const }], + [ + 'left in doubt by a restart on a host that omits the recovered flag', + { dispatchState: 'unknown' as const, reason: 'host_restarted_before_acknowledgement' } + ], [ "rejected by an older host's not-delivered verdict", { dispatchState: 'rejected' as const, reason: 'not_delivered' } diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index b6df6527f0f4..25a8ae6823b6 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -166,6 +166,17 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } +/** An `unknown` a crash or a dead agent settled for good, rather than one a live child may still + * answer. Hosts before the `recovered` flag reached the wire publish only the restart reason. */ +export function isRecoveredStructuredAgentSessionDoubt( + submission: Pick +): boolean { + return ( + submission.dispatchState === 'unknown' && + (submission.recovered === true || submission.reason === 'host_restarted_before_acknowledgement') + ) +} + /** * A send settled as sent, so a client has nothing left to hold or retry: the provider took it, or * a crash or a dead agent left it in doubt for good and the next message is how the chat @@ -181,7 +192,7 @@ export function structuredAgentSessionSubmissionSettledAsSent( ): boolean { return ( submission.dispatchState === 'accepted' || - (submission.dispatchState === 'unknown' && submission.recovered === true) || + isRecoveredStructuredAgentSessionDoubt(submission) || (submission.dispatchState === 'rejected' && classifyDispatchRejection(submission).kind === 'notDelivered') ) diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts index 2ac7aa129887..0305947dbede 100644 --- a/src/shared/structured-agent-session-unanswered-dispatch.ts +++ b/src/shared/structured-agent-session-unanswered-dispatch.ts @@ -1,5 +1,6 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' +import { isRecoveredStructuredAgentSessionDoubt } from './structured-agent-session-dispatch-rejection' /** One send the provider has neither opened a turn for nor refused; the rule is explained on * `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */ @@ -15,9 +16,7 @@ export function isUnansweredStructuredAgentSessionDispatch( (currentFence == null || submission.fence >= currentFence) && (submission.dispatchState === 'pending' || (submission.dispatchState === 'unknown' && - submission.recovered !== true && - // Older hosts publish the recovery reason but omit the optional marker. - submission.reason !== 'host_restarted_before_acknowledgement')) + !isRecoveredStructuredAgentSessionDoubt(submission))) ) } From a72f7550e13a2f7eaacb2d42218f6e708ddd5407 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:53:44 -0700 Subject: [PATCH 20/49] refactor(native-chat): decide a send's settlement in one classifier Every client-side reader re-derived "is this send still open, drawn as sent, or refused" from the raw dispatch facts, and the copies kept disagreeing. The last one, hasStructuredAgentSessionRequest, did not count a send a crash left in doubt, so a chat whose only message a crash cut off drew it as sent while the status feed published no status or prompt for it. structuredAgentSessionSubmissionSettlement now decides open / sent / refused once; every shared, renderer and mobile reader branches on it, and a boundary test fails on a new raw read of dispatchState/recovered outside it. --- .../mobile-structured-send-delivery.ts | 7 +- ...ructured-agent-session-delivery-notices.ts | 3 +- ...tructured-agent-session-outbox-dispatch.ts | 10 +- .../use-structured-agent-session-outbox.ts | 11 ++- .../structured-agent-session-launch-prompt.ts | 22 +++-- ...ctured-agent-session-dispatch-rejection.ts | 32 ------- ...tured-agent-session-latest-request.test.ts | 38 +++++++- ...structured-agent-session-latest-request.ts | 29 +++--- ...ctured-agent-session-message-projection.ts | 8 +- src/shared/structured-agent-session-outbox.ts | 59 ++++++------ ...ructured-agent-session-send-disposition.ts | 71 ++++++-------- ...ion-submission-settlement-boundary.test.ts | 96 +++++++++++++++++++ ...gent-session-submission-settlement.test.ts | 92 ++++++++++++++++++ ...red-agent-session-submission-settlement.ts | 38 ++++++++ .../structured-agent-session-turn-timing.ts | 8 +- ...tured-agent-session-unanswered-dispatch.ts | 6 +- 16 files changed, 372 insertions(+), 158 deletions(-) create mode 100644 src/shared/structured-agent-session-submission-settlement-boundary.test.ts create mode 100644 src/shared/structured-agent-session-submission-settlement.test.ts create mode 100644 src/shared/structured-agent-session-submission-settlement.ts diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 28b31fb92761..63c6cca30d41 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -25,7 +25,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' -import { structuredAgentSessionSubmissionSettledAsSent } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from '../../../src/shared/structured-agent-session-submission-settlement' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -66,10 +66,7 @@ export function mobileStructuredSendDelivery( if (!submission) { return { outcome: 'unknown', operationIdSpent: false, error: null } } - if ( - submission.dispatchState === 'rejected' && - !structuredAgentSessionSubmissionSettledAsSent(submission) - ) { + if (structuredAgentSessionSubmissionSettlement(submission) === 'refused') { return { outcome: 'rejected', operationIdSpent: true, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index ec3eb60e6875..6a7c36ee8a64 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -28,6 +28,7 @@ import { } from '../../../../shared/structured-agent-session-outbox' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionSubmissionSettlement } from '../../../../shared/structured-agent-session-submission-settlement' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' @@ -113,7 +114,7 @@ export function structuredAgentSessionDeliveryNotices( const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null const rejected = new Map( submissions - .filter((submission) => submission.dispatchState === 'rejected') + .filter((submission) => structuredAgentSessionSubmissionSettlement(submission) === 'refused') .map((submission) => [submission.clientMessageId, submission]) ) const notices = new Map() diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 1d28e185b34e..612b3aa68f6e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -15,7 +15,7 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionSubmissionSettledAsSent } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from '../../../../shared/structured-agent-session-submission-settlement' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -119,10 +119,10 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { createOperationId: args.createOperationId }) ) - return result.ok - ? structuredAgentSessionSubmissionSettledAsSent(result.value.submission) || - result.value.submission.dispatchState !== 'rejected' - : false + return ( + result.ok && + structuredAgentSessionSubmissionSettlement(result.value.submission) !== 'refused' + ) } catch (caught) { if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { return false diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 5b22bbb650d4..3756ebac50ab 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -11,6 +11,7 @@ import { journalAnswersInFlightSend, type StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionSubmissionSettlement } from '../../../../shared/structured-agent-session-submission-settlement' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' import { @@ -95,7 +96,9 @@ export function useStructuredAgentSessionOutbox(args: { const current = outboxRef.current const hostOwns = new Set( submissions - .filter((submission) => submission.dispatchState !== 'rejected') + .filter( + (submission) => structuredAgentSessionSubmissionSettlement(submission) !== 'refused' + ) .map((submission) => submission.clientMessageId) ) const next = reconcileStructuredAgentSessionOutbox(current, submissions) @@ -293,7 +296,11 @@ export function useStructuredAgentSessionOutbox(args: { // already rotated it. const recordedRejection = current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' - if (current && (recordedRejection || submission?.dispatchState === 'rejected')) { + if ( + current && + (recordedRejection || + (submission && structuredAgentSessionSubmissionSettlement(submission) === 'refused')) + ) { const rotated = outboxRef.current.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 37b417562c1a..73d61adc2f4e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -10,7 +10,7 @@ import { } from '../../../shared/structured-agent-session-outbox' import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' -import { structuredAgentSessionSubmissionSettledAsSent } from '../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from '../../../shared/structured-agent-session-submission-settlement' import { mutateStructuredAgentSessionLaunchPrompt, type StructuredAgentSessionLaunchPromptMutation @@ -120,14 +120,18 @@ async function dispatchStructuredLaunchPrompt( ) return false } - const { submission } = result.value - // A recovered `unknown` is settled as sent; a live one is held like a `pending`. - const sent = structuredAgentSessionSubmissionSettledAsSent(submission) - const held = submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' - mutateEntry(entry, (current) => - sent ? null : { ...current, state: held ? 'dispatching' : 'queued' } - ) - return sent || held + switch (structuredAgentSessionSubmissionSettlement(result.value.submission)) { + case 'sent': + mutateEntry(entry, () => null) + return true + // The host holds it and may still reject it. + case 'open': + mutateEntry(entry, (current) => ({ ...current, state: 'dispatching' })) + return true + case 'refused': + mutateEntry(entry, (current) => ({ ...current, state: 'queued' })) + return false + } } catch { mutateEntry(entry, (current) => ({ ...current, state: 'unconfirmed' })) return false diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 25a8ae6823b6..050979091c8a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -166,38 +166,6 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } -/** An `unknown` a crash or a dead agent settled for good, rather than one a live child may still - * answer. Hosts before the `recovered` flag reached the wire publish only the restart reason. */ -export function isRecoveredStructuredAgentSessionDoubt( - submission: Pick -): boolean { - return ( - submission.dispatchState === 'unknown' && - (submission.recovered === true || submission.reason === 'host_restarted_before_acknowledgement') - ) -} - -/** - * A send settled as sent, so a client has nothing left to hold or retry: the provider took it, or - * a crash or a dead agent left it in doubt for good and the next message is how the chat - * continues. A live `unknown` is not settled — it can still turn `rejected` — so a client keeps it - * like a `pending`. An older host's `notDelivered` was only ever that same doubt, inferred from - * the transcript, so it is drawn as sent rather than dropped from history. Such a host wrote no - * session row, so that message carries no notice; sending again is how the chat continues. - */ -export function structuredAgentSessionSubmissionSettledAsSent( - submission: Pick & { - rejection?: unknown - } -): boolean { - return ( - submission.dispatchState === 'accepted' || - isRecoveredStructuredAgentSessionDoubt(submission) || - (submission.dispatchState === 'rejected' && - classifyDispatchRejection(submission).kind === 'notDelivered') - ) -} - /** A submission that says Orca never handed it over, in any dispatch state: journals written * before this state moved hold it as `unknown` carrying the marker. */ export function isWriteFailureSubmission( diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 7cdb1b2d36af..8d1bfb9c3a86 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -21,6 +21,7 @@ import { latestStructuredAgentSessionRequest } from './structured-agent-session-latest-request' import { projectStructuredAgentSessionStatusSummary } from './structured-agent-session-projection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' const START_FAILURE = 'Claude is not signed in.' @@ -111,7 +112,7 @@ const ROWS: Row[] = [ listed: false }, { - name: 'a send crash recovery found the provider never received (no verdict)', + name: "an older host's not_delivered restart verdict, drawn as sent (no verdict)", items: [userEntry('m1', 1)], submissions: [ sent('m1', { @@ -121,7 +122,7 @@ const ROWS: Row[] = [ }) ], outcome: null, - listed: false + listed: true }, { name: 'a turn that succeeded, then a send crash recovery found the provider never received', @@ -142,11 +143,20 @@ const ROWS: Row[] = [ listed: true }, { - name: 'a send crash recovery left unknown', + name: 'a send a crash left in doubt, drawn as sent (no verdict)', items: [userEntry('m1', 1)], submissions: [sent('m1', { dispatchState: 'unknown', recovered: true })], outcome: null, - listed: false + listed: true + }, + { + name: 'a restart-doubted send from a host without `recovered` on the wire (no verdict)', + items: [userEntry('m1', 1)], + submissions: [ + sent('m1', { dispatchState: 'unknown', reason: 'host_restarted_before_acknowledgement' }) + ], + outcome: null, + listed: true }, { name: 'a send pending at an older fence', @@ -239,6 +249,22 @@ describe('the latest request and its verdict', () => { } }) + it('reports a chat whose only send a crash cut off as idle on that prompt, as the chat draws it', () => { + // Recovery settled it, so nothing is working and no one failed: the prompt with no verdict. + const items = [userEntry('m1', 1)] + const doubted = sent('m1', { dispatchState: 'unknown', recovered: true, resolvedAt: 30 }) + expect(projectStructuredAgentSessionStatusSummary(items, [doubted], 2)).toEqual({ + status: 'idle', + latestPrompt: 'm1' + }) + // Still live, it is a send the provider may yet answer. + const live = sent('m1', { dispatchState: 'unknown', resolvedAt: 30, fence: 2 }) + expect(projectStructuredAgentSessionStatusSummary(items, [live], 2)).toMatchObject({ + status: 'working', + latestPrompt: 'm1' + }) + }) + it('dates an idle session by the refusal it reports, not by the older turn', () => { const items = [ userEntry('m1', 1), @@ -287,7 +313,9 @@ describe('the sidebar verdict agrees with the rejection classifier', () => { const items = [userEntry('m1', 1)] const { verdict } = classifyDispatchRejection(submission) expect(latestStructuredAgentSessionRequest(items, [submission])?.outcome ?? null).toBe(verdict) - expect(hasStructuredAgentSessionRequest(items, [submission])).toBe(verdict === 'failure') + expect(hasStructuredAgentSessionRequest(items, [submission])).toBe( + verdict === 'failure' || structuredAgentSessionSubmissionSettlement(submission) === 'sent' + ) }) const failedNobody = new Set([ diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 9679c0c12bd7..95fffa74d7ee 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -15,6 +15,7 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' export type StructuredAgentSessionLatestRequest = { @@ -70,10 +71,11 @@ export function latestStructuredAgentSessionRequest( return null } -/** Whether the session has a request to list. A send that failed nobody and never became a turn - * leaves nothing to report, so a session holding only those is not listed; a user message the - * provider journaled itself (history, an older host) still is. Deliberately NOT scoped by - * producer: a session whose only content came from a subagent still has content. */ +/** Whether the session has a request to list. A refused send that failed nobody leaves nothing to + * report, so a session holding only those is not listed; a send drawn as sent is listed even when + * a crash left it in doubt, and so is a user message the provider journaled itself (history, an + * older host). Deliberately NOT scoped by producer: a session whose only content came from a + * subagent still has content. */ export function hasStructuredAgentSessionRequest( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], @@ -89,13 +91,16 @@ export function hasStructuredAgentSessionRequest( (item.body.kind === 'message' && (item.body.role === 'assistant' || (item.body.role === 'user' && !sent.has(item.itemId)))) ) || - submissions.some( - (submission) => - submission.dispatchState === 'accepted' || - isUnansweredStructuredAgentSessionDispatch(submission, currentFence) || - (submission.dispatchState === 'rejected' && - classifyDispatchRejection(submission).verdict === 'failure') - ) + submissions.some((submission) => { + switch (structuredAgentSessionSubmissionSettlement(submission)) { + case 'sent': + return true + case 'open': + return isUnansweredStructuredAgentSessionDispatch(submission, currentFence) + case 'refused': + return classifyDispatchRejection(submission).verdict === 'failure' + } + }) ) } @@ -104,7 +109,7 @@ function rejectedSubmissionsByItem( ): Map { const rejected = new Map() for (const submission of submissions) { - if (submission.dispatchState === 'rejected') { + if (structuredAgentSessionSubmissionSettlement(submission) === 'refused') { rejected.set(agentJournalSubmissionKey(submission.clientMessageId), submission) } } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index fba11a91a736..97b72a16abbf 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -7,7 +7,7 @@ import { type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' -import { structuredAgentSessionSubmissionSettledAsSent } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], @@ -20,11 +20,7 @@ export function projectStructuredAgentSessionMessages( // An older host's inferred "not delivered" is drawn as the sent message it may well have been. const rejected = new Set( submissions - .filter( - (submission) => - submission.dispatchState === 'rejected' && - !structuredAgentSessionSubmissionSettledAsSent(submission) - ) + .filter((submission) => structuredAgentSessionSubmissionSettlement(submission) === 'refused') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const visibleItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 00135fbc8377..36150de9debe 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -11,10 +11,8 @@ import { } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { - classifyDispatchRejection, - structuredAgentSessionSubmissionSettledAsSent -} from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' /** `unconfirmed`: this client cannot tell whether the host recorded the send (a lost reply, a * remount mid-send); a same-id resend asks it, and nothing may overtake it meanwhile. @@ -197,36 +195,35 @@ export function reconcileStructuredAgentSessionOutbox( const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) return entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - // Settled as sent: the journal draws it, and the entry has nothing left to hold or retry. - if (submission && structuredAgentSessionSubmissionSettledAsSent(submission)) { - return [] - } - if ( - submission?.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' - ) { - return [] - } - // The host holds it and may still reject it: kept, not a barrier, for a Retry that answer needs. - if (submission?.dispatchState === 'pending' || submission?.dispatchState === 'unknown') { - return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] + if (!submission) { + return [entry] } - // Accepted, then not delivered — the agent never started, or its start was refused. The text - // and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a - // remount reads an entry it left dispatching; the journal has since answered it. - if ( - submission?.dispatchState === 'rejected' && - (entry.state === 'dispatching' || entry.state === 'unconfirmed') - ) { - return [ - { - ...entry, - state: 'rejected' as const, - lastFailure: structuredAgentSessionRejectedFailure(submission) + switch (structuredAgentSessionSubmissionSettlement(submission)) { + // The journal draws it, and the entry has nothing left to hold or retry. + case 'sent': + return [] + // The host holds it and may still reject it: kept, not a barrier, for a Retry that answer needs. + case 'open': + return entry.state === 'dispatching' + ? [entry] + : [{ ...entry, state: 'dispatching' as const }] + case 'refused': + if (classifyDispatchRejection(submission).category === 'withdrawn') { + return [] } - ] + // Accepted, then not delivered — the agent never started, or its start was refused. The + // text and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` + // is how a remount reads an entry it left dispatching; the journal has since answered it. + return entry.state === 'dispatching' || entry.state === 'unconfirmed' + ? [ + { + ...entry, + state: 'rejected' as const, + lastFailure: structuredAgentSessionRejectedFailure(submission) + } + ] + : [entry] } - return [entry] }) } diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 69da8072296f..755b8bca0eb3 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -21,10 +21,8 @@ import { agentSessionFailureSentence, type AgentSessionFailureWordsContext } from './agent-session-failure-words' -import { - classifyDispatchRejection, - structuredAgentSessionSubmissionSettledAsSent -} from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -207,48 +205,33 @@ export function disposeStructuredAgentSessionSendResult( } } const submission = result.value.submission - // Sent, or left in doubt for good by a crash or a dead agent: the journal draws the message, and - // sending a new one is how the chat continues. - if (structuredAgentSessionSubmissionSettledAsSent(submission)) { - return { - entries: dropEntry(input), - error: null, - blockedClientMessageId: input.blockedClientMessageId - } - } - // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile - // drops it, with no notice. - if ( - submission.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' - ) { - return { - entries: dropEntry(input), - error: null, - blockedClientMessageId: input.blockedClientMessageId - } - } - if (submission.dispatchState === 'rejected') { - return { - entries: replaceEntryState( - input, - 'rejected', - structuredAgentSessionRejectedFailure(submission) - ), - error: null, - blockedClientMessageId: input.blockedClientMessageId - } - } - // `pending` is the host saying the message was written and is awaiting the - // provider's acknowledgement, which cannot arrive until the turn ahead of it - // ends. That is not doubt, and keeping order is no longer the reason to hold - // the entry -- the host fixed the order when it wrote the row. It stays - // because a `pending`, or a live `unknown`, can still settle `rejected`, and - // only the entry carries the retry state that answer needs. - return { - entries: replaceEntryState(input, 'dispatching'), + const settled = ( + entries: StructuredAgentSessionOutboxEntry[] + ): StructuredAgentSessionSendDisposition => ({ + entries, error: null, blockedClientMessageId: input.blockedClientMessageId + }) + switch (structuredAgentSessionSubmissionSettlement(submission)) { + // Sent, or left in doubt for good by a crash or a dead agent: the journal draws the message, + // and sending a new one is how the chat continues. + case 'sent': + return settled(dropEntry(input)) + // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the + // reconcile drops it, with no notice. Any other refusal keeps the text for the user's Retry. + case 'refused': + return settled( + classifyDispatchRejection(submission).category === 'withdrawn' + ? dropEntry(input) + : replaceEntryState(input, 'rejected', structuredAgentSessionRejectedFailure(submission)) + ) + // `pending` is the host saying the message was written and is awaiting the provider's + // acknowledgement, which cannot arrive until the turn ahead of it ends. That is not doubt, and + // keeping order is no longer the reason to hold the entry -- the host fixed the order when it + // wrote the row. It stays because a `pending`, or a live `unknown`, can still settle + // `rejected`, and only the entry carries the retry state that answer needs. + case 'open': + return settled(replaceEntryState(input, 'dispatching')) } } diff --git a/src/shared/structured-agent-session-submission-settlement-boundary.test.ts b/src/shared/structured-agent-session-submission-settlement-boundary.test.ts new file mode 100644 index 000000000000..125f73222521 --- /dev/null +++ b/src/shared/structured-agent-session-submission-settlement-boundary.test.ts @@ -0,0 +1,96 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Every client-side reader of a send's raw dispatch facts once re-derived "is this send still + * open, drawn as sent, or refused" on its own, and those copies disagreed with each other again + * and again. `structuredAgentSessionSubmissionSettlement` decides it once; this fails on a new + * raw read anywhere a client or the shared projection could grow another copy. The host's own + * journal code (src/main) writes these facts and is out of scope. + */ +const ALLOWED: ReadonlyMap = new Map([ + ['src/shared/structured-agent-session-submission-settlement.ts', 'the classifier itself'], + [ + 'src/shared/agent-session-queued-submission.ts', + 'queued is a pending the host has not handed over yet: a subset of open, read with handover facts' + ], + [ + 'mobile/src/session/mobile-structured-send-operation-journal.ts', + 'clears an ack-lost operation id once the host has moved the send past pending, as mobile ' + + 'delivery spends the id on a live unknown; `open` deliberately joins those two' + ] +]) + +const SCANNED_ROOTS = ['src/shared', 'src/renderer', 'mobile/src'] +const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '__fixtures__']) +const RAW_READ = [ + // Not a spread: `...recovered` is some other value's name. + /(?])/ +] + +function isTestFile(path: string): boolean { + return ( + /\.(?:test|spec)\.tsx?$/.test(path) || + /(?:test-harness|test-utils|test-setup|test-fixture)/.test(path) || + path.includes('/__tests__/') + ) +} + +function collectSourceFiles(root: string): string[] { + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return [] + } + return entries.flatMap((entry) => { + if (IGNORED_DIRECTORIES.has(entry)) { + return [] + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + return collectSourceFiles(full) + } + return /\.tsx?$/.test(full) ? [full] : [] + }) +} + +/** Drop comment-only lines so prose about the facts is not an offender. */ +function codeText(contents: string): string { + return contents + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) + .join('\n') +} + +describe('submission settlement boundary', () => { + const repoRoot = resolve(__dirname, '..', '..') + const files = SCANNED_ROOTS.flatMap((root) => collectSourceFiles(join(repoRoot, root))) + .map((file) => relative(repoRoot, file).split('\\').join('/')) + .filter((path) => !isTestFile(path)) + const offenders = files.filter((path) => { + const code = codeText(readFileSync(join(repoRoot, path), 'utf8')) + return RAW_READ.some((pattern) => pattern.test(code)) + }) + + it('scans a plausible number of files', () => { + // A broken root would make the guard silently vacuous. + expect(files.length).toBeGreaterThan(1000) + expect(files.some((path) => path.startsWith('mobile/src/'))).toBe(true) + }) + + it('reads dispatch facts only through the settlement classifier', () => { + expect( + offenders.filter((path) => !ALLOWED.has(path)), + "Raw read of a submission's dispatchState/recovered. Branch on " + + 'structuredAgentSessionSubmissionSettlement instead.' + ).toEqual([]) + }) + + it('has no stale exception', () => { + expect([...ALLOWED.keys()].filter((path) => !offenders.includes(path))).toEqual([]) + }) +}) diff --git a/src/shared/structured-agent-session-submission-settlement.test.ts b/src/shared/structured-agent-session-submission-settlement.test.ts new file mode 100644 index 000000000000..efc2117466fa --- /dev/null +++ b/src/shared/structured-agent-session-submission-settlement.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_FAILURE_KINDS, isSubmissionRejectionKind } from './agent-session-failure' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' +import { + structuredAgentSessionSubmissionSettlement, + type StructuredAgentSessionSubmissionSettlement +} from './structured-agent-session-submission-settlement' + +type Shape = Pick & + Partial> & { + rejection?: unknown + } + +const firstHandRejections: [string, Shape][] = [ + ...AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind) + .filter((kind) => kind !== 'notDelivered') + .map((kind): [string, Shape] => [ + `typed ${kind}`, + { dispatchState: 'rejected', reason: 'The message was not sent.', rejection: { kind } } + ]), + ...[ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED, + 'Claude does not support .bmp' + ].map((reason): [string, Shape] => [`legacy ${reason}`, { dispatchState: 'rejected', reason }]), + [ + 'a fact this build cannot place', + { dispatchState: 'rejected', reason: 'x', rejection: { kind: 'someFutureKind' } } + ], + // `recovered` on a rejection says who wrote it, not that it is in doubt. + [ + 'a rejection crash recovery wrote', + { dispatchState: 'rejected', reason: 'x', rejection: { kind: 'startFailed' }, recovered: true } + ] +] + +const CASES: [string, Shape, StructuredAgentSessionSubmissionSettlement][] = [ + ['pending', { dispatchState: 'pending', reason: null }, 'open'], + ['queued', { dispatchState: 'pending', reason: null, handoverRecorded: true }, 'open'], + ['accepted', { dispatchState: 'accepted', reason: null }, 'sent'], + [ + 'live unknown', + { dispatchState: 'unknown', reason: 'provider_write_outcome_unknown: EPIPE' }, + 'open' + ], + [ + 'recovered unknown (new host)', + { dispatchState: 'unknown', reason: 'provider_idle_before_acknowledgement', recovered: true }, + 'sent' + ], + [ + 'restart-doubted unknown from a host without `recovered` on the wire (<= v1.4.200)', + { dispatchState: 'unknown', reason: 'host_restarted_before_acknowledgement' }, + 'sent' + ], + [ + "an older host's legacy not_delivered", + { dispatchState: 'rejected', reason: DISPATCH_REJECTED_NOT_DELIVERED }, + 'sent' + ], + [ + "an older host's typed notDelivered", + { dispatchState: 'rejected', reason: 'x', rejection: { kind: 'notDelivered' } }, + 'sent' + ], + ...firstHandRejections.map( + ([name, shape]): [string, Shape, StructuredAgentSessionSubmissionSettlement] => [ + name, + shape, + 'refused' + ] + ) +] + +describe('structuredAgentSessionSubmissionSettlement', () => { + it.each(CASES)('%s', (_name, shape, expected) => { + expect(structuredAgentSessionSubmissionSettlement(shape)).toBe(expected) + }) +}) diff --git a/src/shared/structured-agent-session-submission-settlement.ts b/src/shared/structured-agent-session-submission-settlement.ts new file mode 100644 index 000000000000..3b46b572d464 --- /dev/null +++ b/src/shared/structured-agent-session-submission-settlement.ts @@ -0,0 +1,38 @@ +// The one reading of a journaled send's dispatch facts. Clients, the host's status projection and +// mobile all decide from this; a ratchet test fails on a new raw read of those facts elsewhere, +// because every earlier copy of this rule drifted from the others. + +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' + +/** + * - `open`: the host still holds it and it can yet turn accepted or rejected — pending, queued, or + * a live `unknown`. A client keeps its outbox entry for the answer. + * - `sent`: drawn as an ordinary sent message with nothing left to hold or retry — the provider + * took it, or a crash or dead agent left it in doubt for good and the next message is how the + * chat continues. An older host's `notDelivered` was that same doubt, inferred from the + * transcript; such a host wrote no session row, so that message carries no notice. + * - `refused`: provably did not happen; `classifyDispatchRejection` says why. + */ +export type StructuredAgentSessionSubmissionSettlement = 'open' | 'sent' | 'refused' + +export function structuredAgentSessionSubmissionSettlement( + submission: Pick & { + rejection?: unknown + } +): StructuredAgentSessionSubmissionSettlement { + switch (submission.dispatchState) { + case 'accepted': + return 'sent' + case 'pending': + return 'open' + case 'unknown': + // Hosts before the `recovered` flag reached the wire publish only the restart reason. + return submission.recovered === true || + submission.reason === 'host_restarted_before_acknowledgement' + ? 'sent' + : 'open' + case 'rejected': + return classifyDispatchRejection(submission).kind === 'notDelivered' ? 'sent' : 'refused' + } +} diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 3f480013886d..d710c5a6c1d7 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -11,6 +11,7 @@ import type { } from './agent-session-journal-types' import { readAgentJournalTurn } from './agent-session-turn-record' import type { NativeChatSettledTurn, NativeChatSettledTurns } from './native-chat-turn-status' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' export type StructuredAgentTurnTiming = { state: AgentJournalTurnLifecycleState @@ -165,7 +166,7 @@ export function structuredAgentTurnLocalStartedAt( /** What a chat surface hands to the shared turn-status selector: every turn the * host recorded, with its duration or null. A null still outranks the local * clock, so a turn whose end the host never observed shows no duration on the - * surface that watched it, exactly as it will after a reload. A rejected send + * surface that watched it, exactly as it will after a reload. A refused send * never reached the provider, so it opened no turn and its message shows none. */ export function selectStructuredAgentSettledTurns( items: readonly AgentJournalRenderItem[], @@ -183,7 +184,10 @@ export function selectStructuredAgentSettledTurns( } for (const submission of submissions) { const userItemId = agentJournalSubmissionKey(submission.clientMessageId) - if (submission.dispatchState === 'rejected' && !settled.has(userItemId)) { + if ( + structuredAgentSessionSubmissionSettlement(submission) === 'refused' && + !settled.has(userItemId) + ) { settled.set(userItemId, null) } } diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts index 0305947dbede..f1f9eb815d1d 100644 --- a/src/shared/structured-agent-session-unanswered-dispatch.ts +++ b/src/shared/structured-agent-session-unanswered-dispatch.ts @@ -1,6 +1,6 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' -import { isRecoveredStructuredAgentSessionDoubt } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' /** One send the provider has neither opened a turn for nor refused; the rule is explained on * `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */ @@ -14,9 +14,7 @@ export function isUnansweredStructuredAgentSessionDispatch( } return ( (currentFence == null || submission.fence >= currentFence) && - (submission.dispatchState === 'pending' || - (submission.dispatchState === 'unknown' && - !isRecoveredStructuredAgentSessionDoubt(submission))) + structuredAgentSessionSubmissionSettlement(submission) === 'open' ) } From 328fbed42f1b1e5ee5704411654d8d1ff048ef3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:56:59 -0700 Subject: [PATCH 21/49] fix(native-chat): settle a dispatch state this build cannot place as sent The wire schema admits any dispatch-state string, so the classifier must answer for one a newer host writes rather than return nothing (which the outbox reconcile would have kept as an undefined entry). To this build it is doubt, and doubt is drawn as sent. --- ...gent-session-submission-settlement.test.ts | 6 ++++ ...red-agent-session-submission-settlement.ts | 29 ++++++++++--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/shared/structured-agent-session-submission-settlement.test.ts b/src/shared/structured-agent-session-submission-settlement.test.ts index efc2117466fa..8050141f8ba8 100644 --- a/src/shared/structured-agent-session-submission-settlement.test.ts +++ b/src/shared/structured-agent-session-submission-settlement.test.ts @@ -89,4 +89,10 @@ describe('structuredAgentSessionSubmissionSettlement', () => { it.each(CASES)('%s', (_name, shape, expected) => { expect(structuredAgentSessionSubmissionSettlement(shape)).toBe(expected) }) + + it('draws a state a newer host wrote as sent, never as nothing', () => { + // The wire schema admits any dispatch state string. + const future: Shape = JSON.parse('{"dispatchState":"superseded","reason":null}') + expect(structuredAgentSessionSubmissionSettlement(future)).toBe('sent') + }) }) diff --git a/src/shared/structured-agent-session-submission-settlement.ts b/src/shared/structured-agent-session-submission-settlement.ts index 3b46b572d464..a7c70582008e 100644 --- a/src/shared/structured-agent-session-submission-settlement.ts +++ b/src/shared/structured-agent-session-submission-settlement.ts @@ -21,18 +21,21 @@ export function structuredAgentSessionSubmissionSettlement( rejection?: unknown } ): StructuredAgentSessionSubmissionSettlement { - switch (submission.dispatchState) { - case 'accepted': - return 'sent' - case 'pending': - return 'open' - case 'unknown': - // Hosts before the `recovered` flag reached the wire publish only the restart reason. - return submission.recovered === true || - submission.reason === 'host_restarted_before_acknowledgement' - ? 'sent' - : 'open' - case 'rejected': - return classifyDispatchRejection(submission).kind === 'notDelivered' ? 'sent' : 'refused' + const { dispatchState } = submission + if (dispatchState === 'pending') { + return 'open' } + if (dispatchState === 'unknown') { + // Hosts before the `recovered` flag reached the wire publish only the restart reason. + return submission.recovered === true || + submission.reason === 'host_restarted_before_acknowledgement' + ? 'sent' + : 'open' + } + if (dispatchState === 'rejected') { + return classifyDispatchRejection(submission).kind === 'notDelivered' ? 'sent' : 'refused' + } + // `accepted` — or a state a newer host wrote, since the wire admits any string here: to this + // build that is doubt, and doubt is drawn as sent. + return 'sent' } From 086201686e9c7f2c61bf3de1d40932bf86385d82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:09:55 -0700 Subject: [PATCH 22/49] fix(native-chat): keep a failure row outside a settled turn's fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crash or a dead agent writes its one session row ("Claude's session didn't survive the restart…", "Claude stopped while this response was in progress") after the cut-off turn's partial answer. That turn has a duration, so the fold treated the row as the turn's work and hid it behind "Worked for Ns", leaving the chat with no visible notice. A system row carrying a failure fact now outlives its turn, like a spawn roster or background task. A provider retry is not a failure yet and still folds. Mobile only folds tool runs, so it already showed these rows. --- .../native-chat-transcript-slots.test.ts | 54 +++++++++++++++++++ .../native-chat-transcript-slots.ts | 8 +-- src/shared/native-chat-turn-fold.ts | 21 ++++++-- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index f22d25bfa8f5..b49d07f4dfd1 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -5,6 +5,9 @@ import { type NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { structuredAgentSessionStatusBlock } from '../../../../shared/structured-agent-session-status-block' import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { @@ -206,3 +209,54 @@ describe('a send the host rejected', () => { ]) }) }) + +describe('a turn a crash or a dead agent cut off', () => { + // The row the host writes at the boundary, as the journal projection draws it. + function statusRow(id: string, kind: 'hostRestarted' | 'providerExited' | 'providerRetrying') { + const words = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'row', + agentName: 'Claude' + }) + return { + id, + role: 'system' as const, + blocks: [structuredAgentSessionStatusBlock({ kind: 'status', ...words })], + timestamp: 1, + source: 'transcript' as const + } + } + const settled = { + active: null, + completedByTurn: { u: { startedAt: 1, thinking: false, workedSeconds: 7 } } + } + + it.each(['hostRestarted', 'providerExited'] as const)( + 'keeps the %s row outside the settled turn it landed in', + (kind) => { + const slots = build( + [ + text('u', 'go', 'user'), + toolRun('work'), + text('partial', 'Half an answer'), + statusRow('boundary', kind), + text('next', 'again', 'user') + ], + { turnStatuses: settled } + ) + expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([ + ['u', false], + ['partial', false], + ['boundary', false], + ['next', false] + ]) + } + ) + + it('still folds a provider retry, which is the turn working', () => { + const slots = build( + [text('u', 'go', 'user'), statusRow('retry', 'providerRetrying'), text('a', 'Done.')], + { turnStatuses: settled } + ) + expect(slots.map((slot) => slot.message.id)).toEqual(['u', 'a']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index a0fe23b6df34..d158bc69d1d4 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -15,6 +15,7 @@ import { } from '../../../../shared/native-chat-types' import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' import { + nativeChatRowReportsFailure, nativeChatTurnFold, type NativeChatTurnFoldRow } from '../../../../shared/native-chat-turn-fold' @@ -97,9 +98,10 @@ export function buildNativeChatTranscriptSlots( rendersProse: content.markdown.length > 0 || content.hasImages, // The raw blocks, not the renderable ones: a childless roster draws no row // and its plain-text twin is then the only record the spawn happened. - outlivesTurn: message.blocks.some( - (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) - ) + outlivesTurn: + message.blocks.some( + (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) + ) || nativeChatRowReportsFailure(message.blocks) } }) // Liveness is the turn's, not any one call's: the run at the frontier stays diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 5ceb0ef89898..d766c14eaa48 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -9,7 +9,7 @@ // Shared because desktop and mobile both draw this disclosure, and a fold that // hides a different row on each surface is the same bug twice. -import type { NativeChatRole } from './native-chat-types' +import type { NativeChatBlock, NativeChatRole } from './native-chat-types' /** What the fold needs to know about one transcript row. Deliberately not a * `NativeChatMessage`: each surface derives "renders prose" through its own @@ -20,12 +20,25 @@ export type NativeChatTurnFoldRow = { role: NativeChatRole /** Whether the row draws prose — the only thing that can be an answer. */ rendersProse: boolean - /** Whether the row carries work that outlives the turn that started it: a - * spawn roster or a background task. That row is the durable report of how - * the work ended — often the only one — so it never folds. */ + /** Whether the row outlives the turn that started it: a spawn roster, a + * background task, or a reported failure (`nativeChatRowReportsFailure`). + * That row is the durable report of how the work ended — often the only + * one — so it never folds. */ outlivesTurn: boolean } +/** A failure row — the agent stopped, the session did not survive a restart — says how the turn + * or the chat ended and what to do next, so it is never the turn's hidden work. A provider retry + * is not a failure yet. */ +export function nativeChatRowReportsFailure(blocks: readonly NativeChatBlock[]): boolean { + return blocks.some( + (block) => + block.type === 'text' && + block.failure !== undefined && + block.failure.kind !== 'providerRetrying' + ) +} + export type NativeChatTurnFold = { /** Rows a folded turn must not draw, by index. */ foldedRows: ReadonlySet From 2ce280324ec6556e640bb77f69a565304176f696 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:09:58 -0700 Subject: [PATCH 23/49] docs(agent-status): list a crash-doubted send as a request The status projection now counts a send a crash or dead agent left in doubt, drawn as sent, so the chat lists idle with no verdict; the store reference still said a restart-undelivered send made nothing listable. --- docs/reference/agent-status-store.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dde..0ab4dd2b032b 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -107,9 +107,11 @@ ingests the summary into the hook server as a status row: Sessions with no request (`status === null`) produce no row. A request is a turn record, an assistant message, a user message the provider journaled itself -(history, an older host), an accepted or unanswered send, or a send the agent or -its start refused; a send that was withdrawn, or left undelivered by a -restart or a close, fails nobody and makes nothing listable. +(history, an older host), an accepted or unanswered send, a send a crash or a +dead agent left in doubt (drawn as sent, so listed idle with no verdict), or a +send the agent or its start refused; a send that was withdrawn, or refused +because a restart or a close came before it was handed over, fails nobody and +makes nothing listable. `summary.turnOutcome` is the latest request's verdict: its turn's outcome, or `failure` for a send the agent or its start refused (a send that joined a running turn is answered by that turn). The row also publishes `interrupted` from From fb06b52c8ff71632b0bf573b7c89cf0e6e8f05f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:21:22 -0700 Subject: [PATCH 24/49] docs(native-chat): name failure rows where the fold skips them; a doubted send adds no verdict The fold's loop comment still listed only rosters and background tasks as rows that outlive a turn. The status-store reference implied a crash-doubted send leaves the chat with no verdict; it adds none of its own, and an earlier turn's verdict still reports. --- docs/reference/agent-status-store.md | 2 +- src/shared/native-chat-turn-fold.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 0ab4dd2b032b..4dfb5221e456 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -108,7 +108,7 @@ ingests the summary into the hook server as a status row: Sessions with no request (`status === null`) produce no row. A request is a turn record, an assistant message, a user message the provider journaled itself (history, an older host), an accepted or unanswered send, a send a crash or a -dead agent left in doubt (drawn as sent, so listed idle with no verdict), or a +dead agent left in doubt (drawn as sent; it adds no verdict of its own), or a send the agent or its start refused; a send that was withdrawn, or refused because a restart or a close came before it was handed over, fails nobody and makes nothing listable. diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index d766c14eaa48..77141ea32869 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -90,7 +90,7 @@ export function nativeChatTurnFold({ for (const [index, row] of rows.entries()) { const { turnKey } = row // Outside the fold by construction: the reader's own message anchors the - // turn, and a roster or background-task row outlives it. + // turn, and a roster, background-task or failure row outlives it. if ( turnKey === undefined || row.role === 'user' || From 7a97fdb9dba28bdd513a7eca07e4be5ff2fbb698 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:42:21 -0700 Subject: [PATCH 25/49] test(native-chat): name the settlement classifier's input for the submission record it is --- ...gent-session-submission-settlement.test.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/shared/structured-agent-session-submission-settlement.test.ts b/src/shared/structured-agent-session-submission-settlement.test.ts index 8050141f8ba8..03f62ace4328 100644 --- a/src/shared/structured-agent-session-submission-settlement.test.ts +++ b/src/shared/structured-agent-session-submission-settlement.test.ts @@ -15,15 +15,16 @@ import { type StructuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' -type Shape = Pick & +// The host-written submission record, as far as the classifier reads it. +type SubmissionRecord = Pick & Partial> & { rejection?: unknown } -const firstHandRejections: [string, Shape][] = [ +const firstHandRejections: [string, SubmissionRecord][] = [ ...AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind) .filter((kind) => kind !== 'notDelivered') - .map((kind): [string, Shape] => [ + .map((kind): [string, SubmissionRecord] => [ `typed ${kind}`, { dispatchState: 'rejected', reason: 'The message was not sent.', rejection: { kind } } ]), @@ -35,7 +36,10 @@ const firstHandRejections: [string, Shape][] = [ DISPATCH_REJECTED_CODEX_QUEUE_FULL, DISPATCH_REJECTED_WRITE_FAILED, 'Claude does not support .bmp' - ].map((reason): [string, Shape] => [`legacy ${reason}`, { dispatchState: 'rejected', reason }]), + ].map((reason): [string, SubmissionRecord] => [ + `legacy ${reason}`, + { dispatchState: 'rejected', reason } + ]), [ 'a fact this build cannot place', { dispatchState: 'rejected', reason: 'x', rejection: { kind: 'someFutureKind' } } @@ -47,7 +51,7 @@ const firstHandRejections: [string, Shape][] = [ ] ] -const CASES: [string, Shape, StructuredAgentSessionSubmissionSettlement][] = [ +const CASES: [string, SubmissionRecord, StructuredAgentSessionSubmissionSettlement][] = [ ['pending', { dispatchState: 'pending', reason: null }, 'open'], ['queued', { dispatchState: 'pending', reason: null, handoverRecorded: true }, 'open'], ['accepted', { dispatchState: 'accepted', reason: null }, 'sent'], @@ -77,22 +81,22 @@ const CASES: [string, Shape, StructuredAgentSessionSubmissionSettlement][] = [ 'sent' ], ...firstHandRejections.map( - ([name, shape]): [string, Shape, StructuredAgentSessionSubmissionSettlement] => [ - name, - shape, - 'refused' - ] + ([name, submission]): [ + string, + SubmissionRecord, + StructuredAgentSessionSubmissionSettlement + ] => [name, submission, 'refused'] ) ] describe('structuredAgentSessionSubmissionSettlement', () => { - it.each(CASES)('%s', (_name, shape, expected) => { - expect(structuredAgentSessionSubmissionSettlement(shape)).toBe(expected) + it.each(CASES)('%s', (_name, submission, expected) => { + expect(structuredAgentSessionSubmissionSettlement(submission)).toBe(expected) }) it('draws a state a newer host wrote as sent, never as nothing', () => { // The wire schema admits any dispatch state string. - const future: Shape = JSON.parse('{"dispatchState":"superseded","reason":null}') + const future: SubmissionRecord = JSON.parse('{"dispatchState":"superseded","reason":null}') expect(structuredAgentSessionSubmissionSettlement(future)).toBe('sent') }) }) From 4002f3c851e8ceddb017a77bd7a5b2fb4d243f8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:32:59 -0700 Subject: [PATCH 26/49] fix(native-chat): explain a send retired in doubt with no exit or restart row A send whose hand-over was in doubt could be retired with no row explaining it: Codex going idle without answering it, or an expected close (the idle sweep, quit, a tab close) settling it. The chat then drew the message as sent, with an idle agent, no reply and nothing saying why. Both boundaries now write one session-level notice, " didn't respond to your last message. Send a message to continue.", from one row builder. A crash keeps its restart row and an observed exit its stopped row; the new row is written only where neither is, and it is keyed by its boundary so a retry revises it rather than adding a second. --- ...d-agent-session-conversation-close.test.ts | 34 +++++ ...session-dead-generation-settlement.test.ts | 107 ++++++++++++-- ...gent-session-dead-generation-settlement.ts | 37 ++++- .../structured-agent-session-host-lifetime.ts | 4 + ...ession-unanswered-dispatch-release.test.ts | 138 +++++++++++++++++- ...ent-session-unanswered-dispatch-release.ts | 23 ++- src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 3 + 8 files changed, 324 insertions(+), 24 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index bf3cd1f96a60..da20ff939674 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -324,3 +324,37 @@ describe('the wind-down retry with a message queued (P2-31)', () => { expect(stopAgent).not.toHaveBeenCalled() }) }) + +describe('a send still in doubt when the sweep puts the agent to rest', () => { + it('leaves one row saying the agent did not respond, and no second on reopen', async () => { + rig.adapter.dispatch.mockResolvedValueOnce({ + state: 'unknown', + reason: 'provider_write_outcome_unknown: timeout' + }) + await foundRestTestChat(rig) + await vi.waitFor(() => + expect(openSession()?.journal.submissions()).toEqual([ + expect.objectContaining({ dispatchState: 'unknown' }) + ]) + ) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + const reopened = await rig.host.journalSnapshot(SESSION) + + expect(reopened.submissions).toEqual([ + expect.objectContaining({ dispatchState: 'unknown', recovered: true }) + ]) + expect( + reopened.items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + ).toEqual([ + { + kind: 'status', + tone: 'notice', + text: "Codex didn't respond to your last message. Send a message to continue.", + failure: { kind: 'messageUnanswered' } + } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 4a0febb6ceb9..0965e7e9c749 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -19,6 +19,14 @@ import { const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' +const UNANSWERED_OUTCOME = "Codex didn't respond to your last message. Send a message to continue." + +function statusRows() { + return journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) +} + const SESSION = 'session-dead-generation' const THREAD = 'thread-1' let root: string @@ -85,7 +93,7 @@ async function seedUnfinishedWork(): Promise { } describe('dead structured-session generation settlement', () => { - it('settles probe-proven work as unverifiable without a technical chat row or fake end time', async () => { + it('settles probe-proven work as unverifiable without a technical row or fake end time', async () => { await seedUnfinishedWork() await expect( @@ -118,7 +126,15 @@ describe('dead structured-session generation settlement', () => { { kind: 'turn', turnId: 'turn-1', state: 'unverifiable', startedAt: 900 } ]) ) - expect(snapshot.items.some((item) => item.body.kind === 'status')).toBe(false) + // The send it retired in doubt gets the one plain row, never an exit reason. + expect(statusRows()).toEqual([ + { + kind: 'status', + tone: 'notice', + text: "The agent didn't respond to your last message. Send a message to continue.", + failure: { kind: 'messageUnanswered' } + } + ]) }) it('adds one actionable outcome for observed active-work failure and is idempotent', async () => { @@ -138,14 +154,8 @@ describe('dead structured-session generation settlement', () => { await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toBe(true) expect(journal.cursor()).toEqual(settledCursor) - expect( - journal - .snapshot() - .items.filter( - (item) => - item.body.kind === 'status' && item.body.text === UNEXPECTED_PROVIDER_EXIT_OUTCOME - ) - ).toHaveLength(1) + // The exit row alone explains the send it left in doubt: one row for one boundary. + expect(statusRows().map((row) => row.text)).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) }) it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { @@ -294,6 +304,79 @@ describe('dead structured-session generation settlement', () => { ]) }) + it('says once that the agent did not respond when an expected close retires a send in doubt', async () => { + await journal.appendSubmission({ + clientMessageId: 'client-unknown', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'did this land?' }] }, + fence: 7 + }) + await journal.resolveDispatch({ + clientMessageId: 'client-unknown', + state: 'unknown', + reason: 'provider write outcome unknown', + fence: 7 + }) + const close = () => + settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `expected-close:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: false, + failureTextContext: { agentName: 'Codex' } + }) + + await expect(close()).resolves.toBe(true) + await expect(close()).resolves.toBe(true) + + expect(statusRows()).toEqual([ + { + kind: 'status', + tone: 'notice', + text: UNANSWERED_OUTCOME, + failure: { kind: 'messageUnanswered' } + } + ]) + }) + + it('writes no row for an expected close that left nothing in doubt', async () => { + await journal.appendSubmission({ + clientMessageId: 'client-answered', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 7 + }) + await journal.resolveDispatch({ + clientMessageId: 'client-answered', + state: 'unknown', + reason: 'provider write outcome unknown', + fence: 7, + recovered: true + }) + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, + { fence: 7 } + ) + + await expect( + settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `expected-close:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: false + }) + ).resolves.toBe(true) + + expect(statusRows()).toEqual([]) + }) + it('rejects a send a child that never started left pending with its diagnostic, in words', async () => { await journal.appendSubmission({ clientMessageId: 'client-held', @@ -390,7 +473,9 @@ describe('dead structured-session generation settlement', () => { ['question', 2, undefined], ['turn', 2, undefined], ['tool-call', 2, 'thread-child'], - ['approval', 2, 'thread-child'] + ['approval', 2, 'thread-child'], + // The seeded send's session row is Orca's, never the subagent's. + ['status', 1, undefined] ]) await journal.close() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index fc1f43a115ec..bcd0de290aed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -106,7 +106,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: SubmissionRejectionFact - /** Who a failed start's sentence names. */ + /** Who the row's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ @@ -126,9 +126,12 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) - : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) + const sendsLeftInDoubt = startupFailure + ? await input.journal.rejectPendingSubmissions(input.fence, startupFailure).then(() => []) + : await input.journal.markPendingSubmissionsUnknown( + input.fence, + input.pendingSubmissionReason + ) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { @@ -157,6 +160,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ) } }) + } else if (sendsLeftInDoubt.length > 0) { + // An expected close: no exit row says why the send got no reply. + mutations.push(unansweredSendsRow(input.settlementId, input.failureTextContext)) } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -265,6 +271,29 @@ function staleSettlementRow( return null } +/** + * The row for sends a boundary retired in doubt when no exit or restart row covers them: an + * expected close, or the provider going idle without answering. Keyed by the boundary, so a + * repeated settlement revises its one row instead of adding another. + */ +export function unansweredSendsRow( + boundaryId: string, + failureTextContext?: AgentSessionFailureWordsContext +): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: { provider: 'orca', clientMessageId: boundaryId }, + body: { + kind: 'status', + tone: 'notice', + ...agentSessionFailureWords(agentSessionFailureFact('messageUnanswered'), { + ...failureTextContext, + surface: 'row' + }) + } + } +} + function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { if (item.body.kind === 'tool-call' && item.body.state === 'running') { return { ...item.body, state: 'failed' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index c9ba8714b4ee..549702e32234 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -138,6 +139,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, + failureTextContext: structuredAgentSessionFailureWordsContext( + context.deps.store.getRecord(sessionId) + ), onError: (id, error) => { settlementError = error context.deps.onEventSinkError?.({ sessionId: id, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index 9a27415dc711..aa6c0ba299ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -1,4 +1,8 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' @@ -22,23 +26,42 @@ function submission(over: Partial): AgentJournalSubmissi type ReleaseContext = Parameters[0] type ReleaseSession = NonNullable> type ResolveDispatchInput = Parameters[0] +type LifecycleBatchInput = Parameters[0] function contextWith(submissions: AgentJournalSubmission[]) { const resolved: ResolveDispatchInput[] = [] + const batches: LifecycleBatchInput[] = [] + const writes: string[] = [] const journal = { submissions: () => submissions, resolveDispatch: vi.fn(async (input: ResolveDispatchInput) => { resolved.push(input) + writes.push(`dispatch:${input.clientMessageId}`) + return { epoch: 'e', sequence: 1 } + }), + appendLifecycleBatch: vi.fn(async (input: LifecycleBatchInput) => { + batches.push(input) + writes.push(`batch:${input.settlementId}`) return { epoch: 'e', sequence: 1 } }) } - // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and the record fence. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the session and the record is unreachable from this mutation. + // The mutation reads only the journal calls above, the record fence and the record's agent. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field the mutation reads is supplied here; the rest of the session and the record is unreachable from it. const context = { sessions: new Map([['s-1', { journal }]]), - deps: { store: { getRecord: () => ({ lease: { runtimeFence: FENCE } }) } } + deps: { + store: { getRecord: () => ({ provider: 'codex', lease: { runtimeFence: FENCE } }) } + } } as unknown as ReleaseContext - return { context, resolved, journal } + return { context, resolved, batches, writes, journal } +} + +function rowsOf(batches: LifecycleBatchInput[]) { + return batches.flatMap((batch) => + batch.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [{ identity: mutation.identity, body: mutation.body }] : [] + ) + ) } describe('releasing dispatches the provider can no longer answer', () => { @@ -68,8 +91,107 @@ describe('releasing dispatches the provider can no longer answer', () => { ) }) + it('says once, in a session row, that the agent did not respond, before retiring the sends', async () => { + const { context, batches, writes } = contextWith([ + submission({}), + submission({ clientMessageId: 'm-2' }) + ]) + + await releaseStructuredAgentSessionUnansweredDispatches(context, { + sessionId: 's-1', + reason: 'provider_idle_before_acknowledgement' + }) + + expect(rowsOf(batches)).toEqual([ + { + identity: { provider: 'orca', clientMessageId: 'provider-idle:s-1:m-1' }, + body: { + kind: 'status', + tone: 'notice', + text: "Codex didn't respond to your last message. Send a message to continue.", + failure: { kind: 'messageUnanswered' } + } + } + ]) + // Row first: a retry after a failed retire revises this row instead of leaving none. + expect(writes).toEqual(['batch:provider-idle:s-1:m-1', 'dispatch:m-1', 'dispatch:m-2']) + }) + + it('keeps one row when a retire fails and the next idle retries it', async () => { + const live = [submission({})] + const { context, batches, journal } = contextWith(live) + journal.resolveDispatch.mockRejectedValueOnce(new Error('journal write failed')) + const release = () => + releaseStructuredAgentSessionUnansweredDispatches(context, { + sessionId: 's-1', + reason: 'provider_idle_before_acknowledgement' + }) + + await expect(release()).rejects.toThrow('journal write failed') + await release() + + const ids = rowsOf(batches).map((row) => row.identity) + expect(ids).toHaveLength(2) + // The same identity revises the one row in place. + expect(new Set(ids.map((id) => JSON.stringify(id))).size).toBe(1) + }) + + it('leaves one row in the journal however often the provider goes idle', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-idle-release-')) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 's-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root, + now: () => 1_000 + }) + try { + await journal.appendSubmission({ + clientMessageId: 'm-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: FENCE + }) + await journal.resolveDispatch({ + clientMessageId: 'm-1', + state: 'unknown', + reason: 'turn start timed out', + fence: FENCE + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mutation reads only the session's journal and the record's fence and agent, all supplied. + const context = { + sessions: new Map([['s-1', { journal }]]), + deps: { + store: { getRecord: () => ({ provider: 'codex', lease: { runtimeFence: FENCE } }) } + } + } as unknown as ReleaseContext + const release = () => + releaseStructuredAgentSessionUnansweredDispatches(context, { + sessionId: 's-1', + reason: 'provider_idle_before_acknowledgement' + }) + + await release() + await release() + + expect( + journal.snapshot().items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + ).toEqual([expect.objectContaining({ failure: { kind: 'messageUnanswered' } })]) + expect(journal.submissions()).toEqual([ + expect.objectContaining({ dispatchState: 'unknown', recovered: true }) + ]) + } finally { + await journal.close() + await rm(root, { recursive: true, force: true }) + } + }) + it('never touches a pending send, whose dispatch may still be in flight', async () => { - const { context, resolved } = contextWith([ + const { context, resolved, batches } = contextWith([ submission({ clientMessageId: 'm-2', dispatchState: 'pending', reason: null }) ]) @@ -79,10 +201,11 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) + expect(batches).toEqual([]) }) - it('leaves an already recovered unknown alone', async () => { - const { context, resolved } = contextWith([submission({ recovered: true })]) + it('leaves an already recovered unknown alone, writing no second row', async () => { + const { context, resolved, batches } = contextWith([submission({ recovered: true })]) await releaseStructuredAgentSessionUnansweredDispatches(context, { sessionId: 's-1', @@ -90,6 +213,7 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) + expect(batches).toEqual([]) }) it('falls back to the supplied reason when the submission named none', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts index 713838c177ec..f185b472d804 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -1,4 +1,6 @@ +import { unansweredSendsRow } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' @@ -13,6 +15,7 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s * `pending` is deliberately untouched: that send's dispatch has not returned yet * and may be in flight right now. And `recovered` only retires the obligation — * it never makes a send re-deliverable, because the provider may well have run it. + * The chat says so once, in the row every retired doubt without an exit or restart row gets. */ export async function releaseStructuredAgentSessionUnansweredDispatches( context: Pick & { @@ -27,16 +30,32 @@ export async function releaseStructuredAgentSessionUnansweredDispatches( const stranded = session.journal .submissions() .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) - if (stranded.length === 0) { + const [first] = stranded + if (!first) { return } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) + // Written before the sends retire and keyed by the oldest, so a retry after a failed retire + // revises this row rather than leaving the doubt silent or adding a second. + const boundaryId = `provider-idle:${input.sessionId}:${first.clientMessageId}` + await session.journal.appendLifecycleBatch({ + settlementId: boundaryId, + fence, + recovered: true, + mutations: [ + unansweredSendsRow( + boundaryId, + structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(input.sessionId)) + ) + ] + }) for (const entry of stranded) { await session.journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'unknown', // The earlier reason names a sharper fact than this one does. reason: entry.reason ?? input.reason, - fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), + fence, recovered: true }) } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 1c950de7956f..a6b1a34197a3 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -208,6 +208,8 @@ const FAILURE_SENTENCES = { compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', cancelUnconfirmed: () => 'Cancellation was not confirmed.', answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', + messageUnanswered: ({ agentName }) => + `${agentName ?? 'The agent'} didn't respond to your last message. Send a message to continue.`, hostFault: ({ retryControl }) => `Orca ran into a problem, so this didn't go through.${retryControl ? '' : ' Try again.'}`, hostStopped: ({ agentName }) => diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index ed8b677b359e..b5e4b08cbf4f 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -38,6 +38,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'compactionUnconfirmed', 'cancelUnconfirmed', 'answerUnconfirmed', + /** A send left in doubt was retired with no reply, and no exit or restart row says why. */ + 'messageUnanswered', 'hostFault', /** Orca stopped an agent whose start never finished. */ 'hostStopped', @@ -56,6 +58,7 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'compactionUnconfirmed', 'cancelUnconfirmed', 'answerUnconfirmed', + 'messageUnanswered', 'providerRetrying' ] as const satisfies readonly AgentSessionFailureKind[] From d6d00bf812ecee8fd6f9d768c8c8c8f2c4e55964 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:46:40 -0700 Subject: [PATCH 27/49] test(native-chat): pin that the unanswered-send row stays outside a folded turn --- .../native-chat/native-chat-transcript-slots.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 15bd0533400d..46e95e95a829 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -212,7 +212,10 @@ describe('a send the host rejected', () => { describe('a turn a crash or a dead agent cut off', () => { // The row the host writes at the boundary, as the journal projection draws it. - function statusRow(id: string, kind: 'hostRestarted' | 'providerExited' | 'providerRetrying') { + function statusRow( + id: string, + kind: 'hostRestarted' | 'providerExited' | 'messageUnanswered' | 'providerRetrying' + ) { const words = agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'row', agentName: 'Claude' @@ -230,7 +233,7 @@ describe('a turn a crash or a dead agent cut off', () => { completedByTurn: { u: { startedAt: 1, thinking: false, workedSeconds: 7 } } } - it.each(['hostRestarted', 'providerExited'] as const)( + it.each(['hostRestarted', 'providerExited', 'messageUnanswered'] as const)( 'keeps the %s row outside the settled turn it landed in', (kind) => { const slots = build( From 0a9a356851ae222be51a32eef0bc6a687fcd1a35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:17:20 -0700 Subject: [PATCH 28/49] fix(native-chat): say Claude stopped when its exit is settled after the turn already ended When a Claude child dies mid-reply, the adapter ends the running turn itself at the exit time it stamps, and the host decides whether a response was interrupted by matching that turn's end time to the exit it is settling. The Claude lifecycle mapping dropped that exit time, so the host fell back to its own later clock. Whenever the adapter's turn end reached the journal before the host took up the exit (routine on a loaded machine) the times never matched, the host concluded nothing had been interrupted, and the chat was left with a cut-off reply and no row saying Claude stopped. The mapping now carries the adapter's exit time, as the Codex lane already does. --- ...laude-structured-exit-mid-response.test.ts | 59 +++++++++++++++++++ .../structured-claude-runtime-adapter.ts | 2 + ...ed-claude-scripted-runtime-test-support.ts | 6 +- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts index fd30df7dad0e..a51744809f91 100644 --- a/src/main/runtime/claude-structured-exit-mid-response.test.ts +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -69,4 +69,63 @@ describe('a started Claude CLI that exits while a response is in progress', () = ]) ) }) + + // The CLI echoed the send and was streaming its reply when it was killed. The adapter ends the + // running turn itself, at the exit time it stamped; the host must read that turn as one this + // exit interrupted however late it takes the exit up. + it('says Claude stopped when the exit lands after the send was accepted and output streamed', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await send(host, 'write a story') + const child = claude.child(SESSION) + await vi.waitFor(() => expect(child.sent).toHaveLength(1)) + const providerSession = String(child.launch.options.sessionId) + child.handlers.onMessage?.({ ...child.sent[0], uuid: 'user-echo' }) + child.handlers.onMessage?.({ + type: 'stream_event', + session_id: providerSession, + uuid: 'assistant-partial', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Once upon' } } + }) + await host.flushStreamedEvents(SESSION) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.map((entry) => entry.dispatchState) + ).toEqual(['accepted']) + ) + + // As on a loaded machine: the host takes up the exit only after the adapter's own end of the + // turn has landed in the journal, and on a later millisecond than the one the adapter stamped. + let clock = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => (clock += 1)) + const handleAdapterEvent = host.handleAdapterEvent + vi.spyOn(host, 'handleAdapterEvent').mockImplementation(async (event) => { + await host.flushStreamedEvents(SESSION) + return handleAdapterEvent(event) + }) + child.exit(scriptedClaudeExitError('claude stream-json exited (code 137)')) + await waitForStructuredAgentSessionRecovery() + + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure ? [item.body.failure.kind] : [] + ) + ).toEqual(['providerExited']) + ) + + // The next send starts a fresh child; settling the dead one again adds no second row. + vi.mocked(host.handleAdapterEvent).mockRestore() + await send(host, 'carry on') + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) + await vi.waitFor(() => expect(claude.child(SESSION).sent).toHaveLength(1)) + await host.flushStreamedEvents(SESSION) + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.failure?.kind ?? item.body.text] : [] + ) + ).toEqual(['providerExited']) + }) }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4160b81c4dea..eeec32ad7c72 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -64,6 +64,8 @@ export function structuredClaudeLifecycleEvent( cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, + // The instant the adapter ended the running turn at; the host matches that end to this exit. + ...(event.observedAt !== undefined ? { observedAt: event.observedAt } : {}), ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) } } diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 07de5e58761a..5adf8699e6a6 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -53,6 +53,8 @@ export type ScriptedClaudeChild = { sessionId: string launch: ClaudeStreamJsonLaunch calls: string[] + /** Every frame the host wrote to the CLI's stdin, so a test can echo one as the CLI would. */ + sent: Record[] handlers: ClaudeStreamJsonConnectionHandlers connection: Omit & { closed: boolean @@ -109,6 +111,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { sessionId, launch, calls: [], + sent: [], handlers, exit: (error) => { child.connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } @@ -174,8 +177,9 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { interrupt: async () => undefined, cancelAsyncMessage: async () => false, stopTask: async () => {}, - send: async () => { + send: async (message) => { child.calls.push('send') + child.sent.push(message) }, close: async () => { child.connection.closed = true From b7c37f7f20ac7cafb08be23f3bb10c514123c1b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:51:06 -0700 Subject: [PATCH 29/49] revert(native-chat): drop the "didn't respond to your last message" row An expected close (tab close, quit, the idle sweep) or Codex going idle with a send still in doubt no longer writes a session row. Comparable chat tools write no notice for an expected stop with an unanswered message: the message stays an ordinary sent message and the next one is how the chat continues. The retirement of the send is unchanged; only the row, its failure kind and its wording go. This reverts commit 4002f3c851e and the test pin d6d00bf812e. --- ...d-agent-session-conversation-close.test.ts | 34 ----- ...session-dead-generation-settlement.test.ts | 107 ++------------ ...gent-session-dead-generation-settlement.ts | 37 +---- .../structured-agent-session-host-lifetime.ts | 4 - ...ession-unanswered-dispatch-release.test.ts | 138 +----------------- ...ent-session-unanswered-dispatch-release.ts | 23 +-- .../native-chat-transcript-slots.test.ts | 7 +- src/shared/agent-session-failure-words.ts | 2 - src/shared/agent-session-failure.ts | 3 - 9 files changed, 26 insertions(+), 329 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index da20ff939674..bf3cd1f96a60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -324,37 +324,3 @@ describe('the wind-down retry with a message queued (P2-31)', () => { expect(stopAgent).not.toHaveBeenCalled() }) }) - -describe('a send still in doubt when the sweep puts the agent to rest', () => { - it('leaves one row saying the agent did not respond, and no second on reopen', async () => { - rig.adapter.dispatch.mockResolvedValueOnce({ - state: 'unknown', - reason: 'provider_write_outcome_unknown: timeout' - }) - await foundRestTestChat(rig) - await vi.waitFor(() => - expect(openSession()?.journal.submissions()).toEqual([ - expect.objectContaining({ dispatchState: 'unknown' }) - ]) - ) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - const reopened = await rig.host.journalSnapshot(SESSION) - - expect(reopened.submissions).toEqual([ - expect.objectContaining({ dispatchState: 'unknown', recovered: true }) - ]) - expect( - reopened.items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - ).toEqual([ - { - kind: 'status', - tone: 'notice', - text: "Codex didn't respond to your last message. Send a message to continue.", - failure: { kind: 'messageUnanswered' } - } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 0965e7e9c749..4a0febb6ceb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -19,14 +19,6 @@ import { const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' -const UNANSWERED_OUTCOME = "Codex didn't respond to your last message. Send a message to continue." - -function statusRows() { - return journal - .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) -} - const SESSION = 'session-dead-generation' const THREAD = 'thread-1' let root: string @@ -93,7 +85,7 @@ async function seedUnfinishedWork(): Promise { } describe('dead structured-session generation settlement', () => { - it('settles probe-proven work as unverifiable without a technical row or fake end time', async () => { + it('settles probe-proven work as unverifiable without a technical chat row or fake end time', async () => { await seedUnfinishedWork() await expect( @@ -126,15 +118,7 @@ describe('dead structured-session generation settlement', () => { { kind: 'turn', turnId: 'turn-1', state: 'unverifiable', startedAt: 900 } ]) ) - // The send it retired in doubt gets the one plain row, never an exit reason. - expect(statusRows()).toEqual([ - { - kind: 'status', - tone: 'notice', - text: "The agent didn't respond to your last message. Send a message to continue.", - failure: { kind: 'messageUnanswered' } - } - ]) + expect(snapshot.items.some((item) => item.body.kind === 'status')).toBe(false) }) it('adds one actionable outcome for observed active-work failure and is idempotent', async () => { @@ -154,8 +138,14 @@ describe('dead structured-session generation settlement', () => { await expect(settleStructuredAgentSessionDeadGeneration(input)).resolves.toBe(true) expect(journal.cursor()).toEqual(settledCursor) - // The exit row alone explains the send it left in doubt: one row for one boundary. - expect(statusRows().map((row) => row.text)).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) + expect( + journal + .snapshot() + .items.filter( + (item) => + item.body.kind === 'status' && item.body.text === UNEXPECTED_PROVIDER_EXIT_OUTCOME + ) + ).toHaveLength(1) }) it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { @@ -304,79 +294,6 @@ describe('dead structured-session generation settlement', () => { ]) }) - it('says once that the agent did not respond when an expected close retires a send in doubt', async () => { - await journal.appendSubmission({ - clientMessageId: 'client-unknown', - payloadFingerprint: 'fingerprint', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'did this land?' }] }, - fence: 7 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-unknown', - state: 'unknown', - reason: 'provider write outcome unknown', - fence: 7 - }) - const close = () => - settleStructuredAgentSessionDeadGeneration({ - journal, - sessionId: SESSION, - fence: 7, - settlementId: `expected-close:${SESSION}:7:generation-1`, - pendingSubmissionReason: 'provider_closed_before_acknowledgement', - verdict: { state: 'interrupted', completedAt: 1_000 }, - showUnexpectedExitOutcome: false, - failureTextContext: { agentName: 'Codex' } - }) - - await expect(close()).resolves.toBe(true) - await expect(close()).resolves.toBe(true) - - expect(statusRows()).toEqual([ - { - kind: 'status', - tone: 'notice', - text: UNANSWERED_OUTCOME, - failure: { kind: 'messageUnanswered' } - } - ]) - }) - - it('writes no row for an expected close that left nothing in doubt', async () => { - await journal.appendSubmission({ - clientMessageId: 'client-answered', - payloadFingerprint: 'fingerprint', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - fence: 7 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-answered', - state: 'unknown', - reason: 'provider write outcome unknown', - fence: 7, - recovered: true - }) - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: 7 } - ) - - await expect( - settleStructuredAgentSessionDeadGeneration({ - journal, - sessionId: SESSION, - fence: 7, - settlementId: `expected-close:${SESSION}:7:generation-1`, - pendingSubmissionReason: 'provider_closed_before_acknowledgement', - verdict: { state: 'interrupted', completedAt: 1_000 }, - showUnexpectedExitOutcome: false - }) - ).resolves.toBe(true) - - expect(statusRows()).toEqual([]) - }) - it('rejects a send a child that never started left pending with its diagnostic, in words', async () => { await journal.appendSubmission({ clientMessageId: 'client-held', @@ -473,9 +390,7 @@ describe('dead structured-session generation settlement', () => { ['question', 2, undefined], ['turn', 2, undefined], ['tool-call', 2, 'thread-child'], - ['approval', 2, 'thread-child'], - // The seeded send's session row is Orca's, never the subagent's. - ['status', 1, undefined] + ['approval', 2, 'thread-child'] ]) await journal.close() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index bcd0de290aed..fc1f43a115ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -106,7 +106,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: SubmissionRejectionFact - /** Who the row's sentence names. */ + /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ @@ -126,12 +126,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - const sendsLeftInDoubt = startupFailure - ? await input.journal.rejectPendingSubmissions(input.fence, startupFailure).then(() => []) - : await input.journal.markPendingSubmissionsUnknown( - input.fence, - input.pendingSubmissionReason - ) + await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { @@ -160,9 +157,6 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ) } }) - } else if (sendsLeftInDoubt.length > 0) { - // An expected close: no exit row says why the send got no reply. - mutations.push(unansweredSendsRow(input.settlementId, input.failureTextContext)) } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -271,29 +265,6 @@ function staleSettlementRow( return null } -/** - * The row for sends a boundary retired in doubt when no exit or restart row covers them: an - * expected close, or the provider going idle without answering. Keyed by the boundary, so a - * repeated settlement revises its one row instead of adding another. - */ -export function unansweredSendsRow( - boundaryId: string, - failureTextContext?: AgentSessionFailureWordsContext -): JournalLifecycleMutationInput { - return { - kind: 'item', - identity: { provider: 'orca', clientMessageId: boundaryId }, - body: { - kind: 'status', - tone: 'notice', - ...agentSessionFailureWords(agentSessionFailureFact('messageUnanswered'), { - ...failureTextContext, - surface: 'row' - }) - } - } -} - function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { if (item.body.kind === 'tool-call' && item.body.state === 'running') { return { ...item.body, state: 'failed' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 549702e32234..c9ba8714b4ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -30,7 +30,6 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -139,9 +138,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, - failureTextContext: structuredAgentSessionFailureWordsContext( - context.deps.store.getRecord(sessionId) - ), onError: (id, error) => { settlementError = error context.deps.onEventSinkError?.({ sessionId: id, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index aa6c0ba299ea..9a27415dc711 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -1,8 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' @@ -26,42 +22,23 @@ function submission(over: Partial): AgentJournalSubmissi type ReleaseContext = Parameters[0] type ReleaseSession = NonNullable> type ResolveDispatchInput = Parameters[0] -type LifecycleBatchInput = Parameters[0] function contextWith(submissions: AgentJournalSubmission[]) { const resolved: ResolveDispatchInput[] = [] - const batches: LifecycleBatchInput[] = [] - const writes: string[] = [] const journal = { submissions: () => submissions, resolveDispatch: vi.fn(async (input: ResolveDispatchInput) => { resolved.push(input) - writes.push(`dispatch:${input.clientMessageId}`) - return { epoch: 'e', sequence: 1 } - }), - appendLifecycleBatch: vi.fn(async (input: LifecycleBatchInput) => { - batches.push(input) - writes.push(`batch:${input.settlementId}`) return { epoch: 'e', sequence: 1 } }) } - // The mutation reads only the journal calls above, the record fence and the record's agent. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field the mutation reads is supplied here; the rest of the session and the record is unreachable from it. + // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and the record fence. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the session and the record is unreachable from this mutation. const context = { sessions: new Map([['s-1', { journal }]]), - deps: { - store: { getRecord: () => ({ provider: 'codex', lease: { runtimeFence: FENCE } }) } - } + deps: { store: { getRecord: () => ({ lease: { runtimeFence: FENCE } }) } } } as unknown as ReleaseContext - return { context, resolved, batches, writes, journal } -} - -function rowsOf(batches: LifecycleBatchInput[]) { - return batches.flatMap((batch) => - batch.mutations.flatMap((mutation) => - mutation.kind === 'item' ? [{ identity: mutation.identity, body: mutation.body }] : [] - ) - ) + return { context, resolved, journal } } describe('releasing dispatches the provider can no longer answer', () => { @@ -91,107 +68,8 @@ describe('releasing dispatches the provider can no longer answer', () => { ) }) - it('says once, in a session row, that the agent did not respond, before retiring the sends', async () => { - const { context, batches, writes } = contextWith([ - submission({}), - submission({ clientMessageId: 'm-2' }) - ]) - - await releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - expect(rowsOf(batches)).toEqual([ - { - identity: { provider: 'orca', clientMessageId: 'provider-idle:s-1:m-1' }, - body: { - kind: 'status', - tone: 'notice', - text: "Codex didn't respond to your last message. Send a message to continue.", - failure: { kind: 'messageUnanswered' } - } - } - ]) - // Row first: a retry after a failed retire revises this row instead of leaving none. - expect(writes).toEqual(['batch:provider-idle:s-1:m-1', 'dispatch:m-1', 'dispatch:m-2']) - }) - - it('keeps one row when a retire fails and the next idle retries it', async () => { - const live = [submission({})] - const { context, batches, journal } = contextWith(live) - journal.resolveDispatch.mockRejectedValueOnce(new Error('journal write failed')) - const release = () => - releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - await expect(release()).rejects.toThrow('journal write failed') - await release() - - const ids = rowsOf(batches).map((row) => row.identity) - expect(ids).toHaveLength(2) - // The same identity revises the one row in place. - expect(new Set(ids.map((id) => JSON.stringify(id))).size).toBe(1) - }) - - it('leaves one row in the journal however often the provider goes idle', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-idle-release-')) - const journal = await openAgentSessionJournal({ - identity: { - sessionId: 's-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - journalDir: root, - now: () => 1_000 - }) - try { - await journal.appendSubmission({ - clientMessageId: 'm-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: FENCE - }) - await journal.resolveDispatch({ - clientMessageId: 'm-1', - state: 'unknown', - reason: 'turn start timed out', - fence: FENCE - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mutation reads only the session's journal and the record's fence and agent, all supplied. - const context = { - sessions: new Map([['s-1', { journal }]]), - deps: { - store: { getRecord: () => ({ provider: 'codex', lease: { runtimeFence: FENCE } }) } - } - } as unknown as ReleaseContext - const release = () => - releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - await release() - await release() - - expect( - journal.snapshot().items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - ).toEqual([expect.objectContaining({ failure: { kind: 'messageUnanswered' } })]) - expect(journal.submissions()).toEqual([ - expect.objectContaining({ dispatchState: 'unknown', recovered: true }) - ]) - } finally { - await journal.close() - await rm(root, { recursive: true, force: true }) - } - }) - it('never touches a pending send, whose dispatch may still be in flight', async () => { - const { context, resolved, batches } = contextWith([ + const { context, resolved } = contextWith([ submission({ clientMessageId: 'm-2', dispatchState: 'pending', reason: null }) ]) @@ -201,11 +79,10 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) - expect(batches).toEqual([]) }) - it('leaves an already recovered unknown alone, writing no second row', async () => { - const { context, resolved, batches } = contextWith([submission({ recovered: true })]) + it('leaves an already recovered unknown alone', async () => { + const { context, resolved } = contextWith([submission({ recovered: true })]) await releaseStructuredAgentSessionUnansweredDispatches(context, { sessionId: 's-1', @@ -213,7 +90,6 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) - expect(batches).toEqual([]) }) it('falls back to the supplied reason when the submission named none', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts index f185b472d804..713838c177ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -1,6 +1,4 @@ -import { unansweredSendsRow } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' @@ -15,7 +13,6 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s * `pending` is deliberately untouched: that send's dispatch has not returned yet * and may be in flight right now. And `recovered` only retires the obligation — * it never makes a send re-deliverable, because the provider may well have run it. - * The chat says so once, in the row every retired doubt without an exit or restart row gets. */ export async function releaseStructuredAgentSessionUnansweredDispatches( context: Pick & { @@ -30,32 +27,16 @@ export async function releaseStructuredAgentSessionUnansweredDispatches( const stranded = session.journal .submissions() .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) - const [first] = stranded - if (!first) { + if (stranded.length === 0) { return } - const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) - // Written before the sends retire and keyed by the oldest, so a retry after a failed retire - // revises this row rather than leaving the doubt silent or adding a second. - const boundaryId = `provider-idle:${input.sessionId}:${first.clientMessageId}` - await session.journal.appendLifecycleBatch({ - settlementId: boundaryId, - fence, - recovered: true, - mutations: [ - unansweredSendsRow( - boundaryId, - structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(input.sessionId)) - ) - ] - }) for (const entry of stranded) { await session.journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'unknown', // The earlier reason names a sharper fact than this one does. reason: entry.reason ?? input.reason, - fence, + fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), recovered: true }) } diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 46e95e95a829..15bd0533400d 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -212,10 +212,7 @@ describe('a send the host rejected', () => { describe('a turn a crash or a dead agent cut off', () => { // The row the host writes at the boundary, as the journal projection draws it. - function statusRow( - id: string, - kind: 'hostRestarted' | 'providerExited' | 'messageUnanswered' | 'providerRetrying' - ) { + function statusRow(id: string, kind: 'hostRestarted' | 'providerExited' | 'providerRetrying') { const words = agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'row', agentName: 'Claude' @@ -233,7 +230,7 @@ describe('a turn a crash or a dead agent cut off', () => { completedByTurn: { u: { startedAt: 1, thinking: false, workedSeconds: 7 } } } - it.each(['hostRestarted', 'providerExited', 'messageUnanswered'] as const)( + it.each(['hostRestarted', 'providerExited'] as const)( 'keeps the %s row outside the settled turn it landed in', (kind) => { const slots = build( diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index a6b1a34197a3..1c950de7956f 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -208,8 +208,6 @@ const FAILURE_SENTENCES = { compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', cancelUnconfirmed: () => 'Cancellation was not confirmed.', answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', - messageUnanswered: ({ agentName }) => - `${agentName ?? 'The agent'} didn't respond to your last message. Send a message to continue.`, hostFault: ({ retryControl }) => `Orca ran into a problem, so this didn't go through.${retryControl ? '' : ' Try again.'}`, hostStopped: ({ agentName }) => diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b5e4b08cbf4f..ed8b677b359e 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -38,8 +38,6 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'compactionUnconfirmed', 'cancelUnconfirmed', 'answerUnconfirmed', - /** A send left in doubt was retired with no reply, and no exit or restart row says why. */ - 'messageUnanswered', 'hostFault', /** Orca stopped an agent whose start never finished. */ 'hostStopped', @@ -58,7 +56,6 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'compactionUnconfirmed', 'cancelUnconfirmed', 'answerUnconfirmed', - 'messageUnanswered', 'providerRetrying' ] as const satisfies readonly AgentSessionFailureKind[] From 1c50e6e2caad0b8146b06215e912e686af90e722 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:51:13 -0700 Subject: [PATCH 30/49] fix(native-chat): keep an agent running while a send it took is unanswered The idle sweep stopped a quiet agent even when the last message was still waiting on it: admitted but not yet echoed, or handed over with an outcome Orca could not confirm. Stopping the child then retired that message in doubt. The sweep now counts the same unanswered-send rule that makes a chat read as working, so it keeps the agent until the provider answers, refuses or reports idle, or the child exits. Comparable agent runtimes refuse to idle-release a session with a pending turn start or active turn for the same reason. --- ...tructured-agent-session-idle-sweep.test.ts | 42 +++++++++++++++++++ .../structured-agent-session-idle-sweep.ts | 22 +++++++--- ...red-agent-session-surface-lifetime.test.ts | 15 +++++-- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index ebc2009fa7db..5e38b99bab5a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -194,6 +194,48 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() }) + // Stopping the child would retire the send in doubt; the provider's idle report settles it. + it('never stops an agent while a send it took is still in doubt, and stops it once settled', async () => { + rig.adapter.dispatch.mockResolvedValueOnce({ + state: 'unknown', + reason: 'provider_write_outcome_unknown: timeout' + }) + await foundRestTestChat(rig) + await vi.waitFor(async () => + expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([ + expect.objectContaining({ dispatchState: 'unknown' }) + ]) + ) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + await rig.host.releaseUnansweredDispatches({ + sessionId: SESSION, + reason: 'provider_idle_before_acknowledgement' + }) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + // Settled without a row: the send is drawn as sent, and nothing explains the idle agent. + const settled = await rig.host.journalSnapshot(SESSION) + expect(settled.submissions).toEqual([ + expect.objectContaining({ dispatchState: 'unknown', recovered: true }) + ]) + expect(settled.items.some((item) => item.body.kind === 'status')).toBe(false) + }) + + it('never stops an agent a handed-over send still waits on to open its turn', async () => { + rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + await foundRestTestChat(rig) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([ + expect.objectContaining({ dispatchState: 'pending' }) + ]) + }) + it('stops an agent whose chat is still open on screen (P2-12)', async () => { await foundRestTestChat(rig) const reader = collectSubscriber() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2394b604171c..ea764b134ecf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -9,8 +9,8 @@ // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { owesStructuredAgentSessionWork } from '../../../shared/structured-agent-session-owed-work' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -110,7 +110,11 @@ export class StructuredAgentSessionIdleSweep { } // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child // can read done before the lead's wake-up turn writes anything. - if (session.child && session.child.phase !== 'starting' && this.owesWork(sessionId, session)) { + if ( + session.child && + session.child.phase !== 'starting' && + this.owesWork(sessionId, session, session.child.fence) + ) { this.deps.sessions.touch(sessionId) return } @@ -137,12 +141,18 @@ export class StructuredAgentSessionIdleSweep { } /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the - * handle, and a leftover prompt or turn row is only history. */ - private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { + * handle, and a leftover prompt or turn row is only history. A send the child has neither + * answered nor refused — pending, or a live `unknown` — is owed too: stopping the child would + * retire it in doubt. The provider's answer, refusal or idle report settles it, as does an exit. */ + private owesWork( + sessionId: string, + session: StructuredAgentSessionHostSession, + childFence: number + ): boolean { const items = session.journal.snapshot().items return ( - activeStructuredAgentSessionTurnId(items) !== null || - this.queuedOrDelivering(sessionId, session) || + owesStructuredAgentSessionWork(items, session.journal.submissions(), childFence) || + this.deps.deliveryActive(sessionId) || agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || this.deps.hasOpenDispatch(sessionId) || hasPendingStructuredAgentSessionPrompt(items) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 98e50783bdf3..4b2b37b899bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -378,14 +378,23 @@ describe('a session with a turn in flight', () => { await waitForEviction() }) - // Codex settles an admitted send only on its echo, which may never come; the stop retires it. - it('is stopped with an admitted send outstanding once no turn runs', async () => { + // Stopping the child would retire an admitted send in doubt; its echo settles it first. + it('is not stopped while an admitted send awaits its echo, and is once the echo lands', async () => { await attach() - await sendPending('admitted, never echoed') + await sendPending('admitted, not yet echoed') await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) clock += IDLE_MS + await waitOutSeveralSweeps() + expect(closeSession).not.toHaveBeenCalled() + const [sent] = (await host.journalSnapshot(SESSION)).submissions + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: sent!.clientMessageId, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + clock += IDLE_MS await waitForEviction() }) }) From 03e44d239a6012f3605e3392af93bf4b952a9df8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:53:48 -0700 Subject: [PATCH 31/49] fix(native-chat): keep a failure row in view only when the turn ended in it A failure row stayed outside the collapsed "Worked for" turn wherever it landed, so a step that failed and was then answered past (a failed compaction, an unconfirmed Stop) stood in the transcript after the agent had already moved on. A failure row now stays in view only when it is how the turn ended: no answer from the session's own agent comes after it. A crash or stopped-agent row after a partial answer is still the turn's last row and stays visible; a failure the agent answered past folds with the rest of the work. Rows before any prompt were already outside every turn. This is how comparable chat timelines place errors: the row that ends a turn stays, earlier ones fold with the work. --- .../native-chat-transcript-slots.test.ts | 13 +++++++ .../native-chat-transcript-slots.ts | 8 ++--- src/shared/native-chat-turn-fold.test.ts | 34 +++++++++++++++++++ src/shared/native-chat-turn-fold.ts | 25 +++++++++----- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 15bd0533400d..2bddbbf93809 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -252,6 +252,19 @@ describe('a turn a crash or a dead agent cut off', () => { } ) + it('folds a failure the agent answered past in the same turn', () => { + const slots = build( + [ + text('u', 'go', 'user'), + statusRow('failed-step', 'providerExited'), + toolRun('work'), + text('a', 'Done after all.') + ], + { turnStatuses: settled } + ) + expect(slots.map((slot) => slot.message.id)).toEqual(['u', 'a']) + }) + it('still folds a provider retry, which is the turn working', () => { const slots = build( [text('u', 'go', 'user'), statusRow('retry', 'providerRetrying'), text('a', 'Done.')], diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index f492a9cfd49f..aa02c735df36 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -107,10 +107,10 @@ export function buildNativeChatTranscriptSlots( rendersProse: content.markdown.length > 0 || content.hasImages, // The raw blocks, not the renderable ones: a childless roster draws no row // and its plain-text twin is then the only record the spawn happened. - outlivesTurn: - message.blocks.some( - (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) - ) || nativeChatRowReportsFailure(message.blocks), + outlivesTurn: message.blocks.some( + (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) + ), + reportsFailure: nativeChatRowReportsFailure(message.blocks), ...(agentId === null ? {} : { agentId }) } }) diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index d6bba89e552c..9c00bbd1f3fd 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -11,6 +11,7 @@ function row(overrides: Partial = {}): NativeChatTurnFold role: 'assistant', rendersProse: true, outlivesTurn: false, + reportsFailure: false, ...overrides } } @@ -98,6 +99,39 @@ describe('nativeChatTurnFold', () => { expect(foldedRows.has(1)).toBe(true) }) + describe('a failure row', () => { + const failure = (overrides: Partial = {}) => + row({ role: 'system', rendersProse: false, reportsFailure: true, ...overrides }) + const fold = (rows: NativeChatTurnFoldRow[]) => + nativeChatTurnFold({ rows, settledTurnKeys: SETTLED, expandedTurnKeys: NONE }).foldedRows + + it('stays in view when the turn ended in it', () => { + expect([...fold([row({ role: 'user' }), row({ rendersProse: false }), failure()])]).toEqual([ + 1 + ]) + }) + + it('stays in view after a partial answer, as the last row of its turn', () => { + const folded = fold([row({ role: 'user' }), row(), row(), failure()]) + expect([...folded]).toEqual([1]) + }) + + it('folds with the work when the agent answered after it', () => { + const folded = fold([row({ role: 'user' }), failure(), row({ rendersProse: false }), row()]) + expect([...folded].sort()).toEqual([1, 2]) + }) + + it("stays in view when only a subagent spoke after it, which is not the turn's answer", () => { + const folded = fold([row({ role: 'user' }), row(), failure(), row({ agentId: 'task-1' })]) + expect(folded.has(2)).toBe(false) + expect(folded.has(3)).toBe(true) + }) + + it('stays in view when it is tied to no turn', () => { + expect(fold([failure({ turnKey: undefined }), row({ role: 'user' }), row()]).size).toBe(0) + }) + }) + it('folds a prose-less turn whole, so its commands do not return to the transcript', () => { const rows = [row({ role: 'user' }), row({ rendersProse: false }), row({ rendersProse: false })] const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({ diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 0b6b7747b4a8..f1e12fd5a76c 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -21,18 +21,20 @@ export type NativeChatTurnFoldRow = { role: NativeChatRole /** Whether the row draws prose — the only thing that can be an answer. */ rendersProse: boolean - /** Whether the row outlives the turn that started it: a spawn roster, a - * background task, or a reported failure (`nativeChatRowReportsFailure`). - * That row is the durable report of how the work ended — often the only - * one — so it never folds. */ + /** Whether the row carries work that outlives the turn that started it: a + * spawn roster or a background task. That row is the durable report of how + * the work ended — often the only one — so it never folds. */ outlivesTurn: boolean + /** Whether the row reports a failure (`nativeChatRowReportsFailure`). It stays + * out of the fold only while nothing the agent answered comes after it. */ + reportsFailure: boolean /** The subagent that produced the row. Absent ⇒ the session's own agent. */ agentId?: string } -/** A failure row — the agent stopped, the session did not survive a restart — says how the turn - * or the chat ended and what to do next, so it is never the turn's hidden work. A provider retry - * is not a failure yet. */ +/** A failure row — the agent stopped, the session did not survive a restart. When it is how the + * turn ended it says what to do next; one the agent answered past was only on the way. A + * provider retry is not a failure yet. */ export function nativeChatRowReportsFailure(blocks: readonly NativeChatBlock[]): boolean { return blocks.some( (block) => @@ -100,7 +102,7 @@ export function nativeChatTurnFold({ for (const [index, row] of rows.entries()) { const { turnKey } = row // Outside the fold by construction: the reader's own message anchors the - // turn, and a roster, background-task or failure row outlives it. + // turn, and a roster or background-task row outlives it. if ( turnKey === undefined || row.role === 'user' || @@ -109,10 +111,15 @@ export function nativeChatTurnFold({ ) { continue } + const answer = answers.get(turnKey) // A turn that produced no prose folds whole: its status row is the anchor, // so there is still something on screen to open. Keeping such a turn // unfolded instead would put every command it ran back in the transcript. - if (index === answers.get(turnKey)) { + if (index === answer) { + continue + } + // A failure with no answer after it is how the turn ended, so it stays in view. + if (row.reportsFailure && (answer === undefined || index > answer)) { continue } foldableTurnKeys.add(turnKey) From c309dfc6f2a09aef6fdfc2bd0af7ae70008fa7ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:09:29 -0700 Subject: [PATCH 32/49] feat(native-chat): draw a crash-doubted send as sent on released clients too A released client drew a send that a crash or a dead agent left in doubt for good (`unknown` with `recovered`) as "Message delivery is unconfirmed", with a Retry that held its queue. It reads only `accepted` as sent: that state drops its outbox copy, and mobile clears its saved send record on it. Clients from this version advertise `agent-session.recovered-send.v1`. The host publishes such a submission as `accepted` to any client that does not, at the RPC boundary only (history, the live stream, and a replayed send's reply), the same per-client downgrade the turn item already uses. The journal and every host reader keep the real facts; nothing is re-sent either way. History and the stream now compose their client projections in one module so they cannot disagree. Stub history pages in the RPC and cross-version fixtures gain the `submissions` list every real page carries, and the cross-version suite pairs an old client (the baseline's list minus the capability) against the current host. --- docs/reference/remote-wire-compatibility.md | 8 + src/cli/runtime/websocket-transport.test.ts | 2 + ...ktop-renderer-runtime-capabilities.test.ts | 4 +- .../desktop-renderer-runtime-capabilities.ts | 2 + ...session-background-task-capability.test.ts | 14 +- ...uctured-agent-session-client-projection.ts | 42 ++++ ...-session-recovered-send-capability.test.ts | 218 ++++++++++++++++++ ...agent-session-recovered-send-capability.ts | 92 ++++++++ ...ructured-agent-session-rpc.test-fixture.ts | 2 +- ...ctured-agent-session-send-compatibility.ts | 10 +- .../rpc/methods/structured-agent-session.ts | 16 +- .../src/web/web-runtime-client.test.ts | 2 + .../web-runtime-connection-frame-router.ts | 2 + src/shared/protocol-version.ts | 6 + .../remote-runtime-client-capabilities.ts | 2 + ...ss-version-agent-session-wire.unit.test.ts | 21 ++ .../structured-agent-session-host-fixture.ts | 44 +++- 17 files changed, 468 insertions(+), 19 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-client-projection.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index b2bcb3c40f4e..c762f2461680 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -245,6 +245,14 @@ The cross-version suite derives the old client's list by removing this capabilit baseline's own list, per the rule above, so the downgrade stays exercised after a release ships it. +The same boundary carries a second downgrade, `agent-session.recovered-send.v1` +(`structured-agent-session-recovered-send-capability.ts`). A send a crash or a dead agent left in +doubt for good (`unknown` with `recovered`) is drawn as an ordinary sent message; a client that +predates that reading drew it unconfirmed, with a Retry that held its queue. Such a client is +published that submission as `accepted`, the one state it already draws as sent, on history, the +live stream and a replayed send's reply. The journal keeps the real facts. Both compositions live in +`structured-agent-session-client-projection.ts`, so history and the stream cannot disagree. + ## Known debt: JSON-RPC errors drop Node's string code An error raised on an SSH host crosses the relay as JSON-RPC, and diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index e6fc34699ff5..a7b327378ed8 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -19,6 +19,7 @@ import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -76,6 +77,7 @@ describe('CLI remote WebSocket transport', () => { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 68f6e97e6323..72bc77cedbf4 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -11,6 +11,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, @@ -51,12 +52,13 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all - * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a + * six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a * statement that no remote client wants them. Why it is one is not recorded here. */ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 0bcfd1bd686d..cb94b7b37ee5 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -28,6 +29,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index 8a9e2267a5a8..56fce70f95b9 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -54,7 +54,10 @@ describe('background-task stop capability at the RPC boundary', () => { ['current reader', CURRENT_CLIENT, TASKS], ['in-process reader', undefined, TASKS] ] as const)('projects history for a %s', async (_label, client, expected) => { - hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: TASKS } }) + hostCalls.history.mockReturnValue({ + ok: true, + page: { items: [], submissions: [], backgroundTasks: TASKS } + }) expect( await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) ).toMatchObject({ ok: true, result: { page: { backgroundTasks: expected } } }) @@ -131,7 +134,7 @@ describe('background-task stop capability at the RPC boundary', () => { ] as const)('projects unstoppable rows for a %s', async (_label, client, expected) => { hostCalls.history.mockReturnValue({ ok: true, - page: { items: [], backgroundTasks: MIXED_ROWS } + page: { items: [], submissions: [], backgroundTasks: MIXED_ROWS } }) expect( await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client()) @@ -147,7 +150,7 @@ describe('background-task stop capability at the RPC boundary', () => { } hostCalls.history.mockReturnValue({ ok: true, - page: { items: [], backgroundTasks: foregroundOnly } + page: { items: [], submissions: [], backgroundTasks: foregroundOnly } }) expect( await call( @@ -163,7 +166,10 @@ describe('background-task stop capability at the RPC boundary', () => { it('preserves legacy stoppable state for both readers', async () => { const stoppable = { state: 'monitoring', tasks: TASKS.tasks } - hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: stoppable } }) + hostCalls.history.mockReturnValue({ + ok: true, + page: { items: [], submissions: [], backgroundTasks: stoppable } + }) for (const client of [STRUCTURED_CLIENT, CURRENT_CLIENT]) { expect( await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-client-projection.ts b/src/main/runtime/rpc/methods/structured-agent-session-client-projection.ts new file mode 100644 index 000000000000..034380221121 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-client-projection.ts @@ -0,0 +1,42 @@ +// What each negotiated client is published, composed once for history and the live stream so the +// two can never disagree. Each projection is a no-op for a client that advertises its capability. + +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import type { RpcContext } from '../core' +import { + projectBackgroundTaskEvent, + projectBackgroundTaskHistory +} from './structured-agent-session-background-task-capability' +import { + projectRecoveredSendEvent, + projectRecoveredSendHistory +} from './structured-agent-session-recovered-send-capability' +import { + projectTurnItemEvent, + projectTurnItemHistory +} from './structured-agent-session-turn-item-capability' + +type ClientReader = Pick + +export function projectStructuredAgentSessionHistoryForClient( + result: AgentSessionHistoryResult, + ctx: ClientReader +): AgentSessionHistoryResult { + return projectRecoveredSendHistory( + projectTurnItemHistory(projectBackgroundTaskHistory(result, ctx), ctx), + ctx + ) +} + +export function projectStructuredAgentSessionEventForClient( + event: AgentSessionSubscribeEvent, + ctx: ClientReader +): AgentSessionSubscribeEvent { + return projectRecoveredSendEvent( + projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx), + ctx + ) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts new file mode 100644 index 000000000000..ddf40d5f310a --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_RECOVERED_SEND_CAPABILITY } from '../../../../shared/protocol-version' +import { structuredAgentSessionSubmissionSettlement } from '../../../../shared/structured-agent-session-submission-settlement' +import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { + call, + clearStructuredHostStub, + FINGERPRINT, + hostCalls, + installStructuredHostStub, + OPERATION, + sendParams, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' +import { + projectRecoveredSendEvent, + projectRecoveredSendHistory, + projectRecoveredSendResult +} from './structured-agent-session-recovered-send-capability' + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +function submission(overrides: Partial = {}): AgentJournalSubmission { + return { + clientMessageId: OPERATION, + fence: 1, + payloadFingerprint: FINGERPRINT, + dispatchState: 'unknown', + providerItemId: null, + reason: 'host_restarted_before_acknowledgement', + submittedAt: 1, + resolvedAt: 2, + recovered: true, + handoverRecorded: true, + handedOverAt: 1, + ...overrides + } +} + +// A crash left this send in doubt for good: the host's own record of it. +const RECOVERED = Object.freeze(submission()) +// The only shape a client that predates the capability draws as an ordinary sent message. +const AS_ACCEPTED: AgentJournalSubmission = { + clientMessageId: OPERATION, + fence: 1, + payloadFingerprint: FINGERPRINT, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + handoverRecorded: true, + handedOverAt: 1 +} +const CURRENT_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_CLIENT.clientCapabilities, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY + ] +} + +function page(submissions: AgentJournalSubmission[]): AgentSessionHistoryPage { + return { + sessionId: SESSION, + epoch: 'a', + direction: 'tail', + items: [], + removedItemIds: [], + submissions, + window: { oldest: null, newest: null, nextCursor: { epoch: 'a', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } +} + +describe('a recovered send at the RPC boundary', () => { + it.each([ + ['older reader', STRUCTURED_CLIENT, AS_ACCEPTED], + ['current reader', CURRENT_CLIENT, RECOVERED], + ['in-process reader', undefined, RECOVERED] + ] as const)('reaches the %s in the shape it draws as sent', async (_label, client, expected) => { + hostCalls.history.mockReturnValue({ ok: true, page: page([RECOVERED]) }) + const reply = await call( + 'agentSession.history', + { sessionId: SESSION, direction: 'tail' }, + client + ) + // Exact, not a subset: a projected send must not keep `recovered`. + expect(reply).toEqual( + expect.objectContaining({ + result: expect.objectContaining({ + page: expect.objectContaining({ submissions: [expected] }) + }) + }) + ) + }) + + it.each(['snapshot', 'batch', 'reset'] as const)( + 'projects the %s stream only for an older reader', + async (type) => { + hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { + const base = { sessionId: SESSION, fence: 1 } + if (type === 'batch') { + input.emit({ + ...base, + type, + batch: { + cursor: { epoch: 'a', sequence: 2 }, + items: [], + removedItemIds: [], + submissions: [RECOVERED] + } + }) + } else { + input.emit( + type === 'snapshot' + ? { ...base, type, page: page([RECOVERED]) } + : { ...base, type, page: page([RECOVERED]), reset: 'epoch_changed' } + ) + } + return () => {} + }) + for (const [client, expected] of [ + [STRUCTURED_CLIENT, AS_ACCEPTED], + [CURRENT_CLIENT, RECOVERED] + ] as const) { + const reply = await call('agentSession.subscribe', { sessionId: SESSION }, client) + const event = (reply.ok ? reply.result : null) as AgentSessionSubscribeEvent + const submissions = + event.type === 'batch' + ? event.batch.submissions + : 'page' in event + ? event.page.submissions + : [] + expect(submissions).toEqual([expected]) + } + } + ) + + it('answers a replayed send of a recovered message as accepted to an older reader', async () => { + hostCalls.send.mockResolvedValue({ + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { clientMessageId: OPERATION, submission: RECOVERED } + }) + for (const [client, expected] of [ + [STRUCTURED_CLIENT, AS_ACCEPTED], + [CURRENT_CLIENT, RECOVERED] + ] as const) { + expect(await call('agentSession.send', sendParams(), client)).toEqual( + expect.objectContaining({ + result: expect.objectContaining({ + value: { clientMessageId: OPERATION, submission: expected } + }) + }) + ) + } + }) +}) + +describe('recovered send projection', () => { + it('reads as sent on both sides of the capability', () => { + expect(structuredAgentSessionSubmissionSettlement(RECOVERED)).toBe('sent') + expect(structuredAgentSessionSubmissionSettlement(AS_ACCEPTED)).toBe('sent') + }) + + it('never touches the host record it projects', () => { + const history: AgentSessionHistoryResult = { ok: true, page: page([RECOVERED]) } + const projected = projectRecoveredSendHistory(history, STRUCTURED_CLIENT) + expect(projected.page.submissions).toEqual([AS_ACCEPTED]) + expect(history.page.submissions[0]).toBe(RECOVERED) + expect(RECOVERED).toMatchObject({ dispatchState: 'unknown', recovered: true }) + }) + + it.each([ + ['a live unknown, which can still be answered', submission({ recovered: undefined })], + ['a pending send', submission({ dispatchState: 'pending', recovered: undefined })], + ['a refused send', submission({ dispatchState: 'rejected', reason: 'queue full' })], + ['an accepted send', submission({ dispatchState: 'accepted', recovered: undefined })] + ])('leaves %s as the host wrote it', (_label, entry) => { + const history: AgentSessionHistoryResult = { ok: true, page: page([entry]) } + expect(projectRecoveredSendHistory(history, STRUCTURED_CLIENT)).toBe(history) + }) + + it.each([ + ['capable client', CURRENT_CLIENT], + ['in-process caller', {}] + ] as const)('hands a %s the same object back', (_label, ctx) => { + const history: AgentSessionHistoryResult = { ok: true, page: page([RECOVERED]) } + const snapshot: AgentSessionSubscribeEvent = { + type: 'snapshot', + sessionId: SESSION, + fence: 1, + page: page([RECOVERED]) + } + const sent = { + ok: true as const, + replayed: true, + fence: 1, + cursor: { epoch: 'a', sequence: 1 }, + value: { clientMessageId: OPERATION, submission: RECOVERED } + } + expect(projectRecoveredSendHistory(history, ctx)).toBe(history) + expect(projectRecoveredSendEvent(snapshot, ctx)).toBe(snapshot) + expect(projectRecoveredSendResult(sent, ctx)).toBe(sent) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts new file mode 100644 index 000000000000..fb4c426acfe5 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts @@ -0,0 +1,92 @@ +// Transitional: remove once no supported release lacks AGENT_SESSION_RECOVERED_SEND_CAPABILITY. +// +// A send a crash or a dead agent left in doubt for good (`unknown`, `recovered`) is drawn as an +// ordinary sent message. A client that predates that reading draws it unconfirmed, with a Retry +// that holds its queue, so the host publishes it to that client as `accepted` — the one state it +// already draws as sent — at the RPC boundary only. The journal and every host reader keep the +// real facts, and the host never re-sends a recovered send whichever way it is published. + +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionMutationResult, + AgentSessionSendResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_RECOVERED_SEND_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' + +type RecoveredSendReader = Pick + +function readsRecoveredSends(ctx: RecoveredSendReader): boolean { + // An in-process caller is this build; only a negotiated client can predate the reading. + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_RECOVERED_SEND_CAPABILITY) === true + ) +} + +function projectSubmission(submission: AgentJournalSubmission): AgentJournalSubmission { + if (submission.dispatchState !== 'unknown' || submission.recovered !== true) { + return submission + } + const { recovered: _recovered, ...rest } = submission + return { ...rest, dispatchState: 'accepted', reason: null } +} + +function projectSubmissions(submissions: AgentJournalSubmission[]): AgentJournalSubmission[] { + return submissions.some((submission) => projectSubmission(submission) !== submission) + ? submissions.map(projectSubmission) + : submissions +} + +function projectPage(page: AgentSessionHistoryPage): AgentSessionHistoryPage { + const submissions = projectSubmissions(page.submissions) + return submissions === page.submissions ? page : { ...page, submissions } +} + +export function projectRecoveredSendHistory( + result: AgentSessionHistoryResult, + ctx: RecoveredSendReader +): AgentSessionHistoryResult { + if (readsRecoveredSends(ctx)) { + return result + } + const page = projectPage(result.page) + return page === result.page ? result : { ...result, page } +} + +export function projectRecoveredSendEvent( + event: AgentSessionSubscribeEvent, + ctx: RecoveredSendReader +): AgentSessionSubscribeEvent { + if (readsRecoveredSends(ctx)) { + return event + } + if (event.type === 'batch') { + const submissions = projectSubmissions(event.batch.submissions) + return submissions === event.batch.submissions + ? event + : { ...event, batch: { ...event.batch, submissions } } + } + if (event.type === 'snapshot' || event.type === 'reset') { + const page = projectPage(event.page) + return page === event.page ? event : { ...event, page } + } + return event +} + +/** A replayed send answers with its recorded submission, which may be a recovered one. */ +export function projectRecoveredSendResult( + result: AgentSessionMutationResult, + ctx: RecoveredSendReader +): AgentSessionMutationResult { + if (readsRecoveredSends(ctx) || !result.ok) { + return result + } + const submission = projectSubmission(result.value.submission) + return submission === result.value.submission + ? result + : { ...result, value: { ...result.value, submission } } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 9a0f3902496b..38940eb0c9df 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -190,7 +190,7 @@ export function hostStub(): StructuredAgentSessionHost { models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], current: { model: 'gpt-live' } })), - history: vi.fn(() => ({ ok: true, page: { items: [] } })), + history: vi.fn(() => ({ ok: true, page: { items: [], submissions: [] } })), journalSnapshot: vi.fn((sessionId: string) => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 5fde5e0f6e49..ba7e3f99571a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -5,14 +5,22 @@ import { import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' +import { projectRecoveredSendResult } from './structured-agent-session-recovered-send-capability' import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +export async function sendStructuredAgentSessionForClient( + params: Parameters[1], + context: RpcContext +) { + return projectRecoveredSendResult(await sendHeldForClient(params, context), context) +} + /** * A send answers once the host accepts it. A client that predates that answer cannot show a * message rejected after it, so its reply is held until the message is handed over or rejected; * one that predates pending replies at all waits, as before, for the provider's answer. */ -export async function sendStructuredAgentSessionForClient( +async function sendHeldForClient( params: Parameters[1], context: RpcContext ) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index dc01a2fee8f1..3a7e7ce95518 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -9,13 +9,9 @@ import { agentSessionRefusalError } from '../../../../shared/agent-session-wire- import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' import { - projectBackgroundTaskEvent, - projectBackgroundTaskHistory -} from './structured-agent-session-background-task-capability' -import { - projectTurnItemEvent, - projectTurnItemHistory -} from './structured-agent-session-turn-item-capability' + projectStructuredAgentSessionEventForClient, + projectStructuredAgentSessionHistoryForClient +} from './structured-agent-session-client-projection' import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, @@ -255,8 +251,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.history', params: HistoryParams, handler: async (params, ctx) => - projectTurnItemHistory( - projectBackgroundTaskHistory(await (await requireInstalledHost(ctx)).history(params), ctx), + projectStructuredAgentSessionHistoryForClient( + await (await requireInstalledHost(ctx)).history(params), ctx ) }), @@ -277,7 +273,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), + emit: (event) => emit(projectStructuredAgentSessionEventForClient(event, ctx)), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 7e17e914433f..ed91bbb79f6f 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -13,6 +13,7 @@ import { import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -90,6 +91,7 @@ describe('WebRuntimeClient', () => { clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index e9fa20fa0150..eb40a725db88 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -2,6 +2,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -62,6 +63,7 @@ export async function routeWebRuntimeConnectionFrame( clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index bd8b3f62a31f..7ceda03fdd43 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -211,6 +211,11 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' // status form to clients that do not advertise this. Transitional: drop the downgrade once no // supported release lacks the capability. export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const +// Why: a send a crash or a dead agent left in doubt for good (`unknown`, `recovered`) is drawn as an +// ordinary sent message. A client that predates this draws it unconfirmed, with a Retry that holds +// its queue, so the host publishes such a send to it as `accepted`. Transitional: drop the +// projection once no supported release lacks the capability. +export const AGENT_SESSION_RECOVERED_SEND_CAPABILITY = 'agent-session.recovered-send.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const // Why: agentSession.cancel has a strict schema, so clients must not send prompt identity to an @@ -396,6 +401,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 445f7c7a5337..13d9dc6c1ee8 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -22,6 +23,7 @@ export function remoteRuntimeClientCapabilities( AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, 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 c52cbfcca034..89085bfab5db 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 @@ -34,6 +34,7 @@ import { resolveBaselineReleaseRef } from './release-checkout' import { installableHost, structuredHostStub, + recoveredSendSkew, turnItemSkew } from './structured-agent-session-host-fixture' import { @@ -239,6 +240,26 @@ describe('cross-version structured agent sessions', () => { }) }) + describe('a client that draws a recovered send as unconfirmed', () => { + beforeEach(() => recoveredSendSkew.install(SESSION, WORKSPACE)) + afterEach(() => setStructuredAgentSessionHost(null)) + + it('is published it as accepted where a capable client gets the recovered facts', async () => { + const params = paramsFor('agentSession.history') + for (const [clientCapabilities, submission] of recoveredSendSkew.clients(baseline, current)) { + const client = { clientKind: 'runtime' as const, clientCapabilities } + const replies = await callBuild(current, 'agentSession.history', params, client) + expect(replies[0]).toEqual( + expect.objectContaining({ + result: expect.objectContaining({ + page: expect.objectContaining({ submissions: [submission] }) + }) + }) + ) + } + }) + }) + describe('a client that predates the turn item', () => { beforeEach(() => turnItemSkew.install(SESSION, WORKSPACE)) afterEach(() => setStructuredAgentSessionHost(null)) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 7af9283f328a..932b4ac71e69 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { + AGENT_SESSION_RECOVERED_SEND_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -74,7 +75,7 @@ export function structuredHostStub( readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), modelCatalog: vi.fn(() => ({ origin: 'unknown' as const })), readCommands: vi.fn(() => ({ commands: [{ name: 'clear', kind: 'command' as const }] })), - history: vi.fn(() => ({ ok: true, page: { items: [] } })), + history: vi.fn(() => ({ ok: true, page: { items: [], submissions: [] } })), journalSnapshot: vi.fn(() => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 }, @@ -126,7 +127,7 @@ export const turnItemSkew = { install(sessionId: string, workspaceId: string): void { const host = structuredHostStub(sessionId, workspaceId) const items = [{ ...TURN_ROW, body: { kind: 'turn', ...TURN } }] - host.history.mockReturnValue({ ok: true, page: { items } }) + host.history.mockReturnValue({ ok: true, page: { items, submissions: [] } }) setStructuredAgentSessionHost(installableHost(host)) }, /** Each skew's advertised list and the item it must be published. */ @@ -144,3 +145,42 @@ export const turnItemSkew = { ] as const } } + +const SEND = { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'f'.repeat(64), + providerItemId: null, + submittedAt: 1, + resolvedAt: 2 +} + +/** One send a crash left in doubt for good, and the two ways the current host publishes it. The + * old client is derived from the baseline by removing the capability, as for the turn item. */ +export const recoveredSendSkew = { + install(sessionId: string, workspaceId: string): void { + const host = structuredHostStub(sessionId, workspaceId) + const submissions = [ + { ...SEND, dispatchState: 'unknown', reason: 'host_restarted', recovered: true } + ] + host.history.mockReturnValue({ ok: true, page: { items: [], submissions } }) + setStructuredAgentSessionHost(installableHost(host)) + }, + /** Each skew's advertised list and the submission it must be published. */ + clients( + baseline: { capabilities: readonly string[] }, + current: { capabilities: readonly string[] } + ) { + const old = baseline.capabilities.filter((c) => c !== AGENT_SESSION_RECOVERED_SEND_CAPABILITY) + return [ + [ + [...old, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + { ...SEND, dispatchState: 'accepted', reason: null } + ], + [ + [...current.capabilities], + { ...SEND, dispatchState: 'unknown', reason: 'host_restarted', recovered: true } + ] + ] as const + } +} From 71dd19897239c9b47d69e3908df8cbce0b77bed5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:14:02 -0700 Subject: [PATCH 33/49] test(native-chat): assert the projected stream without a type assertion --- ...nt-session-recovered-send-capability.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts index ddf40d5f310a..350c1524a788 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts @@ -133,15 +133,14 @@ describe('a recovered send at the RPC boundary', () => { [STRUCTURED_CLIENT, AS_ACCEPTED], [CURRENT_CLIENT, RECOVERED] ] as const) { - const reply = await call('agentSession.subscribe', { sessionId: SESSION }, client) - const event = (reply.ok ? reply.result : null) as AgentSessionSubscribeEvent - const submissions = - event.type === 'batch' - ? event.batch.submissions - : 'page' in event - ? event.page.submissions - : [] - expect(submissions).toEqual([expected]) + const carrier = expect.objectContaining({ submissions: [expected] }) + expect(await call('agentSession.subscribe', { sessionId: SESSION }, client)).toEqual( + expect.objectContaining({ + result: expect.objectContaining( + type === 'batch' ? { batch: carrier } : { page: carrier } + ) + }) + ) } } ) From a8949ab3b80a6a7de1baa9aaa993142e5bc2cf7a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:19:17 -0700 Subject: [PATCH 34/49] fix(native-chat): name the crash-doubted send capability without a raw-read shape `agent-session.recovered-send.v1` contains `.recovered`, which the submission settlement ratchet reads as a raw dispatch-fact read. The capability is now `agent-session.crash-doubted-send.v1`; it has not shipped, so nothing else moves. --- docs/reference/remote-wire-compatibility.md | 2 +- src/shared/protocol-version.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index c762f2461680..f93855b4d862 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -245,7 +245,7 @@ The cross-version suite derives the old client's list by removing this capabilit baseline's own list, per the rule above, so the downgrade stays exercised after a release ships it. -The same boundary carries a second downgrade, `agent-session.recovered-send.v1` +The same boundary carries a second downgrade, `agent-session.crash-doubted-send.v1` (`structured-agent-session-recovered-send-capability.ts`). A send a crash or a dead agent left in doubt for good (`unknown` with `recovered`) is drawn as an ordinary sent message; a client that predates that reading drew it unconfirmed, with a Retry that held its queue. Such a client is diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 7ceda03fdd43..f2ac3cf14bb9 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -215,7 +215,8 @@ export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' a // ordinary sent message. A client that predates this draws it unconfirmed, with a Retry that holds // its queue, so the host publishes such a send to it as `accepted`. Transitional: drop the // projection once no supported release lacks the capability. -export const AGENT_SESSION_RECOVERED_SEND_CAPABILITY = 'agent-session.recovered-send.v1' as const +export const AGENT_SESSION_RECOVERED_SEND_CAPABILITY = + 'agent-session.crash-doubted-send.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const // Why: agentSession.cancel has a strict schema, so clients must not send prompt identity to an From a0ca7137350fbce6e96b68d282761d8a955d02e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:38:54 -0700 Subject: [PATCH 35/49] fix(native-chat): publish an older host's "not delivered" as sent to released clients too The released-client downgrade re-derived "drawn as sent" from `unknown` + `recovered` alone, a second copy of the settlement rule. It missed the one input a new host still serves that the classifier also draws as sent: an older host's inferred `not_delivered` rejection, still on disk. A released client kept hiding that message behind a Retry, which is the double send this change exists to remove. The projection now reads the shared classifier, so a released client is published exactly what a current one draws as sent. --- docs/reference/remote-wire-compatibility.md | 10 ++++---- ...-session-recovered-send-capability.test.ts | 24 ++++++++++++++++++- ...agent-session-recovered-send-capability.ts | 18 +++++++++----- src/shared/protocol-version.ts | 8 +++---- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index f93855b4d862..8ed17001eeac 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -247,10 +247,12 @@ it. The same boundary carries a second downgrade, `agent-session.crash-doubted-send.v1` (`structured-agent-session-recovered-send-capability.ts`). A send a crash or a dead agent left in -doubt for good (`unknown` with `recovered`) is drawn as an ordinary sent message; a client that -predates that reading drew it unconfirmed, with a Retry that held its queue. Such a client is -published that submission as `accepted`, the one state it already draws as sent, on history, the -live stream and a replayed send's reply. The journal keeps the real facts. Both compositions live in +doubt for good (`unknown` with `recovered`) is drawn as an ordinary sent message, as is an older +host's inferred `not_delivered`; a client that predates that reading drew the first unconfirmed, +with a Retry that held its queue, and hid the second. So whatever +`structuredAgentSessionSubmissionSettlement` reads as sent reaches such a client as `accepted`, the +one state it already draws as sent, on history, the live stream and a replayed send's reply. The +journal keeps the real facts. Both compositions live in `structured-agent-session-client-projection.ts`, so history and the stream cannot disagree. ## Known debt: JSON-RPC errors drop Node's string code diff --git a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts index 350c1524a788..4d49d636a373 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts @@ -183,7 +183,10 @@ describe('recovered send projection', () => { }) it.each([ - ['a live unknown, which can still be answered', submission({ recovered: undefined })], + [ + 'a live unknown, which can still be answered', + submission({ recovered: undefined, reason: 'provider_write_outcome_unknown: timeout' }) + ], ['a pending send', submission({ dispatchState: 'pending', recovered: undefined })], ['a refused send', submission({ dispatchState: 'rejected', reason: 'queue full' })], ['an accepted send', submission({ dispatchState: 'accepted', recovered: undefined })] @@ -192,6 +195,25 @@ describe('recovered send projection', () => { expect(projectRecoveredSendHistory(history, STRUCTURED_CLIENT)).toBe(history) }) + // An older host's verdicts, still on disk: a current client draws both as sent. + it.each([ + [ + 'an inferred "not delivered"', + submission({ + dispatchState: 'rejected', + reason: 'not_delivered', + rejection: { kind: 'notDelivered' } + }) + ], + ['a restart doubt without the recovered flag', submission({ recovered: undefined })] + ])('publishes %s to an older reader as accepted', (_label, entry) => { + expect(structuredAgentSessionSubmissionSettlement(entry)).toBe('sent') + const history: AgentSessionHistoryResult = { ok: true, page: page([entry]) } + expect(projectRecoveredSendHistory(history, STRUCTURED_CLIENT).page.submissions).toEqual([ + AS_ACCEPTED + ]) + }) + it.each([ ['capable client', CURRENT_CLIENT], ['in-process caller', {}] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts index fb4c426acfe5..54a9963938d5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts @@ -1,10 +1,11 @@ // Transitional: remove once no supported release lacks AGENT_SESSION_RECOVERED_SEND_CAPABILITY. // // A send a crash or a dead agent left in doubt for good (`unknown`, `recovered`) is drawn as an -// ordinary sent message. A client that predates that reading draws it unconfirmed, with a Retry -// that holds its queue, so the host publishes it to that client as `accepted` — the one state it -// already draws as sent — at the RPC boundary only. The journal and every host reader keep the -// real facts, and the host never re-sends a recovered send whichever way it is published. +// ordinary sent message, as is an older host's inferred `not_delivered`. A client that predates +// that reading draws the first unconfirmed, with a Retry that holds its queue, and hides the +// second behind a Retry, so the host publishes both to it as `accepted` — the one state it already +// draws as sent — at the RPC boundary only. The journal and every host reader keep the real facts, +// and the host never re-sends such a send whichever way it is published. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { @@ -15,6 +16,7 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' import { AGENT_SESSION_RECOVERED_SEND_CAPABILITY } from '../../../../shared/protocol-version' +import { structuredAgentSessionSubmissionSettlement } from '../../../../shared/structured-agent-session-submission-settlement' import type { RpcContext } from '../core' type RecoveredSendReader = Pick @@ -28,10 +30,14 @@ function readsRecoveredSends(ctx: RecoveredSendReader): boolean { } function projectSubmission(submission: AgentJournalSubmission): AgentJournalSubmission { - if (submission.dispatchState !== 'unknown' || submission.recovered !== true) { + // The same reading a current client draws from, so the two cannot drift apart. + if ( + submission.dispatchState === 'accepted' || + structuredAgentSessionSubmissionSettlement(submission) !== 'sent' + ) { return submission } - const { recovered: _recovered, ...rest } = submission + const { recovered: _recovered, rejection: _rejection, ...rest } = submission return { ...rest, dispatchState: 'accepted', reason: null } } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f2ac3cf14bb9..f34dd2d2321c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -211,10 +211,10 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' // status form to clients that do not advertise this. Transitional: drop the downgrade once no // supported release lacks the capability. export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const -// Why: a send a crash or a dead agent left in doubt for good (`unknown`, `recovered`) is drawn as an -// ordinary sent message. A client that predates this draws it unconfirmed, with a Retry that holds -// its queue, so the host publishes such a send to it as `accepted`. Transitional: drop the -// projection once no supported release lacks the capability. +// Why: a send a crash or a dead agent left in doubt for good (`unknown`, `recovered`), or an older +// host's inferred `not_delivered`, is drawn as an ordinary sent message. A client that predates +// this draws it unconfirmed or hidden, behind a Retry, so the host publishes such a send to it as +// `accepted`. Transitional: drop the projection once no supported release lacks the capability. export const AGENT_SESSION_RECOVERED_SEND_CAPABILITY = 'agent-session.crash-doubted-send.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = From 6e7ebc5bbad35e5785896eb88f2ccf9fa35a9ec3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:57:23 -0700 Subject: [PATCH 36/49] fix(native-chat): keep only the failure a turn ended in outside its fold A settled turn kept every failure row with no answer after it in view, so a turn that failed twice showed both rows beside "Worked for". Only the failure the turn ended in says what to do next; an earlier one the turn moved past, by answering or by failing again, now folds with the work. --- src/shared/native-chat-turn-fold.test.ts | 6 +++++ src/shared/native-chat-turn-fold.ts | 33 ++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index 9c00bbd1f3fd..c95c78bcb52f 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -127,6 +127,12 @@ describe('nativeChatTurnFold', () => { expect(folded.has(3)).toBe(true) }) + it('folds an earlier failure the turn failed past, keeping the one it ended in', () => { + expect([...fold([row({ role: 'user' }), failure(), failure()])]).toEqual([1]) + const afterAnswer = fold([row({ role: 'user' }), row(), failure(), failure()]) + expect([...afterAnswer]).toEqual([2]) + }) + it('stays in view when it is tied to no turn', () => { expect(fold([failure({ turnKey: undefined }), row({ role: 'user' }), row()]).size).toBe(0) }) diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index f1e12fd5a76c..80cd92a8332d 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -26,14 +26,14 @@ export type NativeChatTurnFoldRow = { * the work ended — often the only one — so it never folds. */ outlivesTurn: boolean /** Whether the row reports a failure (`nativeChatRowReportsFailure`). It stays - * out of the fold only while nothing the agent answered comes after it. */ + * out of the fold only when the turn ended in it: see `nativeChatTurnEndingFailureRows`. */ reportsFailure: boolean /** The subagent that produced the row. Absent ⇒ the session's own agent. */ agentId?: string } /** A failure row — the agent stopped, the session did not survive a restart. When it is how the - * turn ended it says what to do next; one the agent answered past was only on the way. A + * turn ended it says what to do next; one the turn moved past was only on the way. A * provider retry is not a failure yet. */ export function nativeChatRowReportsFailure(blocks: readonly NativeChatBlock[]): boolean { return blocks.some( @@ -78,6 +78,26 @@ export function nativeChatTurnAnswerRows( return answers } +/** The failure row each turn ended in: its last, with no answer after it. A failure the turn + * moved past, by answering or by failing again, folds with the work. */ +function nativeChatTurnEndingFailureRows( + rows: readonly NativeChatTurnFoldRow[], + answers: ReadonlyMap +): ReadonlyMap { + const endings = new Map() + for (const [index, row] of rows.entries()) { + if (row.turnKey !== undefined && row.reportsFailure) { + endings.set(row.turnKey, index) + } + } + for (const [turnKey, index] of endings) { + if ((answers.get(turnKey) ?? -1) > index) { + endings.delete(turnKey) + } + } + return endings +} + /** * Fold every settled, unexpanded turn to its answer. * @@ -97,6 +117,7 @@ export function nativeChatTurnFold({ expandedTurnKeys: ReadonlySet }): NativeChatTurnFold { const answers = nativeChatTurnAnswerRows(rows) + const endings = nativeChatTurnEndingFailureRows(rows, answers) const foldedRows = new Set() const foldableTurnKeys = new Set() for (const [index, row] of rows.entries()) { @@ -111,15 +132,11 @@ export function nativeChatTurnFold({ ) { continue } - const answer = answers.get(turnKey) // A turn that produced no prose folds whole: its status row is the anchor, // so there is still something on screen to open. Keeping such a turn // unfolded instead would put every command it ran back in the transcript. - if (index === answer) { - continue - } - // A failure with no answer after it is how the turn ended, so it stays in view. - if (row.reportsFailure && (answer === undefined || index > answer)) { + // The failure a turn ended in stays in view: it says what to do next. + if (index === answers.get(turnKey) || index === endings.get(turnKey)) { continue } foldableTurnKeys.add(turnKey) From cd2da8eb42657030297a8c76e6189dba4bc8552e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:57:26 -0700 Subject: [PATCH 37/49] refactor(native-chat): read the in-flight send at teardown through the shared rule The quit-time snapshot of a send still awaiting its turn restated the unanswered-dispatch rule by hand. It now asks isUnansweredStructuredAgentSessionDispatch, the rule that makes that window read as working, so the two cannot drift. --- .../structured-agent-session-working-at-teardown.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 366ce1cf948e..b56556c37cfa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -38,6 +38,7 @@ import { type AgentSessionRestartTask } from '../../../shared/agent-session-restart-activity' import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness' +import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import { activeStructuredAgentSessionTurnId, newestStructuredAgentSessionTurn @@ -45,18 +46,14 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' -/** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the - * projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */ +/** A send Orca journaled that the provider has neither opened a turn for nor refused: the + * projection's own rule, which is what makes that window read as `working`. */ function pendingSubmissionInFlight( submissions: readonly AgentJournalSubmission[] ): AgentJournalSubmission | null { for (let index = submissions.length - 1; index >= 0; index -= 1) { const submission = submissions[index] - if ( - submission && - submission.recovered !== true && - (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') - ) { + if (submission && isUnansweredStructuredAgentSessionDispatch(submission)) { return submission } } From c2004ebdd3e09cdaf19419d7174280923669045a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:00:08 -0700 Subject: [PATCH 38/49] test(native-chat): read the Claude crash-restart journal as a current client The suite's client advertised only the structured capability, so the host down-projected the crash-doubted send to accepted for it while the test expected the raw unknown. Advertise the current remote capability list and pin the recorded unknown + recovered facts on both the send reply and the journal history. --- ...ude-structured-session-integration.test.ts | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 82db5d6c5425..1800695c6756 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -5,10 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' -import { - AGENT_SESSION_TURN_ITEM_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { fakeClaude } from './claude-structured-fake-connection-test-fixture' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity' @@ -33,10 +31,12 @@ const WORKSPACE = 'workspace-claude' // Why 'runtime': this file exercises the Claude structured integration over agentSession.*, not the // mobile surface — nothing here asserts anything mobile-specific, and its sibling integration // suites use 'runtime' too. Mobile additionally requires the experimental structured-chat setting, -// which structured-agent-session.test.ts pins in both its satisfied and refused states. +// which structured-agent-session.test.ts pins in both its satisfied and refused states. It +// advertises what a current remote client does, so it reads the host's current shapes, not the +// down-projection an older client gets. const CLIENT = { clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: remoteRuntimeClientCapabilities([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) } const { resolveSessionFilePath } = vi.hoisted(() => ({ @@ -157,9 +157,7 @@ async function ok(method: string, params: unknown): Promise { return result.value as T } -async function subscribe( - client: { clientKind: 'runtime'; clientCapabilities: string[] } = CLIENT -): Promise { +async function subscribe(): Promise { const frames: AgentSessionSubscribeEvent[] = [] await dispatcher.dispatchStreaming( { @@ -174,7 +172,7 @@ async function subscribe( frames.push(response.result) } }, - client + CLIENT ) return frames } @@ -549,16 +547,28 @@ describe('a structured Claude session over agentSession.*', () => { connection.sent.push(message) } const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } - const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( - 'agentSession.send', - { envelope: envelope('agentSession.send', { body }, created.fence), body } - ) + const inFlight = ok<{ submission: unknown }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) - expect((await inFlight).submission).toMatchObject({ + // The death leaves the send in doubt for good: drawn as sent, never re-sent by the host. + const doubted = { dispatchState: 'unknown', - reason: 'provider_exited_before_acknowledgement' + reason: 'provider_exited_before_acknowledgement', + recovered: true + } + expect((await inFlight).submission).toMatchObject(doubted) + const journal = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 10 + }) + expect(journal).toMatchObject({ + ok: true, + result: { page: { submissions: [expect.objectContaining(doubted)] } } }) // The crash releases the lease; nothing restarts Claude until the chat has work for it. await waitForStructuredAgentSessionRecovery() @@ -781,10 +791,7 @@ describe('a structured Claude session over agentSession.*', () => { const answers: ((value: unknown) => void)[] = [] claude.setContextUsage(() => new Promise((resolve) => answers.push(resolve))) const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - const stream = await subscribe({ - ...CLIENT, - clientCapabilities: [...CLIENT.clientCapabilities, AGENT_SESSION_TURN_ITEM_CAPABILITY] - }) + const stream = await subscribe() const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Hi' }] } await ok('agentSession.send', { envelope: envelope('agentSession.send', { body }, created.fence), From f71d13b206e04ee89e3b01a8d45805d7bffbd93a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:32:41 -0700 Subject: [PATCH 39/49] fix(i18n): drop the wait-for-setup help keys main removed, which the merge kept --- src/renderer/src/i18n/locales/en.json | 4 +--- src/renderer/src/i18n/locales/es.json | 4 +--- src/renderer/src/i18n/locales/fr.json | 4 +--- src/renderer/src/i18n/locales/ja.json | 4 +--- src/renderer/src/i18n/locales/ko.json | 4 +--- src/renderer/src/i18n/locales/zh.json | 4 +--- 6 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 875f55456f69..3d21bf18cea3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1728,7 +1728,6 @@ "notConnected": "Not connected", "notePasteTooLarge": "Paste is too large for the note field.", "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", - "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.", "destroyDisabled": "destroy disabled", "destroyConfigured": "destroy configured", "noDestroyConfigured": "no destroy", @@ -8055,8 +8054,7 @@ "81057d5f71": "Saving...", "da37d6f10e": "Copy", "3149964b66": "Copied", - "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", - "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent" }, "RepositoryIconPicker": { "2b7d27b93c": "Use {{value0}} repo color", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 68bc24c2f91c..2bc95df751cb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1379,7 +1379,6 @@ "reuseExistingBranchHint": "Haz checkout de la rama existente en lugar de crear una nueva a partir de ella.", "createMultiple": "Crear más", "waitForSetupBeforeAgent": "Esperar a que termine el setup antes de iniciar el agente", - "waitForSetupBeforeAgentHelp": "Activa esto cuando el setup instale dependencias, servidores MCP o archivos de configuración que el agente necesita al iniciar.", "destroyDisabled": "destroy deshabilitado", "destroyConfigured": "destroy configurado", "noDestroyConfigured": "sin destroy", @@ -6804,8 +6803,7 @@ "81057d5f71": "Guardando...", "da37d6f10e": "Copiar", "3149964b66": "Copiado", - "waitForSetupBeforeAgent": "Espera a que se complete la configuración antes de iniciar el agente", - "waitForSetupBeforeAgentHelp": "Activa esto cuando la configuración instale dependencias, servidores MCP o archivos de configuración que el agente necesite durante el inicio." + "waitForSetupBeforeAgent": "Espera a que se complete la configuración antes de iniciar el agente" }, "RepositoryIconPicker": { "2b7d27b93c": "Usar {{value0}} como color del repositorio", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 7bd37abaafdc..8dffa5e7c746 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1706,7 +1706,6 @@ "notConnected": "Non connecté", "notePasteTooLarge": "Le contenu collé est trop volumineux pour le champ de note.", "waitForSetupBeforeAgent": "Attendre la fin du setup avant de démarrer l'agent", - "waitForSetupBeforeAgentHelp": "Activez cette option quand le setup installe des dépendances, des serveurs MCP ou des fichiers de configuration dont l'agent a besoin au démarrage.", "destroyDisabled": "destroy désactivé", "destroyConfigured": "destroy configuré", "noDestroyConfigured": "pas de destroy", @@ -7991,8 +7990,7 @@ "81057d5f71": "Enregistrement...", "da37d6f10e": "Copier", "3149964b66": "Copied", - "waitForSetupBeforeAgent": "Attendre la fin du setup avant de démarrer l'agent", - "waitForSetupBeforeAgentHelp": "Activez cette option quand le setup installe des dépendances, des serveurs MCP ou des fichiers de configuration dont l'agent a besoin au démarrage." + "waitForSetupBeforeAgent": "Attendre la fin du setup avant de démarrer l'agent" }, "RepositoryIconPicker": { "2b7d27b93c": "Utiliser la couleur de dépôt {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 484362999b18..1cd5371bece6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1603,7 +1603,6 @@ "reuseExistingBranchHint": "既存のブランチから新規ブランチを作成せず、そのブランチをチェックアウトします。", "createMultiple": "さらに作成", "waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください", - "waitForSetupBeforeAgentHelp": "セットアップによって、起動時に Agent に必要な依存関係、MCP サーバー、または構成ファイルがインストールされるときに、これをオンにします。", "destroyDisabled": "破壊は無効です", "destroyConfigured": "設定されたものを破棄する", "noDestroyConfigured": "破壊しない", @@ -7872,8 +7871,7 @@ "81057d5f71": "保存中…", "da37d6f10e": "コピー", "3149964b66": "コピーしました", - "waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください", - "waitForSetupBeforeAgentHelp": "セットアップによって、起動時に Agent に必要な依存関係、MCP サーバー、または構成ファイルがインストールされるときに、これをオンにします。" + "waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください" }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} リポジトリカラーを使用する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 524477dad463..8862e95e3233 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1603,7 +1603,6 @@ "reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.", "createMultiple": "더 만들기", "waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요.", - "waitForSetupBeforeAgentHelp": "설치 프로그램이 시작 중에 agent에 필요한 종속성, MCP 서버 또는 구성 파일을 설치할 때 이 기능을 켜십시오.", "destroyDisabled": "비활성화된 파괴", "destroyConfigured": "구성된 파괴", "noDestroyConfigured": "파괴하지 마세요", @@ -7835,8 +7834,7 @@ "81057d5f71": "저장 중...", "da37d6f10e": "복사", "3149964b66": "복사됨", - "waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요.", - "waitForSetupBeforeAgentHelp": "설치 프로그램이 시작 중에 agent에 필요한 종속성, MCP 서버 또는 구성 파일을 설치할 때 이 기능을 켜십시오." + "waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요." }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} repo 색상 사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f1a916e30a46..3752ba6f2ef0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1603,7 +1603,6 @@ "reuseExistingBranchHint": "检出已有分支,而不是从它创建新分支。", "createMultiple": "创建更多", "waitForSetupBeforeAgent": "等待设置完成后再启动智能体", - "waitForSetupBeforeAgentHelp": "如果设置会安装依赖、MCP 服务器或智能体启动时需要的配置文件,请开启此选项。", "destroyDisabled": "已禁用销毁", "destroyConfigured": "已配置销毁", "noDestroyConfigured": "未配置销毁", @@ -7835,8 +7834,7 @@ "81057d5f71": "保存中...", "da37d6f10e": "复制", "3149964b66": "已复制", - "waitForSetupBeforeAgent": "等待设置完成后再启动智能体", - "waitForSetupBeforeAgentHelp": "如果设置会安装依赖、MCP 服务器或智能体启动时需要的配置文件,请开启此选项。" + "waitForSetupBeforeAgent": "等待设置完成后再启动智能体" }, "RepositoryIconPicker": { "2b7d27b93c": "使用 {{value0}} 仓库颜色", From 0e4263f994c9c0c4425b5e8172a9e6f9af684613 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:15:32 -0700 Subject: [PATCH 40/49] fix(native-chat): give a doubted send a way to die at its turn end, a start that threw, and Stop A handed-over send the provider never answered counted as owed work with no exit, so the idle sweep held the agent and the chat read working until the tab closed, and Stop did not clear it. It now stops counting as owed work, settled in doubt (recovered unknown, drawn as sent, no row), at each exit: - the provider ends the turn it was running (Claude result, Codex turn/completed, including a failed turn after an error Codex will not retry): the journal settles every open send at that fence inside the terminal row's serialized write, never for a revision of an earlier turn or a host settlement; - the dispatch threw or lost its write outcome: written settled at once; - Stop: after the interrupt, and a failure to record it never fails the Stop. A late acceptance or refusal still replaces the doubt. The Codex idle report path that only released live unknowns is removed: nothing writes a live unknown any more. --- docs/reference/agent-status-store.md | 5 +- .../codex/codex-structured-dispatch-echo.ts | 5 +- .../codex-structured-journal-contracts.ts | 3 - ...dex-structured-journal-translation.test.ts | 97 ---------- .../codex-structured-journal-translation.ts | 33 +--- .../codex/codex-structured-session-acquire.ts | 1 - .../codex/codex-structured-session-state.ts | 3 - .../codex/codex-structured-thread-facts.ts | 8 - .../codex-structured-turn-end-settlement.ts | 2 +- .../journal-dispatch-doubt-reasons.ts | 8 +- .../journal-item-appender.ts | 10 +- .../journal-lifecycle-batch-appender.ts | 4 +- .../journal-pending-submission-recovery.ts | 12 +- .../agent-session-journal/journal-store.ts | 8 +- ...urnal-turn-end-dispatch-settlement.test.ts | 180 ++++++++++++++++++ .../journal-turn-end-dispatch-settlement.ts | 82 ++++++++ ...structured-agent-session-host-mutations.ts | 26 ++- .../structured-agent-session-host.ts | 5 - ...tructured-agent-session-idle-sweep.test.ts | 58 +++--- .../structured-agent-session-idle-sweep.ts | 4 +- ...red-agent-session-surface-lifetime.test.ts | 97 +++++++++- .../structured-agent-session-turns.ts | 19 +- ...ession-unanswered-dispatch-release.test.ts | 105 ---------- ...ent-session-unanswered-dispatch-release.ts | 43 ----- ...ude-structured-turn-end-settlement.test.ts | 87 +++++++++ ...-session-codex-turn-end-settlement.test.ts | 57 +++++- .../structured-agent-session-runtime.ts | 14 -- ...tured-agent-session-unanswered-dispatch.ts | 6 +- 28 files changed, 608 insertions(+), 374 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts create mode 100644 src/main/runtime/claude-structured-turn-end-settlement.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 4dfb5221e456..47a09ff084aa 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -107,8 +107,9 @@ ingests the summary into the hook server as a status row: Sessions with no request (`status === null`) produce no row. A request is a turn record, an assistant message, a user message the provider journaled itself -(history, an older host), an accepted or unanswered send, a send a crash or a -dead agent left in doubt (drawn as sent; it adds no verdict of its own), or a +(history, an older host), an accepted or unanswered send, a send left in doubt +by a crash, a dead agent, its turn's end, a Stop or a start that threw (drawn as +sent; it adds no verdict of its own), or a send the agent or its start refused; a send that was withdrawn, or refused because a restart or a close came before it was handed over, fails nobody and makes nothing listable. diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index c1a020b2c1f8..02a24f6d927a 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -2,7 +2,7 @@ import type { ProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that - * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ + * end; any other whose echo never arrives is settled in doubt by the journal at its turn's end. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 /** Turn ends kept for an answer read after the turn it names had already ended. */ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 @@ -40,7 +40,8 @@ export type CodexDispatchEchoes = { bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it - * completed, which echoes its pending input first, so one it never echoed waits for recovery. + * completed, which echoes its pending input first, so one it never echoed is left to the + * journal's turn-end settlement, and stays armed for a late echo. */ endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] /** Submission origin for this exact send, retained until its echo settles it. */ diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 28b5583a9b42..9aa69e8de3f2 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -21,9 +21,6 @@ export type CodexJournalTranslatorDeps = { * identity the journal row carries so a replay computes the same key. */ onUserMessageEcho?: (clientMessageId: string, identity: AgentJournalItemIdentity) => void primaryThreadId?: () => string | null - /** Codex reported the primary thread is not running while no turn is open here. - * A send whose dispatch was never answered is owed nothing after this. */ - onPrimaryThreadStoppedRunning?: () => void /** Submission origin for one exact client message still awaiting its echo. */ dispatchRequestOrigin?: (clientMessageId: string) => CodexDispatchRequestOrigin | null subagentExecutions?: CodexSubagentExecutions diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 676d9054ed70..d6ff54f09a77 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -393,103 +393,6 @@ describe('codex journal translation', () => { }) }) - describe('the thread reporting it stopped running releases unanswered sends', () => { - function statusChanged(type: string) { - return notification('thread/status/changed', { threadId: THREAD_ID, status: { type } }) - } - - function translatorReporting(stopped: string[]) { - const tap = recorder() - const translator = createCodexJournalTranslator({ - sink: tap.sink, - primaryThreadId: () => THREAD_ID, - onPrimaryThreadStoppedRunning: () => stopped.push(THREAD_ID) - }) - return { tap, translator } - } - - it.each(['idle', 'systemError'])('reports the thread stopped running on %s', (type) => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(statusChanged(type)) - - expect(stopped).toEqual([THREAD_ID]) - }) - - it.each(['active', 'notLoaded'])('stays silent on %s', (type) => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(statusChanged(type)) - - expect(stopped).toEqual([]) - }) - - it('stays silent while a turn is open, whatever the thread reports', () => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(TURN_STARTED) - translator.handle(statusChanged('systemError')) - - expect(stopped).toEqual([]) - }) - - it('releases after the failed completion that follows systemError and the error', () => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(TURN_STARTED) - translator.handle(statusChanged('systemError')) - translator.handle( - notification('error', { - turnId: TURN_ID, - willRetry: false, - error: { message: 'fatal' } - }) - ) - expect(stopped).toEqual([]) - - translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } })) - - expect(stopped).toEqual([THREAD_ID]) - }) - - it('releases after idle arrives before the terminal turn completion notification', () => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(TURN_STARTED) - translator.handle(statusChanged('idle')) - expect(stopped).toEqual([]) - - translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) - - expect(stopped).toEqual([THREAD_ID]) - }) - - it('reports once the open turn has settled', () => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(TURN_STARTED) - translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) - translator.handle(statusChanged('idle')) - - expect(stopped).toEqual([THREAD_ID]) - }) - - it('ignores a bare string status from a shape this build does not get', () => { - const stopped: string[] = [] - const { translator } = translatorReporting(stopped) - - translator.handle(notification('thread/status/changed', { status: 'idle' })) - - expect(stopped).toEqual([]) - }) - }) - it('matches out-of-order completions to each turn identity', () => { const tap = recorder() const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fadab8b4589c..711caac047e9 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -12,7 +12,7 @@ import { CodexJournalTurnBoundaries } from './codex-structured-journal-translati import { createCodexJournalTranslatorWriters } from './codex-structured-journal-translation-writers' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' -import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexTurnId } from './codex-structured-thread-facts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' export type { @@ -58,15 +58,6 @@ export function createCodexJournalTranslator( linkageFor, ...(deps.now ? { now: deps.now } : {}) }) - let primaryThreadStoppedRunning = false - const reportPrimaryThreadStoppedRunning = (): void => { - const primaryThreadId = deps.primaryThreadId?.() ?? null - if (!primaryThreadStoppedRunning || !primaryThreadId || activeTurns.current(primaryThreadId)) { - return - } - primaryThreadStoppedRunning = false - deps.onPrimaryThreadStoppedRunning?.() - } const routeThreadItem = createCodexThreadItemRouter({ deps, subagents, @@ -202,14 +193,9 @@ export function createCodexJournalTranslator( if (!childAdmission.accepted) { return childAdmission } - const admission = - event.method === 'turn/started' - ? turnBoundaries.start(event) - : turnBoundaries.complete(event) - if (admission.accepted) { - reportPrimaryThreadStoppedRunning() - } - return admission + return event.method === 'turn/started' + ? turnBoundaries.start(event) + : turnBoundaries.complete(event) } const compaction = compactions.handle(event) if (compaction) { @@ -235,17 +221,6 @@ export function createCodexJournalTranslator( return publishActivity(event, routed) } } - // A thread that stopped running settles no open turn: Codex clears `running` - // on every error, and an open turn ends on its `turn/completed`. It releases - // a send whose dispatch was never answered, which nothing else re-derives live. - if ( - event.method === 'thread/status/changed' && - codexThreadStoppedRunning(event.params) && - event.threadId === (deps.primaryThreadId?.() ?? null) - ) { - primaryThreadStoppedRunning = true - reportPrimaryThreadStoppedRunning() - } // A turn-ending `error` is a row inside the turn it names; the failed // `turn/completed` Codex sends after it is that turn's end. const unhandled = genericFrames.appendUnhandled( diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 0cbe62224c56..d8246882ac97 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -95,7 +95,6 @@ export async function acquireCodexStructuredSession(input: { sessionId, ...(deps.now ? { now: deps.now } : {}), primaryThreadId: () => primaryThreadId, - onPrimaryThreadStoppedRunning: () => deps.onPrimaryThreadStoppedRunning?.({ sessionId }), dispatchRequestOrigin: (clientMessageId) => dispatchEchoes.requestOrigin(clientMessageId), subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 7f40407e3ffd..67b77c74653e 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -89,9 +89,6 @@ export type CodexStructuredSessionAdapterDeps = { | ({ state: 'rejected' } & AgentJournalDispatchRejection) ) ) => void - /** Codex reported its thread not running with no turn open: a send whose - * dispatch was never answered is owed nothing after this. */ - onPrimaryThreadStoppedRunning?: (input: { sessionId: string }) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index aaf65f93ec6e..ead9d88d6571 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -62,9 +62,6 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null } -/** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a - * bare string. `idle` and `systemError` are the two arms that mean the thread - * is not running; `active` and `notLoaded` are not. */ /** * Codex hardcodes `willRetry: false` on an `error` that affects turn status, and `true` on a * stream error it is about to retry, which ends nothing. @@ -73,11 +70,6 @@ export function readCodexErrorWillRetry(payload: unknown): boolean { return record(payload)?.willRetry === true } -export function codexThreadStoppedRunning(payload: unknown): boolean { - const type = record(record(payload)?.status)?.type - return type === 'idle' || type === 'systemError' -} - /** An `active` thread flags each request it has open on the user (an approval, a question). */ export function codexThreadWaitsOnUser(payload: unknown): boolean { const status = record(record(payload)?.status) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 5187aabf45aa..dc4113ce5aad 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -3,7 +3,7 @@ // and is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending // input before `turn/completed`, a failed turn after its `error` frame, so only that // frame settles: a failed turn that never echoed the send refused it, in Codex's words, -// and a completed one leaves it pending for the journal's recovery on exit. +// and a completed one leaves it to the journal, which settles it in doubt at that turn end. import { agentSessionFailureFact, diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 6c01e6224dc0..36fb953086bc 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -23,9 +23,11 @@ export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_fa /** The operation tombstone survived recovery but its journal submission did not. */ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' -/** The provider reported its thread not running with no turn open, so nothing is - * left that could still acknowledge the message. */ -export const DISPATCH_DOUBT_PROVIDER_IDLE = 'provider_idle_before_acknowledgement' +/** The provider ended the turn it was running without answering the message. */ +export const DISPATCH_DOUBT_TURN_SETTLED = 'turn_settled_before_acknowledgement' + +/** The user stopped the session before the message was answered. */ +export const DISPATCH_DOUBT_STOPPED = 'stopped_before_acknowledgement' /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index e0bf1ad62381..5fde9b9ce33e 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -19,11 +19,17 @@ export class JournalItemAppender { append( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions + options: JournalItemAppendOptions, + /** Runs inside the serialized build, before the row is applied. */ + beforeBuild?: () => void ): Promise { const itemId = agentJournalItemKey(identity) + const build = journalItemRowBuilder(this.deps.state, identity, body, options) return this.deps - .enqueue(journalItemRowBuilder(this.deps.state, identity, body, options)) + .enqueue((seq, ts) => { + beforeBuild?.() + return build(seq, ts) + }) .then((row) => ({ cursor: { epoch: row.epoch, sequence: row.seq }, itemId, diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a87457..31e6a30c9414 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -15,7 +15,8 @@ export class JournalLifecycleBatchAppender { } ) {} - append(input: JournalLifecycleBatchInput): Promise { + /** `beforeBuild` runs inside the serialized build, before the row is applied. */ + append(input: JournalLifecycleBatchInput, beforeBuild?: () => void): Promise { if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } @@ -30,6 +31,7 @@ export class JournalLifecycleBatchAppender { if (this.wasApplied(input.settlementId)) { throw SETTLEMENT_ALREADY_APPLIED } + beforeBuild?.() return build(seq, ts) }) .then((row) => ({ epoch: row.epoch, sequence: row.seq })) diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c252a57d8a57..6b153c616c50 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -4,13 +4,14 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' -/** Settles every submission a process fact left unanswerable. Doubt is never - * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued - * submission was never handed over, so it is not in doubt and is left alone. */ +/** Settles every submission a process fact left unanswerable, optionally only those `which` + * names. Doubt is never proof of non-delivery, so nothing here ever becomes re-deliverable. A + * queued submission was never handed over, so it is not in doubt and is left alone. */ export async function markJournalPendingSubmissionsUnknown( journal: AgentSessionJournal, fence: number, - reason: string = DISPATCH_DOUBT_HOST_RESTARTED + reason: string = DISPATCH_DOUBT_HOST_RESTARTED, + which: (submission: AgentJournalSubmission) => boolean = () => true ): Promise { const unresolved = journal .submissions() @@ -18,7 +19,8 @@ export async function markJournalPendingSubmissionsUnknown( (entry) => !isQueuedAgentJournalSubmission(entry) && (entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true)) + (entry.dispatchState === 'unknown' && entry.recovered !== true)) && + which(entry) ) for (const entry of unresolved) { // An earlier reason already names a sharper fact than "the host restarted". diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3be3aebbc514..6faf19c53f80 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -24,6 +24,7 @@ import { import { agentSessionJournalCloseRetries } from './journal-close-retry' import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' import type { JournalReplacementItem } from './journal-epoch-replacement' +import { journalTurnEndDispatchSettlement } from './journal-turn-end-dispatch-settlement' import { readJournalSince } from './journal-cursor' import { readJournalRowsAfterCursor, type JournalLoad } from './journal-open' import { journalDatabaseFile } from './journal-paths' @@ -275,7 +276,8 @@ export class AgentSessionJournal { body: AgentJournalItemBody, options: JournalItemAppendOptions = { fence: 0 } ): Promise { - return this.itemAppender.append(identity, body, options) + const turnEnd = journalTurnEndDispatchSettlement(this, () => this.state, [body], options) + return turnEnd.after(this.itemAppender.append(identity, body, options, turnEnd.capture)) } appendTombstone( @@ -289,7 +291,9 @@ export class AgentSessionJournal { } appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { - return this.lifecycleBatchAppender.append(input) + const bodies = input.mutations.flatMap((entry) => (entry.kind === 'item' ? [entry.body] : [])) + const turnEnd = journalTurnEndDispatchSettlement(this, () => this.state, bodies, input) + return turnEnd.after(this.lifecycleBatchAppender.append(input, turnEnd.capture)) } /** diff --git a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts new file mode 100644 index 000000000000..2a4e3fe917cb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts @@ -0,0 +1,180 @@ +// The provider's end of the turn it was running ends what a send handed to it was owed, on either +// write path, and never for a revision of an earlier turn, a host settlement or another fence. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentJournalTurnItem +} from '../../../shared/agent-session-journal-types' +import { owesStructuredAgentSessionWork } from '../../../shared/structured-agent-session-owed-work' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const FENCE = 3 +const journals = createTrackedJournalOpener() +let root: string +let clock = 1_000 + +function turnRow(turnId: string, ordinal: number): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: 'claude-1', uuid: `${turnId}-${ordinal}` } +} + +function turn(turnId: string, state: AgentJournalTurnItem['state']): AgentJournalTurnItem { + return { kind: 'turn', turnId, state } +} + +async function open(): Promise { + return journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'claude-1' } + }, + journalDir: root, + now: () => ++clock, + mintEpoch: () => 'epoch-1' + }) +} + +/** A send accepted and handed to the provider, awaiting its answer. */ +async function handOver(journal: AgentSessionJournal, clientMessageId: string, fence = FENCE) { + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: `fingerprint-${clientMessageId}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ clientMessageId, state: 'pending', fence }) +} + +function dispatchOf(journal: AgentSessionJournal, clientMessageId: string) { + return journal.submissions().find((entry) => entry.clientMessageId === clientMessageId) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-turn-end-settlement-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a turn the provider ends', () => { + it.each(['completed', 'failed', 'interrupted'] as const)( + 'settles the sends it left unanswered in doubt when it %s', + async (state) => { + const journal = await open() + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) + await handOver(journal, 'steer') + + await journal.appendItem(turnRow('turn-1', 1), turn('turn-1', state), { fence: FENCE }) + + expect(dispatchOf(journal, 'steer')).toMatchObject({ + dispatchState: 'unknown', + reason: 'turn_settled_before_acknowledgement', + recovered: true + }) + const snapshot = journal.snapshot() + expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, FENCE)).toBe( + false + ) + } + ) + + it('settles through a lifecycle batch as through a single row', async () => { + const journal = await open() + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) + await handOver(journal, 'steer') + + await journal.appendLifecycleBatch({ + settlementId: 'turn-1-end', + fence: FENCE, + mutations: [ + { kind: 'item', identity: turnRow('turn-1', 0), body: turn('turn-1', 'completed') } + ] + }) + + expect(dispatchOf(journal, 'steer')).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + }) + + it('lets a late echo replace the doubt', async () => { + const journal = await open() + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) + await handOver(journal, 'steer') + await journal.appendItem(turnRow('turn-1', 1), turn('turn-1', 'completed'), { fence: FENCE }) + + await journal.resolveDispatch({ + clientMessageId: 'steer', + state: 'accepted', + providerIdentity: turnRow('echo', 0), + fence: FENCE + }) + + expect(dispatchOf(journal, 'steer')).toMatchObject({ dispatchState: 'accepted' }) + expect(dispatchOf(journal, 'steer')?.recovered).toBeUndefined() + }) + + it('leaves a send it never held: queued, another fence, or handed over after the end', async () => { + const journal = await open() + await handOver(journal, 'older-child', FENCE - 1) + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) + await journal.appendSubmission({ + clientMessageId: 'queued', + payloadFingerprint: 'fingerprint-queued', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued' }] }, + fence: FENCE, + handoverRecorded: true + }) + + const ended = journal.appendItem(turnRow('turn-1', 1), turn('turn-1', 'completed'), { + fence: FENCE + }) + const after = handOver(journal, 'after-the-end') + await Promise.all([ended, after]) + + expect(dispatchOf(journal, 'queued')?.dispatchState).toBe('pending') + expect(dispatchOf(journal, 'older-child')?.dispatchState).toBe('pending') + expect(dispatchOf(journal, 'after-the-end')?.dispatchState).toBe('pending') + }) + + it('ends nothing when it revises an earlier turn while a later one runs', async () => { + const journal = await open() + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'completed'), { fence: FENCE }) + await journal.appendItem(turnRow('turn-2', 0), turn('turn-2', 'running'), { fence: FENCE }) + await handOver(journal, 'steer') + + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'interrupted'), { + fence: FENCE + }) + + expect(dispatchOf(journal, 'steer')?.dispatchState).toBe('pending') + }) + + it("leaves a host settlement's sends to the reason it gives them", async () => { + const journal = await open() + await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) + await handOver(journal, 'steer') + + await journal.appendLifecycleBatch({ + settlementId: 'dead-generation', + fence: FENCE, + recovered: true, + mutations: [ + { kind: 'item', identity: turnRow('turn-1', 0), body: turn('turn-1', 'interrupted') } + ] + }) + + expect(dispatchOf(journal, 'steer')?.dispatchState).toBe('pending') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts new file mode 100644 index 000000000000..8b50f55101b5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts @@ -0,0 +1,82 @@ +// A provider ending the turn it was running ends what every send handed to it still owed. +// +// A handed-over send the provider has neither answered nor refused counts as work, so the chat +// reads working and the idle sweep keeps the agent. Once the provider ends its turn, nothing is left +// that owes that send an answer: it settles as recovered `unknown` — doubt, drawn as sent — and a +// late acceptance or refusal from the provider still replaces it. The sends are read inside the +// terminal row's serialized write, so one handed over after that row is never taken for this turn's. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { activeStructuredAgentSessionTurnIdBySequence } from '../../../shared/structured-agent-session-live-turn' +import { structuredAgentSessionSubmissionSettlement } from '../../../shared/structured-agent-session-submission-settlement' +import { DISPATCH_DOUBT_TURN_SETTLED } from './journal-dispatch-doubt-reasons' +import { markJournalPendingSubmissionsUnknown } from './journal-pending-submission-recovery' +import type { JournalReducerState } from './journal-reducer' +import type { AgentSessionJournal } from './journal-store' + +export type JournalTurnEndDispatchSettlement = { + /** Runs inside the write's serialized build, before its row is applied. */ + capture: () => void + /** Settles what `capture` found once the write has landed; never fails the write. */ + after: (write: Promise) => Promise +} + +const NOTHING_TO_SETTLE: JournalTurnEndDispatchSettlement = { + capture: () => undefined, + after: (write) => write +} + +export function journalTurnEndDispatchSettlement( + journal: AgentSessionJournal, + state: () => JournalReducerState, + bodies: readonly AgentJournalItemBody[], + /** A host settlement writes `recovered` rows and settles its own sends. */ + options: { fence: number; recovered?: true } +): JournalTurnEndDispatchSettlement { + const endedTurnIds = options.recovered + ? [] + : bodies.flatMap((body) => { + const turn = readAgentJournalTurn(body) + return turn && turn.state !== 'running' ? [turn.turnId] : [] + }) + if (endedTurnIds.length === 0) { + return NOTHING_TO_SETTLE + } + const owed = new Set() + return { + capture: () => { + const { items, submissions } = state() + const active = activeStructuredAgentSessionTurnIdBySequence(items.values()) + // Only the turn running now: a late revision of an earlier turn ends nothing handed over since. + if (active === null || !endedTurnIds.includes(active)) { + return + } + for (const submission of submissions.values()) { + if ( + submission.fence === options.fence && + !isQueuedAgentJournalSubmission(submission) && + structuredAgentSessionSubmissionSettlement(submission) === 'open' + ) { + owed.add(submission.clientMessageId) + } + } + }, + after: (write) => + write.then(async (result) => { + if (owed.size > 0) { + // The next exit (a Stop, the child's end, the next open) settles what this could not. + await markJournalPendingSubmissionsUnknown( + journal, + options.fence, + DISPATCH_DOUBT_TURN_SETTLED, + (submission) => owed.has(submission.clientMessageId) + ).catch((error: unknown) => { + console.warn('[agent-session] settling sends at a turn end failed:', error) + }) + } + return result + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 35782b676c2e..9b864c076a75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -26,7 +26,9 @@ import { type AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { DISPATCH_DOUBT_STOPPED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { @@ -182,15 +184,33 @@ export function cancelStructuredAgentSessionTurn( await context.stopAgent(ctx.sessionId) return { ok: true, value: { turnId: params.turnId, cancelled: true } } } - return child - ? plan.run(ctx) - : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } + try { + return child + ? await plan.run(ctx) + : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } + } finally { + await settleSendsLeftByStop(context, ctx) + } } }, openForWrite(context, params.envelope) ) } +/** After the interrupt, so the provider's own answer for a send it withdrew lands first. Nothing + * is owed a handed-over send once the user stopped: it settles in doubt, drawn as sent, and a late + * answer from the provider still replaces that. A failure here is reported, never Stop's. */ +async function settleSendsLeftByStop( + context: StructuredAgentSessionMutationContext, + ctx: Pick +): Promise { + try { + await ctx.journal.markPendingSubmissionsUnknown(ctx.fence, DISPATCH_DOUBT_STOPPED) + } catch (error) { + context.deps.onEventSinkError?.({ sessionId: ctx.sessionId, error }) + } +} + export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, 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 26697d3f6819..e9a7ee44cd7c 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 @@ -37,7 +37,6 @@ import { settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionCaller, @@ -315,10 +314,6 @@ export class StructuredAgentSessionHost { settleLateDispatch = (input: Parameters[1]) => settleStructuredAgentSessionLateDispatch(this.mutationContext(), input) - releaseUnansweredDispatches = ( - input: Parameters[1] - ) => releaseStructuredAgentSessionUnansweredDispatches(this.mutationContext(), input) - publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = (...args) => this.backgroundTasks.publish(...args) publishChildWorkEvidence = this.clientDelivery.publishChildWork diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 5e38b99bab5a..637e13a9af4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -194,35 +194,35 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() }) - // Stopping the child would retire the send in doubt; the provider's idle report settles it. - it('never stops an agent while a send it took is still in doubt, and stops it once settled', async () => { - rig.adapter.dispatch.mockResolvedValueOnce({ - state: 'unknown', - reason: 'provider_write_outcome_unknown: timeout' - }) - await foundRestTestChat(rig) - await vi.waitFor(async () => - expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([ - expect.objectContaining({ dispatchState: 'unknown' }) - ]) - ) - rig.clock.now += 2 * IDLE_MS - - await sweepTicks() - expect(rig.adapter.closeSession).not.toHaveBeenCalled() - await rig.host.releaseUnansweredDispatches({ - sessionId: SESSION, - reason: 'provider_idle_before_acknowledgement' - }) - rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) - // Settled without a row: the send is drawn as sent, and nothing explains the idle agent. - const settled = await rig.host.journalSnapshot(SESSION) - expect(settled.submissions).toEqual([ - expect.objectContaining({ dispatchState: 'unknown', recovered: true }) - ]) - expect(settled.items.some((item) => item.body.kind === 'status')).toBe(false) - }) + // The start that threw is no longer waiting on anything: the send settles in doubt, not owed. + it.each([ + ['threw', () => rig.adapter.dispatch.mockRejectedValueOnce(new Error('turn/start timed out'))], + [ + 'lost its write outcome', + () => + rig.adapter.dispatch.mockResolvedValueOnce({ + state: 'unknown', + reason: 'provider_write_outcome_unknown: timeout' + }) + ] + ])( + 'stops an agent once idle after its send start %s, the send drawn as sent', + async (_, fail) => { + fail() + await foundRestTestChat(rig) + await vi.waitFor(async () => + expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([ + expect.objectContaining({ dispatchState: 'unknown', recovered: true }) + ]) + ) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + // Settled without a row: nothing explains the idle agent. + const settled = await rig.host.journalSnapshot(SESSION) + expect(settled.items.some((item) => item.body.kind === 'status')).toBe(false) + } + ) it('never stops an agent a handed-over send still waits on to open its turn', async () => { rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index ea764b134ecf..579ef0465b42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -142,8 +142,8 @@ export class StructuredAgentSessionIdleSweep { /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the * handle, and a leftover prompt or turn row is only history. A send the child has neither - * answered nor refused — pending, or a live `unknown` — is owed too: stopping the child would - * retire it in doubt. The provider's answer, refusal or idle report settles it, as does an exit. */ + * answered nor refused is owed too: stopping the child would retire it in doubt. Its answer or + * refusal settles it, as do its turn's end, a Stop, a start that threw and an exit. */ private owesWork( sessionId: string, session: StructuredAgentSessionHostSession, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index d0bf3cca878e..8382378f1366 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -397,6 +397,92 @@ describe('a session with a turn in flight', () => { clock += IDLE_MS await waitForEviction() }) + + // The provider ending its turn ends what a send handed to it was owed; a late echo still lands. + it('settles an unanswered send in doubt when its turn ends, and is then stopped', async () => { + await attach() + emitTurnLifecycle('running', 1) + await host.flushStreamedEvents(SESSION) + await sendPending('steered, never echoed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + emitTurnLifecycle('completed', 2) + await host.flushStreamedEvents(SESSION) + const [sent] = (await host.journalSnapshot(SESSION)).submissions + expect(sent).toMatchObject({ + dispatchState: 'unknown', + reason: 'turn_settled_before_acknowledgement', + recovered: true + }) + const items = (await host.journalSnapshot(SESSION)).items + expect(items.some((item) => item.body.kind === 'status' && item.body.failure)).toBe(false) + + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: sent!.clientMessageId, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 3 } + }) + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') + clock += IDLE_MS + await waitForEviction() + }) + + it('keeps an unanswered send owed when only an earlier turn is revised', async () => { + await attach() + emitTurnLifecycle('running', 1) + emitTurnLifecycle('completed', 2) + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 3 }, + { kind: 'turn', turnId: 'turn-2', state: 'running' } + ) + await host.flushStreamedEvents(SESSION) + await sendPending('steered into turn-2') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + emitTurnLifecycle('completed', 2) + await host.flushStreamedEvents(SESSION) + + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('pending') + }) + + // Stop is never gated on what the provider answers: it ends whatever a send was still owed. + it('settles an unanswered send in doubt on Stop, and the chat reads idle', async () => { + await attach() + await sendPending('admitted, never echoed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + + expect(stopped).toMatchObject({ ok: true }) + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions[0]).toMatchObject({ + dispatchState: 'unknown', + reason: 'stopped_before_acknowledgement', + recovered: true + }) + expect(hasUnansweredStructuredAgentSessionDispatch(snapshot.submissions)).toBe(false) + clock += IDLE_MS + await waitForEviction() + }) + + it('still stops when settling the send fails', async () => { + await attach() + await sendPending('admitted, never echoed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + const journal = host['sessions'].get(SESSION)!.journal + vi.spyOn(journal, 'markPendingSubmissionsUnknown').mockRejectedValueOnce(new Error('disk full')) + + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + + expect(stopped).toMatchObject({ ok: true }) + expect(hostErrors).toEqual([expect.objectContaining({ message: 'disk full' })]) + }) }) describe('startup', () => { @@ -706,14 +792,15 @@ describe('an unexpected provider exit', () => { expect(dispatch).toHaveBeenCalledOnce() expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') - // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — its failure fact beside it, and never the old bare `Provider exited: ` row. + expect(history.ok && history.page.submissions[0]).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + // The start that threw settled its send in doubt, so no response was in progress to report. const statuses = history.ok ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) : [] - expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) - expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) + expect(statuses).toEqual([]) dispatch.mockResolvedValueOnce({ state: 'accepted', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49642b9a897e..8209e3512ba1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -70,7 +70,8 @@ function invalid( /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` * rather than as a rejection — unless the child had not proven its start. Such a child has * accepted nothing (input is written only after it initializes), so a dispatch it could not - * take is provably unwritten and is rejected with the cause the adapter gave. */ + * take is provably unwritten and is rejected with the cause the adapter gave. The `unknown` is + * written settled (see `handOverSubmission`). */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, @@ -203,17 +204,25 @@ export async function handOverSubmission( rejection: outcome.rejection, fence: ctx.fence } - : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } + : // A start that threw is no longer waiting on anything: the send is settled in doubt, + // not owed work. If the provider did take it, its turn and echo say so. + { + clientMessageId, + state: 'unknown', + reason: outcome.reason, + fence: ctx.fence, + recovered: true + } ) } catch (error) { - // A failed resolution must not strand a pending row; an unknown result is - // explicitly replayable. + // A failed resolution must not strand a pending row; the send is settled in doubt. try { await ctx.journal.resolveDispatch({ clientMessageId, state: 'unknown', reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, - fence: ctx.fence + fence: ctx.fence, + recovered: true }) } catch { // Nothing further to record; the pending row is settled on the next open. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts deleted file mode 100644 index 9a27415dc711..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' -import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' - -const FENCE = 7 - -function submission(over: Partial): AgentJournalSubmission { - return { - clientMessageId: 'm-1', - fence: FENCE, - payloadFingerprint: 'fp', - dispatchState: 'unknown', - providerItemId: null, - reason: 'provider_write_outcome_unknown: timeout', - submittedAt: 1, - resolvedAt: 2, - ...over - } -} - -type ReleaseContext = Parameters[0] -type ReleaseSession = NonNullable> -type ResolveDispatchInput = Parameters[0] - -function contextWith(submissions: AgentJournalSubmission[]) { - const resolved: ResolveDispatchInput[] = [] - const journal = { - submissions: () => submissions, - resolveDispatch: vi.fn(async (input: ResolveDispatchInput) => { - resolved.push(input) - return { epoch: 'e', sequence: 1 } - }) - } - // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and the record fence. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the session and the record is unreachable from this mutation. - const context = { - sessions: new Map([['s-1', { journal }]]), - deps: { store: { getRecord: () => ({ lease: { runtimeFence: FENCE } }) } } - } as unknown as ReleaseContext - return { context, resolved, journal } -} - -describe('releasing dispatches the provider can no longer answer', () => { - it('retires a live unknown so the session stops reading working', async () => { - const live = [submission({})] - // POSITIVE CONTROL: this is the latch being dissolved. - expect(projectStructuredAgentSessionStatus([], live, FENCE)).toBe('working') - const { context, resolved } = contextWith(live) - - await releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - expect(resolved).toEqual([ - { - clientMessageId: 'm-1', - state: 'unknown', - // The sharper earlier fact survives, exactly as restart recovery keeps it. - reason: 'provider_write_outcome_unknown: timeout', - fence: FENCE, - recovered: true - } - ]) - expect(projectStructuredAgentSessionStatus([], [submission({ recovered: true })], FENCE)).toBe( - 'idle' - ) - }) - - it('never touches a pending send, whose dispatch may still be in flight', async () => { - const { context, resolved } = contextWith([ - submission({ clientMessageId: 'm-2', dispatchState: 'pending', reason: null }) - ]) - - await releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - expect(resolved).toEqual([]) - }) - - it('leaves an already recovered unknown alone', async () => { - const { context, resolved } = contextWith([submission({ recovered: true })]) - - await releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - expect(resolved).toEqual([]) - }) - - it('falls back to the supplied reason when the submission named none', async () => { - const { context, resolved } = contextWith([submission({ reason: null })]) - - await releaseStructuredAgentSessionUnansweredDispatches(context, { - sessionId: 's-1', - reason: 'provider_idle_before_acknowledgement' - }) - - expect(resolved[0]).toMatchObject({ reason: 'provider_idle_before_acknowledgement' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts deleted file mode 100644 index 713838c177ec..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' - -/** - * Releases sends the provider can no longer be holding. - * - * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of - * non-delivery — and a live `unknown` reads as work still owed, so the session - * shows working until something re-derives it. The provider reporting its thread - * not running, with no turn open, IS that re-derivation. - * - * `pending` is deliberately untouched: that send's dispatch has not returned yet - * and may be in flight right now. And `recovered` only retires the obligation — - * it never makes a send re-deliverable, because the provider may well have run it. - */ -export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick & { - deps: { store: Pick } - }, - input: { sessionId: string; reason: string } -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const stranded = session.journal - .submissions() - .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) - if (stranded.length === 0) { - return - } - for (const entry of stranded) { - await session.journal.resolveDispatch({ - clientMessageId: entry.clientMessageId, - state: 'unknown', - // The earlier reason names a sharper fact than this one does. - reason: entry.reason ?? input.reason, - fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), - recovered: true - }) - } -} diff --git a/src/main/runtime/claude-structured-turn-end-settlement.test.ts b/src/main/runtime/claude-structured-turn-end-settlement.test.ts new file mode 100644 index 000000000000..a44468e4396c --- /dev/null +++ b/src/main/runtime/claude-structured-turn-end-settlement.test.ts @@ -0,0 +1,87 @@ +// A message handed to Claude during a turn that Claude then ends without taking it: the chat stops +// reading working, and the message is drawn as sent. Against the production runtime, adapter, +// record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-turn-end-settlement' +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +describe('a Claude turn that ends without taking a message handed to it', () => { + it.each([false, true])( + 'settles the message in doubt when its result arrives (is_error: %s)', + async (isError) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await send(host, 'write a story') + const child = claude.child(SESSION) + await vi.waitFor(() => expect(child.sent).toHaveLength(1)) + const providerSession = String(child.launch.options.sessionId) + child.handlers.onMessage?.({ ...child.sent[0], uuid: 'user-echo' }) + await host.flushStreamedEvents(SESSION) + const steer = await send(host, 'make it shorter') + await vi.waitFor(() => expect(child.sent).toHaveLength(2)) + + child.handlers.onMessage?.({ + type: 'result', + subtype: isError ? 'error_during_execution' : 'success', + is_error: isError, + session_id: providerSession, + uuid: 'result-1' + }) + await host.flushStreamedEvents(SESSION) + + await vi.waitFor(async () => { + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions.find((entry) => entry.clientMessageId === steer)).toMatchObject( + { + dispatchState: 'unknown', + reason: 'turn_settled_before_acknowledgement', + recovered: true + } + ) + const fence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence)).toBe( + false + ) + }) + } + ) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index fc06a2a782be..62b7ef312d15 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -50,6 +50,8 @@ let host: StructuredAgentSessionHost let fence: number let handlers: CodexAppServerConnectionHandlers | undefined let answers: number +/** Codex answers the next `turn/start` without naming a turn, so the send binds to none. */ +let answerWithoutTurn: boolean let turns: ReturnType let operations = 0 @@ -111,6 +113,7 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) answers = 0 + answerWithoutTurn = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -132,7 +135,8 @@ beforeEach(async () => { } if (method === 'turn/start') { answers += 1 - return turns.routes['turn/start']() + const answer = turns.routes['turn/start']() + return answerWithoutTurn ? {} : answer } if (method === 'turn/interrupt') { return turns.routes['turn/interrupt'](params) @@ -215,3 +219,54 @@ describe('a Codex send its turn ended without taking it', () => { expect(after.owesWork).toBe(false) }) }) + +describe('a Codex send its turn ended without answering', () => { + it('settles in doubt when the turn completes without echoing it', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const followUp = await send('and check the tests') + await vi.waitFor(() => expect(answers).toBe(2)) + + turns.end('completed') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') + ) + const after = await settled() + expect(after.submissions.find((entry) => entry.clientMessageId === followUp)).toMatchObject({ + dispatchState: 'unknown', + reason: 'turn_settled_before_acknowledgement', + recovered: true + }) + expect(after.owesWork).toBe(false) + }) + + // Codex follows every error it will not retry with its turn's failed completion. + it('settles in doubt after an error Codex will not retry ends its turn', async () => { + answerWithoutTurn = true + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + handlers?.onNotification?.('error', { + threadId: THREAD, + turnId: turns.turnId, + willRetry: false, + error: { message: 'Selected model is at capacity. Please try a different model.' } + }) + turns.end('failed', 'Selected model is at capacity. Please try a different model.') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).not.toBe('pending') + ) + const after = await settled() + expect(after.submissions[0]).toMatchObject({ + dispatchState: 'unknown', + reason: 'turn_settled_before_acknowledgement', + recovered: true + }) + expect(after.owesWork).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 1659fcd512ea..1973ab17b97c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -11,7 +11,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import { structuredAgentSessionTeardownTrigger, @@ -233,19 +232,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, - onPrimaryThreadStoppedRunning: ({ sessionId }) => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - }, onEvent: (event) => { if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { lifecycle.deliver(event) diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts index f1f9eb815d1d..d5b467e99758 100644 --- a/src/shared/structured-agent-session-unanswered-dispatch.ts +++ b/src/shared/structured-agent-session-unanswered-dispatch.ts @@ -26,9 +26,9 @@ export function isUnansweredStructuredAgentSessionDispatch( * on that echo to call a session working leaves the whole gap reading idle in the chat and in * every session list, so the send itself is the evidence. * - * A live `unknown` still counts because an ambiguous adapter reply does not prove the provider - * stopped. A recovered `unknown` does not — it outlived the host generation that sent it, so - * there is nothing still running to report. + * A live `unknown` (only an older host writes one) still counts: an ambiguous adapter reply does + * not prove the provider stopped. A recovered `unknown` does not — the provider's turn end, a + * Stop, a start that threw or the end of the host generation left nothing owing it an answer. */ export function hasUnansweredStructuredAgentSessionDispatch( submissions: readonly AgentJournalSubmission[], From c709f012ef02da6790b38464f0aec97635f6333f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:20:39 -0700 Subject: [PATCH 41/49] fix(native-chat): word a crash row by the boundary it crossed, not by when its proof landed One Orca crash could read "stopped while this response was in progress" or "didn't survive the restart" depending on whether the probe proving the old agent dead ran before or after the chat opened. The death evidence's kind is the boundary: only a running Orca watching the agent records exit-observed, and a probe proves death only for an owner whose Orca process was already gone. So probe evidence now writes the restart row, keyed by the dead owner's fence so both orders name the same row; an observed exit keeps the stopped row. --- ...d-agent-session-crash-row-boundary.test.ts | 94 +++++++++++++++++++ ...ured-agent-session-stale-settlement-row.ts | 47 +++++++--- ...d-agent-session-stale-turn-verdict.test.ts | 26 ++++- ...t-session-wedged-profile-migration.test.ts | 10 +- 4 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts new file mode 100644 index 000000000000..364d35b9d67f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts @@ -0,0 +1,94 @@ +// The row one Orca crash leaves names the restart, whichever side of the chat's open the proof of +// the old agent's death lands on, and a reopen adds nothing. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' + +// One Orca crash with a turn running: its owner is proven gone by a probe either before the chat +// opens (the lease has moved to fence 2) or after (the open still sees fence 1). +describe('an Orca crash reads as the restart whenever its proof lands', () => { + const CRASHED: AgentSessionDeathEvidence = { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ownerFence: 1, + lastProvenAliveAt: 100 + } + + async function crashedJournal() { + const root = await mkdtemp(join(tmpdir(), 'orca-crash-boundary-')) + const journals = createTrackedJournalOpener() + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'claude-1' } + }, + journalDir: root, + now: () => 9_000 + }) + await journal.appendItem( + { provider: 'claude', sessionId: 'claude-1', uuid: 'turn-row' }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 50 }, + { fence: 1 } + ) + const rows = () => + journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'status' ? [{ itemId: item.itemId, text: item.body.text }] : [] + ) + const dispose = async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + return { journal, rows, dispose } + } + + const restartRow = { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'crash-boundary:session-1:1' + }), + text: "Claude's session didn't survive the restart. Send a message to continue." + } + + it.each([ + ['before the chat opens', [{ fence: 2, deathEvidence: CRASHED, crashBoundary: true }]], + [ + 'after the chat opened', + [ + { fence: 1, deathEvidence: null, crashBoundary: true }, + { fence: 1, deathEvidence: CRASHED, crashBoundary: false } + ] + ] + ] as const)('writes the one restart row when the proof lands %s', async (_, settles) => { + const { journal, rows, dispose } = await crashedJournal() + try { + const reopen = { fence: 2, deathEvidence: CRASHED, crashBoundary: true } as const + for (const settle of [...settles, reopen]) { + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: settle.fence, + acquisitionGeneration: null, + deathEvidence: settle.deathEvidence, + failureTextContext: { agentName: 'Claude' }, + ...(settle.crashBoundary ? { crashBoundary: { sendsLeftInDoubt: 0 } } : {}) + }) + } + + expect(rows()).toEqual([restartRow]) + } finally { + await dispose() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-settlement-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-settlement-row.ts index 7ce3611815f4..2492314ce6c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-settlement-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-settlement-row.ts @@ -1,6 +1,6 @@ -// The one row a settle of a gone generation writes, and which: the provider's exit when a proof -// names the owner that was mid-response, else at a crash boundary that the session did not -// survive. One boundary never shows two. +// The one row a settle of a gone generation writes, and which, by the boundary the death crossed: +// the agent exiting while an Orca process watched it, or Orca itself going away. One boundary never +// shows two, and which one it was never depends on whether its proof landed before the chat opened. import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -19,7 +19,11 @@ const CRASH_BOUNDARY_ROW_PREFIX = 'crash-boundary:' /** The death evidence is Orca's log text, never a sentence for a person: a row says only that * the provider stopped, or that the session did not survive the restart. No Retry: sending a * new message is how the chat continues. Each is named by what it explains, so a retry after a - * partly written settle adds no second row. */ + * partly written settle adds no second row. + * + * The evidence kind is the boundary: only a running Orca watching the agent records + * `exit-observed`, while a probe proves death only for an owner whose transport holder, the Orca + * process that started it, was already gone. */ export function staleSettlementRow( input: { sessionId: string @@ -33,6 +37,9 @@ export function staleSettlementRow( const context = { ...input.failureTextContext, surface: 'row' as const } const evidence = input.deathEvidence if (evidence && (work.diedInProgress || work.provenUnexplained)) { + if (evidence.kind !== 'exit-observed') { + return crashBoundaryRow(input, evidence.ownerFence ?? input.fence, context) + } return { identity: { provider: 'orca', @@ -46,21 +53,31 @@ export function staleSettlementRow( } const boundary = input.crashBoundary if (boundary && (work.inProgress || boundary.sendsLeftInDoubt > 0)) { - return { - identity: { - provider: 'orca', - clientMessageId: `${CRASH_BOUNDARY_ROW_PREFIX}${input.sessionId}:${input.fence}` - }, - body: { - kind: 'status', - tone: 'notice', - ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), context) - } - } + return crashBoundaryRow(input, input.fence, context) } return null } +/** Keyed by the fence of the owner that died, so a proof landing before the open (the lease has + * moved on) and one landing after it (it has not) name the same row. */ +function crashBoundaryRow( + input: { sessionId: string }, + ownerFence: number, + context: AgentSessionFailureWordsContext & { surface: 'row' } +): Omit, 'kind'> { + return { + identity: { + provider: 'orca', + clientMessageId: `${CRASH_BOUNDARY_ROW_PREFIX}${input.sessionId}:${ownerFence}` + }, + body: { + kind: 'status', + tone: 'notice', + ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), context) + } + } +} + function isCrashBoundaryRow(item: AgentJournalRenderItem): boolean { const identity = parseAgentJournalItemKey(item.itemId) return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 7ad5d0387fc5..324e9fa9d66d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -448,12 +448,11 @@ describe('stale session state on a cold acquire', () => { state: 'interrupted', completedAt: 650 }) - // The probe's detail is Orca's, so the row carries none. + // A probe proves death only once the Orca that held the agent is gone: the restart's row, + // without the probe's detail, which is Orca's. expect( items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toEqual([ - 'The agent stopped while this response was in progress. You can continue in this conversation.' - ]) + ).toEqual(["The agent's session didn't survive the restart. Send a message to continue."]) } finally { await journals.closeAll() await rm(root, { recursive: true, force: true }) @@ -790,6 +789,25 @@ describe('stale session state on a cold acquire', () => { expect(await settle([unverifiable, boundaryRow])).toEqual([]) // A turn some other release left unverifiable has no row yet: the proof's is the one. expect(await settle([unverifiable])).toMatchObject([ + { + identity: { clientMessageId: 'crash-boundary:session-1:1' }, + body: { failure: { kind: 'hostRestarted' } } + } + ]) + }) + + it('says an observed exit stopped the agent even with no crash boundary', async () => { + const { journal, appendLifecycleBatch } = journalWith([ + lifecycleItem('turn-2', 'running', 2, { startedAt: 30 }) + ]) + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence: { kind: 'exit-observed', detail: 'exit', observedAt: 500, ownerFence: 1 } + }) + expect(boundaryRows(appendLifecycleBatch)).toMatchObject([ { body: { failure: { kind: 'providerExited' } } } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 07d09b7ad1b6..6968274fa391 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -351,17 +351,17 @@ describe('already-wedged profiles become usable on load', () => { ...verdict }) expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) - // The row never carries the proof's detail, which is Orca's log text. With no proof, the - // crash boundary's row is the one. + // The row never carries the proof's detail, which is Orca's log text. Only an exit the host + // watched says the agent stopped; a probe's proof, or none, is the restart the chat crossed. const statusRows = restoredJournal() .snapshot() .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) expect(statusRows).toEqual( - remote - ? ["Codex's session didn't survive the restart. Send a message to continue."] - : [ + deathEvidence?.kind === 'exit-observed' + ? [ 'Codex stopped while this response was in progress. You can continue in this conversation.' ] + : ["Codex's session didn't survive the restart. Send a message to continue."] ) // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) From 58f406f3de8409ea4baf5b4046439296e2b144af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:27:48 -0700 Subject: [PATCH 42/49] test(native-chat): type the turn-end and crash-boundary fixtures against the real journal shapes A Claude provider handle carries its leaf, and a turn has no 'failed' state: a failed turn completes with a failure outcome. Cover the real terminal states, unverifiable included. --- ...urnal-turn-end-dispatch-settlement.test.ts | 25 +++++++++++++------ ...d-agent-session-crash-row-boundary.test.ts | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts index 2a4e3fe917cb..69a9e321e5d4 100644 --- a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts @@ -22,8 +22,12 @@ function turnRow(turnId: string, ordinal: number): AgentJournalItemIdentity { return { provider: 'claude', sessionId: 'claude-1', uuid: `${turnId}-${ordinal}` } } -function turn(turnId: string, state: AgentJournalTurnItem['state']): AgentJournalTurnItem { - return { kind: 'turn', turnId, state } +function turn( + turnId: string, + state: AgentJournalTurnItem['state'], + outcome?: AgentJournalTurnItem['outcome'] +): AgentJournalTurnItem { + return { kind: 'turn', turnId, state, ...(outcome ? { outcome } : {}) } } async function open(): Promise { @@ -33,7 +37,7 @@ async function open(): Promise { workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-1' } + providerHandle: { kind: 'claude', sessionId: 'claude-1', leafUuid: null } }, journalDir: root, now: () => ++clock, @@ -68,14 +72,21 @@ afterEach(async () => { }) describe('a turn the provider ends', () => { - it.each(['completed', 'failed', 'interrupted'] as const)( - 'settles the sends it left unanswered in doubt when it %s', - async (state) => { + it.each([ + ['completed', 'success'], + ['completed', 'failure'], + ['interrupted', 'cancellation'], + ['unverifiable', undefined] + ] as const)( + 'settles the sends it left unanswered in doubt when it ends %s (%s)', + async (state, outcome) => { const journal = await open() await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) await handOver(journal, 'steer') - await journal.appendItem(turnRow('turn-1', 1), turn('turn-1', state), { fence: FENCE }) + await journal.appendItem(turnRow('turn-1', 1), turn('turn-1', state, outcome), { + fence: FENCE + }) expect(dispatchOf(journal, 'steer')).toMatchObject({ dispatchState: 'unknown', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts index 364d35b9d67f..e87c44f2f9a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-row-boundary.test.ts @@ -30,7 +30,7 @@ describe('an Orca crash reads as the restart whenever its proof lands', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-1' } + providerHandle: { kind: 'claude', sessionId: 'claude-1', leafUuid: null } }, journalDir: root, now: () => 9_000 From 8e25362a1a6e144679813998ca0c9e51748d9d7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:04:20 -0700 Subject: [PATCH 43/49] fix(native-chat): settle a doubted send at the provider's turn-over, not at every turn row that ends A send written during a Claude turn that the CLI runs as its NEXT request cycle was settled at the first turn's result: the result arrives without it, the next cycle's init right after, and its echo seconds later. For that gap the chat and every session list read idle and the finished announcement could fire, then flipped back to working at the echo. The journal inferred the turn-over from any terminal turn row, but a Claude result ends one request cycle, not the CLI's work. Each provider now declares its own turn-over through the event sink, queued behind the rows ahead of it: Claude at its session-state idle (at its result only for a CLI that never reports session state), Codex at the primary thread's turn/completed as before. A child's exit, a superseded cycle and a host settlement no longer settle sends on the side; the dead-generation settlement and Stop own those. --- .../claude/claude-session-state-turn-over.ts | 11 + .../claude-structured-journal-translation.ts | 13 +- .../codex/codex-structured-dispatch-echo.ts | 4 +- ...red-journal-translation-turn-boundaries.ts | 2 + .../codex-structured-turn-end-settlement.ts | 2 +- .../journal-dispatch-doubt-reasons.ts | 3 +- .../journal-item-appender.ts | 10 +- .../journal-lifecycle-batch-appender.ts | 4 +- .../journal-pending-submission-recovery.ts | 12 +- .../agent-session-journal/journal-store.ts | 8 +- ...urnal-turn-end-dispatch-settlement.test.ts | 191 ------------------ .../journal-turn-end-dispatch-settlement.ts | 82 -------- .../structured-agent-session-event-sink.ts | 25 ++- ...red-agent-session-surface-lifetime.test.ts | 21 +- ...ude-structured-turn-end-settlement.test.ts | 186 ++++++++++++++++- 15 files changed, 258 insertions(+), 316 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts diff --git a/src/main/claude/claude-session-state-turn-over.ts b/src/main/claude/claude-session-state-turn-over.ts index f2242f088fcd..d5a0a4a50783 100644 --- a/src/main/claude/claude-session-state-turn-over.ts +++ b/src/main/claude/claude-session-state-turn-over.ts @@ -7,6 +7,11 @@ // leaves the running lifecycle row latched, and the chat reads working for the // life of the session. // +// It is also what ends the sends handed to the CLI. A `result` ends one request cycle, and a send +// written during that cycle the CLI runs as its NEXT one: measured (2.1.280, p2-miss), the result +// arrives without it, the next init 28 ms later, its echo 3 s after that, and no idle in between. +// A CLI that never reports state gets its `result` as the turn-over, as its turns do. +// // `requires_action` is deliberately NOT an end. The turn is parked on the user, // and a pending approval or question already projects as attention; settling // here would read as idle in the gap before that row lands. @@ -17,6 +22,12 @@ function isClaudeSessionStateFrame(message: Record): boolean { return message.type === 'system' && message.subtype === 'session_state_changed' } +/** Whether this frame is the CLI reporting its state at all. A CLI that reports it announces + * `running` before a cycle's first frame, so this is known before the first `result`. */ +export function claudeReportsSessionState(message: Record): boolean { + return isClaudeSessionStateFrame(message) +} + /** Whether this frame reports the CLI has no work in flight. */ export function claudeSessionStateEndsTurn(message: Record): boolean { return isClaudeSessionStateFrame(message) && claudeText(message.state) === 'idle' diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 082b0024061f..41d102b99973 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -29,7 +29,10 @@ import { import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' import { ClaudeContextFacts } from './claude-context-facts' -import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' +import { + claudeReportsSessionState, + claudeSessionStateEndsTurn +} from './claude-session-state-turn-over' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' @@ -161,6 +164,9 @@ export function createClaudeJournalTranslator( turn } + // See `claude-session-state-turn-over.ts`: the CLI's idle is its turn-over when it reports state. + let reportsSessionState = false + const handleMessage = ( message: Record, startsTurn: boolean, @@ -183,6 +189,7 @@ export function createClaudeJournalTranslator( return } if (event.type === 'message') { + reportsSessionState ||= claudeReportsSessionState(event.message) context.observe(event.message, event.observedAt ?? Date.now()) // A root init is the CLI starting a new request cycle (measured per turn, // per queued turn, per background wake, per /compact); a send replayed @@ -238,6 +245,9 @@ export function createClaudeJournalTranslator( // would otherwise retain that text for the life of the session. streamedBlocks.clear() streamedText.settle() + if (!reportsSessionState) { + deps.sink.settleSendsAtTurnOver?.() + } } const kind = claudeProviderFrameKind(event.message) const failure = claudeResultFailure(event.message) @@ -286,6 +296,7 @@ export function createClaudeJournalTranslator( subagents.settleTurn(turn.groupKey) // No verdict: the CLI said the turn is over, not how it ended. turn.settle({ state: 'completed', completedAt: event.observedAt ?? Date.now() }) + deps.sink.settleSendsAtTurnOver?.() } } else if (event.type === 'provider-frame') { providerFallback.append(event.kind, event.payload) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 02a24f6d927a..d22e5d7e944b 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -2,7 +2,7 @@ import type { ProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that - * end; any other whose echo never arrives is settled in doubt by the journal at its turn's end. */ + * end; any other whose echo never arrives is settled in doubt when its turn completes. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 /** Turn ends kept for an answer read after the turn it names had already ended. */ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 @@ -41,7 +41,7 @@ export type CodexDispatchEchoes = { /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed is left to the - * journal's turn-end settlement, and stays armed for a late echo. + * turn-over the translator declares, and stays armed for a late echo. */ endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] /** Submission origin for this exact send, retained until its echo settles it. */ diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 3116a329c97b..10a8b5530211 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -190,6 +190,8 @@ export class CodexJournalTurnBoundaries { requestOrigin, latestDispatchSequence ) + // Codex runs one turn per thread and echoes a send's pending input before this frame. + this.deps.sink.settleSendsAtTurnOver?.() } this.deps.items.ordinals.forgetTurn(event.threadId, turnId) this.deps.activeTurns.forget(event.threadId, turnId) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index dc4113ce5aad..19c09caa8942 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -3,7 +3,7 @@ // and is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending // input before `turn/completed`, a failed turn after its `error` frame, so only that // frame settles: a failed turn that never echoed the send refused it, in Codex's words, -// and a completed one leaves it to the journal, which settles it in doubt at that turn end. +// and a completed one leaves it to the translator's turn-over, which settles it in doubt. import { agentSessionFailureFact, diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 36fb953086bc..ae880d83d2b4 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -23,7 +23,8 @@ export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_fa /** The operation tombstone survived recovery but its journal submission did not. */ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' -/** The provider ended the turn it was running without answering the message. */ +/** The provider said its turn was over (Claude's idle, Codex's `turn/completed`) without + * answering the message. */ export const DISPATCH_DOUBT_TURN_SETTLED = 'turn_settled_before_acknowledgement' /** The user stopped the session before the message was answered. */ diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 5fde9b9ce33e..e0bf1ad62381 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -19,17 +19,11 @@ export class JournalItemAppender { append( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions, - /** Runs inside the serialized build, before the row is applied. */ - beforeBuild?: () => void + options: JournalItemAppendOptions ): Promise { const itemId = agentJournalItemKey(identity) - const build = journalItemRowBuilder(this.deps.state, identity, body, options) return this.deps - .enqueue((seq, ts) => { - beforeBuild?.() - return build(seq, ts) - }) + .enqueue(journalItemRowBuilder(this.deps.state, identity, body, options)) .then((row) => ({ cursor: { epoch: row.epoch, sequence: row.seq }, itemId, diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 31e6a30c9414..c05d22a87457 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -15,8 +15,7 @@ export class JournalLifecycleBatchAppender { } ) {} - /** `beforeBuild` runs inside the serialized build, before the row is applied. */ - append(input: JournalLifecycleBatchInput, beforeBuild?: () => void): Promise { + append(input: JournalLifecycleBatchInput): Promise { if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } @@ -31,7 +30,6 @@ export class JournalLifecycleBatchAppender { if (this.wasApplied(input.settlementId)) { throw SETTLEMENT_ALREADY_APPLIED } - beforeBuild?.() return build(seq, ts) }) .then((row) => ({ epoch: row.epoch, sequence: row.seq })) diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index 6b153c616c50..c252a57d8a57 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -4,14 +4,13 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' -/** Settles every submission a process fact left unanswerable, optionally only those `which` - * names. Doubt is never proof of non-delivery, so nothing here ever becomes re-deliverable. A - * queued submission was never handed over, so it is not in doubt and is left alone. */ +/** Settles every submission a process fact left unanswerable. Doubt is never + * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued + * submission was never handed over, so it is not in doubt and is left alone. */ export async function markJournalPendingSubmissionsUnknown( journal: AgentSessionJournal, fence: number, - reason: string = DISPATCH_DOUBT_HOST_RESTARTED, - which: (submission: AgentJournalSubmission) => boolean = () => true + reason: string = DISPATCH_DOUBT_HOST_RESTARTED ): Promise { const unresolved = journal .submissions() @@ -19,8 +18,7 @@ export async function markJournalPendingSubmissionsUnknown( (entry) => !isQueuedAgentJournalSubmission(entry) && (entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true)) && - which(entry) + (entry.dispatchState === 'unknown' && entry.recovered !== true)) ) for (const entry of unresolved) { // An earlier reason already names a sharper fact than "the host restarted". diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 6faf19c53f80..3be3aebbc514 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -24,7 +24,6 @@ import { import { agentSessionJournalCloseRetries } from './journal-close-retry' import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' import type { JournalReplacementItem } from './journal-epoch-replacement' -import { journalTurnEndDispatchSettlement } from './journal-turn-end-dispatch-settlement' import { readJournalSince } from './journal-cursor' import { readJournalRowsAfterCursor, type JournalLoad } from './journal-open' import { journalDatabaseFile } from './journal-paths' @@ -276,8 +275,7 @@ export class AgentSessionJournal { body: AgentJournalItemBody, options: JournalItemAppendOptions = { fence: 0 } ): Promise { - const turnEnd = journalTurnEndDispatchSettlement(this, () => this.state, [body], options) - return turnEnd.after(this.itemAppender.append(identity, body, options, turnEnd.capture)) + return this.itemAppender.append(identity, body, options) } appendTombstone( @@ -291,9 +289,7 @@ export class AgentSessionJournal { } appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { - const bodies = input.mutations.flatMap((entry) => (entry.kind === 'item' ? [entry.body] : [])) - const turnEnd = journalTurnEndDispatchSettlement(this, () => this.state, bodies, input) - return turnEnd.after(this.lifecycleBatchAppender.append(input, turnEnd.capture)) + return this.lifecycleBatchAppender.append(input) } /** diff --git a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts deleted file mode 100644 index 69a9e321e5d4..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -// The provider's end of the turn it was running ends what a send handed to it was owed, on either -// write path, and never for a revision of an earlier turn, a host settlement or another fence. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem -} from '../../../shared/agent-session-journal-types' -import { owesStructuredAgentSessionWork } from '../../../shared/structured-agent-session-owed-work' -import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' - -const FENCE = 3 -const journals = createTrackedJournalOpener() -let root: string -let clock = 1_000 - -function turnRow(turnId: string, ordinal: number): AgentJournalItemIdentity { - return { provider: 'claude', sessionId: 'claude-1', uuid: `${turnId}-${ordinal}` } -} - -function turn( - turnId: string, - state: AgentJournalTurnItem['state'], - outcome?: AgentJournalTurnItem['outcome'] -): AgentJournalTurnItem { - return { kind: 'turn', turnId, state, ...(outcome ? { outcome } : {}) } -} - -async function open(): Promise { - return journals.open({ - identity: { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-1', leafUuid: null } - }, - journalDir: root, - now: () => ++clock, - mintEpoch: () => 'epoch-1' - }) -} - -/** A send accepted and handed to the provider, awaiting its answer. */ -async function handOver(journal: AgentSessionJournal, clientMessageId: string, fence = FENCE) { - await journal.appendSubmission({ - clientMessageId, - payloadFingerprint: `fingerprint-${clientMessageId}`, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - fence, - handoverRecorded: true - }) - await journal.resolveDispatch({ clientMessageId, state: 'pending', fence }) -} - -function dispatchOf(journal: AgentSessionJournal, clientMessageId: string) { - return journal.submissions().find((entry) => entry.clientMessageId === clientMessageId) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-turn-end-settlement-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a turn the provider ends', () => { - it.each([ - ['completed', 'success'], - ['completed', 'failure'], - ['interrupted', 'cancellation'], - ['unverifiable', undefined] - ] as const)( - 'settles the sends it left unanswered in doubt when it ends %s (%s)', - async (state, outcome) => { - const journal = await open() - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) - await handOver(journal, 'steer') - - await journal.appendItem(turnRow('turn-1', 1), turn('turn-1', state, outcome), { - fence: FENCE - }) - - expect(dispatchOf(journal, 'steer')).toMatchObject({ - dispatchState: 'unknown', - reason: 'turn_settled_before_acknowledgement', - recovered: true - }) - const snapshot = journal.snapshot() - expect(owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, FENCE)).toBe( - false - ) - } - ) - - it('settles through a lifecycle batch as through a single row', async () => { - const journal = await open() - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) - await handOver(journal, 'steer') - - await journal.appendLifecycleBatch({ - settlementId: 'turn-1-end', - fence: FENCE, - mutations: [ - { kind: 'item', identity: turnRow('turn-1', 0), body: turn('turn-1', 'completed') } - ] - }) - - expect(dispatchOf(journal, 'steer')).toMatchObject({ - dispatchState: 'unknown', - recovered: true - }) - }) - - it('lets a late echo replace the doubt', async () => { - const journal = await open() - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) - await handOver(journal, 'steer') - await journal.appendItem(turnRow('turn-1', 1), turn('turn-1', 'completed'), { fence: FENCE }) - - await journal.resolveDispatch({ - clientMessageId: 'steer', - state: 'accepted', - providerIdentity: turnRow('echo', 0), - fence: FENCE - }) - - expect(dispatchOf(journal, 'steer')).toMatchObject({ dispatchState: 'accepted' }) - expect(dispatchOf(journal, 'steer')?.recovered).toBeUndefined() - }) - - it('leaves a send it never held: queued, another fence, or handed over after the end', async () => { - const journal = await open() - await handOver(journal, 'older-child', FENCE - 1) - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) - await journal.appendSubmission({ - clientMessageId: 'queued', - payloadFingerprint: 'fingerprint-queued', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued' }] }, - fence: FENCE, - handoverRecorded: true - }) - - const ended = journal.appendItem(turnRow('turn-1', 1), turn('turn-1', 'completed'), { - fence: FENCE - }) - const after = handOver(journal, 'after-the-end') - await Promise.all([ended, after]) - - expect(dispatchOf(journal, 'queued')?.dispatchState).toBe('pending') - expect(dispatchOf(journal, 'older-child')?.dispatchState).toBe('pending') - expect(dispatchOf(journal, 'after-the-end')?.dispatchState).toBe('pending') - }) - - it('ends nothing when it revises an earlier turn while a later one runs', async () => { - const journal = await open() - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'completed'), { fence: FENCE }) - await journal.appendItem(turnRow('turn-2', 0), turn('turn-2', 'running'), { fence: FENCE }) - await handOver(journal, 'steer') - - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'interrupted'), { - fence: FENCE - }) - - expect(dispatchOf(journal, 'steer')?.dispatchState).toBe('pending') - }) - - it("leaves a host settlement's sends to the reason it gives them", async () => { - const journal = await open() - await journal.appendItem(turnRow('turn-1', 0), turn('turn-1', 'running'), { fence: FENCE }) - await handOver(journal, 'steer') - - await journal.appendLifecycleBatch({ - settlementId: 'dead-generation', - fence: FENCE, - recovered: true, - mutations: [ - { kind: 'item', identity: turnRow('turn-1', 0), body: turn('turn-1', 'interrupted') } - ] - }) - - expect(dispatchOf(journal, 'steer')?.dispatchState).toBe('pending') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts deleted file mode 100644 index 8b50f55101b5..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-turn-end-dispatch-settlement.ts +++ /dev/null @@ -1,82 +0,0 @@ -// A provider ending the turn it was running ends what every send handed to it still owed. -// -// A handed-over send the provider has neither answered nor refused counts as work, so the chat -// reads working and the idle sweep keeps the agent. Once the provider ends its turn, nothing is left -// that owes that send an answer: it settles as recovered `unknown` — doubt, drawn as sent — and a -// late acceptance or refusal from the provider still replaces it. The sends are read inside the -// terminal row's serialized write, so one handed over after that row is never taken for this turn's. - -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { activeStructuredAgentSessionTurnIdBySequence } from '../../../shared/structured-agent-session-live-turn' -import { structuredAgentSessionSubmissionSettlement } from '../../../shared/structured-agent-session-submission-settlement' -import { DISPATCH_DOUBT_TURN_SETTLED } from './journal-dispatch-doubt-reasons' -import { markJournalPendingSubmissionsUnknown } from './journal-pending-submission-recovery' -import type { JournalReducerState } from './journal-reducer' -import type { AgentSessionJournal } from './journal-store' - -export type JournalTurnEndDispatchSettlement = { - /** Runs inside the write's serialized build, before its row is applied. */ - capture: () => void - /** Settles what `capture` found once the write has landed; never fails the write. */ - after: (write: Promise) => Promise -} - -const NOTHING_TO_SETTLE: JournalTurnEndDispatchSettlement = { - capture: () => undefined, - after: (write) => write -} - -export function journalTurnEndDispatchSettlement( - journal: AgentSessionJournal, - state: () => JournalReducerState, - bodies: readonly AgentJournalItemBody[], - /** A host settlement writes `recovered` rows and settles its own sends. */ - options: { fence: number; recovered?: true } -): JournalTurnEndDispatchSettlement { - const endedTurnIds = options.recovered - ? [] - : bodies.flatMap((body) => { - const turn = readAgentJournalTurn(body) - return turn && turn.state !== 'running' ? [turn.turnId] : [] - }) - if (endedTurnIds.length === 0) { - return NOTHING_TO_SETTLE - } - const owed = new Set() - return { - capture: () => { - const { items, submissions } = state() - const active = activeStructuredAgentSessionTurnIdBySequence(items.values()) - // Only the turn running now: a late revision of an earlier turn ends nothing handed over since. - if (active === null || !endedTurnIds.includes(active)) { - return - } - for (const submission of submissions.values()) { - if ( - submission.fence === options.fence && - !isQueuedAgentJournalSubmission(submission) && - structuredAgentSessionSubmissionSettlement(submission) === 'open' - ) { - owed.add(submission.clientMessageId) - } - } - }, - after: (write) => - write.then(async (result) => { - if (owed.size > 0) { - // The next exit (a Stop, the child's end, the next open) settles what this could not. - await markJournalPendingSubmissionsUnknown( - journal, - options.fence, - DISPATCH_DOUBT_TURN_SETTLED, - (submission) => owed.has(submission.clientMessageId) - ).catch((error: unknown) => { - console.warn('[agent-session] settling sends at a turn end failed:', error) - }) - } - return result - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index ad92e1c24cab..7549e55ac1c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -5,6 +5,7 @@ import type { AgentJournalProducerLinkage } from '../../../shared/agent-session-journal-types' import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import { DISPATCH_DOUBT_TURN_SETTLED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' @@ -134,6 +135,9 @@ export type StructuredAgentSessionEventSink = { tryPublish?(options?: StructuredAgentSessionAppendOptions): StructuredAgentSessionSinkAdmission /** Couples durable-queue pressure to the exact provider stream producing it. */ bindReadingControl?(control: StructuredAgentSessionReadingControl): () => void + /** The provider's own word that its turn is over: after the rows queued ahead of it land, no + * send handed to it is still owed an answer. Each provider calls it at its turn-over signal. */ + settleSendsAtTurnOver?(): void } export type StructuredAgentSessionEventTarget = { @@ -304,7 +308,26 @@ export function createDeferredStructuredAgentSessionEventSink( run: (bound) => bound.publish(activity) }) }, - tryPublish: publish + tryPublish: publish, + settleSendsAtTurnOver: () => { + queue.submit( + { + bytes: 64, + // Bookkeeping: a failure is logged and the next exit (Stop, the child's end, the next + // open) settles the send; it must never fail the sink and take the provider down. + run: (bound) => + bound.journal + .markPendingSubmissionsUnknown(bound.fence, DISPATCH_DOUBT_TURN_SETTLED) + .then( + () => undefined, + (error: unknown) => { + console.warn('[agent-session] settling sends at a turn end failed:', error) + } + ) + }, + { lifecycle: true } + ) + } }, bind: (next) => queue.bind(next), unbind: () => queue.unbind(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 8382378f1366..29dfd4d77f13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -398,8 +398,9 @@ describe('a session with a turn in flight', () => { await waitForEviction() }) - // The provider ending its turn ends what a send handed to it was owed; a late echo still lands. - it('settles an unanswered send in doubt when its turn ends, and is then stopped', async () => { + // The provider declaring its turn over ends what a send handed to it was owed; a late echo + // still lands. + it('settles an unanswered send in doubt when its turn is over, and is then stopped', async () => { await attach() emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) @@ -407,6 +408,7 @@ describe('a session with a turn in flight', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) emitTurnLifecycle('completed', 2) + sink?.settleSendsAtTurnOver?.() await host.flushStreamedEvents(SESSION) const [sent] = (await host.journalSnapshot(SESSION)).submissions expect(sent).toMatchObject({ @@ -427,22 +429,21 @@ describe('a session with a turn in flight', () => { await waitForEviction() }) - it('keeps an unanswered send owed when only an earlier turn is revised', async () => { + // A turn row ending is not the provider's turn-over: Claude ends one request cycle per result + // and runs a send written during it as the next cycle. + it('keeps an unanswered send owed when a turn row ends without the provider saying so', async () => { await attach() emitTurnLifecycle('running', 1) - emitTurnLifecycle('completed', 2) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 3 }, - { kind: 'turn', turnId: 'turn-2', state: 'running' } - ) await host.flushStreamedEvents(SESSION) - await sendPending('steered into turn-2') + await sendPending('runs as the next cycle') await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) emitTurnLifecycle('completed', 2) await host.flushStreamedEvents(SESSION) - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('pending') + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions[0]?.dispatchState).toBe('pending') + expect(hasUnansweredStructuredAgentSessionDispatch(snapshot.submissions)).toBe(true) }) // Stop is never gated on what the provider answers: it ends whatever a send was still owed. diff --git a/src/main/runtime/claude-structured-turn-end-settlement.test.ts b/src/main/runtime/claude-structured-turn-end-settlement.test.ts index a44468e4396c..68202e849913 100644 --- a/src/main/runtime/claude-structured-turn-end-settlement.test.ts +++ b/src/main/runtime/claude-structured-turn-end-settlement.test.ts @@ -1,9 +1,12 @@ -// A message handed to Claude during a turn that Claude then ends without taking it: the chat stops -// reading working, and the message is drawn as sent. Against the production runtime, adapter, +// A message handed to Claude during a turn: it stays owed work until Claude says its turn is over, +// and one Claude never took is then drawn as sent. Against the production runtime, adapter, // record store and host, with only the CLI process scripted. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionTurnCompletionEvent } from '../../shared/agent-session-wire' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -44,7 +47,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { it.each([false, true])( - 'settles the message in doubt when its result arrives (is_error: %s)', + 'settles the message in doubt at the result of a CLI that reports no session state (is_error: %s)', async (isError) => { const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ @@ -85,3 +88,180 @@ describe('a Claude turn that ends without taking a message handed to it', () => } ) }) + +type CapturedEvent = + | { at: number; kind: 'frame'; frame: Record } + | { at: number; kind: 'dispatch'; sentUuid: string; text: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A capture recorded through Orca's adapter against the real CLI (2.1.280), verbatim. */ +function loadCapture(name: string): { providerSessionId: string; events: CapturedEvent[] } { + const lines = readFileSync( + join(__dirname, '..', 'claude', '__fixtures__', `claude-adapter-capture-${name}.jsonl`), + 'utf8' + ) + .trim() + .split('\n') + .map((line): unknown => JSON.parse(line)) + let providerSessionId = '' + const events: CapturedEvent[] = [] + for (const line of lines) { + if (!isRecord(line) || typeof line.at !== 'number') { + throw new Error('capture line is not a recorded event') + } + if (line.kind === 'meta' && typeof line.providerSessionId === 'string') { + providerSessionId = line.providerSessionId + } else if (line.kind === 'frame' && isRecord(line.frame)) { + events.push({ at: line.at, kind: 'frame', frame: line.frame }) + } else if ( + line.kind === 'dispatch' && + typeof line.sentUuid === 'string' && + typeof line.text === 'string' + ) { + events.push({ at: line.at, kind: 'dispatch', sentUuid: line.sentUuid, text: line.text }) + } + } + return { providerSessionId, events } +} + +async function owesWork(host: StructuredAgentSessionHost): Promise { + const snapshot = await host.journalSnapshot(SESSION) + const fence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + return owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) +} + +async function submissionOf(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function attachWithCompletions(): Promise<{ + host: StructuredAgentSessionHost + completions: string[] +}> { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const completions: string[] = [] + host.subscribeTurnCompletions({ + id: 'turn-completions', + emit: (event: AgentSessionTurnCompletionEvent) => { + if (event.type === 'completion') { + completions.push(event.completion.turnId) + } + } + }) + return { host, completions } +} + +describe('a message Claude runs as its next request cycle', () => { + // p2-miss: the steer lands too late to fold. result-1 arrives without it, the next cycle's init + // right after, its echo later — and Claude reports idle only after result-2. + it('stays owed work from its send to its echo, with no completion in the gap', async () => { + const { host, completions } = await attachWithCompletions() + const capture = loadCapture('miss') + const uuids = new Map() + const clientIds: string[] = [] + let providerSession = '' + const replay = (frame: Record): Record => { + let text = JSON.stringify(frame).replaceAll(capture.providerSessionId, providerSession) + for (const [captured, live] of uuids) { + text = text.replaceAll(captured, live) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + // Frames recorded ahead of the first send reach a child only once a send has spawned it. + const early: Record[] = [] + let steerEchoed = false + for (const event of capture.events) { + if (event.kind === 'dispatch') { + clientIds.push(await send(host, event.text)) + const child = claude.child(SESSION) + await vi.waitFor(() => expect(child.sent).toHaveLength(clientIds.length)) + providerSession = String(child.launch.options.sessionId) + uuids.set(event.sentUuid, String(child.sent.at(-1)?.uuid)) + for (const frame of early.splice(0)) { + child.handlers.onMessage?.(replay(frame)) + } + continue + } + if (clientIds.length === 0) { + early.push(event.frame) + continue + } + claude.child(SESSION).handlers.onMessage?.(replay(event.frame)) + await host.flushStreamedEvents(SESSION) + const steer = clientIds[1] + if (steer && !steerEchoed) { + steerEchoed = (await submissionOf(host, steer))?.dispatchState === 'accepted' + if (!steerEchoed) { + // Through result-1 (21085) and the next cycle's init: working, and nothing announced. + expect(await owesWork(host)).toBe(true) + expect((await submissionOf(host, steer))?.recovered).toBeUndefined() + expect(completions).toEqual([]) + } + } + } + + expect(steerEchoed).toBe(true) + await vi.waitFor(async () => expect(await owesWork(host)).toBe(false)) + const [first, steer] = await Promise.all(clientIds.map((id) => submissionOf(host, id))) + expect(first?.dispatchState).toBe('accepted') + expect(steer?.dispatchState).toBe('accepted') + }) + + it('settles one Claude never runs at its idle, not at the result before it', async () => { + const { host, completions } = await attachWithCompletions() + const first = await send(host, 'write a story') + const child = claude.child(SESSION) + await vi.waitFor(() => expect(child.sent).toHaveLength(1)) + const providerSession = String(child.launch.options.sessionId) + const state = (value: string) => ({ + type: 'system', + subtype: 'session_state_changed', + state: value, + session_id: providerSession, + uuid: `state-${value}` + }) + child.handlers.onMessage?.(state('running')) + child.handlers.onMessage?.({ ...child.sent[0], isReplay: true }) + await host.flushStreamedEvents(SESSION) + const steer = await send(host, 'make it shorter') + await vi.waitFor(() => expect(child.sent).toHaveLength(2)) + + child.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + is_error: false, + session_id: providerSession, + uuid: 'result-1', + user_message_uuid: child.sent[0]?.uuid, + user_message_uuids: [child.sent[0]?.uuid] + }) + await host.flushStreamedEvents(SESSION) + expect((await submissionOf(host, steer))?.dispatchState).toBe('pending') + expect(await owesWork(host)).toBe(true) + expect(completions).toEqual([]) + + child.handlers.onMessage?.(state('idle')) + await host.flushStreamedEvents(SESSION) + await vi.waitFor(async () => + expect(await submissionOf(host, steer)).toMatchObject({ + dispatchState: 'unknown', + reason: 'turn_settled_before_acknowledgement', + recovered: true + }) + ) + expect(await owesWork(host)).toBe(false) + expect((await submissionOf(host, first))?.dispatchState).toBe('accepted') + }) +}) From 3361da8da263864ebff5000e8a10d09e095c340b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:12:20 -0700 Subject: [PATCH 44/49] fix(native-chat): keep a failed turn-over settle from stalling the event sink A journal that throws before returning its promise escaped the handler and stalled the queue behind it. The settle is bookkeeping, so any failure is logged and the next exit settles the send. --- ...tured-journal-translation-settlement.test.ts | 4 +++- .../structured-agent-session-event-sink.ts | 17 +++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index ab0e602e955d..e152290a32cb 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -110,6 +110,7 @@ function deferredTarget( return { cursor: { epoch: 'e', sequence: log.length } } }), appendTombstone: vi.fn(async () => ({ epoch: 'e', sequence: log.length })), + markPendingSubmissionsUnknown: vi.fn(async () => []), appendLifecycleBatch: vi.fn( async (input: { mutations: readonly JournalLifecycleMutationInput[] }) => { for (const mutation of input.mutations) { @@ -212,7 +213,8 @@ describe('codex journal translation', () => { expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true }) + // The turn-over declaration after the end row is admitted across the watermark too. + expect(deferred.state()).toMatchObject({ queuedOperations: 6, backpressured: true }) deferred.bind(deferredTarget(bodies, publishes)) await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 7549e55ac1c9..b07fa4c5dc8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -315,15 +315,16 @@ export function createDeferredStructuredAgentSessionEventSink( bytes: 64, // Bookkeeping: a failure is logged and the next exit (Stop, the child's end, the next // open) settles the send; it must never fail the sink and take the provider down. - run: (bound) => - bound.journal - .markPendingSubmissionsUnknown(bound.fence, DISPATCH_DOUBT_TURN_SETTLED) - .then( - () => undefined, - (error: unknown) => { - console.warn('[agent-session] settling sends at a turn end failed:', error) - } + run: async (bound) => { + try { + await bound.journal.markPendingSubmissionsUnknown( + bound.fence, + DISPATCH_DOUBT_TURN_SETTLED ) + } catch (error) { + console.warn('[agent-session] settling sends at a turn end failed:', error) + } + } }, { lifecycle: true } ) From 76c2d5b324a1fd6567c42f5826a363e430bdfce3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:13:46 -0700 Subject: [PATCH 45/49] test(codex): leave the cast journal fake untouched; the turn-over settle tolerates its absence --- .../codex-structured-journal-translation-settlement.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index e152290a32cb..4b4c3913af20 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -110,7 +110,6 @@ function deferredTarget( return { cursor: { epoch: 'e', sequence: log.length } } }), appendTombstone: vi.fn(async () => ({ epoch: 'e', sequence: log.length })), - markPendingSubmissionsUnknown: vi.fn(async () => []), appendLifecycleBatch: vi.fn( async (input: { mutations: readonly JournalLifecycleMutationInput[] }) => { for (const mutation of input.mutations) { From 814cf1162fcaa90acbe6ff27753d5fbcf1953817 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:26:08 -0700 Subject: [PATCH 46/49] test(codex): pin that a subagent thread's turn end leaves the user's send owed Only the primary thread's turn/completed is Codex's turn-over for a send; a child thread ending its own turn must not settle it. Fails if the settle fires for any thread. --- ...-session-codex-turn-end-settlement.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 62b7ef312d15..765f2004644c 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -243,6 +243,31 @@ describe('a Codex send its turn ended without answering', () => { expect(after.owesWork).toBe(false) }) + // A subagent's thread ends its own turns; only the primary thread's turn-over ends a user's send. + it('keeps the send owed through a child thread ending its turn', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const followUp = await send('and check the tests') + await vi.waitFor(() => expect(answers).toBe(2)) + + const child = { threadId: 'child-thread', turn: { id: 'child-turn-1', status: 'completed' } } + handlers?.onNotification?.('turn/started', { + ...child, + turn: { ...child.turn, status: 'inProgress' } + }) + handlers?.onNotification?.('turn/completed', child) + const during = await settled() + expect(verdictOf(during.submissions, followUp)).toBe('pending') + expect(during.owesWork).toBe(true) + + turns.end('completed') + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, followUp)).toBe('unknown') + ) + }) + // Codex follows every error it will not retry with its turn's failed completion. it('settles in doubt after an error Codex will not retry ends its turn', async () => { answerWithoutTurn = true From 726cf30237d9dcd2a0ae312fe0bf957672dbef40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:42:23 -0700 Subject: [PATCH 47/49] fix(native-chat): read main's two new send-state checks through the settlement classifier The Stop-withdrawal restore and the turn-grouping in-flight set landed on main reading a submission's dispatch state directly, which the settlement boundary test refuses. Withdrawn is a refused send whose rejection classifies as withdrawn; the in-flight set is the open sends not yet matched to a provider item. --- ...ructured-agent-session-withdrawn-message-restore.ts | 6 ++++-- .../structured-agent-session-dispatch-rejection.ts | 10 ---------- .../structured-agent-session-submission-settlement.ts | 10 ++++++++++ src/shared/structured-agent-session-turn-timing.ts | 6 +++++- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index fb8a0c202e58..a2e564676ae8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionWasWithdrawn } from '../../../../shared/structured-agent-session-submission-settlement' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { readOutbox } from './structured-agent-session-outbox-storage' @@ -61,7 +61,9 @@ export function useStructuredAgentSessionWithdrawnRestore( () => ({ byHost: (entries, submissions) => { const withdrawn = new Set( - submissions.filter(dispatchWasWithdrawn).map((submission) => submission.clientMessageId) + submissions + .filter(structuredAgentSessionSubmissionWasWithdrawn) + .map((submission) => submission.clientMessageId) ) restoreWithdrawnMessages( sessionId, diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 33e7c469dc2a..050979091c8a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -166,16 +166,6 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } -/** A Stop withdrew it before it ran: it will not land, and only its sender can send it again. */ -export function dispatchWasWithdrawn( - submission: Pick | undefined -): boolean { - return ( - submission?.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' - ) -} - /** A submission that says Orca never handed it over, in any dispatch state: journals written * before this state moved hold it as `unknown` carrying the marker. */ export function isWriteFailureSubmission( diff --git a/src/shared/structured-agent-session-submission-settlement.ts b/src/shared/structured-agent-session-submission-settlement.ts index a7c70582008e..11c4fd13ec72 100644 --- a/src/shared/structured-agent-session-submission-settlement.ts +++ b/src/shared/structured-agent-session-submission-settlement.ts @@ -39,3 +39,13 @@ export function structuredAgentSessionSubmissionSettlement( // build that is doubt, and doubt is drawn as sent. return 'sent' } + +/** A Stop withdrew it before it ran: it will not land, and only its sender can send it again. */ +export function structuredAgentSessionSubmissionWasWithdrawn( + submission: Parameters[0] +): boolean { + return ( + structuredAgentSessionSubmissionSettlement(submission) === 'refused' && + classifyDispatchRejection(submission).category === 'withdrawn' + ) +} diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 74000fb42024..53fdf253ae33 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -113,7 +113,11 @@ function readStructuredAgentJournalTurns( // echoes the send, so for that gap the turn names a key no alias resolves yet. const inFlight = new Set( submissions - .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) + .filter( + (submission) => + structuredAgentSessionSubmissionSettlement(submission) === 'open' && + !submission.providerItemId + ) .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const byUserItem = new Map() From 2586cf9b5cf7e05622c2d3355056ca75e5f90eb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:47:19 -0700 Subject: [PATCH 48/49] test(codex): give main's turn-grouping fold rows the failure field the fold now reads --- .../codex-structured-journal-restore-turn-grouping.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index 8ce2591f57c3..7f27d0fb22fc 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -83,7 +83,8 @@ describe('grouping a Codex thread restored from full history', () => { turnKey: turnKeys[index], role: message.role, rendersProse: true, - outlivesTurn: false + outlivesTurn: false, + reportsFailure: false })), settledTurnKeys: new Set(bars.settledTurns.keys()), expandedTurnKeys: new Set() From c9ecc80a346b964e9243c0304ef71dee83ec19cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:56:42 -0700 Subject: [PATCH 49/49] test(native-chat): pin that a Stop naming no turn settles the send it interrupted Main's Stop now interrupts a turnless Stop only while the session reads working; the settle of a handed-over send in doubt runs after it either way. Only turn-naming Stops were pinned. --- .../structured-agent-session-conversation-stop.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index ab0368a6b921..5074bc5cfa1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -165,6 +165,12 @@ describe('a Stop that names no turn', () => { expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) expect(await statusRows()).toEqual(['Cancellation requested.']) + // Nothing is owed the interrupted send any more: settled in doubt, so the chat reads idle. + expect(await submission(id)).toMatchObject({ + dispatchState: 'unknown', + reason: 'stopped_before_acknowledgement', + recovered: true + }) }) it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => {