fix(schema): stop lexicographic id ordering breakage from the 48-bit time-prefix wrap - #265
Merged
Conversation
Models writing workflow YAML by hand drift on field names and structure (live-observed: objective at top level, worker instead of worker_type) even with the reference template in context — YAML is an unvalidated text channel, so prompting alone cannot reach zero. This closes the gap at three layers: - workflow(action="draft"): pass the structured config through the tool parameter schema; the harness renders YAML to .opencode/workflow-drafts/, validates it, and returns the spec_path. Unknown fields are rejected by the provider-side schema before any file is written; YAML syntax errors disappear because rendering is code. spec_path contract unchanged. - routing guide now carries the minimal complete start-spec example inline (one hop) plus the field-ownership rules; description budget 5k -> 6.5k. - schemaDiagnostics maps high-frequency drift fields (worker/agent -> worker_type, prompt -> instruction, top-level objective -> config) to "Did you mean" hints, matching both message text and diagnostic path. Verified: test/dag 496/496 green incl. 5 new draft tests (render round-trip, bad-dependency diagnostics, unsafe-name rejection, schema drift rejection, drift hint); core command tests 21/21; tsgo clean in both packages.
The draft action commit (76df802) added the 11th parameter-union branch but missed test/tool/__snapshots__/parameters.test.ts.snap; the stale snapshot failed 'tool parameters > JSON Schema (wire shape) > workflow' deterministically (surfaced as an external red gate during a concurrent verification run). Regenerated via bun test --update-snapshots; full test/tool + workflow-tool suites green (384 pass).
The draft-action commit spliced the heading onto the preceding paragraph line, dropping it from rendered markdown structure.
The RegExp escape forms tripped two unnecessary-escape warnings, pushing CI's oxlint count past the --max-warnings=4852 ratchet. Diagnostic paths are JSON.stringify-segmented, so a plain ["field"] substring check covers the same matches without the regex.
…time-prefix wrap
Root cause (proven from live logs): identifier create() encoded
value = timestamp*0x1000 + counter into a 48-bit prefix. The shift eats 12
bits, so the prefix space covers only 2^36 ms (~795 days) and wrapped at
epoch 1786706395136 = 2026-08-14 19:19:55.136 +08. Post-wrap ids (prefix
0009...) sort BELOW pre-wrap ids (fff...): observed msg_fffac212c001 at
09:48 UTC and msg_00090cb0400141 at 13:58 UTC match the computed prefixes.
Sessions resumed across the boundary hit the runLoop exit condition
(lastUser.id < lastAssistant.id) and never call the model ('loop step=0'
followed 13ms later by 'exiting loop', no stream); the TUI binary-inserted
new messages at the transcript top instead of the bottom.
Two-layer repair:
- Generator (schema/identifier + opencode id + core id): the prefix is now
the raw 48-bit millisecond value behind a per-process monotonic latch
(max(ts, last+1)) — no shift, 8925-year runway, same-ms bursts stay
ascending, clock regression absorbed. 26-char format and the injected
timestamp param are unchanged; descending() keeps the bitwise NOT.
- Comparisons: every behavior-gating id ordering now uses time.created
(id tiebreak for same-ms): runLoop exit (MessageV2.before), MessageV2
.latest() bindings and tasks filter, revert stage/cleanup ranges, session
fork cutoff, TUI sync-store insertion/removal + session list order, TUI
pending/queued/undo/revert-boundary filters, child-session ordering.
This also revives already-corrupted cross-era sessions: a session whose
last assistant message has a pre-wrap id accepts a new user message and
runs the model again (pinned by the cross-era prompt test).
Truncate cleanup no longer decodes ids at all (file mtime); the legacy
timestamp() decoders keep the new encoding and have no remaining callers
that read historical ids.
Evidence: 4 deterministic red reproductions (identifier wrap boundary,
cross-era runLoop, latest() cross-era, TUI store insert position) all
green; mutation proof — reverting only the identifier encoding re-reddens
the wrap tests and restore is byte-identical (sha256); typecheck clean in
schema/core/opencode/tui; schema full 16/16, tui full 241/241, opencode
test/session test/tool green.
…cern Independent verification of the wrap fix caught a P1 regression it introduced: listSessions() was re-sorted by time.updated (descending), which broke the id-ascending invariant every session event handler's binary search relies on. Touching an old session (session.updated after a prompt) then duplicated its row in the store, session.deleted could miss and leave a ghost, and session.get returned undefined for existing sessions — reproduced with the sync fixture. The store now restores codepoint id order in listSessions(); the session-list dialog applies recency ordering at the display layer (search fallback re-sorts by time.updated, matching search results). Two sync-store regression tests pin the invariant: session.updated reconciles in place (no duplicate) and session.deleted removes when recency order diverges from id order. Both are red on the regressed ordering and green here.
LeXwDeX
force-pushed
the
fix/msgid-time-prefix-wrap
branch
from
August 14, 2026 15:53
939aa25 to
feb3b33
Compare
The wrap-boundary tests used ! on already-string expressions, adding four no-unnecessary-type-assertion warnings that push CI past the --max-warnings=4852 ratchet.
This was referenced Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause (proven from live logs)
identifier.create()encodedtimestamp*0x1000 + counterinto a 48-bit prefix. The×0x1000shift eats 12 bits, so the prefix space covers only 2^36 ms (~795 days) and wrapped at epoch 1786706395136 = 2026-08-14 19:19:55.136 +08. Post-wrap ids (0009...) sort lexicographically BELOW pre-wrap ids (fff...):msg_fffac212c001@ 09:48 UTC ↔ computed prefixfffac2e7✓msg_00090cb0400141@ 13:58 UTC ↔ computed prefix00090c9e✓User-visible symptom: every session whose last assistant message predates the wrap becomes unresponsive on resume — send a message, nothing happens; restart and the message is visible. Server log signature:
loop step=0followed 13ms later byexiting loopwith no model stream: the runLoop exit conditionlastUser.id < lastAssistant.id(string compare) fires instantly. The TUI additionally binary-inserts the new message at the transcript top instead of the bottom.Two-layer repair
1. Generator (root fix)
packages/schema/src/identifier.ts,packages/opencode/src/id/id.ts,packages/core/src/id/id.ts: prefix is now the raw 48-bit millisecond value behind a per-process monotonic latch (max(ts, last+1)):descending()bitwise-NOT contract unchanged2. Comparison hardening (revives already-corrupted sessions)
Every behavior-gating id ordering now uses time.created (id tiebreak for same-ms), via the exported
MessageV2.before:prompt.ts)MessageV2.latest()bindings + tasks filtersearchMessagesbinary search on time order), removal, session-list orderpending()/queued/undo/revert-boundary filters, child-session orderingTruncate.cleanupno longer decodes ids at all (file mtime); legacytimestamp()decoders have zero remaining callers reading historical idsCross-era revival is pinned by test: a session whose last assistant message has a pre-wrap id (
msg_fffac212...) accepts a new post-wrap user message → model invoked (llm.hits=1) → new assistant returned.Evidence (from the failed-verdict-but-fully-verified DAG run)
The verification node of workflow
dag_fff60f8aproduced deterministic evidence; its FAIL verdict was traced (static import-closure attribution) to a snapshot missed in the concurrent #264 commit — nothing in this change:latest()cross-era, TUI store insert positionbun typecheckclean: schema / core / opencode / tuitest/session+test/toolgreen[DEBUG-msgwrap]tags, no mutation leftovers, working tree cleanRemaining uncertainty (documented, not blocking)
~1a0f...vs post-wrap ids0009...) — safe only because all behavior-gating comparisons are now time-based; display-only id sorts were audited and hardened where trivial.Closes the /resume-unresponsive symptom reported today; no data migration needed (pre-wrap sessions become usable again via time-based comparisons).