mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(claude): block replay fallback after waiter eviction
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user