From ffa58b0cbe04a783eb0baf6b6a4775e67d155efa Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 19 Oct 2025 13:55:49 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20Fix=20auto-compact-continue=20ra?= =?UTF-8?q?ce=20by=20tracking=20message=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous workspace-level guard had a race condition because replaceChatHistory sends two events (delete, then new message), each triggering immediate subscription callbacks. Multiple checkAutoCompact() calls could be in-flight simultaneously, both checking the guard before either set it. Root cause: Workspace-level tracking is the wrong granularity. We need to prevent processing the same compaction MESSAGE multiple times, not the same workspace multiple times. Fix: Track processed message IDs instead of workspace IDs. - Message IDs are unique and immutable - Multiple concurrent calls see the same message ID - Only first call proceeds, others skip - Correctness is obvious from implementation Why this works: - Delete event arrives → bump() → checkAutoCompact() starts - New message event arrives → bump() → checkAutoCompact() starts - Both calls extract the same summaryMessage.id - Both check processedMessageIds.has(messageId) - Only first call proceeds past the check and adds the ID - Second call sees ID already in set, skips The key insight: workspace can be compacted multiple times (different messages), but each compaction message is unique. Track what we've processed, not where. Generated with cmux --- src/hooks/useAutoCompactContinue.ts | 37 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/hooks/useAutoCompactContinue.ts b/src/hooks/useAutoCompactContinue.ts index f93581536..6a857d130 100644 --- a/src/hooks/useAutoCompactContinue.ts +++ b/src/hooks/useAutoCompactContinue.ts @@ -30,9 +30,11 @@ export function useAutoCompactContinue() { const store = useWorkspaceStoreRaw(); const workspaceStatesRef = useRef>(new Map()); - // Prevent duplicate auto-sends if effect runs more than once while the same - // compacted summary is visible (e.g., rapid state updates after replaceHistory) - const firedForWorkspace = useRef>(new Set()); + // Track which specific compaction summary messages we've already processed. + // Key insight: Each compaction creates a unique message. Track by message ID, + // not workspace ID, to prevent processing the same compaction result multiple times. + // This is obviously correct because message IDs are immutable and unique. + const processedMessageIds = useRef>(new Set()); // Update ref and check for auto-continue condition const checkAutoCompact = () => { @@ -41,35 +43,38 @@ export function useAutoCompactContinue() { // Check all workspaces for completed compaction for (const [workspaceId, state] of newStates) { - // Reset guard when compaction is no longer in the single-compacted-message state + // Detect if workspace is in "single compacted message" state const isSingleCompacted = state.messages.length === 1 && state.messages[0].type === "assistant" && state.messages[0].isCompacted === true; if (!isSingleCompacted) { - // Allow future auto-continue for this workspace when next compaction completes - firedForWorkspace.current.delete(workspaceId); + // Workspace no longer in compacted state - no action needed + // Processed message IDs will naturally accumulate but stay bounded + // (one per compaction), and get cleared when user sends new messages continue; } - // Only proceed once per compaction completion - if (firedForWorkspace.current.has(workspaceId)) continue; - // After compaction, history is replaced with a single summary message // The summary message has compaction-result metadata with the continueMessage const summaryMessage = state.cmuxMessages[0]; // Single compacted message + const messageId = summaryMessage.id; + + // Have we already processed this specific compaction message? + // This check is race-safe because message IDs are unique and immutable. + if (processedMessageIds.current.has(messageId)) continue; + const cmuxMeta = summaryMessage?.metadata?.cmuxMetadata; const continueMessage = cmuxMeta?.type === "compaction-result" ? cmuxMeta.continueMessage : undefined; if (!continueMessage) continue; - // Mark as fired BEFORE any async operations to prevent race conditions - // This MUST come immediately after checking continueMessage to ensure - // only one of multiple concurrent checkAutoCompact() runs can proceed - if (firedForWorkspace.current.has(workspaceId)) continue; // Double-check - firedForWorkspace.current.add(workspaceId); + // Mark THIS MESSAGE as processed before sending + // Multiple concurrent checkAutoCompact() calls will all see the same message ID, + // so only the first call that reaches this point will proceed + processedMessageIds.current.add(messageId); console.log( `[useAutoCompactContinue] Sending continue message for ${workspaceId}:`, @@ -80,8 +85,8 @@ export function useAutoCompactContinue() { const options = buildSendMessageOptions(workspaceId); window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => { console.error("Failed to send continue message:", error); - // If sending failed, allow another attempt on next render by clearing the guard - firedForWorkspace.current.delete(workspaceId); + // If sending failed, remove from processed set to allow retry + processedMessageIds.current.delete(messageId); }); } };