diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dde..47a09ff084aa 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -107,9 +107,12 @@ 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 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. `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 diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index b2bcb3c40f4e..8ed17001eeac 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -245,6 +245,16 @@ 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.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, 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 An error raised on an SSH host crosses the relay as JSON-RPC, and 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..63c6cca30d41 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 { 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' @@ -61,10 +63,10 @@ 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 (structuredAgentSessionSubmissionSettlement(submission) === 'refused') { 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/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/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-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-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-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/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/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 0d3794b5136b..9a0f482defbb 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 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 @@ -43,7 +43,8 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | 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 + * 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-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-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() 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..4b4c3913af20 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -212,7 +212,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/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-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 02c56b419bdf..9f1172f68efc 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -93,9 +93,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-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 5187aabf45aa..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 pending for the journal's recovery on exit. +// and a completed one leaves it to the translator's turn-over, which settles it in doubt. import { agentSessionFailureFact, 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/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-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 6c01e6224dc0..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,9 +23,12 @@ 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 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. */ +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-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 e4ef8eb85f88..a67f9c2cbfca 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, @@ -19,7 +20,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' @@ -39,7 +39,6 @@ import type { } 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' @@ -344,15 +343,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..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 @@ -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 because the wire type requires it. + 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 dd8853954e3f..c79b07eac119 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,19 +1,22 @@ // 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 type { AgentSessionJournal } from '../agent-session-journal/journal-store' 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, @@ -41,9 +44,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 @@ -97,19 +102,23 @@ 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 }) } // 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 settleGoneGeneration(deps, record, opened.journal) - } + // reader or child sees it — an acquisition's too, before its child's events drain. + await settleGoneGeneration(deps, record, opened.journal, { + deathEvidence: options.acquisition + ? options.acquisition.deathEvidence + : (record.lease.deathEvidence ?? null), + crashBoundary: { sendsLeftInDoubt } + }) return { session: { journal: opened.journal, params, child: null }, reset: opened.recovery?.reset ?? null @@ -128,14 +137,20 @@ export async function resettleOpenStructuredAgentSessionConversation( ): Promise { const record = deps.store.getRecord(sessionId) if (session && record?.lease.deathEvidence) { - await settleGoneGeneration(deps, record, session.journal) + await settleGoneGeneration(deps, record, session.journal, { + deathEvidence: record.lease.deathEvidence + }) } } async function settleGoneGeneration( deps: Pick, record: AgentSessionRecord, - journal: AgentSessionJournal + journal: AgentSessionJournal, + settle: Pick< + Parameters[0], + 'deathEvidence' | 'crashBoundary' + > ): Promise { try { await settleStaleStructuredAgentSessionState({ @@ -143,11 +158,11 @@ async function settleGoneGeneration( sessionId: record.sessionId, fence: record.lease.runtimeFence, acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null, - failureTextContext: structuredAgentSessionFailureWordsContext(record) + failureTextContext: structuredAgentSessionFailureWordsContext(record), + ...settle }) } catch (error) { - // Best effort: the next open or acquire re-derives it. + // Best effort: the next open or acquire re-derives what was left running. deps.onEventSinkError?.({ sessionId: record.sessionId, error }) } } 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 () => { 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..e87c44f2f9a7 --- /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', leafUuid: null } + }, + 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-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 8ee3430c5c91..923e14bee401 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 @@ -30,6 +30,10 @@ import { turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + crashBoundaryExplainedProvenTurn, + staleSettlementRow +} from './structured-agent-session-stale-settlement-row' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ @@ -189,6 +193,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { * death evidence each time, so nothing is owed in between. Proven death ends the turn interrupted, * and a proof written after an earlier settle revises what that settle left `unverifiable`. Must * run before a new child's buffered events land, or a live turn would be judged. + * + * Writes at most one row per boundary: 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 @@ -196,8 +203,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 @@ -220,29 +229,15 @@ export async function settleStaleStructuredAgentSessionState(input: { ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), ...proven ) - const evidence = input.deathEvidence - if ( - evidence && - (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) - ) { - mutations.unshift({ - kind: 'item', - // Named by the death it explains, so a retry after a partly written settle adds no second row. - identity: { - provider: 'orca', - clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` - }, - // 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' - }) - } - }) + const inProgress = items.filter(isInProgressItem) + const row = staleSettlementRow(input, { + inProgress: inProgress.length > 0, + diedInProgress: inProgress.some((item) => verdictFor(item).state === 'interrupted'), + provenUnexplained: + proven.length > 0 && !crashBoundaryExplainedProvenTurn(input.deathEvidence, journal, items) + }) + if (row) { + mutations.unshift({ kind: 'item', ...row }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { await journal.appendLifecycleBatch({ 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..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 @@ -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,27 @@ 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: 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 } + ) + } }, bind: (next) => queue.bind(next), unbind: () => queue.unbind(), 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 1586bd4a1c16..dd2d5265d8fe 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 @@ -27,6 +27,7 @@ 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 { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -195,12 +196,23 @@ export function cancelStructuredAgentSessionTurn( ctx.fence ) const record = context.deps.store.getRecord(ctx.sessionId) - return child && inFlight - ? plan.run({ - ...ctx, - failureTextContext: structuredAgentSessionFailureWordsContext(record) - }) - : { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + try { + return child && inFlight + ? await plan.run({ + ...ctx, + failureTextContext: structuredAgentSessionFailureWordsContext(record) + }) + : { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + } finally { + // 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 still replaces that. A failure here is reported, never Stop's. + await ctx.journal + .markPendingSubmissionsUnknown(ctx.fence, DISPATCH_DOUBT_STOPPED) + .catch((error: unknown) => + context.deps.onEventSinkError?.({ sessionId: ctx.sessionId, error }) + ) + } } }, openForWrite(context, params.envelope) 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 ebc2009fa7db..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,6 +194,48 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() }) + // 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' }) + 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..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 @@ -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 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, + 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-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-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-settlement-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-settlement-row.ts new file mode 100644 index 000000000000..2492314ce6c1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-settlement-row.ts @@ -0,0 +1,103 @@ +// 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 { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +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. + * + * 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 + fence: number + deathEvidence: AgentSessionDeathEvidence | null + failureTextContext?: AgentSessionFailureWordsContext + crashBoundary?: { sendsLeftInDoubt: number } + }, + work: { inProgress: boolean; diedInProgress: boolean; provenUnexplained: boolean } +): Omit, 'kind'> | null { + 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', + clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + }, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), context) + } + } + } + const boundary = input.crashBoundary + if (boundary && (work.inProgress || boundary.sendsLeftInDoubt > 0)) { + 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 ( + identity?.provider === 'orca' && identity.clientMessageId.startsWith(CRASH_BOUNDARY_ROW_PREFIX) + ) +} + +/** A turn only a crash boundary's open could settle is `unverifiable`, and that open already wrote + * the boundary's row after it; a proof arriving later revises the turn and owes no second row. */ +export function crashBoundaryExplainedProvenTurn( + evidence: AgentSessionDeathEvidence | null, + journal: Pick, + items: readonly AgentJournalRenderItem[] +): boolean { + const ownerFence = evidence?.ownerFence + // The same turns `provenUnverifiableTurnRevisions` revises. + const revised = items.findLastIndex( + (item) => + readAgentJournalTurn(item.body)?.state === 'unverifiable' && + journal.itemFence(item.itemId) === ownerFence + ) + return revised !== -1 && items.slice(revised + 1).some(isCrashBoundaryRow) +} 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 2c7a91e76378..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 @@ -260,7 +260,12 @@ const PROVEN: AgentSessionDeathEvidence = { 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 + }) + ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the settle reads only these journal members. const journal = { snapshot: () => ({ items }), @@ -443,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 }) @@ -666,4 +670,146 @@ 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.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: 'crash-boundary:session-1:14' }, + 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([]) + }) + + it('adds no row when a proof later revises a turn the boundary already explained', async () => { + const unverifiable: AgentJournalRenderItem = { + ...lifecycleItem('turn-2', 'running', 2, { startedAt: 30 }), + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + } + const boundaryRow: AgentJournalRenderItem = { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'crash-boundary:session-1:14' + }), + revision: 1, + sequence: 3, + observedAt: 3, + body: { kind: 'status', text: "Claude's session didn't survive the restart." } + } + const settle = (items: AgentJournalRenderItem[]) => { + const { journal, appendLifecycleBatch } = journalWith(items) + return settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null, + deathEvidence: PROVEN + }).then(() => boundaryRows(appendLifecycleBatch)) + } + 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-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index b04a9b3d820a..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 @@ -378,16 +378,112 @@ 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() + }) + + // 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) + await sendPending('steered, never echoed') + 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({ + 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() }) + + // 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) + await host.flushStreamedEvents(SESSION) + await sendPending('runs as the next cycle') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + emitTurnLifecycle('completed', 2) + await host.flushStreamedEvents(SESSION) + + 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. + 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', () => { @@ -697,14 +793,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-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/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 46c4638dc4ee..31c052f5b0e2 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 @@ -73,7 +73,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, @@ -206,17 +207,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/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 a492b0b65ce1..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,16 +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. + // 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 - ? [] - : [ + 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([]) 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 } } 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 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/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), 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..68202e849913 --- /dev/null +++ b/src/main/runtime/claude-structured-turn-end-settlement.test.ts @@ -0,0 +1,267 @@ +// 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' +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 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({ + 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 + ) + }) + } + ) +}) + +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') + }) +}) 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..4d49d636a373 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.test.ts @@ -0,0 +1,239 @@ +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 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 } + ) + }) + ) + } + } + ) + + 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, 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 })] + ])('leaves %s as the host wrote it', (_label, entry) => { + const history: AgentSessionHistoryResult = { ok: true, page: page([entry]) } + 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', {}] + ] 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..54a9963938d5 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-recovered-send-capability.ts @@ -0,0 +1,98 @@ +// 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, 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 { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionMutationResult, + AgentSessionSendResult, + 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 + +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 { + // 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, rejection: _rejection, ...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/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 c86f7c852420..e06c51f6f22c 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 @@ -54,6 +54,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 interrupts: number let childCloses: number let turns: ReturnType @@ -117,6 +119,7 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) answers = 0 + answerWithoutTurn = false interrupts = 0 childCloses = 0 turns = codexTurnLifecycleFake( @@ -140,7 +143,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') { interrupts += 1 @@ -228,6 +232,82 @@ describe('a Codex send its turn ended without taking it', () => { }) }) +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) + }) + + // 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 + 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) + }) +}) + describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { it('waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') 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/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 diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 81a8f53300f1..390a798c03ab 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -517,7 +517,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, @@ -542,7 +542,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, @@ -555,15 +555,15 @@ 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]?.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/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 4c9083755762..8d9837f51a1b 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 @@ -6,6 +6,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,6 +209,82 @@ 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('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.')], + { turnStatuses: settled } + ) + expect(slots.map((slot) => slot.message.id)).toEqual(['u', 'a']) + }) + + it("keeps a failure row outside the fold whichever agent's row it is", () => { + const slots = build( + [ + text('u', 'go', 'user'), + text('partial', 'Half an answer'), + { ...statusRow('boundary', 'providerExited'), agentId: 'task-1' } + ], + { turnStatuses: settled } + ) + expect(slots.map((slot) => slot.message.id)).toEqual(['u', 'partial', 'boundary']) + }) +}) + // Rows belong to the turn the host says owns them, not to the nearest preceding // user bubble. The owned turn keys reshape the fold, the bar and liveness. describe('turn-owned grouping', () => { 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 b3631fb80125..37e8292a93a9 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 @@ -21,6 +21,7 @@ import { } from '../../../../shared/native-chat-turn-status' import { nativeChatSelfAnchoredTurnRows } from '../../../../shared/native-chat-turn-grouping' import { + nativeChatRowReportsFailure, nativeChatTurnFold, type NativeChatTurnFoldRow } from '../../../../shared/native-chat-turn-fold' @@ -113,6 +114,7 @@ export function buildNativeChatTranscriptSlots( outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), + reportsFailure: nativeChatRowReportsFailure(message.blocks), ...(agentId === null ? {} : { agentId }) } }) 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-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 3a3562458cb6..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 @@ -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,32 @@ describe('structured agent session message projection', () => { ).toMatchObject([{ id: acceptedItem.itemId, role: 'user' }]) }) + 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' } + ] + ])('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..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,6 +15,7 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionSubmissionSettlement } from '../../../../shared/structured-agent-session-submission-settlement' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -118,10 +119,10 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { createOperationId: args.createOperationId }) ) - return result.ok - ? result.value.submission.dispatchState === 'accepted' || - result.value.submission.dispatchState === 'pending' - : 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/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/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index feb9f898ce25..122f6a809c9e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -279,15 +279,14 @@ describe('structured agent session outbox admission', () => { expect(result.current.blockedClientMessageId).toBe(heldId) }) - it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => { + it('keeps a refused message held when the journal leaves a message ahead of it in doubt', async () => { const none: readonly AgentJournalSubmission[] = [] mocks.call.mockImplementation((_target, _method, params) => { const id = requestId(params) if (requestText(params) === 'held') { return Promise.resolve(refusedResult('agent_session_ownership_unknown')) } - const sent = sentTexts().filter((text) => text === 'first').length - return Promise.resolve(submissionResult(id, sent === 1 ? 'pending' : 'accepted', 10)) + return Promise.resolve(submissionResult(id, 'pending', 10)) }) const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => @@ -310,12 +309,23 @@ describe('structured agent session outbox admission', () => { dispatchState: 'unknown' } rerender({ submissions: [unknown] }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) - - act(() => result.current.retry(firstId)) - await waitFor(() => expect(result.current.outbox).toHaveLength(1)) await settleTimers(50) - expect(sentTexts()).toEqual(['first', 'held', 'first']) + + // A live doubt is held like a pending: no barrier, no Retry of its own, and the held one stays. + expect(result.current.outbox[0]).toMatchObject({ + clientMessageId: firstId, + state: 'dispatching' + }) + const notices = structuredAgentSessionDeliveryNotices( + result.current.outbox, + result.current.blockedClientMessageId, + 'Claude', + () => {}, + [unknown], + [] + ) + expect(notices.has(agentJournalSubmissionKey(firstId))).toBe(false) + expect(sentTexts()).toEqual(['first', 'held']) expect(result.current.blockedClientMessageId).toBe(heldId) }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts index e4e49c119755..d8767d0abd55 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts @@ -27,10 +27,8 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(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. 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..32ca34b6c09e 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,22 +451,17 @@ 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('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 } 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, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => @@ -480,28 +475,98 @@ describe('useStructuredAgentSessionOutbox', () => { ) act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + 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(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] }) + 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' }) + }) - act(() => expect(result.current.send('second')).toBe(true)) - expect(result.current.outbox).toHaveLength(2) + 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({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + ) - act(() => result.current.retry(firstId)) - await waitFor(() => - expect(result.current.outbox.some((entry) => entry.clientMessageId === firstId)).toBe(false) + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + const firstId = result.current.outbox[0]!.clientMessageId + rerender({ + submissions: [{ ...unknownResultFor(firstId, 10).value.submission, recovered: true }] + }) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(result.current.error).toBeNull() + }) + + it('draws a lost-reply send the host recorded in doubt as sent: never re-sent, and the next goes out', async () => { + mocks.call + .mockRejectedValueOnce(new Error('socket closed')) + .mockImplementation(async (_target, _method, params) => + acceptedResultFor( + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId, + 11 + ) + ) + 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: [unknownResultFor(firstId, 10).value.submission] }) + act(() => expect(result.current.send('second')).toBe(true)) + // Past the probe's first delay, so a re-send of the doubted head would have gone out. await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 1_500)) }) 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' - ) + expect(sent).toEqual(['first', 'second']) + expect(result.current.blockedClientMessageId).toBeNull() }) it('retains a send operation after a pending-admission refusal', async () => { @@ -600,91 +665,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 +687,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 +703,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 +800,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 117f22dd9a66..8d171c6b76d3 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 @@ -12,6 +12,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 { @@ -93,8 +94,7 @@ export function useStructuredAgentSessionOutbox(args: { const hostOwns = new Set( submissions .filter( - (submission) => - submission.dispatchState === 'pending' || submission.dispatchState === 'accepted' + (submission) => structuredAgentSessionSubmissionSettlement(submission) !== 'refused' ) .map((submission) => submission.clientMessageId) ) @@ -275,12 +275,16 @@ export function useStructuredAgentSessionOutbox(args: { ) const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) // The host settled this id as rejected, and reusing it only replays that forever, so rotate the - // id for a safe resend. Read from the message itself, which outlives a restart, or from a - // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that - // settled the message already rotated it. + // id for a safe resend. Read from the message itself, which outlives a restart, or from the + // journal, which can settle it before the outbox catches up. A refusal that settled the message + // 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 ? { @@ -300,12 +304,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 4f72d98bb172..4ff5d4c430b3 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2767,7 +2767,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 30ac3af4db99..da27591f098a 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 79070fc23f5d..d0e245f4ee6c 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 f378465a9436..0ec61aa04e21 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 bcb68593d904..0fcbe457e707 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 8aa0335d8917..05b1bcb55d9e 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 70cbc25bdc54..7d2577bc8139 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..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,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 { structuredAgentSessionSubmissionSettlement } from '../../../shared/structured-agent-session-submission-settlement' import { mutateStructuredAgentSessionLaunchPrompt, type StructuredAgentSessionLaunchPromptMutation @@ -119,21 +120,18 @@ async function dispatchStructuredLaunchPrompt( ) return false } - const dispatchState = result.value.submission.dispatchState - mutateEntry(entry, (current) => - dispatchState === 'accepted' - ? null - : { - ...current, - state: - dispatchState === 'unknown' - ? 'unconfirmed' - : dispatchState === 'pending' - ? 'dispatching' - : 'queued' - } - ) - return dispatchState === 'accepted' || dispatchState === 'pending' + 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/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/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 06befa038eb0..f766ac39827c 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -196,7 +196,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: ({ retryControl }) => retryControl ? 'This message was not delivered.' 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 7306f0b42665..0a4700561fb9 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. */ @@ -302,7 +301,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 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/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index d6bba89e552c..c95c78bcb52f 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,45 @@ 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('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) + }) + }) + 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 52407a9d5244..80cd92a8332d 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -10,7 +10,7 @@ // hides a different row on each surface is the same bug twice. import { isRootAgentJournalItem } from './agent-session-journal-producer' -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 @@ -25,10 +25,25 @@ export type NativeChatTurnFoldRow = { * 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 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 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( + (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 @@ -63,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. * @@ -82,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()) { @@ -99,7 +135,8 @@ export function nativeChatTurnFold({ // 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)) { + // 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) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index cdd920d7922f..a831b6fd3e55 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -216,6 +216,12 @@ 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`), 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 = 'agent-session.background-task-stop.v1' as const // Why: agentSession.cancel has a strict schema, so clients must not send prompt identity to an @@ -404,6 +410,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/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 68e40a530394..050979091c8a 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,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-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 334cde7cfe8c..97b72a16abbf 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 { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], @@ -16,9 +17,10 @@ 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) => 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 c14209a716cc..36150de9debe 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -12,8 +12,11 @@ import { import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' -/** `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 +32,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 @@ -190,41 +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) - if (submission?.dispatchState === 'accepted') { - return [] - } - if ( - submission?.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' - ) { - return [] - } - if (submission?.dispatchState === 'pending') { - 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 [] } - ] - } - if ( - submission?.dispatchState === 'unknown' && - entry.retryAfterUnknownSubmittedAt !== -1 && - entry.retryAfterUnknownSubmittedAt !== submission.submittedAt - ) { - return [{ ...entry, state: 'unconfirmed' as const }] + // 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.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index e95a21937d1c..76110ea8b967 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -101,9 +101,18 @@ 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 }) + } }) it.each([ @@ -358,7 +367,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') @@ -378,12 +387,34 @@ 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('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) { + 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' + }) + + // It can still settle `rejected`; only the entry keeps the text a Retry would need. + expect(disposition).toMatchObject({ + 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 11e2b45fc0be..755b8bca0eb3 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSubmissionSettlement } from './structured-agent-session-submission-settlement' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -71,39 +72,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) } /** @@ -230,67 +205,33 @@ 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 - } - } - if (submission.dispatchState === 'accepted') { - 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 - } - } - 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 - } - } - // `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. - return { - entries: replaceEntryState( - input, - submission.dispatchState === 'unknown' ? 'unconfirmed' : '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..03f62ace4328 --- /dev/null +++ b/src/shared/structured-agent-session-submission-settlement.test.ts @@ -0,0 +1,102 @@ +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' + +// The host-written submission record, as far as the classifier reads it. +type SubmissionRecord = Pick & + Partial> & { + rejection?: unknown + } + +const firstHandRejections: [string, SubmissionRecord][] = [ + ...AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind) + .filter((kind) => kind !== 'notDelivered') + .map((kind): [string, SubmissionRecord] => [ + `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, SubmissionRecord] => [ + `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, SubmissionRecord, 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, submission]): [ + string, + SubmissionRecord, + StructuredAgentSessionSubmissionSettlement + ] => [name, submission, 'refused'] + ) +] + +describe('structuredAgentSessionSubmissionSettlement', () => { + 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: SubmissionRecord = 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 new file mode 100644 index 000000000000..11c4fd13ec72 --- /dev/null +++ b/src/shared/structured-agent-session-submission-settlement.ts @@ -0,0 +1,51 @@ +// 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 { + 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' +} + +/** 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 e760796618c0..53fdf253ae33 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 @@ -112,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() @@ -272,7 +277,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[], @@ -297,7 +302,10 @@ function settledTurnsOf( } 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 412b3e981968..1af0742501ca 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 { 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. */ @@ -13,11 +14,7 @@ export function isUnansweredStructuredAgentSessionDispatch( } return ( (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')) + structuredAgentSessionSubmissionSettlement(submission) === 'open' ) } @@ -30,9 +27,9 @@ export function isUnansweredStructuredAgentSessionDispatch( * 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[], 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 3087385da1d5..279104dcd1d4 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 @@ -35,6 +35,7 @@ import { resolveBaselineReleaseRef } from './release-checkout' import { installableHost, structuredHostStub, + recoveredSendSkew, turnItemSkew } from './structured-agent-session-host-fixture' import { @@ -240,6 +241,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 + } +}