Skip to content

Duplicate user bubble reappears when the browser clock is ahead of the server: echo reconciliation is gated on a 10s skew allowance #1195

Description

@coder8080

Summary

A user's own message sometimes renders twice — the second copy appearing after the agent's reply, not next to the first one. Reloading the page always fixes it.

This is not a regression of #794 (which added removeOptimisticUserEchoes). The reconciler is present and works; it is defeated whenever the browser's clock disagrees with the clock of the machine that writes the transcript.

Reproduced on 1.37.1; the responsible code is byte-identical in 1.37.2 (git diff v1.37.1..v1.37.2 -- src/stores/sessionMessageReconciliation.ts is empty).

Root cause

The chat pane renders one chronologically sorted list built from two different clocks:

  • persisted transcript rows (serverMessages) carry timestamps written by the machine running the provider CLI;
  • optimistic and streaming rows (realtimeMessages) carry new Date() from the browser.

removeOptimisticUserEchoes matches an optimistic row against its persisted copy by fingerprint (trimmed text + image count + file count), but only inside a time gate — src/stores/sessionMessageReconciliation.ts:67-73:

const serverTime = readMessageTime(serverMessage);
if (
  serverTime === null
  || serverTime < localTime - LOCAL_USER_DEDUPE_CLOCK_SKEW_MS   // 10_000
  || serverTime - localTime > dedupeWindow
) {
  continue;
}

The persisted row is allowed to be at most 10 s older than the optimistic one. If the browser clock runs more than 10 s ahead of the server, the persisted row falls outside the gate, no match is claimed, and the optimistic row survives as a second user bubble. Because that row is stamped in the browser's future, computeMerged's chronological sort places it after the agent's reply — which is exactly what the bubble looks like from the user's side.

A reload drops realtimeMessages entirely and rebuilds from the transcript, so the duplicate disappears — matching the "not reproducible after refresh" symptom.

Why "sometimes": the skew has to exceed both the 10 s allowance and the duration of the turn. On a host without working time sync the offset free-runs, so the same deployment reproduces intermittently and then reliably as drift accumulates. On the host where this was found, timedatectl reported System clock synchronized: no with NTP inactive.

Reproduction

  1. Set the browser machine's clock ~1 minute ahead of the host running CloudCLI (or run the server on a host with no time sync and let it drift).
  2. Send any message in a Claude/Codex chat.
  3. The agent replies; the user's message then appears a second time below the reply.
  4. Reload → single bubble.

Same effect with a driven reconciler, no browser needed:

// browser 45s ahead; persisted copy written 800ms after submit on server time
removeOptimisticUserEchoes([persistedUserTurn], [browserStampedEcho])
// → [browserStampedEcho]  (unmatched; renders as the duplicate)

Fix direction

Widening LOCAL_USER_DEDUPE_CLOCK_SKEW_MS would only paper over it — the two timelines still disagree, and rows would still sort into the wrong place. The root fix is to stop putting two clocks in one sorted list: measure the browser↔server offset from a response header and stamp every client-created chat row on the server's timeline.

Note for anyone implementing this: measuring off the HTTP Date header alone is not sufficient. nginx hides upstream Date/Server by default (proxy_hide_header) and substitutes its own, and the reverse-proxy template shipped in docs/nginx-subpath-template.conf has no proxy_pass_header Date. Behind a proxy whose clock is correct while the app host drifts — precisely the failing configuration — the measurement reads the proxy's clock and the fix silently no-ops. An app-set header (e.g. X-Server-Time) is what carries the clock that actually writes the transcripts.

PR follows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions