From 839a2b418f8f835202d1e8bae709d2ba2623551a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:13:48 -0700 Subject: [PATCH 01/26] fix(native-chat): a queued card returned because its agent failed to start no longer holds the cards behind it --- .../MobileNativeChatQueuedMessages.test.tsx | 1 + ...le-structured-queued-message-cards.test.ts | 16 ++++++++++ .../mobile-structured-queued-message-cards.ts | 18 +++++++---- ...se-mobile-native-chat-queued-slot.test.tsx | 1 + ...e-structured-agent-session-queued.test.tsx | 1 + .../queued-message-pause.test.ts | 31 ++++++++++++++++++- .../queued-message-pause.ts | 15 +++++---- ...ed-agent-session-queued-pause-lift.test.ts | 8 ++++- ...uctured-agent-session-queued-cards.test.ts | 17 ++++++++++ .../structured-agent-session-queued-cards.ts | 3 +- ...ctured-agent-session-dispatch-rejection.ts | 31 +++++++++++++++++++ 11 files changed, 127 insertions(+), 15 deletions(-) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..8381a4c2e52e 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -35,6 +35,7 @@ function card( state: 'waiting', paused: false, needsAttention: false, + holdsQueue: overrides.state === 'returned', caption: null, ...overrides } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729f..388734200703 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -45,6 +45,7 @@ describe('mobileQueuedMessageCards', () => { state: 'waiting', paused: false, needsAttention: false, + holdsQueue: false, caption: null }) }) @@ -139,6 +140,21 @@ describe('mobileQueuedMessageCards', () => { expect(cards[1]?.needsAttention).toBe(false) }) + it('holds nothing behind a card returned because its agent failed to start', () => { + const drafts = [ + draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('providerStartFailed')) }), + draft({ messageId: 'b', position: 2 }) + ] + const cards = mobileQueuedMessageCards(drafts, [], { pendingPrompt: false }) + expect(cards[0]).toMatchObject({ state: 'returned', needsAttention: true, holdsQueue: false }) + expect(cards[1]?.caption).toBeNull() + expect( + mobileQueueHasResumableCard( + mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, queuePaused: true }) + ) + ).toBe(true) + }) + it('hides a waiting card once its hand-off arrived, never a returned one', () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c8..d371081b7d75 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -5,7 +5,10 @@ import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' -import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { + dispatchWasWithdrawn, + queuedCardHoldsQueue +} from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, @@ -21,6 +24,8 @@ export type MobileQueuedMessageCard = { paused: boolean /** Returned, or its own send failed: the row leads with an alert. */ needsAttention: boolean + /** Returned for something only the person can resolve: the cards behind it wait. */ + holdsQueue: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ caption: string | null } @@ -64,10 +69,11 @@ const QUEUE_PAUSE_LABELS: Readonly> = { } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any - * returned card. The drain stops at a returned card, so cards behind one never go. */ + * returned card that holds the queue. The drain stops at such a card, so cards behind it never + * go. */ export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { for (const card of cards) { - if (card.state === 'returned') { + if (card.holdsQueue) { return false } if (!card.paused) { @@ -123,6 +129,7 @@ export function mobileQueuedMessageCards( : facts.pendingPrompt ? 'Waiting for your answer' : null + const holdsQueue = queuedCardHoldsQueue(draft) cards.push({ messageId: draft.messageId, text: queuedMessageBodyText(draft.body), @@ -131,11 +138,10 @@ export function mobileQueuedMessageCards( needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), + holdsQueue, caption }) - if (draft.state === 'returned') { - behindReturned = true - } + behindReturned = behindReturned || holdsQueue } return cards } diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx index 002a567548f6..bb9d93b78382 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -33,6 +33,7 @@ const CARD = { state: 'waiting' as const, paused: false, needsAttention: false, + holdsQueue: false, caption: null } diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 277beb17d75a..20cd8a7d6227 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -533,6 +533,7 @@ describe('mobile structured queued messages', () => { state: 'waiting', paused: false, needsAttention: false, + holdsQueue: false, caption: null } ]) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5989a10e9675..410061e83f76 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -27,7 +27,10 @@ import { queuePauseHolding, resumableQueuePause } from './queued-message-pause' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -596,6 +599,32 @@ describe('which cards the pauses in force hold', () => { expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) + it('a card returned because its agent failed to start blocks nothing behind it; any other return does', () => { + const failedStart = agentSessionFailureFact('providerStartFailed') + const returned = (rejection: SubmissionRejectionFact) => + card('returned', 1, { + state: 'returned', + returnedRejection: rejection, + returnedReason: agentSessionFailureWords(rejection, { surface: 'rejection' }).reason + }) + const held = card('held', 2) + const fresh = card('fresh', 6) + + // Each card behind it starts the agent again for itself. + const afterFailedStart = [returned(failedStart), fresh] + expect(nextSendableQueuedCard(pausesOver(afterFailedStart), afterFailedStart)).toBe(fresh) + const pausedAfterFailedStart = [returned(failedStart), held] + expect(resumableQueuePause(pausesOver(pausedAfterFailedStart), pausedAfterFailedStart)).toEqual( + expect.objectContaining({ reason: 'stopped' }) + ) + + const refused = agentSessionFailureFact('queueFull') + const afterRefused = [returned(refused), fresh] + expect(nextSendableQueuedCard(pausesOver(afterRefused), afterRefused)).toBeNull() + const pausedAfterRefused = [returned(refused), held] + expect(resumableQueuePause(pausesOver(pausedAfterRefused), pausedAfterRefused)).toBeNull() + }) + it("a /clear's pause that holds nothing never hides a restart's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 63dc6823df28..a810c5112243 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -11,6 +11,7 @@ // continuation, a launch prompt and the queue's own drain are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import { queuedCardHoldsQueue } from '../../../shared/structured-agent-session-dispatch-rejection' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -38,6 +39,8 @@ type QueueCard = { hostInstance: string carriedFrom: string | null queuedAt: AgentJournalCursor | null + returnedReason?: string | null + returnedRejection?: unknown } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { @@ -170,14 +173,14 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or a held one comes first. The queue never reorders, so a newer card never - * overtakes a held one. The drain's pick and its consume both read this. */ + * returned card that holds the queue or a held one comes first. The queue never reorders, so a + * newer card never overtakes a held one. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || queuePauseHolding(pauses, card)) { + if (queuedCardHoldsQueue(card) || queuePauseHolding(pauses, card)) { return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -188,14 +191,14 @@ export function nextSendableQueuedCard( } /** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned - * card, which blocks everything after it until the user acts. None otherwise, so its header - * never offers a Resume that sends nothing. */ + * card that holds the queue (`queuedCardHoldsQueue`) until the user acts. None otherwise, so its + * header never offers a Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] ): DerivedQueuePause | null { for (const card of cards) { - if (card.state === 'returned') { + if (queuedCardHoldsQueue(card)) { return null } const holding = queuePauseHolding(pauses, card) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index c5baeed25def..f7b4a3e6bb66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -354,11 +354,17 @@ describe("a restart's pause", () => { await next.result await rig.settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + // This rig's start for it fails and returns its card, which holds nothing back. + await eventually(async () => + expect((await rig.drafts()).find((draft) => draft.messageId === first)?.state).toBe( + 'returned' + ) + ) + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) // Reopened, that turn is "before this open", yet the pause it ended stays ended: // the lift adopted the rows into this process. await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() - expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d80247699..99fc9f019650 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -92,6 +92,23 @@ describe('queued message cards', () => { expect(cards[1]?.hold).toBe('behind-returned') }) + it('a card returned because its agent failed to start holds nothing behind it', () => { + const cards = projectQueuedMessageCards( + [ + draft('failed', 1, { + state: 'returned', + returnedReason: "Codex couldn't restart. Send your message to try again.", + returnedRejection: { kind: 'restartFailed' } + }), + draft('behind', 2) + ], + [], + IDLE + ) + expect(cards[0]?.hold).toBe('returned') + expect(cards[1]?.hold).toBe('turn') + }) + it('a paused draft says so, carrying the host marker for the caption to localize', () => { const cards = projectQueuedMessageCards( [draft('a', 1, { paused: true, pausedReason: 'send_failed' })], diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..75b6a82e4677 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -5,6 +5,7 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-ses import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' +import { queuedCardHoldsQueue } from '../../../../shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionEntryAsksToQueue, type StructuredAgentSessionQueueDelivery @@ -75,7 +76,7 @@ export function projectQueuedMessageCards( : session.hasPendingPrompt ? 'awaiting-answer' : 'turn' - behindReturned = behindReturned || message.state === 'returned' + behindReturned = behindReturned || queuedCardHoldsQueue(message) return { messageId: message.messageId, position: message.position, diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 57b8e745ca52..24eb98dc6c77 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -168,6 +168,37 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } +/** Rejected because the agent it waited on never took it: what a failed start writes, whatever + * the person may have to do first. Orca stopping a start that hung is one, and so is the agent + * ending before it was handed the message. */ +export function isFailedStartRejection( + submission: Pick & { rejection?: unknown } +): boolean { + const { category, kind } = classifyDispatchRejection(submission) + return category === 'startFailed' || kind === 'hostStopped' || kind === 'providerExited' +} + +/** + * Whether a queued card holds every card behind it until the person acts: one returned for + * something about it that only they can resolve — the provider or Orca refused its content or + * attachments, its command did not run, the queue was full, Orca could not hand it over or faulted. + * One returned because its agent failed to start holds nothing: each card behind it starts the + * agent again for itself. + */ +export function queuedCardHoldsQueue(card: { + state: string + returnedReason?: string | null + returnedRejection?: unknown +}): boolean { + return ( + card.state === 'returned' && + !isFailedStartRejection({ + reason: card.returnedReason ?? null, + rejection: card.returnedRejection + }) + ) +} + /** 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 From 282660cd1cb44a9d66dd7b514c1667e84038cf5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:25:57 -0700 Subject: [PATCH 02/26] fix(native-chat): a failed agent start fails only the message it was for; the messages behind it each get their own start --- ...-agent-session-accept-then-deliver.test.ts | 127 +++--- ...ured-agent-session-append-delivery.test.ts | 15 +- .../structured-agent-session-child-exit.ts | 5 +- ...ssion-claude-stop-exit-ends-record.test.ts | 15 + ...d-agent-session-conversation-close.test.ts | 27 +- ...session-dead-generation-settlement.test.ts | 2 +- ...gent-session-dead-generation-settlement.ts | 59 +-- ...ctured-agent-session-delivery-loop.test.ts | 2 +- .../structured-agent-session-delivery-loop.ts | 287 ++++++------- ...tured-agent-session-ended-child-failure.ts | 76 ++++ .../structured-agent-session-host-delivery.ts | 3 + .../structured-agent-session-host.ts | 1 + ...gent-session-legacy-handoff-record.test.ts | 14 +- ...gent-session-provider-child-record.test.ts | 106 ++--- ...red-agent-session-send-preparation.test.ts | 21 +- ...session-send-restarts-failed-start.test.ts | 32 +- ...uctured-agent-session-start-failure-row.ts | 80 ---- ...-agent-session-start-failure-settlement.ts | 111 +++++ ...agent-session-start-failure-writer.test.ts | 396 ++++++++++++++---- ...agent-session-startup-failure-exit.test.ts | 79 +++- ...sion-view-start-after-failed-start.test.ts | 14 +- .../structured-conversation-command.test.ts | 11 +- ...structured-conversation-compaction.test.ts | 16 +- ...e-structured-resumed-start-failure.test.ts | 24 +- ...e-structured-send-held-for-startup.test.ts | 20 +- ...-send-restart-dies-before-dispatch.test.ts | 69 ++- ...tured-startup-fault-is-not-an-exit.test.ts | 11 +- 27 files changed, 1006 insertions(+), 617 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts 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 99900a2a8935..9a629ace512d 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 @@ -3,7 +3,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // session's delivery loop starts a provider child for it and hands it over. Against the real host, // store and journal; each assertion reads what an open chat or the journal's next reader sees. -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -37,7 +36,6 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, - hostTestDrawnRowIds, hostTestMessage, hostTestOperationId, resetHostTestOperationIds @@ -187,12 +185,6 @@ async function errorRows(): Promise { ) } -async function errorFailures(): Promise<(AgentSessionFailureFact | undefined)[]> { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.failure] : [] - ) -} - let subscriptions = 0 async function subscribe(): Promise { @@ -324,58 +316,30 @@ describe('a send is answered at acceptance', () => { }) describe('a start the chat needed and did not get', () => { - it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { - await host.close(SESSION, 'evict') - acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) - const first = await accept('first') - const second = await accept('second') - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - const rows = await errorRows() - expect(rows).toHaveLength(1) - // Orca's spawn error goes to the log; the row and every message say what failed, typed. The - // child is gone, but no exit was observed, so nothing blames the provider. - expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") - const failure = { - kind: 'restartFailed', - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } - } - expect(await errorFailures()).toEqual([failure]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: rows[0], - rejection: failure - }) - } - - const next = await accept('after the fix') - await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(await errorRows()).toHaveLength(1) - }) - - it('draws the messages it failed above the error row, since they were accepted first', async () => { + it('rejects only the message whose start failed; the one behind it starts and is delivered (W3)', async () => { await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - const snapshot = await host.journalSnapshot(SESSION) - const errorRow = snapshot.items.find( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - )?.itemId - const shown = [agentJournalSubmissionKey(first), agentJournalSubmissionKey(second), errorRow] - const drawn = hostTestDrawnRowIds(snapshot, [ - { clientMessageId: first, text: 'first' }, - { clientMessageId: second, text: 'second' } - ]) - expect(drawn.filter((id) => shown.includes(id))).toEqual(shown) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + // Orca's spawn error goes to the log; the message says what failed, typed. The child is gone, + // but no exit was observed, so nothing blames the provider. + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + }) + expect(await errorRows()).toEqual([]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) }) it('notifies failed once for the queued messages one start failure refused', async () => { await host.close(SESSION, 'evict') - acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + acquire.mockRejectedValue(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) await accept('first') @@ -438,28 +402,34 @@ describe('a start the chat needed and did not get', () => { } } ] - ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { - await host.close(SESSION, 'evict') - arrange() - await host.flushAllStreamedEvents() - await startHost() - const id = await accept('hello') - const events = await subscribe() - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect(await errorRows()).toEqual([row.text]) - expect(await errorFailures()).toEqual([row.failure]) - const framedRows = events.flatMap((event) => - event.type === 'batch' || event.type === 'snapshot' - ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - ) - : [] - ) - expect(framedRows.length).toBeGreaterThan(0) - }) + ])( + 'tells a live chat a %s refusal on the message it failed (W14)', + async (_source, arrange, expected) => { + await host.close(SESSION, 'evict') + arrange() + await host.flushAllStreamedEvents() + await startHost() + const id = await accept('hello') + const events = await subscribe() + + const told = () => + events.flatMap((event) => + event.type === 'batch' + ? event.batch.submissions.filter((entry) => entry.clientMessageId === id) + : [] + ) + await eventually(() => + expect(told().at(-1)).toMatchObject({ + dispatchState: 'rejected', + reason: expected.text, + rejection: expected.failure + }) + ) + expect(await errorRows()).toEqual([]) + } + ) - it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => { + it('names the start failure on the queued message when the attach fails after acquiring (W4′a)', async () => { await host.close(SESSION, 'evict') const id = await accept('hello') // The attach's own success record is the post-acquisition step that fails. @@ -483,10 +453,10 @@ describe('a start the chat needed and did not get', () => { // the store's own error, which is Orca's and goes to the log. expect(settled).toMatchObject({ dispatchState: 'rejected', - rejection: (await errorFailures())[0] + rejection: { kind: 'restartFailed' } }) expect(settled?.reason).not.toContain('record store write failed') - expect(await errorRows()).toEqual([settled?.reason]) + expect(await errorRows()).toEqual([]) }) }) @@ -599,7 +569,7 @@ describe('a child that exits before its message is handed over', () => { }) describe('a start whose failure the delivery loop settles before the exit is published', () => { - it('keeps one row in the words its rejected messages carry', async () => { + it('rejects each message in the words of its own start, and the exit adds no row', async () => { // The adapter's startup answer and its later exit event word the same start differently. const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) adapterExtras = { awaitStarted } @@ -627,16 +597,12 @@ describe('a start whose failure the delivery loop settles before the exit is pub }) await host.flushStreamedEvents(SESSION) - const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => - item.itemId.includes('start-failure') - ) - expect(rows).toHaveLength(1) const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { surface: 'rejection', agentName: 'Codex', provider: 'codex' }) - expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) + // Each message made its own attempt, and each start failed the same way. for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -644,6 +610,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub rejection: words.rejection }) } + expect(await errorRows()).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index b80135108056..4e1b47f9cff2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -167,16 +167,19 @@ describe('an open chat receives every row its journal commits', () => { await exitBeforeProof() - // The exit ends the child; the delivery loop, which reads why, rejects what it had queued. + // The exit ends the child; the delivery loop, which reads why, records it on what it had queued. await vi.waitFor(() => expect(pane.received().submissions).toContainEqual( - expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) + expect.objectContaining({ + clientMessageId: held, + dispatchState: 'rejected', + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: expect.objectContaining({ kind: 'providerStartFailed' }) + }) ) ) - // One row, however many of its writers reported the start. - expect(pane.received().statusRows).toEqual([ - 'Codex stopped before it finished starting. Send your message to try again.' - ]) + // The message says why; the chat gets no row of its own for it. + expect(pane.received().statusRows).toEqual([]) }) it('shows a revision the provider queued with no publish behind it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index c606a73e310f..c658ce814658 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -165,7 +165,10 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< )), ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), ...(!expected && exitedDuringStartup && child.generation - ? { exitedDuringStartup: { generation: child.generation } } + ? { + exitedDuringStartup: + child.startedFor === undefined ? {} : { startedFor: child.startedFor } + } : {}) }) if (!settled.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index afb4ac1c1c11..7b5c804c812e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -451,6 +451,21 @@ it('rejects a message whose start meets a close still unverifiable, and starts n expect(scopes()).toContain('provider-close-unproven') }) +it('fails only the message its refused step was for: one queued behind it starts once the close proves', async () => { + const connection = await stopWithUnprovenClose(2) + + // Both accepted before the first's start step runs. + const [first, second] = await Promise.all([send('First.'), send('Second.')]) + + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) + expect((await submission(first))?.rejection).toMatchObject({ + refusal: { details: { reason: 'previousExitUnverifiable' } } + }) + // Its own start joins the close again, which proves this time, and goes to a resumed child. + await resumedWith(connection, 'Second.') + expect((await submission(second))?.dispatchState).not.toBe('rejected') +}) + it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { const connection = await stopWithUnprovenClose(2) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 0133fbc9fedd..1728242bb6f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -222,7 +222,7 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + it('is stopped by the sweep, and the delivery loop alone records it on the message', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' const order: string[] = [] const started = Promise.withResolvers() @@ -237,16 +237,15 @@ describe('a start that never finishes (P2-15)', () => { providerChildPhase: 'starting' as const })) Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - // The loop rejects the queued messages in the same append as its row. - const append = AgentSessionJournal.prototype.appendLifecycleBatch - vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( + const resolve = AgentSessionJournal.prototype.resolveDispatch + vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(function ( this: AgentSessionJournal, - ...args + input ) { - if (args[0].rejectsQueued) { - order.push(`rejected: ${args[0].rejectsQueued.reason}`) + if (input.state === 'rejected') { + order.push(`rejected: ${input.reason}`) } - return append.apply(this, args) + return resolve.call(this, input) }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) @@ -260,10 +259,14 @@ describe('a start that never finishes (P2-15)', () => { expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) + expect.objectContaining({ + dispatchState: 'rejected', + reason: stopReason, + rejection: { kind: 'hostStopped' } + }) ) ) - // One rejection, written after the stop by the loop, with the stop's own words. + // One record, written after the stop by the loop, with the stop's own words. expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) const errorRows = reader.events.flatMap((event) => event.type === 'batch' @@ -272,9 +275,7 @@ describe('a start that never finishes (P2-15)', () => { ) : [] ) - expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ - stopReason - ]) + expect(errorRows).toEqual([]) expect(rig.adapter.dispatch).not.toHaveBeenCalled() const again = await rig.host.send(CALLER, restTestSend('try again', fence())) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 1df56dc5228a..aadee53356aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -335,7 +335,7 @@ describe('dead structured-session generation settlement', () => { exitFailure: agentSessionFailureFact('providerExited', { detail: providerDiagnostic('code 1\nnot signed in', 'log') }), - exitedDuringStartup: { generation: 'generation-1' } + exitedDuringStartup: {} }) // The sentence is Orca's; the stderr the exit carried rides as a log detail only. 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 25703ea5ba55..5eb9b805af8c 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 @@ -5,10 +5,10 @@ import { } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem + type AgentJournalRenderItem, + type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' @@ -23,11 +23,11 @@ import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { - hasStructuredAgentSessionStartFailureRow, - structuredAgentSessionStartFailureRow -} from './structured-agent-session-start-failure-row' + structuredAgentSessionStartFailure, + structuredAgentSessionStartFailureFact +} from './structured-agent-session-failure-text' +import { messageCarriesFailedStart } from './structured-agent-session-start-failure-settlement' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { endedByPersonsStop, @@ -51,7 +51,13 @@ import { * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -export type DeadGenerationJournal = UnopenedSendJournal & { +type DeadGenerationSubmission = ReturnType< + NonNullable +>[number] & + Partial> + +export type DeadGenerationJournal = Omit & { + submissions?: () => DeadGenerationSubmission[] appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] @@ -123,9 +129,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { exitFailure?: SubmissionRejectionFact /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext - /** The provider never finished starting: the start that failed, keyed by the child's - * generation. Its row is the one the delivery loop writes for the same start. */ - exitedDuringStartup?: { generation: string | null } + /** The provider never finished starting. `startedFor`: the queued message the start was for, + * which carries the failure itself. */ + exitedDuringStartup?: { startedFor?: string } }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) @@ -143,23 +149,22 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!startupFailure) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) - : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) + const rejectedUnstarted = startupFailure + ? await input.journal.rejectPendingSubmissions(input.fence, startupFailure) + : await input.journal.markPendingSubmissionsUnknown( + input.fence, + input.pendingSubmissionReason + ) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { - const startKey = input.exitedDuringStartup.generation ?? input.settlementId - // A start a message waited on is the delivery loop's to record, before or after this exit, - // in the words it rejected the message with; this row is for a command, goal or rewind start. - // A row already written stays: rejected is terminal, so its words are not reworded. - const recordedByDeliveryLoop = - input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || - hasStructuredAgentSessionStartFailureRow(items, startKey) - if (!recordedByDeliveryLoop) { - mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) - } - } else if (showUnexpectedExitOutcome) { + // A failed start a message carries is said there. A goal, rewind or command start with no + // message waiting has only this row to say why. + const failureOnAMessage = + input.exitedDuringStartup !== undefined && + (rejectedUnstarted.length > 0 || + input.exitedDuringStartup.startedFor !== undefined || + messageCarriesFailedStart(input.journal.submissions?.() ?? [], input.fence)) + if (showUnexpectedExitOutcome && !failureOnAMessage) { // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ kind: 'item', @@ -167,7 +172,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { body: { kind: 'status', ...agentSessionFailureWords( - input.exitFailure ?? agentSessionFailureFact('providerExited'), + input.exitedDuringStartup + ? structuredAgentSessionStartFailureFact({ exit: input.exitFailure }) + : (input.exitFailure ?? agentSessionFailureFact('providerExited')), { ...input.failureTextContext, surface: 'row' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts index 242daf74faf6..61f596359165 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-delivery-loop' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import type { StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index e3aaa965edc9..eda664d0b1c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -9,6 +9,10 @@ // that arrives while a start holds the queue withdraw the queued messages before the handover that // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +// +// A start that fails is recorded on the one message it was for, and the messages behind it each +// get their own start. Every write names a message fixed when its pass chose it, never the queue's +// head read again after a failure. import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -23,22 +27,21 @@ import { } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' -import { - structuredAgentSessionStartFailure, - type StructuredAgentSessionStartFailureCause -} from './structured-agent-session-failure-text' +import type { StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { - StructuredAgentSessionChildEndCause, - StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure -} from './structured-agent-session-start-failure-row' -import { failedProviderChildStart } from './structured-agent-session-provider-child' + rejectStructuredAgentSessionStartFailure +} from './structured-agent-session-start-failure-settlement' +import { + childWhoseStartFailed, + startThatFailedWhileQueued, + structuredAgentSessionEndedChildFailure +} from './structured-agent-session-ended-child-failure' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -50,12 +53,13 @@ export type StructuredAgentSessionDeliveryLoopDeps = { serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise - /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ ensureProviderChild: ( sessionId: string, startedFor: string ) => Promise + /** Ends the session's child, whose start failed, as a host stop; for a caller inside `serialize`. */ + endFailedStart: (sessionId: string) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number /** Rejects queued messages as a completed close of the chat does; false when that failed. */ @@ -74,12 +78,14 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Step = 'continue' | 'stop' type Prepared = - | 'stop' - | Extract - | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } + | Step + | (Extract & { startedFor: string }) + // `waitingFor`: the message the start was waited on for. + | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null; waitingFor: string } -/** A failed start before it is worded; `fail` words it once, through the one wording point. */ -type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } +/** The message one pass is attempting, set before each await that could fail for it, so a throw + * still knows its target. Dies with the pass. */ +type Attempt = { clientMessageId?: string } export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() @@ -107,54 +113,75 @@ export class StructuredAgentSessionDeliveryLoop { } private async run(sessionId: string): Promise { - try { - for (;;) { - const prepared = await this.deps.trackStart( - this.deps.serialize(sessionId, () => this.prepare(sessionId)) - ) - if (prepared === 'stop') { - return - } - if (!prepared.ok) { - await this.deps.serialize(sessionId, () => - this.fail(sessionId, this.refusedStart(sessionId, prepared)) - ) - return - } - // A child published before it proved its start takes no input yet; waited for outside - // the queue so a Stop can reach it meanwhile. - const failure = await this.deps.adapter.awaitStarted?.(sessionId) - const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited, failure || null) - ) - if (handed === 'stop') { - return - } + for (;;) { + const attempt: Attempt = {} + let step: Step + try { + step = await this.attempt(sessionId, attempt) + } catch (error) { + step = await this.failAttempt(sessionId, attempt, error) } - } catch (error) { - // The error is Orca's own and goes to the log; the chat says only that Orca failed. - this.deps.logger.warn('delivering a queued message failed', { - scope: 'delivery-loop', + if (step === 'stop') { + return + } + } + } + + /** One message's pass: a start for it, then its handover. A failed start fails only that + * message; the next pass takes the one behind it. */ + private async attempt(sessionId: string, attempt: Attempt): Promise { + const prepared = await this.deps.trackStart( + this.deps.serialize(sessionId, () => this.prepare(sessionId, attempt)) + ) + if (prepared === 'stop' || prepared === 'continue') { + return prepared + } + if (!prepared.ok) { + return this.deps.serialize(sessionId, () => + this.rejectStartFailure( + sessionId, + this.refusedStart(sessionId, prepared), + prepared.startedFor + ) + ) + } + // A child published before it proved its start takes no input yet; waited for outside + // the queue so a Stop can reach it meanwhile. + const failure = await this.deps.adapter.awaitStarted?.(sessionId) + return this.deps.serialize(sessionId, () => + this.handOver(sessionId, prepared, failure || null, attempt) + ) + } + + /** The error is Orca's own and goes to the log; the chat says only that Orca failed, on the + * message this pass attempted and never on another. */ + private async failAttempt(sessionId: string, attempt: Attempt, error: unknown): Promise { + this.deps.logger.warn('delivering a queued message failed', { + scope: 'delivery-loop', + sessionId, + error + }) + const { clientMessageId } = attempt + try { + return await this.deps.serialize(sessionId, async () => + clientMessageId === undefined + ? this.stop(sessionId) + : this.rejectStartFailure(sessionId, { hostFault: true }, clientMessageId) + ) + } catch (failure) { + // Left queued, it is taken by the next wake, or rejected as a leftover by the next open. + this.running.delete(sessionId) + this.deps.logger.warn('recording a failed delivery failed', { + scope: 'delivery-loop-fail', sessionId, - error + error: failure }) - const cause = { hostFault: true } as const - await this.deps - .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) - .catch((failure: unknown) => { - // Rows left queued are rejected by the next open, or by the next loop an accept wakes. - this.running.delete(sessionId) - this.deps.logger.warn('recording a failed delivery failed', { - scope: 'delivery-loop-fail', - sessionId, - error: failure - }) - }) + return 'stop' } } /** Settles what an earlier host process left queued, then makes the session ready. */ - private async prepare(sessionId: string): Promise { + private async prepare(sessionId: string, attempt: Attempt): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -169,37 +196,49 @@ export class StructuredAgentSessionDeliveryLoop { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) } - const oldest = oldestQueuedSubmission(session) + const oldest = oldestQueuedSubmission(session.journal) // A running command takes no input while its child carries it; its end is a commit, which // wakes the loop again. With no child it is a gone generation's, which the start below settles. if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } - const failedStart = startThatFailedWhileQueued(session, oldest) + const failedStart = startThatFailedWhileQueued(session) if (failedStart) { - return this.fail(sessionId, failedStart) + attempt.clientMessageId = failedStart.clientMessageId + return this.rejectStartFailure(sessionId, failedStart.cause, failedStart.clientMessageId) + } + if (childWhoseStartFailed(session)) { + // The next message gets a fresh start rather than this child's failure. + await this.endFailedStart(sessionId) } + attempt.clientMessageId = oldest.clientMessageId const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) if (!ready.ok && ready.refusal.details?.reason === 'previousExitUnverifiable') { // Failed in the step that was refused: a message accepted, or an exit proven, after it must // not be failed for a verdict that no longer holds. - return this.fail(sessionId, this.refusedStart(sessionId, ready)) + return this.rejectStartFailure( + sessionId, + this.refusedStart(sessionId, ready), + oldest.clientMessageId + ) } if (!ready.ok) { - return ready + return { ...ready, startedFor: oldest.clientMessageId } } const child = this.deps.sessions.get(sessionId)?.child // The child this run waits on; handover checks it is still the one there. return { ok: true, - awaited: child ? { generation: child.generation, fence: child.fence } : null + awaited: child ? { generation: child.generation, fence: child.fence } : null, + waitingFor: oldest.clientMessageId } } private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: SubmissionRejectionFact | null + { awaited, waitingFor }: Extract, + startFailure: SubmissionRejectionFact | null, + attempt: Attempt ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -223,17 +262,21 @@ export class StructuredAgentSessionDeliveryLoop { if (endedFailure === null) { return 'continue' } - return this.fail(sessionId, { - startKey: awaited?.generation ?? null, - cause: endedFailure ?? + // A child still here is ended by the next message's step (`childWhoseStartFailed`), after any + // exit of its own already queued: the message's failure never waits on that cleanup. + return this.rejectStartFailure( + sessionId, + endedFailure ?? // Gone with no end observed: nothing says the provider stopped. - { failure: startFailure ?? agentSessionFailureFact('startFailed') } - }) + { failure: startFailure ?? agentSessionFailureFact('startFailed') }, + waitingFor + ) } - const next = oldestQueuedSubmission(session) + const next = oldestQueuedSubmission(session.journal) if (!next) { return this.stop(sessionId) } + attempt.clientMessageId = next.clientMessageId await handOverSubmission( { sessionId, @@ -252,38 +295,54 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } - /** A start the session refused, as the failure every queued message it was for is rejected with. */ + /** A start the session refused, as the failure the message it was for is rejected with. */ private refusedStart( sessionId: string, { refusal, diagnostic }: Extract - ): StartFailure { + ): StructuredAgentSessionStartFailureCause { // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 return { - startKey: null, - cause: { - refusal, - ...(diagnostic ? { diagnostic } : {}), - ...(newSession ? { newSession: true as const } : {}) - } + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(newSession ? { newSession: true as const } : {}) } } - private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + /** Fails only the message the start was for; the next pass takes the one behind it. */ + private async rejectStartFailure( + sessionId: string, + cause: StructuredAgentSessionStartFailureCause, + clientMessageId: string + ): Promise<'continue'> { const session = this.deps.sessions.get(sessionId) if (session) { - await recordStructuredAgentSessionStartFailure( - { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, + await rejectStructuredAgentSessionStartFailure( { - startKey: failure.startKey, - ...structuredAgentSessionStartFailure( - failure.cause, - this.deps.failureTextContext(sessionId) - ) - } + journal: session.journal, + fence: this.deps.conversationFence(sessionId), + record: this.deps.record(sessionId) + }, + cause, + clientMessageId ) } - return this.stop(sessionId) + return 'continue' + } + + /** Ends a child whose start failed, once its message is settled. Best effort: a stop that could + * not prove the exit leaves the child closing, and the next start is refused for that + * (`previousExitUnverifiable`), as after any such stop. */ + private async endFailedStart(sessionId: string): Promise { + try { + await this.deps.endFailedStart(sessionId) + } catch (error) { + this.deps.logger.warn('ending a failed start failed', { + scope: 'delivery-loop-end-failed-start', + sessionId, + error + }) + } } /** A close of this chat that stopped its child and then did not complete still closed what was @@ -313,55 +372,3 @@ export class StructuredAgentSessionDeliveryLoop { return 'stop' } } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): StartFailure | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} - -function providerEndFailure( - ended: StructuredAgentSessionEndedChild -): StructuredAgentSessionStartFailureCause { - if (ended.duringStartup) { - return { exit: ended.failure } - } - return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } -} - -// Every end cause, so a new one does not compile until it says whether it fails what is queued. -const ENDED_CHILD_FAILURE = { - 'user-stop': () => null, - // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. - 'user-close': () => null, - // The host stopping the child is Orca's cause, never the provider's: a start that never finished. - 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), - exit: providerEndFailure, - // The attach records its own fault as the end's failure. - 'attach-failed': providerEndFailure, - // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. - evict: providerEndFailure -} satisfies Record< - StructuredAgentSessionChildEndCause, - (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null -> - -/** Why a queued message the child never took is rejected; null when its end fails nothing. */ -export function structuredAgentSessionEndedChildFailure( - ended: StructuredAgentSessionEndedChild -): StructuredAgentSessionStartFailureCause | null { - return ENDED_CHILD_FAILURE[ended.cause](ended) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts new file mode 100644 index 000000000000..d496bfefc2f1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts @@ -0,0 +1,76 @@ +// What a provider child's end means for the queued message it was started for: a failed start that +// message takes, or nothing. Read from the child records and the journal each step, never latched. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { + isStillQueued, + rejectedAsFailedStartAt +} from './structured-agent-session-start-failure-settlement' + +/** A start that died between steps fails the message it was for, while that message is still + * queued: once it is rejected nothing matches, so the next message starts afresh. A view's start + * was for no message and fails none. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession +): { cause: StructuredAgentSessionStartFailureCause; clientMessageId: string } | null { + const ended = failedProviderChildStart(session) + const startedFor = ended?.startedFor + if (!ended || startedFor === undefined || !isStillQueued(session.journal, startedFor)) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { cause, clientMessageId: startedFor } : null +} + +/** A child still starting whose start a message was already rejected for: read from the journal, + * so no flag can outlive it. Ended before the next message's start rather than at once, so an exit + * of its own, already on its way, settles it first. One a stop is closing is that stop's. */ +export function childWhoseStartFailed(session: StructuredAgentSessionHostSession): boolean { + const child = session.child + if (!child || child.phase !== 'starting' || child.close) { + return false + } + return session.journal + .submissions() + .some((submission) => rejectedAsFailedStartAt(submission, child.fence)) +} + +function providerEndFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause { + if (ended.duringStartup) { + return { exit: ended.failure } + } + return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } +} + +// Every end cause, so a new one does not compile until it says whether it fails what is queued. +const ENDED_CHILD_FAILURE = { + 'user-stop': () => null, + // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. + 'user-close': () => null, + // The host stopping the child is Orca's cause, never the provider's: a start that never finished. + 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + exit: providerEndFailure, + // The attach records its own fault as the end's failure. + 'attach-failed': providerEndFailure, + // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. + evict: providerEndFailure +} satisfies Record< + StructuredAgentSessionChildEndCause, + (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null +> + +/** Why a queued message the child never took is rejected; null when its end fails nothing. */ +export function structuredAgentSessionEndedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause | null { + return ENDED_CHILD_FAILURE[ended.cause](ended) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 80460bb72fa9..b1200ad4a89c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -53,6 +53,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + /** Ends the session's child, whose start failed, as a host stop; for a caller inside `serialize`. */ + endFailedStart: (sessionId: string) => Promise clientDelivery: Pick }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input @@ -63,6 +65,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, + endFailedStart: input.endFailedStart, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), abandonQueued: async (sessionId, which) => { 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 59dc79af0153..7e781769849f 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 @@ -127,6 +127,7 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + endFailedStart: (sessionId) => this.lifetime.stopAgent(sessionId, { cause: 'host-stop' }), clientDelivery: this.clientDelivery }) this.restore = createStructuredAgentSessionHostRestore(deps, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 918afba6c5b3..08be19f8e0bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -251,20 +251,20 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: 'recovering' }) // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is - // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, + // accepted, then rejected by the start that cannot take the lease, and the message says why, // worded from the refusal's details; only the live refusal names the process. const quitTerminal = 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await delivered('while the terminal still runs')).toMatchObject({ - dispatchState: 'rejected' + dispatchState: 'rejected', + reason: + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." }) expect( - (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' ) - ).toEqual([ - "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." - ]) + ).toEqual([]) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6f0c544f377c..031d806868fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -226,6 +226,8 @@ function exit(child: ReturnType, reason: string, startupUnp }) } +/** Whether a subscriber was told the message was rejected. */ +/** Whether a subscriber was told the message was rejected. */ function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { return events.some( (event) => @@ -236,6 +238,10 @@ function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { ) } +async function rejected(id: string): Promise { + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) +} + function conversation() { return host['sessions'].get(SESSION) } @@ -387,7 +393,7 @@ describe('a published child that dies while it proves its start', () => { const TEXT = START_TEXT it.each([['the loop sees the start fail first'], ['the exit is processed first']])( - 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', + 'records the failure once on the message it was for, and the next message starts again: %s (R2)', async (order) => { const settled = deferred() adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } @@ -404,23 +410,13 @@ describe('a published child that dies while it proves its start', () => { settled.resolve(START_FAILURE) } else { settled.resolve(START_FAILURE) - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) + await rejected(first) await exit(child, EXIT, true) } - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) + // The second goes on to a start of its own, which fails the same way. + await rejected(second) + expect(await statusRows()).toEqual([]) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -428,9 +424,9 @@ describe('a published child that dies while it proves its start', () => { rejection: START_FAILURE }) } - expect(rejectedIn(events, second)).toBe(true) + expect(rejectedIn(events, first)).toBe(true) expect(dispatch).not.toHaveBeenCalled() - expect(acquire).toHaveBeenCalledTimes(2) + expect(acquire).toHaveBeenCalledTimes(3) } ) }) @@ -446,7 +442,7 @@ describe('a start another operation made that dies while a sent message waits on const EXIT = START_EXIT const TEXT = START_TEXT - it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { + it("is not the message's: the message makes its own start, which says why it failed (R2)", async () => { adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) @@ -466,21 +462,20 @@ describe('a start another operation made that dies while a sent message waits on await exited const id = params.envelope.clientOperationId - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + await rejected(id) await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) + // Its own start failed the same way. + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: { kind: 'providerStartFailed' } + }) expect(rejectedIn(events, id)).toBe(true) - // The setup's child and the operation's: nothing started again into the same failure. - expect(acquire).toHaveBeenCalledTimes(2) + // The setup's child, the operation's, and the message's own. + expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).not.toHaveBeenCalled() + // A message was waiting, so the operation's exit adds no row saying the same thing. + expect(await statusRows()).toEqual([]) }) it('leaves a message sent after that start failed to a fresh start (R2)', async () => { @@ -488,7 +483,10 @@ describe('a start another operation made that dies while a sent message waits on acquire.mockImplementationOnce(spawnStartingChild) await startForOperation() await exit(currentChild(), EXIT, true) - expect(await statusRows()).toHaveLength(1) + // A start for no message says why it failed in its exit's row. + expect(await statusRows()).toEqual([ + expect.objectContaining({ text: TEXT, tone: 'error', failure: START_FAILURE }) + ]) const id = await accept('after the failure') @@ -535,7 +533,7 @@ describe('a start another operation made that dies while a sent message waits on }) describe('a child that ends before its message is handed over', () => { - it('starts one child for the message, then rejects it and stops (R2)', async () => { + it('starts one child for the message, then records its end on it and stops (R2)', async () => { // The child the loop starts exits between its start step and its handover step. adapterExtras = { awaitStarted: vi.fn(async () => { @@ -546,19 +544,17 @@ describe('a child that ends before its message is handed over', () => { const id = await accept('hello') const events = await subscribe() - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + await rejected(id) // The exit's stderr rides beside the sentence, never in it. - const failure = { - kind: 'providerExited', - detail: { text: 'codex app-server crashed', audience: 'log' } - } expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', reason: 'Codex stopped before this message was sent.', - rejection: failure + rejection: { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + } }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } - ]) + expect(await statusRows()).toEqual([]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) expect(acquire).toHaveBeenCalledTimes(2) @@ -662,19 +658,20 @@ describe('a quit with a message still queued', () => { }) describe('a send whose start failed, sent again with the same operation id', () => { - it('replays the recorded rejection and starts no second agent (R2)', async () => { + it('replays the recorded answer and starts no second agent (R2)', async () => { acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence const params = sendParams('hello') - // A failed start is a rejected message, never a refused send. + // A failed start rejects the accepted message, never the send. expect(await host.send(CALLER, params)).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) const id = params.envelope.clientOperationId - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + await rejected(id) // The start moved the fence while the message was out. expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) + await settleLoop() const starts = acquire.mock.calls.length const replay = await host.send(CALLER, params) @@ -682,7 +679,13 @@ describe('a send whose start failed, sent again with the same operation id', () expect(replay).toMatchObject({ ok: true, replayed: true, - value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } + value: { + submission: { + clientMessageId: id, + dispatchState: 'rejected', + rejection: { kind: 'restartFailed' } + } + } }) await settleLoop() expect(acquire).toHaveBeenCalledTimes(starts) @@ -827,16 +830,17 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) ) - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + // The start was waited on for the first message, which takes its failure; the second goes on. + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + const first = (await host.journalSnapshot(SESSION)).submissions[0] // The sentence is the constructor's, not the reason the stop was given. const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(second)).toMatchObject({ + expect(first).toMatchObject({ + dispatchState: 'rejected', reason: text, rejection: { kind: 'hostStopped' } }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } - ]) - expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f1bbd673cd62..54c19748a842 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -402,7 +402,7 @@ describe('a send with no live owner', () => { expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { + it("rejects the accepted message with the restart's own cause, said on the message alone", async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') @@ -422,8 +422,8 @@ describe('a send with no live owner', () => { expect( store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) ).toMatchObject({ outcome: { status: 'succeeded' } }) - // One row, in the error tone, so the reason outlives the error strip. - expect(await errorStatuses()).toEqual([cause]) + // The message carries the reason; no row repeats it beside the message. + expect(await errorStatuses()).toEqual([]) }) it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { @@ -445,9 +445,7 @@ describe('a send with no live owner', () => { reason: "Codex couldn't restart. Send your message to try again.", rejection }) - expect(await errorStatuses()).toEqual([ - "Codex couldn't restart. Send your message to try again." - ]) + expect(await errorStatuses()).toEqual([]) // Orca's own text is logged once where the start failed. expect( hostErrors.filter( @@ -463,21 +461,20 @@ describe('a send with no live owner', () => { await settled(await accept(params)) expect(acquire).toHaveBeenCalledTimes(1) - // A client that resends the same id gets the recorded rejection, and the chat no second row. + // A client that resends the same id gets the recorded rejection, and no second attempt. await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, value: { submission: { dispatchState: 'rejected' } } }) expect(acquire).toHaveBeenCalledTimes(1) - expect(await errorStatuses()).toHaveLength(1) - // The outbox's Retry rotates the id: a fresh attempt, with its own row. + // The outbox's Retry rotates the id: a fresh attempt, failed on its own message. expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toHaveLength(2) + expect(await errorStatuses()).toEqual([]) expect(dispatch).not.toHaveBeenCalled() }) @@ -542,7 +539,7 @@ describe('a send with no live owner', () => { rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } }) expect(acquire).not.toHaveBeenCalled() - expect(await errorStatuses()).toEqual([cause]) + expect(await errorStatuses()).toEqual([]) }) it('rejects the message, and reports the fault, when the restart itself faults', async () => { @@ -563,7 +560,7 @@ describe('a send with no live owner', () => { expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) ) - expect(await errorStatuses()).toHaveLength(1) + expect(await errorStatuses()).toEqual([]) }) it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index f41d2e190c7d..8937e50024c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -4,14 +4,13 @@ // message to the new owner, instead of parking it behind a lease nothing would ever re-acquire. // // A child is published before it has proven its start, and it owns the send from that moment: the -// message is handed to it. When the child exits first, the exit settlement rejects the message and -// writes the cause into the chat, once, and the next send is a fresh restart. +// message is handed to it. When the child exits first, the exit settlement rejects the message with +// the cause on it, and the next send is a fresh restart. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -23,7 +22,6 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, - hostTestDrawnRowIds, hostTestMessage, hostTestOperationId, resetHostTestOperationIds @@ -203,7 +201,7 @@ describe('a send into a published session whose child ended before startup', () expect(dispatch).toHaveBeenCalledTimes(2) }) - it('retires the held message with the cause when the restarted child exits before proving its start', async () => { + it('rejects the held message with the cause when the restarted child exits before proving its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const rowsBefore = (await journalStatuses()).length @@ -211,7 +209,8 @@ describe('a send into a published session whose child ended before startup', () await exitBeforeProof() // The child never proved its start, so it accepted nothing: the exit rejects the message this - // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. + // host admitted, so nothing pins the session and Retry stays offered. The message names the + // cause; the chat gets no row of its own for it. expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: 'Codex stopped before it finished starting. Send your message to try again.', @@ -221,18 +220,7 @@ describe('a send into a published session whose child ended before startup', () expect( (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) - expect((await journalStatuses()).slice(rowsBefore)).toEqual([ - 'Codex stopped before it finished starting. Send your message to try again.' - ]) - // Accepted before the restart it needed, so the chat draws it above the row naming the cause. - const snapshot = await host.journalSnapshot(SESSION) - const causeRow = snapshot.items.findLast((item) => item.body.kind === 'status')?.itemId - const shown = [agentJournalSubmissionKey(held), causeRow] - expect( - hostTestDrawnRowIds(snapshot, [ - { clientMessageId: held, text: 'still not signed in' } - ]).filter((id) => shown.includes(id)) - ).toEqual(shown) + expect((await journalStatuses()).slice(rowsBefore)).toEqual([]) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -243,7 +231,7 @@ describe('a send into a published session whose child ended before startup', () await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) - expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) + expect((await journalStatuses()).slice(rowsBefore)).toEqual([]) }) }) @@ -260,7 +248,7 @@ describe('a send while the child of the first start is still proving itself', () expect(await journalStatuses()).toEqual([]) }) - it('is retired with the cause when that child exits first, and restarts nothing', async () => { + it('is rejected with the cause when that child exits first, and restarts nothing', async () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const held = await send('hello') @@ -273,9 +261,7 @@ describe('a send while the child of the first start is still proving itself', () recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(await journalStatuses()).toEqual([ - 'Codex stopped before it finished starting. Send your message to try again.' - ]) + expect(await journalStatuses()).toEqual([]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts deleted file mode 100644 index 9206f95fde0b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' -import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' - -/** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ -export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { - startKey: string | null -} - -/** - * The one row a start that failed leaves in the chat, whoever saw it fail: an error row, so the - * reason outlives any error strip. Keyed by the start — the child's generation, or the oldest - * message it was for when no child was ever published — so a second report of the same failure - * lands on the same row instead of adding one. - */ -export function structuredAgentSessionStartFailureRow( - startKey: string, - words: StructuredAgentSessionStartFailureWords -): JournalLifecycleMutationInput { - return { - kind: 'item', - identity: structuredAgentSessionStartFailureRowIdentity(startKey), - // The row repeats the sentence the start's rejected messages carry. - body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }, - // A start that failed opened no turn. - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } -} - -/** Whether the start's row is already written. Its words are the ones its rejected messages carry, - * and rejected is terminal, so the exit's later report of the same start must not reword it. */ -export function hasStructuredAgentSessionStartFailureRow( - items: readonly { itemId: string }[], - startKey: string -): boolean { - const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) - return items.some((item) => item.itemId === itemId) -} - -/** - * A start the delivery loop needed and did not get: every queued message rejected with the same - * words, then the start's row. Writes nothing when nothing is still queued: a start whose messages - * Stop withdrew did not fail anyone. - */ -export async function recordStructuredAgentSessionStartFailure( - session: Pick & { fence: number }, - failure: StructuredAgentSessionStartFailure -): Promise { - const oldest = oldestQueuedSubmission(session) - if (!oldest) { - return - } - const startKey = failure.startKey ?? oldest.clientMessageId - await session.journal.appendLifecycleBatch({ - settlementId: `start-failure:${startKey}`, - fence: session.fence, - recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], - rejectsQueued: { reason: failure.reason, rejection: failure.rejection } - }) -} - -export function oldestQueuedSubmission( - session: Pick -): ReturnType[number] | undefined { - let oldest: ReturnType - for (const submission of session.journal.submissions()) { - if ( - isQueuedAgentJournalSubmission(submission) && - (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) - ) { - oldest = submission - } - } - return oldest -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts new file mode 100644 index 000000000000..98fadb348381 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -0,0 +1,111 @@ +// The delivery loop's writer of a failed start: the one queued message the start was for. A message +// handed to a child that never proved its start is the handover's or the exit's to settle; each +// state has one writer, and `rejected` is terminal, so no message is failed twice. + +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isFailedStartRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND } from './structured-agent-session-command-turn' +import { + structuredAgentSessionStartFailure, + type StructuredAgentSessionStartFailureCause +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' + +type StartFailureJournal = Pick + +/** Who the message's sentence names, and the command its own body sends, so the next step is to run + * that command again rather than to send a message. */ +function startFailureWordsContext( + journal: Pick, + record: AgentSessionRecord | null, + clientMessageId: string +): AgentSessionFailureWordsContext { + const body = journal.itemBody(agentJournalSubmissionKey(clientMessageId)) + const command = + body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + : undefined + return { + ...structuredAgentSessionFailureWordsContext(record), + ...(command ? { command } : {}) + } +} + +/** Whether the message is still accepted and not yet handed over. */ +export function isStillQueued( + journal: Pick, + clientMessageId: string +): boolean { + const submission = journal + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + return submission !== undefined && isQueuedAgentJournalSubmission(submission) +} + +/** Rejects the message a failed start was for. Writes nothing once it is no longer queued: Stop + * withdrew it, or another writer settled it. */ +export async function rejectStructuredAgentSessionStartFailure( + writer: { journal: StartFailureJournal; fence: number; record: AgentSessionRecord | null }, + cause: StructuredAgentSessionStartFailureCause, + clientMessageId: string +): Promise { + if (!isStillQueued(writer.journal, clientMessageId)) { + return + } + await writer.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...structuredAgentSessionStartFailure( + cause, + startFailureWordsContext(writer.journal, writer.record, clientMessageId) + ), + fence: writer.fence + }) +} + +type FailedStartSubmission = Pick< + AgentJournalSubmission, + 'dispatchState' | 'handoverRecorded' | 'handedOverAt' +> & + Partial> + +/** Whether a message was rejected as the failed start of the child under `fence`. */ +export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence: number): boolean { + return ( + submission.dispatchState === 'rejected' && + submission.fence === fence && + isFailedStartRejection({ reason: submission.reason ?? null, rejection: submission.rejection }) + ) +} + +/** Whether a message says why a child that died starting under `fence` failed: a queued one, which + * the delivery loop rejects with it or starts again for, or one already rejected as that start. */ +export function messageCarriesFailedStart( + submissions: readonly FailedStartSubmission[], + fence: number +): boolean { + return submissions.some( + (submission) => + isQueuedAgentJournalSubmission(submission) || rejectedAsFailedStartAt(submission, fence) + ) +} + +export function oldestQueuedSubmission( + journal: Pick +): AgentJournalSubmission | undefined { + let oldest: AgentJournalSubmission | undefined + for (const submission of journal.submissions()) { + if ( + isQueuedAgentJournalSubmission(submission) && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + return oldest +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 77b48a375a47..b45545640f1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -1,7 +1,8 @@ -// A start a queued message waited on can be seen failing twice: by the delivery loop, when the -// adapter settles the start without proving it, and by the exit settlement, when the child's exit -// lands. The chat gets one row for that start, the loop's, in the words the message was rejected -// with — whichever of the two reports first. +// A start that fails fails only the message it was for, and the messages behind it each get their +// own start. Three writers settle a failed start, one per state the message is in: the delivery +// loop the queued message the start was for, the handover the message being handed over, and the +// exit any message handed to a child that never proved its start. Every write names a message fixed +// when its pass chose it, so a failure on the way never lands on another message. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -11,11 +12,19 @@ import { agentSessionFailureFact, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { ResolveDispatchInput } from '../agent-session-journal/journal-store-contracts' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -27,21 +36,28 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' -const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') -const ADAPTER_FAILURE_TEXT = - 'Codex is not signed in for the selected account. Sign in, then send your message again.' -// The exit's reason is Orca's log text; the row says only that the start stopped. -const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' -// The first child (generation-1) is lost at setup; the send starts generation-2. -const START_ROW = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'start-failure:generation-2' +// A situation that could clear on its own, seen after the child was spawned. +const TRANSIENT = agentSessionFailureFact('accountSwitchInProgress') +const TRANSIENT_WORDS = agentSessionFailureWords(TRANSIENT, { + surface: 'rejection', + agentName: 'Codex' +}) +// A start that faulted at a dispatch: no exit was observed, so nothing blames the provider. +const DISPATCH_FAULT = agentSessionFailureFact('startFailed') +const DISPATCH_WORDS = agentSessionFailureWords(DISPATCH_FAULT, { + surface: 'rejection', + agentName: 'Codex' +}) +// A child that ran and could not finish its start. +const PROVIDER_START_FAILED = agentSessionFailureFact('providerStartFailed') +const HOST_FAULT_WORDS = agentSessionFailureWords(agentSessionFailureFact('hostFault'), { + surface: 'rejection' }) function eventually(assertion: () => void | Promise): Promise { @@ -51,9 +67,14 @@ function eventually(assertion: () => void | Promise): Promise { let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +let log: ReturnType let generation = 0 let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} +// Runs before each spawn; throwing refuses that start before it ran. +let beforeSpawn = vi.fn<() => Promise>() let awaitStarted = vi.fn<() => Promise>() +let dispatch = vi.fn() +let closeSession = vi.fn>() let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -68,7 +89,48 @@ function exitBeforeProof(): Promise { }) } -async function sendQueued(text: string): Promise { +function startHost(): void { + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: log.logger, + store, + adapter: { + acquire: vi.fn(async ({ fence, spawnToken }) => { + await beforeSpawn() + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + } + }), + awaitStarted, + releaseAcquisition: vi.fn(async () => true), + closeSession, + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation + 1}`, + now: () => NOW + }) +} + +async function send(text: string): Promise { const body = hostTestMessage(text) const sent = await host.send(CALLER, { envelope: { @@ -84,33 +146,74 @@ async function sendQueued(text: string): Promise { body }) expect(sent).toMatchObject({ ok: true }) - // The loop started a child and waits on its start with the message still queued. - await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) - expect(generation).toBe(2) return sent.ok ? sent.value.clientMessageId : '' } -async function submission(clientMessageId: string) { +/** Sent while the loop waits on a start it made for the first message. */ +async function sendQueued(text: string): Promise { + const id = await send(text) + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + return id +} + +async function submission(clientMessageId: string): Promise { return (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === clientMessageId ) } -/** Every text the subscriber was sent for the start's row, in order. */ -function publishedStartRows(): string[] { +async function startRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + isStructuredAgentSessionStartFailureRow(item.itemId) ? [item.itemId] : [] + ) +} + +/** Every dispatch state a subscriber was told for one message, in order. */ +function framedStates(clientMessageId: string): string[] { return frames.flatMap((frame) => frame.type === 'batch' - ? frame.batch.items.flatMap((item) => - item.itemId === START_ROW && item.body.kind === 'status' ? [item.body.text] : [] - ) + ? frame.batch.submissions + .filter((entry) => entry.clientMessageId === clientMessageId) + .map((entry) => entry.dispatchState) : [] ) } -async function startRows(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.itemId === START_ROW && item.body.kind === 'status' ? [item.body.text] : [] - ) +function dispatched(): string[] { + return dispatch.mock.calls.map(([input]) => input.clientMessageId) +} + +/** Records each rejection the journal is asked to write, in order; with `fail`, fails that + * message's first `times` (one by default), before they land or after. */ +function spyRejections(fail?: { + clientMessageId: string + when: 'before' | 'after' + times?: number +}): string[] { + const order: string[] = [] + const resolve = AgentSessionJournal.prototype.resolveDispatch + let failures = 0 + vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(async function ( + this: AgentSessionJournal, + input: ResolveDispatchInput + ) { + if (input.state === 'rejected') { + order.push(`rejected ${input.clientMessageId}`) + } + if ( + failures >= (fail?.times ?? 1) || + input.state !== 'rejected' || + input.clientMessageId !== fail?.clientMessageId + ) { + return resolve.call(this, input) + } + failures += 1 + if (fail.when === 'after') { + await resolve.call(this, input) + } + throw new Error('journal write failed') + }) + return order } beforeEach(async () => { @@ -118,84 +221,233 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 frames = [] + log = recordingStructuredAgentSessionLogger() + beforeSpawn = vi.fn(async () => undefined) awaitStarted = vi.fn( () => new Promise((resolve) => (settleStart = resolve)) ) + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: generation === 0 ? ('created' as const) : ('resumed' as const), - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const - })), - awaitStarted, - releaseAcquisition: vi.fn(async () => true), - closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${generation + 1}`, - now: () => NOW - }) + startHost() await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) + // The first child (generation-1) is lost at setup; the first send starts generation-2. await exitBeforeProof() await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) }) afterEach(async () => { + vi.restoreAllMocks() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) -describe('a queued message whose start fails and whose child then exits', () => { - it("keeps the loop's row when the loop saw the failure first", async () => { +describe('a queued message whose start fails', () => { + it('is rejected at once for any failure after its child was spawned, whatever the failure', async () => { const queued = await sendQueued('hello') - settleStart(ADAPTER_FAILURE) + settleStart(TRANSIENT) + await eventually(async () => expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected', - reason: ADAPTER_FAILURE_TEXT, - rejection: ADAPTER_FAILURE + ...TRANSIENT_WORDS }) ) + expect(await startRows()).toEqual([]) + }) + + it('fails only itself: each message behind it gets its own start', async () => { + beforeSpawn.mockImplementationOnce(async () => { + throw new AgentSessionPreSpawnError(new Error('spawn codex ENOENT')) + }) + awaitStarted.mockImplementation(async () => undefined) + const first = await send('first') + const second = await send('second') + const third = await send('third') + + await eventually(() => expect(dispatched()).toEqual([second, third])) + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'restartFailed' } + }) + for (const id of [second, third]) { + expect(framedStates(id)).not.toContain('rejected') + } + expect(await startRows()).toEqual([]) + }) + + it('ends a failed child still there when the next message comes, and starts afresh', async () => { + const failed = await sendQueued('first') + settleStart(PROVIDER_START_FAILED) + await eventually(async () => + expect(await submission(failed)).toMatchObject({ dispatchState: 'rejected' }) + ) + // Not ended at once: an exit of its own may already be on its way. + expect(closeSession).not.toHaveBeenCalled() + awaitStarted.mockImplementation(async () => undefined) + + const next = await send('second') + + await eventually(() => expect(dispatched()).toEqual([next])) + expect(closeSession).toHaveBeenCalledOnce() + expect(generation).toBe(3) + }) +}) + +describe('a failure on the way to recording a failed start', () => { + it('retries the same message when the rejection did not land, and never fails the one behind it', async () => { + const first = await sendQueued('first') + const second = await send('second') + spyRejections({ clientMessageId: first, when: 'before' }) + awaitStarted.mockImplementation(async () => undefined) + + settleStart(DISPATCH_FAULT) + + // The catch's retry is Orca's fault, worded as one; the message behind it goes on. + await eventually(async () => + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_FAULT_WORDS + }) + ) + await eventually(() => expect(dispatched()).toEqual([second])) + expect(framedStates(second)).not.toContain('rejected') + }) + + it('never fails the message behind one whose rejection landed before the error', async () => { + const first = await sendQueued('first') + const second = await send('second') + spyRejections({ clientMessageId: first, when: 'after' }) + awaitStarted.mockImplementation(async () => undefined) + + settleStart(DISPATCH_FAULT) + + await eventually(async () => + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...DISPATCH_WORDS + }) + ) + await eventually(() => expect(dispatched()).toEqual([second])) + expect(framedStates(second)).not.toContain('rejected') + expect(log.entries.map((entry) => entry.fields.scope)).toContain('delivery-loop') + }) + + it('records the failure before ending the failed child, so a failed cleanup loses nothing', async () => { + const first = await sendQueued('first') + const second = await send('second') + const order = spyRejections() + closeSession.mockImplementation(async () => { + order.push('cleanup') + throw new Error('stop failed') + }) + + settleStart(DISPATCH_FAULT) + + await eventually(async () => + expect(await submission(second)).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(order.slice(0, 2)).toEqual([`rejected ${first}`, 'cleanup']) + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...DISPATCH_WORDS }) + expect(log.entries.map((entry) => entry.fields.scope)).toContain( + 'delivery-loop-end-failed-start' + ) + // The stop could not prove the exit, so the next start is refused for that, in its own words. + expect(await submission(second)).toMatchObject({ + rejection: { + kind: 'restartFailed', + refusal: { details: { reason: 'previousExitUnverifiable' } } + } + }) + expect(dispatch).not.toHaveBeenCalled() + // Quit's own stop of the old child succeeds. + closeSession.mockImplementation(async () => true) + }) +}) + +describe('a start that fails while its child exits', () => { + // Neither the loop nor its catch could record the failure, so the message stayed queued. + it('records the start on the message it was for when its child ends, rather than starting again', async () => { + const queued = await sendQueued('hello') + spyRejections({ clientMessageId: queued, when: 'before', times: 2 }) + + settleStart(DISPATCH_FAULT) + await eventually(() => + expect(log.entries.map((entry) => entry.fields.scope)).toContain('delivery-loop-fail') + ) + expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) await exitBeforeProof() - await host.flushStreamedEvents(SESSION) - expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) - expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + ) + expect(generation).toBe(2) + expect(await startRows()).toEqual([]) }) - it('leaves the row to the loop when the exit lands while the message still waits', async () => { + it('is recorded once when the exit lands before the loop sees the start fail', async () => { const queued = await sendQueued('hello') await exitBeforeProof() expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) settleStart(undefined) await eventually(async () => - expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) + expect(await submission(queued)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + ) + const recorded = await submission(queued) + await host.flushStreamedEvents(SESSION) + + expect(await submission(queued)).toEqual(recorded) + expect(await startRows()).toEqual([]) + }) + + it('is recorded once when the exit lands after the loop recorded it', async () => { + const queued = await sendQueued('hello') + + settleStart(DISPATCH_FAULT) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ + dispatchState: 'rejected', + ...DISPATCH_WORDS + }) ) + const recorded = await submission(queued) + await exitBeforeProof() await host.flushStreamedEvents(SESSION) - expect(await startRows()).toEqual([EXIT_TEXT]) - // Written once, after the message was settled, not first by the exit and again by the loop. - expect(publishedStartRows()).toEqual([EXIT_TEXT]) + // The exit's own reason never rewrites the failure the loop recorded. + expect(await submission(queued)).toEqual(recorded) + expect(await startRows()).toEqual([]) + }) + + it('rejects a message its unproven child was handed with the start, never in doubt', async () => { + awaitStarted.mockImplementation(async () => undefined) + const handed = await send('hello') + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(await submission(handed)).toMatchObject({ + dispatchState: 'pending', + handedOverAt: expect.any(Number) + }) + + await exitBeforeProof() + + await eventually(async () => + expect(await submission(handed)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + ) + // Never in doubt on the way: the child it was handed to took nothing. + expect(framedStates(handed)).not.toContain('unknown') + expect(await startRows()).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 07f54bc2ab4a..e1c4ba03a4d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, type Mock } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import type { JournalLifecycleBatchInput } from '../agent-session-journal/journal-store-contracts' import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { settleStructuredAgentSessionChildExit, @@ -18,7 +19,12 @@ const REASON = 'claude stream-json exited (code 1): session limit reached' const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' function startedSession(): StructuredAgentSessionChildExitSession & { - journal: { appendLifecycleBatch: ReturnType } + journal: { + appendLifecycleBatch: Mock< + (batch: JournalLifecycleBatchInput) => Promise<{ epoch: string; sequence: number }> + > + rejectPendingSubmissions: Mock<(...args: unknown[]) => Promise> + } } { return { child: { generation: GENERATION, fence: 7, phase: 'ready' }, @@ -27,13 +33,30 @@ function startedSession(): StructuredAgentSessionChildExitSession & { itemBody: () => null, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), - appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), + appendLifecycleBatch: vi.fn(async (_batch: JournalLifecycleBatchInput) => ({ + epoch: 'epoch-1', + sequence: 1 + })), markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectPendingSubmissions: vi.fn(async () => []) + rejectPendingSubmissions: vi.fn(async (..._args: unknown[]): Promise => []) } } } +/** The exit's row, which says why a start that carried no message failed. */ +const EXIT_ROW_IDENTITY = { + provider: 'orca', + clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` +} + +function statusRowsWritten(session: ReturnType): unknown[] { + return session.journal.appendLifecycleBatch.mock.calls.flatMap(([batch]) => + batch.mutations.filter( + (mutation) => mutation.kind === 'item' && mutation.body.kind === 'status' + ) + ) +} + function contextFor(session: StructuredAgentSessionChildExitSession) { let record: AgentSessionRecord = agentSessionRecordFixture( agentSessionLeaseFixture({ @@ -75,7 +98,7 @@ const ended = { } describe('a provider that ends before it finished starting', () => { - it('tells the user why, even with no response in progress', async () => { + it('tells the user why a start that carried no message failed, even with no response in progress', async () => { const session = startedSession() await settleStructuredAgentSessionChildExit(contextFor(session), { @@ -89,8 +112,7 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - // The same row the delivery loop writes for a failed start: an error, keyed by it. - identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + identity: EXIT_ROW_IDENTITY, body: { kind: 'status', text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', @@ -127,8 +149,7 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - // The same row the delivery loop writes for a failed start: an error, keyed by it. - identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + identity: EXIT_ROW_IDENTITY, // The exit's stderr stays out of the sentence, as a log detail beside it. body: { kind: 'status', @@ -145,7 +166,7 @@ describe('a provider that ends before it finished starting', () => { ) }) - it('names /compact as the next step when the start that failed was carrying it', async () => { + it('names /compact as the next step on the command the start that failed was carrying, and writes no row', async () => { const base = startedSession() const session = { ...base, @@ -156,6 +177,7 @@ describe('a provider that ends before it finished starting', () => { itemBody: () => structuredAgentSessionCompactBody() } } + session.journal.rejectPendingSubmissions.mockResolvedValue(['compact-1']) await settleStructuredAgentSessionChildExit(contextFor(session), ended) @@ -164,10 +186,39 @@ describe('a provider that ends before it finished starting', () => { 7, expect.objectContaining({ reason: text }) ) - expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( - expect.objectContaining({ - mutations: [expect.objectContaining({ body: expect.objectContaining({ text }) })] - }) + expect(statusRowsWritten(session)).toEqual([]) + }) + + it('rejects only the messages handed to it, and writes no row: each message says why', async () => { + const session = { + ...startedSession(), + child: { generation: GENERATION, fence: 7, phase: 'starting' as const } + } + session.journal.rejectPendingSubmissions.mockResolvedValue(['handed-1']) + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: STARTUP_TEXT }) ) + expect(session.journal.markPendingSubmissionsUnknown).not.toHaveBeenCalled() + expect(statusRowsWritten(session)).toEqual([]) + }) + + it('writes no row for a start made for a queued message: the delivery loop rejects that message', async () => { + const session = { + ...startedSession(), + child: { + generation: GENERATION, + fence: 7, + phase: 'starting' as const, + startedFor: 'queued-1' + } + } + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(statusRowsWritten(session)).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 8f6037c6d9c7..19c994c18015 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -1,6 +1,6 @@ -// A Claude chat whose CLI exits before it finishes starting leaves one red row per start. A view -// opening, or coming back to, a chat whose last start failed used to start the CLI again, so every -// look at the chat added an identical row. Only a send retries a failed start: it is the user asking. +// A Claude chat whose CLI exits before it finishes starting. A view opening, or coming back to, a +// chat whose last start failed used to start the CLI again, so every look at the chat added an +// identical row. Only a send retries a failed start: it is the user asking, and its message says why. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -154,7 +154,7 @@ describe('a fresh chat whose Claude start fails', () => { ['after the create already died', false], ['while the create is still starting', true] ] as const)( - 'starts once for the open and once for a send, one row each, when the view binds %s', + 'starts once for the open and once for a send, which says why on its message, when the view binds %s', async (_when, createStillStarting) => { // Released only once the views bound, so no runner is slow enough to let the create die first. let releaseCreate = (): void => {} @@ -203,16 +203,16 @@ describe('a fresh chat whose Claude start fails', () => { ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) ) await settleExits() - // The send's own start, once, and one row for it below the message. + // The send's own start, once; the message carries why, with no row beside it. expect(claude.connections).toHaveLength(2) - expect(await timeline()).toEqual([startFailure, 'message', startFailure]) + expect(await timeline()).toEqual([startFailure, 'message']) // Switching away and back re-subscribes; it starts nothing and adds no row. unsubscribe() await view(SURFACE) await settleExits() expect(claude.connections).toHaveLength(2) - expect(await timeline()).toEqual([startFailure, 'message', startFailure]) + expect(await timeline()).toEqual([startFailure, 'message']) } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index d2cb969bf1d3..8a49352c337f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -577,7 +577,7 @@ describe("the replacement's first send", () => { kind: 'startFailed' } ])( - 'fails on the message when the start fails ($name): one row, the message kept as not sent, and Retry starts it', + 'fails on the message when the start fails ($name): the message kept as not sent, with its reason, and Retry starts it', async ({ error, words, kind }) => { const replacement = await clearCommits() vi.mocked(adapter.acquire).mockRejectedValueOnce(error()) @@ -590,11 +590,10 @@ describe("the replacement's first send", () => { rejection: { kind } }) ) - const rows = await errorRows(replacement) - expect(rows).toHaveLength(1) - expect(rows[0]).toContain(words) - expect(rows.join(' ')).not.toContain('/clear') - expect((await submissionOf(replacement, clientMessageId))?.reason).not.toContain('/clear') + expect(await errorRows(replacement)).toEqual([]) + const { reason } = (await submissionOf(replacement, clientMessageId)) ?? {} + expect(reason).toContain(words) + expect(reason).not.toContain('/clear') expect(adapter.dispatch).not.toHaveBeenCalled() // Retry resends the same words, which starts the agent and delivers them. diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 6113b17ddec6..be47c7a96e03 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -400,7 +400,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B }) }) -it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { +it('leaves a command whose start failed not sent, with its reason on it and no row beside it (B3)', async () => { await attach() await state.host.close(SESSION, 'evict') state.acquire.mockRejectedValue(new Error('not signed in')) @@ -417,19 +417,9 @@ it('leaves a command whose start failed not sent, beside one start-failure row ( }) const snapshot = await journal() expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) - // The start's own row, in the words the command's message was refused with. expect( - snapshot.items - .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') - .map((item) => item.body) - ).toEqual([ - { - kind: 'status', - text: "Codex couldn't restart. Run /compact again.", - failure: expect.objectContaining({ kind: 'restartFailed' }), - tone: 'error' - } - ]) + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toEqual([]) expect( snapshot.submissions.find( (entry) => entry.clientMessageId === params.envelope.clientOperationId diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 9d274cd68a8c..06d3ba2f1bd3 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -1,6 +1,6 @@ // A reopened Claude chat is published as soon as its child spawns, so a CLI that dies before it -// answers initialize fails a session the user is already looking at. That chat must say why, in -// the transcript, exactly as a failed first start does. +// answers initialize fails a session the user is already looking at. That chat must say why, on the +// message the start was for, exactly as a failed first start does. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -24,12 +24,18 @@ afterEach(async () => { claude = createScriptedClaudeRuntime([SESSION]) }) -/** Status rows the open chat was sent, whether in a batch or in the snapshot a fence change sends. */ -function statusTexts(events: AgentSessionSubscribeEvent[]): string[] { +/** The rejection reasons the open chat was sent on its messages, in a batch or a snapshot. */ +function rejectionTexts(events: AgentSessionSubscribeEvent[]): string[] { return events.flatMap((event) => { - const items = - event.type === 'batch' ? event.batch.items : event.type === 'snapshot' ? event.page.items : [] - return items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + const submissions = + event.type === 'batch' + ? event.batch.submissions + : event.type === 'snapshot' + ? event.page.submissions + : [] + return submissions.flatMap((entry) => + entry.dispatchState === 'rejected' && entry.reason ? [entry.reason] : [] + ) }) } @@ -69,8 +75,8 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => expect(statusTexts(events)).toContainEqual(STARTUP_TEXT)) - // Never written, so it did not happen: refused, not left in doubt. + await vi.waitFor(() => expect(rejectionTexts(events)).toContainEqual(STARTUP_TEXT)) + // Never written, so it did not happen: rejected with the cause, not left in doubt. const submission = (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) ) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index efb8d3cde16a..b0dc0456daa5 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -1,8 +1,8 @@ // A Claude chat is published the moment its child spawns, before the CLI has answered initialize. // A send in that window — into a fresh start, or into the restart the delivery loop makes for a // send after a start that failed — is accepted and stays queued until the child proves its start. -// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, the chat -// shows the cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, +// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, and +// nothing is left as a delivery nobody can confirm. Against the production runtime, // adapter, record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -79,13 +79,14 @@ async function failLatestStart(host: StructuredAgentSessionHost, count: number): } describe('a send into a Claude chat whose CLI keeps failing at startup', () => { - it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { + it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers a new one once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) + // No message waited on the open's start, so its exit's row says why. expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) @@ -98,22 +99,22 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ) await failLatestStart(host, 2) - // Rejected with the cause, not left in doubt; one row for this attempt names it. + // The cause is on the message, which is rejected: not left in doubt, and no row of its own. await vi.waitFor(async () => expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', - // Worded for the user: the red line under the composer shows it as it stands. + // Worded for the user: the message's own notice shows it. reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') - // The user signs in and retries: one restart, proven, written to the CLI. + // The user signs in and sends again: one restart, proven, written to the CLI. claude.behave(SESSION, {}) await send(host, 'hello again') await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(3)) @@ -122,7 +123,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) - expect(await statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) }) }) @@ -155,12 +156,11 @@ describe('a send while the first Claude start is still answering initialize', () await vi.waitFor(async () => expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', - // Worded for the user: the red line under the composer shows it as it stands. reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('send') diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index c5b21f9027fa..0503e81a7f75 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,8 +1,8 @@ // A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child. // When that child dies before it proves its start, the message was never handed to it — delivery -// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a -// delivery nobody can confirm, and a client that was subscribed the whole time receives the -// failure row and the rejected submission over the wire. Against the production runtime, adapter, +// waits for the start — so the send is rejected with the child's own diagnostic, never left as a +// delivery nobody can confirm, and a client that was subscribed the whole time receives +// that over the wire, with no row of its own. Against the production runtime, adapter, // record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -118,7 +118,10 @@ function received(events: AgentSessionSubscribeEvent[]) { } } for (const entry of page.submissions) { - submissions.set(entry.clientMessageId, entry.dispatchState) + submissions.set( + entry.clientMessageId, + entry.dispatchState === 'rejected' ? `rejected: ${entry.reason}` : entry.dispatchState + ) } if (event.fence !== undefined) { fences.push(event.fence) @@ -128,13 +131,15 @@ function received(events: AgentSessionSubscribeEvent[]) { } describe('a send whose restarted Claude child dies before it proves its start', () => { - it('settles rejected with the diagnostic, keeps one failure row, and a Retry is one new attempt', async () => { + it('is rejected with the diagnostic, adds no row of its own, and a new send is one new attempt', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) + // The open's start carried no message, so its exit's row says why. + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) const sent = await send(host, 'hello?') @@ -146,29 +151,28 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(async () => expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', - // Worded for the user: the red line under the composer shows it as it stands. reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') - // Retry under a new id: one restart, and once the CLI is healthy the message is written. + // A new send: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) await send(host, 'hello again') await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) - expect(await statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) }) - // A restart refused because its child died before it was handed over leaves one row, from the - // delivery, in the words any failed start uses, and rejects the message with them. + // A restart refused because its child died before it was handed over is recorded on the message, + // in the words any failed start uses, with no row of its own. it.each(['spawn', 'start-time-read'] as const)( - 'leaves one row for a restart whose child exits at %s', + 'records a restart whose child exits at %s on the message', async (at) => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() @@ -188,7 +192,8 @@ describe('a send whose restarted Claude child dies before it proves its start', ) await waitForStructuredAgentSessionRecovery() - expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) + // Only the open's start, which carried no message, has a row. + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) } ) @@ -213,8 +218,9 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => { const seen = received(events) - expect(seen.submissions.get(sent)).toBe('rejected') - expect(seen.statusTexts).toContainEqual(STARTUP_TEXT) + expect(seen.submissions.get(sent)).toBe(`rejected: ${STARTUP_TEXT}`) + // Only the open's start, which carried no message, has a row; each snapshot repeats it. + expect([...new Set(seen.statusTexts)]).toEqual([STARTUP_TEXT]) // The subscriber ended up on the fence the exit published, not the one the restart did. expect(seen.fences.at(-1)).toBe(fence(host)) }) @@ -245,7 +251,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o return rows } - it('shows one row naming the cause per failed attempt, however the start died, and none once the CLI is fixed', async () => { + it('shows the cause on each message whose start died, however it died, and no row of their own', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] @@ -261,23 +267,18 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o }) try { await failLatestStart(host, 1) + // The open's start carried no message, so its exit's row says why. await eventually(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE])) // Send: accepted, and its delivery restarts the child, which dies before starting. const sent = await send(host, 'hello?') await failLatestStart(host, 2) - await eventually(async () => - expect(await submission(host, sent)).toMatchObject({ - dispatchState: 'rejected', - reason: STARTUP_FAILURE - }) - ) await eventually(() => - expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE, STARTUP_FAILURE]) + expect(received(events).submissions.get(sent)).toBe(`rejected: ${STARTUP_FAILURE}`) ) - // Retry while still broken: this restart dies before its child is handed over, so the - // delivery's start is refused. Still one row, saying the same thing, on the rejected message. + // Sent again while still broken: this restart dies before its child is handed over, so the + // delivery's start is refused, and the new message says the same thing. claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: 'claude stream-json exited (code 1): claude: not signed in (rig)', @@ -285,26 +286,16 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o } }) const retried = await send(host, 'hello?') - await eventually(async () => - expect(await submission(host, retried)).toMatchObject({ - dispatchState: 'rejected', - reason: STARTUP_FAILURE - }) - ) - await waitForStructuredAgentSessionRecovery() await eventually(() => - expect([...shownRows(events).values()]).toEqual([ - STARTUP_FAILURE, - STARTUP_FAILURE, - STARTUP_FAILURE - ]) + expect(received(events).submissions.get(retried)).toBe(`rejected: ${STARTUP_FAILURE}`) ) + await waitForStructuredAgentSessionRecovery() - // The CLI is fixed: Retry delivers and adds no row. + // The CLI is fixed: a new send delivers. claude.behave(SESSION, {}) await expect(attempt(host, 'hello?')).resolves.toMatchObject({ ok: true }) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) - expect(shownRows(events).size).toBe(3) + expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE]) } finally { unsubscribe() } diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 863cbaeaebf9..2ad4ad6500fd 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -1,7 +1,8 @@ // A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't // restore, or an init frame naming another session. Orca ends that child itself, so the chat must -// not say Claude stopped on its own; only an exit Orca saw says that. Against the production -// runtime, adapter, record store and host, with only the CLI process scripted. +// not say Claude stopped on its own, on a message or in a row; only an exit Orca saw says that. +// Against the production runtime, adapter, record store and host, with only the CLI process +// scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -110,9 +111,7 @@ describe('a Claude start that Orca fails while the CLI is still running', () => rejection: { kind: 'startFailed' } }) ) - expect(await failureRows(host)).toEqual([ - { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } - ]) + expect(await failureRows(host)).toEqual([]) expect(claude.child(SESSION).calls).not.toContain('send') }) @@ -134,6 +133,6 @@ describe('a Claude start that Orca fails while the CLI is still running', () => rejection: { kind: 'providerStartFailed' } }) ) - expect(await failureRows(host)).toEqual([{ text: STOPPED_TEXT, kind: 'providerStartFailed' }]) + expect(await failureRows(host)).toEqual([]) }) }) From 639b28af827a1fd163d1426c6fc79bac3ff62a65 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:25:57 -0700 Subject: [PATCH 03/26] refactor(native-chat): drop the journal's reject-every-queued-message batch, which nothing writes now --- .../journal-lifecycle-batch-appender.ts | 29 ---- .../journal-pending-submission-recovery.ts | 21 --- .../journal-queued-rejection-batch.test.ts | 130 ------------------ .../journal-row-writer.test.ts | 2 +- .../journal-row-writer.ts | 11 +- .../journal-store-collaborators.ts | 3 +- .../journal-store-contracts.ts | 9 +- 7 files changed, 5 insertions(+), 200 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 3cac8d03c211..4394145c8c67 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -11,7 +11,6 @@ import type { JournalResolvedLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -21,38 +20,10 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise - enqueueRows: ( - plan: () => readonly ((seq: number, ts: number) => JournalRow)[] - ) => Promise } ) {} append(input: JournalLifecycleBatchInput): Promise { - const { rejectsQueued } = input - if (rejectsQueued) { - // Planned on the lane: the sends queued then, and this batch unless it already landed. With - // none left (a Stop withdrew them first) it failed no one, so nothing is written. - return this.deps - .enqueueRows(() => { - const rejections = journalQueuedRejectionRowBuilders( - this.deps.state, - input.fence, - rejectsQueued - ) - return rejections.length === 0 || this.wasApplied(input.settlementId) - ? rejections - : [ - ...rejections, - journalLifecycleBatchRowBuilder( - this.deps.state, - input.settlementId, - input.mutations, - input - ) - ] - }) - .then(() => this.deps.cursor()) - } if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index b52e3648b72d..964ea42f9318 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -2,9 +2,6 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' -import type { JournalReducerState } from './journal-reducer' -import { journalDispatchRowBuilder } from './journal-row-builders' -import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' /** Settles every submission a process fact left unanswerable. Doubt is never @@ -91,21 +88,3 @@ export async function rejectJournalQueuedSubmissions( ) return queued.map((entry) => entry.clientMessageId) } - -/** Rows rejecting every submission still queued, read from `state` when called: for an append - * that must carry them with what follows, in one transaction. */ -export function journalQueuedRejectionRowBuilders( - state: () => JournalReducerState, - fence: number, - rejection: AgentJournalDispatchRejection -): ((seq: number, ts: number) => JournalRow)[] { - return [...state().submissions.values()].filter(isQueuedAgentJournalSubmission).map((entry) => - journalDispatchRowBuilder(state, { - clientMessageId: entry.clientMessageId, - state: 'rejected', - ...rejection, - fence, - recovered: true - }) - ) -} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts deleted file mode 100644 index cd14ce2fe943..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// A failed start's row and the queued messages it failed land in ONE append, the messages first: -// no reader meets one without the other, and the messages sit above the row that says why. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from './journal-store' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener -} from './journal-host-database-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-start', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} - -const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { - surface: 'rejection' -}) -const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) -}) - -afterEach(async () => { - await closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -async function openWithQueued(...ids: string[]): Promise { - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => 'epoch-1' - }) - for (const id of ids) { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: `fp-${id}`, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, - fence: 0, - handoverRecorded: true - }) - } - return journal -} - -function startFailureBatch(mutations = 1) { - return { - settlementId: 'start-failure:gen-1', - fence: 0, - recovered: true as const, - mutations: Array.from({ length: mutations }, () => ({ - kind: 'item' as const, - identity: ERROR_ROW, - body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - })), - rejectsQueued: START_FAILED - } -} - -it('writes the rejections first and the row after them, and draws the messages above it', async () => { - const journal = await openWithQueued('first', 'second') - const before = journal.cursor().sequence - - await journal.appendLifecycleBatch(startFailureBatch()) - - expect(journal.cursor().sequence).toBe(before + 3) - expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'rejected', - 'rejected' - ]) - const order = journal.snapshot().items.map((item) => item.itemId) - expect(order).toEqual([ - agentJournalSubmissionKey('first'), - agentJournalSubmissionKey('second'), - 'orca:start-failure%3Agen-1' - ]) -}) - -it('writes neither when the row cannot be written', async () => { - const journal = await openWithQueued('first') - const before = journal.cursor().sequence - - // An empty batch breaks the row's bound, so the transaction rolls back as a whole. - await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( - 'journal_lifecycle_batch_mutation_bound_exceeded' - ) - - expect(journal.cursor().sequence).toBe(before) - expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) -}) - -// A Stop that reaches the lane first takes the message back; the failed start then failed no one. -it('writes nothing when a Stop withdrew every queued message first', async () => { - const journal = await openWithQueued('first') - const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - - await Promise.all([ - journal.rejectQueuedSubmissions(0, withdrawal), - journal.appendLifecycleBatch(startFailureBatch()) - ]) - - expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) - expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ - agentJournalSubmissionKey('first') - ]) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index 3671ce46cfcf..28d44ce331a3 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -99,7 +99,7 @@ describe('journal row writer', () => { // Sequence 2 is taken, so the second of the two rows violates the primary key. insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) - await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() + expect(() => writer.writeRows(() => [row, row])).toThrow() expect(committedRows).toHaveLength(0) expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual( diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 60db6e0cbd1e..d51489f7c723 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -71,15 +71,8 @@ export class JournalRowWriter { }) } - /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is - * durable unless all are, so no reader ever meets some without the rest. */ - enqueueRows( - plan: () => readonly ((seq: number, ts: number) => JournalRow)[] - ): Promise { - return this.deps.serialize(() => this.writeRows(plan)) - } - - /** `enqueueRows`' write, for a caller already running at its own turn in the queue. */ + /** Several rows in ONE transaction, in order, for a caller already running at its own turn in + * the queue: none is durable unless all are, so no reader ever meets some without the rest. */ writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const first = this.deps.nextSequence() diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 1995b2e98751..2e1755d899d9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -105,8 +105,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal const lifecycleBatchAppender = new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue, - enqueueRows: (plan) => rowWriter.enqueueRows(plan) + enqueue: host.enqueue }) return { epochController, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2547c658c88..cd2d00894208 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -74,16 +74,9 @@ export type JournalLifecycleBatchInput = { mutations: readonly JournalLifecycleMutationInput[] fence: number recovered?: true - /** Rejects the sends still queued with this first, in the same append: a failed start's row - * follows the messages it failed, and no reader meets one without the other. With none still - * queued, the batch is not written either. */ - rejectsQueued?: AgentJournalDispatchRejection } -export type JournalResolvedLifecycleBatchInput = Omit< - JournalLifecycleBatchInput, - 'mutations' | 'rejectsQueued' -> & { +export type JournalResolvedLifecycleBatchInput = Omit & { /** Read from the fold with every earlier write landed; may return none. */ resolve: () => readonly JournalLifecycleMutationInput[] } From 397a4e12de2a5301893264198c26b3bcc5b7a9ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:42:56 -0700 Subject: [PATCH 04/26] fix(native-chat): announce a failed start the moment it lands, once for a run of them --- ...-agent-session-accept-then-deliver.test.ts | 17 ++- ...agent-session-turn-completion-feed.test.ts | 101 ++++++++++++++++++ ...ured-agent-session-turn-completion-feed.ts | 55 ++++++++-- ...tured-agent-session-latest-request.test.ts | 24 +++++ ...structured-agent-session-latest-request.ts | 26 ++++- .../structured-agent-session-projection.ts | 11 +- 6 files changed, 221 insertions(+), 13 deletions(-) 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 9a629ace512d..ebd56ccbb374 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 @@ -337,22 +337,31 @@ describe('a start the chat needed and did not get', () => { expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) }) - it('notifies failed once for the queued messages one start failure refused', async () => { + it('notifies once for queued messages whose starts all fail alike, each rejected on its own', async () => { await host.close(SESSION, 'evict') acquire.mockRejectedValue(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) - await accept('first') + const first = await accept('first') const second = await accept('second') + const third = await accept('third') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(third))?.dispatchState).toBe('rejected')) + // Each message made its own start. + for (const id of [first, second, third]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'restartFailed' } + }) + } + expect(acquire.mock.calls.length).toBeGreaterThanOrEqual(4) await host.flushAllStreamedEvents() expect(completions).toEqual([ { type: 'completion', completion: expect.objectContaining({ sessionId: SESSION, - turnId: agentJournalSubmissionKey(second), + turnId: agentJournalSubmissionKey(first), outcome: 'failure' }) } 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 1c2ff9b9e53b..b2f8933e805e 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 @@ -557,6 +557,107 @@ describe('a request the agent or its start refused', () => { ]) }) + const failedStart = (clientMessageId: string) => + sent(clientMessageId, { + dispatchState: 'rejected', + reason: "Codex couldn't start.", + rejection: { kind: 'startFailed' } + }) + + it('notifies a failed start once, the moment it is final, though a later send is still owed', () => { + const h = harness() + h.listen() + h.observe() + const queued = [userEntry('m1', 1), userEntry('m2', 2)] + h.setJournal(queued, [pending('m1'), pending('m2')]) + h.observe() + h.setJournal(queued, [failedStart('m1'), pending('m2')]) + h.observe() + const accepted = sent('m2', { dispatchState: 'accepted' }) + h.setJournal([...queued, turnItem(turn('t2', 'running'), 3)], [failedStart('m1'), accepted]) + h.observe() + h.setJournal( + [...queued, turnItem(turn('t2', 'completed', 'success'), 3)], + [failedStart('m1'), accepted] + ) + h.observe() + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + ['t2', 'success'] + ]) + }) + + it('notifies a lone failed start exactly once', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [pending('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1)], [failedStart('m1')]) + h.observe() + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + }) + + // A conversation command is not a request: the session's verdict passes it over, and so does this. + it('notifies nothing for a /compact whose start failed', () => { + const h = afterSuccessfulTurn() + const compact: AgentJournalRenderItem = { + ...userEntry('m2', 3), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + } + const items = [userEntry('m1', 1), settledTurn, compact] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(items, [accepted, pending('m2')]) + h.observe() + h.setJournal(items, [accepted, failedStart('m2')]) + h.observe() + h.observe() + + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + // Each queued message makes its own start; when the agent's CLI is missing they all fail alike. + it('notifies a run of failed starts once, and the next failure after a turn ran again', () => { + const h = harness() + h.listen() + h.observe() + const items = [userEntry('m1', 1), userEntry('m2', 2), userEntry('m3', 3)] + h.setJournal(items, [pending('m1'), pending('m2'), pending('m3')]) + h.observe() + h.setJournal(items, [failedStart('m1'), pending('m2'), pending('m3')]) + h.observe() + h.setJournal(items, [failedStart('m1'), failedStart('m2'), pending('m3')]) + h.observe() + h.setJournal(items, [failedStart('m1'), failedStart('m2'), failedStart('m3')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + + const later = [...items, userEntry('m4', 4), turnItem(turn('t4', 'running'), 5)] + const failed = [failedStart('m1'), failedStart('m2'), failedStart('m3')] + h.setJournal(later, [...failed, sent('m4', { dispatchState: 'accepted' })]) + h.observe() + const settled = [...items, userEntry('m4', 4), turnItem(turn('t4', 'completed', 'success'), 5)] + h.setJournal(settled, [...failed, sent('m4', { dispatchState: 'accepted' })]) + h.observe() + h.setJournal( + [...settled, userEntry('m5', 6)], + [...failed, sent('m4', { dispatchState: 'accepted' }), failedStart('m5')] + ) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + ['t4', 'success'], + [agentJournalSubmissionKey('m5'), 'failure'] + ]) + }) + it('does not wait on a send left pending at an older fence', () => { const h = harness() h.listen() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 84339a7587d9..14e8f845d480 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -44,9 +44,15 @@ export type StructuredAgentSessionTurnCompletionFeedDeps = { type RequestMark = Pick -/** Per-session baseline. `settled` is the last settled request this feed has accounted for; - * absence of the whole entry — not a null field — is what makes the first observation silent. */ -type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } +/** Per-session baseline. `settled` is the last settled request this feed has accounted for, and + * `failedStarts` the sends whose start had failed as of the last observation; absence of the whole + * entry — not a null field — is what makes the first observation silent. `failedStartStreak`: the + * last completion announced was a failed start, and no turn has settled since. */ +type SessionBaseline = CompletionFeedCursor & { + settled: RequestMark | null + failedStarts: ReadonlySet + failedStartStreak: boolean +} function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { return request && request.turnState !== 'running' ? { kind: request.kind, id: request.id } : null @@ -100,9 +106,15 @@ export class StructuredAgentSessionTurnCompletionFeed { const cursor = (journal ?? session.journal).cursor() const request = state.latestRequest const baseline = this.baselines.get(sessionId) + const failedStarts = new Set(state.failedStarts) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) }) + this.baselines.set(sessionId, { + ...cursor, + settled: settledMark(request), + failedStarts, + failedStartStreak: false + }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -112,13 +124,26 @@ export class StructuredAgentSessionTurnCompletionFeed { baseline.epoch = cursor.epoch baseline.sequence = cursor.sequence baseline.settled = settledMark(request) + baseline.failedStarts = failedStarts return } baseline.sequence = cursor.sequence + // A send whose start failed is announced the moment it is final, whatever else the session + // still owes, and never again as the latest request below. A run of them, as when the agent's + // CLI is missing and each queued message tries in turn, is announced once. + for (const id of failedStarts) { + if (!baseline.failedStarts.has(id) && !baseline.failedStartStreak) { + baseline.failedStartStreak = true + this.announce(session, sessionId, state, id, 'failure') + } + } + baseline.failedStarts = failedStarts if (request?.turnState === 'running') { // A running turn clears the mark, so this detector fires on each running → settled - // transition rather than on an id it happens not to have seen. + // transition rather than on an id it happens not to have seen. A start landed, so the next + // failed one is news again. baseline.settled = null + baseline.failedStartStreak = false return } // Owed work waits, so sends refused one commit at a time announce once, when the last is @@ -133,18 +158,34 @@ export class StructuredAgentSessionTurnCompletionFeed { return } baseline.settled = settledMark(request) + if (request.kind === 'turn') { + baseline.failedStartStreak = false + } + if (request.kind === 'refused-send' && failedStarts.has(request.id)) { + return + } // ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no // event. Inferring success here is the one mistake that would light the dot on a failure. if (!request.outcome) { return } + this.announce(session, sessionId, state, request.id, request.outcome) + } + + private announce( + session: CompletionFeedSession, + sessionId: string, + state: StructuredAgentSessionStatusState, + turnId: string, + outcome: AgentSessionTurnCompletion['outcome'] + ): void { this.broadcast({ type: 'completion', completion: { scope: session.params.location, sessionId, - turnId: request.id, - outcome: request.outcome, + turnId, + outcome, completedAt: this.deps.now(), // Stated here, not joined from the status stream: remote clients receive the two unordered. ...(state.summary.status === 'attention' ? { awaitingUser: true } : {}) diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 6bb7f117bf80..7e9540fa5d1e 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -260,6 +260,30 @@ describe('the latest request and its verdict', () => { statusStartedAt: 20 }) }) + + // A failed start is placed where its rejection was written: after a turn a Stop could not yet + // prove ended, whose interrupted end lands in place later. + it('reports a failed start written while an older turn was still running, after that turn ends', () => { + const items = [ + userEntry('m1', 1), + turn('t1', 2, { state: 'interrupted', completedAt: 40 }), + userEntry('m2', 3) + ] + const submissions = [ + sent('m1', { dispatchState: 'accepted' }), + sent('m2', { + dispatchState: 'rejected', + reason: "Couldn't stop Codex from before. Send your message again to try once more.", + rejection: { kind: 'restartFailed' }, + resolvedAt: 30 + }) + ] + expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ + kind: 'refused-send', + id: agentJournalSubmissionKey('m2'), + outcome: 'failure' + }) + }) }) describe('the sidebar verdict agrees with the rejection classifier', () => { diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 35856a21552d..ca20cc5a3936 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -17,7 +17,10 @@ import type { 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 { + classifyDispatchRejection, + isFailedStartRejection +} from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' import { isStructuredAgentSessionCommandEntry, @@ -87,6 +90,27 @@ export function latestStructuredAgentSessionRequest( return null } +/** The sends whose start failed, or that Orca's own fault kept from their agent, by their item + * keys: each one's failure is final the moment it is written, whatever else the session still + * owes. A conversation command is not a request. */ +export function structuredAgentSessionFailedStartIds( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] +): string[] { + const commands = new Set( + items.flatMap((item) => (isStructuredAgentSessionCommandEntry(item.body) ? [item.itemId] : [])) + ) + return submissions.flatMap((submission) => { + const key = agentJournalSubmissionKey(submission.clientMessageId) + return submission.dispatchState === 'rejected' && + !commands.has(key) && + (isFailedStartRejection(submission) || + classifyDispatchRejection(submission).kind === 'hostFault') + ? [key] + : [] + }) +} + /** 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 diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index bb352d7549c9..e8765539855b 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -25,6 +25,7 @@ import { latestStructuredAgentSessionAssistantMessage, latestStructuredAgentSessionPrompt, latestStructuredAgentSessionRequest, + structuredAgentSessionFailedStartIds, type StructuredAgentSessionLatestRequest } from './structured-agent-session-latest-request' import { @@ -244,9 +245,16 @@ export function projectStructuredAgentSessionStatusState( latestRequest: StructuredAgentSessionLatestRequest | null /** Whether a running turn or an unanswered send is still owed, even beneath a pending prompt. */ owesWork: boolean + /** The sends whose start failed; see `structuredAgentSessionFailedStartIds`. */ + failedStarts: string[] } { if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) { - return { summary: { status: null, latestPrompt: '' }, latestRequest: null, owesWork: false } + return { + summary: { status: null, latestPrompt: '' }, + latestRequest: null, + owesWork: false, + failedStarts: [] + } } const status = projectStructuredAgentSessionStatus(items, submissions, currentFence) const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null @@ -279,6 +287,7 @@ export function projectStructuredAgentSessionStatusState( return { latestRequest, owesWork: status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence), + failedStarts: structuredAgentSessionFailedStartIds(items, submissions), summary: { status, latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)), From fca0ced545c9a0071e9912f82a19fa9d102b7eb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:56:31 -0700 Subject: [PATCH 05/26] fix(native-chat): a restart continuation whose start failed says so on its message, with no second note --- ...ured-agent-session-restart-continuation.ts | 48 +++++++++---------- ...ent-session-restart-failure-filing.test.ts | 18 +++---- ...ed-agent-session-restart-ownership.test.ts | 13 +++-- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 1a66b5561031..7b72b6f34b72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -27,6 +27,7 @@ import { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { isFailedStartRejection } from '../../../shared/structured-agent-session-dispatch-rejection' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -200,9 +201,13 @@ export type StructuredAgentSessionContinuationDeps = { } /** A continuation handed to its agent, or already decided. */ -export type StartedStructuredAgentSessionContinuation = - | { done: StructuredAgentSessionContinuationOutcome } - | { verdict: () => Promise } +export type StartedStructuredAgentSessionContinuation< + TOutcome = StructuredAgentSessionContinuationOutcome +> = { done: TOutcome } | { verdict: () => Promise } + +/** `saidOnMessage`: its agent did not start for it, which the continuation message itself says in + * the chat, as any message's failed start does; a note would say it twice. Never reported. */ +type ContinuationResult = StructuredAgentSessionContinuationOutcome & { saidOnMessage?: true } /** * Sends the continuation to ONE session and returns once the agent has taken it or its start @@ -216,7 +221,7 @@ export async function startStructuredAgentSessionContinuation( /** This action's continuation, as its offer recorded it. */ continuationId: string ): Promise { - let started: StartedStructuredAgentSessionContinuation + let started: StartedStructuredAgentSessionContinuation try { started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { @@ -227,24 +232,17 @@ export async function startStructuredAgentSessionContinuation( throw error } if ('done' in started) { - await noteOutcome(deps, sessionId, started.done) - return started + return { done: await noteOutcome(deps, sessionId, started.done) } } const { verdict } = started - return { - verdict: async () => { - const outcome = await verdict() - await noteOutcome(deps, sessionId, outcome) - return outcome - } - } + return { verdict: async () => noteOutcome(deps, sessionId, await verdict()) } } async function noteOutcome( deps: Pick, sessionId: string, - result: StructuredAgentSessionContinuationOutcome -): Promise { + { saidOnMessage, ...result }: ContinuationResult +): Promise { if (result.outcome === 'continued') { // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to // carry on. Best effort — losing it must not turn a delivered continuation into a failure. @@ -253,7 +251,7 @@ async function noteOutcome( } catch { reportNoteFailed(deps, sessionId) } - } else { + } else if (!saidOnMessage) { await noteNotContinued( deps, sessionId, @@ -264,6 +262,7 @@ async function noteOutcome( : 'refused' ) } + return result } /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, @@ -293,7 +292,7 @@ async function sendContinuation( sessionId: string, marker: AgentSessionResumeMarker, continuationId: string -): Promise { +): Promise> { const fence = deps.currentFence(sessionId) if (fence === null) { return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } @@ -342,24 +341,25 @@ async function sendContinuation( } } -function refusedBy( - sessionId: string, - submission: ContinuationSubmission -): StructuredAgentSessionContinuationOutcome { +function refusedBy(sessionId: string, submission: ContinuationSubmission): ContinuationResult { // A start the agent was refused files that refusal's code, which the failure guidance keys on. const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal + const reason = submission.reason ?? null return { sessionId, outcome: 'refused', - reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', - ...(refusal ? { refusal } : {}) + reason: refusal?.code ?? reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}), + ...(isFailedStartRejection({ reason, rejection: submission.rejection }) + ? { saidOnMessage: true as const } + : {}) } } function verdictOf( sessionId: string, submission: ContinuationSubmission | undefined -): StructuredAgentSessionContinuationOutcome { +): ContinuationResult { const dispatch = submission?.dispatchState if (submission && dispatch === 'rejected') { return refusedBy(sessionId, submission) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 4602630960a9..377e20a21ae5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -51,8 +51,9 @@ it('files nothing for a chat the user moved on in before its attempt, and spends }) // The continuation is accepted and its agent then fails to start: the message is rejected with the -// cause, the failure is filed, and the chat says the agent did not carry on. -it('says so in the chat when the agent cannot start for the continuation', async () => { +// cause and says so in the chat, as any message's failed start does, and the failure is filed. No +// note says it a second time. +it('says so on the continuation message when the agent cannot start for it', async () => { const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) @@ -64,12 +65,13 @@ it('says so in the chat when the agent cannot start for the continuation', async expect(await host.restartResume.listFailures()).toMatchObject([ { sessionId: SESSION, outcome: 'refused', retryable: true } ]) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' - }) - // The refusal note is now the one explanation: the cut is not said a second time beside it. - expect(await readerNotes(host)).toEqual(await statusNotes(host)) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'restartFailed' } } + ]) + expect((await statusNotes(host)).map((note) => note.text)).not.toContain( + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE + ) + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) // A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in 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 4019582e1fde..995a31e50aac 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 @@ -167,8 +167,8 @@ it('keeps a continuation unconfirmed when its send throws after acceptance', asy ).toMatchObject({ outcome: { status: 'succeeded' } }) }) -// The continuation is accepted, then its start fails: the message is rejected with the cause and -// the failure is filed, and nothing is stopped because nothing started. +// The continuation is accepted, then its start fails: the message is rejected with the cause, which +// it says in the chat, the failure is filed, and nothing is stopped because nothing started. it('rejects the continuation when its start fails, and files a retryable refusal', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) @@ -177,12 +177,11 @@ it('rejects the continuation when its start fails, and files a retryable refusal expect(result.continued).toMatchObject([{ outcome: 'refused' }]) // Nothing ran, so the offer stands as a failure a retry can act on. expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' - }) + expect((await statusNotes(host)).map((note) => note.text)).not.toContain( + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE + ) expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ - { dispatchState: 'rejected' } + { dispatchState: 'rejected', rejection: { kind: 'restartFailed' } } ]) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).not.toHaveBeenCalled() From a1556cd2c785b59a69f1976d58d86a13e46b2877 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:00:14 -0700 Subject: [PATCH 06/26] fix(native-chat): an older chat's start-failure row hushes only the messages its own start rejected --- ...red-agent-session-delivery-notices.test.ts | 31 +++++++++-- ...ructured-agent-session-delivery-notices.ts | 55 ++++++++++++++----- ...ssage-projection.rejected-in-place.test.ts | 3 +- ...ructured-conversation-command-send.test.ts | 2 +- .../structured-conversation-command-send.ts | 12 ++-- ...-agent-session-start-failure-facts.test.ts | 4 +- ...tured-agent-session-start-failure-facts.ts | 24 +++++--- 7 files changed, 96 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 9054c35b5335..0da5413f120a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -17,7 +17,8 @@ import { import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionDeliveryNotices, - structuredAgentSessionStartFailureFacts + structuredAgentSessionStartFailureFacts, + type StatedStartFailure } from './structured-agent-session-delivery-notices' function entry( @@ -43,7 +44,7 @@ const SENDING = 'Sending…' function texts( outbox: StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] = [], - startFailures: readonly AgentSessionFailureFact[] = [] + startFailures: readonly StatedStartFailure[] = [] ): Record { // Every failure seen while the chat was open, so each words its whole cause. const notices = structuredAgentSessionDeliveryNotices( @@ -757,7 +758,7 @@ describe('the notice on each message that did not go through', () => { statusRow(startRowKey, startFailed), statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) ]) - ).toEqual([startFailed]) + ).toEqual([{ itemId: startRowKey, fact: startFailed, observedAt: 1 }]) }) it('says only that each was not sent, and words any other rejection in full', () => { @@ -790,12 +791,34 @@ describe('the notice on each message that did not go through', () => { it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { const shown = "Claude couldn't start. Start a new chat to continue." - expect(texts([rejected('first', startFailed)], [], [startFailed])).toEqual({ + const stated = { itemId: startRowKey, fact: startFailed, observedAt: 1 } + expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ [agentJournalSubmissionKey('first')]: 'Written by the host.' }) expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual( { [agentJournalSubmissionKey('first')]: shown } ) }) + + // A newer host writes no start row: a later failure equal to an older row's is its own start's. + it("words in full a later failure equal to an older start's row, which speaks only for its batch", () => { + const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) + const later = { ...recorded('later', startFailed), submittedAt: 5, resolvedAt: 6 } + expect( + texts( + [ + rejected('first', startFailed), + rejected('second', startFailed), + rejected('later', startFailed) + ], + [recorded('first', startFailed), recorded('second', startFailed), later], + facts + ) + ).toEqual({ + [agentJournalSubmissionKey('first')]: 'Your message was not sent.', + [agentJournalSubmissionKey('second')]: 'Your message was not sent.', + [agentJournalSubmissionKey('later')]: "Claude couldn't start. Start a new chat to continue." + }) + }) }) }) 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 52f3832dffaf..b1e28b255bbc 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 @@ -12,9 +12,9 @@ // // A message the host recorded and then rejected is drawn from the host's history, worded from the // journal's own fact, with no Retry: sending it again is a new message. A rejection that is a -// failed start's, the fact its loaded row states, says only that it was not sent: the row already -// says why. Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch -// or page that loads the row. +// failed start's, the fact a loaded start row from an older host states for that same batch, says +// only that it was not sent: the row already says why. Until its row loads, the outbox draws it from +// its smaller copy, which leaves on the batch or page that loads the row. import { readAgentSessionFailureFact, @@ -54,20 +54,27 @@ const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { se const NO_COMMANDS: ReadonlySet = new Set() const NO_ITEMS: readonly AgentJournalRenderItem[] = [] -/** The facts the chat's loaded start-failure rows state. */ +/** A loaded start-failure row an older host wrote: the failure it states, and when. */ +export type StatedStartFailure = { + itemId: string + fact: AgentSessionFailureFact + observedAt: number +} + +/** What the chat's loaded start-failure rows state. */ export function structuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[] -): AgentSessionFailureFact[] { - const facts: AgentSessionFailureFact[] = [] +): StatedStartFailure[] { + const stated: StatedStartFailure[] = [] for (const item of items) { if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { const fact = readAgentSessionFailureFact(item.body.failure) if (fact) { - facts.push(fact) + stated.push({ itemId: item.itemId, fact, observedAt: item.observedAt }) } } } - return facts + return stated } /** Whether two facts are one failure: a start's row and the messages it rejected share one. */ @@ -88,15 +95,35 @@ export function sameAgentSessionFailureFact( ) } -/** Whether a loaded start-failure row already states this failure. Matching is identity, not +/** Whether one of these start-failure rows already states this failure. Matching is identity, not * wording: what this build can read is enough. */ export function agentSessionFailureStatedByStartRow( failure: unknown, - startFailures: readonly AgentSessionFailureFact[] + startFailures: readonly StatedStartFailure[] ): boolean { const fact = readAgentSessionFailureFact(failure) return ( - fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) + fact !== undefined && + startFailures.some((stated) => sameAgentSessionFailureFact(stated.fact, fact)) + ) +} + +/** Whether the row of the start that rejected this message already says why: the same failure, + * written after the message was sent and no later than its rejection, as the older host wrote + * both. An equal failure in another row is another start's, so the message keeps its words. */ +function rejectionStatedByItsStartRow( + recorded: AgentJournalSubmission, + startFailures: readonly StatedStartFailure[] +): boolean { + const { resolvedAt } = recorded + return ( + resolvedAt !== null && + agentSessionFailureStatedByStartRow( + recorded.rejection, + startFailures.filter( + ({ observedAt }) => observedAt >= recorded.submittedAt && observedAt <= resolvedAt + ) + ) ) } @@ -136,9 +163,9 @@ function deliveryNoticeText( function hostRejectionNoticeText( submission: AgentJournalSubmission, agentName: string, - startFailures: readonly AgentSessionFailureFact[] + startFailures: readonly StatedStartFailure[] ): string { - if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) { + if (rejectionStatedByItsStartRow(submission, startFailures)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } return agentSessionWriteNoticeText( @@ -161,7 +188,7 @@ export function structuredAgentSessionDeliveryNotices( * message with no pending or accepted one is still sending. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ - startFailures: readonly AgentSessionFailureFact[], + startFailures: readonly StatedStartFailure[], /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ failedHere: ReadonlySet, /** The queue's live cards, which the transcript leaves a rejected message to. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 4e7577052256..86cfb8aa8cae 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -194,7 +194,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => 'Claude', () => {}, [failedStart], - [{ kind: 'notSignedIn' }], + // An older host's row, written with the rejection it states. + [{ itemId: 'orca:start-failure', fact: { kind: 'notSignedIn' }, observedAt: 3 }], new Set() ) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 498e9d4fc011..4a20cb279af9 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -155,7 +155,7 @@ describe('the line under the composer after a conversation command failed', () = agentName: 'Claude', pending: { current: false }, blocked: false, - startFailures: () => [START_FAILED], + startFailures: () => [{ itemId: 'orca:start-failure', fact: START_FAILED, observedAt: 1 }], send: async () => ({ kind: 'done', value: result }) }) ).toEqual({ accepted: false, error }) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index 0a351db917f1..f3267d127c52 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -3,14 +3,14 @@ import { type AgentSessionConversationCommand, type AgentSessionConversationCommandResult } from '../../../../shared/agent-session-conversation-command' -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' +import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { translate } from '@/i18n/i18n' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' -import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' +import { + agentSessionFailureStatedByStartRow, + type StatedStartFailure +} from './structured-agent-session-delivery-notices' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' export async function sendStructuredConversationCommand(input: { @@ -20,7 +20,7 @@ export async function sendStructuredConversationCommand(input: { pending: { current: boolean } blocked: boolean /** What the chat's loaded start-failure rows state, read when the reply lands. */ - startFailures: () => readonly AgentSessionFailureFact[] + startFailures: () => readonly StatedStartFailure[] send: ( command: AgentSessionConversationCommand ) => Promise> diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts index 97ef023dffb7..1010d22a4c67 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -31,7 +31,9 @@ it('holds the same facts while the start rows state nothing new', () => { { initialProps: { items: [row] } } ) const first = result.current - expect(first).toEqual([{ kind: 'providerStartFailed' }]) + expect(first).toEqual([ + { itemId: row.itemId, fact: { kind: 'providerStartFailed' }, observedAt: 1 } + ]) rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) expect(result.current).not.toBe(first) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts index 83f20103de11..9668926fc472 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -1,28 +1,36 @@ import { useEffect, useMemo, useRef } from 'react' -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { sameAgentSessionFailureFact, - structuredAgentSessionStartFailureFacts + structuredAgentSessionStartFailureFacts, + type StatedStartFailure } from './structured-agent-session-delivery-notices' -const NO_FACTS: readonly AgentSessionFailureFact[] = [] +const NO_FACTS: readonly StatedStartFailure[] = [] -/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a - * streaming turn does not rebuild every row's delivery notice. */ +/** What the loaded start-failure rows an older host wrote state, read only while `enabled`. Held + * while unchanged, so a streaming turn does not rebuild every row's delivery notice. */ export function useStructuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[], enabled: boolean -): readonly AgentSessionFailureFact[] { +): readonly StatedStartFailure[] { const facts = useMemo( () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), [enabled, items] ) - const previousRef = useRef(NO_FACTS) + const previousRef = useRef(NO_FACTS) const previous = previousRef.current const stable = previous.length === facts.length && - previous.every((fact, index) => sameAgentSessionFailureFact(fact, facts[index])) + previous.every((stated, index) => { + const next = facts[index] + return ( + next !== undefined && + stated.itemId === next.itemId && + stated.observedAt === next.observedAt && + sameAgentSessionFailureFact(stated.fact, next.fact) + ) + }) ? previous : facts // Written after commit, so render stays pure. From c756c45a0ca6c153d69da9368afe4aa28a0f24f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:53:18 -0700 Subject: [PATCH 07/26] fix(native-chat): keep each failed start's one row, written with its rejection, so clients that hide a rejected message still see why --- .../journal-lifecycle-batch-appender.ts | 25 ++++ .../journal-pending-submission-recovery.ts | 24 ++++ .../journal-queued-rejection-batch.test.ts | 129 ++++++++++++++++++ .../journal-row-writer.test.ts | 2 +- .../journal-row-writer.ts | 11 +- .../journal-store-collaborators.ts | 3 +- .../journal-store-contracts.ts | 16 ++- ...-agent-session-accept-then-deliver.test.ts | 34 ++++- ...ured-agent-session-append-delivery.test.ts | 15 +- .../structured-agent-session-child-exit.ts | 5 +- .../structured-agent-session-command-turn.ts | 11 +- ...d-agent-session-conversation-close.test.ts | 27 ++-- ...red-agent-session-conversation-lifetime.ts | 4 +- ...session-dead-generation-settlement.test.ts | 2 +- ...gent-session-dead-generation-settlement.ts | 51 +++---- ...gent-session-legacy-handoff-record.test.ts | 14 +- ...gent-session-provider-child-record.test.ts | 25 +++- ...ured-agent-session-restart-continuation.ts | 4 +- ...ent-session-restart-failure-filing.test.ts | 28 +++- ...red-agent-session-send-preparation.test.ts | 21 +-- ...session-send-restarts-failed-start.test.ts | 32 +++-- ...-agent-session-start-failure-settlement.ts | 94 +++++++++---- ...agent-session-start-failure-writer.test.ts | 77 +++++++---- ...agent-session-startup-failure-exit.test.ts | 121 +++++++++------- .../structured-agent-session-turns.ts | 12 ++ ...sion-view-start-after-failed-start.test.ts | 14 +- .../structured-conversation-command.test.ts | 11 +- ...structured-conversation-compaction.test.ts | 16 ++- ...e-structured-resumed-start-failure.test.ts | 24 ++-- ...e-structured-send-held-for-startup.test.ts | 20 +-- ...-send-restart-dies-before-dispatch.test.ts | 69 ++++++---- ...tured-startup-fault-is-not-an-exit.test.ts | 11 +- ...ctured-agent-session-dispatch-rejection.ts | 10 ++ 33 files changed, 677 insertions(+), 285 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 4394145c8c67..3886b1cac95f 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -11,6 +11,7 @@ import type { JournalResolvedLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import { journalBatchRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -20,10 +21,34 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} append(input: JournalLifecycleBatchInput): Promise { + const { rejects } = input + if (rejects) { + // Planned on the lane: the send if it is still `which` then, and this batch unless it already + // landed. With the send settled meanwhile it failed no one, so nothing is written. + return this.deps + .enqueueRows(() => { + const rejections = journalBatchRejectionRowBuilders(this.deps.state, input.fence, rejects) + return rejections.length === 0 || this.wasApplied(input.settlementId) + ? rejections + : [ + ...rejections, + journalLifecycleBatchRowBuilder( + this.deps.state, + input.settlementId, + input.mutations, + input + ) + ] + }) + .then(() => this.deps.cursor()) + } if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index 964ea42f9318..e8f3015efef8 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -2,7 +2,11 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' +import type { JournalReducerState } from './journal-reducer' +import { journalDispatchRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' +import type { JournalBatchRejection } from './journal-store-contracts' /** Settles every submission a process fact left unanswerable. Doubt is never * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued @@ -88,3 +92,23 @@ export async function rejectJournalQueuedSubmissions( ) return queued.map((entry) => entry.clientMessageId) } + +/** The row rejecting that one submission, read from `state` when called, or none when `which` no + * longer holds for it: for an append that must carry it with what follows, in one transaction. */ +export function journalBatchRejectionRowBuilders( + state: () => JournalReducerState, + fence: number, + { clientMessageId, which, ...rejection }: JournalBatchRejection +): ((seq: number, ts: number) => JournalRow)[] { + const submission = state().submissions.get(clientMessageId) + return submission && which(submission) + ? [ + journalDispatchRowBuilder(state, { + clientMessageId, + state: 'rejected', + ...rejection, + fence + }) + ] + : [] +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts new file mode 100644 index 000000000000..b6f5ccdd5230 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -0,0 +1,129 @@ +// A failed start's row and the queued message it failed land in ONE append, the message first: no +// reader meets one without the other, the message sits above the row that says why, and a message +// queued behind it is left for its own start. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-start', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) +} + +const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { + surface: 'rejection' +}) +const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:first' } as const + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) +}) + +afterEach(async () => { + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +async function openWithQueued(...ids: string[]): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => 'epoch-1' + }) + for (const id of ids) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 0, + handoverRecorded: true + }) + } + return journal +} + +function startFailureBatch(mutations = 1) { + return { + settlementId: 'start-failure:first', + fence: 0, + recovered: true as const, + mutations: Array.from({ length: mutations }, () => ({ + kind: 'item' as const, + identity: ERROR_ROW, + body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), + rejects: { ...START_FAILED, clientMessageId: 'first', which: isQueuedAgentJournalSubmission } + } +} + +it('writes the rejection first and the row right after it, leaving the message behind it queued', async () => { + const journal = await openWithQueued('first', 'second') + const before = journal.cursor().sequence + + await journal.appendLifecycleBatch(startFailureBatch()) + + expect(journal.cursor().sequence).toBe(before + 2) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['rejected', 'pending']) + const placed = new Map(journal.snapshot().items.map((item) => [item.itemId, item])) + const message = placed.get(agentJournalSubmissionKey('first')) + const row = placed.get('orca:start-failure%3Afirst') + // One write: the row is the next row after the rejection, at the same instant. + expect(row?.sequence).toBe((message?.sequence ?? 0) + 1) + expect(row?.observedAt).toBe(message?.observedAt) +}) + +it('writes neither when the row cannot be written', async () => { + const journal = await openWithQueued('first') + const before = journal.cursor().sequence + + // An empty batch breaks the row's bound, so the transaction rolls back as a whole. + await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( + 'journal_lifecycle_batch_mutation_bound_exceeded' + ) + + expect(journal.cursor().sequence).toBe(before) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) +}) + +// A Stop that reaches the lane first takes the message back; the failed start then failed no one. +it('writes nothing when a Stop withdrew the message first', async () => { + const journal = await openWithQueued('first') + const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + + await Promise.all([ + journal.rejectQueuedSubmissions(0, withdrawal), + journal.appendLifecycleBatch(startFailureBatch()) + ]) + + expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('first') + ]) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index 28d44ce331a3..3671ce46cfcf 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -99,7 +99,7 @@ describe('journal row writer', () => { // Sequence 2 is taken, so the second of the two rows violates the primary key. insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) - expect(() => writer.writeRows(() => [row, row])).toThrow() + await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() expect(committedRows).toHaveLength(0) expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual( diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index d51489f7c723..60db6e0cbd1e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -71,8 +71,15 @@ export class JournalRowWriter { }) } - /** Several rows in ONE transaction, in order, for a caller already running at its own turn in - * the queue: none is durable unless all are, so no reader ever meets some without the rest. */ + /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is + * durable unless all are, so no reader ever meets some without the rest. */ + enqueueRows( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(() => this.writeRows(plan)) + } + + /** `enqueueRows`' write, for a caller already running at its own turn in the queue. */ writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const first = this.deps.nextSequence() diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 2e1755d899d9..1995b2e98751 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -105,7 +105,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal const lifecycleBatchAppender = new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) return { epochController, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index cd2d00894208..00b0815644af 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -8,6 +8,7 @@ import type { AgentJournalProducerLinkage, AgentJournalResetReason, AgentJournalRowAttribution, + AgentJournalSubmission, AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -74,9 +75,22 @@ export type JournalLifecycleBatchInput = { mutations: readonly JournalLifecycleMutationInput[] fence: number recovered?: true + /** Rejects that send first, in the same append: a failed start's row follows the message it + * failed, and no reader meets one without the other. */ + rejects?: JournalBatchRejection } -export type JournalResolvedLifecycleBatchInput = Omit & { +/** One send a batch rejects, when `which` still holds for it on the lane. When it no longer does — + * a Stop withdrew it, another writer settled it — the batch is not written either. */ +export type JournalBatchRejection = AgentJournalDispatchRejection & { + clientMessageId: string + which: (submission: AgentJournalSubmission) => boolean +} + +export type JournalResolvedLifecycleBatchInput = Omit< + JournalLifecycleBatchInput, + 'mutations' | 'rejects' +> & { /** Read from the fold with every earlier write landed; may return none. */ resolve: () => readonly JournalLifecycleMutationInput[] } 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 ebd56ccbb374..f7feaa3b7f21 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 @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { @@ -333,10 +334,28 @@ describe('a start the chat needed and did not get', () => { refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } } }) - expect(await errorRows()).toEqual([]) + // The failed start's one row, which a client that hides rejected messages still shows. + expect(await errorRows()).toEqual(["Codex couldn't restart. Send your message to try again."]) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) }) + // A client that hides rejected messages — a released desktop, any phone — still reads why. + it('leaves the failure in the transcript of a client that hides a rejected message sent elsewhere', async () => { + await host.close(SESSION, 'evict') + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const id = await accept('from another device') + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + + const snapshot = await host.journalSnapshot(SESSION) + const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: false + }) + expect(drawn.map((message) => message.id)).not.toContain(agentJournalSubmissionKey(id)) + expect(drawn.flatMap((message) => message.blocks)).toContainEqual( + expect.objectContaining({ text: "Codex couldn't restart. Send your message to try again." }) + ) + }) + it('notifies once for queued messages whose starts all fail alike, each rejected on its own', async () => { await host.close(SESSION, 'evict') acquire.mockRejectedValue(new Error('spawn codex ENOENT')) @@ -355,6 +374,8 @@ describe('a start the chat needed and did not get', () => { }) } expect(acquire.mock.calls.length).toBeGreaterThanOrEqual(4) + // One row per failed start: each message's own. + expect(await errorRows()).toHaveLength(3) await host.flushAllStreamedEvents() expect(completions).toEqual([ { @@ -412,7 +433,7 @@ describe('a start the chat needed and did not get', () => { } ] ])( - 'tells a live chat a %s refusal on the message it failed (W14)', + "tells a live chat a %s refusal on the message it failed, beside the start's one row (W14)", async (_source, arrange, expected) => { await host.close(SESSION, 'evict') arrange() @@ -434,7 +455,7 @@ describe('a start the chat needed and did not get', () => { rejection: expected.failure }) ) - expect(await errorRows()).toEqual([]) + expect(await errorRows()).toEqual([expected.text]) } ) @@ -465,7 +486,7 @@ describe('a start the chat needed and did not get', () => { rejection: { kind: 'restartFailed' } }) expect(settled?.reason).not.toContain('record store write failed') - expect(await errorRows()).toEqual([]) + expect(await errorRows()).toEqual([settled?.reason]) }) }) @@ -578,7 +599,7 @@ describe('a child that exits before its message is handed over', () => { }) describe('a start whose failure the delivery loop settles before the exit is published', () => { - it('rejects each message in the words of its own start, and the exit adds no row', async () => { + it('rejects each message in the words of its own start, one row each, and the exit adds none', async () => { // The adapter's startup answer and its later exit event word the same start differently. const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) adapterExtras = { awaitStarted } @@ -619,7 +640,8 @@ describe('a start whose failure the delivery loop settles before the exit is pub rejection: words.rejection }) } - expect(await errorRows()).toEqual([]) + // One row per failed start; the exit adds none. + expect(await errorRows()).toEqual([words.reason, words.reason]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 4e1b47f9cff2..b80135108056 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -167,19 +167,16 @@ describe('an open chat receives every row its journal commits', () => { await exitBeforeProof() - // The exit ends the child; the delivery loop, which reads why, records it on what it had queued. + // The exit ends the child; the delivery loop, which reads why, rejects what it had queued. await vi.waitFor(() => expect(pane.received().submissions).toContainEqual( - expect.objectContaining({ - clientMessageId: held, - dispatchState: 'rejected', - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: expect.objectContaining({ kind: 'providerStartFailed' }) - }) + expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) ) ) - // The message says why; the chat gets no row of its own for it. - expect(pane.received().statusRows).toEqual([]) + // One row, however many of its writers reported the start. + expect(pane.received().statusRows).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' + ]) }) it('shows a revision the provider queued with no publish behind it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index c658ce814658..c606a73e310f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -165,10 +165,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< )), ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), ...(!expected && exitedDuringStartup && child.generation - ? { - exitedDuringStartup: - child.startedFor === undefined ? {} : { startedFor: child.startedFor } - } + ? { exitedDuringStartup: { generation: child.generation } } : {}) }) if (!settled.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index e51b8e672f96..0a383ddcf312 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -83,7 +83,16 @@ export function structuredAgentSessionAwaitedCommand( oldest = submission } } - const body = oldest && journal.itemBody(agentJournalSubmissionKey(oldest.clientMessageId)) + return oldest && structuredAgentSessionMessageCommand(journal, oldest.clientMessageId) +} + +/** The command a message's own body sends: a start that fails it leaves that command to run again, + * not a message to send. */ +export function structuredAgentSessionMessageCommand( + journal: Pick, + clientMessageId: string +): AgentSessionConversationCommand | undefined { + const body = journal.itemBody(agentJournalSubmissionKey(clientMessageId)) return body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND : undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 1728242bb6f9..e6045f35ba0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -222,7 +222,7 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the delivery loop alone records it on the message', async () => { + it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' const order: string[] = [] const started = Promise.withResolvers() @@ -237,15 +237,16 @@ describe('a start that never finishes (P2-15)', () => { providerChildPhase: 'starting' as const })) Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - const resolve = AgentSessionJournal.prototype.resolveDispatch - vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(function ( + // The loop rejects the queued message in the same append as its row. + const append = AgentSessionJournal.prototype.appendLifecycleBatch + vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( this: AgentSessionJournal, - input + ...args ) { - if (input.state === 'rejected') { - order.push(`rejected: ${input.reason}`) + if (args[0].rejects) { + order.push(`rejected: ${args[0].rejects.reason}`) } - return resolve.call(this, input) + return append.apply(this, args) }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) @@ -259,14 +260,10 @@ describe('a start that never finishes (P2-15)', () => { expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ - dispatchState: 'rejected', - reason: stopReason, - rejection: { kind: 'hostStopped' } - }) + expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) ) ) - // One record, written after the stop by the loop, with the stop's own words. + // One rejection, written after the stop by the loop, with the stop's own words. expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) const errorRows = reader.events.flatMap((event) => event.type === 'batch' @@ -275,7 +272,9 @@ describe('a start that never finishes (P2-15)', () => { ) : [] ) - expect(errorRows).toEqual([]) + expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ + stopReason + ]) expect(rig.adapter.dispatch).not.toHaveBeenCalled() const again = await rig.host.send(CALLER, restTestSend('try again', fence())) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 30c4fbbf933d..2915ef2ebea1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -89,8 +89,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), - // A host stop: the delivery loop waiting on this child writes the one error row and rejects - // what is queued with it, both worded from the hostStopped fact. + // A host stop: the delivery loop waiting on this child rejects the message it waited for, with + // that start's one row, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index aadee53356aa..1df56dc5228a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -335,7 +335,7 @@ describe('dead structured-session generation settlement', () => { exitFailure: agentSessionFailureFact('providerExited', { detail: providerDiagnostic('code 1\nnot signed in', 'log') }), - exitedDuringStartup: {} + exitedDuringStartup: { generation: 'generation-1' } }) // The sentence is Orca's; the stderr the exit carried rides as a log detail only. 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 5eb9b805af8c..9bd1b1304862 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 @@ -23,11 +23,11 @@ import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { - structuredAgentSessionStartFailure, - structuredAgentSessionStartFailureFact -} from './structured-agent-session-failure-text' -import { messageCarriesFailedStart } from './structured-agent-session-start-failure-settlement' + messageCarriesFailedStart, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-settlement' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { endedByPersonsStop, @@ -129,9 +129,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { exitFailure?: SubmissionRejectionFact /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext - /** The provider never finished starting. `startedFor`: the queued message the start was for, - * which carries the failure itself. */ - exitedDuringStartup?: { startedFor?: string } + /** The provider never finished starting: the start that failed, keyed by the child's + * generation, for the row a start no message carries leaves. */ + exitedDuringStartup?: { generation: string | null } }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) @@ -149,22 +149,27 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!startupFailure) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } - const rejectedUnstarted = startupFailure - ? await input.journal.rejectPendingSubmissions(input.fence, startupFailure) - : await input.journal.markPendingSubmissionsUnknown( - input.fence, - input.pendingSubmissionReason - ) + const settled = await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - // A failed start a message carries is said there. A goal, rewind or command start with no - // message waiting has only this row to say why. - const failureOnAMessage = - input.exitedDuringStartup !== undefined && - (rejectedUnstarted.length > 0 || - input.exitedDuringStartup.startedFor !== undefined || - messageCarriesFailedStart(input.journal.submissions?.() ?? [], input.fence)) - if (showUnexpectedExitOutcome && !failureOnAMessage) { + if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { + const startKey = input.exitedDuringStartup.generation ?? input.settlementId + // One row per failed start. A queued message is the delivery loop's to record with its own + // row, and one already rejected as this start's failure has its writer's; this row is for the + // messages handed to this child, or a command, goal or rewind start no message waited on. + const settledHere = new Set(settled) + const recordedElsewhere = messageCarriesFailedStart( + (input.journal.submissions?.() ?? []).filter( + (submission) => !settledHere.has(submission.clientMessageId) + ), + input.fence + ) + if (!recordedElsewhere) { + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) + } + } else if (showUnexpectedExitOutcome) { // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ kind: 'item', @@ -172,9 +177,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { body: { kind: 'status', ...agentSessionFailureWords( - input.exitedDuringStartup - ? structuredAgentSessionStartFailureFact({ exit: input.exitFailure }) - : (input.exitFailure ?? agentSessionFailureFact('providerExited')), + input.exitFailure ?? agentSessionFailureFact('providerExited'), { ...input.failureTextContext, surface: 'row' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 08be19f8e0bd..918afba6c5b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -251,20 +251,20 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: 'recovering' }) // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is - // accepted, then rejected by the start that cannot take the lease, and the message says why, + // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, // worded from the refusal's details; only the live refusal names the process. const quitTerminal = 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await delivered('while the terminal still runs')).toMatchObject({ - dispatchState: 'rejected', - reason: - "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + dispatchState: 'rejected' }) expect( - (await host.journalSnapshot(SESSION)).items.filter( - (item) => item.body.kind === 'status' && item.body.tone === 'error' + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] ) - ).toEqual([]) + ).toEqual([ + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ]) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 031d806868fc..00cc14ec937b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -21,6 +21,8 @@ import { } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -183,6 +185,11 @@ async function submission(id: string): Promise { // The second goes on to a start of its own, which fails the same way. await rejected(second) - expect(await statusRows()).toEqual([]) + // One row per failed start, keyed by the message it was for. + expect((await statusRows()).map((row) => row.itemId)).toEqual( + [first, second].map((id) => startRowKey(id)) + ) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -475,7 +485,8 @@ describe('a start another operation made that dies while a sent message waits on expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).not.toHaveBeenCalled() // A message was waiting, so the operation's exit adds no row saying the same thing. - expect(await statusRows()).toEqual([]) + // The message's own start leaves its row; the operation's exit adds none beside it. + expect((await statusRows()).map((row) => row.itemId)).toEqual([startRowKey(id)]) }) it('leaves a message sent after that start failed to a fresh start (R2)', async () => { @@ -554,7 +565,13 @@ describe('a child that ends before its message is handed over', () => { detail: { text: 'codex app-server crashed', audience: 'log' } } }) - expect(await statusRows()).toEqual([]) + expect(await statusRows()).toEqual([ + expect.objectContaining({ + itemId: startRowKey(id), + text: 'Codex stopped before this message was sent.', + tone: 'error' + }) + ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) expect(acquire).toHaveBeenCalledTimes(2) @@ -840,7 +857,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { reason: text, rejection: { kind: 'hostStopped' } }) - expect(await statusRows()).toEqual([]) + expect((await statusRows()).map((row) => row.text)).toEqual([text]) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 7b72b6f34b72..cf86cd22b8ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -27,7 +27,7 @@ import { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import { isFailedStartRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import { isFailedStartOrHostFault } from '../../../shared/structured-agent-session-dispatch-rejection' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -350,7 +350,7 @@ function refusedBy(sessionId: string, submission: ContinuationSubmission): Conti outcome: 'refused', reason: refusal?.code ?? reason ?? 'agent_session_dispatch_rejected', ...(refusal ? { refusal } : {}), - ...(isFailedStartRejection({ reason, rejection: submission.rejection }) + ...(isFailedStartOrHostFault({ reason, rejection: submission.rejection }) ? { saidOnMessage: true as const } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 377e20a21ae5..ec692634b190 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -51,8 +51,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends }) // The continuation is accepted and its agent then fails to start: the message is rejected with the -// cause and says so in the chat, as any message's failed start does, and the failure is filed. No -// note says it a second time. +// cause and its start's row says why, as for any message's failed start, and the failure is filed. +// No note says it a second time; the cut keeps its own notice. it('says so on the continuation message when the agent cannot start for it', async () => { const { host, acquire } = await interruptedRestart() await host.restartResume.list() @@ -71,7 +71,29 @@ it('says so on the continuation message when the agent cannot start for it', asy expect((await statusNotes(host)).map((note) => note.text)).not.toContain( AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE ) - expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) + expect(await readerNotes(host)).toEqual([ + QUIT_CUT_NOTICE, + { text: "Codex couldn't restart. Send your message to try again.", tone: 'error' } + ]) +}) + +// Orca's own fault on the way to the continuation's start says so on the message, as any failed +// start does, so no note repeats it. +it("notes nothing beside a continuation that Orca's own fault kept from its agent", async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + vi.spyOn(host['conversationDelivery'].loop['deps'], 'ensureProviderChild').mockRejectedValueOnce( + new Error('spawn-token mint failed') + ) + + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'hostFault' } } + ]) + expect((await statusNotes(host)).map((note) => note.text)).not.toContain( + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE + ) }) // A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 54c19748a842..f1bbd673cd62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -402,7 +402,7 @@ describe('a send with no live owner', () => { expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it("rejects the accepted message with the restart's own cause, said on the message alone", async () => { + it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') @@ -422,8 +422,8 @@ describe('a send with no live owner', () => { expect( store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) ).toMatchObject({ outcome: { status: 'succeeded' } }) - // The message carries the reason; no row repeats it beside the message. - expect(await errorStatuses()).toEqual([]) + // One row, in the error tone, so the reason outlives the error strip. + expect(await errorStatuses()).toEqual([cause]) }) it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { @@ -445,7 +445,9 @@ describe('a send with no live owner', () => { reason: "Codex couldn't restart. Send your message to try again.", rejection }) - expect(await errorStatuses()).toEqual([]) + expect(await errorStatuses()).toEqual([ + "Codex couldn't restart. Send your message to try again." + ]) // Orca's own text is logged once where the start failed. expect( hostErrors.filter( @@ -461,20 +463,21 @@ describe('a send with no live owner', () => { await settled(await accept(params)) expect(acquire).toHaveBeenCalledTimes(1) - // A client that resends the same id gets the recorded rejection, and no second attempt. + // A client that resends the same id gets the recorded rejection, and the chat no second row. await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, value: { submission: { dispatchState: 'rejected' } } }) expect(acquire).toHaveBeenCalledTimes(1) + expect(await errorStatuses()).toHaveLength(1) - // The outbox's Retry rotates the id: a fresh attempt, failed on its own message. + // The outbox's Retry rotates the id: a fresh attempt, with its own row. expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toEqual([]) + expect(await errorStatuses()).toHaveLength(2) expect(dispatch).not.toHaveBeenCalled() }) @@ -539,7 +542,7 @@ describe('a send with no live owner', () => { rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } }) expect(acquire).not.toHaveBeenCalled() - expect(await errorStatuses()).toEqual([]) + expect(await errorStatuses()).toEqual([cause]) }) it('rejects the message, and reports the fault, when the restart itself faults', async () => { @@ -560,7 +563,7 @@ describe('a send with no live owner', () => { expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) ) - expect(await errorStatuses()).toEqual([]) + expect(await errorStatuses()).toHaveLength(1) }) it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 8937e50024c8..f41d2e190c7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -4,13 +4,14 @@ // message to the new owner, instead of parking it behind a lease nothing would ever re-acquire. // // A child is published before it has proven its start, and it owns the send from that moment: the -// message is handed to it. When the child exits first, the exit settlement rejects the message with -// the cause on it, and the next send is a fresh restart. +// message is handed to it. When the child exits first, the exit settlement rejects the message and +// writes the cause into the chat, once, and the next send is a fresh restart. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -22,6 +23,7 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, + hostTestDrawnRowIds, hostTestMessage, hostTestOperationId, resetHostTestOperationIds @@ -201,7 +203,7 @@ describe('a send into a published session whose child ended before startup', () expect(dispatch).toHaveBeenCalledTimes(2) }) - it('rejects the held message with the cause when the restarted child exits before proving its start', async () => { + it('retires the held message with the cause when the restarted child exits before proving its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const rowsBefore = (await journalStatuses()).length @@ -209,8 +211,7 @@ describe('a send into a published session whose child ended before startup', () await exitBeforeProof() // The child never proved its start, so it accepted nothing: the exit rejects the message this - // host admitted, so nothing pins the session and Retry stays offered. The message names the - // cause; the chat gets no row of its own for it. + // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: 'Codex stopped before it finished starting. Send your message to try again.', @@ -220,7 +221,18 @@ describe('a send into a published session whose child ended before startup', () expect( (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) - expect((await journalStatuses()).slice(rowsBefore)).toEqual([]) + expect((await journalStatuses()).slice(rowsBefore)).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' + ]) + // Accepted before the restart it needed, so the chat draws it above the row naming the cause. + const snapshot = await host.journalSnapshot(SESSION) + const causeRow = snapshot.items.findLast((item) => item.body.kind === 'status')?.itemId + const shown = [agentJournalSubmissionKey(held), causeRow] + expect( + hostTestDrawnRowIds(snapshot, [ + { clientMessageId: held, text: 'still not signed in' } + ]).filter((id) => shown.includes(id)) + ).toEqual(shown) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -231,7 +243,7 @@ describe('a send into a published session whose child ended before startup', () await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) - expect((await journalStatuses()).slice(rowsBefore)).toEqual([]) + expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) }) }) @@ -248,7 +260,7 @@ describe('a send while the child of the first start is still proving itself', () expect(await journalStatuses()).toEqual([]) }) - it('is rejected with the cause when that child exits first, and restarts nothing', async () => { + it('is retired with the cause when that child exits first, and restarts nothing', async () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const held = await send('hello') @@ -261,7 +273,9 @@ describe('a send while the child of the first start is still proving itself', () recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(await journalStatuses()).toEqual([]) + expect(await journalStatuses()).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' + ]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index 98fadb348381..c7107953e3d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -1,35 +1,79 @@ -// The delivery loop's writer of a failed start: the one queued message the start was for. A message -// handed to a child that never proved its start is the handover's or the exit's to settle; each -// state has one writer, and `rejected` is terminal, so no message is failed twice. +// The writer of a failed start's record: the message it failed, rejected, and the start's one row, +// in one write, so a client that hides rejected messages still sees why. The delivery loop writes it +// for the queued message a start was for, the handover for the message it was handing over; the +// exit writes the row for a start no message carries. Each message state has one writer, and +// `rejected` is terminal, so no message is failed twice. -import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalDispatchRejection, + AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { isFailedStartRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import { isFailedStartOrHostFault } from '../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND } from './structured-agent-session-command-turn' +import { structuredAgentSessionMessageCommand } from './structured-agent-session-command-turn' import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -type StartFailureJournal = Pick +type StartFailureJournal = Pick< + AgentSessionJournal, + 'submissions' | 'itemBody' | 'appendLifecycleBatch' +> + +/** The one row a failed start leaves in the chat: an error row keyed by the start, repeating the + * sentence its message carries, so the reason outlives any client that hides that message. */ +export function structuredAgentSessionStartFailureRow( + startKey: string, + words: AgentJournalDispatchRejection +): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: structuredAgentSessionStartFailureRowIdentity(startKey), + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }, + // A start that failed opened no turn. + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } +} -/** Who the message's sentence names, and the command its own body sends, so the next step is to run - * that command again rather than to send a message. */ +/** Rejects a message its start failed and writes that start's row with it, keyed by the message, in + * one append. Writes nothing once `which` no longer holds for it: Stop withdrew it, or another + * writer settled it. */ +export async function rejectWithStartFailureRow( + journal: Pick, + input: { + clientMessageId: string + words: AgentJournalDispatchRejection + fence: number + which: (submission: AgentJournalSubmission) => boolean + } +): Promise { + const { clientMessageId, words, fence, which } = input + await journal.appendLifecycleBatch({ + settlementId: `start-failure:${clientMessageId}`, + fence, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow(clientMessageId, words)], + rejects: { clientMessageId, ...words, which } + }) +} + +/** Who the message's sentence names, and the command its own body sends. */ function startFailureWordsContext( journal: Pick, record: AgentSessionRecord | null, clientMessageId: string ): AgentSessionFailureWordsContext { - const body = journal.itemBody(agentJournalSubmissionKey(clientMessageId)) - const command = - body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND - ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND - : undefined + const command = structuredAgentSessionMessageCommand(journal, clientMessageId) return { ...structuredAgentSessionFailureWordsContext(record), ...(command ? { command } : {}) @@ -47,24 +91,20 @@ export function isStillQueued( return submission !== undefined && isQueuedAgentJournalSubmission(submission) } -/** Rejects the message a failed start was for. Writes nothing once it is no longer queued: Stop - * withdrew it, or another writer settled it. */ -export async function rejectStructuredAgentSessionStartFailure( +/** The delivery loop's record of the queued message a failed start was for. */ +export function rejectStructuredAgentSessionStartFailure( writer: { journal: StartFailureJournal; fence: number; record: AgentSessionRecord | null }, cause: StructuredAgentSessionStartFailureCause, clientMessageId: string ): Promise { - if (!isStillQueued(writer.journal, clientMessageId)) { - return - } - await writer.journal.resolveDispatch({ + return rejectWithStartFailureRow(writer.journal, { clientMessageId, - state: 'rejected', - ...structuredAgentSessionStartFailure( + words: structuredAgentSessionStartFailure( cause, startFailureWordsContext(writer.journal, writer.record, clientMessageId) ), - fence: writer.fence + fence: writer.fence, + which: isQueuedAgentJournalSubmission }) } @@ -79,7 +119,7 @@ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence return ( submission.dispatchState === 'rejected' && submission.fence === fence && - isFailedStartRejection({ reason: submission.reason ?? null, rejection: submission.rejection }) + isFailedStartOrHostFault({ reason: submission.reason ?? null, rejection: submission.rejection }) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index b45545640f1e..de4ba53f290b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -16,7 +16,11 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' +import { + isStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowIdentity +} from '../../../shared/structured-agent-session-start-failure-row-key' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { @@ -24,7 +28,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ResolveDispatchInput } from '../agent-session-journal/journal-store-contracts' +import type { JournalLifecycleBatchInput } from '../agent-session-journal/journal-store-contracts' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -41,6 +45,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } +const SETUP_ROW = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')) const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' // A situation that could clear on its own, seen after the child was spawned. const TRANSIENT = agentSessionFailureFact('accountSwitchInProgress') @@ -162,12 +167,20 @@ async function submission(clientMessageId: string): Promise { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - isStructuredAgentSessionStartFailureRow(item.itemId) ? [item.itemId] : [] + isStructuredAgentSessionStartFailureRow(item.itemId) && item.itemId !== SETUP_ROW + ? [item.itemId] + : [] ) } +/** The row a failed start leaves for the message it was for. */ +function rowFor(clientMessageId: string): string { + return agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(clientMessageId)) +} + /** Every dispatch state a subscriber was told for one message, in order. */ function framedStates(clientMessageId: string): string[] { return frames.flatMap((frame) => @@ -183,7 +196,7 @@ function dispatched(): string[] { return dispatch.mock.calls.map(([input]) => input.clientMessageId) } -/** Records each rejection the journal is asked to write, in order; with `fail`, fails that +/** Records each failed start's write the journal is asked for, in order; with `fail`, fails that * message's first `times` (one by default), before they land or after. */ function spyRejections(fail?: { clientMessageId: string @@ -191,28 +204,28 @@ function spyRejections(fail?: { times?: number }): string[] { const order: string[] = [] - const resolve = AgentSessionJournal.prototype.resolveDispatch + const append = AgentSessionJournal.prototype.appendLifecycleBatch let failures = 0 - vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(async function ( - this: AgentSessionJournal, - input: ResolveDispatchInput - ) { - if (input.state === 'rejected') { - order.push(`rejected ${input.clientMessageId}`) - } - if ( - failures >= (fail?.times ?? 1) || - input.state !== 'rejected' || - input.clientMessageId !== fail?.clientMessageId - ) { - return resolve.call(this, input) - } - failures += 1 - if (fail.when === 'after') { - await resolve.call(this, input) + vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation( + async function (this: AgentSessionJournal, input: JournalLifecycleBatchInput) { + const rejected = input.rejects?.clientMessageId + if (rejected !== undefined) { + order.push(`rejected ${rejected}`) + } + if ( + failures >= (fail?.times ?? 1) || + rejected === undefined || + rejected !== fail?.clientMessageId + ) { + return append.call(this, input) + } + failures += 1 + if (fail.when === 'after') { + await append.call(this, input) + } + throw new Error('journal write failed') } - throw new Error('journal write failed') - }) + ) return order } @@ -256,7 +269,7 @@ describe('a queued message whose start fails', () => { ...TRANSIENT_WORDS }) ) - expect(await startRows()).toEqual([]) + expect(await startRows()).toEqual([rowFor(queued)]) }) it('fails only itself: each message behind it gets its own start', async () => { @@ -276,7 +289,8 @@ describe('a queued message whose start fails', () => { for (const id of [second, third]) { expect(framedStates(id)).not.toContain('rejected') } - expect(await startRows()).toEqual([]) + // One row per failed start: the first's, and none for the messages that went on. + expect(await startRows()).toEqual([rowFor(first)]) }) it('ends a failed child still there when the next message comes, and starts afresh', async () => { @@ -388,7 +402,7 @@ describe('a start that fails while its child exits', () => { }) ) expect(generation).toBe(2) - expect(await startRows()).toEqual([]) + expect(await startRows()).toEqual([rowFor(queued)]) }) it('is recorded once when the exit lands before the loop sees the start fail', async () => { @@ -407,7 +421,7 @@ describe('a start that fails while its child exits', () => { await host.flushStreamedEvents(SESSION) expect(await submission(queued)).toEqual(recorded) - expect(await startRows()).toEqual([]) + expect(await startRows()).toEqual([rowFor(queued)]) }) it('is recorded once when the exit lands after the loop recorded it', async () => { @@ -426,7 +440,7 @@ describe('a start that fails while its child exits', () => { // The exit's own reason never rewrites the failure the loop recorded. expect(await submission(queued)).toEqual(recorded) - expect(await startRows()).toEqual([]) + expect(await startRows()).toEqual([rowFor(queued)]) }) it('rejects a message its unproven child was handed with the start, never in doubt', async () => { @@ -448,6 +462,9 @@ describe('a start that fails while its child exits', () => { ) // Never in doubt on the way: the child it was handed to took nothing. expect(framedStates(handed)).not.toContain('unknown') - expect(await startRows()).toEqual([]) + // The exit rejected it, so the exit writes the start's one row. + expect(await startRows()).toEqual([ + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) + ]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index e1c4ba03a4d1..29cecaafc444 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it, vi, type Mock } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import type { JournalLifecycleBatchInput } from '../agent-session-journal/journal-store-contracts' import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { settleStructuredAgentSessionChildExit, @@ -19,12 +19,7 @@ const REASON = 'claude stream-json exited (code 1): session limit reached' const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' function startedSession(): StructuredAgentSessionChildExitSession & { - journal: { - appendLifecycleBatch: Mock< - (batch: JournalLifecycleBatchInput) => Promise<{ epoch: string; sequence: number }> - > - rejectPendingSubmissions: Mock<(...args: unknown[]) => Promise> - } + journal: { appendLifecycleBatch: ReturnType } } { return { child: { generation: GENERATION, fence: 7, phase: 'ready' }, @@ -33,30 +28,13 @@ function startedSession(): StructuredAgentSessionChildExitSession & { itemBody: () => null, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), - appendLifecycleBatch: vi.fn(async (_batch: JournalLifecycleBatchInput) => ({ - epoch: 'epoch-1', - sequence: 1 - })), + appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown: vi.fn(async () => []), - rejectPendingSubmissions: vi.fn(async (..._args: unknown[]): Promise => []) + rejectPendingSubmissions: vi.fn(async () => []) } } } -/** The exit's row, which says why a start that carried no message failed. */ -const EXIT_ROW_IDENTITY = { - provider: 'orca', - clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` -} - -function statusRowsWritten(session: ReturnType): unknown[] { - return session.journal.appendLifecycleBatch.mock.calls.flatMap(([batch]) => - batch.mutations.filter( - (mutation) => mutation.kind === 'item' && mutation.body.kind === 'status' - ) - ) -} - function contextFor(session: StructuredAgentSessionChildExitSession) { let record: AgentSessionRecord = agentSessionRecordFixture( agentSessionLeaseFixture({ @@ -98,7 +76,7 @@ const ended = { } describe('a provider that ends before it finished starting', () => { - it('tells the user why a start that carried no message failed, even with no response in progress', async () => { + it('tells the user why, even with no response in progress', async () => { const session = startedSession() await settleStructuredAgentSessionChildExit(contextFor(session), { @@ -112,7 +90,8 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - identity: EXIT_ROW_IDENTITY, + // The same row the delivery loop writes for a failed start: an error, keyed by it. + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, body: { kind: 'status', text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', @@ -149,7 +128,8 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - identity: EXIT_ROW_IDENTITY, + // The same row the delivery loop writes for a failed start: an error, keyed by it. + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, // The exit's stderr stays out of the sentence, as a log detail beside it. body: { kind: 'status', @@ -166,7 +146,7 @@ describe('a provider that ends before it finished starting', () => { ) }) - it('names /compact as the next step on the command the start that failed was carrying, and writes no row', async () => { + it('names /compact as the next step when the start that failed was carrying it', async () => { const base = startedSession() const session = { ...base, @@ -177,7 +157,6 @@ describe('a provider that ends before it finished starting', () => { itemBody: () => structuredAgentSessionCompactBody() } } - session.journal.rejectPendingSubmissions.mockResolvedValue(['compact-1']) await settleStructuredAgentSessionChildExit(contextFor(session), ended) @@ -186,39 +165,75 @@ describe('a provider that ends before it finished starting', () => { 7, expect.objectContaining({ reason: text }) ) - expect(statusRowsWritten(session)).toEqual([]) + expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ + mutations: [expect.objectContaining({ body: expect.objectContaining({ text }) })] + }) + ) }) +}) - it('rejects only the messages handed to it, and writes no row: each message says why', async () => { - const session = { - ...startedSession(), - child: { generation: GENERATION, fence: 7, phase: 'starting' as const } +describe('the one row a failed start leaves', () => { + type Submission = Pick & + Partial + + function startingSession(submissions: Submission[], rejectedHere: string[] = []) { + const base = startedSession() + return { + ...base, + child: { generation: GENERATION, fence: 7, phase: 'starting' as const }, + journal: { + ...base.journal, + submissions: () => submissions, + rejectPendingSubmissions: vi.fn(async () => rejectedHere) + } } - session.journal.rejectPendingSubmissions.mockResolvedValue(['handed-1']) + } - await settleStructuredAgentSessionChildExit(contextFor(session), ended) + function rowsWritten(session: ReturnType): unknown[] { + return session.journal.appendLifecycleBatch.mock.calls.flatMap((call) => { + const [batch] = call + // The mock records whatever batch it was given; each names its mutations. + const mutations: { body?: { kind?: string } }[] = batch.mutations + return mutations.filter((mutation) => mutation.body?.kind === 'status') + }) + } - expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( - 7, - expect.objectContaining({ reason: STARTUP_TEXT }) + it("is the exit's for the messages it rejected", async () => { + const session = startingSession( + [{ clientMessageId: 'handed-1', dispatchState: 'pending', handedOverAt: 1, fence: 7 }], + ['handed-1'] ) - expect(session.journal.markPendingSubmissionsUnknown).not.toHaveBeenCalled() - expect(statusRowsWritten(session)).toEqual([]) + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(rowsWritten(session)).toEqual([ + expect.objectContaining({ + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` } + }) + ]) }) - it('writes no row for a start made for a queued message: the delivery loop rejects that message', async () => { - const session = { - ...startedSession(), - child: { - generation: GENERATION, + it.each<[string, Submission]>([ + [ + 'a queued message, which the delivery loop rejects with its own row', + { clientMessageId: 'queued-1', dispatchState: 'pending', handoverRecorded: true } + ], + [ + "a message already rejected as this start, with its writer's row", + { + clientMessageId: 'rejected-1', + dispatchState: 'rejected', fence: 7, - phase: 'starting' as const, - startedFor: 'queued-1' + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } } - } + ] + ])('is not written again beside %s', async (_case, submission) => { + const session = startingSession([submission]) await settleStructuredAgentSessionChildExit(contextFor(session), ended) - expect(statusRowsWritten(session)).toEqual([]) + expect(rowsWritten(session)).toEqual([]) }) }) 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 2d5a9a133e4c..cd7fbc3cb9d2 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 @@ -31,6 +31,8 @@ import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { rejectWithStartFailureRow } from './structured-agent-session-start-failure-settlement' +import { isFailedStartOrHostFault } from '../../../shared/structured-agent-session-dispatch-rejection' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { handOverStructuredAgentSessionCommand, @@ -222,6 +224,16 @@ export async function handOverSubmission( return } try { + if (outcome.state === 'rejected' && isFailedStartOrHostFault(outcome)) { + // A start that failed under it: the message and that start's row, as any failed start leaves. + await rejectWithStartFailureRow(ctx.journal, { + clientMessageId, + words: { reason: outcome.reason, rejection: outcome.rejection }, + fence: ctx.fence, + which: (entry) => entry.dispatchState === 'pending' + }) + return + } await ctx.journal.resolveDispatch( outcome.state === 'accepted' ? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 19c994c18015..8f6037c6d9c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -1,6 +1,6 @@ -// A Claude chat whose CLI exits before it finishes starting. A view opening, or coming back to, a -// chat whose last start failed used to start the CLI again, so every look at the chat added an -// identical row. Only a send retries a failed start: it is the user asking, and its message says why. +// A Claude chat whose CLI exits before it finishes starting leaves one red row per start. A view +// opening, or coming back to, a chat whose last start failed used to start the CLI again, so every +// look at the chat added an identical row. Only a send retries a failed start: it is the user asking. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -154,7 +154,7 @@ describe('a fresh chat whose Claude start fails', () => { ['after the create already died', false], ['while the create is still starting', true] ] as const)( - 'starts once for the open and once for a send, which says why on its message, when the view binds %s', + 'starts once for the open and once for a send, one row each, when the view binds %s', async (_when, createStillStarting) => { // Released only once the views bound, so no runner is slow enough to let the create die first. let releaseCreate = (): void => {} @@ -203,16 +203,16 @@ describe('a fresh chat whose Claude start fails', () => { ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) ) await settleExits() - // The send's own start, once; the message carries why, with no row beside it. + // The send's own start, once, and one row for it below the message. expect(claude.connections).toHaveLength(2) - expect(await timeline()).toEqual([startFailure, 'message']) + expect(await timeline()).toEqual([startFailure, 'message', startFailure]) // Switching away and back re-subscribes; it starts nothing and adds no row. unsubscribe() await view(SURFACE) await settleExits() expect(claude.connections).toHaveLength(2) - expect(await timeline()).toEqual([startFailure, 'message']) + expect(await timeline()).toEqual([startFailure, 'message', startFailure]) } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 8a49352c337f..d2cb969bf1d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -577,7 +577,7 @@ describe("the replacement's first send", () => { kind: 'startFailed' } ])( - 'fails on the message when the start fails ($name): the message kept as not sent, with its reason, and Retry starts it', + 'fails on the message when the start fails ($name): one row, the message kept as not sent, and Retry starts it', async ({ error, words, kind }) => { const replacement = await clearCommits() vi.mocked(adapter.acquire).mockRejectedValueOnce(error()) @@ -590,10 +590,11 @@ describe("the replacement's first send", () => { rejection: { kind } }) ) - expect(await errorRows(replacement)).toEqual([]) - const { reason } = (await submissionOf(replacement, clientMessageId)) ?? {} - expect(reason).toContain(words) - expect(reason).not.toContain('/clear') + const rows = await errorRows(replacement) + expect(rows).toHaveLength(1) + expect(rows[0]).toContain(words) + expect(rows.join(' ')).not.toContain('/clear') + expect((await submissionOf(replacement, clientMessageId))?.reason).not.toContain('/clear') expect(adapter.dispatch).not.toHaveBeenCalled() // Retry resends the same words, which starts the agent and delivers them. diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 77c62a90212c..22fa2b415284 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -402,7 +402,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B }) }) -it('leaves a command whose start failed not sent, with its reason on it and no row beside it (B3)', async () => { +it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { await attach() await state.host.close(SESSION, 'evict') state.acquire.mockRejectedValue(new Error('not signed in')) @@ -419,9 +419,19 @@ it('leaves a command whose start failed not sent, with its reason on it and no r }) const snapshot = await journal() expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) + // The start's own row, in the words the command's message was refused with. expect( - snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') - ).toEqual([]) + snapshot.items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: "Codex couldn't restart. Run /compact again.", + failure: expect.objectContaining({ kind: 'restartFailed' }), + tone: 'error' + } + ]) expect( snapshot.submissions.find( (entry) => entry.clientMessageId === params.envelope.clientOperationId diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 06d3ba2f1bd3..9d274cd68a8c 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -1,6 +1,6 @@ // A reopened Claude chat is published as soon as its child spawns, so a CLI that dies before it -// answers initialize fails a session the user is already looking at. That chat must say why, on the -// message the start was for, exactly as a failed first start does. +// answers initialize fails a session the user is already looking at. That chat must say why, in +// the transcript, exactly as a failed first start does. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -24,18 +24,12 @@ afterEach(async () => { claude = createScriptedClaudeRuntime([SESSION]) }) -/** The rejection reasons the open chat was sent on its messages, in a batch or a snapshot. */ -function rejectionTexts(events: AgentSessionSubscribeEvent[]): string[] { +/** Status rows the open chat was sent, whether in a batch or in the snapshot a fence change sends. */ +function statusTexts(events: AgentSessionSubscribeEvent[]): string[] { return events.flatMap((event) => { - const submissions = - event.type === 'batch' - ? event.batch.submissions - : event.type === 'snapshot' - ? event.page.submissions - : [] - return submissions.flatMap((entry) => - entry.dispatchState === 'rejected' && entry.reason ? [entry.reason] : [] - ) + const items = + event.type === 'batch' ? event.batch.items : event.type === 'snapshot' ? event.page.items : [] + return items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) }) } @@ -75,8 +69,8 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => expect(rejectionTexts(events)).toContainEqual(STARTUP_TEXT)) - // Never written, so it did not happen: rejected with the cause, not left in doubt. + await vi.waitFor(() => expect(statusTexts(events)).toContainEqual(STARTUP_TEXT)) + // Never written, so it did not happen: refused, not left in doubt. const submission = (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) ) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index b0dc0456daa5..efb8d3cde16a 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -1,8 +1,8 @@ // A Claude chat is published the moment its child spawns, before the CLI has answered initialize. // A send in that window — into a fresh start, or into the restart the delivery loop makes for a // send after a start that failed — is accepted and stays queued until the child proves its start. -// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, and -// nothing is left as a delivery nobody can confirm. Against the production runtime, +// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, the chat +// shows the cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, // adapter, record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -79,14 +79,13 @@ async function failLatestStart(host: StructuredAgentSessionHost, count: number): } describe('a send into a Claude chat whose CLI keeps failing at startup', () => { - it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers a new one once the CLI is healthy', async () => { + it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) - // No message waited on the open's start, so its exit's row says why. expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) @@ -99,22 +98,22 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ) await failLatestStart(host, 2) - // The cause is on the message, which is rejected: not left in doubt, and no row of its own. + // Rejected with the cause, not left in doubt; one row for this attempt names it. await vi.waitFor(async () => expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', - // Worded for the user: the message's own notice shows it. + // Worded for the user: the red line under the composer shows it as it stands. reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') - // The user signs in and sends again: one restart, proven, written to the CLI. + // The user signs in and retries: one restart, proven, written to the CLI. claude.behave(SESSION, {}) await send(host, 'hello again') await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(3)) @@ -123,7 +122,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) + expect(await statusRows(host)).toHaveLength(2) }) }) @@ -156,11 +155,12 @@ describe('a send while the first Claude start is still answering initialize', () await vi.waitFor(async () => expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', + // Worded for the user: the red line under the composer shows it as it stands. reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('send') diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 0503e81a7f75..c5b21f9027fa 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,8 +1,8 @@ // A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child. // When that child dies before it proves its start, the message was never handed to it — delivery -// waits for the start — so the send is rejected with the child's own diagnostic, never left as a -// delivery nobody can confirm, and a client that was subscribed the whole time receives -// that over the wire, with no row of its own. Against the production runtime, adapter, +// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a +// delivery nobody can confirm, and a client that was subscribed the whole time receives the +// failure row and the rejected submission over the wire. Against the production runtime, adapter, // record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -118,10 +118,7 @@ function received(events: AgentSessionSubscribeEvent[]) { } } for (const entry of page.submissions) { - submissions.set( - entry.clientMessageId, - entry.dispatchState === 'rejected' ? `rejected: ${entry.reason}` : entry.dispatchState - ) + submissions.set(entry.clientMessageId, entry.dispatchState) } if (event.fence !== undefined) { fences.push(event.fence) @@ -131,15 +128,13 @@ function received(events: AgentSessionSubscribeEvent[]) { } describe('a send whose restarted Claude child dies before it proves its start', () => { - it('is rejected with the diagnostic, adds no row of its own, and a new send is one new attempt', async () => { + it('settles rejected with the diagnostic, keeps one failure row, and a Retry is one new attempt', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) - // The open's start carried no message, so its exit's row says why. - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) const sent = await send(host, 'hello?') @@ -151,28 +146,29 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(async () => expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', + // Worded for the user: the red line under the composer shows it as it stands. reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') - // A new send: one restart, and once the CLI is healthy the message is written. + // Retry under a new id: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) await send(host, 'hello again') await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) + expect(await statusRows(host)).toHaveLength(2) }) - // A restart refused because its child died before it was handed over is recorded on the message, - // in the words any failed start uses, with no row of its own. + // A restart refused because its child died before it was handed over leaves one row, from the + // delivery, in the words any failed start uses, and rejects the message with them. it.each(['spawn', 'start-time-read'] as const)( - 'records a restart whose child exits at %s on the message', + 'leaves one row for a restart whose child exits at %s', async (at) => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() @@ -192,8 +188,7 @@ describe('a send whose restarted Claude child dies before it proves its start', ) await waitForStructuredAgentSessionRecovery() - // Only the open's start, which carried no message, has a row. - expect(await statusRows(host)).toEqual([STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) } ) @@ -218,9 +213,8 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => { const seen = received(events) - expect(seen.submissions.get(sent)).toBe(`rejected: ${STARTUP_TEXT}`) - // Only the open's start, which carried no message, has a row; each snapshot repeats it. - expect([...new Set(seen.statusTexts)]).toEqual([STARTUP_TEXT]) + expect(seen.submissions.get(sent)).toBe('rejected') + expect(seen.statusTexts).toContainEqual(STARTUP_TEXT) // The subscriber ended up on the fence the exit published, not the one the restart did. expect(seen.fences.at(-1)).toBe(fence(host)) }) @@ -251,7 +245,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o return rows } - it('shows the cause on each message whose start died, however it died, and no row of their own', async () => { + it('shows one row naming the cause per failed attempt, however the start died, and none once the CLI is fixed', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] @@ -267,18 +261,23 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o }) try { await failLatestStart(host, 1) - // The open's start carried no message, so its exit's row says why. await eventually(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE])) // Send: accepted, and its delivery restarts the child, which dies before starting. const sent = await send(host, 'hello?') await failLatestStart(host, 2) + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_FAILURE + }) + ) await eventually(() => - expect(received(events).submissions.get(sent)).toBe(`rejected: ${STARTUP_FAILURE}`) + expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE, STARTUP_FAILURE]) ) - // Sent again while still broken: this restart dies before its child is handed over, so the - // delivery's start is refused, and the new message says the same thing. + // Retry while still broken: this restart dies before its child is handed over, so the + // delivery's start is refused. Still one row, saying the same thing, on the rejected message. claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: 'claude stream-json exited (code 1): claude: not signed in (rig)', @@ -286,16 +285,26 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o } }) const retried = await send(host, 'hello?') - await eventually(() => - expect(received(events).submissions.get(retried)).toBe(`rejected: ${STARTUP_FAILURE}`) + await eventually(async () => + expect(await submission(host, retried)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_FAILURE + }) ) await waitForStructuredAgentSessionRecovery() + await eventually(() => + expect([...shownRows(events).values()]).toEqual([ + STARTUP_FAILURE, + STARTUP_FAILURE, + STARTUP_FAILURE + ]) + ) - // The CLI is fixed: a new send delivers. + // The CLI is fixed: Retry delivers and adds no row. claude.behave(SESSION, {}) await expect(attempt(host, 'hello?')).resolves.toMatchObject({ ok: true }) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) - expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE]) + expect(shownRows(events).size).toBe(3) } finally { unsubscribe() } diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 2ad4ad6500fd..863cbaeaebf9 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -1,8 +1,7 @@ // A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't // restore, or an init frame naming another session. Orca ends that child itself, so the chat must -// not say Claude stopped on its own, on a message or in a row; only an exit Orca saw says that. -// Against the production runtime, adapter, record store and host, with only the CLI process -// scripted. +// not say Claude stopped on its own; only an exit Orca saw says that. Against the production +// runtime, adapter, record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -111,7 +110,9 @@ describe('a Claude start that Orca fails while the CLI is still running', () => rejection: { kind: 'startFailed' } }) ) - expect(await failureRows(host)).toEqual([]) + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) expect(claude.child(SESSION).calls).not.toContain('send') }) @@ -133,6 +134,6 @@ describe('a Claude start that Orca fails while the CLI is still running', () => rejection: { kind: 'providerStartFailed' } }) ) - expect(await failureRows(host)).toEqual([]) + expect(await failureRows(host)).toEqual([{ text: STOPPED_TEXT, kind: 'providerStartFailed' }]) }) }) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 24eb98dc6c77..1c2b2275fbcb 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -178,6 +178,16 @@ export function isFailedStartRejection( return category === 'startFailed' || kind === 'hostStopped' || kind === 'providerExited' } +/** A failed start, or Orca's own fault on the way to one: either way the message never reached an + * agent, and its record says why. */ +export function isFailedStartOrHostFault( + submission: Pick & { rejection?: unknown } +): boolean { + return ( + isFailedStartRejection(submission) || classifyDispatchRejection(submission).kind === 'hostFault' + ) +} + /** * Whether a queued card holds every card behind it until the person acts: one returned for * something about it that only they can resolve — the provider or Orca refused its content or From c838320f171e082faddc0a44679e8cf6b19b67f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:53:18 -0700 Subject: [PATCH 08/26] fix(native-chat): derive a run of failed starts from the journal, so a later separate failure is announced again --- ...agent-session-turn-completion-feed.test.ts | 70 +++++++++++++++---- ...ured-agent-session-turn-completion-feed.ts | 54 +++++++------- ...structured-agent-session-latest-request.ts | 34 ++++++--- .../structured-agent-session-projection.ts | 9 +-- 4 files changed, 113 insertions(+), 54 deletions(-) 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 b2f8933e805e..c9b753439f1c 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 @@ -623,41 +623,81 @@ describe('a request the agent or its start refused', () => { expect(h.outcomes()).toEqual([['t1', 'success']]) }) + /** A failed start sent at `submittedAt` whose failure landed at `resolvedAt`. */ + const failedStartAt = (clientMessageId: string, submittedAt: number, resolvedAt: number) => ({ + ...failedStart(clientMessageId), + submittedAt, + resolvedAt + }) + // Each queued message makes its own start; when the agent's CLI is missing they all fail alike. - it('notifies a run of failed starts once, and the next failure after a turn ran again', () => { + it('notifies a run of failed starts once: each later one was already waiting when the first failed', () => { const h = harness() h.listen() h.observe() const items = [userEntry('m1', 1), userEntry('m2', 2), userEntry('m3', 3)] h.setJournal(items, [pending('m1'), pending('m2'), pending('m3')]) h.observe() - h.setJournal(items, [failedStart('m1'), pending('m2'), pending('m3')]) + h.setJournal(items, [failedStartAt('m1', 1, 10), pending('m2'), pending('m3')]) h.observe() - h.setJournal(items, [failedStart('m1'), failedStart('m2'), pending('m3')]) + h.setJournal(items, [failedStartAt('m1', 1, 10), failedStartAt('m2', 2, 11), pending('m3')]) h.observe() - h.setJournal(items, [failedStart('m1'), failedStart('m2'), failedStart('m3')]) + h.setJournal(items, [ + failedStartAt('m1', 1, 10), + failedStartAt('m2', 2, 11), + failedStartAt('m3', 3, 12) + ]) h.observe() expect(h.outcomes()).toEqual([[M1, 'failure']]) + }) - const later = [...items, userEntry('m4', 4), turnItem(turn('t4', 'running'), 5)] - const failed = [failedStart('m1'), failedStart('m2'), failedStart('m3')] - h.setJournal(later, [...failed, sent('m4', { dispatchState: 'accepted' })]) + // Nothing ran in between, and the first failure was hours earlier: it is news again. + it('notifies a failed start sent after the earlier failure landed, with no turn between', () => { + const h = harness() + h.listen() h.observe() - const settled = [...items, userEntry('m4', 4), turnItem(turn('t4', 'completed', 'success'), 5)] - h.setJournal(settled, [...failed, sent('m4', { dispatchState: 'accepted' })]) + const first = [userEntry('m1', 1)] + h.setJournal(first, [failedStartAt('m1', 1, 10)]) h.observe() - h.setJournal( - [...settled, userEntry('m5', 6)], - [...failed, sent('m4', { dispatchState: 'accepted' }), failedStart('m5')] - ) + const later = [...first, userEntry('m2', 2)] + h.setJournal(later, [failedStartAt('m1', 1, 10), pending('m2')]) + h.observe() + h.setJournal(later, [failedStartAt('m1', 1, 10), failedStartAt('m2', 3_600_000, 3_600_010)]) h.observe() expect(h.outcomes()).toEqual([ [M1, 'failure'], - ['t4', 'success'], - [agentJournalSubmissionKey('m5'), 'failure'] + [M2, 'failure'] ]) }) + it.each([ + ['sent after the first failure landed: notified', 30, [M1, M3]], + ['already waiting when the first failure landed: part of its run', 3, [M1]] + ] as const)( + 'ignores a content refusal between failed starts: a failure %s', + (_case, submittedAt, notified) => { + const h = harness() + h.listen() + h.observe() + const items = [userEntry('m1', 1), userEntry('m2', 2), userEntry('m3', 3)] + const contentRefused = { ...refused('m2'), submittedAt: 15, resolvedAt: 20 } + h.setJournal(items, [failedStartAt('m1', 1, 10), contentRefused, pending('m3')]) + h.observe() + h.setJournal(items, [ + failedStartAt('m1', 1, 10), + contentRefused, + failedStartAt('m3', submittedAt, 40) + ]) + h.observe() + expect( + h + .outcomes() + .filter(([id]) => id !== M2) + .map(([id]) => id) + ).toEqual(notified) + } + ) + it('does not wait on a send left pending at an older fence', () => { const h = harness() h.listen() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 14e8f845d480..039fff8ba704 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -16,7 +16,10 @@ import type { AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request' +import type { + StructuredAgentSessionFailedStart, + StructuredAgentSessionLatestRequest +} from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' @@ -46,12 +49,27 @@ type RequestMark = Pick /** Per-session baseline. `settled` is the last settled request this feed has accounted for, and * `failedStarts` the sends whose start had failed as of the last observation; absence of the whole - * entry — not a null field — is what makes the first observation silent. `failedStartStreak`: the - * last completion announced was a failed start, and no turn has settled since. */ + * entry — not a null field — is what makes the first observation silent. */ type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null failedStarts: ReadonlySet - failedStartStreak: boolean +} + +/** Whether a failed start is part of an earlier one's run: it was already waiting when that one's + * failure landed, as each queued message is when the agent's CLI is missing. A failure sent after + * every earlier one landed starts a run of its own. */ +function inEarlierRun( + failed: StructuredAgentSessionFailedStart, + index: number, + all: readonly StructuredAgentSessionFailedStart[] +): boolean { + return all.some( + (earlier, earlierIndex) => + earlierIndex !== index && + (earlier.resolvedAt < failed.resolvedAt || + (earlier.resolvedAt === failed.resolvedAt && earlierIndex < index)) && + failed.submittedAt <= earlier.resolvedAt + ) } function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { @@ -106,15 +124,10 @@ export class StructuredAgentSessionTurnCompletionFeed { const cursor = (journal ?? session.journal).cursor() const request = state.latestRequest const baseline = this.baselines.get(sessionId) - const failedStarts = new Set(state.failedStarts) + const failedStarts = new Set(state.failedStarts.map((failed) => failed.id)) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { - ...cursor, - settled: settledMark(request), - failedStarts, - failedStartStreak: false - }) + this.baselines.set(sessionId, { ...cursor, settled: settledMark(request), failedStarts }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -129,21 +142,17 @@ export class StructuredAgentSessionTurnCompletionFeed { } baseline.sequence = cursor.sequence // A send whose start failed is announced the moment it is final, whatever else the session - // still owes, and never again as the latest request below. A run of them, as when the agent's - // CLI is missing and each queued message tries in turn, is announced once. - for (const id of failedStarts) { - if (!baseline.failedStarts.has(id) && !baseline.failedStartStreak) { - baseline.failedStartStreak = true - this.announce(session, sessionId, state, id, 'failure') + // still owes, and never again as the latest request below. A run of them is announced once. + state.failedStarts.forEach((failed, index, all) => { + if (!baseline.failedStarts.has(failed.id) && !inEarlierRun(failed, index, all)) { + this.announce(session, sessionId, state, failed.id, 'failure') } - } + }) baseline.failedStarts = failedStarts if (request?.turnState === 'running') { // A running turn clears the mark, so this detector fires on each running → settled - // transition rather than on an id it happens not to have seen. A start landed, so the next - // failed one is news again. + // transition rather than on an id it happens not to have seen. baseline.settled = null - baseline.failedStartStreak = false return } // Owed work waits, so sends refused one commit at a time announce once, when the last is @@ -158,9 +167,6 @@ export class StructuredAgentSessionTurnCompletionFeed { return } baseline.settled = settledMark(request) - if (request.kind === 'turn') { - baseline.failedStartStreak = false - } if (request.kind === 'refused-send' && failedStarts.has(request.id)) { return } diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index ca20cc5a3936..5cacb40f5880 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -19,7 +19,7 @@ import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { classifyDispatchRejection, - isFailedStartRejection + isFailedStartOrHostFault } from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' import { @@ -90,23 +90,35 @@ export function latestStructuredAgentSessionRequest( return null } -/** The sends whose start failed, or that Orca's own fault kept from their agent, by their item - * keys: each one's failure is final the moment it is written, whatever else the session still - * owes. A conversation command is not a request. */ -export function structuredAgentSessionFailedStartIds( +/** A send whose start failed: its item key, when it was sent, and when its failure landed. */ +export type StructuredAgentSessionFailedStart = { + id: string + submittedAt: number + resolvedAt: number +} + +/** The sends whose start failed, or that Orca's own fault kept from their agent, in journal order: + * each one's failure is final the moment it is written, whatever else the session still owes. A + * conversation command is not a request. */ +export function structuredAgentSessionFailedStarts( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] -): string[] { +): StructuredAgentSessionFailedStart[] { const commands = new Set( items.flatMap((item) => (isStructuredAgentSessionCommandEntry(item.body) ? [item.itemId] : [])) ) return submissions.flatMap((submission) => { - const key = agentJournalSubmissionKey(submission.clientMessageId) + const id = agentJournalSubmissionKey(submission.clientMessageId) return submission.dispatchState === 'rejected' && - !commands.has(key) && - (isFailedStartRejection(submission) || - classifyDispatchRejection(submission).kind === 'hostFault') - ? [key] + !commands.has(id) && + isFailedStartOrHostFault(submission) + ? [ + { + id, + submittedAt: submission.submittedAt, + resolvedAt: submission.resolvedAt ?? submission.submittedAt + } + ] : [] }) } diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index a6c0db789e23..67541fe53697 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -25,7 +25,8 @@ import { latestStructuredAgentSessionAssistantMessage, latestStructuredAgentSessionPrompt, latestStructuredAgentSessionRequest, - structuredAgentSessionFailedStartIds, + structuredAgentSessionFailedStarts, + type StructuredAgentSessionFailedStart, type StructuredAgentSessionLatestRequest } from './structured-agent-session-latest-request' import { @@ -247,8 +248,8 @@ export function projectStructuredAgentSessionStatusState( latestRequest: StructuredAgentSessionLatestRequest | null /** Whether a running turn or an unanswered send is still owed, even beneath a pending prompt. */ owesWork: boolean - /** The sends whose start failed; see `structuredAgentSessionFailedStartIds`. */ - failedStarts: string[] + /** The sends whose start failed; see `structuredAgentSessionFailedStarts`. */ + failedStarts: StructuredAgentSessionFailedStart[] } { if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) { return { @@ -289,7 +290,7 @@ export function projectStructuredAgentSessionStatusState( return { latestRequest, owesWork: status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence), - failedStarts: structuredAgentSessionFailedStartIds(items, submissions), + failedStarts: structuredAgentSessionFailedStarts(items, submissions), summary: { status, latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)), From 9e5f57aef7fb9e12a4f193a3126b9e68c6dffbd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:53:18 -0700 Subject: [PATCH 09/26] fix(native-chat): hush a rejected message only by the start-failure row its own start wrote, in either write order --- ...turedSession.start-failure-notice.test.tsx | 30 ++- ...-session-delivery-notices-test-fixtures.ts | 51 ++++ ...session-delivery-notices.start-row.test.ts | 217 ++++++++++++++++++ ...red-agent-session-delivery-notices.test.ts | 167 +------------- ...ructured-agent-session-delivery-notices.ts | 59 +++-- ...ssage-projection.rejected-in-place.test.ts | 10 +- ...ructured-conversation-command-send.test.ts | 36 ++- .../structured-conversation-command-send.ts | 10 +- ...ructured-agent-session-delivery-notices.ts | 6 +- ...-agent-session-start-failure-facts.test.ts | 6 +- ...tured-agent-session-start-failure-facts.ts | 16 +- 11 files changed, 406 insertions(+), 202 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-test-fixtures.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 73334d71ca3b..31adcafdaf04 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -41,12 +41,12 @@ const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } const START_FAILED_REASON = 'Claude stopped before it finished starting. Send your message to try again.' -function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { +function startFailureRow(fact: AgentSessionFailureFact, sequence = 1): AgentJournalRenderItem { return { itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), revision: 1, - sequence: 1, - observedAt: 1, + sequence, + observedAt: sequence, body: { kind: 'status', tone: 'error', @@ -55,6 +55,17 @@ function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem } } +/** A rejected message where the journal placed it: where it was rejected. */ +function rejectedAt(clientMessageId: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { return { outbox: { @@ -116,7 +127,12 @@ async function notice(clientMessageId: string): Promise { // The start's own row says why, so its rejected messages say only that they were not sent. Sending // one again is a new message, so none offers a Retry. it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { - mocks.journalItems = [startFailureRow(START_FAILED)] + // The start rejected both, then wrote its row after them. + mocks.journalItems = [ + rejectedAt('first', 1), + rejectedAt('second', 2), + startFailureRow(START_FAILED, 3) + ] renderPane([ rejected('first', START_FAILED_REASON, START_FAILED), @@ -132,7 +148,11 @@ it("says only 'not sent', with no Retry, on each message the failed start's row }) it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { - mocks.journalItems = [startFailureRow(START_FAILED)] + mocks.journalItems = [ + rejectedAt('stated', 1), + rejectedAt('other', 2), + startFailureRow(START_FAILED, 3) + ] const providerRejected: AgentSessionFailureFact = { kind: 'providerRejected', detail: { text: 'Image type .bmp', audience: 'person' } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-test-fixtures.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-test-fixtures.ts new file mode 100644 index 000000000000..6c3eff0af67a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-test-fixtures.ts @@ -0,0 +1,51 @@ +// What the structured chat's delivery-notice tests build on: an outbox entry, and the words each +// message's notice says. + +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { + structuredAgentSessionDeliveryNotices, + type StatedStartFailure +} from './structured-agent-session-delivery-notices' + +export function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +export const NOT_FAILED_HERE: ReadonlySet = new Set() +// What the row shows, quietly in place of its time, while nothing has confirmed the message. +export const SENDING = 'Sending…' + +export function texts( + outbox: StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] = [], + startFailures: readonly StatedStartFailure[] = [] +): Record { + // Every failure seen while the chat was open, so each words its whole cause. + const notices = structuredAgentSessionDeliveryNotices( + outbox, + 'Claude', + () => {}, + submissions, + startFailures, + new Set(outbox.map((candidate) => candidate.clientMessageId)) + ) + return Object.fromEntries( + [...notices].map(([id, notice]) => [id, notice.sending ? SENDING : notice.text]) + ) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts new file mode 100644 index 000000000000..926d0c3d3223 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts @@ -0,0 +1,217 @@ +// A message rejected by a start whose row says why says only that it was not sent. A row covers +// the rejections its start wrote it after, so an equal failure in another start's row never hushes +// a message. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' +import { entry, texts } from './structured-agent-session-delivery-notices-test-fixtures' + +// Matched on the typed fact of a row found by its identity, never on either sentence. +describe('a message rejected by a start whose row already says why', () => { + const startFailed: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + } + const rejected = (id: string, fact: AgentSessionFailureFact) => + entry(id, { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Written by the host.', + rejection: { kind: fact.kind } + } + }) + const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState: 'rejected', + providerItemId: null, + reason: 'Written by the host.', + rejection: fact, + submittedAt: 1, + resolvedAt: 1 + }) + const statusRow = ( + itemId: string, + fact: AgentSessionFailureFact, + sequence = 1 + ): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + }) + /** A message placed where the journal put it: a rejected one where it was rejected. */ + const messageAt = (id: string, sequence: number): AgentJournalRenderItem => ({ + itemId: agentJournalSubmissionKey(id), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + }) + const rowKey = (startKey: string) => + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) + const startRowKey = rowKey('gen') + + it('reads only the start-failure rows, each covering the rejections its start wrote it after', () => { + expect( + structuredAgentSessionStartFailureFacts( + [ + messageAt('first', 1), + statusRow(startRowKey, startFailed, 2), + statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }, 3) + ], + [recorded('first', startFailed)] + ) + ).toEqual([ + { itemId: startRowKey, fact: startFailed, covers: [agentJournalSubmissionKey('first')] } + ]) + }) + + // An older host rejected every queued message, then wrote the start's one row after them. + it('says only that each was not sent, and words any other rejection in full', () => { + const otherRefusal: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + } + const submissions = [ + recorded('first', startFailed), + recorded('second', startFailed), + recorded('other', otherRefusal) + ] + const facts = structuredAgentSessionStartFailureFacts( + [ + messageAt('first', 1), + messageAt('second', 2), + statusRow(startRowKey, startFailed, 3), + messageAt('other', 4) + ], + submissions + ) + expect( + texts( + [ + rejected('first', startFailed), + rejected('second', startFailed), + rejected('other', otherRefusal) + ], + submissions, + facts + ) + ).toEqual({ + [agentJournalSubmissionKey('first')]: 'Your message was not sent.', + [agentJournalSubmissionKey('second')]: 'Your message was not sent.', + [agentJournalSubmissionKey('other')]: + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + }) + }) + + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { + const shown = "Claude couldn't start. Start a new chat to continue." + const stated = { + itemId: startRowKey, + fact: startFailed, + covers: [agentJournalSubmissionKey('first')] + } + expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ + [agentJournalSubmissionKey('first')]: 'Written by the host.' + }) + expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual({ + [agentJournalSubmissionKey('first')]: shown + }) + }) + + // An equal failure in an older row is another start's: the later message keeps its words. + it("words in full a later failure equal to an older start's row", () => { + const submissions = [ + recorded('first', startFailed), + { ...recorded('sent', startFailed), dispatchState: 'accepted' as const }, + recorded('later', startFailed) + ] + const facts = structuredAgentSessionStartFailureFacts( + [ + messageAt('first', 1), + statusRow(startRowKey, startFailed, 2), + messageAt('sent', 3), + messageAt('later', 5) + ], + submissions + ) + expect( + texts([rejected('first', startFailed), rejected('later', startFailed)], submissions, facts) + ).toEqual({ + [agentJournalSubmissionKey('first')]: 'Your message was not sent.', + [agentJournalSubmissionKey('later')]: "Claude couldn't start. Start a new chat to continue." + }) + }) + + // A message that went through, then a rejection with no row of its own, then a later start's + // row: that row is not the rejection's. + it('lets no row speak for a rejection a message that went through came after', () => { + const submissions = [ + recorded('unrowed', startFailed), + { ...recorded('sent', startFailed), dispatchState: 'accepted' as const }, + recorded('later', startFailed) + ] + const facts = structuredAgentSessionStartFailureFacts( + [ + messageAt('unrowed', 1), + messageAt('sent', 2), + messageAt('later', 3), + statusRow(rowKey('later'), startFailed, 4) + ], + submissions + ) + expect( + texts([rejected('unrowed', startFailed), rejected('later', startFailed)], submissions, facts) + ).toEqual({ + [agentJournalSubmissionKey('unrowed')]: + "Claude couldn't start. Start a new chat to continue.", + [agentJournalSubmissionKey('later')]: 'Your message was not sent.' + }) + }) + + // An older host's exit rejected the messages handed to its child, then wrote the row in a write + // of its own, a moment after. + it("hushes the messages an older host's exit rejected before writing their row", () => { + const submissions = [ + { ...recorded('handed-1', startFailed), resolvedAt: 10 }, + { ...recorded('handed-2', startFailed), resolvedAt: 11 } + ] + const facts = structuredAgentSessionStartFailureFacts( + [ + messageAt('handed-1', 10), + messageAt('handed-2', 11), + statusRow(startRowKey, startFailed, 12) + ], + submissions + ) + expect( + texts( + [rejected('handed-1', startFailed), rejected('handed-2', startFailed)], + submissions, + facts + ) + ).toEqual({ + [agentJournalSubmissionKey('handed-1')]: 'Your message was not sent.', + [agentJournalSubmissionKey('handed-2')]: 'Your message was not sent.' + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 0da5413f120a..3cdb65923b0b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -5,60 +5,18 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { - createStructuredAgentSessionOutboxEntry, structuredAgentSessionRejectedFailure, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../../shared/agent-session-journal-item-key' -import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' -import { - structuredAgentSessionDeliveryNotices, - structuredAgentSessionStartFailureFacts, - type StatedStartFailure -} from './structured-agent-session-delivery-notices' - -function entry( - clientMessageId: string, - patch: Partial = {} -): StructuredAgentSessionOutboxEntry { - return { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId, - sessionId: 'session-1', - text: clientMessageId, - attachments: [], - queuedAt: 1 - }), - ...patch - } -} - -const NOT_FAILED_HERE: ReadonlySet = new Set() -// What the row shows, quietly in place of its time, while nothing has confirmed the message. -const SENDING = 'Sending…' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -function texts( - outbox: StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] = [], - startFailures: readonly StatedStartFailure[] = [] -): Record { - // Every failure seen while the chat was open, so each words its whole cause. - const notices = structuredAgentSessionDeliveryNotices( - outbox, - 'Claude', - () => {}, - submissions, - startFailures, - new Set(outbox.map((candidate) => candidate.clientMessageId)) - ) - return Object.fromEntries( - [...notices].map(([id, notice]) => [id, notice.sending ? SENDING : notice.text]) - ) -} +import { + entry, + NOT_FAILED_HERE, + SENDING, + texts +} from './structured-agent-session-delivery-notices-test-fixtures' describe('the notice on each message that did not go through', () => { // Recorded by the host, so sending one again is a new message: no Retry. @@ -712,113 +670,4 @@ describe('the notice on each message that did not go through', () => { expect(texts([doubt(patch)])).toEqual(UNCONFIRMED) }) }) - - // Matched on the typed fact of a row found by its identity, never on either sentence. - describe('a message rejected by a start whose row already says why', () => { - const startFailed: AgentSessionFailureFact = { - kind: 'startFailed', - refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } - } - const rejected = (id: string, fact: AgentSessionFailureFact) => - entry(id, { - state: 'rejected', - lastFailure: { - kind: 'rejected', - reason: 'Written by the host.', - rejection: { kind: fact.kind } - } - }) - const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ - clientMessageId: id, - fence: 1, - payloadFingerprint: id, - dispatchState: 'rejected', - providerItemId: null, - reason: 'Written by the host.', - rejection: fact, - submittedAt: 1, - resolvedAt: 1 - }) - const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({ - itemId, - revision: 1, - sequence: 1, - observedAt: 1, - body: { - kind: 'status', - tone: 'error', - ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) - } - }) - const startRowKey = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')) - - it('reads only the start-failure rows', () => { - expect( - structuredAgentSessionStartFailureFacts([ - statusRow(startRowKey, startFailed), - statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) - ]) - ).toEqual([{ itemId: startRowKey, fact: startFailed, observedAt: 1 }]) - }) - - it('says only that each was not sent, and words any other rejection in full', () => { - const otherRefusal: AgentSessionFailureFact = { - kind: 'startFailed', - refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } - } - const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) - expect( - texts( - [ - rejected('first', startFailed), - rejected('second', startFailed), - rejected('other', otherRefusal) - ], - [ - recorded('first', startFailed), - recorded('second', startFailed), - recorded('other', otherRefusal) - ], - facts - ) - ).toEqual({ - [agentJournalSubmissionKey('first')]: 'Your message was not sent.', - [agentJournalSubmissionKey('second')]: 'Your message was not sent.', - [agentJournalSubmissionKey('other')]: - "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." - }) - }) - - it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { - const shown = "Claude couldn't start. Start a new chat to continue." - const stated = { itemId: startRowKey, fact: startFailed, observedAt: 1 } - expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ - [agentJournalSubmissionKey('first')]: 'Written by the host.' - }) - expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual( - { [agentJournalSubmissionKey('first')]: shown } - ) - }) - - // A newer host writes no start row: a later failure equal to an older row's is its own start's. - it("words in full a later failure equal to an older start's row, which speaks only for its batch", () => { - const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) - const later = { ...recorded('later', startFailed), submittedAt: 5, resolvedAt: 6 } - expect( - texts( - [ - rejected('first', startFailed), - rejected('second', startFailed), - rejected('later', startFailed) - ], - [recorded('first', startFailed), recorded('second', startFailed), later], - facts - ) - ).toEqual({ - [agentJournalSubmissionKey('first')]: 'Your message was not sent.', - [agentJournalSubmissionKey('second')]: 'Your message was not sent.', - [agentJournalSubmissionKey('later')]: "Claude couldn't start. Start a new chat to continue." - }) - }) - }) }) 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 b1e28b255bbc..8c102be93ebc 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 @@ -11,10 +11,10 @@ // words and gets its Retry once the queue moves. // // A message the host recorded and then rejected is drawn from the host's history, worded from the -// journal's own fact, with no Retry: sending it again is a new message. A rejection that is a -// failed start's, the fact a loaded start row from an older host states for that same batch, says -// only that it was not sent: the row already says why. Until its row loads, the outbox draws it from -// its smaller copy, which leaves on the batch or page that loads the row. +// journal's own fact, with no Retry: sending it again is a new message. A rejection whose own +// start's row states the same failure says only that it was not sent: the row already says why. +// Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch or page +// that loads the row. import { readAgentSessionFailureFact, @@ -54,29 +54,49 @@ const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { se const NO_COMMANDS: ReadonlySet = new Set() const NO_ITEMS: readonly AgentJournalRenderItem[] = [] -/** A loaded start-failure row an older host wrote: the failure it states, and when. */ +/** A loaded start-failure row: the failure it states, and the rejected messages its start wrote it + * for, by item id. */ export type StatedStartFailure = { itemId: string fact: AgentSessionFailureFact - observedAt: number + covers: readonly string[] } -/** What the chat's loaded start-failure rows state. */ +/** What the chat's loaded start-failure rows state. A start writes its row right after the + * rejections it failed, each placed where it was rejected, so a row covers the rejected messages + * since the row before it, unless a message that went through came between them: a row after that + * is another start's. */ export function structuredAgentSessionStartFailureFacts( - items: readonly AgentJournalRenderItem[] + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] ): StatedStartFailure[] { + const rejected = new Set( + submissions.flatMap((submission) => + submission.dispatchState === 'rejected' + ? [agentJournalSubmissionKey(submission.clientMessageId)] + : [] + ) + ) const stated: StatedStartFailure[] = [] - for (const item of items) { + let since: string[] = [] + for (const item of [...items].sort(byJournalPlace)) { if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { const fact = readAgentSessionFailureFact(item.body.failure) if (fact) { - stated.push({ itemId: item.itemId, fact, observedAt: item.observedAt }) + stated.push({ itemId: item.itemId, fact, covers: since }) } + since = [] + } else if (item.body.kind === 'message' && item.body.role === 'user') { + since = rejected.has(item.itemId) ? [...since, item.itemId] : [] } } return stated } +function byJournalPlace(a: AgentJournalRenderItem, b: AgentJournalRenderItem): number { + return a.sequence - b.sequence || (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0) +} + /** Whether two facts are one failure: a start's row and the messages it rejected share one. */ export function sameAgentSessionFailureFact( a: AgentSessionFailureFact, @@ -108,22 +128,17 @@ export function agentSessionFailureStatedByStartRow( ) } -/** Whether the row of the start that rejected this message already says why: the same failure, - * written after the message was sent and no later than its rejection, as the older host wrote - * both. An equal failure in another row is another start's, so the message keeps its words. */ +/** Whether the row of the start that rejected this message already says why: the same failure, in + * the row that covers it. An equal failure in another row is another start's, so the message keeps + * its words. */ function rejectionStatedByItsStartRow( recorded: AgentJournalSubmission, startFailures: readonly StatedStartFailure[] ): boolean { - const { resolvedAt } = recorded - return ( - resolvedAt !== null && - agentSessionFailureStatedByStartRow( - recorded.rejection, - startFailures.filter( - ({ observedAt }) => observedAt >= recorded.submittedAt && observedAt <= resolvedAt - ) - ) + const id = agentJournalSubmissionKey(recorded.clientMessageId) + return agentSessionFailureStatedByStartRow( + recorded.rejection, + startFailures.filter(({ covers }) => covers.includes(id)) ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 86cfb8aa8cae..4e8fa71a74a3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -194,8 +194,14 @@ describe('a message the host accepted and then rejected, on the desktop', () => 'Claude', () => {}, [failedStart], - // An older host's row, written with the rejection it states. - [{ itemId: 'orca:start-failure', fact: { kind: 'notSignedIn' }, observedAt: 3 }], + // The start's row, written right after the rejection it states. + [ + { + itemId: 'orca:start-failure', + fact: { kind: 'notSignedIn' }, + covers: [agentJournalSubmissionKey('first')] + } + ], new Set() ) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 4a20cb279af9..34efe05cf931 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -69,6 +69,40 @@ afterEach(async () => { await i18n.changeLanguage('en') }) +describe("a /compact whose start failed, beside the chat's start-failure rows", () => { + const row = (itemId: string) => ({ itemId, fact: START_FAILED, covers: [] }) + + it('says nothing under the composer when its own start wrote the row', async () => { + let loaded: ReturnType[] = [] + const outcome = await sendStructuredConversationCommand({ + command: 'compact', + agentName: 'Claude', + pending: { current: false }, + blocked: false, + startFailures: () => loaded, + send: async () => { + loaded = [row('orca:start-failure%3Acompact-1')] + return { kind: 'done', value: hostResult('compact', START_FAILED) } + } + }) + expect(outcome).toEqual({ accepted: false, error: null }) + }) + + // An older start's row with the same failure is not this command's: its own words show. + it('keeps its words when the only row with that failure was loaded before it was sent', async () => { + const result = hostResult('compact', START_FAILED) + const outcome = await sendStructuredConversationCommand({ + command: 'compact', + agentName: 'Claude', + pending: { current: false }, + blocked: false, + startFailures: () => [row('orca:start-failure%3Aolder')], + send: async () => ({ kind: 'done', value: result }) + }) + expect(outcome).toEqual({ accepted: false, error: result.error }) + }) +}) + describe('the line under the composer after a conversation command failed', () => { it('says in English exactly what the host wrote, for every failure a command reports', async () => { for (const provider of ['claude', 'codex'] as const) { @@ -155,7 +189,7 @@ describe('the line under the composer after a conversation command failed', () = agentName: 'Claude', pending: { current: false }, blocked: false, - startFailures: () => [{ itemId: 'orca:start-failure', fact: START_FAILED, observedAt: 1 }], + startFailures: () => [{ itemId: 'orca:start-failure', fact: START_FAILED, covers: [] }], send: async () => ({ kind: 'done', value: result }) }) ).toEqual({ accepted: false, error }) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index f3267d127c52..b9f831e9ca48 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -19,7 +19,8 @@ export async function sendStructuredConversationCommand(input: { agentName: string pending: { current: boolean } blocked: boolean - /** What the chat's loaded start-failure rows state, read when the reply lands. */ + /** What the chat's loaded start-failure rows state, read before the send and when the reply + * lands. */ startFailures: () => readonly StatedStartFailure[] send: ( command: AgentSessionConversationCommand @@ -35,6 +36,8 @@ export async function sendStructuredConversationCommand(input: { } } input.pending.current = true + // Only a row this command's own start wrote says why it failed; one already loaded is another's. + const loadedBefore = new Set(input.startFailures().map((stated) => stated.itemId)) try { const outcome = await input.send(input.command) if (outcome.kind === 'not-done') { @@ -51,7 +54,10 @@ export async function sendStructuredConversationCommand(input: { if ( isAgentSessionConversationCommand(value.command) && value.command !== 'clear' && - agentSessionFailureStatedByStartRow(value.failure, input.startFailures()) + agentSessionFailureStatedByStartRow( + value.failure, + input.startFailures().filter((stated) => !loadedBefore.has(stated.itemId)) + ) ) { return { accepted: false, error: null } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index 2c39d306e82a..42f448e773e5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -43,7 +43,11 @@ export function useStructuredAgentSessionDeliveryNotices(args: { // Only an outbox copy of a rejected message reads the loaded rows, so a streaming turn rebuilds // no notice otherwise. const loadedItems = hasRejected && outbox.length > 0 ? args.journalItems : NO_ITEMS - const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) + const startFailures = useStructuredAgentSessionStartFailureFacts( + args.journalItems, + submissions, + hasRejected + ) const commandItemIds = useCommandItemIds(args.journalItems, hasRejected) const notices = useMemo( () => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts index 1010d22a4c67..03aa159baf27 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -27,13 +27,11 @@ function startFailureRow(startKey: string, fact: AgentSessionFailureFact): Agent it('holds the same facts while the start rows state nothing new', () => { const row = startFailureRow('gen-1', { kind: 'providerStartFailed' }) const { result, rerender } = renderHook( - ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), + ({ items }) => useStructuredAgentSessionStartFailureFacts(items, [], true), { initialProps: { items: [row] } } ) const first = result.current - expect(first).toEqual([ - { itemId: row.itemId, fact: { kind: 'providerStartFailed' }, observedAt: 1 } - ]) + expect(first).toEqual([{ itemId: row.itemId, fact: { kind: 'providerStartFailed' }, covers: [] }]) rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) expect(result.current).not.toBe(first) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts index 9668926fc472..3f9305afd223 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -1,5 +1,8 @@ import { useEffect, useMemo, useRef } from 'react' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { sameAgentSessionFailureFact, structuredAgentSessionStartFailureFacts, @@ -8,15 +11,16 @@ import { const NO_FACTS: readonly StatedStartFailure[] = [] -/** What the loaded start-failure rows an older host wrote state, read only while `enabled`. Held - * while unchanged, so a streaming turn does not rebuild every row's delivery notice. */ +/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a + * streaming turn does not rebuild every row's delivery notice. */ export function useStructuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[], enabled: boolean ): readonly StatedStartFailure[] { const facts = useMemo( - () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), - [enabled, items] + () => (enabled ? structuredAgentSessionStartFailureFacts(items, submissions) : NO_FACTS), + [enabled, items, submissions] ) const previousRef = useRef(NO_FACTS) const previous = previousRef.current @@ -27,7 +31,7 @@ export function useStructuredAgentSessionStartFailureFacts( return ( next !== undefined && stated.itemId === next.itemId && - stated.observedAt === next.observedAt && + stated.covers.join('\n') === next.covers.join('\n') && sameAgentSessionFailureFact(stated.fact, next.fact) ) }) From 582089e5576aea9cd3294a0845e4a6034a61a755 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:10:53 -0700 Subject: [PATCH 10/26] fix(native-chat): a start-failure row keyed by a message hushes only that message; an older host's batch row matches by its failure --- ...session-delivery-notices.start-row.test.ts | 303 ++++++++---------- ...ructured-agent-session-delivery-notices.ts | 55 ++-- ...ssage-projection.rejected-in-place.test.ts | 6 +- ...ructured-conversation-command-send.test.ts | 4 +- ...ructured-agent-session-delivery-notices.ts | 6 +- ...-agent-session-start-failure-facts.test.ts | 4 +- ...tured-agent-session-start-failure-facts.ts | 11 +- 7 files changed, 159 insertions(+), 230 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts index 926d0c3d3223..122af2a0269e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts @@ -1,6 +1,6 @@ -// A message rejected by a start whose row says why says only that it was not sent. A row covers -// the rejections its start wrote it after, so an equal failure in another start's row never hushes -// a message. +// A message rejected by a start whose row says why says only that it was not sent. A row keyed by +// the message is its own start's and speaks for it alone; a chat an older host wrote has one row for +// a batch, so a message with no row of its own takes any loaded row with the same failure. import { describe, expect, it } from 'vitest' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' @@ -17,201 +17,154 @@ import { structuredAgentSessionStartFailureRowIdentity } from '../../../../share import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { entry, texts } from './structured-agent-session-delivery-notices-test-fixtures' -// Matched on the typed fact of a row found by its identity, never on either sentence. -describe('a message rejected by a start whose row already says why', () => { - const startFailed: AgentSessionFailureFact = { - kind: 'startFailed', - refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } - } - const rejected = (id: string, fact: AgentSessionFailureFact) => - entry(id, { - state: 'rejected', - lastFailure: { - kind: 'rejected', - reason: 'Written by the host.', - rejection: { kind: fact.kind } - } - }) - const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ - clientMessageId: id, - fence: 1, - payloadFingerprint: id, - dispatchState: 'rejected', - providerItemId: null, - reason: 'Written by the host.', - rejection: fact, - submittedAt: 1, - resolvedAt: 1 - }) - const statusRow = ( - itemId: string, - fact: AgentSessionFailureFact, - sequence = 1 - ): AgentJournalRenderItem => ({ - itemId, - revision: 1, - sequence, - observedAt: sequence, - body: { - kind: 'status', - tone: 'error', - ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) +const startFailed: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } +} +const otherRefusal: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } +} +const NOT_SENT = 'Your message was not sent.' +const START_FAILED_WORDS = "Claude couldn't start. Start a new chat to continue." + +const rejected = (id: string, fact: AgentSessionFailureFact) => + entry(id, { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Written by the host.', + rejection: { kind: fact.kind } } }) - /** A message placed where the journal put it: a rejected one where it was rejected. */ - const messageAt = (id: string, sequence: number): AgentJournalRenderItem => ({ - itemId: agentJournalSubmissionKey(id), - revision: 0, - sequence, - observedAt: sequence, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } +const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState: 'rejected', + providerItemId: null, + reason: 'Written by the host.', + rejection: fact, + submittedAt: 1, + resolvedAt: 1 +}) +const row = ( + startKey: string, + fact: AgentSessionFailureFact, + sequence: number +): AgentJournalRenderItem => ({ + itemId: rowKey(startKey), + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } +}) +/** A rejected message where the journal placed it: where it was rejected. */ +const messageAt = (id: string, sequence: number): AgentJournalRenderItem => ({ + itemId: agentJournalSubmissionKey(id), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } +}) +function rowKey(startKey: string): string { + return agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) +} + +function noticesFor( + ids: readonly [string, AgentSessionFailureFact][], + items: readonly AgentJournalRenderItem[] +): Record { + return texts( + ids.map(([id, fact]) => rejected(id, fact)), + ids.map(([id, fact]) => recorded(id, fact)), + structuredAgentSessionStartFailureFacts(items) + ) +} + +const key = agentJournalSubmissionKey + +// Matched on the typed fact of a row found by its identity, never on either sentence. +describe('a message rejected by a start whose row already says why', () => { + it('reads only the start-failure rows', () => { + expect( + structuredAgentSessionStartFailureFacts([ + row('gen', startFailed, 2), + { ...row('gen', { kind: 'providerExited' }, 3), itemId: key('exit-row') } + ]) + ).toEqual([{ itemId: rowKey('gen'), fact: startFailed }]) }) - const rowKey = (startKey: string) => - agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) - const startRowKey = rowKey('gen') - it('reads only the start-failure rows, each covering the rejections its start wrote it after', () => { + // This host: each failed start rejects its message and writes the row keyed by it, in one write. + it('hushes a message by its own row, and only by its own', () => { expect( - structuredAgentSessionStartFailureFacts( + noticesFor( [ - messageAt('first', 1), - statusRow(startRowKey, startFailed, 2), - statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }, 3) + ['first', startFailed], + ['later', startFailed] ], - [recorded('first', startFailed)] + [ + messageAt('first', 1), + row('first', startFailed, 2), + messageAt('later', 5), + row('later', otherRefusal, 6) + ] ) - ).toEqual([ - { itemId: startRowKey, fact: startFailed, covers: [agentJournalSubmissionKey('first')] } - ]) + ).toEqual({ + [key('first')]: NOT_SENT, + // Its own row states another failure: an equal failure in another message's row is not its. + [key('later')]: START_FAILED_WORDS + }) }) - // An older host rejected every queued message, then wrote the start's one row after them. - it('says only that each was not sent, and words any other rejection in full', () => { - const otherRefusal: AgentSessionFailureFact = { - kind: 'startFailed', - refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } - } - const submissions = [ - recorded('first', startFailed), - recorded('second', startFailed), - recorded('other', otherRefusal) - ] - const facts = structuredAgentSessionStartFailureFacts( - [ - messageAt('first', 1), - messageAt('second', 2), - statusRow(startRowKey, startFailed, 3), - messageAt('other', 4) - ], - submissions - ) + // v1.4.221: the row first, keyed by the oldest queued message, then every queued message rejected. + it("hushes a released host's batch, its row written before the rejections", () => { expect( - texts( + noticesFor( [ - rejected('first', startFailed), - rejected('second', startFailed), - rejected('other', otherRefusal) + ['oldest', startFailed], + ['second', startFailed], + ['other', otherRefusal] ], - submissions, - facts + [ + row('oldest', startFailed, 1), + messageAt('oldest', 2), + messageAt('second', 3), + messageAt('other', 9) + ] ) ).toEqual({ - [agentJournalSubmissionKey('first')]: 'Your message was not sent.', - [agentJournalSubmissionKey('second')]: 'Your message was not sent.', - [agentJournalSubmissionKey('other')]: + [key('oldest')]: NOT_SENT, + [key('second')]: NOT_SENT, + [key('other')]: "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." }) }) - it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { - const shown = "Claude couldn't start. Start a new chat to continue." - const stated = { - itemId: startRowKey, - fact: startFailed, - covers: [agentJournalSubmissionKey('first')] - } - expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ - [agentJournalSubmissionKey('first')]: 'Written by the host.' - }) - expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual({ - [agentJournalSubmissionKey('first')]: shown - }) - }) - - // An equal failure in an older row is another start's: the later message keeps its words. - it("words in full a later failure equal to an older start's row", () => { - const submissions = [ - recorded('first', startFailed), - { ...recorded('sent', startFailed), dispatchState: 'accepted' as const }, - recorded('later', startFailed) - ] - const facts = structuredAgentSessionStartFailureFacts( - [ - messageAt('first', 1), - statusRow(startRowKey, startFailed, 2), - messageAt('sent', 3), - messageAt('later', 5) - ], - submissions - ) + // Unreleased main: every queued message rejected, then the row keyed by the start's generation. + it('hushes a batch whose row was written after its rejections', () => { expect( - texts([rejected('first', startFailed), rejected('later', startFailed)], submissions, facts) - ).toEqual({ - [agentJournalSubmissionKey('first')]: 'Your message was not sent.', - [agentJournalSubmissionKey('later')]: "Claude couldn't start. Start a new chat to continue." - }) + noticesFor( + [ + ['first', startFailed], + ['second', startFailed] + ], + [messageAt('first', 1), messageAt('second', 2), row('generation-1', startFailed, 3)] + ) + ).toEqual({ [key('first')]: NOT_SENT, [key('second')]: NOT_SENT }) }) - // A message that went through, then a rejection with no row of its own, then a later start's - // row: that row is not the rejection's. - it('lets no row speak for a rejection a message that went through came after', () => { - const submissions = [ - recorded('unrowed', startFailed), - { ...recorded('sent', startFailed), dispatchState: 'accepted' as const }, - recorded('later', startFailed) - ] - const facts = structuredAgentSessionStartFailureFacts( - [ - messageAt('unrowed', 1), - messageAt('sent', 2), - messageAt('later', 3), - statusRow(rowKey('later'), startFailed, 4) - ], - submissions - ) - expect( - texts([rejected('unrowed', startFailed), rejected('later', startFailed)], submissions, facts) - ).toEqual({ - [agentJournalSubmissionKey('unrowed')]: - "Claude couldn't start. Start a new chat to continue.", - [agentJournalSubmissionKey('later')]: 'Your message was not sent.' + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { + const stated = { itemId: rowKey('first'), fact: startFailed } + expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ + [key('first')]: 'Written by the host.' }) - }) - - // An older host's exit rejected the messages handed to its child, then wrote the row in a write - // of its own, a moment after. - it("hushes the messages an older host's exit rejected before writing their row", () => { - const submissions = [ - { ...recorded('handed-1', startFailed), resolvedAt: 10 }, - { ...recorded('handed-2', startFailed), resolvedAt: 11 } - ] - const facts = structuredAgentSessionStartFailureFacts( - [ - messageAt('handed-1', 10), - messageAt('handed-2', 11), - statusRow(startRowKey, startFailed, 12) - ], - submissions - ) - expect( - texts( - [rejected('handed-1', startFailed), rejected('handed-2', startFailed)], - submissions, - facts - ) - ).toEqual({ - [agentJournalSubmissionKey('handed-1')]: 'Your message was not sent.', - [agentJournalSubmissionKey('handed-2')]: 'Your message was not sent.' + expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual({ + [key('first')]: START_FAILED_WORDS }) }) }) 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 8c102be93ebc..808ae34bc84c 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 @@ -21,13 +21,19 @@ import { readWholeAgentSessionFailureFact, type AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { + isStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowIdentity +} from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionEntryIdExpired, structuredAgentSessionEntryRejectedByHost, @@ -54,49 +60,28 @@ const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { se const NO_COMMANDS: ReadonlySet = new Set() const NO_ITEMS: readonly AgentJournalRenderItem[] = [] -/** A loaded start-failure row: the failure it states, and the rejected messages its start wrote it - * for, by item id. */ +/** A loaded start-failure row: its item id, and the failure it states. */ export type StatedStartFailure = { itemId: string fact: AgentSessionFailureFact - covers: readonly string[] } -/** What the chat's loaded start-failure rows state. A start writes its row right after the - * rejections it failed, each placed where it was rejected, so a row covers the rejected messages - * since the row before it, unless a message that went through came between them: a row after that - * is another start's. */ +/** What the chat's loaded start-failure rows state. */ export function structuredAgentSessionStartFailureFacts( - items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[] = [] + items: readonly AgentJournalRenderItem[] ): StatedStartFailure[] { - const rejected = new Set( - submissions.flatMap((submission) => - submission.dispatchState === 'rejected' - ? [agentJournalSubmissionKey(submission.clientMessageId)] - : [] - ) - ) const stated: StatedStartFailure[] = [] - let since: string[] = [] - for (const item of [...items].sort(byJournalPlace)) { + for (const item of items) { if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { const fact = readAgentSessionFailureFact(item.body.failure) if (fact) { - stated.push({ itemId: item.itemId, fact, covers: since }) + stated.push({ itemId: item.itemId, fact }) } - since = [] - } else if (item.body.kind === 'message' && item.body.role === 'user') { - since = rejected.has(item.itemId) ? [...since, item.itemId] : [] } } return stated } -function byJournalPlace(a: AgentJournalRenderItem, b: AgentJournalRenderItem): number { - return a.sequence - b.sequence || (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0) -} - /** Whether two facts are one failure: a start's row and the messages it rejected share one. */ export function sameAgentSessionFailureFact( a: AgentSessionFailureFact, @@ -128,17 +113,21 @@ export function agentSessionFailureStatedByStartRow( ) } -/** Whether the row of the start that rejected this message already says why: the same failure, in - * the row that covers it. An equal failure in another row is another start's, so the message keeps - * its words. */ +/** Whether the row of the start that rejected this message already says why. A row keyed by the + * message is its own start's and speaks for it alone, so its failure decides. With none, as in a + * chat an older host wrote — one row for a batch, keyed by its start or by its oldest message — + * any loaded row with the same failure does. */ function rejectionStatedByItsStartRow( recorded: AgentJournalSubmission, startFailures: readonly StatedStartFailure[] ): boolean { - const id = agentJournalSubmissionKey(recorded.clientMessageId) + const ownRow = agentJournalItemKey( + structuredAgentSessionStartFailureRowIdentity(recorded.clientMessageId) + ) + const own = startFailures.filter(({ itemId }) => itemId === ownRow) return agentSessionFailureStatedByStartRow( recorded.rejection, - startFailures.filter(({ covers }) => covers.includes(id)) + own.length > 0 ? own : startFailures ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 4e8fa71a74a3..24113bb037c9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -196,11 +196,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => [failedStart], // The start's row, written right after the rejection it states. [ - { - itemId: 'orca:start-failure', - fact: { kind: 'notSignedIn' }, - covers: [agentJournalSubmissionKey('first')] - } + { itemId: 'orca:start-failure%3Afirst', fact: { kind: 'notSignedIn' } } ], new Set() ) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 34efe05cf931..b037bec0db27 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -70,7 +70,7 @@ afterEach(async () => { }) describe("a /compact whose start failed, beside the chat's start-failure rows", () => { - const row = (itemId: string) => ({ itemId, fact: START_FAILED, covers: [] }) + const row = (itemId: string) => ({ itemId, fact: START_FAILED }) it('says nothing under the composer when its own start wrote the row', async () => { let loaded: ReturnType[] = [] @@ -189,7 +189,7 @@ describe('the line under the composer after a conversation command failed', () = agentName: 'Claude', pending: { current: false }, blocked: false, - startFailures: () => [{ itemId: 'orca:start-failure', fact: START_FAILED, covers: [] }], + startFailures: () => [{ itemId: 'orca:start-failure', fact: START_FAILED }], send: async () => ({ kind: 'done', value: result }) }) ).toEqual({ accepted: false, error }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index 42f448e773e5..2c39d306e82a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -43,11 +43,7 @@ export function useStructuredAgentSessionDeliveryNotices(args: { // Only an outbox copy of a rejected message reads the loaded rows, so a streaming turn rebuilds // no notice otherwise. const loadedItems = hasRejected && outbox.length > 0 ? args.journalItems : NO_ITEMS - const startFailures = useStructuredAgentSessionStartFailureFacts( - args.journalItems, - submissions, - hasRejected - ) + const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) const commandItemIds = useCommandItemIds(args.journalItems, hasRejected) const notices = useMemo( () => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts index 03aa159baf27..fffbd6431947 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -27,11 +27,11 @@ function startFailureRow(startKey: string, fact: AgentSessionFailureFact): Agent it('holds the same facts while the start rows state nothing new', () => { const row = startFailureRow('gen-1', { kind: 'providerStartFailed' }) const { result, rerender } = renderHook( - ({ items }) => useStructuredAgentSessionStartFailureFacts(items, [], true), + ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), { initialProps: { items: [row] } } ) const first = result.current - expect(first).toEqual([{ itemId: row.itemId, fact: { kind: 'providerStartFailed' }, covers: [] }]) + expect(first).toEqual([{ itemId: row.itemId, fact: { kind: 'providerStartFailed' } }]) rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) expect(result.current).not.toBe(first) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts index 3f9305afd223..166e9abdfbe1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -1,8 +1,5 @@ import { useEffect, useMemo, useRef } from 'react' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { sameAgentSessionFailureFact, structuredAgentSessionStartFailureFacts, @@ -15,12 +12,11 @@ const NO_FACTS: readonly StatedStartFailure[] = [] * streaming turn does not rebuild every row's delivery notice. */ export function useStructuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[], enabled: boolean ): readonly StatedStartFailure[] { const facts = useMemo( - () => (enabled ? structuredAgentSessionStartFailureFacts(items, submissions) : NO_FACTS), - [enabled, items, submissions] + () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), + [enabled, items] ) const previousRef = useRef(NO_FACTS) const previous = previousRef.current @@ -31,7 +27,6 @@ export function useStructuredAgentSessionStartFailureFacts( return ( next !== undefined && stated.itemId === next.itemId && - stated.covers.join('\n') === next.covers.join('\n') && sameAgentSessionFailureFact(stated.fact, next.fact) ) }) From c74c23802bbb2ec373a2a887362fad4b07ae83bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:16:46 -0700 Subject: [PATCH 11/26] fix(native-chat): the exit writes its start row unless a queued message is charged with that start --- .../structured-agent-session-child-exit.ts | 34 ++++++++- ...gent-session-dead-generation-settlement.ts | 27 ++++---- ...ctured-agent-session-delivery-loop-deps.ts | 41 +++++++++++ .../structured-agent-session-delivery-loop.ts | 69 +++++++------------ ...structured-agent-session-event-recovery.ts | 7 +- .../structured-agent-session-host.ts | 3 +- ...gent-session-provider-child-record.test.ts | 11 +-- ...-agent-session-start-failure-settlement.ts | 17 +---- ...agent-session-start-failure-writer.test.ts | 26 +++++++ ...agent-session-startup-failure-exit.test.ts | 56 ++++++++++++--- 10 files changed, 200 insertions(+), 91 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index c606a73e310f..72ecbaa8e240 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -5,12 +5,14 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { endProviderChild } from './structured-agent-session-provider-child' import { @@ -54,6 +56,11 @@ export type StructuredAgentSessionChildExitContext< publishStatus?: (sessionId: string) => void /** The delivery loop hands over whatever is queued once the child is off the record. */ wakeDelivery?: (sessionId: string) => void + /** Whether a pass of the delivery loop is waiting on this child's start. */ + deliveryAwaits?: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger @@ -165,7 +172,12 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< )), ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), ...(!expected && exitedDuringStartup && child.generation - ? { exitedDuringStartup: { generation: child.generation } } + ? { + exitedDuringStartup: { + generation: child.generation, + chargedToQueued: startChargedToQueued(context, sessionId, session, child) + } + } : {}) }) if (!settled.ok) { @@ -209,6 +221,24 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } } +/** Whether a queued message is still waiting on this start, so the delivery loop rejects it with + * the start's failure: the message it was started for, or the one a pass waits on it for. */ +function startChargedToQueued( + context: { deliveryAwaits?: StructuredAgentSessionChildExitContext['deliveryAwaits'] }, + sessionId: string, + session: StructuredAgentSessionChildExitSession, + child: StructuredAgentSessionProviderChild +): boolean { + const { startedFor } = child + const startedForQueued = + startedFor !== undefined && + (session.journal.submissions?.() ?? []).some( + (submission) => + submission.clientMessageId === startedFor && isQueuedAgentJournalSubmission(submission) + ) + return startedForQueued || context.deliveryAwaits?.(sessionId, child) === true +} + function logExitFailure( context: Pick, sessionId: string, 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 9bd1b1304862..023b45e4acf3 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 @@ -25,7 +25,7 @@ import { } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { - messageCarriesFailedStart, + rejectedAsFailedStartAt, structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-settlement' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -130,8 +130,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's - * generation, for the row a start no message carries leaves. */ - exitedDuringStartup?: { generation: string | null } + * generation, for the row a start no message carries leaves. `chargedToQueued`: a queued + * message is still waiting on this start, so the delivery loop rejects it with its own row. */ + exitedDuringStartup?: { generation: string | null; chargedToQueued?: boolean } }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) @@ -156,16 +157,16 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { const startKey = input.exitedDuringStartup.generation ?? input.settlementId - // One row per failed start. A queued message is the delivery loop's to record with its own - // row, and one already rejected as this start's failure has its writer's; this row is for the - // messages handed to this child, or a command, goal or rewind start no message waited on. - const settledHere = new Set(settled) - const recordedElsewhere = messageCarriesFailedStart( - (input.journal.submissions?.() ?? []).filter( - (submission) => !settledHere.has(submission.clientMessageId) - ), - input.fence - ) + // The messages handed to this child take this row. With none, a message still waiting on the + // start is the delivery loop's to record with its own row, and one already rejected as this + // start's failure has its writer's; anything else — a command, goal or rewind start, or one + // no message is charged with — has only this row to say why. + const recordedElsewhere = + settled.length === 0 && + (input.exitedDuringStartup.chargedToQueued === true || + (input.journal.submissions?.() ?? []).some((submission) => + rejectedAsFailedStartAt(submission, input.fence) + )) if (!recordedElsewhere) { mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts new file mode 100644 index 000000000000..32cc376e3cd7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts @@ -0,0 +1,41 @@ +// What the delivery loop reads of the host: its sessions, its queue, the adapter, and the start +// and settlement steps it drives. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + +export type StructuredAgentSessionDeliveryLoopDeps = { + sessions: ReadonlyMap + adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry + serialize: (sessionId: string, task: () => Promise) => Promise + /** A start step, tracked from enqueue so quit waits for the child it may produce. */ + trackStart: (start: Promise) => Promise + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise + /** Ends the session's child, whose start failed, as a host stop; for a caller inside `serialize`. */ + endFailedStart: (sessionId: string) => Promise + /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ + conversationFence: (sessionId: string) => number + /** Rejects queued messages as a completed close of the chat does; false when that failed. */ + abandonQueued: ( + sessionId: string, + which: (submission: AgentJournalSubmission) => boolean + ) => Promise + /** Who the chat's failure sentences name. */ + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext + logger: StructuredAgentSessionLogger + record: (sessionId: string) => AgentSessionRecord | null + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + now: () => number +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index eda664d0b1c4..2bf90a2d5883 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -14,19 +14,11 @@ // get their own start. Every write names a message fixed when its pass chose it, never the queue's // head read again after a failure. -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentRegistry } from './structured-agent-registry' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { @@ -42,38 +34,12 @@ import { startThatFailedWhileQueued, structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { sameProviderChild } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' -export type StructuredAgentSessionDeliveryLoopDeps = { - sessions: ReadonlyMap - adapter: StructuredAgentSessionAdapter - agents: StructuredAgentRegistry - serialize: (sessionId: string, task: () => Promise) => Promise - /** A start step, tracked from enqueue so quit waits for the child it may produce. */ - trackStart: (start: Promise) => Promise - /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ - ensureProviderChild: ( - sessionId: string, - startedFor: string - ) => Promise - /** Ends the session's child, whose start failed, as a host stop; for a caller inside `serialize`. */ - endFailedStart: (sessionId: string) => Promise - /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ - conversationFence: (sessionId: string) => number - /** Rejects queued messages as a completed close of the chat does; false when that failed. */ - abandonQueued: ( - sessionId: string, - which: (submission: AgentJournalSubmission) => boolean - ) => Promise - /** Who the chat's failure sentences name. */ - failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext - logger: StructuredAgentSessionLogger - record: (sessionId: string) => AgentSessionRecord | null - readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined - now: () => number -} +export type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' type Step = 'continue' | 'stop' @@ -89,6 +55,8 @@ type Attempt = { clientMessageId?: string } export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() + /** The child each session's current pass waits on to prove its start; gone with the pass. */ + private readonly waitingOn = new Map() private disposed = false constructor(private readonly deps: StructuredAgentSessionDeliveryLoopDeps) {} @@ -97,6 +65,12 @@ export class StructuredAgentSessionDeliveryLoop { return this.running.has(sessionId) } + /** Whether a pass waits on this child's start: its exit then fails that pass's message. */ + awaits(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): boolean { + const awaited = this.waitingOn.get(sessionId) + return awaited !== undefined && sameProviderChild(awaited, child) + } + /** Quit: no step after this one starts a child or hands a message over. */ dispose(): void { this.disposed = true @@ -145,12 +119,19 @@ export class StructuredAgentSessionDeliveryLoop { ) ) } - // A child published before it proved its start takes no input yet; waited for outside - // the queue so a Stop can reach it meanwhile. - const failure = await this.deps.adapter.awaitStarted?.(sessionId) - return this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared, failure || null, attempt) - ) + if (prepared.awaited) { + this.waitingOn.set(sessionId, prepared.awaited) + } + try { + // A child published before it proved its start takes no input yet; waited for outside + // the queue so a Stop can reach it meanwhile. + const failure = await this.deps.adapter.awaitStarted?.(sessionId) + return await this.deps.serialize(sessionId, () => + this.handOver(sessionId, prepared, failure || null, attempt) + ) + } finally { + this.waitingOn.delete(sessionId) + } } /** The error is Orca's own and goes to the log; the chat says only that Orca failed, on the diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 398d04873d41..79fe68ddabb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -4,7 +4,8 @@ import { stopAgentSessionProviderRoot } from './structured-agent-session-provide import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' @@ -31,6 +32,10 @@ export class StructuredAgentSessionEventRecovery { now: () => number runtimeState: StructuredAgentSessionHostRuntimeState wakeDelivery: (sessionId: string) => void + deliveryAwaits: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => boolean } ) {} 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 d810fa2bbd80..dd1ea5d58e79 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 @@ -154,7 +154,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), runtimeState: this.runtimeState, - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId) + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), + deliveryAwaits: (sessionId, child) => this.conversationDelivery.loop.awaits(sessionId, child) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 00cc14ec937b..d0c7f254040f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -452,7 +452,9 @@ describe('a start another operation made that dies while a sent message waits on const EXIT = START_EXIT const TEXT = START_TEXT - it("is not the message's: the message makes its own start, which says why it failed (R2)", async () => { + // The exit lands between the message's accept and its first step: no pass waits on that start, + // and it was for no message, so its exit's row is the only place its failure is said. + it("is not the message's: its exit's row says why, and the message's own start says why it failed (R2)", async () => { adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) @@ -484,9 +486,10 @@ describe('a start another operation made that dies while a sent message waits on // The setup's child, the operation's, and the message's own. expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).not.toHaveBeenCalled() - // A message was waiting, so the operation's exit adds no row saying the same thing. - // The message's own start leaves its row; the operation's exit adds none beside it. - expect((await statusRows()).map((row) => row.itemId)).toEqual([startRowKey(id)]) + expect((await statusRows()).map((row) => row.itemId)).toEqual([ + startRowKey(operationChild.acquisitionGeneration), + startRowKey(id) + ]) }) it('leaves a message sent after that start failed to a fresh start (R2)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index c7107953e3d6..e432ac764f96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -108,10 +108,7 @@ export function rejectStructuredAgentSessionStartFailure( }) } -type FailedStartSubmission = Pick< - AgentJournalSubmission, - 'dispatchState' | 'handoverRecorded' | 'handedOverAt' -> & +type FailedStartSubmission = Pick & Partial> /** Whether a message was rejected as the failed start of the child under `fence`. */ @@ -123,18 +120,6 @@ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence ) } -/** Whether a message says why a child that died starting under `fence` failed: a queued one, which - * the delivery loop rejects with it or starts again for, or one already rejected as that start. */ -export function messageCarriesFailedStart( - submissions: readonly FailedStartSubmission[], - fence: number -): boolean { - return submissions.some( - (submission) => - isQueuedAgentJournalSubmission(submission) || rejectedAsFailedStartAt(submission, fence) - ) -} - export function oldestQueuedSubmission( journal: Pick ): AgentJournalSubmission | undefined { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index de4ba53f290b..bd9055592e7b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -443,6 +443,32 @@ describe('a start that fails while its child exits', () => { expect(await startRows()).toEqual([rowFor(queued)]) }) + // A message queued behind it waits on the same start; the exit still writes the handed one's row. + it('gives a handed message its row though a message is queued behind it on the same start', async () => { + awaitStarted.mockImplementationOnce(async () => undefined) + const handed = await send('handed') + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + const queued = await send('queued') + // The next pass waits on the same child's start for the queued message. + await eventually(() => expect(awaitStarted).toHaveBeenCalledTimes(2)) + + await exitBeforeProof() + settleStart(undefined) + + await eventually(async () => + expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(await submission(handed)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + // One row each: the exit's for the handed message, the loop's for the queued one. + expect(await startRows()).toEqual([ + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')), + rowFor(queued) + ]) + }) + it('rejects a message its unproven child was handed with the start, never in doubt', async () => { awaitStarted.mockImplementation(async () => undefined) const handed = await send('hello') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 29cecaafc444..451795bceb6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -214,13 +214,37 @@ describe('the one row a failed start leaves', () => { ]) }) - it.each<[string, Submission]>([ - [ - 'a queued message, which the delivery loop rejects with its own row', - { clientMessageId: 'queued-1', dispatchState: 'pending', handoverRecorded: true } - ], - [ - "a message already rejected as this start, with its writer's row", + const QUEUED: Submission = { + clientMessageId: 'queued-1', + dispatchState: 'pending', + handoverRecorded: true + } + + function startedFor(session: ReturnType, clientMessageId: string) { + return { ...session, child: { ...session.child, startedFor: clientMessageId } } + } + + it('is not written again beside the queued message the start was for: the loop rejects it with its own', async () => { + const session = startedFor(startingSession([QUEUED]), 'queued-1') + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(rowsWritten(session)).toEqual([]) + }) + + it('is not written again beside the queued message a delivery pass waits on it for', async () => { + const session = startingSession([QUEUED]) + + await settleStructuredAgentSessionChildExit( + { ...contextFor(session), deliveryAwaits: () => true }, + ended + ) + + expect(rowsWritten(session)).toEqual([]) + }) + + it("is not written again beside a message already rejected as this start, with its writer's row", async () => { + const session = startingSession([ { clientMessageId: 'rejected-1', dispatchState: 'rejected', @@ -228,12 +252,24 @@ describe('the one row a failed start leaves', () => { reason: STARTUP_TEXT, rejection: { kind: 'providerStartFailed' } } - ] - ])('is not written again beside %s', async (_case, submission) => { - const session = startingSession([submission]) + ]) await settleStructuredAgentSessionChildExit(contextFor(session), ended) expect(rowsWritten(session)).toEqual([]) }) + + // A message queued but neither started for nor waited on is no message the loop charges with this + // start: it starts afresh, so this start's failure has only this row. + it('is written beside a queued message no pass charges with this start', async () => { + const session = startingSession([QUEUED]) + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(rowsWritten(session)).toEqual([ + expect.objectContaining({ + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` } + }) + ]) + }) }) From 9609635a8587546e6ff323dd2dcd0505eb4da105 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:30:59 -0700 Subject: [PATCH 12/26] revert(native-chat): a returned card holds the queue again, and the feed announces once when nothing is owed, as on main --- .../MobileNativeChatQueuedMessages.test.tsx | 1 - ...le-structured-queued-message-cards.test.ts | 16 -- .../mobile-structured-queued-message-cards.ts | 18 +-- ...se-mobile-native-chat-queued-slot.test.tsx | 1 - ...e-structured-agent-session-queued.test.tsx | 1 - .../queued-message-pause.test.ts | 31 +--- .../queued-message-pause.ts | 15 +- ...-agent-session-accept-then-deliver.test.ts | 6 +- ...ed-agent-session-queued-pause-lift.test.ts | 8 +- ...agent-session-turn-completion-feed.test.ts | 141 ------------------ ...ured-agent-session-turn-completion-feed.ts | 61 +------- ...uctured-agent-session-queued-cards.test.ts | 17 --- .../structured-agent-session-queued-cards.ts | 3 +- ...ctured-agent-session-dispatch-rejection.ts | 21 --- ...tured-agent-session-latest-request.test.ts | 24 --- ...structured-agent-session-latest-request.ts | 38 +---- .../structured-agent-session-projection.ts | 12 +- 17 files changed, 28 insertions(+), 386 deletions(-) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 8381a4c2e52e..b2929f1a1a57 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -35,7 +35,6 @@ function card( state: 'waiting', paused: false, needsAttention: false, - holdsQueue: overrides.state === 'returned', caption: null, ...overrides } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 388734200703..44d1e0b9729f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -45,7 +45,6 @@ describe('mobileQueuedMessageCards', () => { state: 'waiting', paused: false, needsAttention: false, - holdsQueue: false, caption: null }) }) @@ -140,21 +139,6 @@ describe('mobileQueuedMessageCards', () => { expect(cards[1]?.needsAttention).toBe(false) }) - it('holds nothing behind a card returned because its agent failed to start', () => { - const drafts = [ - draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('providerStartFailed')) }), - draft({ messageId: 'b', position: 2 }) - ] - const cards = mobileQueuedMessageCards(drafts, [], { pendingPrompt: false }) - expect(cards[0]).toMatchObject({ state: 'returned', needsAttention: true, holdsQueue: false }) - expect(cards[1]?.caption).toBeNull() - expect( - mobileQueueHasResumableCard( - mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, queuePaused: true }) - ) - ).toBe(true) - }) - it('hides a waiting card once its hand-off arrived, never a returned one', () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index d371081b7d75..f3edb13e53c8 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -5,10 +5,7 @@ import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' -import { - dispatchWasWithdrawn, - queuedCardHoldsQueue -} from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, @@ -24,8 +21,6 @@ export type MobileQueuedMessageCard = { paused: boolean /** Returned, or its own send failed: the row leads with an alert. */ needsAttention: boolean - /** Returned for something only the person can resolve: the cards behind it wait. */ - holdsQueue: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ caption: string | null } @@ -69,11 +64,10 @@ const QUEUE_PAUSE_LABELS: Readonly> = { } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any - * returned card that holds the queue. The drain stops at such a card, so cards behind it never - * go. */ + * returned card. The drain stops at a returned card, so cards behind one never go. */ export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { for (const card of cards) { - if (card.holdsQueue) { + if (card.state === 'returned') { return false } if (!card.paused) { @@ -129,7 +123,6 @@ export function mobileQueuedMessageCards( : facts.pendingPrompt ? 'Waiting for your answer' : null - const holdsQueue = queuedCardHoldsQueue(draft) cards.push({ messageId: draft.messageId, text: queuedMessageBodyText(draft.body), @@ -138,10 +131,11 @@ export function mobileQueuedMessageCards( needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), - holdsQueue, caption }) - behindReturned = behindReturned || holdsQueue + if (draft.state === 'returned') { + behindReturned = true + } } return cards } diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx index bb9d93b78382..002a567548f6 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -33,7 +33,6 @@ const CARD = { state: 'waiting' as const, paused: false, needsAttention: false, - holdsQueue: false, caption: null } diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 20cd8a7d6227..277beb17d75a 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -533,7 +533,6 @@ describe('mobile structured queued messages', () => { state: 'waiting', paused: false, needsAttention: false, - holdsQueue: false, caption: null } ]) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 410061e83f76..5989a10e9675 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -27,10 +27,7 @@ import { queuePauseHolding, resumableQueuePause } from './queued-message-pause' -import { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -599,32 +596,6 @@ describe('which cards the pauses in force hold', () => { expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) - it('a card returned because its agent failed to start blocks nothing behind it; any other return does', () => { - const failedStart = agentSessionFailureFact('providerStartFailed') - const returned = (rejection: SubmissionRejectionFact) => - card('returned', 1, { - state: 'returned', - returnedRejection: rejection, - returnedReason: agentSessionFailureWords(rejection, { surface: 'rejection' }).reason - }) - const held = card('held', 2) - const fresh = card('fresh', 6) - - // Each card behind it starts the agent again for itself. - const afterFailedStart = [returned(failedStart), fresh] - expect(nextSendableQueuedCard(pausesOver(afterFailedStart), afterFailedStart)).toBe(fresh) - const pausedAfterFailedStart = [returned(failedStart), held] - expect(resumableQueuePause(pausesOver(pausedAfterFailedStart), pausedAfterFailedStart)).toEqual( - expect.objectContaining({ reason: 'stopped' }) - ) - - const refused = agentSessionFailureFact('queueFull') - const afterRefused = [returned(refused), fresh] - expect(nextSendableQueuedCard(pausesOver(afterRefused), afterRefused)).toBeNull() - const pausedAfterRefused = [returned(refused), held] - expect(resumableQueuePause(pausesOver(pausedAfterRefused), pausedAfterRefused)).toBeNull() - }) - it("a /clear's pause that holds nothing never hides a restart's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index a810c5112243..63dc6823df28 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -11,7 +11,6 @@ // continuation, a launch prompt and the queue's own drain are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' -import { queuedCardHoldsQueue } from '../../../shared/structured-agent-session-dispatch-rejection' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -39,8 +38,6 @@ type QueueCard = { hostInstance: string carriedFrom: string | null queuedAt: AgentJournalCursor | null - returnedReason?: string | null - returnedRejection?: unknown } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { @@ -173,14 +170,14 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card that holds the queue or a held one comes first. The queue never reorders, so a - * newer card never overtakes a held one. The drain's pick and its consume both read this. */ + * returned card or a held one comes first. The queue never reorders, so a newer card never + * overtakes a held one. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (queuedCardHoldsQueue(card) || queuePauseHolding(pauses, card)) { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -191,14 +188,14 @@ export function nextSendableQueuedCard( } /** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned - * card that holds the queue (`queuedCardHoldsQueue`) until the user acts. None otherwise, so its - * header never offers a Resume that sends nothing. */ + * card, which blocks everything after it until the user acts. None otherwise, so its header + * never offers a Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] ): DerivedQueuePause | null { for (const card of cards) { - if (queuedCardHoldsQueue(card)) { + if (card.state === 'returned') { return null } const holding = queuePauseHolding(pauses, card) 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 f7feaa3b7f21..5ef012c18f98 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 @@ -356,7 +356,9 @@ describe('a start the chat needed and did not get', () => { ) }) - it('notifies once for queued messages whose starts all fail alike, each rejected on its own', async () => { + // Messages sent straight to the chat, not queued as cards: each makes its own start, and the + // session announces once, when it owes nothing more. + it('notifies once, for the last, when sent messages whose starts all fail alike are each rejected', async () => { await host.close(SESSION, 'evict') acquire.mockRejectedValue(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] @@ -382,7 +384,7 @@ describe('a start the chat needed and did not get', () => { type: 'completion', completion: expect.objectContaining({ sessionId: SESSION, - turnId: agentJournalSubmissionKey(first), + turnId: agentJournalSubmissionKey(third), outcome: 'failure' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index f7b4a3e6bb66..c5baeed25def 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -354,17 +354,11 @@ describe("a restart's pause", () => { await next.result await rig.settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - // This rig's start for it fails and returns its card, which holds nothing back. - await eventually(async () => - expect((await rig.drafts()).find((draft) => draft.messageId === first)?.state).toBe( - 'returned' - ) - ) - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) // Reopened, that turn is "before this open", yet the pause it ended stays ended: // the lift adopted the rows into this process. await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() + expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) }) }) 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 c9b753439f1c..1c2ff9b9e53b 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 @@ -557,147 +557,6 @@ describe('a request the agent or its start refused', () => { ]) }) - const failedStart = (clientMessageId: string) => - sent(clientMessageId, { - dispatchState: 'rejected', - reason: "Codex couldn't start.", - rejection: { kind: 'startFailed' } - }) - - it('notifies a failed start once, the moment it is final, though a later send is still owed', () => { - const h = harness() - h.listen() - h.observe() - const queued = [userEntry('m1', 1), userEntry('m2', 2)] - h.setJournal(queued, [pending('m1'), pending('m2')]) - h.observe() - h.setJournal(queued, [failedStart('m1'), pending('m2')]) - h.observe() - const accepted = sent('m2', { dispatchState: 'accepted' }) - h.setJournal([...queued, turnItem(turn('t2', 'running'), 3)], [failedStart('m1'), accepted]) - h.observe() - h.setJournal( - [...queued, turnItem(turn('t2', 'completed', 'success'), 3)], - [failedStart('m1'), accepted] - ) - h.observe() - h.observe() - expect(h.outcomes()).toEqual([ - [M1, 'failure'], - ['t2', 'success'] - ]) - }) - - it('notifies a lone failed start exactly once', () => { - const h = harness() - h.listen() - h.observe() - h.setJournal([userEntry('m1', 1)], [pending('m1')]) - h.observe() - h.setJournal([userEntry('m1', 1)], [failedStart('m1')]) - h.observe() - h.observe() - expect(h.outcomes()).toEqual([[M1, 'failure']]) - }) - - // A conversation command is not a request: the session's verdict passes it over, and so does this. - it('notifies nothing for a /compact whose start failed', () => { - const h = afterSuccessfulTurn() - const compact: AgentJournalRenderItem = { - ...userEntry('m2', 3), - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - } - const items = [userEntry('m1', 1), settledTurn, compact] - const accepted = sent('m1', { dispatchState: 'accepted' }) - h.setJournal(items, [accepted, pending('m2')]) - h.observe() - h.setJournal(items, [accepted, failedStart('m2')]) - h.observe() - h.observe() - - expect(h.outcomes()).toEqual([['t1', 'success']]) - }) - - /** A failed start sent at `submittedAt` whose failure landed at `resolvedAt`. */ - const failedStartAt = (clientMessageId: string, submittedAt: number, resolvedAt: number) => ({ - ...failedStart(clientMessageId), - submittedAt, - resolvedAt - }) - - // Each queued message makes its own start; when the agent's CLI is missing they all fail alike. - it('notifies a run of failed starts once: each later one was already waiting when the first failed', () => { - const h = harness() - h.listen() - h.observe() - const items = [userEntry('m1', 1), userEntry('m2', 2), userEntry('m3', 3)] - h.setJournal(items, [pending('m1'), pending('m2'), pending('m3')]) - h.observe() - h.setJournal(items, [failedStartAt('m1', 1, 10), pending('m2'), pending('m3')]) - h.observe() - h.setJournal(items, [failedStartAt('m1', 1, 10), failedStartAt('m2', 2, 11), pending('m3')]) - h.observe() - h.setJournal(items, [ - failedStartAt('m1', 1, 10), - failedStartAt('m2', 2, 11), - failedStartAt('m3', 3, 12) - ]) - h.observe() - expect(h.outcomes()).toEqual([[M1, 'failure']]) - }) - - // Nothing ran in between, and the first failure was hours earlier: it is news again. - it('notifies a failed start sent after the earlier failure landed, with no turn between', () => { - const h = harness() - h.listen() - h.observe() - const first = [userEntry('m1', 1)] - h.setJournal(first, [failedStartAt('m1', 1, 10)]) - h.observe() - const later = [...first, userEntry('m2', 2)] - h.setJournal(later, [failedStartAt('m1', 1, 10), pending('m2')]) - h.observe() - h.setJournal(later, [failedStartAt('m1', 1, 10), failedStartAt('m2', 3_600_000, 3_600_010)]) - h.observe() - expect(h.outcomes()).toEqual([ - [M1, 'failure'], - [M2, 'failure'] - ]) - }) - - it.each([ - ['sent after the first failure landed: notified', 30, [M1, M3]], - ['already waiting when the first failure landed: part of its run', 3, [M1]] - ] as const)( - 'ignores a content refusal between failed starts: a failure %s', - (_case, submittedAt, notified) => { - const h = harness() - h.listen() - h.observe() - const items = [userEntry('m1', 1), userEntry('m2', 2), userEntry('m3', 3)] - const contentRefused = { ...refused('m2'), submittedAt: 15, resolvedAt: 20 } - h.setJournal(items, [failedStartAt('m1', 1, 10), contentRefused, pending('m3')]) - h.observe() - h.setJournal(items, [ - failedStartAt('m1', 1, 10), - contentRefused, - failedStartAt('m3', submittedAt, 40) - ]) - h.observe() - expect( - h - .outcomes() - .filter(([id]) => id !== M2) - .map(([id]) => id) - ).toEqual(notified) - } - ) - it('does not wait on a send left pending at an older fence', () => { const h = harness() h.listen() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 039fff8ba704..84339a7587d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -16,10 +16,7 @@ import type { AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' -import type { - StructuredAgentSessionFailedStart, - StructuredAgentSessionLatestRequest -} from '../../../shared/structured-agent-session-latest-request' +import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' @@ -47,30 +44,9 @@ export type StructuredAgentSessionTurnCompletionFeedDeps = { type RequestMark = Pick -/** Per-session baseline. `settled` is the last settled request this feed has accounted for, and - * `failedStarts` the sends whose start had failed as of the last observation; absence of the whole - * entry — not a null field — is what makes the first observation silent. */ -type SessionBaseline = CompletionFeedCursor & { - settled: RequestMark | null - failedStarts: ReadonlySet -} - -/** Whether a failed start is part of an earlier one's run: it was already waiting when that one's - * failure landed, as each queued message is when the agent's CLI is missing. A failure sent after - * every earlier one landed starts a run of its own. */ -function inEarlierRun( - failed: StructuredAgentSessionFailedStart, - index: number, - all: readonly StructuredAgentSessionFailedStart[] -): boolean { - return all.some( - (earlier, earlierIndex) => - earlierIndex !== index && - (earlier.resolvedAt < failed.resolvedAt || - (earlier.resolvedAt === failed.resolvedAt && earlierIndex < index)) && - failed.submittedAt <= earlier.resolvedAt - ) -} +/** Per-session baseline. `settled` is the last settled request this feed has accounted for; + * absence of the whole entry — not a null field — is what makes the first observation silent. */ +type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { return request && request.turnState !== 'running' ? { kind: request.kind, id: request.id } : null @@ -124,10 +100,9 @@ export class StructuredAgentSessionTurnCompletionFeed { const cursor = (journal ?? session.journal).cursor() const request = state.latestRequest const baseline = this.baselines.get(sessionId) - const failedStarts = new Set(state.failedStarts.map((failed) => failed.id)) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { ...cursor, settled: settledMark(request), failedStarts }) + this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -137,18 +112,9 @@ export class StructuredAgentSessionTurnCompletionFeed { baseline.epoch = cursor.epoch baseline.sequence = cursor.sequence baseline.settled = settledMark(request) - baseline.failedStarts = failedStarts return } baseline.sequence = cursor.sequence - // A send whose start failed is announced the moment it is final, whatever else the session - // still owes, and never again as the latest request below. A run of them is announced once. - state.failedStarts.forEach((failed, index, all) => { - if (!baseline.failedStarts.has(failed.id) && !inEarlierRun(failed, index, all)) { - this.announce(session, sessionId, state, failed.id, 'failure') - } - }) - baseline.failedStarts = failedStarts if (request?.turnState === 'running') { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. @@ -167,31 +133,18 @@ export class StructuredAgentSessionTurnCompletionFeed { return } baseline.settled = settledMark(request) - if (request.kind === 'refused-send' && failedStarts.has(request.id)) { - return - } // ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no // event. Inferring success here is the one mistake that would light the dot on a failure. if (!request.outcome) { return } - this.announce(session, sessionId, state, request.id, request.outcome) - } - - private announce( - session: CompletionFeedSession, - sessionId: string, - state: StructuredAgentSessionStatusState, - turnId: string, - outcome: AgentSessionTurnCompletion['outcome'] - ): void { this.broadcast({ type: 'completion', completion: { scope: session.params.location, sessionId, - turnId, - outcome, + turnId: request.id, + outcome: request.outcome, completedAt: this.deps.now(), // Stated here, not joined from the status stream: remote clients receive the two unordered. ...(state.summary.status === 'attention' ? { awaitingUser: true } : {}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 99fc9f019650..995d80247699 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -92,23 +92,6 @@ describe('queued message cards', () => { expect(cards[1]?.hold).toBe('behind-returned') }) - it('a card returned because its agent failed to start holds nothing behind it', () => { - const cards = projectQueuedMessageCards( - [ - draft('failed', 1, { - state: 'returned', - returnedReason: "Codex couldn't restart. Send your message to try again.", - returnedRejection: { kind: 'restartFailed' } - }), - draft('behind', 2) - ], - [], - IDLE - ) - expect(cards[0]?.hold).toBe('returned') - expect(cards[1]?.hold).toBe('turn') - }) - it('a paused draft says so, carrying the host marker for the caption to localize', () => { const cards = projectQueuedMessageCards( [draft('a', 1, { paused: true, pausedReason: 'send_failed' })], diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index 75b6a82e4677..e9d1f394b9af 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -5,7 +5,6 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-ses import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' -import { queuedCardHoldsQueue } from '../../../../shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionEntryAsksToQueue, type StructuredAgentSessionQueueDelivery @@ -76,7 +75,7 @@ export function projectQueuedMessageCards( : session.hasPendingPrompt ? 'awaiting-answer' : 'turn' - behindReturned = behindReturned || queuedCardHoldsQueue(message) + behindReturned = behindReturned || message.state === 'returned' return { messageId: message.messageId, position: message.position, diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 1c2b2275fbcb..3e868ddc6936 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -188,27 +188,6 @@ export function isFailedStartOrHostFault( ) } -/** - * Whether a queued card holds every card behind it until the person acts: one returned for - * something about it that only they can resolve — the provider or Orca refused its content or - * attachments, its command did not run, the queue was full, Orca could not hand it over or faulted. - * One returned because its agent failed to start holds nothing: each card behind it starts the - * agent again for itself. - */ -export function queuedCardHoldsQueue(card: { - state: string - returnedReason?: string | null - returnedRejection?: unknown -}): boolean { - return ( - card.state === 'returned' && - !isFailedStartRejection({ - reason: card.returnedReason ?? null, - rejection: card.returnedRejection - }) - ) -} - /** 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 diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 7e9540fa5d1e..6bb7f117bf80 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -260,30 +260,6 @@ describe('the latest request and its verdict', () => { statusStartedAt: 20 }) }) - - // A failed start is placed where its rejection was written: after a turn a Stop could not yet - // prove ended, whose interrupted end lands in place later. - it('reports a failed start written while an older turn was still running, after that turn ends', () => { - const items = [ - userEntry('m1', 1), - turn('t1', 2, { state: 'interrupted', completedAt: 40 }), - userEntry('m2', 3) - ] - const submissions = [ - sent('m1', { dispatchState: 'accepted' }), - sent('m2', { - dispatchState: 'rejected', - reason: "Couldn't stop Codex from before. Send your message again to try once more.", - rejection: { kind: 'restartFailed' }, - resolvedAt: 30 - }) - ] - expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ - kind: 'refused-send', - id: agentJournalSubmissionKey('m2'), - outcome: 'failure' - }) - }) }) describe('the sidebar verdict agrees with the rejection classifier', () => { diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 5cacb40f5880..35856a21552d 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -17,10 +17,7 @@ import type { 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, - isFailedStartOrHostFault -} from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' import { isStructuredAgentSessionCommandEntry, @@ -90,39 +87,6 @@ export function latestStructuredAgentSessionRequest( return null } -/** A send whose start failed: its item key, when it was sent, and when its failure landed. */ -export type StructuredAgentSessionFailedStart = { - id: string - submittedAt: number - resolvedAt: number -} - -/** The sends whose start failed, or that Orca's own fault kept from their agent, in journal order: - * each one's failure is final the moment it is written, whatever else the session still owes. A - * conversation command is not a request. */ -export function structuredAgentSessionFailedStarts( - items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[] -): StructuredAgentSessionFailedStart[] { - const commands = new Set( - items.flatMap((item) => (isStructuredAgentSessionCommandEntry(item.body) ? [item.itemId] : [])) - ) - return submissions.flatMap((submission) => { - const id = agentJournalSubmissionKey(submission.clientMessageId) - return submission.dispatchState === 'rejected' && - !commands.has(id) && - isFailedStartOrHostFault(submission) - ? [ - { - id, - submittedAt: submission.submittedAt, - resolvedAt: submission.resolvedAt ?? submission.submittedAt - } - ] - : [] - }) -} - /** 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 diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 67541fe53697..0fbb057f66e3 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -25,8 +25,6 @@ import { latestStructuredAgentSessionAssistantMessage, latestStructuredAgentSessionPrompt, latestStructuredAgentSessionRequest, - structuredAgentSessionFailedStarts, - type StructuredAgentSessionFailedStart, type StructuredAgentSessionLatestRequest } from './structured-agent-session-latest-request' import { @@ -248,16 +246,9 @@ export function projectStructuredAgentSessionStatusState( latestRequest: StructuredAgentSessionLatestRequest | null /** Whether a running turn or an unanswered send is still owed, even beneath a pending prompt. */ owesWork: boolean - /** The sends whose start failed; see `structuredAgentSessionFailedStarts`. */ - failedStarts: StructuredAgentSessionFailedStart[] } { if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) { - return { - summary: { status: null, latestPrompt: '' }, - latestRequest: null, - owesWork: false, - failedStarts: [] - } + return { summary: { status: null, latestPrompt: '' }, latestRequest: null, owesWork: false } } const status = projectStructuredAgentSessionStatus(items, submissions, currentFence) const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null @@ -290,7 +281,6 @@ export function projectStructuredAgentSessionStatusState( return { latestRequest, owesWork: status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence), - failedStarts: structuredAgentSessionFailedStarts(items, submissions), summary: { status, latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)), From dee1631cc5cc56b26c9df12bb8cc7ed845de2ea1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:44:20 -0700 Subject: [PATCH 13/26] chore(native-chat): comments match the loop's one waiting map and the exit's start row; the failed-start check is file-local --- .../structured-agent-session-delivery-loop.ts | 9 +++++---- ...ured-agent-session-start-failure-settlement.ts | 15 ++++++++------- ...structured-agent-session-dispatch-rejection.ts | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 2bf90a2d5883..5773c9e964da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -5,10 +5,11 @@ // for a session exactly while a message is queued there — accepted, not yet handed over — and no // child is running a conversation command: a command's turn takes no input, and the commit that // ends it wakes the loop again. Every step re-reads the journal and the conversation's child -// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop -// that arrives while a start holds the queue withdraw the queued messages before the handover that -// would have written them. Stop and the conversation's close are the only other writers of a -// queued message: a child's exit only ends the child, and this loop reads why. +// record to decide; its only state is which child a running pass waits on, cleared with the pass. +// Each step is its own serialized task. That is what lets a Stop that arrives while a start holds +// the queue withdraw the queued messages before the handover that would have written them. Stop +// and the conversation's close are the only other writers of a queued message: a child's exit only +// ends the child, and this loop reads why. // // A start that fails is recorded on the one message it was for, and the messages behind it each // get their own start. Every write names a message fixed when its pass chose it, never the queue's diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index e432ac764f96..79fd2d534648 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -1,8 +1,9 @@ -// The writer of a failed start's record: the message it failed, rejected, and the start's one row, -// in one write, so a client that hides rejected messages still sees why. The delivery loop writes it -// for the queued message a start was for, the handover for the message it was handing over; the -// exit writes the row for a start no message carries. Each message state has one writer, and -// `rejected` is terminal, so no message is failed twice. +// The writer of a failed start's record: the message it failed, rejected, and its row keyed by that +// message, in one write, so a client that hides rejected messages still sees why. The delivery loop +// writes it for the queued message a start was for, the handover for the message it was handing +// over; the exit writes one row keyed by the start for the handed messages it rejected, or for a +// start no message carries. Each message state has one writer, and `rejected` is terminal, so no +// message is failed twice. import type { AgentJournalDispatchRejection, @@ -30,8 +31,8 @@ type StartFailureJournal = Pick< 'submissions' | 'itemBody' | 'appendLifecycleBatch' > -/** The one row a failed start leaves in the chat: an error row keyed by the start, repeating the - * sentence its message carries, so the reason outlives any client that hides that message. */ +/** A failed start's row in the chat: an error row keyed by its message (or by the start, from the + * exit), repeating the message's sentence, so the reason outlives any client that hides it. */ export function structuredAgentSessionStartFailureRow( startKey: string, words: AgentJournalDispatchRejection diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 3e868ddc6936..bd8436b638ef 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -171,7 +171,7 @@ export function classifyDispatchRejection( /** Rejected because the agent it waited on never took it: what a failed start writes, whatever * the person may have to do first. Orca stopping a start that hung is one, and so is the agent * ending before it was handed the message. */ -export function isFailedStartRejection( +function isFailedStartRejection( submission: Pick & { rejection?: unknown } ): boolean { const { category, kind } = classifyDispatchRejection(submission) From 276c4129156259ad9de105ab82eebe0848632278 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:59:13 -0700 Subject: [PATCH 14/26] feat(native-chat): one row speaks for a run of starts that fail alike, until a turn is delivered --- .../journal-lifecycle-batch-appender.ts | 6 +- .../journal-queued-rejection-batch.test.ts | 23 ++- .../journal-start-failure-run.ts | 44 ++++ .../agent-session-journal/journal-store.ts | 6 + ...-agent-session-accept-then-deliver.test.ts | 10 +- ...gent-session-dead-generation-settlement.ts | 29 +-- ...-agent-session-start-failure-settlement.ts | 44 +++- ...agent-session-start-failure-writer.test.ts | 193 ++++++++++++++++-- ...session-delivery-notices.start-row.test.ts | 27 +++ ...ructured-agent-session-delivery-notices.ts | 19 +- ...tured-agent-session-start-failure-facts.ts | 2 +- src/shared/agent-session-failure.ts | 19 ++ 12 files changed, 355 insertions(+), 67 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-start-failure-run.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 3886b1cac95f..e0e09b10d93a 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -31,11 +31,13 @@ export class JournalLifecycleBatchAppender { const { rejects } = input if (rejects) { // Planned on the lane: the send if it is still `which` then, and this batch unless it already - // landed. With the send settled meanwhile it failed no one, so nothing is written. + // landed or is empty. With the send settled meanwhile it failed no one, so nothing is written. return this.deps .enqueueRows(() => { const rejections = journalBatchRejectionRowBuilders(this.deps.state, input.fence, rejects) - return rejections.length === 0 || this.wasApplied(input.settlementId) + return rejections.length === 0 || + input.mutations.length === 0 || + this.wasApplied(input.settlementId) ? rejections : [ ...rejections, diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts index b6f5ccdd5230..3bb187fc9ca3 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -16,6 +16,7 @@ import { } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from './journal-store' +import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' import { closeTestJournalHostDatabases, createTrackedJournalOpener @@ -101,15 +102,29 @@ it('writes neither when the row cannot be written', async () => { const journal = await openWithQueued('first') const before = journal.cursor().sequence - // An empty batch breaks the row's bound, so the transaction rolls back as a whole. - await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( - 'journal_lifecycle_batch_mutation_bound_exceeded' - ) + // A batch over the row's bound fails to build, so the transaction rolls back as a whole. + await expect( + journal.appendLifecycleBatch(startFailureBatch(MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS + 1)) + ).rejects.toThrow('journal_lifecycle_batch_mutation_bound_exceeded') expect(journal.cursor().sequence).toBe(before) expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) }) +// A start failing as its run's row already says rejects its message and writes no row. +it('writes only the rejection when the batch carries no row', async () => { + const journal = await openWithQueued('first', 'second') + const before = journal.cursor().sequence + + await journal.appendLifecycleBatch(startFailureBatch(0)) + + expect(journal.cursor().sequence).toBe(before + 1) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['rejected', 'pending']) + expect(journal.snapshot().items.map((item) => item.itemId)).not.toContain( + 'orca:start-failure%3Afirst' + ) +}) + // A Stop that reaches the lane first takes the message back; the failed start then failed no one. it('writes nothing when a Stop withdrew the message first', async () => { const journal = await openWithQueued('first') diff --git a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts new file mode 100644 index 000000000000..26af2ccc95a8 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts @@ -0,0 +1,44 @@ +// One row speaks for a run of starts that fail alike. Until a turn is delivered, a start that fails +// as the chat's latest start-failure row says writes no row of its own, and its rejected message is +// read under that row. Derived from the fold at each write; nothing marks the run. + +import { + readAgentSessionFailureFact, + sameAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' +import type { JournalReducerState } from './journal-reducer' + +/** Whether the latest start-failure row states this failure, with no turn delivered since. */ +export function journalStartFailureAlreadyStated( + state: Pick, + failure: AgentSessionFailureFact +): boolean { + let latest: AgentJournalRenderItem | undefined + for (const item of state.items.values()) { + if ( + isStructuredAgentSessionStartFailureRow(item.itemId) && + (latest === undefined || item.sequence > latest.sequence) + ) { + latest = item + } + } + const stated = + latest?.body.kind === 'status' ? readAgentSessionFailureFact(latest.body.failure) : undefined + if ( + latest === undefined || + stated === undefined || + !sameAgentSessionFailureFact(stated, failure) + ) { + return false + } + // A send accepted after the row ends its run: the next failure is news. + for (const receipt of state.receipts.values()) { + if (receipt.cursor.sequence > latest.sequence) { + return false + } + } + return true +} diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 626b77cf6140..ce500a3552fc 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,6 +1,7 @@ // Append-only journal store for one agent session. It owns the chat's fold and write queue, and no // connection: every statement goes through the host's one journal database. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { @@ -67,6 +68,7 @@ import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-sc import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' +import { journalStartFailureAlreadyStated } from './journal-start-failure-run' import { createJournalStoreCollaborators } from './journal-store-collaborators' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' @@ -260,6 +262,10 @@ export class AgentSessionJournal { canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) + /** Whether a start failing so would repeat the row of its run (`journal-start-failure-run`). */ + startFailureAlreadyStated = (failure: AgentSessionFailureFact): boolean => + journalStartFailureAlreadyStated(this.state, failure) + /** Reads the fold with every write issued before this call committed, and none issued after: at * once unless writes still wait behind an owed import or a running write. */ readInOrder(read: () => T): Promise { 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 5ef012c18f98..a47a8b3529ca 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 @@ -376,8 +376,8 @@ describe('a start the chat needed and did not get', () => { }) } expect(acquire.mock.calls.length).toBeGreaterThanOrEqual(4) - // One row per failed start: each message's own. - expect(await errorRows()).toHaveLength(3) + // One row for the run: the starts all failed alike, with nothing delivered between. + expect(await errorRows()).toHaveLength(1) await host.flushAllStreamedEvents() expect(completions).toEqual([ { @@ -601,7 +601,7 @@ describe('a child that exits before its message is handed over', () => { }) describe('a start whose failure the delivery loop settles before the exit is published', () => { - it('rejects each message in the words of its own start, one row each, and the exit adds none', async () => { + it('rejects each message in the words of its own start, one row for the run, and the exit adds none', async () => { // The adapter's startup answer and its later exit event word the same start differently. const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) adapterExtras = { awaitStarted } @@ -642,8 +642,8 @@ describe('a start whose failure the delivery loop settles before the exit is pub rejection: words.rejection }) } - // One row per failed start; the exit adds none. - expect(await errorRows()).toEqual([words.reason, words.reason]) + // Both starts failed alike with nothing delivered between: one row; the exit adds none. + expect(await errorRows()).toEqual([words.reason]) }) }) 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 023b45e4acf3..2edadb1fba09 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 @@ -24,10 +24,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' -import { - rejectedAsFailedStartAt, - structuredAgentSessionStartFailureRow -} from './structured-agent-session-start-failure-settlement' +import { exitStartFailureRow } from './structured-agent-session-start-failure-settlement' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { endedByPersonsStop, @@ -62,6 +59,7 @@ export type DeadGenerationJournal = Omit & { markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] + startFailureAlreadyStated?: AgentSessionJournal['startFailureAlreadyStated'] } export type StructuredAgentSessionUnfinishedWork = { @@ -156,20 +154,15 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { - const startKey = input.exitedDuringStartup.generation ?? input.settlementId - // The messages handed to this child take this row. With none, a message still waiting on the - // start is the delivery loop's to record with its own row, and one already rejected as this - // start's failure has its writer's; anything else — a command, goal or rewind start, or one - // no message is charged with — has only this row to say why. - const recordedElsewhere = - settled.length === 0 && - (input.exitedDuringStartup.chargedToQueued === true || - (input.journal.submissions?.() ?? []).some((submission) => - rejectedAsFailedStartAt(submission, input.fence) - )) - if (!recordedElsewhere) { - mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) - } + mutations.push( + ...exitStartFailureRow(input.journal, { + startKey: input.exitedDuringStartup.generation ?? input.settlementId, + fence: input.fence, + rejectedAny: settled.length > 0, + chargedToQueued: input.exitedDuringStartup.chargedToQueued === true, + words: startupFailure + }) + ) } else if (showUnexpectedExitOutcome) { // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index 79fd2d534648..6060e41b5c39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -3,7 +3,8 @@ // writes it for the queued message a start was for, the handover for the message it was handing // over; the exit writes one row keyed by the start for the handed messages it rejected, or for a // start no message carries. Each message state has one writer, and `rejected` is terminal, so no -// message is failed twice. +// message is failed twice. A start failing as its run's row already says writes no row; its message +// is read under that one. import type { AgentJournalDispatchRejection, @@ -28,7 +29,7 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se type StartFailureJournal = Pick< AgentSessionJournal, - 'submissions' | 'itemBody' | 'appendLifecycleBatch' + 'submissions' | 'itemBody' | 'appendLifecycleBatch' | 'startFailureAlreadyStated' > /** A failed start's row in the chat: an error row keyed by its message (or by the start, from the @@ -47,10 +48,10 @@ export function structuredAgentSessionStartFailureRow( } /** Rejects a message its start failed and writes that start's row with it, keyed by the message, in - * one append. Writes nothing once `which` no longer holds for it: Stop withdrew it, or another - * writer settled it. */ + * one append; no row when the run's row already states it. Writes nothing once `which` no longer + * holds for it: Stop withdrew it, or another writer settled it. */ export async function rejectWithStartFailureRow( - journal: Pick, + journal: Pick, input: { clientMessageId: string words: AgentJournalDispatchRejection @@ -63,7 +64,9 @@ export async function rejectWithStartFailureRow( settlementId: `start-failure:${clientMessageId}`, fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(clientMessageId, words)], + mutations: journal.startFailureAlreadyStated(words.rejection) + ? [] + : [structuredAgentSessionStartFailureRow(clientMessageId, words)], rejects: { clientMessageId, ...words, which } }) } @@ -121,6 +124,35 @@ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence ) } +/** The row an exit during startup writes for its start. The messages it rejected take this row. + * With none, a message still waiting on the start is the delivery loop's to record with its own + * row, and one already rejected as this start's failure has its writer's; anything else — a + * command, goal or rewind start, or one no message is charged with — has only this row to say why. + * None when its run's row already says it. */ +export function exitStartFailureRow( + journal: { + submissions?: () => FailedStartSubmission[] + startFailureAlreadyStated?: AgentSessionJournal['startFailureAlreadyStated'] + }, + exit: { + startKey: string + fence: number + rejectedAny: boolean + chargedToQueued: boolean + words: AgentJournalDispatchRejection + } +): JournalLifecycleMutationInput[] { + const recordedElsewhere = + !exit.rejectedAny && + (exit.chargedToQueued || + (journal.submissions?.() ?? []).some((submission) => + rejectedAsFailedStartAt(submission, exit.fence) + )) + return recordedElsewhere || journal.startFailureAlreadyStated?.(exit.words.rejection) === true + ? [] + : [structuredAgentSessionStartFailureRow(exit.startKey, exit.words)] +} + export function oldestQueuedSubmission( journal: Pick ): AgentJournalSubmission | undefined { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index bd9055592e7b..26e50ae3eec0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -15,12 +15,19 @@ import { import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionSubscribeEvent, + AgentSessionTurnCompletionEvent +} from '../../../shared/agent-session-wire' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { isStructuredAgentSessionStartFailureRow, structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { @@ -45,6 +52,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } +const SETUP_FAILURE = agentSessionFailureFact('managedAccountUnsupported') const SETUP_ROW = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')) const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' // A situation that could clear on its own, seen after the child was spawned. @@ -82,7 +90,7 @@ let dispatch = vi.fn() let closeSession = vi.fn>() let frames: AgentSessionSubscribeEvent[] = [] -function exitBeforeProof(): Promise { +function exitBeforeProof(failure?: SubmissionRejectionFact): Promise { return host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -90,7 +98,8 @@ function exitBeforeProof(): Promise { acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, cause: 'unexpected-exit', - startupUnproven: true + startupUnproven: true, + ...(failure ? { failure } : {}) }) } @@ -246,8 +255,9 @@ beforeEach(async () => { await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - // The first child (generation-1) is lost at setup; the first send starts generation-2. - await exitBeforeProof() + // The first child (generation-1) is lost at setup; the first send starts generation-2. Its row + // states another failure, so it never speaks for a test's own failed start. + await exitBeforeProof(SETUP_FAILURE) await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) }) @@ -458,14 +468,15 @@ describe('a start that fails while its child exits', () => { await eventually(async () => expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) ) - expect(await submission(handed)).toMatchObject({ - dispatchState: 'rejected', - rejection: PROVIDER_START_FAILED - }) - // One row each: the exit's for the handed message, the loop's for the queued one. + for (const id of [handed, queued]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + } + // The exit's row for the handed message; the queued one failed alike, so it reads under it. expect(await startRows()).toEqual([ - agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')), - rowFor(queued) + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) ]) }) @@ -494,3 +505,159 @@ describe('a start that fails while its child exits', () => { ]) }) }) + +// One row speaks for a run of starts that fail alike, until a turn is delivered. Each message still +// makes its own start and is rejected on its own; the run's row says why for all of them. +describe('a run of starts that fail alike', () => { + const accepted: StructuredAgentSessionAdapter['dispatch'] = async () => ({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: `turn-${dispatch.mock.calls.length}`, + ordinal: dispatch.mock.calls.length + } + }) + + const NOT_SIGNED_IN = agentSessionFailureFact('notSignedIn') + + it('writes one row when every start fails not signed in, each message making its own attempt', async () => { + const completions: AgentSessionTurnCompletionEvent[] = [] + host.subscribeTurnCompletions({ id: 'dot', emit: (event) => completions.push(event) }) + const first = await sendQueued('first') + const second = await send('second') + const third = await send('third') + awaitStarted.mockImplementation(async () => NOT_SIGNED_IN) + + settleStart(NOT_SIGNED_IN) + + await eventually(async () => + expect(await submission(third)).toMatchObject({ dispatchState: 'rejected' }) + ) + for (const id of [first, second, third]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + rejection: NOT_SIGNED_IN + }) + } + // Setup's child was generation-1; each message made its own start. + expect(generation).toBe(4) + expect(await startRows()).toEqual([rowFor(first)]) + await host.flushStreamedEvents(SESSION) + expect(completions).toHaveLength(1) + // A client that hides rejected messages reads the run's one row, as before. + const snapshot = await host.journalSnapshot(SESSION) + const row = snapshot.items.find((item) => item.itemId === rowFor(first))?.body + const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: false + }) + const texts = drawn.flatMap((message) => + message.blocks.flatMap((block) => ('text' in block ? [block.text] : [])) + ) + expect(row?.kind === 'status' ? texts.filter((text) => text === row.text) : []).toHaveLength(1) + for (const id of [first, second, third]) { + expect(drawn.map((message) => message.id)).not.toContain(agentJournalSubmissionKey(id)) + } + }) + + it('writes one row when a start fails once and the messages behind it are delivered', async () => { + dispatch.mockImplementation(accepted) + const first = await sendQueued('first') + const second = await send('second') + const third = await send('third') + + await exitBeforeProof() + awaitStarted.mockImplementation(async () => undefined) + settleStart(undefined) + + await eventually(() => expect(dispatched()).toEqual([second, third])) + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + for (const id of [second, third]) { + expect(await submission(id)).toMatchObject({ dispatchState: 'accepted' }) + } + expect(await startRows()).toEqual([rowFor(first)]) + }) + + it('writes a row for each failure when the starts fail differently', async () => { + const first = await sendQueued('first') + const second = await send('second') + awaitStarted.mockImplementation(async () => TRANSIENT) + + settleStart(DISPATCH_FAULT) + + await eventually(async () => + expect(await submission(second)).toMatchObject({ + dispatchState: 'rejected', + ...TRANSIENT_WORDS + }) + ) + expect(await startRows()).toEqual([rowFor(first), rowFor(second)]) + }) + + it('writes a row again for a failure alike once a turn was delivered after the last row', async () => { + dispatch.mockImplementation(accepted) + const first = await sendQueued('first') + awaitStarted.mockImplementationOnce(async () => undefined) + settleStart(DISPATCH_FAULT) + await eventually(async () => + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + ) + const delivered = await send('delivered') + await eventually(async () => + expect(await submission(delivered)).toMatchObject({ dispatchState: 'accepted' }) + ) + // The delivered turn's child proved its start and later ends, so the next message makes its own. + const child = { + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}` + } + await host.handleAdapterEvent({ + type: 'started', + ...child, + reportedOptions: { model: 'gpt-5' }, + restoreSkippedOptions: [] + }) + await host.handleAdapterEvent({ + type: 'ended', + ...child, + reason: 'codex app-server exited', + cause: 'unexpected-exit' + }) + awaitStarted.mockImplementation(async () => DISPATCH_FAULT) + + const last = await send('last') + + await eventually(async () => + expect(await submission(last)).toMatchObject({ dispatchState: 'rejected', ...DISPATCH_WORDS }) + ) + expect(await startRows()).toEqual([rowFor(first), rowFor(last)]) + }) + + it("writes no row for an exit that fails alike its run's row, though it rejected the message", async () => { + awaitStarted.mockImplementation(async () => undefined) + const first = await send('first') + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await exitBeforeProof() + await eventually(async () => + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + ) + const second = await send('second') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + + await exitBeforeProof() + + await eventually(async () => + expect(await submission(second)).toMatchObject({ + dispatchState: 'rejected', + rejection: PROVIDER_START_FAILED + }) + ) + expect(await startRows()).toEqual([ + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts index 122af2a0269e..787405d8a7b1 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts @@ -158,6 +158,33 @@ describe('a message rejected by a start whose row already says why', () => { ).toEqual({ [key('first')]: NOT_SENT, [key('second')]: NOT_SENT }) }) + // This host: a run of starts that fail alike writes only its first message's row. + it('hushes the messages of a run under its one row, and not one that failed otherwise', () => { + expect( + noticesFor( + [ + ['first', startFailed], + ['second', startFailed], + ['third', startFailed], + ['other', otherRefusal] + ], + [ + messageAt('first', 1), + row('first', startFailed, 2), + messageAt('second', 3), + messageAt('third', 4), + messageAt('other', 5) + ] + ) + ).toEqual({ + [key('first')]: NOT_SENT, + [key('second')]: NOT_SENT, + [key('third')]: NOT_SENT, + [key('other')]: + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + }) + }) + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { const stated = { itemId: rowKey('first'), fact: startFailed } expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ 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 808ae34bc84c..c86b5218fd5b 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 @@ -19,6 +19,7 @@ import { readAgentSessionFailureFact, readWholeAgentSessionFailureFact, + sameAgentSessionFailureFact, type AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { @@ -82,24 +83,6 @@ export function structuredAgentSessionStartFailureFacts( return stated } -/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ -export function sameAgentSessionFailureFact( - a: AgentSessionFailureFact, - b: AgentSessionFailureFact -): boolean { - return ( - a.kind === b.kind && - a.detail?.text === b.detail?.text && - a.detail?.audience === b.detail?.audience && - a.refusal?.code === b.refusal?.code && - a.refusal?.details?.reason === b.refusal?.details?.reason && - a.attachment?.reason === b.attachment?.reason && - a.attachment?.limit === b.attachment?.limit && - a.retry?.error === b.retry?.error && - a.retry?.status === b.retry?.status - ) -} - /** Whether one of these start-failure rows already states this failure. Matching is identity, not * wording: what this build can read is enough. */ export function agentSessionFailureStatedByStartRow( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts index 166e9abdfbe1..080a07bfa515 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef } from 'react' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { sameAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { - sameAgentSessionFailureFact, structuredAgentSessionStartFailureFacts, type StatedStartFailure } from './structured-agent-session-delivery-notices' diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a3c97850a1ea..864432a7fe13 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -251,6 +251,25 @@ export function readWholeAgentSessionFailureFact( return fact && structuralValuesEqualIgnoringUndefined(fact, value) ? fact : undefined } +/** Whether two facts are one failure, compared by their fields, never their words: a start's row + * and the messages it rejected share one, and the host writes one row for a run of them. */ +export function sameAgentSessionFailureFact( + a: AgentSessionFailureFact, + b: AgentSessionFailureFact +): boolean { + return ( + a.kind === b.kind && + a.detail?.text === b.detail?.text && + a.detail?.audience === b.detail?.audience && + a.refusal?.code === b.refusal?.code && + a.refusal?.details?.reason === b.refusal?.details?.reason && + a.attachment?.reason === b.attachment?.reason && + a.attachment?.limit === b.attachment?.limit && + a.retry?.error === b.retry?.error && + a.retry?.status === b.retry?.status + ) +} + /** The provider-authored diagnostic an error carries, set only where it was composed. Follows the * `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */ export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined { From 9d8e44b8b791f11144752e2277cec451af5140e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:11:51 -0700 Subject: [PATCH 15/26] test(native-chat): a failed start that repeats its run's failure adds no row, in the tests that counted one per attempt --- ...gent-session-provider-child-record.test.ts | 12 +++---- ...red-agent-session-send-preparation.test.ts | 4 +-- ...session-send-restarts-failed-start.test.ts | 13 ++++---- ...sion-view-start-after-failed-start.test.ts | 8 ++--- ...e-structured-send-held-for-startup.test.ts | 6 ++-- ...-send-restart-dies-before-dispatch.test.ts | 33 ++++++++----------- 6 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index d0c7f254040f..1e0f6e344584 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -423,10 +423,8 @@ describe('a published child that dies while it proves its start', () => { // The second goes on to a start of its own, which fails the same way. await rejected(second) - // One row per failed start, keyed by the message it was for. - expect((await statusRows()).map((row) => row.itemId)).toEqual( - [first, second].map((id) => startRowKey(id)) - ) + // One row for the run, keyed by the message its first failed start was for. + expect((await statusRows()).map((row) => row.itemId)).toEqual([startRowKey(first)]) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -454,7 +452,7 @@ describe('a start another operation made that dies while a sent message waits on // The exit lands between the message's accept and its first step: no pass waits on that start, // and it was for no message, so its exit's row is the only place its failure is said. - it("is not the message's: its exit's row says why, and the message's own start says why it failed (R2)", async () => { + it("is not the message's: its exit's row says why, and the message's own start failed alike (R2)", async () => { adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) @@ -486,9 +484,9 @@ describe('a start another operation made that dies while a sent message waits on // The setup's child, the operation's, and the message's own. expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).not.toHaveBeenCalled() + // Its start failed alike, so the exit's row says why for it too. expect((await statusRows()).map((row) => row.itemId)).toEqual([ - startRowKey(operationChild.acquisitionGeneration), - startRowKey(id) + startRowKey(operationChild.acquisitionGeneration) ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f1bbd673cd62..eda3b4166702 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -472,12 +472,12 @@ describe('a send with no live owner', () => { expect(acquire).toHaveBeenCalledTimes(1) expect(await errorStatuses()).toHaveLength(1) - // The outbox's Retry rotates the id: a fresh attempt, with its own row. + // The outbox's Retry rotates the id: a fresh attempt. It failed alike, so the run's row says why. expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toHaveLength(2) + expect(await errorStatuses()).toHaveLength(1) expect(dispatch).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index f41d2e190c7d..69e6e725c65b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -211,7 +211,8 @@ describe('a send into a published session whose child ended before startup', () await exitBeforeProof() // The child never proved its start, so it accepted nothing: the exit rejects the message this - // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. + // host admitted, so nothing pins the session and Retry stays offered. The first start failed + // alike with nothing delivered since, so its row names the cause for this one too. expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: 'Codex stopped before it finished starting. Send your message to try again.', @@ -221,13 +222,11 @@ describe('a send into a published session whose child ended before startup', () expect( (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) - expect((await journalStatuses()).slice(rowsBefore)).toEqual([ - 'Codex stopped before it finished starting. Send your message to try again.' - ]) - // Accepted before the restart it needed, so the chat draws it above the row naming the cause. + expect((await journalStatuses()).slice(rowsBefore)).toEqual([]) + // Rejected after the run's row, so the chat draws it below the row naming the cause. const snapshot = await host.journalSnapshot(SESSION) const causeRow = snapshot.items.findLast((item) => item.body.kind === 'status')?.itemId - const shown = [agentJournalSubmissionKey(held), causeRow] + const shown = [causeRow, agentJournalSubmissionKey(held)] expect( hostTestDrawnRowIds(snapshot, [ { clientMessageId: held, text: 'still not signed in' } @@ -243,7 +242,7 @@ describe('a send into a published session whose child ended before startup', () await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) - expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) + expect((await journalStatuses()).slice(rowsBefore)).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 8f6037c6d9c7..177641038184 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -154,7 +154,7 @@ describe('a fresh chat whose Claude start fails', () => { ['after the create already died', false], ['while the create is still starting', true] ] as const)( - 'starts once for the open and once for a send, one row each, when the view binds %s', + 'starts once for the open and once for a send, under one row, when the view binds %s', async (_when, createStillStarting) => { // Released only once the views bound, so no runner is slow enough to let the create die first. let releaseCreate = (): void => {} @@ -203,16 +203,16 @@ describe('a fresh chat whose Claude start fails', () => { ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) ) await settleExits() - // The send's own start, once, and one row for it below the message. + // The send's own start, once. It failed alike with nothing delivered since: no second row. expect(claude.connections).toHaveLength(2) - expect(await timeline()).toEqual([startFailure, 'message', startFailure]) + expect(await timeline()).toEqual([startFailure, 'message']) // Switching away and back re-subscribes; it starts nothing and adds no row. unsubscribe() await view(SURFACE) await settleExits() expect(claude.connections).toHaveLength(2) - expect(await timeline()).toEqual([startFailure, 'message', startFailure]) + expect(await timeline()).toEqual([startFailure, 'message']) } ) }) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index efb8d3cde16a..2dc218d1cc9a 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -98,7 +98,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ) await failLatestStart(host, 2) - // Rejected with the cause, not left in doubt; one row for this attempt names it. + // Rejected with the cause, not left in doubt; the open's row already names it, so no second. await vi.waitFor(async () => expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', @@ -107,7 +107,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) @@ -122,7 +122,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) - expect(await statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(1) }) }) diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index c5b21f9027fa..49ceab995530 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -151,7 +151,8 @@ describe('a send whose restarted Claude child dies before it proves its start', rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) + // The open's row already says why: a start failing alike writes no second. + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') @@ -162,13 +163,13 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) - expect(await statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(1) }) - // A restart refused because its child died before it was handed over leaves one row, from the - // delivery, in the words any failed start uses, and rejects the message with them. + // A restart refused because its child died before it was handed over rejects the message in the + // words any failed start uses; the open's row already says them, so it adds no row. it.each(['spawn', 'start-time-read'] as const)( - 'leaves one row for a restart whose child exits at %s', + 'adds no row to the run for a restart whose child exits at %s', async (at) => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() @@ -188,7 +189,7 @@ describe('a send whose restarted Claude child dies before it proves its start', ) await waitForStructuredAgentSessionRecovery() - expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) } ) @@ -245,7 +246,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o return rows } - it('shows one row naming the cause per failed attempt, however the start died, and none once the CLI is fixed', async () => { + it('shows one row naming the cause for a run of failed attempts, however the start died, and none once the CLI is fixed', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] @@ -272,12 +273,12 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o reason: STARTUP_FAILURE }) ) - await eventually(() => - expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE, STARTUP_FAILURE]) - ) + // Failed alike, with nothing delivered since the open's row: that row says why for it too. + await waitForStructuredAgentSessionRecovery() + expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE]) // Retry while still broken: this restart dies before its child is handed over, so the - // delivery's start is refused. Still one row, saying the same thing, on the rejected message. + // delivery's start is refused. It failed alike, so the run's one row still says why. claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: 'claude stream-json exited (code 1): claude: not signed in (rig)', @@ -292,19 +293,13 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o }) ) await waitForStructuredAgentSessionRecovery() - await eventually(() => - expect([...shownRows(events).values()]).toEqual([ - STARTUP_FAILURE, - STARTUP_FAILURE, - STARTUP_FAILURE - ]) - ) + expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE]) // The CLI is fixed: Retry delivers and adds no row. claude.behave(SESSION, {}) await expect(attempt(host, 'hello?')).resolves.toMatchObject({ ok: true }) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) - expect(shownRows(events).size).toBe(3) + expect(shownRows(events).size).toBe(1) } finally { unsubscribe() } From 3d59e968b19e6f5e42219cb3da3baed71945a229 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:17:03 -0700 Subject: [PATCH 16/26] test(native-chat): a Claude CLI that is not signed in keeps one row however often the chat is sent to --- ...ude-structured-send-signed-out-run.test.ts | 78 +++++++++++++++++++ ...ed-claude-scripted-runtime-test-support.ts | 9 ++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/claude-structured-send-signed-out-run.test.ts diff --git a/src/main/runtime/claude-structured-send-signed-out-run.test.ts b/src/main/runtime/claude-structured-send-signed-out-run.test.ts new file mode 100644 index 000000000000..03f06026d80b --- /dev/null +++ b/src/main/runtime/claude-structured-send-signed-out-run.test.ts @@ -0,0 +1,78 @@ +// A Claude CLI that is not signed in fails every start the same way. The chat's own start leaves one +// row; each message sent after it makes its own start, is rejected with the same typed failure, and +// adds no row: nothing was delivered between them. Against the production runtime, adapter, record +// store and host, with only the CLI process scripted. + +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +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 { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-signed-out-run' +const CALLER = { callerKey: 'client-1' } +const SIGNED_OUT = + 'Claude is not signed in for the selected account. Sign in, then send your message again.' + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + 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 + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +it('keeps one row for a chat whose every start fails not signed in, however often it is sent to', async () => { + claude.behave(SESSION, { signedOut: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await waitForStructuredAgentSessionRecovery() + await vi.waitFor(async () => expect(await statusRows(host)).toEqual([SIGNED_OUT])) + + for (const [index, text] of ['first', 'second', 'third'].entries()) { + const id = await send(host, text) + await vi.waitFor(async () => + expect(await submission(host, id)).toMatchObject({ dispatchState: 'rejected' }) + ) + // The typed failure carries no provider words, so each attempt states the same one. + expect((await submission(host, id))?.rejection).toEqual({ kind: 'notSignedIn' }) + expect(claude.children(SESSION)).toHaveLength(index + 2) + } + await waitForStructuredAgentSessionRecovery() + + expect(await statusRows(host)).toEqual([SIGNED_OUT]) +}) 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 7a69cbe9c86d..a3c976698181 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -48,6 +48,8 @@ export type ScriptedClaudeBehavior = { optionWritesFail?: boolean /** The init frame names another provider session than the one launched. */ initNamesForeignSession?: boolean + /** Initialize reports an account with no credentials, as a signed-out CLI does. */ + signedOut?: boolean } export type ScriptedClaudeChild = { @@ -122,7 +124,12 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { closed: false, exitVerdict: { root: 'live', tree: 'unverifiable' }, initializationResult: () => { - const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } + const initialized = { + models: [{ value: 'sonnet', displayName: 'Sonnet' }], + ...(behavior.signedOut + ? { account: { apiProvider: 'firstParty', tokenSource: 'none' } } + : {}) + } const announce = (): void => handlers.onMessage?.({ type: 'system', From 89ca4829bb854500b3b104710c467b23518fae8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:22:01 -0700 Subject: [PATCH 17/26] fix(native-chat): decide whether a failed start's row repeats its run on the journal's lane, as its batch is planned --- .../journal-lifecycle-batch-appender.ts | 30 +-- .../journal-start-failure-run.ts | 23 ++- .../agent-session-journal/journal-store.ts | 6 - ...gent-session-dead-generation-settlement.ts | 1 - ...ent-session-start-failure-run-lane.test.ts | 185 ++++++++++++++++++ ...-agent-session-start-failure-settlement.ts | 23 +-- 6 files changed, 234 insertions(+), 34 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-run-lane.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index e0e09b10d93a..268902917256 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -12,8 +12,10 @@ import type { } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' import { journalBatchRejectionRowBuilders } from './journal-pending-submission-recovery' +import { withoutRestatedStartFailureRows } from './journal-start-failure-run' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') +const NOTHING_LEFT_TO_WRITE = new Error('journal_lifecycle_batch_nothing_left') export class JournalLifecycleBatchAppender { constructor( @@ -31,12 +33,14 @@ export class JournalLifecycleBatchAppender { const { rejects } = input if (rejects) { // Planned on the lane: the send if it is still `which` then, and this batch unless it already - // landed or is empty. With the send settled meanwhile it failed no one, so nothing is written. + // landed or nothing is left once its run's row is counted. With the send settled meanwhile it + // failed no one, so nothing is written. return this.deps .enqueueRows(() => { const rejections = journalBatchRejectionRowBuilders(this.deps.state, input.fence, rejects) + const mutations = withoutRestatedStartFailureRows(this.deps.state(), input.mutations) return rejections.length === 0 || - input.mutations.length === 0 || + mutations.length === 0 || this.wasApplied(input.settlementId) ? rejections : [ @@ -44,7 +48,7 @@ export class JournalLifecycleBatchAppender { journalLifecycleBatchRowBuilder( this.deps.state, input.settlementId, - input.mutations, + mutations, input ) ] @@ -54,22 +58,26 @@ export class JournalLifecycleBatchAppender { if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } - const build = journalLifecycleBatchRowBuilder( - this.deps.state, - input.settlementId, - input.mutations, - input - ) return this.deps .enqueue((seq, ts) => { if (this.wasApplied(input.settlementId)) { throw SETTLEMENT_ALREADY_APPLIED } - return build(seq, ts) + const mutations = withoutRestatedStartFailureRows(this.deps.state(), input.mutations) + // Its only row restated its run's: nothing to write. An empty batch still fails its bound. + if (mutations.length === 0 && input.mutations.length > 0) { + throw NOTHING_LEFT_TO_WRITE + } + return journalLifecycleBatchRowBuilder( + this.deps.state, + input.settlementId, + mutations, + input + )(seq, ts) }) .then((row) => ({ epoch: row.epoch, sequence: row.seq })) .catch((error: unknown) => { - if (error === SETTLEMENT_ALREADY_APPLIED) { + if (error === SETTLEMENT_ALREADY_APPLIED || error === NOTHING_LEFT_TO_WRITE) { return this.deps.cursor() } throw error diff --git a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts index 26af2ccc95a8..f4be004343b3 100644 --- a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts +++ b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts @@ -1,6 +1,7 @@ // One row speaks for a run of starts that fail alike. Until a turn is delivered, a start that fails // as the chat's latest start-failure row says writes no row of its own, and its rejected message is -// read under that row. Derived from the fold at each write; nothing marks the run. +// read under that row. Decided on the journal's lane, from the fold every earlier write landed in, +// as the batch carrying the row is planned; nothing marks the run. import { readAgentSessionFailureFact, @@ -8,8 +9,28 @@ import { type AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalReducerState } from './journal-reducer' +import type { JournalLifecycleMutationInput } from './journal-row-builders' + +/** A batch's mutations less any start-failure row whose failure its run's row already states. */ +export function withoutRestatedStartFailureRows( + state: Pick, + mutations: readonly JournalLifecycleMutationInput[] +): readonly JournalLifecycleMutationInput[] { + return mutations.filter((mutation) => { + if ( + mutation.kind !== 'item' || + mutation.body.kind !== 'status' || + !isStructuredAgentSessionStartFailureRow(agentJournalItemKey(mutation.identity)) + ) { + return true + } + const fact = readAgentSessionFailureFact(mutation.body.failure) + return fact === undefined || !journalStartFailureAlreadyStated(state, fact) + }) +} /** Whether the latest start-failure row states this failure, with no turn delivered since. */ export function journalStartFailureAlreadyStated( diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index ce500a3552fc..626b77cf6140 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,7 +1,6 @@ // Append-only journal store for one agent session. It owns the chat's fold and write queue, and no // connection: every statement goes through the host's one journal database. -import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { @@ -68,7 +67,6 @@ import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-sc import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' -import { journalStartFailureAlreadyStated } from './journal-start-failure-run' import { createJournalStoreCollaborators } from './journal-store-collaborators' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' @@ -262,10 +260,6 @@ export class AgentSessionJournal { canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) - /** Whether a start failing so would repeat the row of its run (`journal-start-failure-run`). */ - startFailureAlreadyStated = (failure: AgentSessionFailureFact): boolean => - journalStartFailureAlreadyStated(this.state, failure) - /** Reads the fold with every write issued before this call committed, and none issued after: at * once unless writes still wait behind an owed import or a running write. */ readInOrder(read: () => T): Promise { 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 2edadb1fba09..e28ee2e58b82 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 @@ -59,7 +59,6 @@ export type DeadGenerationJournal = Omit & { markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] - startFailureAlreadyStated?: AgentSessionJournal['startFailureAlreadyStated'] } export type StructuredAgentSessionUnfinishedWork = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-run-lane.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-run-lane.test.ts new file mode 100644 index 000000000000..b6f1b8e65755 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-run-lane.test.ts @@ -0,0 +1,185 @@ +// Whether a failed start's row repeats its run's is decided on the journal's lane, as the batch +// carrying it is planned: a send accepted ahead of it in that lane ends the run, however late the +// writer read the journal. Each case holds the lane with a write in progress and issues the others +// from inside it, so the lane, not timing, orders them. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { + isStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowIdentity +} from '../../../shared/structured-agent-session-start-failure-row-key' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from '../agent-session-journal/journal-host-database-test-support' +import { + rejectWithStartFailureRow, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-settlement' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-run-lane', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) +} +const SIGNED_OUT = agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' +}) +const NOTE = { provider: 'orca', clientMessageId: 'note' } as const + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-failure-run-lane-')) +}) + +afterEach(async () => { + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +/** A chat whose first queued message already failed its start, leaving the run's row. */ +async function openAfterFailedStart(...queued: string[]): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => 'epoch-1' + }) + for (const id of ['first', ...queued]) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 0, + handoverRecorded: true + }) + } + await reject(journal, 'first') + return journal +} + +function reject(journal: AgentSessionJournal, clientMessageId: string): Promise { + return rejectWithStartFailureRow(journal, { + clientMessageId, + words: SIGNED_OUT, + fence: 0, + which: isQueuedAgentJournalSubmission + }) +} + +function accept(journal: AgentSessionJournal, clientMessageId: string): Promise { + return journal.resolveDispatch({ + clientMessageId, + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'native-1', + uuid: `echo-${clientMessageId}` + }, + fence: 0 + }) +} + +/** Runs `issue` from inside a write in progress, so whatever it issues joins the lane behind it. */ +async function fromInsideAWrite( + journal: AgentSessionJournal, + issue: () => Promise[] +): Promise { + let issued: Promise[] = [] + await journal.appendResolvedItem( + () => { + issued = issue() + return { identity: NOTE, body: { kind: 'status', tone: 'info', text: 'held' } } + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await Promise.all(issued) +} + +function startRows(journal: AgentSessionJournal): string[] { + return journal + .snapshot() + .items.flatMap((item) => + isStructuredAgentSessionStartFailureRow(item.itemId) ? [item.itemId] : [] + ) +} + +const rowKey = (id: string) => + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(id)) + +it('writes the row of a start failing alike when a send was accepted ahead of it in the lane', async () => { + const journal = await openAfterFailedStart('delivered', 'second') + + // Both issued before either lands: the writer reads a journal with no accept in it yet. + await fromInsideAWrite(journal, () => [accept(journal, 'delivered'), reject(journal, 'second')]) + + expect(journal.submission('delivered')?.dispatchState).toBe('accepted') + expect(journal.submission('second')?.dispatchState).toBe('rejected') + expect(startRows(journal)).toEqual([rowKey('first'), rowKey('second')]) +}) + +it('writes no row for a start failing alike when nothing was accepted ahead of it', async () => { + const journal = await openAfterFailedStart('second') + + await fromInsideAWrite(journal, () => [reject(journal, 'second')]) + + expect(journal.submission('second')?.dispatchState).toBe('rejected') + expect(startRows(journal)).toEqual([rowKey('first')]) +}) + +// The exit's batch also ends any turn its child ran: only the restated row is dropped. +it('drops only a restated row from a batch that carries other writes', async () => { + const journal = await openAfterFailedStart() + + await journal.appendLifecycleBatch({ + settlementId: 'dead-generation:generation-2', + fence: 0, + recovered: true, + mutations: [ + structuredAgentSessionStartFailureRow('generation-2', SIGNED_OUT), + { + kind: 'item', + identity: NOTE, + body: { kind: 'status', tone: 'info', text: 'settled' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + + expect(startRows(journal)).toEqual([rowKey('first')]) + expect(journal.itemBody(agentJournalItemKey(NOTE))).toMatchObject({ text: 'settled' }) +}) + +it('writes nothing, and settles, for a batch whose only write restates its run', async () => { + const journal = await openAfterFailedStart() + const before = journal.cursor().sequence + + await expect( + journal.appendLifecycleBatch({ + settlementId: 'dead-generation:generation-2', + fence: 0, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow('generation-2', SIGNED_OUT)] + }) + ).resolves.toEqual({ epoch: 'epoch-1', sequence: before }) + + expect(startRows(journal)).toEqual([rowKey('first')]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index 6060e41b5c39..0f157094c0b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -29,7 +29,7 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se type StartFailureJournal = Pick< AgentSessionJournal, - 'submissions' | 'itemBody' | 'appendLifecycleBatch' | 'startFailureAlreadyStated' + 'submissions' | 'itemBody' | 'appendLifecycleBatch' > /** A failed start's row in the chat: an error row keyed by its message (or by the start, from the @@ -48,10 +48,10 @@ export function structuredAgentSessionStartFailureRow( } /** Rejects a message its start failed and writes that start's row with it, keyed by the message, in - * one append; no row when the run's row already states it. Writes nothing once `which` no longer - * holds for it: Stop withdrew it, or another writer settled it. */ + * one append; the journal's lane drops the row when its run's row already states it. Writes + * nothing once `which` no longer holds for it: Stop withdrew it, or another writer settled it. */ export async function rejectWithStartFailureRow( - journal: Pick, + journal: Pick, input: { clientMessageId: string words: AgentJournalDispatchRejection @@ -64,9 +64,7 @@ export async function rejectWithStartFailureRow( settlementId: `start-failure:${clientMessageId}`, fence, recovered: true, - mutations: journal.startFailureAlreadyStated(words.rejection) - ? [] - : [structuredAgentSessionStartFailureRow(clientMessageId, words)], + mutations: [structuredAgentSessionStartFailureRow(clientMessageId, words)], rejects: { clientMessageId, ...words, which } }) } @@ -128,12 +126,9 @@ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence * With none, a message still waiting on the start is the delivery loop's to record with its own * row, and one already rejected as this start's failure has its writer's; anything else — a * command, goal or rewind start, or one no message is charged with — has only this row to say why. - * None when its run's row already says it. */ + * The journal's lane drops it when its run's row already says it. */ export function exitStartFailureRow( - journal: { - submissions?: () => FailedStartSubmission[] - startFailureAlreadyStated?: AgentSessionJournal['startFailureAlreadyStated'] - }, + journal: { submissions?: () => FailedStartSubmission[] }, exit: { startKey: string fence: number @@ -148,9 +143,7 @@ export function exitStartFailureRow( (journal.submissions?.() ?? []).some((submission) => rejectedAsFailedStartAt(submission, exit.fence) )) - return recordedElsewhere || journal.startFailureAlreadyStated?.(exit.words.rejection) === true - ? [] - : [structuredAgentSessionStartFailureRow(exit.startKey, exit.words)] + return recordedElsewhere ? [] : [structuredAgentSessionStartFailureRow(exit.startKey, exit.words)] } export function oldestQueuedSubmission( From c4c00ce6d1a924cc5120d317d4684d68196ccd43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:31:19 -0700 Subject: [PATCH 18/26] fix(native-chat): a failed start's log diagnostic is not part of its failure, so a CLI's timestamped stderr keeps one row per run --- ...agent-session-start-failure-writer.test.ts | 48 +++++++++++++++++++ ...session-delivery-notices.start-row.test.ts | 34 +++++++++++++ src/shared/agent-session-failure.test.ts | 29 +++++++++++ src/shared/agent-session-failure.ts | 7 +-- 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 26e50ae3eec0..9ffd0866330d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -637,6 +637,54 @@ describe('a run of starts that fail alike', () => { expect(await startRows()).toEqual([rowFor(first), rowFor(last)]) }) + // Codex's stderr is a log: its tracing stamps each line, so two exits alike differ in the time. + it('writes one row for two startup exits whose stderr differs only by its timestamp', async () => { + const stderr = (at: string) => + agentSessionFailureFact('providerExited', { + detail: { + text: `${at} ERROR codex_app_server: unknown field \`foo\` in config.toml`, + audience: 'log' + } + }) + awaitStarted.mockImplementation(async () => undefined) + const first = await send('first') + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await exitBeforeProof(stderr('2026-10-07T01:02:03.456789Z')) + await eventually(async () => + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + ) + const second = await send('second') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + + await exitBeforeProof(stderr('2026-10-07T01:02:09.012345Z')) + + await eventually(async () => + expect(await submission(second)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'providerStartFailed', detail: { audience: 'log' } } + }) + ) + expect(await startRows()).toEqual([ + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) + ]) + }) + + // Words written for a person name what failed: two that differ are two failures. + it('writes a row for each of two refusals whose words for a person differ', async () => { + const refused = (text: string) => + agentSessionFailureFact('providerStartFailed', { detail: { text, audience: 'person' } }) + const first = await sendQueued('first') + const second = await send('second') + awaitStarted.mockImplementation(async () => refused('no rollout found for thread id t-2')) + + settleStart(refused('no rollout found for thread id t-1')) + + await eventually(async () => + expect(await submission(second)).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(await startRows()).toEqual([rowFor(first), rowFor(second)]) + }) + it("writes no row for an exit that fails alike its run's row, though it rejected the message", async () => { awaitStarted.mockImplementation(async () => undefined) const first = await send('first') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts index 787405d8a7b1..63417f41d8ce 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts @@ -185,6 +185,40 @@ describe('a message rejected by a start whose row already says why', () => { }) }) + // A log diagnostic's text is not the failure: a CLI's stderr can carry a timestamp per attempt. + it('hushes a message under a row whose log diagnostic differs, not one whose words for a person do', () => { + const exited = (text: string, audience: 'log' | 'person'): AgentSessionFailureFact => ({ + kind: 'providerStartFailed', + detail: { text, audience } + }) + expect( + noticesFor( + [ + ['first', exited('01:02:03 ERROR config', 'log')], + ['logged', exited('01:02:09 ERROR config', 'log')] + ], + [ + messageAt('first', 1), + row('first', exited('01:02:03 ERROR config', 'log'), 2), + messageAt('logged', 3) + ] + ) + ).toEqual({ [key('first')]: NOT_SENT, [key('logged')]: NOT_SENT }) + expect( + noticesFor( + [ + ['first', exited('no rollout for t-1', 'person')], + ['worded', exited('no rollout for t-2', 'person')] + ], + [ + messageAt('first', 1), + row('first', exited('no rollout for t-1', 'person'), 2), + messageAt('worded', 3) + ] + )[key('worded')] + ).not.toBe(NOT_SENT) + }) + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { const stated = { itemId: rowKey('first'), fact: startFailed } expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index 0e718c3132bb..fbeed8e99677 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -7,6 +7,7 @@ import { readAgentSessionFailureFact, readProviderRetry, readWholeAgentSessionFailureFact, + sameAgentSessionFailureFact, withProviderDiagnostic } from './agent-session-failure' import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details' @@ -149,3 +150,31 @@ describe('reading all of a failure fact', () => { } }) }) + +describe('one failure', () => { + const exited = (text: string, audience: 'log' | 'person') => + agentSessionFailureFact('providerStartFailed', { detail: { text, audience } }) + + it('ignores the text of a log diagnostic, which can carry a timestamp per attempt', () => { + expect( + sameAgentSessionFailureFact( + exited('01:02:03 ERROR x', 'log'), + exited('01:02:09 ERROR x', 'log') + ) + ).toBe(true) + }) + + it('compares words for a person, and the audience either way', () => { + expect(sameAgentSessionFailureFact(exited('t-1', 'person'), exited('t-2', 'person'))).toBe( + false + ) + expect(sameAgentSessionFailureFact(exited('t-1', 'person'), exited('t-1', 'person'))).toBe(true) + expect(sameAgentSessionFailureFact(exited('t-1', 'person'), exited('t-1', 'log'))).toBe(false) + expect( + sameAgentSessionFailureFact( + exited('t-1', 'log'), + agentSessionFailureFact('providerStartFailed') + ) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 864432a7fe13..2e859578c407 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -251,16 +251,17 @@ export function readWholeAgentSessionFailureFact( return fact && structuralValuesEqualIgnoringUndefined(fact, value) ? fact : undefined } -/** Whether two facts are one failure, compared by their fields, never their words: a start's row - * and the messages it rejected share one, and the host writes one row for a run of them. */ +/** Whether two facts are one failure, compared by their fields, never Orca's words: a start's row + * and the messages it rejected share one, and the host writes one row for a run of them. A log + * diagnostic's text is not compared: a CLI's stderr can carry a timestamp per attempt. */ export function sameAgentSessionFailureFact( a: AgentSessionFailureFact, b: AgentSessionFailureFact ): boolean { return ( a.kind === b.kind && - a.detail?.text === b.detail?.text && a.detail?.audience === b.detail?.audience && + (a.detail?.audience !== 'person' || a.detail.text === b.detail?.text) && a.refusal?.code === b.refusal?.code && a.refusal?.details?.reason === b.refusal?.details?.reason && a.attachment?.reason === b.attachment?.reason && From 77c9a6f30af62e6f453e830b83f2424d1301ba81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:03:40 -0700 Subject: [PATCH 19/26] fix(native-chat): any send the provider accepted ends a run of failed starts, a /compact with no item of its own too --- .../journal-dispatch-reducer.ts | 1 + .../agent-session-journal/journal-reducer.ts | 4 + .../journal-start-failure-run.ts | 13 +- .../journal-submission-fold.ts | 1 + ...ersation-command-start-failure-run.test.ts | 120 ++++++++++++++++++ 5 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 3625982a24fa..78cd7a2e16c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -54,6 +54,7 @@ export function applyJournalDispatchRow( } if (row.state === 'accepted') { notePersonTurnAccepted(state, submission) + state.latestAcceptedSequence = Math.max(state.latestAcceptedSequence, row.seq) } if (row.state !== 'accepted' || !row.providerItemId) { return diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e2d1b1ef3ca3..263e58e27e2f 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -62,6 +62,9 @@ export type JournalReducerState = { /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ latestPersonTurnSequence: number + /** The row at which the provider last accepted a send, with or without an item of its own; 0 + * when none. Kept as it folds; it ends a run of failed starts. */ + latestAcceptedSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks } @@ -83,6 +86,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, + latestAcceptedSequence: 0, queuePauseMarks: createJournalQueuePauseMarks() } } diff --git a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts index f4be004343b3..0e560f74ff5a 100644 --- a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts +++ b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts @@ -16,7 +16,7 @@ import type { JournalLifecycleMutationInput } from './journal-row-builders' /** A batch's mutations less any start-failure row whose failure its run's row already states. */ export function withoutRestatedStartFailureRows( - state: Pick, + state: Pick, mutations: readonly JournalLifecycleMutationInput[] ): readonly JournalLifecycleMutationInput[] { return mutations.filter((mutation) => { @@ -34,7 +34,7 @@ export function withoutRestatedStartFailureRows( /** Whether the latest start-failure row states this failure, with no turn delivered since. */ export function journalStartFailureAlreadyStated( - state: Pick, + state: Pick, failure: AgentSessionFailureFact ): boolean { let latest: AgentJournalRenderItem | undefined @@ -55,11 +55,6 @@ export function journalStartFailureAlreadyStated( ) { return false } - // A send accepted after the row ends its run: the next failure is news. - for (const receipt of state.receipts.values()) { - if (receipt.cursor.sequence > latest.sequence) { - return false - } - } - return true + // Any send accepted after the row, a command too, ends its run: the next failure is news. + return state.latestAcceptedSequence < latest.sequence } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index b00bdd36de24..0a8093183714 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -110,6 +110,7 @@ export function acceptSubmissionFromProviderItem( submission.fence = row.fence submission.dispatchState = 'accepted' notePersonTurnAccepted(state, submission) + state.latestAcceptedSequence = Math.max(state.latestAcceptedSequence, row.seq) submission.providerItemId = providerItemId submission.reason = null submission.resolvedAt = row.ts diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts new file mode 100644 index 000000000000..889a37b33ab0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts @@ -0,0 +1,120 @@ +// A run of failed starts against conversation commands. A send the provider accepted ends a run, +// a /compact Codex takes without an item of its own too. Against the real host, store and journal. + +import { beforeEach, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + isStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowIdentity +} from '../../../shared/structured-agent-session-start-failure-row-key' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +let state: ReturnType +let compact: Mock> + +beforeEach(() => { + state = hostTestState() + // Codex's ack: the provider took the command, with no item of its own. + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + Object.assign(state.host.deps.adapter, { compact, closeSession: vi.fn(async () => true) }) +}) + +/** The provider ends the command it took, as its translator writes it. */ +function finishCompact(): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { + ...command.running, + state: 'completed', + outcome: 'success', + completedAt: HOST_TEST_NOW + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +/** The chat's child is gone; the next start it needs crashes the same way each time. */ +async function startsCrash(times?: 'once'): Promise { + await state.host.close(SESSION, 'evict') + const crash = new Error('codex app-server exited (code 1)') + if (times === 'once') { + state.acquire.mockRejectedValueOnce(crash) + } else { + state.acquire.mockRejectedValue(crash) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const params = { envelope: envelope('agentSession.send', { body }), body } + await expect(state.host.send(CALLER, params)).resolves.toMatchObject({ ok: true }) + return params.envelope.clientOperationId +} + +async function compactCommand(): Promise { + const params = { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + return params.envelope.clientOperationId +} + +async function settled(clientMessageId: string, dispatchState: 'accepted' | 'rejected') { + await vi.waitFor(async () => + expect( + (await state.host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + ).toMatchObject({ dispatchState }) + ) +} + +async function startRows(): Promise { + return (await state.host.journalSnapshot(SESSION)).items.flatMap((item) => + isStructuredAgentSessionStartFailureRow(item.itemId) ? [item.itemId] : [] + ) +} + +const rowFor = (id: string) => + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(id)) + +it('starts a new run after a /compact the provider took with no item of its own', async () => { + await attach() + await startsCrash('once') + const first = await send('first') + await settled(first, 'rejected') + const compacted = await compactCommand() + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await settled(compacted, 'accepted') + finishCompact() + await state.host.flushStreamedEvents(SESSION) + + await startsCrash('once') + const second = await send('second') + await settled(second, 'rejected') + + // The same crash, but a turn was delivered between: news, with its own row. + expect(await startRows()).toEqual([rowFor(first), rowFor(second)]) +}) From 0afb50b0d0579e72e6ebf2bb1816c79751f574b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:05:49 -0700 Subject: [PATCH 20/26] fix(native-chat): a /compact's failed start always writes its own row, and that row never speaks for a message's failure --- .../journal-start-failure-run.ts | 22 +++++++++-- ...ersation-command-start-failure-run.test.ts | 38 ++++++++++++++++++- ...session-delivery-notices.start-row.test.ts | 26 ++++++++++++- ...ructured-agent-session-delivery-notices.ts | 19 ++++++++-- ...ssage-projection.rejected-in-place.test.ts | 4 +- ...ructured-conversation-command-send.test.ts | 6 ++- ...-agent-session-start-failure-facts.test.ts | 4 +- ...tured-agent-session-start-failure-facts.ts | 1 + ...red-agent-session-start-failure-row-key.ts | 22 ++++++++++- 9 files changed, 122 insertions(+), 20 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts index 0e560f74ff5a..e8f2f23139ad 100644 --- a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts +++ b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts @@ -10,7 +10,10 @@ import { } from '../../../shared/agent-session-failure' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' +import { + isStructuredAgentSessionCommandStartFailureRow, + isStructuredAgentSessionStartFailureRow +} from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalReducerState } from './journal-reducer' import type { JournalLifecycleMutationInput } from './journal-row-builders' @@ -20,10 +23,14 @@ export function withoutRestatedStartFailureRows( mutations: readonly JournalLifecycleMutationInput[] ): readonly JournalLifecycleMutationInput[] { return mutations.filter((mutation) => { + if (mutation.kind !== 'item' || mutation.body.kind !== 'status') { + return true + } + const itemId = agentJournalItemKey(mutation.identity) if ( - mutation.kind !== 'item' || - mutation.body.kind !== 'status' || - !isStructuredAgentSessionStartFailureRow(agentJournalItemKey(mutation.identity)) + !isStructuredAgentSessionStartFailureRow(itemId) || + // A command's failed start always says so: a client that hides its message has only this. + isStructuredAgentSessionCommandStartFailureRow(itemId, (id) => state.items.get(id)?.body) ) { return true } @@ -48,6 +55,13 @@ export function journalStartFailureAlreadyStated( } const stated = latest?.body.kind === 'status' ? readAgentSessionFailureFact(latest.body.failure) : undefined + // A command's row names the command's next step, so it starts no run for a message. + if ( + latest !== undefined && + isStructuredAgentSessionCommandStartFailureRow(latest.itemId, (id) => state.items.get(id)?.body) + ) { + return false + } if ( latest === undefined || stated === undefined || diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts index 889a37b33ab0..0adabeaae2f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-start-failure-run.test.ts @@ -1,5 +1,6 @@ -// A run of failed starts against conversation commands. A send the provider accepted ends a run, -// a /compact Codex takes without an item of its own too. Against the real host, store and journal. +// A run of failed starts against conversation commands. A command's failed start always writes its +// own row, which never speaks for a message's; and a send the provider accepted ends a run, a +// /compact Codex takes without an item of its own too. Against the real host, store and journal. import { beforeEach, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -8,6 +9,7 @@ import { isStructuredAgentSessionStartFailureRow, structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { attach, @@ -118,3 +120,35 @@ it('starts a new run after a /compact the provider took with no item of its own' // The same crash, but a turn was delivered between: news, with its own row. expect(await startRows()).toEqual([rowFor(first), rowFor(second)]) }) + +it('gives a /compact that fails like the message before it a row of its own', async () => { + await attach() + await startsCrash() + const first = await send('first') + await settled(first, 'rejected') + + const compacted = await compactCommand() + await settled(compacted, 'rejected') + + expect(await startRows()).toEqual([rowFor(first), rowFor(compacted)]) + // A client that hides rejected messages still reads the command's own row. + const snapshot = await state.host.journalSnapshot(SESSION) + const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: false + }) + expect(drawn.flatMap((message) => message.blocks)).toContainEqual( + expect.objectContaining({ text: "Codex couldn't restart. Run /compact again." }) + ) +}) + +it('gives a message that fails like the /compact before it a row of its own', async () => { + await attach() + await startsCrash() + const compacted = await compactCommand() + await settled(compacted, 'rejected') + + const later = await send('later') + await settled(later, 'rejected') + + expect(await startRows()).toEqual([rowFor(compacted), rowFor(later)]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts index 63417f41d8ce..3af27291e206 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts @@ -96,7 +96,7 @@ describe('a message rejected by a start whose row already says why', () => { row('gen', startFailed, 2), { ...row('gen', { kind: 'providerExited' }, 3), itemId: key('exit-row') } ]) - ).toEqual([{ itemId: rowKey('gen'), fact: startFailed }]) + ).toEqual([{ itemId: rowKey('gen'), fact: startFailed, ofCommand: false }]) }) // This host: each failed start rejects its message and writes the row keyed by it, in one write. @@ -219,8 +219,30 @@ describe('a message rejected by a start whose row already says why', () => { ).not.toBe(NOT_SENT) }) + // A command's row says to run the command again: never the next step for a message. + it("hushes a /compact under its own row, and never a message under the command's row", () => { + const compactAt = (id: string, sequence: number): AgentJournalRenderItem => ({ + ...messageAt(id, sequence), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + }) + expect( + noticesFor( + [ + ['compacted', startFailed], + ['later', startFailed] + ], + [compactAt('compacted', 1), row('compacted', startFailed, 2), messageAt('later', 3)] + ) + ).toEqual({ [key('compacted')]: NOT_SENT, [key('later')]: START_FAILED_WORDS }) + }) + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { - const stated = { itemId: rowKey('first'), fact: startFailed } + const stated = { itemId: rowKey('first'), fact: startFailed, ofCommand: false } expect(texts([rejected('first', startFailed)], [], [stated])).toEqual({ [key('first')]: 'Written by the host.' }) 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 c86b5218fd5b..ed54883b1347 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 @@ -32,6 +32,7 @@ import type { } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import { + isStructuredAgentSessionCommandStartFailureRow, isStructuredAgentSessionStartFailureRow, structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' @@ -61,10 +62,12 @@ const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { se const NO_COMMANDS: ReadonlySet = new Set() const NO_ITEMS: readonly AgentJournalRenderItem[] = [] -/** A loaded start-failure row: its item id, and the failure it states. */ +/** A loaded start-failure row: its item id, the failure it states, and whether a command's start + * wrote it (its words name the command's next step, so it speaks for no message). */ export type StatedStartFailure = { itemId: string fact: AgentSessionFailureFact + ofCommand: boolean } /** What the chat's loaded start-failure rows state. */ @@ -72,11 +75,18 @@ export function structuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[] ): StatedStartFailure[] { const stated: StatedStartFailure[] = [] + let bodies: Map | undefined + const bodyOf = (id: string) => + (bodies ??= new Map(items.map((entry) => [entry.itemId, entry.body]))).get(id) for (const item of items) { if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { const fact = readAgentSessionFailureFact(item.body.failure) if (fact) { - stated.push({ itemId: item.itemId, fact }) + stated.push({ + itemId: item.itemId, + fact, + ofCommand: isStructuredAgentSessionCommandStartFailureRow(item.itemId, bodyOf) + }) } } } @@ -99,7 +109,8 @@ export function agentSessionFailureStatedByStartRow( /** Whether the row of the start that rejected this message already says why. A row keyed by the * message is its own start's and speaks for it alone, so its failure decides. With none, as in a * chat an older host wrote — one row for a batch, keyed by its start or by its oldest message — - * any loaded row with the same failure does. */ + * or a run this host left one row for, any loaded row with the same failure does, but a + * command's. */ function rejectionStatedByItsStartRow( recorded: AgentJournalSubmission, startFailures: readonly StatedStartFailure[] @@ -110,7 +121,7 @@ function rejectionStatedByItsStartRow( const own = startFailures.filter(({ itemId }) => itemId === ownRow) return agentSessionFailureStatedByStartRow( recorded.rejection, - own.length > 0 ? own : startFailures + own.length > 0 ? own : startFailures.filter(({ ofCommand }) => !ofCommand) ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 24113bb037c9..10178c03de38 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -195,9 +195,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => () => {}, [failedStart], // The start's row, written right after the rejection it states. - [ - { itemId: 'orca:start-failure%3Afirst', fact: { kind: 'notSignedIn' } } - ], + [{ itemId: 'orca:start-failure%3Afirst', fact: { kind: 'notSignedIn' }, ofCommand: false }], new Set() ) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index b037bec0db27..8b0b235d7b5a 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -70,7 +70,7 @@ afterEach(async () => { }) describe("a /compact whose start failed, beside the chat's start-failure rows", () => { - const row = (itemId: string) => ({ itemId, fact: START_FAILED }) + const row = (itemId: string) => ({ itemId, fact: START_FAILED, ofCommand: true }) it('says nothing under the composer when its own start wrote the row', async () => { let loaded: ReturnType[] = [] @@ -189,7 +189,9 @@ describe('the line under the composer after a conversation command failed', () = agentName: 'Claude', pending: { current: false }, blocked: false, - startFailures: () => [{ itemId: 'orca:start-failure', fact: START_FAILED }], + startFailures: () => [ + { itemId: 'orca:start-failure', fact: START_FAILED, ofCommand: true } + ], send: async () => ({ kind: 'done', value: result }) }) ).toEqual({ accepted: false, error }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts index fffbd6431947..6533f9611565 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -31,7 +31,9 @@ it('holds the same facts while the start rows state nothing new', () => { { initialProps: { items: [row] } } ) const first = result.current - expect(first).toEqual([{ itemId: row.itemId, fact: { kind: 'providerStartFailed' } }]) + expect(first).toEqual([ + { itemId: row.itemId, fact: { kind: 'providerStartFailed' }, ofCommand: false } + ]) rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) expect(result.current).not.toBe(first) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts index 080a07bfa515..6b0ed03c4f79 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -27,6 +27,7 @@ export function useStructuredAgentSessionStartFailureFacts( return ( next !== undefined && stated.itemId === next.itemId && + stated.ofCommand === next.ofCommand && sameAgentSessionFailureFact(stated.fact, next.fact) ) }) diff --git a/src/shared/structured-agent-session-start-failure-row-key.ts b/src/shared/structured-agent-session-start-failure-row-key.ts index 6dc4a4817b14..ec42c06a4858 100644 --- a/src/shared/structured-agent-session-start-failure-row-key.ts +++ b/src/shared/structured-agent-session-start-failure-row-key.ts @@ -1,7 +1,10 @@ // A failed start's one row is keyed by the start, so a reader finds it by identity, not by its words. -import { parseAgentJournalItemKey } from './agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from './agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from './agent-session-journal-item-key' +import type { AgentJournalItemBody, AgentJournalItemIdentity } from './agent-session-journal-types' const START_FAILURE_ROW = 'start-failure:' @@ -15,3 +18,18 @@ export function isStructuredAgentSessionStartFailureRow(itemId: string): boolean const identity = parseAgentJournalItemKey(itemId) return identity?.provider === 'orca' && identity.clientMessageId.startsWith(START_FAILURE_ROW) } + +/** Whether a start-failure row is a conversation command's: keyed by a sent message that names one. + * Its words name the command's next step, so it never speaks for a message's failure. */ +export function isStructuredAgentSessionCommandStartFailureRow( + itemId: string, + bodyOf: (itemId: string) => AgentJournalItemBody | undefined +): boolean { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'orca' || !identity.clientMessageId.startsWith(START_FAILURE_ROW)) { + return false + } + const startKey = identity.clientMessageId.slice(START_FAILURE_ROW.length) + const body = bodyOf(agentJournalSubmissionKey(startKey)) + return body?.kind === 'message' && body.command !== undefined +} From 6ad3bb6ef0855eedeca9c18df4b7564c0561269f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:05:57 -0700 Subject: [PATCH 21/26] chore(native-chat): the run's check is file-local --- .../agent-session-journal/journal-start-failure-run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts index e8f2f23139ad..8390cbc5da93 100644 --- a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts +++ b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts @@ -40,7 +40,7 @@ export function withoutRestatedStartFailureRows( } /** Whether the latest start-failure row states this failure, with no turn delivered since. */ -export function journalStartFailureAlreadyStated( +function journalStartFailureAlreadyStated( state: Pick, failure: AgentSessionFailureFact ): boolean { From 73b44111f434f26fdf6442c9511f68d4cd54a7a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:18:45 -0700 Subject: [PATCH 22/26] fix(native-chat): an exit's row is keyed by the message its words are for, so a row worded for /compact is that command's The exit wrote its row keyed by the start even when it rejected a handed /compact and worded the row "Run /compact again". That row then started a run, so a later message failing alike was read under the /compact's words. Keying the row by the message its words are for makes it a command's row, which never starts a run and is never dropped. A /compact's failure now always writes its own row, so the composer no longer needs to ignore rows loaded before the send. --- .../structured-agent-session-command-turn.ts | 20 +++++++--- ...gent-session-dead-generation-settlement.ts | 6 ++- ...-agent-session-start-failure-settlement.ts | 40 ++++++++++++------- ...agent-session-start-failure-writer.test.ts | 18 +++------ ...agent-session-startup-failure-exit.test.ts | 27 ++++++++++++- ...ructured-agent-session-delivery-notices.ts | 7 ++-- ...ructured-conversation-command-send.test.ts | 34 ---------------- .../structured-conversation-command-send.ts | 10 +---- 8 files changed, 79 insertions(+), 83 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index 0a383ddcf312..f27d7bcd6098 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -69,11 +69,10 @@ export type StructuredAgentSessionAwaitedCommandJournal = { itemBody: AgentSessionJournal['itemBody'] } -/** The command the oldest message still waiting on the provider names: a start that fails now - * fails that message first, so its next step is to run the command again. */ -export function structuredAgentSessionAwaitedCommand( - journal: StructuredAgentSessionAwaitedCommandJournal -): AgentSessionConversationCommand | undefined { +/** The oldest message still waiting on the provider: a start that fails now fails it first. */ +export function structuredAgentSessionAwaitedMessage( + journal: Pick +): string | undefined { let oldest: AwaitedSubmission | undefined for (const submission of journal.submissions?.() ?? []) { if ( @@ -83,7 +82,16 @@ export function structuredAgentSessionAwaitedCommand( oldest = submission } } - return oldest && structuredAgentSessionMessageCommand(journal, oldest.clientMessageId) + return oldest?.clientMessageId +} + +/** The command the oldest message still waiting on the provider names: its next step is to run the + * command again. */ +export function structuredAgentSessionAwaitedCommand( + journal: StructuredAgentSessionAwaitedCommandJournal +): AgentSessionConversationCommand | undefined { + const awaited = structuredAgentSessionAwaitedMessage(journal) + return awaited && structuredAgentSessionMessageCommand(journal, awaited) } /** The command a message's own body sends: a start that fails it leaves that command to run again, 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 e28ee2e58b82..cc095578cac9 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 @@ -25,6 +25,7 @@ import { } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { exitStartFailureRow } from './structured-agent-session-start-failure-settlement' +import { structuredAgentSessionAwaitedMessage } from './structured-agent-session-command-turn' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { endedByPersonsStop, @@ -147,6 +148,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!startupFailure) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } + // Read before it is rejected: the message a failed start's words are for. + const wordedFor = structuredAgentSessionAwaitedMessage(input.journal) const settled = await (startupFailure ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) @@ -156,8 +159,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { mutations.push( ...exitStartFailureRow(input.journal, { startKey: input.exitedDuringStartup.generation ?? input.settlementId, + wordedFor, fence: input.fence, - rejectedAny: settled.length > 0, + rejected: settled, chargedToQueued: input.exitedDuringStartup.chargedToQueued === true, words: startupFailure }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index 0f157094c0b9..96408349396c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -1,8 +1,8 @@ // The writer of a failed start's record: the message it failed, rejected, and its row keyed by that // message, in one write, so a client that hides rejected messages still sees why. The delivery loop // writes it for the queued message a start was for, the handover for the message it was handing -// over; the exit writes one row keyed by the start for the handed messages it rejected, or for a -// start no message carries. Each message state has one writer, and `rejected` is terminal, so no +// over; the exit writes one row for the handed messages it rejected, keyed by the oldest, whose +// words it carries, or one keyed by the start for a start no message carries. Each message state has one writer, and `rejected` is terminal, so no // message is failed twice. A start failing as its run's row already says writes no row; its message // is read under that one. @@ -32,8 +32,9 @@ type StartFailureJournal = Pick< 'submissions' | 'itemBody' | 'appendLifecycleBatch' > -/** A failed start's row in the chat: an error row keyed by its message (or by the start, from the - * exit), repeating the message's sentence, so the reason outlives any client that hides it. */ +/** A failed start's row in the chat: an error row keyed by its message (or by the start, for one no + * message carries), repeating the message's sentence, so the reason outlives any client that hides + * it. */ export function structuredAgentSessionStartFailureRow( startKey: string, words: AgentJournalDispatchRejection @@ -122,27 +123,36 @@ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence ) } -/** The row an exit during startup writes for its start. The messages it rejected take this row. - * With none, a message still waiting on the start is the delivery loop's to record with its own - * row, and one already rejected as this start's failure has its writer's; anything else — a - * command, goal or rewind start, or one no message is charged with — has only this row to say why. - * The journal's lane drops it when its run's row already says it. */ +/** The row an exit during startup writes for its start. The messages it rejected take this row, + * keyed by the one its words are for, so a row worded for a command is that command's. With none, + * a message still waiting on the start is the delivery loop's to record with its own row, and one + * already rejected as this start's failure has its writer's; anything else — a command, goal or + * rewind start, or one no message is charged with — has only this row, keyed by the start, to say + * why. The journal's lane drops it when its run's row already says it. */ export function exitStartFailureRow( journal: { submissions?: () => FailedStartSubmission[] }, exit: { startKey: string + /** The message the words are for, if any. */ + wordedFor: string | undefined fence: number - rejectedAny: boolean + rejected: readonly string[] chargedToQueued: boolean words: AgentJournalDispatchRejection } ): JournalLifecycleMutationInput[] { + if (exit.rejected.length > 0) { + const key = + exit.wordedFor !== undefined && exit.rejected.includes(exit.wordedFor) + ? exit.wordedFor + : exit.startKey + return [structuredAgentSessionStartFailureRow(key, exit.words)] + } const recordedElsewhere = - !exit.rejectedAny && - (exit.chargedToQueued || - (journal.submissions?.() ?? []).some((submission) => - rejectedAsFailedStartAt(submission, exit.fence) - )) + exit.chargedToQueued || + (journal.submissions?.() ?? []).some((submission) => + rejectedAsFailedStartAt(submission, exit.fence) + ) return recordedElsewhere ? [] : [structuredAgentSessionStartFailureRow(exit.startKey, exit.words)] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 9ffd0866330d..05516d3a1efd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -475,9 +475,7 @@ describe('a start that fails while its child exits', () => { }) } // The exit's row for the handed message; the queued one failed alike, so it reads under it. - expect(await startRows()).toEqual([ - agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) - ]) + expect(await startRows()).toEqual([rowFor(handed)]) }) it('rejects a message its unproven child was handed with the start, never in doubt', async () => { @@ -499,10 +497,8 @@ describe('a start that fails while its child exits', () => { ) // Never in doubt on the way: the child it was handed to took nothing. expect(framedStates(handed)).not.toContain('unknown') - // The exit rejected it, so the exit writes the start's one row. - expect(await startRows()).toEqual([ - agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) - ]) + // The exit rejected it, so the exit writes the start's one row, keyed by it. + expect(await startRows()).toEqual([rowFor(handed)]) }) }) @@ -664,9 +660,7 @@ describe('a run of starts that fail alike', () => { rejection: { kind: 'providerStartFailed', detail: { audience: 'log' } } }) ) - expect(await startRows()).toEqual([ - agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) - ]) + expect(await startRows()).toEqual([rowFor(first)]) }) // Words written for a person name what failed: two that differ are two failures. @@ -704,8 +698,6 @@ describe('a run of starts that fail alike', () => { rejection: PROVIDER_START_FAILED }) ) - expect(await startRows()).toEqual([ - agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-2')) - ]) + expect(await startRows()).toEqual([rowFor(first)]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 451795bceb6d..466f9a108861 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -199,7 +199,7 @@ describe('the one row a failed start leaves', () => { }) } - it("is the exit's for the messages it rejected", async () => { + it("is the exit's for the messages it rejected, keyed by the one its words are for", async () => { const session = startingSession( [{ clientMessageId: 'handed-1', dispatchState: 'pending', handedOverAt: 1, fence: 7 }], ['handed-1'] @@ -209,7 +209,30 @@ describe('the one row a failed start leaves', () => { expect(rowsWritten(session)).toEqual([ expect.objectContaining({ - identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` } + identity: { provider: 'orca', clientMessageId: 'start-failure:handed-1' } + }) + ]) + }) + + // Keyed by the /compact, it is a command's row: it never speaks for a later message's failure. + it('is keyed by the /compact it rejected when its words name the command', async () => { + const base = startingSession( + [{ clientMessageId: 'compact-1', dispatchState: 'pending', handedOverAt: 1, fence: 7 }], + ['compact-1'] + ) + const session = { + ...base, + journal: { ...base.journal, itemBody: () => structuredAgentSessionCompactBody() } + } + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(rowsWritten(session)).toEqual([ + expect.objectContaining({ + identity: { provider: 'orca', clientMessageId: 'start-failure:compact-1' }, + body: expect.objectContaining({ + text: 'Claude stopped before it finished starting. Run /compact again.' + }) }) ]) }) 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 ed54883b1347..6fece084fee7 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 @@ -107,10 +107,9 @@ export function agentSessionFailureStatedByStartRow( } /** Whether the row of the start that rejected this message already says why. A row keyed by the - * message is its own start's and speaks for it alone, so its failure decides. With none, as in a - * chat an older host wrote — one row for a batch, keyed by its start or by its oldest message — - * or a run this host left one row for, any loaded row with the same failure does, but a - * command's. */ + * message is its own start's and speaks for it alone, so its failure decides. With none — a run + * left one row for it, or an exit wrote one keyed by its start — any loaded row with the same + * failure does, but a command's. */ function rejectionStatedByItsStartRow( recorded: AgentJournalSubmission, startFailures: readonly StatedStartFailure[] diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 8b0b235d7b5a..8f7b63876970 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -69,40 +69,6 @@ afterEach(async () => { await i18n.changeLanguage('en') }) -describe("a /compact whose start failed, beside the chat's start-failure rows", () => { - const row = (itemId: string) => ({ itemId, fact: START_FAILED, ofCommand: true }) - - it('says nothing under the composer when its own start wrote the row', async () => { - let loaded: ReturnType[] = [] - const outcome = await sendStructuredConversationCommand({ - command: 'compact', - agentName: 'Claude', - pending: { current: false }, - blocked: false, - startFailures: () => loaded, - send: async () => { - loaded = [row('orca:start-failure%3Acompact-1')] - return { kind: 'done', value: hostResult('compact', START_FAILED) } - } - }) - expect(outcome).toEqual({ accepted: false, error: null }) - }) - - // An older start's row with the same failure is not this command's: its own words show. - it('keeps its words when the only row with that failure was loaded before it was sent', async () => { - const result = hostResult('compact', START_FAILED) - const outcome = await sendStructuredConversationCommand({ - command: 'compact', - agentName: 'Claude', - pending: { current: false }, - blocked: false, - startFailures: () => [row('orca:start-failure%3Aolder')], - send: async () => ({ kind: 'done', value: result }) - }) - expect(outcome).toEqual({ accepted: false, error: result.error }) - }) -}) - describe('the line under the composer after a conversation command failed', () => { it('says in English exactly what the host wrote, for every failure a command reports', async () => { for (const provider of ['claude', 'codex'] as const) { diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index b9f831e9ca48..f3267d127c52 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -19,8 +19,7 @@ export async function sendStructuredConversationCommand(input: { agentName: string pending: { current: boolean } blocked: boolean - /** What the chat's loaded start-failure rows state, read before the send and when the reply - * lands. */ + /** What the chat's loaded start-failure rows state, read when the reply lands. */ startFailures: () => readonly StatedStartFailure[] send: ( command: AgentSessionConversationCommand @@ -36,8 +35,6 @@ export async function sendStructuredConversationCommand(input: { } } input.pending.current = true - // Only a row this command's own start wrote says why it failed; one already loaded is another's. - const loadedBefore = new Set(input.startFailures().map((stated) => stated.itemId)) try { const outcome = await input.send(input.command) if (outcome.kind === 'not-done') { @@ -54,10 +51,7 @@ export async function sendStructuredConversationCommand(input: { if ( isAgentSessionConversationCommand(value.command) && value.command !== 'clear' && - agentSessionFailureStatedByStartRow( - value.failure, - input.startFailures().filter((stated) => !loadedBefore.has(stated.itemId)) - ) + agentSessionFailureStatedByStartRow(value.failure, input.startFailures()) ) { return { accepted: false, error: null } } From 2c807017a5f44e76f301b3e1896012236395b8dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:44:05 -0700 Subject: [PATCH 23/26] chore(native-chat): the delivery loop's deps type lives with the loop again, and the awaited command reads an empty id as none --- ...-agent-session-accept-then-deliver.test.ts | 45 ++++++---------- .../structured-agent-session-command-turn.ts | 2 +- ...ctured-agent-session-delivery-loop-deps.ts | 43 --------------- .../structured-agent-session-delivery-loop.ts | 52 ++++++++++++++++--- 4 files changed, 62 insertions(+), 80 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts 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 0a2773cc95d0..8a2e857edbd8 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 @@ -637,41 +637,32 @@ describe('a start that fails after it was handed messages', () => { })) await host.close(SESSION, 'evict') await startHost() + // The latest child exits before it proves its start, with the CLI's own words for why. + const exitDuringStart = () => + host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server exited with code 1', + failure: agentSessionFailureFact('providerStartFailed', { + detail: { text: 'codex: config.toml is invalid', audience: 'person' } + }), + cause: 'unexpected-exit', + startupUnproven: true + }) const first = await accept('first') const second = await accept('second') await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) // The second's pass found nothing to hand over yet, so it waits on no start. await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - reason: 'codex app-server exited with code 1', - failure: agentSessionFailureFact('providerStartFailed', { - detail: { text: 'codex: config.toml is invalid', audience: 'person' } - }), - cause: 'unexpected-exit', - startupUnproven: true - }) - await host.flushStreamedEvents(SESSION) + await exitDuringStart() // The held message was never handed to that child: it gets a start of its own, which fails alike. await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) expect((await submission(second))?.dispatchState).toBe('pending') - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - reason: 'codex app-server exited with code 1', - failure: agentSessionFailureFact('providerStartFailed', { - detail: { text: 'codex: config.toml is invalid', audience: 'person' } - }), - cause: 'unexpected-exit', - startupUnproven: true - }) + await exitDuringStart() await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => item.itemId.includes('start-failure') @@ -680,9 +671,7 @@ describe('a start that fails after it was handed messages', () => { // diagnostic, as is every message it speaks for. expect(rows).toHaveLength(1) const row = rows[0].body - expect(row).toMatchObject({ - failure: { kind: 'providerStartFailed', detail: { text: 'codex: config.toml is invalid' } } - }) + expect(row).toMatchObject({ failure: { detail: { text: 'codex: config.toml is invalid' } } }) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index 5d7109dc8430..7650e7440fb5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -94,7 +94,7 @@ export function structuredAgentSessionAwaitedCommand( journal: StructuredAgentSessionAwaitedCommandJournal ): AgentSessionConversationCommand | undefined { const awaited = structuredAgentSessionAwaitedMessage(journal) - return awaited && structuredAgentSessionMessageCommand(journal, awaited) + return awaited === undefined ? undefined : structuredAgentSessionMessageCommand(journal, awaited) } /** The command a message's own body sends: a start that fails it leaves that command to run again, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts deleted file mode 100644 index 41f5e59119b1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts +++ /dev/null @@ -1,43 +0,0 @@ -// What the delivery loop reads of the host: its sessions, its queue, the adapter, and the start -// and settlement steps it drives. - -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' - -export type StructuredAgentSessionDeliveryLoopDeps = { - sessions: ReadonlyMap - adapter: StructuredAgentSessionAdapter - agents: StructuredAgentRegistry - serialize: (sessionId: string, task: () => Promise) => Promise - /** A start step, tracked from enqueue so quit waits for the child it may produce. */ - trackStart: (start: Promise) => Promise - /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ - ensureProviderChild: ( - sessionId: string, - startedFor: string - ) => Promise - /** Ends the session's child, whose start failed, as a host stop; for a caller inside `serialize`. */ - endFailedStart: (sessionId: string) => Promise - /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ - conversationFence: (sessionId: string) => number - /** Settles queued messages as a completed close of the chat does; false when that failed. */ - holdClosed: ( - sessionId: string, - which: (submission: AgentJournalSubmission) => boolean - ) => Promise - /** Who the chat's failure sentences name. */ - failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext - logger: StructuredAgentSessionLogger - record: (sessionId: string) => AgentSessionRecord | null - readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined - /** A person's Stop is still ending the session's work: the status feed's own reading. */ - stopping: (sessionId: string) => boolean - now: () => number -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 59116923f86f..009a4a09f7f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -5,20 +5,26 @@ // for a session exactly while a message is queued there — accepted, not yet handed over — and no // child is running a conversation command: a command's turn takes no input, and the commit that // ends it wakes the loop again. Every step re-reads the journal and the conversation's child -// record to decide, so there is no loop state to disagree with them. Each step is its own -// serialized task. That is what lets a Stop that arrives while a start holds the queue withdraw the -// queued messages before the handover that would have written them. Stop and the conversation's -// close are the only other writers of a queued message: a child's exit only ends the child, and -// this loop reads why. A message an earlier host process left queued is never handed over: the -// open, or this loop's first step, settles it first (`journal-unsent-send-hold.ts`). +// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop +// that arrives while a start holds the queue withdraw the queued messages before the handover that +// would have written them. Stop and the conversation's close are the only other writers of a +// queued message: a child's exit only ends the child, and this loop reads why. A message an +// earlier host process left queued is never handed over: the open, or this loop's first step, +// settles it first (`journal-unsent-send-hold.ts`). // // A start that fails is recorded on the one message it was for, and the messages behind it each // get their own start. Every write names a message fixed when its pass chose it, never the queue's // head read again after a failure. +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { @@ -37,9 +43,39 @@ import { import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' -import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -export type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' +export type StructuredAgentSessionDeliveryLoopDeps = { + sessions: ReadonlyMap + adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry + serialize: (sessionId: string, task: () => Promise) => Promise + /** A start step, tracked from enqueue so quit waits for the child it may produce. */ + trackStart: (start: Promise) => Promise + /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise + /** Ends the session's child, whose start failed, as a host stop; for a caller inside `serialize`. */ + endFailedStart: (sessionId: string) => Promise + /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ + conversationFence: (sessionId: string) => number + /** Settles queued messages as a completed close of the chat does; false when that failed. */ + holdClosed: ( + sessionId: string, + which: (submission: AgentJournalSubmission) => boolean + ) => Promise + /** Who the chat's failure sentences name. */ + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext + logger: StructuredAgentSessionLogger + record: (sessionId: string) => AgentSessionRecord | null + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** A person's Stop is still ending the session's work: the status feed's own reading. */ + stopping: (sessionId: string) => boolean + now: () => number +} type Step = 'continue' | 'stop' From 227feeee433ea475875ef918997b1dbd6a2ca805 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:03:02 -0700 Subject: [PATCH 24/26] fix(native-chat): a message that only joined a failed start gets its own, and the exit words its row only for a message it was handed - The delivery loop charges a gone child's failure only to the message that child was started for; a message whose pass merely joined that start goes on to its own. - The exit reads the message its words are for among sends it was handed, so a /compact still queued never words, or keys, the exit's row. - A send a child past its start refuses because it ended writes no start-failure row; the exit's own row says why. - The exit checks for itself whether the message its start was for is still queued. - Restart continuation is left as on main (its note beside a failed start is main's). --- .../journal-start-failure-run.ts | 26 +++++----- .../structured-agent-session-child-exit.ts | 19 +------- .../structured-agent-session-command-turn.ts | 11 +++-- ...red-agent-session-conversation-lifetime.ts | 2 +- ...gent-session-dead-generation-settlement.ts | 8 ++-- .../structured-agent-session-delivery-loop.ts | 4 ++ ...gent-session-provider-child-record.test.ts | 1 - ...ured-agent-session-restart-continuation.ts | 48 +++++++++---------- ...ent-session-restart-failure-filing.test.ts | 40 ++++------------ ...ed-agent-session-restart-ownership.test.ts | 13 ++--- ...-agent-session-start-failure-settlement.ts | 23 +++++---- ...agent-session-start-failure-writer.test.ts | 41 ++++++++++++++++ ...agent-session-startup-failure-exit.test.ts | 20 ++++++++ .../structured-agent-session-turns.ts | 7 ++- ...session-delivery-notices.start-row.test.ts | 2 +- ...-session-delivery-notices.test-fixture.ts} | 0 ...red-agent-session-delivery-notices.test.ts | 2 +- ...ctured-agent-session-dispatch-rejection.ts | 19 +++----- 18 files changed, 157 insertions(+), 129 deletions(-) rename src/renderer/src/components/native-chat/{structured-agent-session-delivery-notices-test-fixtures.ts => structured-agent-session-delivery-notices.test-fixture.ts} (100%) diff --git a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts index 8390cbc5da93..bc5b2d070f03 100644 --- a/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts +++ b/src/main/native-chat/agent-session-journal/journal-start-failure-run.ts @@ -55,20 +55,16 @@ function journalStartFailureAlreadyStated( } const stated = latest?.body.kind === 'status' ? readAgentSessionFailureFact(latest.body.failure) : undefined - // A command's row names the command's next step, so it starts no run for a message. - if ( + return ( latest !== undefined && - isStructuredAgentSessionCommandStartFailureRow(latest.itemId, (id) => state.items.get(id)?.body) - ) { - return false - } - if ( - latest === undefined || - stated === undefined || - !sameAgentSessionFailureFact(stated, failure) - ) { - return false - } - // Any send accepted after the row, a command too, ends its run: the next failure is news. - return state.latestAcceptedSequence < latest.sequence + stated !== undefined && + sameAgentSessionFailureFact(stated, failure) && + // A command's row names the command's next step, so it starts no run for a message. + !isStructuredAgentSessionCommandStartFailureRow( + latest.itemId, + (id) => state.items.get(id)?.body + ) && + // Any send accepted after the row, a command too, ends its run: the next failure is news. + state.latestAcceptedSequence < latest.sequence + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index d17309986e53..158cfadebabb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -8,7 +8,6 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -217,7 +216,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< ? { exitedDuringStartup: { generation: child.generation, - chargedToQueued: startChargedToQueued(session, child) + startedFor: child.startedFor } } : {}), @@ -265,22 +264,6 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } } -/** Whether the message this start was for is still queued, so the delivery loop rejects it with - * the start's failure. */ -function startChargedToQueued( - session: StructuredAgentSessionChildExitSession, - child: StructuredAgentSessionProviderChild -): boolean { - const { startedFor } = child - return ( - startedFor !== undefined && - (session.journal.submissions?.() ?? []).some( - (submission) => - submission.clientMessageId === startedFor && isQueuedAgentJournalSubmission(submission) - ) - ) -} - function logExitFailure( context: Pick, sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index 7650e7440fb5..203aef6b827c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -6,6 +6,7 @@ // and a child that ends first is settled with it. The host writes a command's end only when the // provider never took it. While the turn runs it takes no input, so the loop hands nothing over. +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { agentSessionFailureFact, type AgentSessionFailureFact, @@ -63,7 +64,7 @@ export function structuredAgentSessionCompactBody(): AgentJournalMessageItem { type AwaitedSubmission = Pick< AgentJournalSubmission, - 'clientMessageId' | 'dispatchState' | 'acceptedSequence' + 'clientMessageId' | 'dispatchState' | 'acceptedSequence' | 'handoverRecorded' | 'handedOverAt' > /** Optional `submissions` as the dead-generation journal reads it. */ @@ -72,7 +73,8 @@ export type StructuredAgentSessionAwaitedCommandJournal = { itemBody: AgentSessionJournal['itemBody'] } -/** The oldest message still waiting on the provider: a start that fails now fails it first. */ +/** The oldest message handed to the provider and not yet answered: a start that fails now fails it + * first. A queued one gets a start of its own. */ export function structuredAgentSessionAwaitedMessage( journal: Pick ): string | undefined { @@ -80,6 +82,7 @@ export function structuredAgentSessionAwaitedMessage( for (const submission of journal.submissions?.() ?? []) { if ( submission.dispatchState === 'pending' && + !isQueuedAgentJournalSubmission(submission) && (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) ) { oldest = submission @@ -88,8 +91,8 @@ export function structuredAgentSessionAwaitedMessage( return oldest?.clientMessageId } -/** The command the oldest message still waiting on the provider names: its next step is to run the - * command again. */ +/** The command the oldest message handed to the provider names: its next step is to run the command + * again. */ export function structuredAgentSessionAwaitedCommand( journal: StructuredAgentSessionAwaitedCommandJournal ): AgentSessionConversationCommand | undefined { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 633175123c40..0c454a925c89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -85,7 +85,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), // A host stop: the child's end rejects what it was handed, and the delivery loop the message it - // waited for, each with that start's one row, worded from the hostStopped fact. + // was started for, each with that start's one row, worded from the hostStopped fact. stopStartingAgent: (sessionId) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' 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 6d940cd2a3be..2757b8cc695b 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 @@ -73,9 +73,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's - * generation, for the row a start no message carries leaves. `chargedToQueued`: a queued - * message is still waiting on this start, so the delivery loop rejects it with its own row. */ - exitedDuringStartup?: { generation: string | null; chargedToQueued?: boolean } + * generation, for the row a start no message carries leaves; `startedFor`, the message it was + * started for, which the delivery loop rejects with its own row while it is still queued. */ + exitedDuringStartup?: { generation: string | null; startedFor?: string } /** The exit, watched: what that child's own translator could only end `unverifiable` (its stream * closed before the exit was proven) is revised in this batch. */ exit?: StructuredAgentSessionWatchedExit @@ -116,7 +116,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { wordedFor, fence: input.fence, rejected: settled, - chargedToQueued: input.exitedDuringStartup.chargedToQueued === true, + startedFor: input.exitedDuringStartup.startedFor, words: startupFailure }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 009a4a09f7f6..880f8e5bf49b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -266,6 +266,10 @@ export class StructuredAgentSessionDeliveryLoop { if (endedFailure === null) { return 'continue' } + // A start this message only joined fails the message it was for; this one starts afresh. + if (ended && ended.startedFor !== waitingFor) { + return 'continue' + } // A child still here is ended by the next message's step (`childWhoseStartFailed`), after any // exit of its own already queued: the message's failure never waits on that cleanup. return this.rejectStartFailure( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 154467dfe245..a13d1ebdf80b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -233,7 +233,6 @@ function exit(child: ReturnType, reason: string, startupUnp }) } -/** Whether a subscriber was told the message was rejected. */ /** Whether a subscriber was told the message was rejected. */ function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { return events.some( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index caba2a6438eb..67006fd51d03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -27,7 +27,6 @@ import { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import { isFailedStartOrHostFault } from '../../../shared/structured-agent-session-dispatch-rejection' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -201,13 +200,9 @@ export type StructuredAgentSessionContinuationDeps = { } /** A continuation handed to its agent, or already decided. */ -export type StartedStructuredAgentSessionContinuation< - TOutcome = StructuredAgentSessionContinuationOutcome -> = { done: TOutcome } | { verdict: () => Promise } - -/** `saidOnMessage`: its agent did not start for it, which the continuation message itself says in - * the chat, as any message's failed start does; a note would say it twice. Never reported. */ -type ContinuationResult = StructuredAgentSessionContinuationOutcome & { saidOnMessage?: true } +export type StartedStructuredAgentSessionContinuation = + | { done: StructuredAgentSessionContinuationOutcome } + | { verdict: () => Promise } /** * Sends the continuation to ONE session and returns once the agent has taken it or its start @@ -221,7 +216,7 @@ export async function startStructuredAgentSessionContinuation( /** This action's continuation, as its offer recorded it. */ continuationId: string ): Promise { - let started: StartedStructuredAgentSessionContinuation + let started: StartedStructuredAgentSessionContinuation try { started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { @@ -232,17 +227,24 @@ export async function startStructuredAgentSessionContinuation( throw error } if ('done' in started) { - return { done: await noteOutcome(deps, sessionId, started.done) } + await noteOutcome(deps, sessionId, started.done) + return started } const { verdict } = started - return { verdict: async () => noteOutcome(deps, sessionId, await verdict()) } + return { + verdict: async () => { + const outcome = await verdict() + await noteOutcome(deps, sessionId, outcome) + return outcome + } + } } async function noteOutcome( deps: Pick, sessionId: string, - { saidOnMessage, ...result }: ContinuationResult -): Promise { + result: StructuredAgentSessionContinuationOutcome +): Promise { if (result.outcome === 'continued') { // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to // carry on. Best effort — losing it must not turn a delivered continuation into a failure. @@ -251,7 +253,7 @@ async function noteOutcome( } catch { reportNoteFailed(deps, sessionId) } - } else if (!saidOnMessage) { + } else { await noteNotContinued( deps, sessionId, @@ -262,7 +264,6 @@ async function noteOutcome( : 'refused' ) } - return result } /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, @@ -292,7 +293,7 @@ async function sendContinuation( sessionId: string, marker: AgentSessionResumeMarker, continuationId: string -): Promise> { +): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } @@ -341,25 +342,24 @@ async function sendContinuation( } } -function refusedBy(sessionId: string, submission: ContinuationSubmission): ContinuationResult { +function refusedBy( + sessionId: string, + submission: ContinuationSubmission +): StructuredAgentSessionContinuationOutcome { // A start the agent was refused files that refusal's code, which the failure guidance keys on. const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal - const reason = submission.reason ?? null return { sessionId, outcome: 'refused', - reason: refusal?.code ?? reason ?? 'agent_session_dispatch_rejected', - ...(refusal ? { refusal } : {}), - ...(isFailedStartOrHostFault({ reason, rejection: submission.rejection }) - ? { saidOnMessage: true as const } - : {}) + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) } } function verdictOf( sessionId: string, submission: ContinuationSubmission | undefined -): ContinuationResult { +): StructuredAgentSessionContinuationOutcome { const dispatch = submission?.dispatchState if (submission && dispatch === 'rejected') { return refusedBy(sessionId, submission) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index ec692634b190..4602630960a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -51,9 +51,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends }) // The continuation is accepted and its agent then fails to start: the message is rejected with the -// cause and its start's row says why, as for any message's failed start, and the failure is filed. -// No note says it a second time; the cut keeps its own notice. -it('says so on the continuation message when the agent cannot start for it', async () => { +// cause, the failure is filed, and the chat says the agent did not carry on. +it('says so in the chat when the agent cannot start for the continuation', async () => { const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) @@ -65,35 +64,12 @@ it('says so on the continuation message when the agent cannot start for it', asy expect(await host.restartResume.listFailures()).toMatchObject([ { sessionId: SESSION, outcome: 'refused', retryable: true } ]) - expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ - { dispatchState: 'rejected', rejection: { kind: 'restartFailed' } } - ]) - expect((await statusNotes(host)).map((note) => note.text)).not.toContain( - AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE - ) - expect(await readerNotes(host)).toEqual([ - QUIT_CUT_NOTICE, - { text: "Codex couldn't restart. Send your message to try again.", tone: 'error' } - ]) -}) - -// Orca's own fault on the way to the continuation's start says so on the message, as any failed -// start does, so no note repeats it. -it("notes nothing beside a continuation that Orca's own fault kept from its agent", async () => { - const { host } = await interruptedRestart() - await host.restartResume.list() - vi.spyOn(host['conversationDelivery'].loop['deps'], 'ensureProviderChild').mockRejectedValueOnce( - new Error('spawn-token mint failed') - ) - - await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ - { dispatchState: 'rejected', rejection: { kind: 'hostFault' } } - ]) - expect((await statusNotes(host)).map((note) => note.text)).not.toContain( - AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE - ) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + // The refusal note is now the one explanation: the cut is not said a second time beside it. + expect(await readerNotes(host)).toEqual(await statusNotes(host)) }) // A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in 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 8648e41774f4..23d2e5897761 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 @@ -170,8 +170,8 @@ it('keeps a continuation unconfirmed when its send throws after acceptance', asy ).toMatchObject({ outcome: { status: 'succeeded' } }) }) -// The continuation is accepted, then its start fails: the message is rejected with the cause, which -// it says in the chat, the failure is filed, and nothing is stopped because nothing started. +// The continuation is accepted, then its start fails: the message is rejected with the cause and +// the failure is filed, and nothing is stopped because nothing started. it('rejects the continuation when its start fails, and files a retryable refusal', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) @@ -180,11 +180,12 @@ it('rejects the continuation when its start fails, and files a retryable refusal expect(result.continued).toMatchObject([{ outcome: 'refused' }]) // Nothing ran, so the offer stands as a failure a retry can act on. expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) - expect((await statusNotes(host)).map((note) => note.text)).not.toContain( - AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE - ) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ - { dispatchState: 'rejected', rejection: { kind: 'restartFailed' } } + { dispatchState: 'rejected' } ]) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts index 649e24239c0d..3ec0334316f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-settlement.ts @@ -2,9 +2,9 @@ // message, in one write, so a client that hides rejected messages still sees why. The delivery loop // writes it for the queued message a start was for, the handover for the message it was handing // over; the exit writes one row for the handed messages it rejected, keyed by the oldest, whose -// words it carries, or one keyed by the start for a start no message carries. Each message state has one writer, and `rejected` is terminal, so no -// message is failed twice. A start failing as its run's row already says writes no row; its message -// is read under that one. +// words it carries, or one keyed by the start for a start no message carries. Each message state +// has one writer, and `rejected` is terminal, so no message is failed twice. A start failing as +// its run's row already says writes no row; its message is read under that one. import type { AgentJournalDispatchRejection, @@ -115,6 +115,9 @@ export function rejectStructuredAgentSessionStartFailure( type FailedStartSubmission = Pick & Partial> +type ExitSubmission = FailedStartSubmission & + Pick + /** Whether a message was rejected as the failed start of the child under `fence`. */ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence: number): boolean { return ( @@ -131,14 +134,15 @@ export function rejectedAsFailedStartAt(submission: FailedStartSubmission, fence * rewind start, or one no message is charged with — has only this row, keyed by the start, to say * why. The journal's lane drops it when its run's row already says it. */ export function exitStartFailureRow( - journal: { submissions?: () => FailedStartSubmission[] }, + journal: { submissions?: () => ExitSubmission[] }, exit: { startKey: string /** The message the words are for, if any. */ wordedFor: string | undefined + /** The message the start was for, if any. */ + startedFor: string | undefined fence: number rejected: readonly string[] - chargedToQueued: boolean words: AgentJournalDispatchRejection } ): JournalLifecycleMutationInput[] { @@ -149,11 +153,12 @@ export function exitStartFailureRow( : exit.startKey return [structuredAgentSessionStartFailureRow(key, exit.words)] } - const recordedElsewhere = - exit.chargedToQueued || - (journal.submissions?.() ?? []).some((submission) => + const recordedElsewhere = (journal.submissions?.() ?? []).some( + (submission) => + (submission.clientMessageId === exit.startedFor && + isQueuedAgentJournalSubmission(submission)) || rejectedAsFailedStartAt(submission, exit.fence) - ) + ) return recordedElsewhere ? [] : [structuredAgentSessionStartFailureRow(exit.startKey, exit.words)] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 395dfc931b83..7b9377800fc4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -317,6 +317,33 @@ describe('a queued message whose start fails', () => { expect(closeSession).toHaveBeenCalledOnce() expect(generation).toBe(3) }) + // A child past its start that dies says so in its exit's own row. + it('writes no start row for a send a started child refused because it ended', async () => { + dispatch.mockImplementation(accepted) + const first = await send('first') + await eventually(async () => + expect(await submission(first)).toMatchObject({ dispatchState: 'accepted' }) + ) + await host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}`, + reportedOptions: { model: 'gpt-5' }, + restoreSkippedOptions: [] + }) + dispatch.mockResolvedValueOnce({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + surface: 'rejection' + }) + }) + + const second = await send('second') + + await rejected(second) + expect(await startRows()).toEqual([]) + }) }) describe('a failure on the way to recording a failed start', () => { @@ -461,6 +488,20 @@ describe('a start that fails while its child exits', () => { expect(framedStates(queued)).not.toContain('rejected') expect(await startRows()).toEqual([rowFor(handed)]) }) + + // Its pass joined the starting child and waits to hand it over when the exit lands between steps. + it('gives a message that only joined the failed start its own start', async () => { + const handed = await sendHanded('handed') + dispatch.mockImplementation(accepted) + const joined = await send('joined') + + await exitBeforeProof() + + await rejected(handed) + await eventually(() => expect(dispatched()).toEqual([handed, joined])) + expect(framedStates(joined)).not.toContain('rejected') + expect(await startRows()).toEqual([rowFor(handed)]) + }) }) // One row speaks for a run of starts that fail alike, until a turn is delivered. Each message still diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 5d719ac32c6e..98bbb5c17888 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -256,6 +256,26 @@ describe('the one row a failed start leaves', () => { expect(rowsWritten(session)).toEqual([]) }) + // The /compact waits for a start of its own, which writes its own row if it fails too. + it('is not worded for a /compact still queued behind the start', async () => { + const base = startingSession([ + { clientMessageId: 'compact-1', dispatchState: 'pending', handoverRecorded: true } + ]) + const session = { + ...base, + journal: { ...base.journal, itemBody: () => structuredAgentSessionCompactBody() } + } + + await settleStructuredAgentSessionChildExit(contextFor(session), ended) + + expect(rowsWritten(session)).toEqual([ + expect.objectContaining({ + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + body: expect.objectContaining({ text: STARTUP_TEXT }) + }) + ]) + }) + it("is not written again beside a message already rejected as this start, with its writer's row", async () => { const session = startingSession([ { 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 ce52de3f6ded..0f209b579bda 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 @@ -227,8 +227,13 @@ export async function handOverSubmission( return } try { - if (outcome.state === 'rejected' && isFailedStartOrHostFault(outcome)) { + if ( + outcome.state === 'rejected' && + isFailedStartOrHostFault(outcome) && + ctx.providerChildPhase?.() !== 'ready' + ) { // A start that failed under it: the message and that start's row, as any failed start leaves. + // A child past its start that dies says so in its exit's own row. await rejectWithStartFailureRow(ctx.journal, { clientMessageId, words: { reason: outcome.reason, rejection: outcome.rejection }, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts index 3af27291e206..ba34711fd329 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.start-row.test.ts @@ -15,7 +15,7 @@ import { } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' -import { entry, texts } from './structured-agent-session-delivery-notices-test-fixtures' +import { entry, texts } from './structured-agent-session-delivery-notices.test-fixture' const startFailed: AgentSessionFailureFact = { kind: 'startFailed', diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-test-fixtures.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test-fixture.ts similarity index 100% rename from src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-test-fixtures.ts rename to src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test-fixture.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 9b0c128082b3..b9c3f79a4b4b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -16,7 +16,7 @@ import { NOT_FAILED_HERE, SENDING, texts -} from './structured-agent-session-delivery-notices-test-fixtures' +} from './structured-agent-session-delivery-notices.test-fixture' describe('the notice on each message that did not go through', () => { // Recorded by the host, so sending one again is a new message: no Retry. diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index d6287b34e710..f505ca613b0b 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -174,23 +174,18 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } -/** Rejected because the agent it waited on never took it: what a failed start writes, whatever - * the person may have to do first. Orca stopping a start that hung is one, and so is the agent - * ending before it was handed the message. */ -function isFailedStartRejection( - submission: Pick & { rejection?: unknown } -): boolean { - const { category, kind } = classifyDispatchRejection(submission) - return category === 'startFailed' || kind === 'hostStopped' || kind === 'providerExited' -} - -/** A failed start, or Orca's own fault on the way to one: either way the message never reached an +/** Rejected because the agent it waited on never took it — a failed start, Orca stopping a start + * that hung, the agent ending first — or Orca's own fault on the way: the message never reached an * agent, and its record says why. */ export function isFailedStartOrHostFault( submission: Pick & { rejection?: unknown } ): boolean { + const { category, kind } = classifyDispatchRejection(submission) return ( - isFailedStartRejection(submission) || classifyDispatchRejection(submission).kind === 'hostFault' + category === 'startFailed' || + kind === 'hostStopped' || + kind === 'providerExited' || + kind === 'hostFault' ) } From 0c1224262dce555e389514c62614769081ffa2d7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:28:20 -0700 Subject: [PATCH 25/26] test(native-chat): the lease-release test hands its message to the starting child before that child exits --- .../structured-agent-session-append-delivery.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index b80135108056..fae7aa03e984 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -161,13 +161,17 @@ afterEach(async () => { describe('an open chat receives every row its journal commits', () => { it('shows a failed start whose lease could not be handed back', async () => { const held = await send('hello') + // Handed to the starting child, so that child's failed start is this message's. + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) const pane = await liveReader() // The exit settles the journal, then fails to release the lease: nothing moves the fence. vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('record store busy')) await exitBeforeProof() - // The exit ends the child; the delivery loop, which reads why, rejects what it had queued. + // The exit ends the child and rejects what it was handed. await vi.waitFor(() => expect(pane.received().submissions).toContainEqual( expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) From 64e46e9f02ed826f21954bab4c9afa73e4ad0c3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:41:58 -0700 Subject: [PATCH 26/26] test(native-chat): the two new journal-backed start-failure tests run in the Node runtime project --- config/scripts/vitest-sqlite-database-runtime-files.mjs | 1 + config/scripts/vitest-sqlite-runtime-files.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 9098a6f566db..2e659139c87c 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -31,6 +31,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/claude-structured-resumed-start-failure.test.ts', 'src/main/runtime/claude-structured-send-during-startup.test.ts', 'src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts', + 'src/main/runtime/claude-structured-send-signed-out-run.test.ts', 'src/main/runtime/claude-structured-session-integration.test.ts', 'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts', 'src/main/runtime/exit-provenance-audit.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 69df486ddc82..3c8a041ca28e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -117,6 +117,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-run-lane.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts',