diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 2e470dd25c8..cdb7ded21e7 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -87,6 +87,60 @@ describe('Claude structured dispatch image limits', () => { expect(session.activeTurnSequence).toBe(session.dispatchSequence) }) + it('settles the send a timed-out replay proves was delivered', async () => { + const session = sessionFor() + const settled = vi.fn() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + + resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'), settled) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: sentUuid } + }) + }) + + it('settles a superseded dispatch even though it no longer owns the turn identity', async () => { + const session = sessionFor() + const settled = vi.fn() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + + // The stale replay must not claim the active turn, but the message it names + // did land, so the send it came from is delivered and must stop reading as + // unconfirmed — that banner is what makes a user resend a duplicate. + expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'), settled)).toBe( + false + ) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid } + }) + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled) + await expect(second).resolves.toMatchObject({ state: 'accepted' }) + expect(settled).toHaveBeenCalledTimes(1) + }) + it('never lets a late replay for dispatch A resolve dispatch B', async () => { const session = sessionFor() const first = dispatchClaudeTurn( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 96271e41d71..b7619a1e94e 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -1,5 +1,8 @@ import { randomUUID } from 'node:crypto' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeHasReplayContent, @@ -14,9 +17,16 @@ import { const MAX_RETIRED_DISPATCH_WAITERS = 64 +/** A dispatch whose ack window expired, proven delivered by this replay. */ +export type ClaudeLateDispatchSettlement = (input: { + clientMessageId: string + providerIdentity: AgentJournalItemIdentity +}) => void + export function resolveClaudeReplayWaiter( session: ClaudeSession, - message: Record + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement ): boolean { const envelope = readClaudeMessageEnvelope(message) const isUserReplay = @@ -52,7 +62,7 @@ export function resolveClaudeReplayWaiter( ) if (retired) { forgetRetiredWaiter(session, retired) - return recoverLateIdentity(session, retired, uuid, isUserReplay) + return recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) } return false } @@ -65,7 +75,7 @@ export function resolveClaudeReplayWaiter( const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) if (retired) { forgetRetiredWaiter(session, retired) - return recoverLateIdentity(session, retired, uuid, isUserReplay) + return recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) } if (isUserReplay) { @@ -89,7 +99,7 @@ export function resolveClaudeReplayWaiter( if (lateCompatible.length === 1) { const [candidate] = lateCompatible forgetRetiredWaiter(session, candidate!) - return recoverLateIdentity(session, candidate!, uuid, true) + return recoverLateIdentity(session, candidate!, uuid, true, onSettledLate) } } return false @@ -138,11 +148,19 @@ function recoverLateIdentity( session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string, - isUserReplay: boolean + isUserReplay: boolean, + onSettledLate?: ClaudeLateDispatchSettlement ): boolean { if (!isUserReplay && !waiter.acceptsResult) { return false } + // The provider acted on this dispatch, so the send it came from is delivered. + // Unfenced on purpose: the dispatch-sequence check below only decides which + // turn owns the identity, while delivery is settled for good either way. + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) if (waiter.dispatchSequence === session.dispatchSequence) { session.activeTurnId = uuid session.activeTurnSequence = waiter.dispatchSequence @@ -155,12 +173,14 @@ function waitForReplay( timeoutMs: number, acceptsResult: boolean, sentUuid: string, - replayContentKey: string + replayContentKey: string, + clientMessageId: string ): { waiter: ClaudeDispatchWaiter; promise: Promise } { let waiter!: ClaudeDispatchWaiter const promise = new Promise((resolve) => { waiter = { acceptsResult, + clientMessageId, sentUuid, dispatchSequence: session.dispatchSequence, replayContentKey, @@ -220,7 +240,8 @@ export async function dispatchClaudeTurn( timeoutMs, acceptsResult, sentUuid, - claudeDispatchContentKey(content) + claudeDispatchContentKey(content), + input.clientMessageId ) const replayed = replay.promise try { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index e8d09bd78d9..b4cf25ac469 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -109,7 +109,11 @@ export async function acquireClaudeSession({ if (liveSession) { liveSession.leafUuid = observedLeafUuid } - const startsTurn = liveSession ? resolveClaudeReplayWaiter(liveSession, message) : false + const startsTurn = liveSession + ? resolveClaudeReplayWaiter(liveSession, message, (settlement) => + deps.onDispatchSettledLate?.({ sessionId, ...settlement }) + ) + : false callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, { type: 'message', diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1c0b1862913..5617ff2cd3d 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,4 +1,7 @@ -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection, @@ -56,6 +59,12 @@ export type ClaudeStructuredSessionAdapterDeps = { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + /** A dispatch whose ack timed out, proven delivered by a later provider replay. */ + onDispatchSettledLate?: (input: { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + }) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null @@ -87,6 +96,9 @@ export type ClaudeDispatchWaiter = { resolve: (uuid: string | null) => void timer: ReturnType acceptsResult: boolean + /** Carried so a replay that lands after the ack window can settle the journal + * submission this dispatch came from, not just the in-memory turn identity. */ + clientMessageId: string /** Client uuid echoed by Claude so a replay is tied to its own dispatch. */ sentUuid: string /** Sequence used to fence a late identity from a newer dispatch. */ 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 f4a0244d0af..9a5f3e0c475 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 @@ -5,7 +5,10 @@ // they share one path here rather than five copies in the host. The host keeps attach, holds and // teardown; this is the surface that assumes those already happened. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -118,3 +121,26 @@ export function readStructuredAgentSessionOptions( return context.deps.adapter.readOptions({ sessionId, fence: session.fence }) }) } + +/** Settle provider-proven delivery independently of an in-flight client mutation. */ +export async function settleStructuredAgentSessionLateDispatch( + context: StructuredAgentSessionMutationContext, + input: { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + } +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + // The journal queue drains before close; the host queue would defer this past teardown. + await session.journal.resolveDispatch({ + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence: session.fence + }) + context.publish(input.sessionId, session.journal) +} 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 13b7d7b441e..62ac06719d0 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 @@ -38,6 +38,7 @@ import { respondToStructuredAgentSessionPrompt, sendStructuredAgentSessionTurn, setStructuredAgentSessionOption, + settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' @@ -331,6 +332,9 @@ export class StructuredAgentSessionHost { subscribe = (input: AgentSessionSubscribeInput): (() => void) => this.backgroundTasks.subscribe(input) + settleLateDispatch = (input: Parameters[1]) => + settleStructuredAgentSessionLateDispatch(this.mutationContext(), input) + publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = ( sessionId, state diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts new file mode 100644 index 00000000000..a75d2ea6512 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -0,0 +1,224 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let closeSession: Mock> + +function accepted(): AgentSessionDispatchOutcome { + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } +} + +function sendParams(text: string): { + envelope: AgentSessionMutationEnvelope + body: ReturnType +} { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } +} + +function submissions(): unknown { + const state = host.history({ sessionId: SESSION, direction: 'tail' }) + return state.ok ? state.page.submissions : null +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-late-settle-')) + resetHostTestOperationIds() + dispatch = vi.fn(async () => accepted()) + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + })), + releaseAcquisition: vi.fn(async () => true), + dispatch, + closeSession, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await host.close(SESSION) + await rm(root, { recursive: true, force: true }) +}) + +describe('settling a send the provider proves it received after the ack window', () => { + it('publishes acceptance during a pending send and never reopens it for retry', async () => { + let finishDispatch!: (outcome: AgentSessionDispatchOutcome) => void + dispatch.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDispatch = resolve + }) + ) + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = host.subscribe({ + id: 'late-receipt', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const params = sendParams('echo before send completes') + const pending = host.send(CALLER, params) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + try { + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'early-echo' } + }) + expect(events.at(-1)).toMatchObject({ + type: 'batch', + batch: { + submissions: [ + { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } + ] + } + }) + } finally { + finishDispatch({ state: 'unknown', reason: 'ack timeout' }) + unsubscribe() + } + await expect(pending).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('persists an echo received while the provider is closing', async () => { + dispatch.mockResolvedValueOnce({ state: 'unknown', reason: 'ack timeout' }) + const params = sendParams('received just before shutdown') + await host.send(CALLER, params) + let settlement: Promise | undefined + closeSession.mockImplementationOnce(async () => { + settlement = host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'closing-echo' } + }) + void settlement.catch(() => undefined) + return true + }) + + await host.close(SESSION) + await expect(settlement).resolves.toBeUndefined() + await host.revealSession(SESSION) + expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('moves a durable unknown to accepted so nothing offers to send it again', async () => { + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const params = sendParams('sent while a turn was running') + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'late-uuid' } + }) + + expect(submissions()).toMatchObject([ + { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } + ]) + // The point of the fix: the client stops rendering Retry, and Retry is what + // was delivering the message to the agent a second time. + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('leaves an already accepted send alone', async () => { + const params = sendParams('ordinary send') + await host.send(CALLER, params) + + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'a-different-uuid' } + }) + + expect(submissions()).toMatchObject([ + { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } + ]) + }) + + it('ignores a session this host is not holding', async () => { + await expect( + host.settleLateDispatch({ + sessionId: 'session-that-is-not-attached', + clientMessageId: 'whatever', + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'x' } + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d670c16f47d..51640fb0cb0 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -260,6 +260,14 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), + onDispatchSettledLate: (settlement) => { + void host?.settleLateDispatch(settlement).catch((error) => + deps.onError?.({ + scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, + error + }) + ) + }, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 95151f1dae9..26c9922bb07 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -34,6 +34,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void + onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] } export function createStructuredClaudeRuntimeAdapter( @@ -100,6 +101,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.onBackgroundTasksChanged ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } : {}), + ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) })