Description
Following the identifier rollover work that landed on dev: isAfter() in MessageV2.latest(), the parentID completion gate, mtime truncation retention, positional revert cleanup.
We run a fork and hit the same wrap in production on 2026-08-14. Identifier.create() keeps only the low 36 bits of Date.now(), so ascending ids wrap every 2^36 ms, about 795 days, and 7,228 of our 7,235 sessions stopped producing assistant messages. We implemented a fix independently and arrived at the same core design. Auditing ours turned up two gaps still present here, plus two smaller observations.
1. An interrupted revert cleanup can resurrect reverted messages.
SessionRevert.cleanup deletes from the revert boundary onward, boundary first, each removal its own durable transaction. If the loop is interrupted partway the boundary is already gone. The next cleanup calls findIndex for a message that no longer exists, gets -1, deletes nothing, and clearRevert() still runs and discards the only marker that could have located the rest.
Those messages are now permanent transcript and go to the model on the next turn. From the user's side, content they explicitly reverted comes back with no error.
Deleting newest-first makes the boundary a progress marker, so an interrupted cleanup leaves a state the next one completes:
- for (const msg of remove) {
+ for (const msg of remove.toReversed()) {
The part loop below it has the same shape and the same fix.
2. Client and storage can disagree on message order.
Storage pages with ORDER BY time_created, id, which is SQLite BINARY collation. packages/tui/src/context/sync.tsx:55 and packages/web/src/components/Share.tsx:79 tie-break the same data with localeCompare, which is locale collation. For two messages created in the same millisecond these can order oppositely. Ids minted in the same millisecond by different processes share the packed prefix, so the mixed case random suffix decides, precisely where the collations diverge.
localeCompare also returns 0 for canonically equivalent distinct strings. MessageID only requires a msg prefix and is not restricted to ASCII, so two distinct primary keys can compare equal and the sort becomes input order dependent.
- a.time.created - b.time.created || a.id.localeCompare(b.id)
+ a.time.created - b.time.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
Two smaller things, not in the PR.
packages/app still orders by raw id. The server and TUI were converted, the App store was not: context/global-sync/bootstrap.ts:192, context/server-sync.tsx:429, pages/layout/helpers.ts:15, pages/session.tsx:841. Sessions there will order incorrectly across a wrap. We fixed our copy, but it touches about 24 sites and wants a shared comparator helper, so it seemed better raised than dropped on you unrequested.
The completion gate has no chronological fallback. parentID === lastUser.id is immune to both wrap and clock movement, which is why we adopted it too. But where the latest assistant's parentID points at an earlier user than the chronologically latest one, the equality never holds. We found 31 such sessions in a 251k message database, each with a chronologically later synthetic user carrying a legacy short format id while the latest assistant is parented to the ordinary user before it. Reading your gate against that shape it would not exit. Ours falls back to a chronological comparison. Flagging in case those histories exist elsewhere; if parentID only is deliberate, ignore this.
Next wrap is 2028-10-17. Widening the identifier is worth doing before then but cannot repair existing data, since the 48 bit space is exhausted and no encoding reorders ids already stored. That is why fixing the call sites was the right call.
OpenCode version
Fork of v1.17.11; defects verified against dev @ 4643e65ad6 (v1.18.18).
Operating System
Windows 11
Description
Following the identifier rollover work that landed on
dev:isAfter()inMessageV2.latest(), theparentIDcompletion gate, mtime truncation retention, positional revert cleanup.We run a fork and hit the same wrap in production on 2026-08-14.
Identifier.create()keeps only the low 36 bits ofDate.now(), so ascending ids wrap every 2^36 ms, about 795 days, and 7,228 of our 7,235 sessions stopped producing assistant messages. We implemented a fix independently and arrived at the same core design. Auditing ours turned up two gaps still present here, plus two smaller observations.1. An interrupted revert cleanup can resurrect reverted messages.
SessionRevert.cleanupdeletes from the revert boundary onward, boundary first, each removal its own durable transaction. If the loop is interrupted partway the boundary is already gone. The next cleanup callsfindIndexfor a message that no longer exists, gets-1, deletes nothing, andclearRevert()still runs and discards the only marker that could have located the rest.Those messages are now permanent transcript and go to the model on the next turn. From the user's side, content they explicitly reverted comes back with no error.
Deleting newest-first makes the boundary a progress marker, so an interrupted cleanup leaves a state the next one completes:
The part loop below it has the same shape and the same fix.
2. Client and storage can disagree on message order.
Storage pages with
ORDER BY time_created, id, which is SQLite BINARY collation.packages/tui/src/context/sync.tsx:55andpackages/web/src/components/Share.tsx:79tie-break the same data withlocaleCompare, which is locale collation. For two messages created in the same millisecond these can order oppositely. Ids minted in the same millisecond by different processes share the packed prefix, so the mixed case random suffix decides, precisely where the collations diverge.localeComparealso returns 0 for canonically equivalent distinct strings.MessageIDonly requires amsgprefix and is not restricted to ASCII, so two distinct primary keys can compare equal and the sort becomes input order dependent.Two smaller things, not in the PR.
packages/appstill orders by raw id. The server and TUI were converted, the App store was not:context/global-sync/bootstrap.ts:192,context/server-sync.tsx:429,pages/layout/helpers.ts:15,pages/session.tsx:841. Sessions there will order incorrectly across a wrap. We fixed our copy, but it touches about 24 sites and wants a shared comparator helper, so it seemed better raised than dropped on you unrequested.The completion gate has no chronological fallback.
parentID === lastUser.idis immune to both wrap and clock movement, which is why we adopted it too. But where the latest assistant'sparentIDpoints at an earlier user than the chronologically latest one, the equality never holds. We found 31 such sessions in a 251k message database, each with a chronologically later synthetic user carrying a legacy short format id while the latest assistant is parented to the ordinary user before it. Reading your gate against that shape it would not exit. Ours falls back to a chronological comparison. Flagging in case those histories exist elsewhere; ifparentIDonly is deliberate, ignore this.Next wrap is 2028-10-17. Widening the identifier is worth doing before then but cannot repair existing data, since the 48 bit space is exhausted and no encoding reorders ids already stored. That is why fixing the call sites was the right call.
OpenCode version
Fork of v1.17.11; defects verified against
dev@4643e65ad6(v1.18.18).Operating System
Windows 11