Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 96 additions & 6 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,85 @@ export function bucketMessages<M extends { agentID?: string | null }>(
return out
}

/**
* Client-side mirror of MessageV2.compare — chronological order of two messages.
*
* Message ids are NOT a clock. The id encoder packs `Date.now() * 0x1000 + counter`
* into 6 bytes, so the sortable prefix wraps every 2^36 ms (~2.18 years); the last
* boundary was 2026-08-14T11:19:55Z. Across a wrap a NEWER message gets a SMALLER
* id, so `a.id < b.id` inverts for any session whose history straddles it.
*
* This matters more here than it looks: the store keeps each bucket sorted and uses
* Binary.search over that order both to locate an existing message and to pick the
* splice index for a new one. With ids as the key, a fresh message lands at index 0
* — which is what put new messages at the top of the transcript — and the >100
* trim then treats `list[0]` as "oldest" and deletes it along with its parts.
*
* `time.created` is an independent field, unaffected by the packing, so it is the
* primary key; the id only breaks ties inside the same millisecond.
*/
export function compareMessages(
a: { id: string; time: { created: number } },
b: { id: string; time: { created: number } },
) {
return a.time.created !== b.time.created ? a.time.created - b.time.created : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
}

/**
* Compare a message against a MARKER id (revert point, pending watermark) held by
* the session rather than by a message object.
*
* Returns `< 0` if `msg` precedes the marker, `> 0` if it follows, `0` if it IS the
* marker. When the marker is not present in `list` there is no anchor to compare
* against and the caller gets `undefined` — callers decide what that means rather
* than silently treating the marker as position 0 or infinity.
*
* Resolving the marker through the list is what makes this wrap-safe: the marker id
* alone carries no time, and comparing id strings inverts across a wrap boundary
* (see compareMessages).
*/
export function compareToMarker<M extends { id: string; time: { created: number } }>(
list: M[],
msg: M,
markerID: string,
): number | undefined {
const marker = list.find((m) => m.id === markerID)
if (!marker) return undefined
return compareMessages(msg, marker)
}

/**
* Locate `id` in a chronologically-sorted message list, or the index it should be
* inserted at. Same contract as Binary.search, but keyed on (time.created, id) —
* Binary.search compares id strings only and cannot express this order.
*
* `probe` is the incoming message when inserting. Lookups that only have an id
* (message.removed) pass none and fall back to a linear scan: without a time there
* is nothing to bisect on, and a wrong bisect here would splice out an unrelated
* message.
*/
export function searchMessages<M extends { id: string; time: { created: number } }>(
list: M[],
id: string,
probe?: { id: string; time: { created: number } },
): { found: boolean; index: number } {
if (!probe) {
const index = list.findIndex((m) => m.id === id)
return index === -1 ? { found: false, index: list.length } : { found: true, index }
}
let left = 0
let right = list.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
if (compareMessages(list[mid], probe) < 0) left = mid + 1
else right = mid
}
// `left` is the first element not ordered before probe — an exact id match can
// only be there, since compare is a total order and probe carries its own id.
if (left < list.length && list[left].id === id) return { found: true, index: left }
return { found: false, index: left }
}

