Skip to content

fix(session): delete reverted messages boundary-last and tie-break ids by raw order - #42819

Open
NamedIdentity wants to merge 2 commits into
anomalyco:devfrom
NamedIdentity:fix/revert-cleanup-order-and-id-tiebreak
Open

fix(session): delete reverted messages boundary-last and tie-break ids by raw order#42819
NamedIdentity wants to merge 2 commits into
anomalyco:devfrom
NamedIdentity:fix/revert-cleanup-order-and-id-tiebreak

Conversation

@NamedIdentity

@NamedIdentity NamedIdentity commented Aug 15, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #42816

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Two fixes to the ID rollover ordering work.

1. Revert cleanup deletes the boundary message first.

SessionRevert.cleanup removes messages from the revert boundary onward. The boundary is remove[0] when no partID is set, and each removeMessage commits separately. If that loop is interrupted, the boundary is already gone but the rest are not. The next cleanup calls findIndex for the boundary, gets -1, so remove is empty and it deletes nothing. clearRevert() still runs at the end, discarding the marker that could have located those rows. They stay in the session permanently and get sent to the model on the next turn.

Using .toReversed() deletes the boundary last, so it works as a progress marker. An interrupted cleanup then leaves a state the next one can finish. The part loop below it has the same problem and the same fix.

2. The localeCompare tie-break does not match storage order.

Storage pages with ORDER BY time_created, id, and SQLite's default collation is BINARY. sync.tsx and Share.tsx tie-break the same data with localeCompare, which is locale collation. For two messages sharing a time.created these can return opposite results, so the client and the database can disagree about which comes first. Ids minted in the same millisecond by different processes share the timestamp prefix, so the random mixed case suffix decides, and that is exactly where the two collations differ.

localeCompare can also return 0 for two distinct strings that are canonically equivalent. MessageID only requires a msg prefix, so two different primary keys can compare equal and the sort becomes dependent on input order.

Raw < and > match what storage already does.

How did you verify your code works?

Read the affected paths and traced the interruption sequence above. Confirmed toReversed() is already used elsewhere in the repo, so there are no target or lib concerns.

Both fixes have been running in our fork for a day. We hit the same rollover in production on 2026-08-14, wrote the same core fix independently, then found yours had landed. These two turned up while auditing ours.

I have not run the full test suite against this branch.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Sean Smith added 2 commits August 15, 2026 14:41
Revert cleanup removed the boundary first. Each removal is a separate
durable transaction, so an interruption after the boundary left the
remaining reverted rows unreachable: the next cleanup's findIndex
returned -1, remove was empty, and clearRevert still discarded the only
marker that could have located them. Those rows then rejoined the
transcript as ordinary history and were sent to the model.

Iterating newest-first makes the boundary its own progress marker, so an
interrupted cleanup leaves a state the next one completes. The part loop
had the same shape and the same fix.
…ation

Message ordering tie-broke equal time.created with localeCompare, while
storage pages with ORDER BY time_created, id under SQLite's BINARY
collation. The two disagree: for ids differing only in suffix case,
en-US collation and raw byte order sort them oppositely, so a paginated
fetch and the client could order the same pair differently.

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.

Applies to the TUI message store and the shared transcript. The session
list sort is left alone -- it has no time component and feeds a binary
search whose relation would have to change with it.
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. needs:title labels Aug 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Hey! Your PR title fix(session): delete reverted messages boundary-last and tie-break ids by raw order doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@NamedIdentity NamedIdentity changed the title fix(session): delete reverted messages boundary-last and tie-break ids by raw order fix(session): delete reverted messages boundary-last and tie-break ids by raw order Aug 15, 2026
@github-actions github-actions Bot removed needs:title needs:compliance This means the issue will auto-close after 2 hours. labels Aug 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

Review of the revert-deletion ordering fix — the boundary-last strategy and its rationale comment are well reasoned (interrupted cleanups stay resumable because the surviving marker still locates the remaining rows):

  1. Test coverage — the crash-resumption property motivating toReversed() ships without any test — add a regression case that fails removePart/removeMessage partway through the loop, then re-runs cleanup and asserts no rows silently rejoin the transcript — this is exactly the invariant a future refactor of the slice/reverse logic would break invisibly.
  2. packages/tui/src/context/sync.tsx:55 and packages/web/src/components/Share.tsx (~L79) — the same comparator (and now the same subtle BINARY-collation rationale) is duplicated across two packages — the copies will drift when one gains a fix the other misses — extract a shared compareByCreatedThenId helper into a common package both frontends already import from.
  3. packages/tui/src/context/sync.tsx:59 — raw </> orders by UTF-16 code units, while SQLite BINARY collation orders by UTF-8 bytes — these disagree for non-BMP characters (surrogates sort below U+E000–U+FFFF in UTF-16 but above in code-point order) — harmless while session/message ids stay ASCII, but the comment claims general equivalence it does not have — scope the claim ("ids are ASCII") or compare by code points explicitly.
  4. packages/opencode/src/session/revert.ts:109 — nit: consider extracting the two commented loops' shared "delete backwards, boundary last" pattern into a tiny named helper (removeReversed) so the durability argument lives in exactly one place alongside the code enforcing it.

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.

[BUG] Two ordering-correctness gaps remain after the ID-rollover fix

2 participants