Skip to content

Conversation

@ammar-agent
Copy link
Collaborator

@ammar-agent ammar-agent commented Oct 19, 2025

Problem

Sometimes after compaction with a continue message (/compact -c "message"), the continue message is sent multiple times in a row, suggesting a race condition.

Root Cause

The useAutoCompactContinue hook calls checkAutoCompact() from two places:

  1. Directly in the effect (initial check)
  2. In the store subscription callback (on state changes)

When the store updates rapidly after compaction completes, both can execute concurrently:

// Run #1 (effect)
if (!firedForWorkspace.has(id)) {  // ✓ not set
  // ... extract continueMessage ...
  firedForWorkspace.add(id);        // Set guard
  sendMessage(continueMessage);     // Send
}

// Run #2 (subscription, overlaps with Run #1)
if (!firedForWorkspace.has(id)) {  // ✓ not set yet (checked before Run #1 set it)
  // ... extract continueMessage ...
  firedForWorkspace.add(id);        // Set guard (too late!)
  sendMessage(continueMessage);     // DUPLICATE SEND
}

Both runs pass the initial guard check because Run #2 checks before Run #1 sets the guard.

Solution

Double-check the guard immediately before setting it:

if (!firedForWorkspace.has(id)) continue;  // Initial check
// ... extract continueMessage ...
if (!firedForWorkspace.has(id)) continue;  // Double-check (catches race)
firedForWorkspace.add(id);
sendMessage(continueMessage);

Since JavaScript is single-threaded, once Run #1 sets the guard, Run #2's double-check will see it and abort.

Changes

  • Added double-check before setting guard in checkAutoCompact()
  • Flattened control flow for clarity
  • Added logging to track when continue messages are sent

Testing

The fix relies on JavaScript's single-threaded execution. The double-check ensures that even if two calls start concurrently, only one can proceed past the final guard check.

Manual testing should confirm no more duplicates, but the fix is sound by construction.


Generated with cmux

The issue: checkAutoCompact() can run concurrently (effect + store subscription),
and both runs could pass the initial guard check before either sets the guard.

Flow:
- User runs /compact -c "continue..."
- Backend completes compaction, sends events
- Store updates → triggers subscription callback
- Effect also triggers
- Both call checkAutoCompact() nearly simultaneously

Race condition:
1. Run #1: checks guard (not set) → proceeds
2. Run #2: checks guard (not set yet) → proceeds
3. Run #1: sets guard → sends message
4. Run #2: sets guard (redundant) → sends message (DUPLICATE!)

Fix: Double-check guard immediately before setting it. Since JS is single-threaded,
whichever run reaches the guard check first will set it, and the other will see it
set and skip.

Added logging to track when continue messages are sent for debugging.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@ammar-agent
Copy link
Collaborator Author

Why Both Checks Are Needed

This hook calls checkAutoCompact() from two places:

  1. Initial call - Catches state at mount (e.g., app restart with compacted history)
  2. Subscription - Reacts to compaction completing during normal operation

Both are necessary. Removing either would break functionality.

Why the Double-Check

The race occurs because the store doesn't batch updates - each states.bump() immediately notifies all subscribers. When replaceChatHistory completes, the backend sends two IPC events (delete + new message), each triggering bump(), each calling our subscription callback.

The double-check (line 70) ensures only one of the overlapping calls proceeds. It's defensive coding, but it's the pragmatic fix.

Alternative: Batch Store Updates

The root cause is architectural - MapStore.bump() could batch notifications in a microtask to collapse rapid updates. This would eliminate the race at the source and benefit all store consumers.

However, that's a bigger change affecting core store behavior. The double-check is simpler and sufficient for now. We can revisit store batching if we see similar issues elsewhere.

@ammario ammario merged commit 975c18c into main Oct 19, 2025
8 checks passed
@ammario ammario deleted the compact-bug branch October 19, 2025 17:56
github-merge-queue bot pushed a commit that referenced this pull request Oct 19, 2025
## Problem

After running `/compact -c "continue message"`, the continue message is
sometimes sent **multiple times in a row** instead of just once.

## Root Cause

The previous workspace-level guard had a race condition:

1. **Backend sends two events** for `replaceChatHistory`: delete event,
then new message event
2. **Each event causes immediate synchronous `bump()`** which notifies
all subscribers
3. **Multiple `checkAutoCompact()` calls can be in-flight
simultaneously**:
- Delete event → `bump()` → subscriber fires → `checkAutoCompact()`
starts
- New message event → `bump()` → subscriber fires → `checkAutoCompact()`
starts
- Both calls check workspace guard **before either sets it** → both
proceed to send

The double-check guard in PR #334 helped but didn't fully solve it
because the checks and sets are separate operations.

**The real issue: Workspace-level tracking is the wrong granularity.**
We need to prevent processing the same compaction MESSAGE multiple
times, not the same workspace multiple times. A new compaction creates a
new message with a new ID.

## Solution

Track processed **message IDs** instead of workspace IDs:

```typescript
// Track which specific compaction summary messages we've already processed
const processedMessageIds = useRef<Set<string>>(new Set());

// In checkAutoCompact:
const messageId = summaryMessage.id;

// Have we already processed this specific compaction message?
if (processedMessageIds.current.has(messageId)) continue;

// Mark THIS MESSAGE as processed
processedMessageIds.current.add(messageId);
```

## Why This Is Obviously Correct

1. **Message IDs are unique and immutable** - Once a message exists, its
ID never changes
2. **We only care about processing each message once** - Not about
processing each workspace once
3. **The guard is set BEFORE the async operation** - No timing window
4. **Multiple calls can overlap** - But they all see the same message
ID, so only the first one proceeds
5. **Cleanup is natural** - IDs accumulate bounded (one per compaction)
and don't need explicit cleanup

The correctness is self-evident: "Have we sent a continue message for
THIS compaction result message? Yes/No."

## How It Fixes The Race

**Before (workspace-level):**
- Call #1 checks `firedForWorkspace.has(workspaceId)` → false
- Call #2 checks `firedForWorkspace.has(workspaceId)` → false (still!)
- Call #1 sets guard and sends
- Call #2 double-checks... but timing window existed

**After (message-level):**
- Call #1 checks `processedMessageIds.has(messageId)` → false
- Call #2 checks `processedMessageIds.has(messageId)` → false (same
message)
- Call #1 adds messageId to set
- Call #2 sees messageId already in set → skips

Both calls are checking the **same unique identifier** (the message ID),
so the guard works correctly even with concurrent execution.

## Testing

Manual testing needed:
1. Run `/compact -c "continue message"` multiple times
2. Verify only ONE continue message is sent per compaction
3. Check console logs for single "Sending continue message" per
compaction
4. Verify backend receives only one user message (not duplicates)

_Generated with `cmux`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants