Skip to content

fix(app): order session messages by creation time, not ID - #324

Merged
quickbeard merged 2 commits into
mainfrom
fix/message-order-wrap
Aug 25, 2026
Merged

quickbeard merged 2 commits into
mainfrom
fix/message-order-wrap

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

In any session started before August 14, 2026, sending a message put the new bubble at the top of the conversation instead of the bottom (screenshot in the report: a stack of "hi" bubbles above a turn from two weeks earlier).

Root cause: the ID timestamp wraps

Every ID (msg_…, ses_…, prt_…) encodes its creation time as 6 bytes of timestamp*4096 — but that value needs 53 bits in 2026, and the encoder keeps only the low 48 (packages/schema/src/identifier.ts, same in packages/opencode/src/id/id.ts). The sortable time prefix therefore wraps every 2^36 ms ≈ 26 months, most recently at 2026-08-14T11:19:55Z.

Verified directly against the affected database: re-encoding each message's stored time_created reproduces its ID prefix —

created stored ID prefix re-encoded
2026-08-13 06:33 msg_ff9d3630… ff9d362c…
2026-08-16 05:19 msg_00902e38… 00902e1f…
2026-08-25 01:42 msg_036951f3… 036951ca…

So every ID minted before the wrap (ff…) sorts lexically after every ID minted since (00…) — and the app kept each session's message array sorted by bare ID. In pre-wrap sessions, each new message inserted at index 0, and every "latest message" read (.at(-1) — sidebar agent color, context usage, followups) answered with the oldest message. Upstream is affected identically; their dev as of today still has the 6-byte encoder.

Fix

Message arrays now hold the order the server already pages in — (time.created, id) (message-v2.ts orders by time_created, id; the file even already had a cmpMessage comparator with exactly this order for pagination boundaries, while the arrays ignored it).

  • Fork-only src/utils/message-order.ts: compareMessages, a matching string key for Binary.search inserts (messageOrderKey — the test pins that key order and comparator order agree), and linear messageIndexByID for membership/removal lookups, which only have an ID in hand and cannot binary-search a time-ordered array.
  • server-session.ts, sync.tsx, global-sync/event-reducer.ts: fetch sorts, page merges, optimistic adds, and event inserts use the comparator; membership checks and removals go linear (bounded by one session's message count).
  • merge/reconcileFetched gained an optional comparator — sessions, parts, and permissions keep their ID order untouched. Parts stay ID-ordered deliberately: one message's parts are minted together, on one side of any wrap.

Deliberately not touched: the ID encoder itself. Widening it would diverge a wire-visible format from upstream, and it wouldn't help — pre-wrap IDs are already stored in every database, so ID order alone can never be trusted again. This ordering stays correct across the next wrap (2028-10-18) with the encoder as it is.

One test expectation updated (server-session.test.ts "uses message creation time for incomplete refresh boundaries"): it used IDs whose lexical order contradicts their times to prove the boundary logic followed time while the array followed IDs — the array now follows time too, which is the point.

Verified

  • Typecheck clean; app suites: 711 unit / 130 browser, all passing (includes new wrap-scenario tests in message-order.test.ts and event-reducer.test.ts).
  • Live, against the real broken session from the report: after the fix the old pwd turn renders first, the "hi" bubbles follow in order, and a newly sent message lands at the bottom — both the fetch path and the live event-insert path.

Known sibling

The TUI's src/context/sync.tsx inserts by bare ID and has the same bug in old sessions; left for a follow-up to keep this change reviewable.

🤖 Generated with Claude Code

@quickbeard

Copy link
Copy Markdown
Owner Author

Added: the deadness fix (ca1ac16) — same wrap, server side

Ordering the display fixed the bubbles but not the second symptom: pre-wrap sessions were dead — typing anything promoted a user message and then nothing happened, ever.

Root cause

MessageV2.latest() derived its user / assistant / finished bindings by max ID, on an assumption its own comment stated: "MessageID is monotonic via MessageID.ascending." The wrap broke that. In a pre-wrap session the old turn's IDs (ff9d…) beat every new message (03…), so:

  • lastUser resolved to the August 13 user message, not the "hi" just sent;
  • lastFinished resolved to the August 13 assistant, which is "greater";
  • the prompt loop's exit check — lastUser.id < lastAssistant.id, i.e. "the last question already has an answer" — was true before the first turn even ran, so the loop exited silently. Confirmed in the affected DB: since Aug 16, every prompt in that session produced a promoted user message and zero assistant messages.

Fix

latest() now derives its bindings via a new exported MessageV2.chronological(a, b) — time.created, then ID for same-millisecond ties (the counter in the ID keeps that tiebreak meaningful). The tasks filter compares the same way, and the loop's already-answered exit uses the comparator instead of raw ID comparison. Same principle as the client half of this PR: the ID's lexical order can never again be trusted across a wrap, creation time can.

Verified

  • New latest() tests using the affected DB's real ID shapes (pre-wrap turn + post-wrap user): bindings resolve chronologically, and the loop's continue/exit predicate flips to "unanswered".
  • bun typecheck clean; message-v2, prompt (57/57), compaction, messages-pagination suites pass.
  • Live, end-to-end: rebuilt the sidecar, reopened the dead session from the report, sent a prompt — assistant replied in 2s, bubble at the bottom. The session is alive again.

Also noted in DIVERGENCES.md: revert.ts / session.ts still compare revert/copy boundary markers by bare ID (they only have an ID in hand) — cosmetic-risk siblings, left alone with the TUI's insert-by-ID.

quickbeard and others added 2 commits August 25, 2026 13:54
The ID encoder keeps only 48 bits of timestamp*4096, so the sortable time
prefix wraps every 2^36 ms — most recently on 2026-08-14T11:19:55Z. Every ID
minted before a wrap sorts lexically after every ID minted since, and the
app kept each session's messages sorted by bare ID: in a session older than
the wrap, each new bubble rendered at the top of the conversation, and every
"latest message" read answered with the oldest.

Message arrays now hold the order the server already pages in —
(time.created, id) — via a shared comparator and a matching Binary.search
key (fork-only src/utils/message-order.ts). Membership and removal lookups,
which only have an ID in hand, scan linearly; an ID alone cannot be
binary-searched in a time-ordered array. Parts stay ID-ordered: one
message's parts are minted together, on one side of any wrap.

Verified against an affected database: the pre-wrap session renders its old
turn first again, and a newly sent message lands at the bottom. The next
wrap (2028-10-18) changes nothing. The TUI's sync store still inserts by
bare ID and shares the bug; left for a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ogical

Ordering the display fixed the bubbles but not the deadness: in a session
older than the 2026-08-14 ID wrap, typing anything promoted a user message
and then nothing happened. MessageV2.latest derived its user/assistant/
finished bindings by max ID — "MessageID is monotonic via
MessageID.ascending", said the comment — so the pre-wrap turn (ff9d…) beat
every new message (03…): the prompt loop saw a finished assistant "newer"
than the latest user message, concluded the question was already answered,
and exited without running a turn.

latest() now derives its bindings via an exported chronological(a, b) —
time.created, then ID for same-millisecond ties — the tasks filter compares
the same way, and the loop's already-answered exit uses the comparator
instead of lastUser.id < lastAssistant.id.

Verified live against the dead session from the report: after the fix, a
new prompt gets an assistant reply again (2s turn), landing at the bottom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard force-pushed the fix/message-order-wrap branch from ca1ac16 to d12bfde Compare August 25, 2026 06:54
@quickbeard
quickbeard merged commit 2017f56 into main Aug 25, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the fix/message-order-wrap branch August 25, 2026 06:59
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.

1 participant