diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index efc7e417b59..758879c31ae 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -17,6 +17,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession prompts: {} as ClaudeSession['prompts'], dispatchWaiters: [], retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, dispatchSequence: 0, optionMutationSequence: 0, options: new Map(), @@ -111,6 +112,59 @@ describe('Claude structured dispatch image limits', () => { await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) }) + it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid + + const fillerDispatches = await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn( + session, + { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }, + 5 + ) + ) + ) + expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(session.retiredDispatchWaiters).toHaveLength(64) + expect(session.replayContentFallbackBlocked).toBe(true) + expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( + false + ) + + while (session.retiredDispatchWaiters.length > 0) { + const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid + resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid, 'same prompt')) + } + expect(session.retiredDispatchWaiters).toHaveLength(0) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt')) + ).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + it('removes only its own waiter when a later send fails', 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 a903e3f70ff..0c839c14871 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -74,7 +74,7 @@ export function resolveClaudeReplayWaiter( // candidate on one side of the timeout boundary; with active and retired // candidates present, identical prompts are intentionally left unknown. const replayContentKey = claudeDispatchContentKey(envelope.content) - if (session.retiredDispatchWaiters.length === 0) { + if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { const compatible = session.dispatchWaiters.filter( (candidate) => candidate.replayContentKey === replayContentKey ) @@ -82,7 +82,7 @@ export function resolveClaudeReplayWaiter( settleWaiter(session, compatible[0]!, uuid) return compatible[0]!.dispatchSequence === session.dispatchSequence } - } else if (session.dispatchWaiters.length === 0) { + } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { const lateCompatible = session.retiredDispatchWaiters.filter( (candidate) => candidate.replayContentKey === replayContentKey ) @@ -184,6 +184,7 @@ function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): voi waiter.retired = true session.retiredDispatchWaiters.push(waiter) if (session.retiredDispatchWaiters.length > MAX_RETIRED_DISPATCH_WAITERS) { + session.replayContentFallbackBlocked = true session.retiredDispatchWaiters.splice( 0, session.retiredDispatchWaiters.length - MAX_RETIRED_DISPATCH_WAITERS diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 6f884231c00..832003c5cc4 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -12,6 +12,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe prompts: {} as ClaudeSession['prompts'], dispatchWaiters: [], retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, dispatchSequence: 0, optionMutationSequence: 0, options: new Map(), diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index f932cae782d..4ff5249f34d 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -44,6 +44,8 @@ export function createClaudeSessionPublication(input: { acquisitionGeneration: input.acquisitionGeneration, prompts: input.prompts, dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, dispatchSequence: 0, optionMutationSequence: 0, options: new Map(input.options), @@ -52,6 +54,7 @@ export function createClaudeSessionPublication(input: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + restoreSkippedOptions: new Set(), translator: input.translator, events: input.events } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 67b55ffd6db..12d692d07e9 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -101,6 +101,8 @@ export type ClaudeSession = { dispatchWaiters: ClaudeDispatchWaiter[] /** Bounded identities for dispatches whose ack was unknown when they returned. */ retiredDispatchWaiters: ClaudeDispatchWaiter[] + /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ + replayContentFallbackBlocked: boolean options: Map reportedOptions: { model?: string; effort?: string } restoreSkippedOptions: Set