Repository navigation
Description
Description
When two opencode server processes share a single opencode.db (a long-lived opencode serve --service instance plus a TUI-embedded server from opencode -c), both allocate session_message.seq from independent in-memory counters. The second server's counter starts from a stale/near-empty view of the session, and as it advances it collides with rows already persisted by the first server: every colliding insert fails with UNIQUE constraint failed: session_message.session_id, session_message.seq, surfacing as "Session failed" in the UI and permanently breaking the session on that server.
OpenCode version
2.0.22 (channel latest); the same topology exists fleet-side on 1.18.34.
Operating System
Linux x64
Plugins
None installable; MCP servers only (via config).
Steps to reproduce
opencode serve --serviceon a data dir (server B, fresh TUI clients attach here)- Run
opencode -c(TUI with embedded server, no TCP listener) on the SAME data dir - Continue working the same session from both processes
- Server B's in-memory seq counter advances into seqs already persisted by server A ->
UNIQUE constraint failed: session_message.session_id, session_message.seq, turns fail
Screenshot and/or share link
Log + DB evidence inline below (no share link available).
Observed failure
ERROR "Failed to drain Session"
cause: EffectDrizzleQueryError: Failed query: insert into "session_message" (...) values (?, ...)
[cause]: SQLiteError: UNIQUE constraint failed: session_message.session_id, session_message.seq
at SessionRunner.publishLLMEvent -> SessionStep.attempt -> SessionRunner.runStep -> SessionRunner.drain
5 occurrences on one day, all against a single long-running session.
Evidence (from the live DB after the incident)
Two interleaved write streams into the same session_id, each with its own counter:
- Stream A (original server): counter intact across a day - wrote seqs 385-736 between 06:18-06:45 UTC.
- Stream B (second server): initialized from a near-empty view of the session - first write landed on seq 2 at 06:51 UTC, then advanced ~19 seqs per assistant turn (2, 17, 32, 51, 70, 89, 113 ... 710 by 12:52 UTC).
- Stream B's inserts succeed only where Stream A left gaps (its ephemeral/unpersisted seqs) and fail exactly where Stream A persisted rows:
- seq 108: occupied since the previous day -> fail
- seq 585 (+cascade 586): occupied 48 min earlier -> fail
- seq 684: occupied 6 h earlier -> fail
- seq 709: occupied 6 h earlier -> fail (the "Session failed" the user saw)
- Follow-up
idlemessages (+1 seq) inserted fine immediately after each failure (109, 685, 710 were free), which is why the session half-works and the failure looks intermittent.
Ruled out: disk space, SQLITE_BUSY, FK violations (session_v2 row exists), DB corruption (quick_check ok).
Root cause
session_message.seq is allocated per-process from in-memory runner state, while the database is shared. Nothing prevents two server processes from serving the same data directory and the same session, and nothing reconciles their counters. The first server to touch the session wins; the second replays an overlapping seq space and aborts turns on collisions. TUI restarts make this easy to hit: the old server keeps running while a new one spawns (both hold the DB).
Suggested directions
- Allocate
seqat insert time from the database (SELECT max(seq)+1 ...inside the write transaction, or an AUTOINCREMENT-style per-session counter table) instead of from in-memory runner state. - On
UniqueViolationfor(session_id, seq), re-read the current max seq and retry the insert once instead of failing the whole session run. - Guard the data directory against a second live server (lock/heartbeat row), or make a later server adopt the session state (re-read max seq at session load).
Related
- core: continuing a V1-created session fails with UNIQUE constraint on event seq #52395 - same UNIQUE constraint on seq, but triggered by continuing a V1-created session (migration path), not by dual live servers.
Activity
ImBIOS commentedon Oct 4, 2026
Matching topology seen from the T3-Code side, plus an
event-table variant of the same collision.Process topology sharing one WAL-mode opencode.db (~9.6G):
opencode serve 127.0.0.1:36681(T3-managed, ran the original 13-minute streaming turn),opencode serve 127.0.0.1:41105(T3-managed, spawned ~1 min after the first crash; the retry ran here),opencode serve --service(not T3-owned, started mid-turn).Variant: our collision was on the legacy
event/event_sequencetables rather thansession_message: the allocator read a stale counter (691 vs committed max 728) and the overlapping insert aborted the entire drain (prompt_async failed), bricking the session for plain retries — not just a single failed turn.This suggests the fix directions (DB-derived allocation, retry-on-UniqueViolation, single-writer guard) should cover the
eventsurface too, not onlysession_message. Happy to provide timestamps/row dumps if it helps repro.github-actions commentedon Oct 4, 2026
Thanks for updating this issue with the Bug Report template and reproduction details.
ImBIOS commentedon Oct 5, 2026
Follow-up with a next-day occurrence on the same session, now on this exact table — and with only ONE live server.
Setup: all old serves were gone; a single fresh
opencode serve(started 5 min before the retry) loaded the session. Committed head insession_messageis seq 1118 (101 sparse rows). The drain wrotesystem@731 anduser@732 (both gaps, committed fine), then died inSessionRunner.publishLLMEventinserting the first assistant message at seq 733 — occupied since the previous day. The outcome-bookkeeping insert (idle) at 734 collided as well. Caller (T3) still shows the run asrunningwith no further events: wedged, not just failed.So a single fresh server process still initializes the session allocator from a stale view (~731) instead of the committed head (1118) and replays overlapping seq space. Session load reconciling with
max(seq)(direction 3) would have survived this; nothing else would. Happy to provide the conflicting row ids/timestamps.satwareAG-ironMike commentedon Oct 5, 2026
New data point (2026-10-05): single writer also rewinds - and the stale counter survives a server restart
Follow-up from satware AG (host: mw workstation, opencode 2.0.23). A second incident the next day shows the dual-process topology is not required for this failure mode.
What happened
Session
ses_ef60a5c98ffe85WeCqrI5WMuhq: ~60 healthy turns 04:47-05:58 local (seqs advanced 5 -> 821, all turnsfinish=stop). At 05:58 the agent ran its project EOD routine from inside the session, whose teardown performs chunked DELETEs +wal_checkpoint(TRUNCATE)on the liveopencode.db(single server process, verified vialsof- no second holder at any point that day).At 06:03:49 the next assistant message persisted at seq 2 - the counter was re-initialized from a near-empty view, the same signature as the second stream in the original report. From then on every turn collided:
Logged collisions: seq 35 and 138 (server process A), then seq 652 and 688 (server process B). Each crash ends the turn
idle outcome=failed; the retry re-generates the same step, so the session visibly loops (55 iterations, ~1.9 M input tokens). Queued user prompts - including an explicit "stop looping" - cannot break the cycle because the loop is the persist-fail/retry cycle below the model layer. Only a hard interrupt stopped it.Persistence across restart is the aggravating part
Process A's watchers stopped at 05:43:20Z and a fresh
opencode serve --service(B) started the same second - clean handover, no overlap (singlelsofholder throughout). B still collided at 652/688, marching up through seq space already occupied by A's healthy rows. So the per-session counter is not re-seeded fromMAX(seq)on restart; poisoned state survives process replacement, making the session permanently unresumable.Ask
session_message.seqallocation fromMAX(seq)of persisted rows (or a persisted counter row) at process start and after any cache invalidation - the "near-empty view" initialization is the common root of both incidents.UNIQUE(seq)violation during publish, abort the turn as failed instead of retrying the step - the retry regenerates identical content and turns one bad write into an infinite loop.Cross-ref: internal RCA satware.ai/harness#1166 (comment 32270 has the full evidence chain).
nkoynov commentedon Oct 7, 2026
In our case (T3 + a CLI-started managed service, 2.0.22) the trigger was #51213's sweep; seqs did not collide because durable seqs come from
event_sequenceinside the write transaction, but every resumed step ran on both servers.Seamus05 commentedon Oct 19, 2026
Cross-reference with single-writer evidence on 2.0.31 — no second process needed, and the wedge is decided at reload, not at the write.
Data from a 1.3 GB
opencode.db(WAL,integrity_check ok, 2167 sessions):UNIQUE constraint failed: session_message.session_id, session_message.seqerrors in one serve log, across 4 sessions, 2026-10-05 → 2026-10-19.session_messagerows, seq 2…1321 (sparse), last rowscompaction@1318 andidle@1321 —event_sequence.seqfor the session = 5.INSERT … seq=5collides on the unique index; the session is suspended the same second the prompt arrives.event_sequencerow, 14 havecounter <= max(session_message.seq)— one reload away from the same wedge. The other 2151 sessions have no counter row at all (allocator derives the seq from the rows), which is why only sessions with a stale row die.So the counter is written around session creation and never advanced by the projection path; a session that lives inside one process is fine (in-memory counter), and the first reload after the row went stale is fatal. Repair that unblocked it (verified on a copy):
event_sequence.seq = COALESCE(MAX(session_message.seq), -1) + 1for the session, then restart.Full write-up with schema, numbers and the before/after repair transcript: #56975.
Seamus05 commentedon Oct 26, 2026
Follow-up: a live session's counter can sit thousands behind its own
max(seq)(v2.1.4, Linux)Following up on our 2026-10-19 comment above, which established single-writer. This one is about where the counter goes wrong, because "stale counter" points at the writer and what we can see points at the read path.
Observed
Two long-lived sessions on this box (v2.1.4, Linux, single writer, one serve per store):
event_sequence.seqmax(session_message.seq)Both were live and taking turns. In A, the first write after an ~5-hour idle window used seq 2. Turns then kept succeeding for about four hours — the allocator was walking A's own early range, where the slots were free. At seq 46 it reached a row from two days earlier;
UNIQUE(session_message.session_id, seq)rejected the insert and the lifecycle publish died. Every turn after that failed. B was caught before collision by the check below.Repair was to set the counter to
max(seq)+1(7766); A re-verified healthy an hour later at 7934 vs max 7925.What this points at
The row was present and readable — it held 45. The allocator did not use 45; the first post-idle write used 2. So the failure is in the read path after whatever happens on idle, not in the write. We read the allocator as falling back to
-1when the row cannot be read and then seeding from 0, but we have not reproduced that deliberately, so treat it as a hypothesis, not a finding.One thing that is not a symptom
In this store only 5 of the 368 sessions that have messages also have an
event_sequencerow — and those 5 are exactly the sessions the serve is currently tracking. A missing row appears to be normal for a closed session, so it should not be used as a corruption signal. The signal is a live session whoseseqsits behind its ownmax(session_message.seq).Detector
Read-only, per store, cheap enough to run on a timer:
Any row returned is a session already walking into a collision. Ours runs every 15 minutes across five stores; it repairs only when the session is idle, and records rather than repairs when it cannot read the serve. It currently reports 0 behind.
Repro shape
Idle a long-lived session past whatever rehydration threshold applies, then write once and watch the seq. We have not narrowed the threshold.
opencodejr commentedon Oct 27, 2026
Thanks for the detailed write-up and the DB evidence. I tried to reproduce this on 2.0.22 (Linux x64) and couldn't.
What I tried: two
opencode serveprocesses sharing one data directory (so oneopencode.db), with a stub OpenAI-compatible model. I ran turns in the same session from both servers, first alternating and then concurrently (about 28 assistant turns). There was noUNIQUE constraint failed: session_message.session_id, session_message.seqand no "Failed to drain Session", andevent_sequence.seqfor the session stayed equal tomax(session_message.seq).I don't think the cause is an in-memory counter. In v2,
session_message.seqis the durable event's sequence number, which is read from theevent_sequencetable (latest + 1) in the same SQLite transaction that inserts the message row. So a second server reads the current value from the database. For a collision like yours, theevent_sequencerow for that session would have to fall behind the rows already insession_message.These would help us track it down:
opencode --versionfrom both processes, the--serviceone and the one started byopencode -c. You mention the same setup exists on machines running 1.18.34: could a 1.x binary ever have opened the sameopencode.db?select seq from event_sequence where aggregate_id = '<session id>', next toselect max(seq) from session_message where session_id = '<session id>'andselect max(seq) from event where aggregate_id = '<session id>'.XDG_DATA_HOMEand channel.You can reply here or edit the issue. If you edit it, please also leave a comment saying you've updated it.
Related:
event.aggregate_id, event.seqafter continuing a V1-created session, where the per-session sequence row was behind the stored rows.opencodejr commentedon Oct 30, 2026
Closing this for now since we haven't heard back. We couldn't reproduce the
UNIQUE constraint failed: session_message.session_id, session_message.seqerror on 2.0.22 with two servers sharing oneopencode.dband running turns in the same session. Without the details we asked for, we can't tell what makes the per-sessionevent_sequencecounter fall behind in your setup. Those details were: the versions of both processes (and whether a 1.x binary ever opened the same DB), whether the session was created in v1, and theevent_sequence.seqvsmax(seq)values for the affected session.If it happens again, please reply here with those details or open a new issue linking this one, and we'll pick it up. #52395 covers a related case: continuing a V1-created session. Thanks again for the thorough report.