diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 31924f44b41..270f34dbac3 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -342,6 +342,40 @@ describe('Claude structured dispatch image limits', () => { }) }) + // A dispatch that gave up still has a replay in flight. Once its waiter is + // gone, that frame matches no owner and would otherwise fall to the compat + // path and settle whoever is now at the head. + it('drops the replay of a dispatch that already timed out', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 60 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const firstUuid = session.dispatchWaiters[0]!.sentUuid + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + expect(session.dispatchWaiters).toHaveLength(0) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + ACK_BUDGET_MS + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + // The dead send's echo lands while the second is waiting. + resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid, 'one')) + expect(session.dispatchWaiters).toHaveLength(1) + + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'two')) + await expect(second).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: secondUuid } + }) + }) + // The waiter queue is positional, so shifting the head on a send failure // resolves whichever dispatch happens to be first - reporting a delivered // message as unconfirmed while the send that actually failed keeps waiting. diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 7788f2ce708..e63321af3b5 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -128,6 +128,12 @@ export function resolveClaudeReplayWaiter( settleWaiter(session, owner, uuid) return } + // A replay for a dispatch that already gave up belongs to nobody. Dropping it + // here is what keeps the compat path below from handing it to the head, which + // would upsert the dead send's text over a live one's bubble. + if (session.retiredSentUuids?.includes(uuid)) { + return + } // Compat path for a CLI that mints its own uuid instead of echoing ours. The // frame then carries no correlation at all, so the head is the only candidate // and the shape gates have to carry the weight. @@ -194,6 +200,10 @@ async function messageContent(body: AgentJournalMessageItem): Promise return content } +/** Enough to cover the replays still in flight for sends that gave up; the list + * only grows when a dispatch leaves without its echo. */ +const RETIRED_UUID_MEMORY = 64 + function dropWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { const index = session.dispatchWaiters.indexOf(waiter) if (index !== -1) { @@ -201,6 +211,15 @@ function dropWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void } } +/** Remember a dispatch that left without its echo, so the echo cannot later be + * mistaken for somebody else's on the compat path. */ +function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { + dropWaiter(session, waiter) + const retired = session.retiredSentUuids ?? [] + retired.push(waiter.sentUuid) + session.retiredSentUuids = retired.slice(-RETIRED_UUID_MEMORY) +} + /** Arms a waiter and hands it back, so a failure can retire ITS OWN waiter — the * queue is positional, and shifting the head resolves whichever send happens to * be first instead. */ @@ -217,7 +236,7 @@ function waitForReplay( sentUuid, resolve, timer: setTimeout(() => { - dropWaiter(session, waiter) + retireWaiter(session, waiter) resolve(null) }, timeoutMs) } @@ -302,7 +321,7 @@ export async function dispatchClaudeTurn( } } } - dropWaiter(session, waiter) + retireWaiter(session, waiter) clearTimeout(waiter.timer) waiter.resolve(null) return { state: 'unknown', reason: (error as Error).message } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index eb1686d7c4f..49b2113725d 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -72,6 +72,10 @@ export type ClaudeSession = { fence: number prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] + /** Uuids of dispatches that left the queue without their echo (timed out, or + * the send failed). Their replay can still arrive, and it must be dropped + * rather than fall through to the head - it belongs to nobody now. */ + retiredSentUuids?: string[] options: Map reportedOptions: { model?: string; effort?: string } translator: ClaudeJournalTranslator | null