Repo: sst/opencode
Version: 1.18.9 (algorithm unchanged on dev branch packages/opencode/src/id/id.ts as of 2026-07-30)
Summary
Identifier encodes Date.now() * 0x1000 + counter into a fixed 6-byte (12 hex char) field, reading bits 40..0. Since ms * 4096 currently needs ~53 bits, the top ~5 bits of the clock are silently truncated. The encoded time field is therefore only 36 bits of milliseconds, which wraps every 2^36 ms ≈ 2.178 years.
The current era began 2024-06-10T02:35:18.400Z. The next wrap is 2026-08-14T11:19:55.136Z UTC. At that instant, every running server's next generated ID drops from msg_fff… to msg_000000000001… — no restart required. There is no monotonic floor: the lastTimestamp/counter state only disambiguates IDs within the same millisecond and does not prevent new IDs from sorting below all previously issued ones.
Verification
Empirical, from a production sqlite DB — the first 9 hex chars of message IDs equal Date.now() mod 2^36 at creation:
| message ID |
time.created (ms) |
low 36 bits (hex) |
msg_dd7a9f577001… |
1777439732992 |
dd7a9f577 ✔ |
msg_fb0a52512001… |
1785375040786 |
fb0a52512 ✔ |
Wrap math: floor(1785375040786 / 2^36) = 25 → era start 25 · 2^36 = 1,717,986,918,400 (2024-06-10T02:35:18.400Z) → wrap at 26 · 2^36 = 1,786,706,395,136 = 2026-08-14T11:19:55.136Z.
User-visible impact at the wrap
- Every session with pre-wrap history enters an infinite duplicate-response loop. The turn-loop exit in
SessionPrompt.run requires latestUser.id < latestAssistant.id (lexical). After the wrap, the pre-wrap user message is permanently the max-ID "latest user", the condition is permanently false, and the loop re-invokes the provider after every finish: "stop" — the model restates its final answer every ~15–25s until manually aborted. (We hit this exact failure mode early, in production, via a plugin that supplied random high-sorting messageIDs to promptAsync — chats repeated their answers 3–9 times per turn until aborted. The wrap generalizes it to every session with history, with token cost implications.)
- Transcripts scramble at the seam. Messages/parts are loaded
ORDER BY id; post-wrap messages of an ongoing conversation sort before pre-wrap ones, so the context assembled for the model (and the UI transcript) interleaves incorrectly.
- New sessions list as oldest. Session IDs use the descending variant (
~value), which jumps upward at the wrap.
Identifier.timestamp(id) returns encoded / 4096 = ms mod 2^36, so age computations invert across the seam.
Suggested fix directions
- Add a persisted monotonic floor: never issue an ID ≤ the last issued (initialize from max existing ID at boot), or
- Widen the time field / re-base on a fixed epoch with explicit wrap handling, and
- Make ordering-sensitive comparisons (
latestUser.id < latestAssistant.id, ORDER BY id) robust to ID-scheme evolution (e.g., compare by stored creation time with ID as tiebreaker).
A migration story for existing DBs (which contain ~2 years of era-25 IDs) will be needed for any scheme change — the loop-exit and ORDER BY comparisons mix old and new IDs within the same session.
Happy to provide the DB evidence or test cases. Found while root-causing the duplicate-response loop described above; timeline is unfortunately fixed by the clock — ~15 days from this report.
Repo: sst/opencode
Version: 1.18.9 (algorithm unchanged on
devbranchpackages/opencode/src/id/id.tsas of 2026-07-30)Summary
IdentifierencodesDate.now() * 0x1000 + counterinto a fixed 6-byte (12 hex char) field, reading bits 40..0. Sincems * 4096currently needs ~53 bits, the top ~5 bits of the clock are silently truncated. The encoded time field is therefore only 36 bits of milliseconds, which wraps every2^36 ms ≈ 2.178 years.The current era began 2024-06-10T02:35:18.400Z. The next wrap is 2026-08-14T11:19:55.136Z UTC. At that instant, every running server's next generated ID drops from
msg_fff…tomsg_000000000001…— no restart required. There is no monotonic floor: thelastTimestamp/counterstate only disambiguates IDs within the same millisecond and does not prevent new IDs from sorting below all previously issued ones.Verification
Empirical, from a production sqlite DB — the first 9 hex chars of message IDs equal
Date.now() mod 2^36at creation:msg_dd7a9f577001…dd7a9f577✔msg_fb0a52512001…fb0a52512✔Wrap math:
floor(1785375040786 / 2^36) = 25→ era start25 · 2^36 = 1,717,986,918,400(2024-06-10T02:35:18.400Z) → wrap at26 · 2^36 = 1,786,706,395,136= 2026-08-14T11:19:55.136Z.User-visible impact at the wrap
SessionPrompt.runrequireslatestUser.id < latestAssistant.id(lexical). After the wrap, the pre-wrap user message is permanently the max-ID "latest user", the condition is permanently false, and the loop re-invokes the provider after everyfinish: "stop"— the model restates its final answer every ~15–25s until manually aborted. (We hit this exact failure mode early, in production, via a plugin that supplied random high-sortingmessageIDs topromptAsync— chats repeated their answers 3–9 times per turn until aborted. The wrap generalizes it to every session with history, with token cost implications.)ORDER BY id; post-wrap messages of an ongoing conversation sort before pre-wrap ones, so the context assembled for the model (and the UI transcript) interleaves incorrectly.~value), which jumps upward at the wrap.Identifier.timestamp(id)returnsencoded / 4096= ms mod 2^36, so age computations invert across the seam.Suggested fix directions
latestUser.id < latestAssistant.id,ORDER BY id) robust to ID-scheme evolution (e.g., compare by stored creation time with ID as tiebreaker).A migration story for existing DBs (which contain ~2 years of era-25 IDs) will be needed for any scheme change — the loop-exit and ORDER BY comparisons mix old and new IDs within the same session.
Happy to provide the DB evidence or test cases. Found while root-causing the duplicate-response loop described above; timeline is unfortunately fixed by the clock — ~15 days from this report.