From 570b677db4576ff2e6354dc3099313ccbf6093b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:33:52 -0700 Subject: [PATCH 01/20] feat(native-chat): a status for a message waiting on an exit Orca could not verify Adds the `previousExitUnverifiable` failure fact, a status row only, never a reason a message was not sent: Orca couldn't confirm the agent's previous process ended, and the message will send once it has. Copy in every catalog. --- .../native-chat/agent-session-failure-words-text.ts | 6 ++++++ src/renderer/src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 3 ++- src/renderer/src/i18n/locales/es.json | 3 ++- src/renderer/src/i18n/locales/fr.json | 3 ++- src/renderer/src/i18n/locales/ja.json | 3 ++- src/renderer/src/i18n/locales/ko.json | 3 ++- src/renderer/src/i18n/locales/zh.json | 3 ++- src/shared/agent-session-failure-copy.ts | 4 +++- src/shared/agent-session-failure-words.ts | 4 +++- src/shared/agent-session-failure.ts | 7 +++++-- 11 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 26833f720ee3..34dab4ab94f6 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -227,6 +227,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.previousExitUnverifiable', + COPY.previousExitUnverifiable, + values ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e5ad0c295f37..13769bc4303f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2755,6 +2755,7 @@ "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Your message will send once it has.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d8ecc0df0040..c189ea46cf2e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17526,7 +17526,8 @@ "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", - "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}." + "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Your message will send once it has." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5ab6bbaaa5dc..6ac412fd23be 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14964,7 +14964,8 @@ "hostStopped": "{{agent}} no terminó de iniciarse, así que Orca lo detuvo.", "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", - "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}." + "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", + "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó. Tu mensaje se enviará en cuanto termine." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6465f23c87ec..990e90adf250 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17438,7 +17438,8 @@ "hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.", "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", - "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}." + "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", + "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté. Votre message sera envoyé dès qu'il le sera." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4af40935b246..e6708910f981 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17374,7 +17374,8 @@ "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。", "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", - "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。" + "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", + "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。終了しだいメッセージを送信します。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a72fbaa9eaa6..599fe59051b2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17374,7 +17374,8 @@ "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.", "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", - "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}." + "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", + "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다. 종료되는 즉시 메시지를 보냅니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cf73cbb7d8ff..a4df421b64da 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17339,7 +17339,8 @@ "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。", "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", - "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。" + "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", + "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。进程结束后将发送你的消息。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 20d6a69e1127..d9f3c7a0752a 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -80,7 +80,9 @@ export const AGENT_SESSION_FAILURE_COPY = { hostStopped: '{{agent}} never finished starting, so Orca stopped it.', providerRateLimited: '{{agent}} is rate-limited and retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', - providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.' + providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', + previousExitUnverifiable: + "Orca couldn't confirm {{agent}}'s previous process ended. Your message will send once it has." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 47c32fa45992..f64ba5cb3352 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -267,7 +267,9 @@ const FAILURE_SENTENCES = { agent(say, context) ), retry?.cause - ) + ), + previousExitUnverifiable: (context, _fact, _surface, say) => + say('previousExitUnverifiable', agent(say, context)) } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 14bc8eb3a1e4..b08c5250a150 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -47,7 +47,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ /** Orca stopped an agent whose start never finished. */ 'hostStopped', /** The provider is retrying a request its API refused; not a failure yet. */ - 'providerRetrying' + 'providerRetrying', + /** A message waits on a child a Stop could not prove gone: its exit is unverifiable. */ + 'previousExitUnverifiable' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -62,7 +64,8 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'cancelUnconfirmed', 'stopRefused', 'answerUnconfirmed', - 'providerRetrying' + 'providerRetrying', + 'previousExitUnverifiable' ] as const satisfies readonly AgentSessionFailureKind[] /** Why a message was not sent. A new failure kind is one of these until listed above. */ From 20e565108f3e307535b50c48993f270defd3ace8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:33:53 -0700 Subject: [PATCH 02/20] fix(native-chat): a message after a Stop whose exit was unproven retries the stop, then waits When a Claude Stop could not prove its child gone, the child stayed in place with a connection that refuses writes, so the next message failed with "Orca couldn't hand this message to the agent" and every later one did too, until the idle sweep retried the stop after 30 minutes of quiet. The delivery loop now retries an owed stop before it starts or writes to a child, once per new message. Still unproven, the message stays queued under one warning row saying why, and the idle sweep's next tick retries the stop, child or not, and hands the message over once the exit is proven. --- ...-session-claude-unproven-stop-send.test.ts | 313 ++++++++++++++++++ ...d-agent-session-conversation-close.test.ts | 4 + ...red-agent-session-conversation-lifetime.ts | 8 + .../structured-agent-session-delivery-loop.ts | 19 ++ .../structured-agent-session-host-delivery.ts | 3 + .../structured-agent-session-host-lifetime.ts | 23 ++ .../structured-agent-session-host.ts | 2 + ...tructured-agent-session-idle-sweep.test.ts | 2 + .../structured-agent-session-idle-sweep.ts | 19 +- ...ent-session-restore-without-import.test.ts | 1 + ...ctured-agent-session-wind-down-wait-row.ts | 79 +++++ 11 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts new file mode 100644 index 000000000000..93b0cf03130f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -0,0 +1,313 @@ +// A Claude Stop whose close could not prove the child gone, then the user's next message, on the +// shipping adapter and host. That child takes no input, so the send retries the stop first; still +// unproven, the message waits with its reason until a later retry proves the exit. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let sinkErrors: unknown[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-unproven-stop-send-')) + resetHostTestOperationIds() + sinkErrors = [] + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + onEventSinkError: ({ error }) => sinkErrors.push(error), + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields: { ...fields } + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) + } + return sent.value.clientMessageId +} + +async function submission(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +async function openTurn(connection: FakeConnection): Promise { + const text = 'Write a long reply.' + const clientMessageId = await send(text) + await eventually(() => expect(wrote(connection, text)).toBe(true)) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') + ) + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).not.toBeNull() +} + +function stop() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +/** As the real connection: once a close begins it refuses every write, proven or not. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +/** Stops a turn whose child's close cannot prove the exit `failures` times, and lets the Stop's + * second step fail on it. */ +async function stopWithUnprovenClose(failures: number): Promise { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, failures) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + expect(connection.closeCount).toBe(1) + expect(owedWindDown()).toBeDefined() + return connection +} + +function owedWindDown() { + return host['sessions'].get(SESSION)?.owesProviderChildWindDown +} + +async function waitRows() { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure?.kind === 'previousExitUnverifiable' + ? [item.body] + : [] + ) +} + +async function resumedWith(connection: FakeConnection, text: string): Promise { + return eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, text)).toBe(true) + return started + }) +} + +it('retries the close a Stop could not prove before the next message, then sends it to a resumed child', async () => { + const connection = await stopWithUnprovenClose(1) + + const next = await send('Carry on.') + const resumed = await resumedWith(connection, 'Carry on.') + + expect(connection.closeCount).toBe(2) + expect(wrote(connection, 'Carry on.')).toBe(false) + expect(resumed.closed).toBe(false) + expect(owedWindDown()).toBeUndefined() + expect((await submission(next))?.dispatchState).toBe('pending') + expect(await waitRows()).toEqual([]) +}) + +it('holds the message with its reason while the exit stays unverifiable, and sends it once a later retry proves it', async () => { + const connection = await stopWithUnprovenClose(3) + + // Never refused: the send is accepted and returns at once. + const next = await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + expect(await waitRows()).toEqual([ + { + kind: 'status', + tone: 'warning', + text: "Orca couldn't confirm Claude's previous process ended. Your message will send once it has.", + failure: { kind: 'previousExitUnverifiable' } + } + ]) + // Still queued, drawn below the chat; never written to the child the Stop could not end. + expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(next))?.handedOverAt).toBeUndefined() + expect(claude.connections).toHaveLength(1) + expect(wrote(connection, 'Carry on.')).toBe(false) + expect(owedWindDown()).toBeDefined() + // The Stop's attempt and the send's one retry: the row's own commit retries nothing. + expect(connection.closeCount).toBe(2) + + // A second message retries once more, and waits under the same row. + const second = await send('And this.') + await eventually(() => expect(connection.closeCount).toBe(3)) + await laneDrained() + expect(await submission(second)).toMatchObject({ dispatchState: 'pending' }) + expect(await waitRows()).toHaveLength(1) + + // The sweep's next tick retries the stop; the exit is proven and both messages go out. + await host['lifetime'].idleSweep.tick() + const resumed = await resumedWith(connection, 'Carry on.') + await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) + expect(connection.closeCount).toBe(4) + expect(owedWindDown()).toBeUndefined() + expect(await waitRows()).toHaveLength(1) +}) + +it('lets a held message be withdrawn with Stop, and the owed stop still ends on the next retry', async () => { + const connection = await stopWithUnprovenClose(2) + const next = await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) + expect(owedWindDown()).toBeDefined() + + await host['lifetime'].idleSweep.tick() + expect(connection.closeCount).toBe(3) + expect(owedWindDown()).toBeUndefined() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + // Nothing was waiting, so no child starts. + expect(claude.connections).toHaveLength(1) + expect(wrote(connection, 'Carry on.')).toBe(false) +}) 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 2dd2b5229aa7..6f705ae3945c 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 @@ -301,6 +301,7 @@ describe('the wind-down retry with a message queued (P2-31)', () => { owesProviderChildWindDown: { generation: 'generation-1', fence: 1 } } const stopAgent = vi.fn(async () => undefined) + const finishOwedWindDown = vi.fn(async () => true) const sweep = new StructuredAgentSessionIdleSweep({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. sessions: Object.assign(new Map([[SESSION, session as never]]), { @@ -316,6 +317,8 @@ describe('the wind-down retry with a message queued (P2-31)', () => { providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, + finishOwedWindDown, + wakeDelivery: () => undefined, closeConversation: vi.fn(async () => false), onError: (_id, error) => { throw error @@ -323,5 +326,6 @@ describe('the wind-down retry with a message queued (P2-31)', () => { }) await sweep.tick() expect(stopAgent).not.toHaveBeenCalled() + expect(finishOwedWindDown).not.toHaveBeenCalled() }) }) 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 1980616f1765..695c571b3154 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 @@ -15,6 +15,7 @@ import type { StructuredAgentSessionConversations } from './structured-agent-ses import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, + finishOwedStructuredAgentSessionWindDownUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext @@ -37,6 +38,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { /** PR 1's one open function, for a caller inside the session's serialize. */ open: (sessionId: string) => Promise deliveryActive: (sessionId: string) => boolean + /** For a caller inside the session's serialize. */ + wakeDelivery: (sessionId: string) => void /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ closeStatus: (sessionId: string, options: { listed: boolean }) => void /** The session's child records, the host's one read of them. */ @@ -53,6 +56,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) + const finishOwedWindDown = (sessionId: string) => + finishOwedStructuredAgentSessionWindDownUnderSerialize(host.context(), sessionId) const closeConversation = (sessionId: string): Promise => closeStructuredAgentSessionConversationUnderSerialize( @@ -73,6 +78,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { now: () => host.context().now(), isDisposed: () => disposed, deliveryActive: host.deliveryActive, + wakeDelivery: host.wakeDelivery, childWork: host.readChildWork, hasOpenDispatch: (sessionId) => { const record = deps().store.getRecord(sessionId) @@ -81,6 +87,7 @@ 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, 'evict'), + finishOwedWindDown, // 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. stopStartingAgent: (sessionId) => @@ -95,6 +102,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, + finishOwedWindDown, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true 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 ad149dbf9472..14dd29cf151f 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 @@ -39,6 +39,10 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' +import { + recordStructuredAgentSessionWindDownWait, + structuredAgentSessionWindDownWaitHolds +} from './structured-agent-session-wind-down-wait-row' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -53,6 +57,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessionId: string, startedFor: string ) => Promise + /** Retries a stop that did not finish; resolves whether nothing is owed now. */ + finishOwedWindDown: (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. */ @@ -173,6 +179,19 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } + // A child a stop could not prove gone takes no input, and none may start beside it: a new + // message retries the stop first. Still unproven, it waits, saying why, for the next retry. + if ( + session.owesProviderChildWindDown !== undefined && + (structuredAgentSessionWindDownWaitHolds(session) || + !(await this.deps.finishOwedWindDown(sessionId))) + ) { + await recordStructuredAgentSessionWindDownWait(session, { + fence: this.deps.conversationFence(sessionId), + failureTextContext: this.deps.failureTextContext(sessionId) + }) + return this.stop(sessionId) + } const failedStart = startThatFailedWhileQueued(session, oldest) if (failedStart) { return this.fail(sessionId, failedStart) 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 5d8a4d0429ba..ae8409322cb5 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 @@ -54,6 +54,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + /** Retries a stop that did not finish; resolves whether nothing is owed now. */ + finishOwedWindDown: (sessionId: string) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void clientDelivery: Pick flushStreamedEvents: (sessionId: string) => Promise @@ -65,6 +67,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, + finishOwedWindDown: input.finishOwedWindDown, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), abandonQueued: async (sessionId, which) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 719540da5da8..859f79326af1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -186,6 +186,29 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } +/** + * Retries the wind-down an earlier stop left owed, with that stop's own cause: a child it could + * not prove gone takes no input, so nothing may write to it or start beside it until this lands. + * One pass of the stop, bounded by its own step deadline (10 s): a shorter bound would cut a + * supervised Claude's exit proof (up to about 7 s) short. Resolves whether nothing is owed now; a + * failure is reported, never thrown, and leaves the exit unverifiable, never exited. + */ +export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + const owed = context.sessions.get(sessionId)?.owesProviderChildWindDown + if (!owed) { + return true + } + try { + await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: owed.cause }) + } catch (error) { + context.deps.onEventSinkError?.({ sessionId, error }) + } + return context.sessions.get(sessionId)?.owesProviderChildWindDown === undefined +} + /** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ export type StructuredAgentSessionCloseCause = Extract< StructuredAgentSessionStopCause, 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 37f4a1966101..0adfa3ae19a8 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 @@ -126,6 +126,7 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + finishOwedWindDown: (sessionId) => this.lifetime.finishOwedWindDown(sessionId), reset: (sessionId, journal, reset) => this.subscribers.reset( sessionId, @@ -171,6 +172,7 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), open: (sessionId) => this.conversationDelivery.open(sessionId), deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options), readChildWork: this.clientDelivery.readChildWork }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index cad1d17d94f5..f6dd3f55c7a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -319,6 +319,8 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, + finishOwedWindDown: vi.fn(async () => true), + wakeDelivery: () => undefined, closeConversation, onError: (_id, error) => { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 14cc0e56030d..bb909e163e57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -35,6 +35,10 @@ export type StructuredAgentSessionIdleSweepDeps = { providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise + /** Retries a stop that did not finish; resolves whether nothing is owed now. */ + finishOwedWindDown: (sessionId: string) => Promise + /** Hands a message waiting on that stop to the delivery loop. */ + wakeDelivery: (sessionId: string) => void stopStartingAgent: (sessionId: string) => Promise closeConversation: (sessionId: string) => Promise onError: (sessionId: string, error: unknown) => void @@ -101,14 +105,13 @@ export class StructuredAgentSessionIdleSweep { if (!session || this.deps.isDisposed()) { return } - // A stop that failed after the child was proven gone: finish it now, before the idle test, so - // the rows its settlement wrote cannot push the retry out. A message accepted since goes first. - if ( - session.owesProviderChildWindDown !== undefined && - !session.child && - !this.queuedOrDelivering(sessionId, session) - ) { - await this.deps.stopAgent(sessionId) + // A stop that did not finish: retry it now, before the idle test, so the rows its settlement + // wrote cannot push the retry out. A running delivery step retries it itself; a message left + // waiting on it goes out once it lands. + if (session.owesProviderChildWindDown !== undefined && !this.deps.deliveryActive(sessionId)) { + if (await this.deps.finishOwedWindDown(sessionId)) { + this.deps.wakeDelivery(sessionId) + } return } // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 2ea475cf3bab..d790165775ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -250,6 +250,7 @@ async function restore(sessionIds: readonly string[]) { serialize: async (_sessionId, task) => task(), open, deliveryActive: () => false, + wakeDelivery: () => undefined, closeStatus: () => undefined, readChildWork: () => undefined }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts new file mode 100644 index 000000000000..74082b37a22c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts @@ -0,0 +1,79 @@ +// The row a message waiting on an unfinished stop leaves in the chat: why it has not gone out. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' + +/** Keyed by the child the stop could not prove gone, so every send that waits on it shares a row. */ +export function structuredAgentSessionWindDownWaitIdentity( + owed: StructuredAgentSessionProviderChildIdentity +): AgentJournalItemIdentity { + return { + provider: 'orca', + clientMessageId: `wind-down-wait:${owed.fence}:${owed.generation ?? 'unknown'}` + } +} + +type WaitingSession = Pick< + StructuredAgentSessionHostSession, + 'journal' | 'owesProviderChildWindDown' +> + +function windDownWaitRow( + session: WaitingSession, + owed: StructuredAgentSessionProviderChildIdentity +) { + const itemId = agentJournalItemKey(structuredAgentSessionWindDownWaitIdentity(owed)) + return session.journal.snapshot().items.find((item) => item.itemId === itemId) +} + +/** Every queued message already waited through a retry of the owed stop: its row came after the + * newest. Only a new message or the sweep retries again, so the row's own commit, which wakes the + * delivery loop, does not. */ +export function structuredAgentSessionWindDownWaitHolds(session: WaitingSession): boolean { + const owed = session.owesProviderChildWindDown + const row = owed && windDownWaitRow(session, owed) + if (!row) { + return false + } + return session.journal + .submissions() + .every( + (submission) => + !isQueuedAgentJournalSubmission(submission) || + (submission.acceptedSequence ?? 0) < row.sequence + ) +} + +/** Written once per child, so every message that waits on it shares the row. */ +export async function recordStructuredAgentSessionWindDownWait( + session: WaitingSession, + input: { fence: number; failureTextContext: AgentSessionFailureWordsContext } +): Promise { + const owed = session.owesProviderChildWindDown + if (!owed || windDownWaitRow(session, owed)) { + return + } + const identity = structuredAgentSessionWindDownWaitIdentity(owed) + const words = agentSessionFailureWords(agentSessionFailureFact('previousExitUnverifiable'), { + ...input.failureTextContext, + surface: 'row' + }) + await session.journal.appendItem( + identity, + { kind: 'status', tone: 'warning', ...words }, + { fence: input.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} From 53172a3a0fab253faf96fbe19d65eaedea0d22ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:04:58 -0700 Subject: [PATCH 03/20] fix(native-chat): every operation that reaches the agent finishes an owed stop first Option changes, card answers, background-task stops, goal changes and rewinds went to a child a Stop could not prove gone, whose connection takes no input. The retry now lives in one place, `finishOwedStructuredAgentSessionStop`: `ensureStructuredAgentSessionAgent` calls it before reporting or starting a child, and operations on the running child prepare through it. Still unproven, it refuses with `previousExitUnverifiable` and the exit `unverifiable`; the delivery loop turns that refusal into the visible wait, so a send still waits under its one row instead of being refused. --- .../structured-agent-session-agent-start.ts | 39 +++++++++++++++++++ ...-session-claude-unproven-stop-send.test.ts | 28 +++++++++++++ ...red-agent-session-conversation-lifetime.ts | 1 - .../structured-agent-session-delivery-loop.ts | 30 +++++++------- .../structured-agent-session-host-delivery.ts | 3 -- ...structured-agent-session-host-mutations.ts | 8 ++-- .../structured-agent-session-host.ts | 6 ++- ...ructured-agent-session-mutation-context.ts | 2 + ...ructured-agent-session-send-preparation.ts | 12 ++++++ src/shared/agent-session-refusal-details.ts | 5 ++- src/shared/agent-session-refusal-notice.ts | 4 +- 11 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index a4c93fa00f88..0d3fa157dba5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -28,6 +28,10 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + finishOwedStructuredAgentSessionWindDownUnderSerialize, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' import { structuredAgentSessionResumeOperationId, structuredAgentSessionResumeParams @@ -48,6 +52,37 @@ export type StructuredAgentSessionResumeOutcome = /** The attach's caller key: the ledger row a start settles is Orca's own. */ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' +/** + * Every operation that reaches the provider finishes a stop an earlier attempt left owed first: the + * child that stop could not prove gone takes no input, and none may start beside it. Still + * unproven, the operation is refused with the exit `unverifiable`, never assumed `exited`. + */ +export async function finishOwedStructuredAgentSessionStop( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + if (await finishOwedStructuredAgentSessionWindDownUnderSerialize(context, sessionId)) { + return { ok: true } + } + return { + ok: false, + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' }, + "Orca could not prove this chat's previous agent process exited." + ) + } +} + +export function isStructuredAgentSessionPreviousExitUnverifiable( + refusal: AgentSessionWireRefusal +): boolean { + return ( + refusal.code === 'agent_session_ownership_unknown' && + refusal.details?.reason === 'previousExitUnverifiable' + ) +} + /** * Gives the session a provider child if it has none, for a caller inside its serialize — which is * what makes "if it has none" exact: two askers run this in turn, and the second finds the first @@ -58,6 +93,10 @@ export async function ensureStructuredAgentSessionAgent( sessionId: string, startedFor?: string ): Promise { + const settled = await finishOwedStructuredAgentSessionStop(context, sessionId) + if (!settled.ok) { + return settled + } if (context.sessions.get(sessionId)?.child) { return { ok: true } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 93b0cf03130f..d2113973fe48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -311,3 +311,31 @@ it('lets a held message be withdrawn with Stop, and the owed stop still ends on expect(claude.connections).toHaveLength(1) expect(wrote(connection, 'Carry on.')).toBe(false) }) + +function setModel(model: string) { + const fields = { key: 'model', value: model } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +it('refuses an option change with the exit unverifiable after retrying the stop, never writing to the old child', async () => { + const connection = await stopWithUnprovenClose(2) + + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } + } + }) + expect(connection.closeCount).toBe(2) + expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) + expect(owedWindDown()).toBeDefined() + + // Trying again retries the stop; proven, the pick is the chat's at rest, for the next start. + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.closeCount).toBe(3) + expect(owedWindDown()).toBeUndefined() + expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) + expect(claude.connections).toHaveLength(1) +}) 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 695c571b3154..390e283903b4 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 @@ -102,7 +102,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, - finishOwedWindDown, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true 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 14dd29cf151f..666d4fa1a032 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 @@ -26,7 +26,10 @@ import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import { + isStructuredAgentSessionPreviousExitUnverifiable, + type StructuredAgentSessionResumeOutcome +} from './structured-agent-session-agent-start' import type { StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, @@ -57,8 +60,6 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessionId: string, startedFor: string ) => Promise - /** Retries a stop that did not finish; resolves whether nothing is owed now. */ - finishOwedWindDown: (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. */ @@ -179,17 +180,9 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } - // A child a stop could not prove gone takes no input, and none may start beside it: a new - // message retries the stop first. Still unproven, it waits, saying why, for the next retry. - if ( - session.owesProviderChildWindDown !== undefined && - (structuredAgentSessionWindDownWaitHolds(session) || - !(await this.deps.finishOwedWindDown(sessionId))) - ) { - await recordStructuredAgentSessionWindDownWait(session, { - fence: this.deps.conversationFence(sessionId), - failureTextContext: this.deps.failureTextContext(sessionId) - }) + // Already waiting on a stop that could not prove its child gone: only a new message, or the + // sweep, retries it, so the waiting row's own commit does not. + if (structuredAgentSessionWindDownWaitHolds(session)) { return this.stop(sessionId) } const failedStart = startThatFailedWhileQueued(session, oldest) @@ -197,6 +190,15 @@ export class StructuredAgentSessionDeliveryLoop { return this.fail(sessionId, failedStart) } const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) + if (!ready.ok && isStructuredAgentSessionPreviousExitUnverifiable(ready.refusal)) { + // The start retried that stop first and still could not prove the exit: the message waits, + // saying why, rather than being refused. + await recordStructuredAgentSessionWindDownWait(session, { + fence: this.deps.conversationFence(sessionId), + failureTextContext: this.deps.failureTextContext(sessionId) + }) + return this.stop(sessionId) + } if (!ready.ok) { return ready } 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 ae8409322cb5..5d8a4d0429ba 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 @@ -54,8 +54,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise - /** Retries a stop that did not finish; resolves whether nothing is owed now. */ - finishOwedWindDown: (sessionId: string) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void clientDelivery: Pick flushStreamedEvents: (sessionId: string) => Promise @@ -67,7 +65,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, - finishOwedWindDown: input.finishOwedWindDown, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), abandonQueued: async (sessionId, which) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 5067bcb3fb2d..3fbb96ddaf87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -25,7 +25,7 @@ import { type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import { - openForWrite, + openForProviderWrite, openWithAgent, sendPreparation, structuredAgentSessionSendBlock @@ -101,7 +101,7 @@ export function cancelStructuredAgentSessionTurn( caller, params.envelope, cancelPlan({ ...params, childWork: () => context.readChildWork(params.envelope.sessionId) }), - openForWrite(context, params.envelope) + openForProviderWrite(context, params.envelope) ) } const plan = cancelPlan(params) @@ -130,7 +130,7 @@ export function respondToStructuredAgentSessionPrompt( caller, params.envelope, promptPlan(params), - openForWrite(context, params.envelope) + openForProviderWrite(context, params.envelope) ) } @@ -159,7 +159,7 @@ export async function setStructuredAgentSessionOption( ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) : plan.run(ctx) }, - openForWrite(context, params.envelope) + openForProviderWrite(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 0adfa3ae19a8..0585857e934e 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 @@ -24,7 +24,8 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { ensureStructuredAgentSessionAgent, - ensureStructuredAgentSessionAgentForOperation + ensureStructuredAgentSessionAgentForOperation, + finishOwedStructuredAgentSessionStop } from './structured-agent-session-agent-start' import { createStructuredAgentSessionConversationLifetime, @@ -126,7 +127,6 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), - finishOwedWindDown: (sessionId) => this.lifetime.finishOwedWindDown(sessionId), reset: (sessionId, journal, reset) => this.subscribers.reset( sessionId, @@ -284,6 +284,8 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), + finishOwedStop: (sessionId) => + finishOwedStructuredAgentSessionStop(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 73b47f1fe6e2..02f99104083e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -32,6 +32,8 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise + /** Finishes a stop an earlier attempt left owed; inside the caller's serialize. */ + finishOwedStop: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index be29089991bd..aa813d9b8d22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -77,6 +77,18 @@ export function openForWrite( return () => openConversationForWrite(context.openConversation, envelope) } +/** For an operation the running child performs, which starts none: the conversation, then any + * stop an earlier attempt left owed, so it never reaches a child that takes no input. */ +export function openForProviderWrite( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + return opened.ok ? context.finishOwedStop(envelope.sessionId) : opened + } +} + /** For an operation only the provider can perform: the conversation, then its agent. */ export function openWithAgent( context: Pick, diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 9e697c10b53f..70c6121d03f5 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -66,7 +66,10 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'spawnIdentityMismatch', 'notResumable', 'noProviderChild', - 'conversationHeldElsewhere' + 'conversationHeldElsewhere', + /** A Stop could not prove its child gone, and a retry could not either: that child takes no + * input and none starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ + 'previousExitUnverifiable' ], agent_session_conflict: [ 'chatStarting', diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index b2021797bed6..0f9c47ded03f 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -156,7 +156,9 @@ const REASON_WORDS = { spawnIdentityMismatch: codeWords('hostFinding'), notResumable: codeWords('retry'), noProviderChild: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry') + conversationHeldElsewhere: codeWords('retry'), + // Trying again retries the stop that could not prove the exit. + previousExitUnverifiable: causeWords('ownerUnproven', 'retry') }, agent_session_conflict: { chatStarting: AGENT_STARTING, From c0e87a31c057dfc753bdca51a283310c3c7b6cef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:12:52 -0700 Subject: [PATCH 04/20] test(native-chat): type the option-change envelope in the unproven-stop test --- ...tructured-agent-session-claude-unproven-stop-send.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index d2113973fe48..d430bbbbc668 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -101,7 +101,10 @@ function eventually(assertion: () => T | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } -function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { +function envelope( + method: 'agentSession.send' | 'agentSession.cancel' | 'agentSession.setOption', + fields: object +) { return { sessionId: SESSION, clientOperationId: hostTestOperationId(), From 53118b3fd6d414ffba313beda35a3cf80ee5fb39 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:55:19 -0700 Subject: [PATCH 05/20] fix(native-chat): the stop that proves the exit hands over the message that waited on it - The stop itself wakes delivery where its owed wind-down clears, so a message held on an unproven exit goes out whichever retry lands: an option change, a background-task stop, the sweep or the next message. Only the sweep woke it before, so an option change that proved the exit left the message queued with nothing to send it. - The owed stop keeps where it was asked for, and the child's end is ordered there. A message accepted while retries ran is no longer rejected as "chat closed" (a tab close) or failed as a host stop when the retry that proves the exit lands after it. - A wind-down owed by an earlier child never stops a different live child in front of it. --- ...-session-claude-unproven-stop-send.test.ts | 77 +++++++++++++++++++ ...d-agent-session-conversation-close.test.ts | 1 - ...red-agent-session-conversation-lifetime.ts | 3 - .../structured-agent-session-delivery-loop.ts | 4 +- .../structured-agent-session-host-lifetime.ts | 39 +++++++++- .../structured-agent-session-host-types.ts | 7 +- .../structured-agent-session-host.ts | 4 +- ...tructured-agent-session-idle-sweep.test.ts | 1 - .../structured-agent-session-idle-sweep.ts | 14 ++-- ...structured-agent-session-provider-child.ts | 29 +++++-- ...ent-session-restore-without-import.test.ts | 1 - ...ctured-agent-session-wind-down-wait-row.ts | 11 +-- 12 files changed, 156 insertions(+), 35 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index d430bbbbc668..2940a80bada8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -342,3 +342,80 @@ it('refuses an option change with the exit unverifiable after retrying the stop, expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) expect(claude.connections).toHaveLength(1) }) + +function stopBackgroundTasks() { + const fields = { scope: 'background-tasks' as const } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +/** Holds the user's next message on a Stop whose exit stays unproven through the send's retry. */ +async function heldAfterStop(): Promise { + const connection = await stopWithUnprovenClose(2) + await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + expect(connection.closeCount).toBe(2) + return connection +} + +it('sends a held message once an option change proves the exit: the stop that lands hands it over', async () => { + const connection = await heldAfterStop() + + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.closeCount).toBe(3) + const resumed = await resumedWith(connection, 'Carry on.') + expect(resumed.closed).toBe(false) + expect(owedWindDown()).toBeUndefined() + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) +}) + +it('sends a held message once a background-task stop proves the exit, though that stop finds no agent', async () => { + const connection = await heldAfterStop() + + // Proven gone, the old agent has no tasks left to stop, and the message still goes out. + await expect(stopBackgroundTasks()).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'noLiveOwner' } } + }) + expect(connection.closeCount).toBe(3) + await resumedWith(connection, 'Carry on.') + expect(owedWindDown()).toBeUndefined() +}) + +it('sends a message held after a tab close whose exit was unproven, rather than rejecting it as closed', async () => { + const connection = claude.connections[0]! + closeUnprovenFor(connection, 2) + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(owedWindDown()).toMatchObject({ cause: 'user-close' }) + + // The chat stays open on the host; the user sends again, and the message waits on that close. + const next = await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + expect(connection.closeCount).toBe(2) + + // The close was asked for before this message, so the retry that lands ends nothing it waited on. + await host['lifetime'].idleSweep.tick() + await laneDrained() + expect(await submission(next)).not.toMatchObject({ dispatchState: 'rejected' }) + await resumedWith(connection, 'Carry on.') + expect(owedWindDown()).toBeUndefined() +}) + +it('never stops a live child for a wind-down another, earlier child still owes', async () => { + const connection = claude.connections[0]! + const session = host['sessions'].get(SESSION)! + session.owesProviderChildWindDown = { + generation: 'an-earlier-child', + fence: session.child!.fence, + cause: 'user-stop', + requestedAt: session.journal.cursor() + } + + await send('Carry on.') + await eventually(() => expect(wrote(connection, 'Carry on.')).toBe(true)) + await laneDrained() + await host['lifetime'].idleSweep.tick() + expect(connection.closeCount).toBe(0) + expect(claude.connections).toHaveLength(1) +}) 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 6f705ae3945c..9813168e62fd 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 @@ -318,7 +318,6 @@ describe('the wind-down retry with a message queued (P2-31)', () => { stopAgent, stopStartingAgent: stopAgent, finishOwedWindDown, - wakeDelivery: () => undefined, closeConversation: vi.fn(async () => false), onError: (_id, error) => { throw error 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 390e283903b4..abf7bcb74bdc 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 @@ -38,8 +38,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { /** PR 1's one open function, for a caller inside the session's serialize. */ open: (sessionId: string) => Promise deliveryActive: (sessionId: string) => boolean - /** For a caller inside the session's serialize. */ - wakeDelivery: (sessionId: string) => void /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ closeStatus: (sessionId: string, options: { listed: boolean }) => void /** The session's child records, the host's one read of them. */ @@ -78,7 +76,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { now: () => host.context().now(), isDisposed: () => disposed, deliveryActive: host.deliveryActive, - wakeDelivery: host.wakeDelivery, childWork: host.readChildWork, hasOpenDispatch: (sessionId) => { const record = deps().store.getRecord(sessionId) 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 666d4fa1a032..6fddf92efbaa 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 @@ -180,8 +180,8 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } - // Already waiting on a stop that could not prove its child gone: only a new message, or the - // sweep, retries it, so the waiting row's own commit does not. + // Already waiting on a stop that could not prove its child gone: a new message retries it, and + // any other retry that lands wakes this loop itself, so the waiting row's own commit does not. if (structuredAgentSessionWindDownWaitHolds(session)) { return this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 859f79326af1..7c411884088e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -22,10 +22,13 @@ import type { StructuredAgentSessionHostRuntimeState } from './structured-agent- import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, + StructuredAgentSessionOwedWindDown, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { endProviderChild, + pendingProviderChildWindDown, + sameProviderChild, structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' @@ -40,6 +43,8 @@ export type StructuredAgentSessionLifetimeContext = { now: () => number /** Re-projects the session's status after its agent stopped and the chat stays. */ publishStatus?: (sessionId: string) => void + /** Hands the delivery loop what is queued; for a caller inside the session's serialize. */ + wakeDelivery?: (sessionId: string) => void /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -87,6 +92,26 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } +/** The stop this pass owes. A retry continues the one already asked for, keeping where it was asked; + * any other stop is a new ask. */ +function owedStop( + session: StructuredAgentSessionHostSession, + cause: StructuredAgentSessionStopCause +): StructuredAgentSessionOwedWindDown | undefined { + const owed = owedProviderChildWindDown(session) + if (!owed) { + return undefined + } + const asked = session.owesProviderChildWindDown + const continues = asked !== undefined && asked.cause === cause && sameProviderChild(asked, owed) + return { + generation: owed.generation, + fence: owed.fence, + cause, + requestedAt: continues ? asked.requestedAt : session.journal.cursor() + } +} + /** * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next @@ -110,8 +135,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. - const owed = owedProviderChildWindDown(session) - session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined + const owed = owedStop(session, cause) + session.owesProviderChildWindDown = owed const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { @@ -135,6 +160,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( cause, reason: ending.reason ?? null, duringStartup: stopping.phase === 'starting', + // A later retry that proves the exit still ends the child at the Stop it finishes. + ...(owed ? { endedAt: owed.requestedAt } : {}), ...verdict }) } @@ -178,6 +205,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // Whatever ended the child, the row belongs to the conversation: it shows not-running, and // only the conversation's close forgets it. context.publishStatus?.(sessionId) + // The stop's own end hands over what waited on it, whichever caller's retry landed. + context.wakeDelivery?.(sessionId) } } await evictStructuredAgentSession( @@ -191,13 +220,15 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( * not prove gone takes no input, so nothing may write to it or start beside it until this lands. * One pass of the stop, bounded by its own step deadline (10 s): a shorter bound would cut a * supervised Claude's exit proof (up to about 7 s) short. Resolves whether nothing is owed now; a - * failure is reported, never thrown, and leaves the exit unverifiable, never exited. + * failure is reported, never thrown, and leaves the exit unverifiable, never exited. Landing, the + * stop itself hands over what waited on it. */ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string ): Promise { - const owed = context.sessions.get(sessionId)?.owesProviderChildWindDown + const session = context.sessions.get(sessionId) + const owed = session && pendingProviderChildWindDown(session) if (!owed) { return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c2ec18332882..a4484ca4500d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -37,9 +37,11 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } -/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */ +/** A wind-down still owed, with the stop that owes it: a retry finishes that stop. */ export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { readonly cause: StructuredAgentSessionStopCause + /** Where the journal stood when the stop was asked for; the child's end is ordered there. */ + readonly requestedAt: AgentJournalCursor } /** The provider process behind a conversation. Written only in @@ -73,7 +75,8 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi duringStartup: boolean startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a - * message's acceptance. */ + * message's acceptance. A stop's end stands where it was asked for: a message accepted while + * retries proved the exit waited on it, and came after it. */ endedAt: AgentJournalCursor } 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 0585857e934e..0f1666252509 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 @@ -172,7 +172,6 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), open: (sessionId) => this.conversationDelivery.open(sessionId), deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options), readChildWork: this.clientDelivery.readChildWork }) @@ -195,7 +194,8 @@ export class StructuredAgentSessionHost { runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), - publishStatus: this.clientDelivery.publishStatus + publishStatus: this.clientDelivery.publishStatus, + wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId) } satisfies StructuredAgentSessionLifetimeContext } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index f6dd3f55c7a3..b0a4f687e757 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -320,7 +320,6 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { stopAgent, stopStartingAgent: stopAgent, finishOwedWindDown: vi.fn(async () => true), - wakeDelivery: () => undefined, closeConversation, onError: (_id, error) => { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index bb909e163e57..f2efc1838630 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -14,6 +14,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { pendingProviderChildWindDown } from './structured-agent-session-provider-child' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -35,10 +36,8 @@ export type StructuredAgentSessionIdleSweepDeps = { providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise - /** Retries a stop that did not finish; resolves whether nothing is owed now. */ + /** Retries a stop that did not finish; landing, it hands over what waited on it. */ finishOwedWindDown: (sessionId: string) => Promise - /** Hands a message waiting on that stop to the delivery loop. */ - wakeDelivery: (sessionId: string) => void stopStartingAgent: (sessionId: string) => Promise closeConversation: (sessionId: string) => Promise onError: (sessionId: string, error: unknown) => void @@ -106,12 +105,9 @@ export class StructuredAgentSessionIdleSweep { return } // A stop that did not finish: retry it now, before the idle test, so the rows its settlement - // wrote cannot push the retry out. A running delivery step retries it itself; a message left - // waiting on it goes out once it lands. - if (session.owesProviderChildWindDown !== undefined && !this.deps.deliveryActive(sessionId)) { - if (await this.deps.finishOwedWindDown(sessionId)) { - this.deps.wakeDelivery(sessionId) - } + // wrote cannot push the retry out. A running delivery step retries it itself. + if (pendingProviderChildWindDown(session) && !this.deps.deliveryActive(sessionId)) { + await this.deps.finishOwedWindDown(sessionId) return } // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index da5e491e1d18..5785cfc53e19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -5,11 +5,13 @@ // an exit, a failed re-attach, a Stop and an eviction. Each is matched on the child's generation // and fence, so an ending that arrives late for an older child cannot end a newer one. +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, + StructuredAgentSessionOwedWindDown, StructuredAgentSessionProviderChild, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' @@ -46,9 +48,12 @@ export function markProviderChildStarted( return child !== null } +/** `endedAt` is a stop's ask; an exit ends where the journal stands. */ export function endProviderChild( session: ChildBearer, - ended: Omit + ended: Omit & { + endedAt?: AgentJournalCursor + } ): boolean { const child = matchingChild(session, ended) if (!child) { @@ -58,7 +63,7 @@ export function endProviderChild( session.lastEndedChild = { ...ended, ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), - endedAt: session.journal.cursor() + endedAt: ended.endedAt ?? session.journal.cursor() } return true } @@ -72,12 +77,26 @@ export function failedProviderChildStart( return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null } +/** The owed wind-down an operation reaching the provider finishes first. One owed for another child + * never outranks the child in front of it, which that child's own stop finishes. */ +export function pendingProviderChildWindDown( + session: Pick +): StructuredAgentSessionOwedWindDown | undefined { + const { child, owesProviderChildWindDown: owed } = session + return owed && (!child || sameProviderChild(child, owed)) ? owed : undefined +} + +export function sameProviderChild( + a: StructuredAgentSessionProviderChildIdentity, + b: StructuredAgentSessionProviderChildIdentity +): boolean { + return a.generation === b.generation && a.fence === b.fence +} + function matchingChild( session: ChildBearer, identity: StructuredAgentSessionProviderChildIdentity ): StructuredAgentSessionProviderChild | null { const { child } = session - return child && child.generation === identity.generation && child.fence === identity.fence - ? child - : null + return child && sameProviderChild(child, identity) ? child : null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index d790165775ee..2ea475cf3bab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -250,7 +250,6 @@ async function restore(sessionIds: readonly string[]) { serialize: async (_sessionId, task) => task(), open, deliveryActive: () => false, - wakeDelivery: () => undefined, closeStatus: () => undefined, readChildWork: () => undefined }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts index 74082b37a22c..08c9fcd4e473 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts @@ -15,6 +15,7 @@ import type { StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { pendingProviderChildWindDown } from './structured-agent-session-provider-child' /** Keyed by the child the stop could not prove gone, so every send that waits on it shares a row. */ export function structuredAgentSessionWindDownWaitIdentity( @@ -28,7 +29,7 @@ export function structuredAgentSessionWindDownWaitIdentity( type WaitingSession = Pick< StructuredAgentSessionHostSession, - 'journal' | 'owesProviderChildWindDown' + 'journal' | 'child' | 'owesProviderChildWindDown' > function windDownWaitRow( @@ -40,10 +41,10 @@ function windDownWaitRow( } /** Every queued message already waited through a retry of the owed stop: its row came after the - * newest. Only a new message or the sweep retries again, so the row's own commit, which wakes the - * delivery loop, does not. */ + * newest. A new message retries again, and a retry that lands wakes the loop itself, so the row's + * own commit, which wakes the delivery loop, does not. */ export function structuredAgentSessionWindDownWaitHolds(session: WaitingSession): boolean { - const owed = session.owesProviderChildWindDown + const owed = pendingProviderChildWindDown(session) const row = owed && windDownWaitRow(session, owed) if (!row) { return false @@ -62,7 +63,7 @@ export async function recordStructuredAgentSessionWindDownWait( session: WaitingSession, input: { fence: number; failureTextContext: AgentSessionFailureWordsContext } ): Promise { - const owed = session.owesProviderChildWindDown + const owed = pendingProviderChildWindDown(session) if (!owed || windDownWaitRow(session, owed)) { return } From 7f855c459d2a98b32c27a5bd4d496e2418650a28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:06:03 -0700 Subject: [PATCH 06/20] docs(native-chat): say who retries a wind-down a Stop leaves owed --- .../structured-agent-session-turns-cancel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index cdb70ff41d45..2b419da594a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -40,8 +40,8 @@ export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; st /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. The next - * Stop retries the wind-down it leaves owed, and so does the idle sweep: at its next tick once the - * child is proven gone, else only after the chat idles with no child work left. + * operation that reaches the agent retries the wind-down it leaves owed, and so does the idle + * sweep's next tick. */ export async function endStoppedStructuredAgentSession( ctx: Pick, From bc4af0dc4110600ad00f5a06a7a127edd023dee9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:17:28 -0700 Subject: [PATCH 07/20] fix(native-chat): a waiting message retries the unproven stop once, and its note stays true - A waiting message holds against the newest pass that failed to prove the exit, kept on the owed-stop record (`failedAt`), not against the note's position. The note is written once per process, so after a second message every later journal commit re-ran a retry of up to 10 s. - The note no longer promises this message will send: "Messages wait to be sent until Orca confirms it has ended." stays true after a Stop withdraws the message, a close, or a restart rejects it. Every catalog follows; es and zh lose the mismatched informal possessive, and the French reads naturally. - Tests: commits after a second waiting message retry nothing; with follow-up queueing on (the default) a follow-up becomes a draft, and Steer retries once and waits under the same note. --- ...-session-claude-unproven-stop-send.test.ts | 69 ++++++++++++++++++- .../structured-agent-session-host-lifetime.ts | 15 ++-- .../structured-agent-session-host-types.ts | 2 + ...ctured-agent-session-wind-down-wait-row.ts | 13 ++-- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-session-failure-copy.ts | 2 +- 12 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 2940a80bada8..0dc32e87543d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' @@ -102,7 +103,11 @@ function eventually(assertion: () => T | Promise): Promise { } function envelope( - method: 'agentSession.send' | 'agentSession.cancel' | 'agentSession.setOption', + method: + | 'agentSession.send' + | 'agentSession.cancel' + | 'agentSession.setOption' + | 'agentSession.queuedMessageSend', fields: object ) { return { @@ -267,7 +272,7 @@ it('holds the message with its reason while the exit stays unverifiable, and sen { kind: 'status', tone: 'warning', - text: "Orca couldn't confirm Claude's previous process ended. Your message will send once it has.", + text: "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended.", failure: { kind: 'previousExitUnverifiable' } } ]) @@ -419,3 +424,63 @@ it('never stops a live child for a wind-down another, earlier child still owes', expect(connection.closeCount).toBe(0) expect(claude.connections).toHaveLength(1) }) + +it('retries once per new message: commits after a second waiting message retry nothing', async () => { + const connection = await stopWithUnprovenClose(3) + await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + await send('And this.') + await eventually(() => expect(connection.closeCount).toBe(3)) + await laneDrained() + + // Any journal commit wakes the delivery loop while a message is queued. + const session = host['sessions'].get(SESSION)! + for (const n of [1, 2, 3]) { + await session.journal.appendItem( + { provider: 'orca', clientMessageId: `unrelated-${n}` }, + { kind: 'status', text: `Unrelated ${n}.` }, + { fence: store.getRecord(SESSION)!.lease.runtimeFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await laneDrained() + } + await laneDrained() + expect(connection.closeCount).toBe(3) + expect(claude.connections).toHaveLength(1) +}) + +it('queues a follow-up as a draft by default while a message waits, and Steer retries the stop once for it', async () => { + const connection = await stopWithUnprovenClose(3) + await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + + // Queueing follow-ups is the default: the waiting message reads as working, so this is a draft. + const body = hostTestMessage('And this.') + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery + }) + expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + await laneDrained() + expect(connection.closeCount).toBe(2) + + // Steer makes it a waiting message, which retries once and waits under the same note. + const messageId = queued.ok && 'queued' in queued.value ? queued.value.queued.messageId : '' + await expect( + host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId }), + messageId + }) + ).resolves.toMatchObject({ ok: true }) + await eventually(() => expect(connection.closeCount).toBe(3)) + await laneDrained() + expect(claude.connections).toHaveLength(1) + expect(await waitRows()).toHaveLength(1) + + await host['lifetime'].idleSweep.tick() + const resumed = await resumedWith(connection, 'Carry on.') + await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 7c411884088e..1574fc29c4f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -209,10 +209,17 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( context.wakeDelivery?.(sessionId) } } - await evictStructuredAgentSession( - eviction, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) - ) + try { + await evictStructuredAgentSession( + eviction, + withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) + ) + } catch (error) { + if (session.owesProviderChildWindDown === owed && owed) { + session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } + } + throw error + } } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index a4484ca4500d..c5a21ab0f933 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -42,6 +42,8 @@ export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderC readonly cause: StructuredAgentSessionStopCause /** Where the journal stood when the stop was asked for; the child's end is ordered there. */ readonly requestedAt: AgentJournalCursor + /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ + readonly failedAt?: AgentJournalCursor } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts index 08c9fcd4e473..92c9f13e86b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts @@ -40,13 +40,12 @@ function windDownWaitRow( return session.journal.snapshot().items.find((item) => item.itemId === itemId) } -/** Every queued message already waited through a retry of the owed stop: its row came after the - * newest. A new message retries again, and a retry that lands wakes the loop itself, so the row's - * own commit, which wakes the delivery loop, does not. */ +/** Every queued message already waited through a retry of the owed stop: it was accepted before the + * newest pass failed. Only a new message retries again, and a retry that lands wakes the loop + * itself, so no other commit, each of which wakes the delivery loop, retries. */ export function structuredAgentSessionWindDownWaitHolds(session: WaitingSession): boolean { - const owed = pendingProviderChildWindDown(session) - const row = owed && windDownWaitRow(session, owed) - if (!row) { + const failedAt = pendingProviderChildWindDown(session)?.failedAt + if (!failedAt || failedAt.epoch !== session.journal.cursor().epoch) { return false } return session.journal @@ -54,7 +53,7 @@ export function structuredAgentSessionWindDownWaitHolds(session: WaitingSession) .every( (submission) => !isQueuedAgentJournalSubmission(submission) || - (submission.acceptedSequence ?? 0) < row.sequence + (submission.acceptedSequence ?? 0) <= failedAt.sequence ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 13769bc4303f..98a4c6d06cec 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2755,7 +2755,7 @@ "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Your message will send once it has.", + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c189ea46cf2e..f979b25f7b7e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17527,7 +17527,7 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Your message will send once it has." + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6ac412fd23be..4c0b13495a39 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14965,7 +14965,7 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó. Tu mensaje se enviará en cuanto termine." + "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó. Los mensajes esperan para enviarse hasta que Orca confirme que terminó." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 990e90adf250..795794a3bd79 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17439,7 +17439,7 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté. Votre message sera envoyé dès qu'il le sera." + "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté. Les messages attendent d'être envoyés jusqu'à ce qu'Orca confirme son arrêt." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e6708910f981..7b2e8a47f130 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17375,7 +17375,7 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。終了しだいメッセージを送信します。" + "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。Orca が終了を確認するまで、メッセージは送信を待機します。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 599fe59051b2..598841affc1c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17375,7 +17375,7 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다. 종료되는 즉시 메시지를 보냅니다." + "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다. Orca가 종료를 확인할 때까지 메시지는 전송을 기다립니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a4df421b64da..73c1305d67c7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17340,7 +17340,7 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。进程结束后将发送你的消息。" + "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。在 Orca 确认其已结束之前,消息会等待发送。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index d9f3c7a0752a..e6eeab282229 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -82,7 +82,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', previousExitUnverifiable: - "Orca couldn't confirm {{agent}}'s previous process ended. Your message will send once it has." + "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY From baab367cd2b838e4a11e9bc618d06e75b8eb3f2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:37:16 -0700 Subject: [PATCH 08/20] fix(native-chat): only a retry continues an owed stop; a new close is a new ask A second tab close of a chat whose exit stayed unproven kept the first close's position, so a message sent between the two closes was delivered once a retry proved the exit, even though the second close closed it (its own rejection of what was queued had failed). Continuation is now explicit: only the retry of an owed stop passes `retry`; every other stop stamps a new position. Tests: a second close whose rejection fails still closes the message it closed; the default- queueing test waits for a draft's commit to settle before asserting it retried nothing, and bounds Steer's retries; the one-retry-per-message test has the budget to show each commit's retry. --- ...-session-claude-unproven-stop-send.test.ts | 48 +++++++++++++++++-- .../structured-agent-session-host-lifetime.ts | 19 +++++--- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 0dc32e87543d..2bd1ddb131ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -181,6 +181,12 @@ function laneDrained(): Promise { return host['tasks'].serialize(SESSION, async () => {}) } +/** A commit queues a serialized wake, and the wake queues its delivery step behind that. */ +async function commitSettled(): Promise { + await laneDrained() + await laneDrained() +} + /** As the real connection: once a close begins it refuses every write, proven or not. */ function closeUnprovenFor(connection: FakeConnection, failures: number): void { const close = connection.close @@ -426,7 +432,7 @@ it('never stops a live child for a wind-down another, earlier child still owes', }) it('retries once per new message: commits after a second waiting message retry nothing', async () => { - const connection = await stopWithUnprovenClose(3) + const connection = await stopWithUnprovenClose(6) await send('Carry on.') await eventually(async () => expect(await waitRows()).toHaveLength(1)) await laneDrained() @@ -442,11 +448,18 @@ it('retries once per new message: commits after a second waiting message retry n { kind: 'status', text: `Unrelated ${n}.` }, { fence: store.getRecord(SESSION)!.lease.runtimeFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - await laneDrained() + await commitSettled() } - await laneDrained() expect(connection.closeCount).toBe(3) expect(claude.connections).toHaveLength(1) + + // The sweep still retries, one pass a tick, until the exit is proven; then both go out. + for (const _tick of [1, 2, 3, 4]) { + await host['lifetime'].idleSweep.tick() + } + expect(connection.closeCount).toBe(7) + const resumed = await resumedWith(connection, 'Carry on.') + await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) }) it('queues a follow-up as a draft by default while a message waits, and Steer retries the stop once for it', async () => { @@ -464,7 +477,7 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re delivery }) expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) - await laneDrained() + await commitSettled() expect(connection.closeCount).toBe(2) // Steer makes it a waiting message, which retries once and waits under the same note. @@ -476,7 +489,8 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re }) ).resolves.toMatchObject({ ok: true }) await eventually(() => expect(connection.closeCount).toBe(3)) - await laneDrained() + await commitSettled() + expect(connection.closeCount).toBe(3) expect(claude.connections).toHaveLength(1) expect(await waitRows()).toHaveLength(1) @@ -484,3 +498,27 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re const resumed = await resumedWith(connection, 'Carry on.') await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) }) + +it('rejects as closed a message a second tab close closed, though that close could not reject it itself', async () => { + const connection = claude.connections[0]! + closeUnprovenFor(connection, 3) + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + const next = await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + expect(connection.closeCount).toBe(2) + + // The second close's own rejection of what is queued fails; its stop is a new ask all the same. + const session = host['sessions'].get(SESSION)! + vi.spyOn(session.journal, 'rejectQueuedSubmissions').mockRejectedValueOnce(new Error('disk full')) + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(connection.closeCount).toBe(3) + expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) + + // The retry that proves the exit closes what that second close closed. + await host['lifetime'].idleSweep.tick() + await commitSettled() + expect(connection.closeCount).toBe(4) + expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) + expect(claude.connections).toHaveLength(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 1574fc29c4f6..ef203dfe0df4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -93,17 +93,18 @@ function owedProviderChildWindDown( } /** The stop this pass owes. A retry continues the one already asked for, keeping where it was asked; - * any other stop is a new ask. */ + * any other stop is a new ask, even one with the same cause: a second close closes what came since. */ function owedStop( session: StructuredAgentSessionHostSession, - cause: StructuredAgentSessionStopCause + cause: StructuredAgentSessionStopCause, + retry: boolean ): StructuredAgentSessionOwedWindDown | undefined { const owed = owedProviderChildWindDown(session) if (!owed) { return undefined } const asked = session.owesProviderChildWindDown - const continues = asked !== undefined && asked.cause === cause && sameProviderChild(asked, owed) + const continues = retry && asked !== undefined && sameProviderChild(asked, owed) return { generation: owed.generation, fence: owed.fence, @@ -121,8 +122,9 @@ function owedStop( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - // Required: an omitted cause must not default to the user's cancellation. - ending: { cause: StructuredAgentSessionStopCause; reason?: string } + // Required: an omitted cause must not default to the user's cancellation. `retry` is set only by + // the retry of a stop already owed. + ending: { cause: StructuredAgentSessionStopCause; reason?: string; retry?: true } ): Promise { const session = context.sessions.get(sessionId) if (!session) { @@ -135,7 +137,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. - const owed = owedStop(session, cause) + const owed = owedStop(session, cause, ending.retry === true) session.owesProviderChildWindDown = owed const stopping = session.child let settlementError: unknown @@ -240,7 +242,10 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( return true } try { - await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: owed.cause }) + await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { + cause: owed.cause, + retry: true + }) } catch (error) { context.deps.onEventSinkError?.({ sessionId, error }) } From a8d87c343f714c3da40bf63eca3ca084a456d519 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:51:45 -0700 Subject: [PATCH 09/20] fix(native-chat): a held message always says why it waits When another operation's retry of the unproven stop failed between a message's accept and its delivery step, the step held the message (it had already waited through a failed retry) and stopped before writing the note, so the message sat Working with no reason shown. The hold now makes sure of the note too; it is written once per unproven process, so its own commit still retries nothing. --- ...-session-claude-unproven-stop-send.test.ts | 17 +++++++++++++++ .../structured-agent-session-delivery-loop.ts | 21 +++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 2bd1ddb131ba..678d1cc1ea6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -522,3 +522,20 @@ it('rejects as closed a message a second tab close closed, though that close cou expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) expect(claude.connections).toHaveLength(1) }) + +it('notes why a message waits when another operation failed its retry before the message was delivered', async () => { + const connection = await stopWithUnprovenClose(2) + + // The send is accepted, then the option change's failed retry runs before the send's delivery. + const sent = send('Carry on.') + const option = setModel('claude-opus-5') + await expect(option).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'previousExitUnverifiable' } } + }) + await sent + await commitSettled() + expect(connection.closeCount).toBe(2) + expect(await waitRows()).toHaveLength(1) + expect(claude.connections).toHaveLength(1) +}) 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 6fddf92efbaa..8f5c8be09fee 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 @@ -182,8 +182,9 @@ export class StructuredAgentSessionDeliveryLoop { } // Already waiting on a stop that could not prove its child gone: a new message retries it, and // any other retry that lands wakes this loop itself, so the waiting row's own commit does not. + // Another operation's retry may have failed first, so the row is made sure of here too. if (structuredAgentSessionWindDownWaitHolds(session)) { - return this.stop(sessionId) + return this.waitOnUnprovenExit(sessionId, session) } const failedStart = startThatFailedWhileQueued(session, oldest) if (failedStart) { @@ -193,11 +194,7 @@ export class StructuredAgentSessionDeliveryLoop { if (!ready.ok && isStructuredAgentSessionPreviousExitUnverifiable(ready.refusal)) { // The start retried that stop first and still could not prove the exit: the message waits, // saying why, rather than being refused. - await recordStructuredAgentSessionWindDownWait(session, { - fence: this.deps.conversationFence(sessionId), - failureTextContext: this.deps.failureTextContext(sessionId) - }) - return this.stop(sessionId) + return this.waitOnUnprovenExit(sessionId, session) } if (!ready.ok) { return ready @@ -304,6 +301,18 @@ export class StructuredAgentSessionDeliveryLoop { ) } + /** The row is written once per unproven child, so this is idempotent. */ + private async waitOnUnprovenExit( + sessionId: string, + session: StructuredAgentSessionHostSession + ): Promise<'stop'> { + await recordStructuredAgentSessionWindDownWait(session, { + fence: this.deps.conversationFence(sessionId), + failureTextContext: this.deps.failureTextContext(sessionId) + }) + return this.stop(sessionId) + } + /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) From e10a102872991eb9d459cb7b70c72dfafe5710ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:58:28 -0700 Subject: [PATCH 10/20] fix(native-chat): an option change waits only on an unproven exit, not on bookkeeping When a stop proved the old process gone but a later step of its wind-down kept failing, an option change retried that bookkeeping, and refused the change when it failed again. On main the change was kept at rest. Operations that start no child (option change, card answer, background-task stop) now hold back only while the exit itself is unproven, its child still on record; with the exit proven, the bookkeeping retry is reported and the operation goes on as with nothing owed. Sends still wait: a start needs the released lease that bookkeeping gives back. --- .../structured-agent-session-agent-start.ts | 11 ++++++++ ...-session-claude-unproven-stop-send.test.ts | 26 +++++++++++++++++++ .../structured-agent-session-host.ts | 4 +-- ...ructured-agent-session-mutation-context.ts | 3 ++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 0d3fa157dba5..4cae8fd64650 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -74,6 +74,17 @@ export async function finishOwedStructuredAgentSessionStop( } } +/** The same, for an operation the running child performs, which starts none: only an exit still + * unproven, its child still on record, holds it back. A proven exit owes only bookkeeping, which + * gates no such operation: its retry was reported, and the operation goes on as with none owed. */ +export async function finishOwedStructuredAgentSessionStopForProviderWrite( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + const settled = await finishOwedStructuredAgentSessionStop(context, sessionId) + return settled.ok || context.sessions.get(sessionId)?.child ? settled : { ok: true } +} + export function isStructuredAgentSessionPreviousExitUnverifiable( refusal: AgentSessionWireRefusal ): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 678d1cc1ea6b..8239db647ba9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -539,3 +539,29 @@ it('notes why a message waits when another operation failed its retry before the expect(await waitRows()).toHaveLength(1) expect(claude.connections).toHaveLength(1) }) + +it('keeps an option change at rest when the Stop proved the exit and only its bookkeeping keeps failing', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + // The close proves the exit; draining what the old agent wrote fails for the Stop. + const barrierLost = { ok: false as const, error: new Error('drain barrier lost') } + const drained = vi + .spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained') + .mockResolvedValueOnce(barrierLost) + .mockResolvedValueOnce(barrierLost) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + expect(connection.closeCount).toBe(1) + expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(owedWindDown()).toBeDefined() + + // The option change's retry of that bookkeeping fails again: reported, and the pick still lands. + drained.mockResolvedValueOnce(barrierLost) + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(owedWindDown()).toBeDefined() + expect(sinkErrors).toHaveLength(2) + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) + expect(connection.closeCount).toBe(1) + expect(claude.connections).toHaveLength(1) +}) 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 0f1666252509..fe5ad392318b 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 @@ -25,7 +25,7 @@ import type { StructuredAgentSessionLifetimeContext } from './structured-agent-s import { ensureStructuredAgentSessionAgent, ensureStructuredAgentSessionAgentForOperation, - finishOwedStructuredAgentSessionStop + finishOwedStructuredAgentSessionStopForProviderWrite } from './structured-agent-session-agent-start' import { createStructuredAgentSessionConversationLifetime, @@ -285,7 +285,7 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), finishOwedStop: (sessionId) => - finishOwedStructuredAgentSessionStop(this.attachContext(), sessionId), + finishOwedStructuredAgentSessionStopForProviderWrite(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 02f99104083e..0779c15d2f39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -32,7 +32,8 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise - /** Finishes a stop an earlier attempt left owed; inside the caller's serialize. */ + /** Finishes a stop an earlier attempt left owed, for an operation that starts no child; inside + * the caller's serialize. */ finishOwedStop: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void From ad83641757c2b0180f5f811cd8896002c21efe47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:07:36 -0700 Subject: [PATCH 11/20] test(native-chat): type the unproven-stop test's envelope fields by the fingerprint's own shape --- ...tructured-agent-session-claude-unproven-stop-send.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 8239db647ba9..605f45d5fd1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -108,7 +108,8 @@ function envelope( | 'agentSession.cancel' | 'agentSession.setOption' | 'agentSession.queuedMessageSend', - fields: object + // The fingerprint's own field shape, as the sibling host tests type it. + fields: Parameters[0]['fields'] ) { return { sessionId: SESSION, @@ -117,7 +118,7 @@ function envelope( payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId: SESSION, - fields: { ...fields } + fields }) } } From 1436ffe491def8d171676a1e131b2e5e09fa1c11 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:54:07 -0700 Subject: [PATCH 12/20] fix(native-chat): a child seen gone after its stop gave up finishes that stop at once A stop that could not prove the child's exit (its proof failed, or ran past the 10 s step deadline) left a waiting message until the idle sweep's next 5-minute tick, even when the old process exited moments later: the connection saw that exit but told no one, and a stop that proved it late had no caller. Both are now reported. The Claude and Codex connections report a root exit seen after a close gave up; the adapters publish it as an exitAfterClose lifecycle event for that exact child; and a stop step that proves the exit after its pass gave up says so too. Either way the host retries the owed stop on the session's own lane, only for the child it names, and the stop that lands hands over what waited on it. The sweep stays the backstop. --- .../claude/claude-stream-json-connection.ts | 5 ++++ .../claude-structured-session-acquisition.ts | 3 ++- .../claude-structured-session-adapter.ts | 3 +++ ...laude-structured-session-exit-lifecycle.ts | 21 ++++++++++++++- .../claude/claude-structured-session-state.ts | 4 +++ .../codex-app-server-connection-types.ts | 2 ++ src/main/codex/codex-app-server-connection.ts | 12 ++++++++- .../codex-structured-journal-translation.ts | 4 +++ .../codex/codex-structured-session-acquire.ts | 12 +++++++-- .../codex/codex-structured-session-close.ts | 20 ++++++++++++++ .../codex/codex-structured-session-state.ts | 2 ++ .../structured-agent-session-adapter.ts | 4 --- ...red-agent-session-conversation-lifetime.ts | 20 +++++++++++++- ...structured-agent-session-event-recovery.ts | 19 +++++++++---- .../structured-agent-session-host-lifetime.ts | 27 +++++++++++++++++++ .../structured-agent-session-host.ts | 12 ++++----- ...tructured-agent-session-lifecycle-event.ts | 20 ++++++++++++++ ...ctured-agent-session-lifecycle-delivery.ts | 20 +++++++------- .../structured-agent-session-runtime.ts | 6 +++-- .../structured-claude-runtime-adapter.ts | 4 +-- .../structured-codex-lifecycle-event.ts | 15 +++++++++++ 21 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts create mode 100644 src/main/runtime/structured-codex-lifecycle-event.ts diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 6f67e04b36ea..7a9fffe6f2f4 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -66,6 +66,8 @@ export type ClaudeStreamJsonConnectionHandlers = { /** A transport/process fault that is not itself first-hand root exit proof. */ onFault?: (error: Error) => void onExit?: (error: Error) => void + /** The root exited after a close gave up proving it; no close is running to see that. */ + onExitAfterClose?: () => void } /** @@ -212,6 +214,9 @@ export async function openClaudeStreamJsonConnection( exitStatus = { code, signal } markExited() handleUnexpectedEnd() + if (closing && !closePromise) { + handlers.onExitAfterClose?.() + } }) const handleUnexpectedEnd = (cause?: Error): void => { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 92f84f6c4896..ac4bcca344e8 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -205,7 +205,8 @@ export async function acquireClaudeSession({ childEnded ??= error initProof.reject(error) callbacks.handleExit(sessionId, attempt, error) - } + }, + onExitAfterClose: () => callbacks.handleExitAfterClose(sessionId, attempt) } ) ) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 46e1d9936358..3370150ff45c 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -32,6 +32,7 @@ import { claudeStoppedRequestEndWait } from './claude-request-end-wait' import { drainClaudeObservedExits, observeClaudeSessionExit, + reportClaudeExitAfterClose, settleClaudeUnexpectedExit, type ClaudeExitLifecycle } from './claude-structured-session-exit-lifecycle' @@ -96,6 +97,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda emit: (session, _events, event) => this.emit(session, event), handleExit: (sessionId, attempt, error) => observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error), + handleExitAfterClose: (sessionId, attempt) => + reportClaudeExitAfterClose(this.exitLifecycle, sessionId, attempt), settleExit: (sessionId, exit) => settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit) } diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 854646490a8c..be4457d885e6 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -22,7 +22,7 @@ export type ClaudeExitLifecycle = { exits: Map /** A settled exit's diagnostic, kept for a send admitted before the host heard of the exit. */ settledExitErrors: Map - deps: Pick + deps: Pick emit: (session: ClaudeSession, event: ClaudeStructuredSessionEvent) => void } @@ -62,6 +62,25 @@ export function observeClaudeSessionExit( .catch(() => undefined) } +/** A close that gave up on this child has since seen its root exit. The host's retry of that close + * settles it; this only tells the host the retry can land now. */ +export function reportClaudeExitAfterClose( + lifecycle: ClaudeExitLifecycle, + sessionId: string, + attempt: ClaudeAcquisitionAttempt +): void { + const session = lifecycle.sessions.get(sessionId) + if (!session || session.connection !== attempt.connection) { + return + } + lifecycle.deps.onEvent?.({ + type: 'exitAfterClose', + sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration + }) +} + /** Lifecycle recovery is published only after the close ladder ran and proved the tree gone or * observed the root's own exit. */ export function settleClaudeUnexpectedExit( diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 2f0a262c3133..33aebf8a2fac 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionStartedEvent, StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionExitAfterCloseEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -67,6 +68,7 @@ export type ClaudeStructuredSessionEvent = | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } /** Startup facts applied and saved options restored; held prompts are about to be written. */ | StructuredAgentSessionStartedEvent + | StructuredAgentSessionExitAfterCloseEvent | { type: 'ended' sessionId: string @@ -348,5 +350,7 @@ export type ClaudeAcquireCallbacks = { event: ClaudeStructuredSessionEvent ) => void handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + /** The child's root exited after a close of it gave up proving that. */ + handleExitAfterClose: (sessionId: string, attempt: ClaudeAcquisitionAttempt) => void settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise } diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index df3f40ac39a5..0a16ec465ed3 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -9,6 +9,8 @@ export type CodexAppServerConnectionHandlers = { onServerRequest?: (request: CodexAppServerServerRequest) => void onUnhandledFrame?: (kind: string, payload: unknown) => void onExit?: (error: Error) => void + /** The child exited after a close gave up proving it; no close is running to see that. */ + onExitAfterClose?: () => void /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ onSpawned?: (pid: number) => Promise } diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 3e7d16ea1bb0..94186a820437 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -78,6 +78,7 @@ export async function openCodexAppServerConnection( let exited = false let exitObserved = false let closing = false + let closeRunning = false let exitReported = false const exitProof = new RetryableProcessExitProof() /** First terminal cause, or null while the transport is still usable. Set once: @@ -129,6 +130,9 @@ export async function openCodexAppServerConnection( if (exitObserved && !closing && !exitReported) { exitReported = true handlers.onExit?.(terminalError) + } else if (exitObserved && closing && !closeRunning && !exitReported) { + exitReported = true + handlers.onExitAfterClose?.() } } @@ -243,7 +247,8 @@ export async function openCodexAppServerConnection( return Promise.resolve(true) } closing = true - return exitProof.run(async () => { + closeRunning = true + const proof = exitProof.run(async () => { try { child.stdin.end() } catch { @@ -267,6 +272,11 @@ export async function openCodexAppServerConnection( dispatcher.failPending(new Error('codex app-server connection closed')) return exitObserved }) + const settled = (): void => { + closeRunning = false + } + proof.then(settled, settled) + return proof } const connection: CodexAppServerConnection = { diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 7200e5bc76ac..6b263507a969 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -144,6 +144,10 @@ export function createCodexJournalTranslator( }) }, handle: (event) => { + // Evidence for the host's retry of a close, never routed here: nothing for the journal. + if (event.type === 'exitAfterClose') { + return CODEX_JOURNAL_ADMITTED + } if (event.type === 'ended') { const streamAdmission = flushStreams() if (!streamAdmission.accepted) { diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a1..1bea693887e5 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -21,7 +21,8 @@ import { buildCodexStructuredChildEnvironment } from './codex-structured-child-e import { openCodexThread } from './codex-structured-thread-open' import { closeCodexPublishedSession, - handleCodexSessionExit + handleCodexSessionExit, + reportCodexExitAfterClose } from './codex-structured-session-close' import { restoredCodexSessionOptions } from './codex-structured-session-options' import { @@ -176,7 +177,14 @@ export async function acquireCodexStructuredSession(input: { } finally { notificationRetries.clear(sessionId, acquisition.connection) } - } + }, + onExitAfterClose: () => + reportCodexExitAfterClose({ + sessions, + sessionId, + connection: acquisition.connection, + ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) + }) } ) acquisition.connection = connection diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 198f2114a655..5dbbad4fb556 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -70,6 +70,26 @@ export function handleCodexSessionExit(input: { return true } +/** A close that gave up on this child has since seen it exit. The host's retry of that close + * settles it; this only tells the host the retry can land now. */ +export function reportCodexExitAfterClose(input: { + sessions: Map + sessionId: string + connection: CodexAppServerConnection | null + onEvent?: (event: CodexStructuredSessionEvent) => void +}): void { + const session = input.sessions.get(input.sessionId) + if (!session || session.connection !== input.connection || session.ended) { + return + } + input.onEvent?.({ + type: 'exitAfterClose', + sessionId: input.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration + }) +} + export async function closeCodexPublishedSession( sessions: Map, sessionId: string, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 896d2b352af2..f1cf8b983d14 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -22,6 +22,7 @@ import type { StructuredAgentSessionEndedEvent, StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionExitAfterCloseEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -70,6 +71,7 @@ export type CodexStructuredSessionEvent = promptKey: string } | StructuredAgentSessionEndedEvent + | StructuredAgentSessionExitAfterCloseEvent /** Translator-only compatibility for callers that do not participate in host recovery. */ | { type: 'ended'; sessionId: string; reason: string; observedAt?: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 4525cda08c3e..780e11dd57a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -217,10 +217,6 @@ export type StructuredAgentSessionStartedEvent = { restoreSkippedOptions: readonly string[] } -export type StructuredAgentSessionLifecycleEvent = - | StructuredAgentSessionEndedEvent - | StructuredAgentSessionStartedEvent - /** Whether the provider child behind an acquisition has proven its start. A publish-first * acquire hands over a `starting` child and the `started` lifecycle event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' 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 abf7bcb74bdc..514c30301b05 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 @@ -15,13 +15,17 @@ import type { StructuredAgentSessionConversations } from './structured-agent-ses import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, + finishOwedStructuredAgentSessionWindDownAfterExitUnderSerialize, finishOwedStructuredAgentSessionWindDownUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { adapterSupportsRecord } from './structured-agent-session-provider-support' @@ -99,6 +103,20 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, + /** The child a stop gave up on was seen to exit; quit's own eviction finishes any after dispose. */ + finishStopAfterExit: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ): Promise => + serialize(sessionId, async () => { + if (!disposed) { + await finishOwedStructuredAgentSessionWindDownAfterExitUnderSerialize( + host.context(), + sessionId, + child + ) + } + }).catch((error: unknown) => deps().onEventSinkError?.({ sessionId, error })), /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true 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 f7a50627370e..1810eaa7c1ea 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 @@ -1,11 +1,10 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - stopAgentSessionProviderRoot, - type StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import { stopAgentSessionProviderRoot } from './structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-lifecycle-event' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' @@ -25,6 +24,10 @@ export class StructuredAgentSessionEventRecovery { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number onBarrierError: (sessionId: string, error: unknown) => void + finishStopAfterExit: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => Promise } ) {} @@ -68,6 +71,12 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } + if (event.type === 'exitAfterClose') { + return this.context.finishStopAfterExit(event.sessionId, { + generation: event.acquisitionGeneration, + fence: event.fence + }) + } await settleUnexpectedStructuredAgentSessionExit(this.context, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index ef203dfe0df4..1fcd1cbd7c1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -45,6 +45,11 @@ export type StructuredAgentSessionLifetimeContext = { publishStatus?: (sessionId: string) => void /** Hands the delivery loop what is queued; for a caller inside the session's serialize. */ wakeDelivery?: (sessionId: string) => void + /** Retries, on the session's own lane, the stop owed for `child` once that child is seen gone. */ + finishStopAfterExit?: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => Promise /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -141,6 +146,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( session.owesProviderChildWindDown = owed const stopping = session.child let settlementError: unknown + // A stop step past its deadline still runs; proving the exit after this pass gave up, it has no + // caller left to finish the stop. + let gaveUp = false const eviction: StructuredAgentSessionEvictionContext = { sessionId, // The retry must not re-stop a child the adapter already proved gone, so this stays honest. @@ -166,6 +174,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ...(owed ? { endedAt: owed.requestedAt } : {}), ...verdict }) + if (gaveUp) { + void context.finishStopAfterExit?.(sessionId, stopping) + } } context.restartWitness?.stopped(sessionId) }, @@ -217,6 +228,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) ) } catch (error) { + gaveUp = true if (session.owesProviderChildWindDown === owed && owed) { session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } } @@ -252,6 +264,21 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( return context.sessions.get(sessionId)?.owesProviderChildWindDown === undefined } +/** The stop owed for `child`, retried once that child was seen to exit after its stop gave up, so + * what waited on it goes out now rather than at the sweep's next tick. A report naming any other + * child is stale and touches nothing. */ +export async function finishOwedStructuredAgentSessionWindDownAfterExitUnderSerialize( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity +): Promise { + const session = context.sessions.get(sessionId) + const owed = session && pendingProviderChildWindDown(session) + if (owed && sameProviderChild(owed, child)) { + await finishOwedStructuredAgentSessionWindDownUnderSerialize(context, sessionId) + } +} + /** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ export type StructuredAgentSessionCloseCause = Extract< StructuredAgentSessionStopCause, 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 fe5ad392318b..79256c876ea5 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 @@ -160,7 +160,8 @@ export class StructuredAgentSessionHost { publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), - onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + finishStopAfterExit: (id, child) => this.lifetime.finishStopAfterExit(id, child) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -195,7 +196,8 @@ export class StructuredAgentSessionHost { sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus, - wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId) + wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), + finishStopAfterExit: (id, child) => this.lifetime.finishStopAfterExit(id, child) } satisfies StructuredAgentSessionLifetimeContext } @@ -255,8 +257,7 @@ export class StructuredAgentSessionHost { return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } - flushStreamedEvents = (sessionId: string): Promise => - this.runtimeState.flushEventSink(sessionId) + flushStreamedEvents = (sessionId: string) => this.runtimeState.flushEventSink(sessionId) // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. @@ -329,8 +330,7 @@ export class StructuredAgentSessionHost { journalSnapshot = async (sessionId: string): Promise => (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => - this.backgroundTasks.subscribe(input) + subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => settleStructuredAgentSessionLateDispatch(this.mutationContext(), input) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts new file mode 100644 index 000000000000..761bdfad9a1c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts @@ -0,0 +1,20 @@ +// What a provider adapter tells the host about its child's life, for the host's lifecycle handler. + +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStartedEvent +} from './structured-agent-session-adapter' + +/** A close that gave up proving this child's exit has since seen its root exit. Evidence to retry + * the stop owed for this child, not a verdict: that stop's own proof decides. */ +export type StructuredAgentSessionExitAfterCloseEvent = { + type: 'exitAfterClose' + sessionId: string + fence: number + acquisitionGeneration: string +} + +export type StructuredAgentSessionLifecycleEvent = + | StructuredAgentSessionEndedEvent + | StructuredAgentSessionStartedEvent + | StructuredAgentSessionExitAfterCloseEvent diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index 961c4d50aa84..16bdc5009b22 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -3,10 +3,10 @@ // Exit recovery runs on one chain so teardown can drain it: exit callbacks arrive from child // process tasks, and a fire-and-forget one could otherwise append after the host flushed and // removed its journal directory. That chain orders nothing across sessions, and a recovery on it -// can run a whole reacquisition, so `started` stays off it: it takes only its own session's -// serialized step, and is tracked here so the same drain still waits for it. +// can run a whole reacquisition, so `started` and `exitAfterClose` stay off it: each takes only its +// own session's serialized step, and is tracked here so the same drain still waits for it. -import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' export function createStructuredAgentSessionLifecycleDelivery(input: { handle: (event: StructuredAgentSessionLifecycleEvent) => Promise | undefined @@ -19,22 +19,22 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { drain: () => Promise } { let recoveryChain = Promise.resolve() - const settlingStarts = new Set>() + const settlingOffChain = new Set>() const settle = async (event: StructuredAgentSessionLifecycleEvent): Promise => { try { await input.handle(event) } catch (error) { - const scope = event.type === 'started' ? 'started' : 'exit' + const scope = event.type === 'ended' ? 'exit' : event.type input.onError?.({ scope: `structured-agent-session-${scope}:${event.sessionId}`, error }) } } return { deliver: (event) => { - if (event.type === 'started') { + if (event.type !== 'ended') { // Called now, so the step is queued on its session ahead of any later exit of that child. const settling = settle(event) - settlingStarts.add(settling) - void settling.finally(() => settlingStarts.delete(settling)) + settlingOffChain.add(settling) + void settling.finally(() => settlingOffChain.delete(settling)) return } recoveryChain = recoveryChain.then(() => settle(event)) @@ -46,8 +46,8 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { await input.drainObservedExits() const observed = recoveryChain await observed - if (settlingStarts.size > 0) { - await Promise.all(settlingStarts) + if (settlingOffChain.size > 0) { + await Promise.all(settlingOffChain) continue } if (observed === recoveryChain) { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index aecf034b3708..d19d95e4be2e 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -53,6 +53,7 @@ import { createStructuredAgentEnvironmentResolvers } from './structured-agent-sh import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' +import { structuredCodexLifecycleEvent } from './structured-codex-lifecycle-event' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { modelCatalogHostDeps, @@ -280,8 +281,9 @@ async function installOnJournal( onDispatchSettledLate, onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { - lifecycle.deliver(event) + const lifecycleEvent = structuredCodexLifecycleEvent(event) + if (lifecycleEvent) { + lifecycle.deliver(lifecycleEvent) } } }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503d..145c93da746b 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -8,7 +8,7 @@ import { type ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' -import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' import type { ClaudeStructuredSessionEvent } from '../claude/claude-structured-session-state' import { recordAgentSessionProviderHandle, @@ -44,7 +44,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'started') { + if (event.type === 'started' || event.type === 'exitAfterClose') { return event } if ( diff --git a/src/main/runtime/structured-codex-lifecycle-event.ts b/src/main/runtime/structured-codex-lifecycle-event.ts new file mode 100644 index 000000000000..502e665c10c7 --- /dev/null +++ b/src/main/runtime/structured-codex-lifecycle-event.ts @@ -0,0 +1,15 @@ +import type { CodexStructuredSessionEvent } from '../codex/codex-structured-session-state' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' + +/** The Codex adapter events the host's lifecycle handler consumes. A close the host asked for and + * saw land is settled by that close, so its `ended` stays here. */ +export function structuredCodexLifecycleEvent( + event: CodexStructuredSessionEvent +): StructuredAgentSessionLifecycleEvent | null { + if (event.type === 'exitAfterClose') { + return event + } + return event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit' + ? event + : null +} From 031ab2ded3eda40f2d7ec3a2288de490998d4271 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:04:51 -0700 Subject: [PATCH 13/20] test(native-chat): a held message goes out when the old child exits after its stop gave up Claude and Codex, on the shipping adapters and host: a late exit report, and a stop that proves the exit after its deadline, each send the held message with no sweep tick. A report naming another child, or from a connection that is no longer the chat's, touches nothing. Connection-level checks pin when an exit is reported (after a close gave up, never during a running one), and the delivery keeps the report off the cross-session exit chain. --- ...laude-stream-json-connection-close.test.ts | 53 +++++ .../codex/codex-app-server-connection.test.ts | 36 ++++ ...-session-claude-unproven-stop-send.test.ts | 88 +++++++++ ...ured-agent-session-codex-late-exit.test.ts | 182 ++++++++++++++++++ ...d-agent-session-lifecycle-delivery.test.ts | 43 +++++ 5 files changed, 402 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-late-exit.test.ts create mode 100644 src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 22076f3600ea..8d813b549366 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -248,4 +248,57 @@ describe('Claude stream-json close ordering', () => { await expect(closing).resolves.toBe(true) expect(child.stdin.writableEnded).toBe(true) }) + + it('reports a root exit seen after a close gave up, and not one a running close sees', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + const open = async (child: ChildProcessWithoutNullStreams, reports: string[]) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ + next: async (): Promise>> => ({ + value: undefined, + done: true + }) + }) + } + }) as unknown as typeof query + return openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { + onExit: () => reports.push('exit'), + onExitAfterClose: () => reports.push('exit-after-close') + }, + () => child, + queryImpl + ) + } + + const gaveUp: string[] = [] + const lateChild = fakeChild() + mocks.proveClaudeChildExit.mockResolvedValueOnce(false) + const late = await open(lateChild, gaveUp) + await expect(late.close()).resolves.toBe(false) + lateChild.emit('exit', 0, null) + expect(gaveUp).toEqual(['exit-after-close']) + + const running: string[] = [] + const runningChild = fakeChild() + const proof = Promise.withResolvers() + mocks.proveClaudeChildExit.mockReturnValueOnce(proof.promise) + const closing = (await open(runningChild, running)).close() + await vi.waitFor(() => expect(mocks.proveClaudeChildExit).toHaveBeenCalledTimes(2)) + runningChild.emit('exit', 0, null) + proof.resolve(true) + await expect(closing).resolves.toBe(true) + expect(running).toEqual([]) + }) }) diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3d95e7ce380f..8dc141e6e94c 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -526,6 +526,42 @@ describe('openCodexAppServerConnection', () => { await expect(connection.close()).resolves.toBe(true) }) + it('reports an exit seen after a close gave up, and not one a running close sees', async () => { + vi.useFakeTimers() + const late = stubChild({ exitOnStdinEnd: false }) + answerInitialize(late.child) + const gaveUp: string[] = [] + const lateConnection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onExit: () => gaveUp.push('exit'), + onExitAfterClose: () => gaveUp.push('exit-after-close') + }, + late.spawnImpl + ) + const first = lateConnection.close() + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) + await expect(first).resolves.toBe(false) + late.child.emit('exit', 0, null) + expect(gaveUp).toEqual(['exit-after-close']) + + const running = stubChild({ exitOnStdinEnd: false }) + answerInitialize(running.child) + const seen: string[] = [] + const runningConnection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onExit: () => seen.push('exit'), + onExitAfterClose: () => seen.push('exit-after-close') + }, + running.spawnImpl + ) + const closing = runningConnection.close() + running.child.emit('exit', 0, null) + await expect(closing).resolves.toBe(true) + expect(seen).toEqual([]) + }) + it.each([1_090_188, 2_900_090])( 'accepts a realistic %i-byte escaped command completion and keeps processing', async (frameBytes) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 605f45d5fd1c..473a53952119 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -20,6 +20,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type * as EvictionDeadline from './structured-agent-session-eviction-deadline' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -30,6 +31,18 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' +// Shortened only by the test of a stop that proves the exit after its deadline. +const stepDeadline = vi.hoisted((): { ms: number | undefined } => ({ ms: undefined })) +vi.mock('./structured-agent-session-eviction-deadline', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withStructuredAgentSessionEvictionDeadline: ( + ...[steps, timeoutMs]: Parameters + ) => actual.withStructuredAgentSessionEvictionDeadline(steps, stepDeadline.ms ?? timeoutMs) + } +}) + const CALLER = { callerKey: 'client-1' } const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] @@ -93,6 +106,7 @@ beforeEach(async () => { }) afterEach(async () => { + stepDeadline.ms = undefined await adapter.closeAll() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -566,3 +580,77 @@ it('keeps an option change at rest when the Stop proved the exit and only its bo expect(connection.closeCount).toBe(1) expect(claude.connections).toHaveLength(1) }) + +it('sends a held message as soon as the old child exits after its retry gave up, with no sweep tick', async () => { + const connection = await heldAfterStop() + + // The old process exits now: the connection reports it, and the retry that follows proves it. + connection.handlers.onExitAfterClose?.() + const resumed = await resumedWith(connection, 'Carry on.') + + expect(connection.closeCount).toBe(3) + expect(resumed.closed).toBe(false) + expect(owedWindDown()).toBeUndefined() +}) + +it('sends a held message as soon as a stop that ran past its deadline proves the exit late', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + stepDeadline.ms = 200 + // Every close of the old child waits on its exit, which comes after both stop passes gave up. + const exited = Promise.withResolvers() + const close = connection.close + connection.close = async () => { + await exited.promise + return close() + } + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + expect(owedWindDown()).toBeDefined() + expect(claude.connections).toHaveLength(1) + + // Both passes already gave up; the retry the late proof starts gets the full deadline. + stepDeadline.ms = undefined + exited.resolve() + const resumed = await resumedWith(connection, 'Carry on.') + + expect(resumed.closed).toBe(false) + expect(owedWindDown()).toBeUndefined() +}) + +it('ignores an exit report naming another child than the one whose stop is owed', async () => { + const connection = await heldAfterStop() + const session = host['sessions'].get(SESSION)! + + await host.handleAdapterEvent({ + type: 'exitAfterClose', + sessionId: SESSION, + fence: session.child!.fence, + acquisitionGeneration: 'an-earlier-child' + }) + await laneDrained() + + expect(connection.closeCount).toBe(2) + expect(owedWindDown()).toBeDefined() + expect(claude.connections).toHaveLength(1) +}) + +it("never lets an old child's late exit report retry a newer child's owed stop", async () => { + const old = await heldAfterStop() + old.handlers.onExitAfterClose?.() + const resumed = await resumedWith(old, 'Carry on.') + closeUnprovenFor(resumed, 1) + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(resumed.closeCount).toBe(1) + expect(owedWindDown()).toBeDefined() + + // The first child's connection reports its exit again: it names no child whose stop is owed. + old.handlers.onExitAfterClose?.() + await laneDrained() + + expect(resumed.closeCount).toBe(1) + expect(owedWindDown()).toBeDefined() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-late-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-late-exit.test.ts new file mode 100644 index 000000000000..80ce97cfc568 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-late-exit.test.ts @@ -0,0 +1,182 @@ +// A Codex chat whose close could not prove the app-server gone, then the user's next message, on the +// shipping adapter and host. The message waits on that close; when the old app-server is later seen +// to exit, the close is retried at once and the message goes out, with no idle-sweep tick. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import { fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' +import { + CodexStructuredSessionAdapter, + type CodexStructuredSessionEvent +} from '../../codex/codex-structured-session-adapter' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredCodexLifecycleEvent } from '../../runtime/structured-codex-lifecycle-event' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let store: AgentSessionRecordStore +let codex: ReturnType +let events: CodexStructuredSessionEvent[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-late-exit-')) + resetHostTestOperationIds() + events = [] + codex = fakeCodex() + let generation = 0 + const adapter = new CodexStructuredSessionAdapter({ + // As the shipping resolver: a restart resumes the thread the record names. + resolveLaunch: async () => { + const head = agentSessionProviderHandleChainHead( + store.getRecord(SESSION)?.providerHandleChain ?? [] + ) + return { + command: 'codex', + args: ['app-server'], + cwd: root, + codexHome: null, + resumeThreadId: head?.handle.provider === 'codex' ? head.handle.threadId : null + } + }, + // As the runtime routes them: what the host's lifecycle handler consumes reaches it. + onEvent: (event) => { + events.push(event) + const lifecycleEvent = structuredCodexLifecycleEvent(event) + if (lifecycleEvent) { + void host.handleAdapterEvent(lifecycleEvent) + } + }, + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW, + mintAcquisitionGeneration: () => `generation-${++generation}` + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) + } +} + +async function waitRows() { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'previousExitUnverifiable' + ) +} + +type CodexConnection = (typeof codex.connections)[number] + +function startedTurnWith(connection: CodexConnection, text: string): boolean { + return connection.calls.some( + (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes(text) + ) +} + +/** Closes the chat with an app-server whose close cannot prove the exit, then holds a message. */ +async function heldAfterUnprovenClose(): Promise { + const connection = codex.connections[0]! + const close = connection.close + let unproven = 2 + connection.close = async () => { + if (unproven === 0) { + return close() + } + unproven -= 1 + connection.closeCount += 1 + return false + } + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toMatchObject({ + cause: 'user-close' + }) + await send('Carry on.') + await vi.waitFor(async () => expect(await waitRows()).toHaveLength(1)) + expect(connection.closeCount).toBe(2) + expect(codex.connections).toHaveLength(1) + return connection +} + +function exitReports(): number { + return events.filter((event) => event.type === 'exitAfterClose').length +} + +it('sends a held message as soon as the old app-server exits after its close gave up', async () => { + const connection = await heldAfterUnprovenClose() + + connection.handlers.onExitAfterClose?.() + const resumed = await vi.waitFor(() => { + const next = codex.connections.at(-1)! + expect(next).not.toBe(connection) + expect(startedTurnWith(next, 'Carry on.')).toBe(true) + return next + }) + + expect(connection.closeCount).toBe(3) + expect(startedTurnWith(connection, 'Carry on.')).toBe(false) + expect(resumed.closeCount).toBe(0) + expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() +}) + +it("drops a late exit report from an app-server that is no longer the chat's", async () => { + const connection = await heldAfterUnprovenClose() + connection.handlers.onExitAfterClose?.() + const resumed = await vi.waitFor(() => { + const next = codex.connections.at(-1)! + expect(startedTurnWith(next, 'Carry on.')).toBe(true) + return next + }) + expect(exitReports()).toBe(1) + + connection.handlers.onExitAfterClose?.() + + expect(exitReports()).toBe(1) + expect(resumed.closeCount).toBe(0) +}) diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts new file mode 100644 index 000000000000..c809ab82f561 --- /dev/null +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts @@ -0,0 +1,43 @@ +import { expect, it, vi } from 'vitest' +import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' + +it("keeps a late exit report off the exit chain, so its wait on its own session holds up no other session's exit, and drain still waits for it", async () => { + const sessionLane = Promise.withResolvers() + const handled: string[] = [] + const delivery = createStructuredAgentSessionLifecycleDelivery({ + handle: async (event) => { + if (event.type === 'exitAfterClose') { + await sessionLane.promise + } + handled.push(`${event.type}:${event.sessionId}`) + }, + drainObservedExits: async () => {} + }) + + delivery.deliver({ + type: 'exitAfterClose', + sessionId: 'session-a', + fence: 1, + acquisitionGeneration: 'a-1' + }) + delivery.deliver({ + type: 'ended', + sessionId: 'session-b', + reason: 'exited', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'b-1' + }) + await vi.waitFor(() => expect(handled).toEqual(['ended:session-b'])) + + let drained = false + const draining = delivery.drain().then(() => { + drained = true + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(drained).toBe(false) + + sessionLane.resolve() + await draining + expect(handled).toEqual(['ended:session-b', 'exitAfterClose:session-a']) +}) From 2efc967a7d705379e4ae0c0b720ae3a0d27bc9e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:30:24 -0700 Subject: [PATCH 14/20] fix(native-chat): a Codex close that proved the exit never reports it as a late exit Node reports a child's end twice: 'exit', then 'close' once its stdio drains. When 'close' landed after a close's proof had settled true, the connection reported an exit after a close gave up, because it gated on "no close is running" rather than "the last close gave up". The adapter usually dropped it (the session was already gone); when settlement admission had refused, the host ran one needless retry. The gate now reads the exit proof itself, which drops an attempt only when it was unproven, the same rule the Claude connection uses. --- src/main/codex/codex-app-server-connection.test.ts | 4 +++- src/main/codex/codex-app-server-connection.ts | 11 ++--------- .../retryable-process-exit-proof.test.ts | 2 ++ .../child-process/retryable-process-exit-proof.ts | 5 +++++ 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 8dc141e6e94c..cb8bf421ae39 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -526,7 +526,7 @@ describe('openCodexAppServerConnection', () => { await expect(connection.close()).resolves.toBe(true) }) - it('reports an exit seen after a close gave up, and not one a running close sees', async () => { + it('reports an exit seen after a close gave up, and not one a running or proven close sees', async () => { vi.useFakeTimers() const late = stubChild({ exitOnStdinEnd: false }) answerInitialize(late.child) @@ -559,6 +559,8 @@ describe('openCodexAppServerConnection', () => { const closing = runningConnection.close() running.child.emit('exit', 0, null) await expect(closing).resolves.toBe(true) + // Stdio 'close' can land after the proof settled; the close still proved the exit. + running.child.emit('close', 0, null) expect(seen).toEqual([]) }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 94186a820437..53fd74b64c40 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -78,7 +78,6 @@ export async function openCodexAppServerConnection( let exited = false let exitObserved = false let closing = false - let closeRunning = false let exitReported = false const exitProof = new RetryableProcessExitProof() /** First terminal cause, or null while the transport is still usable. Set once: @@ -130,7 +129,7 @@ export async function openCodexAppServerConnection( if (exitObserved && !closing && !exitReported) { exitReported = true handlers.onExit?.(terminalError) - } else if (exitObserved && closing && !closeRunning && !exitReported) { + } else if (exitObserved && closing && !exitProof.holdsAttempt && !exitReported) { exitReported = true handlers.onExitAfterClose?.() } @@ -247,8 +246,7 @@ export async function openCodexAppServerConnection( return Promise.resolve(true) } closing = true - closeRunning = true - const proof = exitProof.run(async () => { + return exitProof.run(async () => { try { child.stdin.end() } catch { @@ -272,11 +270,6 @@ export async function openCodexAppServerConnection( dispatcher.failPending(new Error('codex app-server connection closed')) return exitObserved }) - const settled = (): void => { - closeRunning = false - } - proof.then(settled, settled) - return proof } const connection: CodexAppServerConnection = { diff --git a/src/shared/child-process/retryable-process-exit-proof.test.ts b/src/shared/child-process/retryable-process-exit-proof.test.ts index a7bc57976452..73959d19bf28 100644 --- a/src/shared/child-process/retryable-process-exit-proof.test.ts +++ b/src/shared/child-process/retryable-process-exit-proof.test.ts @@ -13,6 +13,7 @@ describe('RetryableProcessExitProof', () => { await expect(Promise.all([first, concurrent])).resolves.toEqual([true, true]) await expect(proof.run(proveExit)).resolves.toBe(true) expect(proveExit).toHaveBeenCalledOnce() + expect(proof.holdsAttempt).toBe(true) }) it('permits another attempt after exit was not proven', async () => { @@ -20,6 +21,7 @@ describe('RetryableProcessExitProof', () => { const proveExit = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) await expect(proof.run(proveExit)).resolves.toBe(false) + expect(proof.holdsAttempt).toBe(false) await expect(proof.run(proveExit)).resolves.toBe(true) expect(proveExit).toHaveBeenCalledTimes(2) }) diff --git a/src/shared/child-process/retryable-process-exit-proof.ts b/src/shared/child-process/retryable-process-exit-proof.ts index f8c3a89de316..d5538360ea75 100644 --- a/src/shared/child-process/retryable-process-exit-proof.ts +++ b/src/shared/child-process/retryable-process-exit-proof.ts @@ -1,6 +1,11 @@ export class RetryableProcessExitProof { private inFlight: Promise | null = null + /** False before any attempt and after one gave up; a proven attempt stays held. */ + get holdsAttempt(): boolean { + return this.inFlight !== null + } + run(proveExit: () => Promise): Promise { if (this.inFlight) { return this.inFlight From 5d040c8661b87ef0877a285f48763e5685141da7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:32:52 -0700 Subject: [PATCH 15/20] fix(native-chat): a late exit retries an owed stop once, however many passes gave up on it Every stop pass started while a Claude close hangs joins that one close, so a Stop, a held message's retry and each sweep tick could all give up on it. When the exit finally proved, each of them asked for a retry of the owed stop. If the first retry then failed a bookkeeping step, every other ask ran another full pass back to back, each with its own 10 s step deadline, holding up that chat. Now only the pass whose proof actually ended the child asks; a failed retry waits for the sweep or the next action, like any other. --- ...-session-claude-unproven-stop-send.test.ts | 35 +++++++++++++++++++ .../structured-agent-session-host-lifetime.ts | 5 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 473a53952119..deacb1a58e26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -621,6 +621,41 @@ it('sends a held message as soon as a stop that ran past its deadline proves the expect(owedWindDown()).toBeUndefined() }) +it('retries an owed stop once when several passes gave up on one hung close and its exit lands', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + stepDeadline.ms = 200 + const exited = Promise.withResolvers() + const close = connection.close + connection.close = async () => { + await exited.promise + return close() + } + // The Stop, the held send's retry and two sweep ticks each give up on the same close. + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + await host['lifetime'].idleSweep.tick() + await host['lifetime'].idleSweep.tick() + await laneDrained() + const releases = vi + .spyOn(store, 'transitionHandoff') + .mockRejectedValueOnce(new Error('record store busy')) + + stepDeadline.ms = undefined + exited.resolve() + await eventually(() => expect(releases).toHaveBeenCalled()) + await laneDrained() + await laneDrained() + + // The one retry failed; the sweep or the next action retries it, not every pass that gave up. + expect(releases).toHaveBeenCalledOnce() + expect(owedWindDown()).toBeDefined() + expect(claude.connections).toHaveLength(1) +}) + it('ignores an exit report naming another child than the one whose stop is owed', async () => { const connection = await heldAfterStop() const session = host['sessions'].get(SESSION)! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 1fcd1cbd7c1b..830c49091794 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -164,7 +164,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // Host state must not disagree with the adapter for the steps in between. onProviderChildStopped: (verdict) => { if (stopping) { - endProviderChild(session, { + const ended = endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, cause, @@ -174,7 +174,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ...(owed ? { endedAt: owed.requestedAt } : {}), ...verdict }) - if (gaveUp) { + // Passes that joined one hung close all prove it; only the one that ended the child asks. + if (ended && gaveUp) { void context.finishStopAfterExit?.(sessionId, stopping) } } From 6d23c5fa0bff006e37c62ace793635d7944ede70 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:34:06 -0700 Subject: [PATCH 16/20] refactor(native-chat): the restart restore owns the lease reconciler it already wraps The host built the restart lease reconciler only to hand it to the restore factory and to attach and start. The factory now builds that one instance from the host's probe and clock and exposes it, so attach and start share the same in-flight reconcile as before. The host file regains the room to keep its explicit return types on flushStreamedEvents and subscribe. --- .../structured-agent-session-host.ts | 21 +++++++------------ .../structured-agent-session-reveal.ts | 19 ++++++++++++++--- 2 files changed, 23 insertions(+), 17 deletions(-) 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 79256c876ea5..3da3e796b54d 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 @@ -10,7 +10,6 @@ import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' @@ -88,9 +87,6 @@ export class StructuredAgentSessionHost { private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState - private readonly reconcileLeases: ( - sessionId: string - ) => Promise private readonly restore: ReturnType private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: ReturnType< @@ -113,12 +109,6 @@ export class StructuredAgentSessionHost { this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => this.eventRecovery.recoverAfterSinkFailure(sessionId, error) ) - this.reconcileLeases = createRestartReconciler({ - store: deps.store, - probe: (record) => this.runtimeState.probeRecord(record), - ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), - now: () => this.now() - }) this.conversationDelivery = createStructuredAgentSessionConversationDelivery({ deps, sessions: this.sessions, @@ -138,7 +128,8 @@ export class StructuredAgentSessionHost { flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { - reconcileLeases: this.reconcileLeases, + probe: (record) => this.runtimeState.probeRecord(record), + now: () => this.now(), resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, @@ -207,7 +198,7 @@ export class StructuredAgentSessionHost { ...this.lifetimeContext(), subscribers: this.subscribers, tasks: this.tasks, - reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), + reconcileLeases: (sessionId) => this.restore.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open } @@ -257,7 +248,8 @@ export class StructuredAgentSessionHost { return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } - flushStreamedEvents = (sessionId: string) => this.runtimeState.flushEventSink(sessionId) + flushStreamedEvents = (sessionId: string): Promise => + this.runtimeState.flushEventSink(sessionId) // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. @@ -330,7 +322,8 @@ export class StructuredAgentSessionHost { journalSnapshot = async (sessionId: string): Promise => (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) + subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => + this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => settleStructuredAgentSessionLateDispatch(this.mutationContext(), input) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 262c9e18d3c6..26e319010ecb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -10,6 +10,8 @@ // a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a // Retry, so the tab is worth publishing either way. +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' @@ -17,6 +19,7 @@ import { StructuredAgentSessionReadableRestorer } from './structured-agent-sessi import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import { createReaderReconcile, + createRestartReconciler, reportEachFailureOnce } from './structured-agent-session-restart-reconcile' import type { @@ -57,21 +60,30 @@ export async function revealStructuredAgentSession( } /** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. - * Its lease bookkeeping is a reader's, which never fails a read or startup; startup shares it. */ + * Its lease bookkeeping is a reader's, which never fails a read or startup; startup shares it. + * `reconcileLeases` is the one restart reconciler, which attach and start await strictly. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' > & { - reconcileLeases: (sessionId: string) => Promise + probe: (record: AgentSessionRecord) => Promise + now: () => number resolveRecovery: (sessionId: string) => Promise } ): { + reconcileLeases: (sessionId: string) => Promise reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { - const { reconcileLeases, resolveRecovery, ...rest } = wiring + const { probe, now, resolveRecovery, ...rest } = wiring + const reconcileLeases = createRestartReconciler({ + store: deps.store, + probe, + ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), + now + }) const failures = reportEachFailureOnce(deps.onLeaseReconcileFailure) const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ @@ -91,6 +103,7 @@ export function createStructuredAgentSessionHostRestore( }) const gate = new StructuredAgentSessionRestartRestoreGate() return { + reconcileLeases, reconcileRestartLeases: async () => { await reconcile('startup') }, From 58351a1b74207fcb7a1d2eea020e7299a64f0f44 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:34:31 -0700 Subject: [PATCH 17/20] test(native-chat): an exit report with nothing waiting finishes the owed stop and starts no agent --- ...d-agent-session-claude-unproven-stop-send.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index deacb1a58e26..6f73277449d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -593,6 +593,18 @@ it('sends a held message as soon as the old child exits after its retry gave up, expect(owedWindDown()).toBeUndefined() }) +it('finishes an owed stop as soon as the old child exits with nothing waiting, starting no agent', async () => { + const connection = await stopWithUnprovenClose(1) + + connection.handlers.onExitAfterClose?.() + await eventually(() => expect(owedWindDown()).toBeUndefined()) + await commitSettled() + + expect(connection.closeCount).toBe(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(claude.connections).toHaveLength(1) +}) + it('sends a held message as soon as a stop that ran past its deadline proves the exit late', async () => { const connection = claude.connections[0]! await openTurn(connection) From 5ed0911d2a8959717df77cf4be0d0ccc7bfa214b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:52:18 -0700 Subject: [PATCH 18/20] test(native-chat): a message after a Codex Stop whose exit was unproven retries that stop, then goes to a fresh Codex --- ...tured-agent-session-codex-stop-row.test.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index e93b44a6b089..42898bac2db8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -36,6 +36,8 @@ let host: StructuredAgentSessionHost let turns: ReturnType let codex: ReturnType let notify: (method: string, params: unknown) => void +/** Read at each start, so a test can say what the next start resumes. */ +let launch: { resumeThreadId?: string | null } let disposeSession: MockInstance> beforeEach(async () => { @@ -57,7 +59,8 @@ beforeEach(async () => { } const store = await openTestAgentSessionRecordStore(root) // The runtime's wiring: an echo accepts its send, and an exit reaches the host. - const adapter = adapterFor(codex, {}, [], { + launch = {} + const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { @@ -395,3 +398,52 @@ describe('a Codex Stop whose interrupt failed', () => { expect(childEndedByStop()).toBe(false) }) }) + +describe('a message after a Codex Stop whose exit was unproven', () => { + it('retries that stop first, then goes to a fresh Codex, never to the old one', async () => { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw interruptFailure('internal error') + } + // As the real connection: once a close begins it refuses every request, proven or not. + const old = codex.connections.at(-1)! + const close = old.close + const request = old.request + let unproven = 1 + old.close = async () => { + old.closed = true + if (unproven === 0) { + return close() + } + unproven -= 1 + return false + } + old.request = (method, params) => + old.closed + ? Promise.reject(new Error('codex app-server is closing')) + : request(method, params) + + await stop() + await host.flushStreamedEvents(SESSION) + expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeDefined() + // The next start resumes the chat's thread, as the runtime's launch resolves it from the record. + launch.resumeThreadId = THREAD + + const sent = await send('carry on') + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) + await vi.waitFor(() => + expect( + codex.connections[1]!.calls.some( + (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') + ) + ).toBe(true) + ) + expect( + old.calls.some( + (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') + ) + ).toBe(false) + expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() + }) +}) From 72279e34f597a0b33d52ae09805bfce890881920 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:36:43 -0700 Subject: [PATCH 19/20] refactor(native-chat): the late exit report lives beside the other lifecycle events The exit-after-close event and the lifecycle union had moved to their own file only because the adapter contract file was over its line limit on the stacked base. Main's split of that file left room, so both go back beside the started and ended events, and the extra file is gone. --- .../claude/claude-structured-session-state.ts | 2 +- .../codex/codex-structured-session-state.ts | 2 +- .../structured-agent-session-adapter.ts | 14 +++++++++++++ ...structured-agent-session-event-recovery.ts | 2 +- ...tructured-agent-session-lifecycle-event.ts | 20 ------------------- ...ctured-agent-session-lifecycle-delivery.ts | 2 +- .../structured-claude-runtime-adapter.ts | 2 +- .../structured-codex-lifecycle-event.ts | 2 +- 8 files changed, 20 insertions(+), 26 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 33aebf8a2fac..809d572111ed 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -6,10 +6,10 @@ import type { } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { + StructuredAgentSessionExitAfterCloseEvent, StructuredAgentSessionStartedEvent, StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionExitAfterCloseEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index f1cf8b983d14..95974a2631d1 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -20,9 +20,9 @@ import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker import type { CodexJournalTranslator } from './codex-structured-journal-translation' import type { StructuredAgentSessionEndedEvent, + StructuredAgentSessionExitAfterCloseEvent, StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionExitAfterCloseEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index f99d3958e22f..60c22cf83692 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -215,6 +215,20 @@ export type StructuredAgentSessionStartedEvent = { restoreSkippedOptions: readonly string[] } +/** A close that gave up proving this child's exit has since seen its root exit. Evidence to retry + * the stop owed for this child, not a verdict: that stop's own proof decides. */ +export type StructuredAgentSessionExitAfterCloseEvent = { + type: 'exitAfterClose' + sessionId: string + fence: number + acquisitionGeneration: string +} + +export type StructuredAgentSessionLifecycleEvent = + | StructuredAgentSessionEndedEvent + | StructuredAgentSessionStartedEvent + | StructuredAgentSessionExitAfterCloseEvent + /** Whether the provider child behind an acquisition has proven its start. A publish-first * acquire hands over a `starting` child and the `started` lifecycle event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' 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 7d42499089a5..99df13044f81 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 @@ -1,5 +1,5 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-lifecycle-event' +import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts deleted file mode 100644 index 761bdfad9a1c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lifecycle-event.ts +++ /dev/null @@ -1,20 +0,0 @@ -// What a provider adapter tells the host about its child's life, for the host's lifecycle handler. - -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionStartedEvent -} from './structured-agent-session-adapter' - -/** A close that gave up proving this child's exit has since seen its root exit. Evidence to retry - * the stop owed for this child, not a verdict: that stop's own proof decides. */ -export type StructuredAgentSessionExitAfterCloseEvent = { - type: 'exitAfterClose' - sessionId: string - fence: number - acquisitionGeneration: string -} - -export type StructuredAgentSessionLifecycleEvent = - | StructuredAgentSessionEndedEvent - | StructuredAgentSessionStartedEvent - | StructuredAgentSessionExitAfterCloseEvent diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index 782dabdbecdc..ce9a023783d1 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -6,7 +6,7 @@ // can run a whole reacquisition, so `started` and `exitAfterClose` stay off it: each takes only its // own session's serialized step, and is tracked here so the same drain still waits for it. -import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const LOG_SCOPES = { diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 145c93da746b..b640ae2dec23 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -8,7 +8,7 @@ import { type ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' -import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStructuredSessionEvent } from '../claude/claude-structured-session-state' import { recordAgentSessionProviderHandle, diff --git a/src/main/runtime/structured-codex-lifecycle-event.ts b/src/main/runtime/structured-codex-lifecycle-event.ts index 502e665c10c7..fbf7a63b91ec 100644 --- a/src/main/runtime/structured-codex-lifecycle-event.ts +++ b/src/main/runtime/structured-codex-lifecycle-event.ts @@ -1,5 +1,5 @@ import type { CodexStructuredSessionEvent } from '../codex/codex-structured-session-state' -import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-lifecycle-event' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' /** The Codex adapter events the host's lifecycle handler consumes. A close the host asked for and * saw land is settled by that close, so its `ended` stays here. */ From 9d91204b70d61abe43d78b33228da47624a9c059 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:59:45 -0700 Subject: [PATCH 20/20] test(native-chat): a late exit's retry keeps the person's Stop as the end of the turn it cut The stop that lands after the old child exits late is the owed stop's retry, so it writes no Stop event of its own: the person's Stop stays the latest, and the turn it cut, ended only by that retry, reads as their cancellation. --- ...-session-claude-unproven-stop-send.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 6e6a640d15d2..17bb74cfa896 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { @@ -594,6 +595,29 @@ it('sends a held message as soon as the old child exits after its retry gave up, expect(owedWindDown()).toBeUndefined() }) +it("keeps the person's Stop as the cut turn's end when the old child exits late, writing no Stop of its own", async () => { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, 2) + // No interrupted result: only the stop that lands ends the turn the Stop cut. + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await send('Carry on.') + await eventually(async () => expect(await waitRows()).toHaveLength(1)) + await laneDrained() + const journal = host['sessions'].get(SESSION)!.journal + const personStop = journal.stopMarks.latest() + expect(personStop).toMatchObject({ event: { reason: 'user-stop' } }) + + connection.handlers.onExitAfterClose?.() + await resumedWith(connection, 'Carry on.') + + expect(journal.stopMarks.latest()).toEqual(personStop) + await host.flushStreamedEvents(SESSION) + const { items } = await host.journalSnapshot(SESSION) + const cut = readAgentJournalTurn(items.find((item) => item.body.kind === 'turn')?.body) + expect(cut).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) +}) + it('finishes an owed stop as soon as the old child exits with nothing waiting, starting no agent', async () => { const connection = await stopWithUnprovenClose(1)