/**
* A `session.status` event is authoritative for the WHOLE status object.
*
Expand Down Expand Up @@ -193,7 +272,7 @@ export function nextSessionStatus(status: SessionStatus) {
// read-only local-DB snapshot and they drift — this arm's population grew
// 1294 → 1313 across this branch's own revisions — so trust the split's shape,
// not the absolute numbers.
export function selectMessages<M extends { id: string }>(
export function selectMessages<M extends { id: string; time: { created: number } }>(
buckets: Record<string, M[]> | undefined,
agentID: string,
sessionID: string,
Expand All @@ -202,7 +281,10 @@ export function selectMessages<M extends { id: string }>(
if (buckets?.[sessionID]?.length) return buckets[sessionID]
const newest = Object.entries(buckets ?? {})
.filter(([key, msgs]) => key !== "main" && msgs.length > 0)
.sort(([, a], [, b]) => (b.at(-1)?.id ?? "").localeCompare(a.at(-1)?.id ?? ""))
// "Newest bucket" by its last message's time, not its id — an id compare
// inverts across an id wrap (see compareMessages) and would pick the bucket
// that happens to hold a pre-wrap tail.
.sort(([, a], [, b]) => (b.at(-1)?.time.created ?? 0) - (a.at(-1)?.time.created ?? 0))
.at(0)
return newest?.[1] ?? []
}
Expand Down Expand Up @@ -567,7 +649,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
const messages = store.message[sid][aid]
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
const result = searchMessages(messages, event.properties.info.id, event.properties.info)
if (result.found) {
setStore("message", sid, aid, result.index, reconcile(event.properties.info))
break
Expand Down Expand Up @@ -608,7 +690,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (!buckets) break
for (const aid of Object.keys(buckets)) {
const messages = buckets[aid]
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
const result = searchMessages(messages, event.properties.messageID)
if (result.found) {
setStore(
"message",
Expand Down Expand Up @@ -1005,8 +1087,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.todo[sessionID] = todo.data ?? []
draft.task[sessionID] = task.data ?? []
const flat = (messages.data ?? []).map((x) => x.info)
// Server returns messages id-ordered and message.updated keeps that order; the footer's post-/rebuild pending-detection deliberately does NOT depend on it (it keys off checkpoint coveredUpTo, model.ts), so reordering here won't resurface the stale-context bug.
draft.message[sessionID] = bucketMessages(flat)
// Server returns messages in (time_created, id) order and
// message.updated keeps that order via searchMessages. Sorting here
// too is belt-and-braces: it makes the store's invariant hold from
// its own code rather than from a property of the endpoint, so a
// paging/ordering change server-side can't silently corrupt the
// splice indices the trim and message.removed both rely on. The
// footer's post-/rebuild pending-detection deliberately does NOT
// depend on this order (it keys off checkpoint coveredUpTo, model.ts),
// so ordering here won't resurface the stale-context bug.
draft.message[sessionID] = bucketMessages(flat.toSorted(compareMessages))
for (const message of messages.data ?? []) {
draft.part[message.info.id] = message.parts
}
Expand Down
52 changes: 41 additions & 11 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Dynamic } from "solid-js/web"
import path from "path"
import { useCurrentAgentID, useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { selectMessages, useSync } from "@tui/context/sync"
import { compareMessages, compareToMarker, selectMessages, useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
Expand Down Expand Up @@ -188,8 +188,11 @@ export function Session() {
)
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)

// The in-flight assistant message itself, not just its id: `queued` compares
// against it chronologically (see compareMessages) and an id compare inverts
// across an id wrap, which would mark settled messages as queued.
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)
})

const lastAssistant = createMemo(() => {
Expand Down Expand Up @@ -733,7 +736,17 @@ export function Session() {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
const all = messages()
// Ordered against the revert marker via compareToMarker, not by raw id: an
// id compare inverts across an id wrap and would undo the wrong turn.
// An unresolvable marker (outside the loaded window) yields undefined, which
// excludes the message rather than defaulting it into the "before" side.
const before = (x: (typeof all)[number]) => {
if (!revert) return true
const rel = compareToMarker(all, x, revert)
return rel !== undefined && rel < 0
}
const message = all.findLast((x) => x.role === "user" && before(x))
if (!message) return
void sdk.client.session
.revert({
Expand Down Expand Up @@ -772,7 +785,13 @@ export function Session() {
dialog.clear()
const messageID = session()?.revert?.messageID
if (!messageID) return
const message = messages().find((x) => x.role === "user" && x.id > messageID)
const all = messages()
// Ordered via compareToMarker, not by raw id (inverts across an id wrap).
const message = all.find((x) => {
if (x.role !== "user") return false
const rel = compareToMarker(all, x, messageID)
return rel !== undefined && rel > 0
})
if (!message) {
void sdk.client.session.unrevert({
sessionID: route.sessionID,
Expand Down Expand Up @@ -1019,9 +1038,14 @@ export function Session() {
category: "session",
onSelect: (dialog) => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
)
const all = messages()
// Ordered via compareToMarker, not by raw id (inverts across an id wrap).
const lastAssistantMessage = all.findLast((msg) => {
if (msg.role !== "assistant") return false
if (!revertID) return true
const rel = compareToMarker(all, msg, revertID)
return rel !== undefined && rel < 0
})
if (!lastAssistantMessage) {
toast.show({ message: "No assistant messages found", variant: "error" })
dialog.clear()
Expand Down Expand Up @@ -1225,7 +1249,13 @@ export function Session() {
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
if (!messageID) return []
return messages().filter((x) => x.id >= messageID && x.role === "user")
const all = messages()
// Ordered via compareToMarker, not by raw id (inverts across an id wrap).
return all.filter((x) => {
if (x.role !== "user") return false
const rel = compareToMarker(all, x, messageID)
return rel !== undefined && rel >= 0
})
})

const revert = createMemo(() => {
Expand Down Expand Up @@ -1405,7 +1435,7 @@ export function Session() {
)
})()}
</Match>
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
<Match when={revert()?.messageID && (compareToMarker(messages(), message, revert()!.messageID) ?? -1) >= 0}>
<></>
</Match>
<Match when={message.role === "user"}>
Expand Down Expand Up @@ -1521,7 +1551,7 @@ function UserMessage(props: {
parts: Part[]
onMouseUp: () => void
index: number
pending?: string
pending?: { id: string; time: { created: number } }
}) {
const ctx = use()
const local = useLocal()
Expand Down Expand Up @@ -1562,7 +1592,7 @@ function UserMessage(props: {
const { theme } = useTheme()
const t = useLanguage().t
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const queued = createMemo(() => !!props.pending && compareMessages(props.message, props.pending) > 0)
const color = createMemo(() => local.agent.color(props.message.agent))
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
Expand Down
23 changes: 18 additions & 5 deletions packages/opencode/src/history/backfill.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Context, Effect, Layer } from "effect"
import { and, asc, desc, eq, gt, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, or, sql } from "drizzle-orm"
import { Database } from "../storage"
import { Config } from "../config"
import { PartTable, SessionTable } from "../session/session.sql"
Expand Down Expand Up @@ -49,7 +49,12 @@ function scanSession(
enabled: ReadonlySet<Kind>,
) {
return Effect.gen(function* () {
let cursor = ""
// Composite (time_created, id) cursor. Part ids share the message-id clock,
// which wraps every ~2.18 years (see MessageV2.compare), so a bare
// `gt(id, cursor)` walk stops dead at a wrap boundary: every post-wrap part
// has a SMALLER id than the pre-wrap tail and would never be visited, leaving
// the newest history permanently unindexed.
let cursor: { time: number; id: string } | undefined
while (true) {
const parts = Database.use((db) =>
db
Expand All @@ -58,18 +63,26 @@ function scanSession(
.where(
and(
eq(PartTable.session_id, session.id as any),
gt(PartTable.id, cursor as any),
...(cursor
? [
or(
gt(PartTable.time_created, cursor.time),
and(eq(PartTable.time_created, cursor.time), gt(PartTable.id, cursor.id as any)),
)!,
]
: []),
sql`NOT EXISTS (SELECT 1 FROM history_fts WHERE history_fts.part_id = ${PartTable.id})`,
),
)
.orderBy(asc(PartTable.id))
.orderBy(asc(PartTable.time_created), asc(PartTable.id))
.limit(BATCH)
.all(),
)
if (parts.length === 0) return

yield* writeBatch(parts, session.project_id, resolver, enabled)
cursor = parts[parts.length - 1]!.id
const last = parts[parts.length - 1]!
cursor = { time: last.time_created, id: last.id }
yield* Effect.sleep("10 millis")
}
})
Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/src/session/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function classifyAssistantStep(input: {
if (
assistant.finish === "tool-calls" &&
!assistant.error &&
input.lastUser.id < assistant.id &&
MessageV2.compare(input.lastUser, assistant) < 0 &&
!input.parts.some((part) => part.type === "tool") &&
input.parts.some(
(part) =>
Expand All @@ -87,7 +87,13 @@ export function classifyAssistantStep(input: {
if (assistant.finish === "tool-calls") return { type: "continue" }

// 4. Stale assistant predating the current user turn — don't terminate on it.
if (input.phase === "existing-assistant" && !(input.lastUser.id < assistant.id))
// Ordered by (time.created, id) via MessageV2.compare, not by raw id: message
// ids encode a 48-bit timestamp that wraps every ~2.18 years, so across a wrap
// a NEWER assistant gets a SMALLER id and a bare compare calls a fresh reply
// "stale" (or a genuinely stale one fresh). Both misjudgements are load-bearing
// here and at #3a — this is the same guard prompt.ts applies before
// auto-continue, and it must agree with it.
if (input.phase === "existing-assistant" && !(MessageV2.compare(input.lastUser, assistant) < 0))
return { type: "continue" }

// 5. Errored step — checked before content so an errored message that also
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,27 @@ const part = (row: typeof PartTable.$inferSelect) =>
const older = (row: Cursor) =>
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))

/**
* Chronological order of two messages: `< 0` if a precedes b, `> 0` if a follows
* b, `0` if they are the same message.
*
* Message IDs are NOT a usable clock. `Identifier.create` packs
* `Date.now() * 0x1000 + counter` into 6 bytes, so the sortable prefix wraps
* every 2^36 ms (~2.18 years); the last wrap was 2026-08-14 12:39:55 UTC and the
* next is ~Oct 2028. Across a wrap, a NEWER message gets a SMALLER id — post-wrap
* ids restart near `msg_000…` while pre-wrap ids sit near `msg_fff…`. Any bare
* `a.id > b.id` therefore inverts for every session whose history straddles a
* boundary, which silently wedged the session loop (a fresh user prompt sorted
* before all history, so the loop saw no new work and exited at step 0).
*
* `time.created` is an independent integer column and is not affected, so it is
* the primary key of the order; the id only breaks ties inside the same
* millisecond, which is exactly the ambiguity the counter was added to resolve.
*/
export function compare(a: { id: string; time: { created: number } }, b: { id: string; time: { created: number } }) {
return a.time.created !== b.time.created ? a.time.created - b.time.created : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
}

function hydrate(rows: (typeof MessageTable.$inferSelect)[]) {
const ids = rows.map((row) => row.id)
const partByMessage = new Map<string, Part[]>()
Expand Down
Loading
Loading