fix(claude): block replay fallback after waiter eviction

This commit is contained in:
Merge Sim
2026-09-02 21:04:27 -07:00
parent 9d1b6ba968
commit 201daee73b
5 changed files with 63 additions and 2 deletions
@@ -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(
@@ -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
@@ -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(),
@@ -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
}
@@ -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<string, string>
reportedOptions: { model?: string; effort?: string }
restoreSkippedOptions: Set<string>