fix(opencode): private session DB per worker — the 'database is locked' root cause - #34
Merged
Merged
Conversation
OpenCode keeps its session state at $XDG_DATA_HOME/opencode/opencode.db — one
SQLite file, shared by every instance, opened for write and growing without
bound (145 MB on this machine). Fan several workers out at once and the losers
of the lock race die before the model emits a token:
Error: Unexpected error
database is locked
Ringer can only record that as an ordinary FAIL, so the scoreboard reads it as
the model failing when no model ever ran. Every non-Codex model routes through
this engine, so the penalty lands hardest on exactly the cheap tier that gets
fanned out widest, and worsens as parallelism rises. A surface misstating what
happened is the failure class this project treats as unforgivable.
Point XDG_DATA_HOME at the per-run scratch root the wrapper already creates and
tears down, giving each sandboxed worker a private store. Two load-bearing
details:
- Credentials live inside the relocated directory, so the private root is
seeded with auth.json by COPYING. A symlink would resolve straight back to
the shared file this exists to avoid, and the profile denies writes outside
SCRATCH. The copy is guarded: `set -euo pipefail` would otherwise make a
missing auth.json fatal on machines that authenticate another way.
- --no-sandbox deliberately keeps the shared store. That path `exec`s, so the
scratch dir's EXIT trap would never fire and the root would leak on every
full-access run. Now says so in a comment.
The 0-4s spawn jitter is kept but demoted in its comment from cure to secondary
net — it only ever narrowed the collision window, and it still spreads the
startup burst of provider calls. It can be deleted once a wide fan-out has been
observed clean.
Verified against the real binary, not just the script text: `opencode debug
paths` through the wrapper resolves `data` to the per-run scratch dir, and a
sandboxed run leaves the shared DB's mtime untouched. New tests drive the
wrapper with a stub opencode on PATH and assert what the worker actually
receives — private data home, two workers never sharing one, credentials
seeded by copy and not symlink, and a missing auth.json not aborting the run.
Matches upstream PR NateBJones-Projects#79, which is unmerged. Its OC_BASE profile subpath is
included; sandbox-exec accepts a subpath param for a path that does not exist,
so it is safe on machines without ~/.opencode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The real cure for the contention the 0-4s spawn jitter has been papering over since 2026-07-06. Fourth defect from the upstream audit (see #32, #33).
The bug
OpenCode keeps its session state at
$XDG_DATA_HOME/opencode/opencode.db— one SQLite file, shared by every instance, opened for write and growing without bound. On this machine it stands at 145 MB. Fan several workers out at once and the losers of the lock race die before the model emits a token:Ringer can only record that as an ordinary
FAIL. So the scoreboard reads it as the model failing when no model ever ran.The part that makes this worth chasing: every non-Codex model routes through this engine, so the contention penalises exactly the cheap tier you fan out widest, and it worsens as parallelism rises. It reads as "the cheap lane can't cope." That's a surface misstating what happened — the failure class this repo treats as unforgivable, and the same class as the
lou-call-transcriptpath-contract bug and the ANSI corruption in #33.The jitter patch (
f380521,bb3aa64) never fixed this. It only narrowed the window.The fix
Point
XDG_DATA_HOMEat the per-run scratch root the wrapper already creates and tears down. Each sandboxed worker gets a private store.Two load-bearing details:
auth.jsonby copying, never symlinking — a link resolves straight back to the shared file this exists to avoid, and the profile denies writes outsideSCRATCH. The copy is guarded, becauseset -euo pipefailwould otherwise make a missingauth.jsonfatal on machines that authenticate another way.--no-sandboxdeliberately keeps the shared store. That pathexecs, so the scratch dir'sEXITtrap would never fire and the root would leak on every full-access run. Now says so in a comment. Fine for a lone full-access task, not for a fan-out.The jitter stays, demoted in its comment from cure to secondary net — it still spreads the startup burst of provider calls. It can be deleted once a wide fan-out is observed clean; that's a one-line follow-up, deliberately not bundled here so a regression can't be ambiguous.
Verification
Against the real binary, not just the script text:
A sandboxed run exits 0 and leaves the shared DB's mtime unchanged.
New
tests/test_opencode_wrapper.pydrives the wrapper with a stubopencodeonPATHand asserts what the worker actually receives, not what the script appears to say: a private data home, two workers never sharing one, credentials seeded by copy and not symlink, and a missingauth.jsonnot aborting the run. macOS/sandbox-exec gated.Upstream: PR NateBJones-Projects#79 (unmerged). Its
OC_BASEprofile subpath is included — verified thatsandbox-execaccepts a subpath param for a path that doesn't exist, so it's safe on machines without~/.opencode.🤖 Generated with Claude Code