Repository navigation
Bound session-summary write amplification #46
Description
Proposal
Why
Live incident, 2026-07-26: every opencode-skein process on this machine — across
every project directory, since the session store is one SQLite file per build
channel shared by all of them — hung with zero forward progress and no surfaced
error. Four processes were pegged at 128-170% CPU for hours (one since the
previous Friday).
Root cause, traced to source: packages/opencode/src/session/processor.ts:744-749
calls SessionSummary.summarize() on every agent step, not once per turn.
summarize() (packages/opencode/src/session/summary.ts) recomputes the full
diff of every file changed so far in the turn — including unified-diff patch
text via snapshot.diffFull — writes it into the triggering user message's
summary.diffs field, and calls sessions.updateMessage(). The session store is
event-sourced (packages/core/src/database/sqlite.bun.ts, event table): every
updateMessage call publishes the entire message document as a new, permanent
row. Nothing ever supersedes or prunes a prior snapshot of the same message.
Measured live against the running fleet's shared DB: one session had 12,881
message.updated rows totaling 1.6 GB, the single largest row 20 MB — one user
message, republished in full on every step of every turn it was ever part of.
The event table alone accounted for roughly 10 GB of a 17 GB database file.
With PRAGMA busy_timeout unset (defaults to 0 — a lock conflict fails
immediately rather than waiting), five opencode processes contending for that one
file turned into sustained CPU-pegged threads parked in kernel lock/condvar waits
with no error ever reaching the user. Only local-provider sessions were affected;
the one cloud-provider session in the same incident kept working throughout,
which is consistent with local (build-agent, tool-heavy) sessions driving far
more steps — and therefore far more summarize() calls — per turn.
What Changes
busy_timeout = 5000on the shared SQLite connection
(packages/core/src/database/sqlite.bun.ts). A lock conflict now waits up to
5s and surfaces a real error instead of an unbounded silent stall. (Already
applied.)- Per-session reentrancy guard on
summarize()
(packages/opencode/src/session/summary.ts): a step whose call lands while a
previous one for the same session is still computing/persisting is skipped
rather than stacking up. This is the direct fix for the observed failure mode
— fast bursts of steps causing concurrent full-repo-diff computations and
writes to pile up and thrash the shared DB. (Already applied.) - Throttle the expensive path to once per 5s per session, even
single-threaded (packages/opencode/src/session/summary.ts): the cheap
reset (additions/deletions/filescounters, small session-level event)
still only fires when a full run is about to happen — no more resetting the
displayed diff to zero and leaving it there through a skipped call. The
expensive part (fullcomputeDiff+ patch text +updateMessage) is skipped
if the last full run for that session was under 5s ago. This bounds a long
turn's write volume by wall-clock time, not step count. (Already applied.) - Guaranteed final call at turn completion
(packages/opencode/src/session/prompt.ts,runLoop's primary exit at the
"no more tool calls" break):summarize()gains aforceflag that bypasses
the throttle (never the reentrancy guard). The turn-completion exit calls it
withforce: true, so the persisted diff is never left stale by a throttled
intermediate step. (Already applied.)
Non-Goals
- No pruning or compaction of historical
message.updatedevent rows.
revert.tsand session history plausibly depend on the event log being
complete; changing that is a distinct, higher-risk architectural change and
needs its own design and review — not bundled into an incident fix. The 17 GB
file existing today is not reduced by this change. - No
VACUUMof the existing database. Reclaiming the on-disk space needs
~17 GB of free temp disk and every opencode process stopped for the duration.
Operational, not code — a separate, explicitly-approved step. - No change to the compaction-triggered loop exit
(packages/opencode/src/session/prompt.ts, theresult === "stop"break from
compaction.process). That path is a rarer, already-anomalous exit; the next
normal turn completion (or the 5s throttle window) still catches up the diff
for it, at the same bounded staleness risk already accepted for any throttled
intermediate step. - No change to the
event/message/partschema or to what data is
considered worth persisting — only to how often the expensive persist runs.
Impact
packages/core/src/database/sqlite.bun.ts—busy_timeoutPRAGMA.packages/opencode/src/session/summary.ts— reentrancy guard, throttle,
forceflag.packages/opencode/src/session/prompt.ts— forced finalsummarize()call at
turn-completion exit.- No API, schema, or on-disk format changes. No migration.
Disposition (2026-09-18)
Shipped. Shipped: reentrancy set, 5 s throttle, busy_timeout, force at turn end. 4.1/4.2 verified 2026-09-18 (typecheck clean, test/session 445 pass).
Tasks
1. Stop concurrent overlap
- 1.1
packages/opencode/src/session/summary.ts: add a per-session
Set<SessionID>reentrancy guard aroundsummarize()'s body so a call
landing while a previous one is still in flight for the same session is
skipped rather than stacked. Verify: typechecks; a second call issued
before the first'sEffect.ensuringcleanup runs returns immediately
without entering the body.
2. Bound steady-state write frequency
- 2.1 Add a
force?: booleanfield tosummarize()'s input and a
per-sessionMap<SessionID, number>of last-full-run timestamps. Verify:
typechecks. - 2.2 Skip the expensive path (full
computeDiff+ patch text +
sessions.updateMessage) when the last full run for that session was
under 5s ago andforceis not set. Move the cheap
reset/setSummary/Session.Event.Diffpublish so it only runs alongside
an actual full run — a throttled call must be a pure no-op, not a
zero-out of the currently displayed diff. Verify: two calls 1s apart for
the same session produce only one full write; the displayed
additions/deletions/files do not flicker to zero on the skipped call. - 2.3
packages/opencode/src/session/prompt.ts: atrunLoop's
turn-completion exit (the "no more tool calls" break), call
summary.summarize({ sessionID, messageID: lastUser.id, force: true })
forked in the background. Verify: a turn whose last few steps landed
inside the throttle window still ends with an accurate, fully persisted
diff.
3. Connection-level safety net
- 3.1
packages/core/src/database/sqlite.bun.ts: set
PRAGMA busy_timeout = 5000on the native connection alongside the
existingjournal_mode = WAL. Verify: typechecks; a lock conflict now
waits up to 5s and fails with a catchable error rather than an unbounded
stall.
4. Verification
- 4.1
bun run --cwd packages/opencode typecheck,
bun run --cwd packages/core typecheck— zero errors. - 4.2
bun test packages/opencode/test/session --timeout 60000— no new
failures versus the pre-existing baseline (one known unrelated failure:
session.llm.stream > sends Google API payload for Gemini models, a mock
HTTP server issue unrelated to this change). - 4.3 Manual: run a
build-agent turn with several fast, tool-only steps
against a live provider. Confirm via the session DB (eventtable,
aggregate_id= the session) thatmessage.updatedrows for the
turn's user message land roughly once per 5s window plus one at turn
end, not once per step.
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Plan changes
5 done
Activity
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.