Skip to content

fix(providers): stop conversation-sync re-send loops - #620

Merged
uladzislausvetlakou merged 8 commits into
codemie-ai:mainfrom
bohdanmaliar:fix/conversation-sync-loop
Oct 9, 2026
Merged

uladzislausvetlakou merged 8 commits into
codemie-ai:mainfrom
bohdanmaliar:fix/conversation-sync-loop

Conversation

@bohdanmaliar

@bohdanmaliar bohdanmaliar commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Since v0.15.2, applyPayloadOutcome in the conversation sync step marked
only the first queued record for a given payload id as sent.

Duplicates came from two sources:

  • the sync step rewinding lastSyncedMessageUuid to the latest successful
    payload, re-queuing already-sent turns under new history indices;
  • concurrent Stop/SubagentStop hooks appending the same turn with no
    already-queued check.

This PR removes both causes on the client and bounds per-run work.

Changes

  • Outcome marking: every not-yet-successful record sharing a sent
    payload id is now resolved together (success/failed), restoring
    pre-regression "mark all by id" semantics while keeping per-payload
    persistence and deadline deferral.
  • Healing on read: a pending/retryable-failed record whose payload id
    already has a success record is marked success without being
    re-sent. Queues left behind by older CLI versions self-heal on first sync
    after upgrade.
  • No pointer rewind: syncProcessor no longer writes
    conversations.lastSyncedMessageUuid; each transform processor remains
    the sole writer of its own pointer, so already-synced turns are never
    re-queued.
  • Claude dedupe-before-append: ConversationsProcessor.processMessages
    reads the existing queue once before appending and skips any result whose
    payload id is already queued (any status), mirroring the Codex processor.
    Its checkpoint still advances.
  • Record-level collapse: among send candidates, an older record is
    marked superseded (not sent, not counted as a failure) when every
    (conversationId, history_index, role) key it carries is also covered by
    a newer record. Partial coverage is sent in full.
  • Per-run cap: MAX_CONVERSATION_PAYLOADS_PER_RUN = 50 bounds API sends
    per sync run; remaining candidates roll over to the next run.

Read pipeline order: heal → collapse → cap → send.

New superseded status added to ConversationPayloadRecord['status'].

Acceptance criteria covered

  • AC1–AC8 per docs/stories/2026-10-09-conversation-sync-loop-fix/story.md
    (duplicate dedupe, healing, no pointer rewind, no re-queue after partial
    failure, concurrent-hook dedupe, 50/run cap, collapse of superseded
    records, no regression for normal sessions).

Out of scope

  • SSO expiry/redirect handling during sync (separate story).
  • The cross-process sync-lock timeout and the hook-append/writeJSON race.
  • Server-side throttling of unchanged uploads.
  • Repairing duplicate turns already stored server-side.
  • Running sessions keep looping until restarted — only the next process
    start picks up the fix.

Test plan

  • Extended syncProcessor-dedupe.test.ts covering duplicate ids with mixed
    success/pending, healing on read, collapse (including Assistant-only
    continuation and partial coverage), the 50-record cap, and absence of
    lastSyncedMessageUuid from syncUpdates.
  • Extended claude.conversations-processor-dedupe.test.ts covering
    skip-already-queued-payload-id with checkpoint advancement.

Bohdan Maliar and others added 8 commits October 9, 2026 12:52
applyPayloadOutcome now updates all pending/failed records with the sent
payloadId, and the send loop sends each id at most once per run, so a
duplicate of an already-synced turn is no longer re-sent on every tick.

Refs: EPMCDME-15771 (conversation-sync-loop-fix, Task 1)

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…n payloads

Before choosing what to send, pending or retryable failed records whose
payloadId already has a success record are marked success without being
sent, and the healed queue is persisted even when the run defers.

Refs: EPMCDME-15771 (conversation-sync-loop-fix, Task 2)

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…geUuid

The sync step no longer reports lastSyncedMessageUuid; each transform
processor stays the sole writer of its pointer. lastSyncedHistoryIndex is
the max index among successful payloads and conversationId comes from the
last successfully sent payload. parseSourceIndex is removed.

Refs: EPMCDME-15771 (conversation-sync-loop-fix, Task 3)

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…sync

The Claude conversations processor reads the conversation queue once before
its drain loop and does not append a turn whose payloadId is already queued
in any status. The sync pointer and per-iteration checkpoint still advance,
and a skip-only run still returns its sync update.

Refs: EPMCDME-15771 (conversation-sync-loop-fix, Task 4)

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…wer record

Adds a terminal 'superseded' status. Before sending, an older candidate whose
every (conversationId, history_index, role) entry is carried by a newer
candidate is marked superseded, persisted and never sent. Records with an
empty history or entries lacking history_index/role (codex sentinels) are
left untouched, and partly covered records are sent whole.

Refs: EPMCDME-15771 (conversation-sync-loop-fix, Task 5)

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Adds MAX_CONVERSATION_PAYLOADS_PER_RUN = 50. After healing and collapse,
candidates are sent oldest first up to the cap; the rest stay queued for the
next run. Healed and superseded records do not count toward the cap.

Refs: EPMCDME-15771 (conversation-sync-loop-fix, Task 6)

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…dupe

- skip the per-run send cap on deadline-bounded SessionEnd runs so no payloads are stranded
- extract queue preparation into prepareSendList and readQueuedPayloadIds
- rename payloadQueue.ts to payload-queue.ts (kebab-case)
- cover an already-queued turn followed by a new turn in one drain

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
@bohdanmaliar
bohdanmaliar force-pushed the fix/conversation-sync-loop branch from 68dfd67 to 35055ba Compare October 9, 2026 11:46
@uladzislausvetlakou
uladzislausvetlakou merged commit 3e5e1b9 into codemie-ai:main Oct 9, 2026
5 checks passed
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