Skip to content

Commit 2b73afa

Browse files
authored
🤖 Harden auto-continue idempotency: requestId guard + backend dedupe (#344)
## Why another fix? Message-ID guard reduced duplicates but users still observed rare double-sends. Root cause in those cases: two distinct summary messages were created for one compaction-run (different IDs) due to duplicated completion signals (delete + message + final bump, reconnect/replay, or abort/accept flows). ## What changed - Add requestId to compaction-result metadata (source compaction-request user msg) - WorkspaceStore: dedupe performCompaction by compaction-request id - useAutoCompactContinue: guard on requestId when present, fallback to message id - Fix retry cleanup to remove the correct guard key ## Why this is obviously correct - A compaction-run is uniquely identified by the compaction-request user message ID - We now: - Perform compaction only once per requestId (store-level dedupe) - Send continue only once per requestId (hook-level idempotency) - If requestId is missing (unexpected), fallback to summary message id (still safe) ## Testing notes - Run repeatedly, interrupt/retry, and force reconnection - Expect exactly one continue send per compaction-run _Generated with _
1 parent f59e17c commit 2b73afa

File tree

3 files changed

+32
-16
lines changed

3 files changed

+32
-16
lines changed

‎src/hooks/useAutoCompactContinue.ts‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useRef, useEffect } from "react";
2-
import { useWorkspaceStoreRaw, type WorkspaceState } from "@/stores/WorkspaceStore";
2+
import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore";
33
import { buildSendMessageOptions } from "@/hooks/useSendMessageOptions";
44

55
/**
@@ -28,8 +28,6 @@ export function useAutoCompactContinue() {
2828
// re-rendering AppInner on every workspace state change. This hook only needs
2929
// to react when messages change to a single compacted message state.
3030
const store = useWorkspaceStoreRaw();
31-
const workspaceStatesRef = useRef<Map<string, WorkspaceState>>(new Map());
32-
3331
// Track which specific compaction summary messages we've already processed.
3432
// Key insight: Each compaction creates a unique message. Track by message ID,
3533
// not workspace ID, to prevent processing the same compaction result multiple times.
@@ -39,7 +37,6 @@ export function useAutoCompactContinue() {
3937
// Update ref and check for auto-continue condition
4038
const checkAutoCompact = () => {
4139
const newStates = store.getAllStates();
42-
workspaceStatesRef.current = newStates;
4340

4441
// Check all workspaces for completed compaction
4542
for (const [workspaceId, state] of newStates) {
@@ -59,22 +56,23 @@ export function useAutoCompactContinue() {
5956
// After compaction, history is replaced with a single summary message
6057
// The summary message has compaction-result metadata with the continueMessage
6158
const summaryMessage = state.cmuxMessages[0]; // Single compacted message
62-
const messageId = summaryMessage.id;
63-
64-
// Have we already processed this specific compaction message?
65-
// This check is race-safe because message IDs are unique and immutable.
66-
if (processedMessageIds.current.has(messageId)) continue;
67-
6859
const cmuxMeta = summaryMessage?.metadata?.cmuxMetadata;
6960
const continueMessage =
7061
cmuxMeta?.type === "compaction-result" ? cmuxMeta.continueMessage : undefined;
7162

7263
if (!continueMessage) continue;
7364

74-
// Mark THIS MESSAGE as processed before sending
75-
// Multiple concurrent checkAutoCompact() calls will all see the same message ID,
76-
// so only the first call that reaches this point will proceed
77-
processedMessageIds.current.add(messageId);
65+
// Prefer compaction-request ID for idempotency; fall back to summary message ID
66+
const idForGuard =
67+
cmuxMeta?.type === "compaction-result" && cmuxMeta.requestId
68+
? `req:${cmuxMeta.requestId}`
69+
: `msg:${summaryMessage.id}`;
70+
71+
// Have we already processed this specific compaction result?
72+
if (processedMessageIds.current.has(idForGuard)) continue;
73+
74+
// Mark THIS RESULT as processed before sending to prevent duplicates
75+
processedMessageIds.current.add(idForGuard);
7876

7977
console.log(
8078
`[useAutoCompactContinue] Sending continue message for ${workspaceId}:`,
@@ -86,7 +84,7 @@ export function useAutoCompactContinue() {
8684
window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => {
8785
console.error("Failed to send continue message:", error);
8886
// If sending failed, remove from processed set to allow retry
89-
processedMessageIds.current.delete(messageId);
87+
processedMessageIds.current.delete(idForGuard);
9088
});
9189
}
9290
};

‎src/stores/WorkspaceStore.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,9 @@ export class WorkspaceStore {
417417
* Handle compact_summary tool completion.
418418
* Returns true if compaction was handled (caller should early return).
419419
*/
420+
// Track processed compaction-request IDs to dedupe performCompaction across duplicated events
421+
private processedCompactionRequestIds = new Set<string>();
422+
420423
private handleCompactionCompletion(
421424
workspaceId: string,
422425
aggregator: StreamingMessageAggregator,
@@ -430,13 +433,27 @@ export class WorkspaceStore {
430433
return false;
431434
}
432435

436+
// Extract the compaction-request message to identify this compaction run
437+
const compactionRequestMsg = findCompactionRequestMessage(aggregator);
438+
if (!compactionRequestMsg) {
439+
return false;
440+
}
441+
442+
// Dedupe: If we've already processed this compaction-request, skip re-running
443+
if (this.processedCompactionRequestIds.has(compactionRequestMsg.id)) {
444+
return true; // Already handled compaction for this request
445+
}
446+
433447
// Extract the summary text from the assistant's response
434448
const summary = aggregator.getCompactionSummary(data.messageId);
435449
if (!summary) {
436450
console.warn("[WorkspaceStore] Compaction completed but no summary text found");
437451
return false;
438452
}
439453

454+
// Mark this compaction-request as processed before performing compaction
455+
this.processedCompactionRequestIds.add(compactionRequestMsg.id);
456+
440457
this.performCompaction(workspaceId, aggregator, data, summary);
441458
return true;
442459
}
@@ -544,7 +561,7 @@ export class WorkspaceStore {
544561
: undefined,
545562
// Store continueMessage in summary so it survives history replacement
546563
cmuxMetadata: continueMessage
547-
? { type: "compaction-result", continueMessage }
564+
? { type: "compaction-result", continueMessage, requestId: compactRequestMsg?.id }
548565
: { type: "normal" },
549566
}
550567
);

‎src/types/message.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type CmuxFrontendMetadata =
2121
| {
2222
type: "compaction-result";
2323
continueMessage: string; // Message to send after compaction completes
24+
requestId?: string; // ID of the compaction-request user message that produced this summary (for idempotency)
2425
}
2526
| {
2627
type: "normal"; // Regular messages

0 commit comments

Comments
 (0)