fix(model): stop opencode auto-compacting Cursor sessions - #92
Merged
Conversation
The Cursor agent runtime compacts its own conversation as it approaches
its context threshold (`preCompact` hook, `trigger: "auto"`), so a second
opencode-driven pass is redundant. It is also actively harmful, in two
ways.
The compaction turn asks the model to summarize with zero tools declared.
The Cursor agent runs its own tools regardless, and opencode rejects the
result outright:
Tool call not allowed while generating summary: <tool>
Worse, compaction rewrites the transcript, so the next turn no longer
matches what the Cursor agent saw. `classifyTurn` correctly reports a
divergence and a fresh Cursor agent is created — and every distinct
agentId permanently holds a guarded SQLite store.db/-wal/-shm triple.
`SDKAgent.close()` cannot release those; it only flushes analytics and
releases the executor lease, while the checkpoint store is cached in an
agentId-keyed map evicted solely by dispose()/deleteAgent(). That
descriptor growth fed an uncatchable EXC_GUARD kill of the whole opencode
process (guard cookie 0x08fd4dbfade2dead — Apple's SQLite guard).
Suppress the trigger with a large `limit.input`, which is the value
opencode uses as its compaction threshold:
Is(e) = limit.input ? limit.input - reserved
: limit.context - maxOutput
`limit.context` is left honest, so the TUI context gauge and cost
reporting keep working — the alternative lever, `limit.context: 0`, would
disable the trigger but blank the gauge and regress #89.
`limit.input` is honored by opencode's runtime but is not declared in the
published @opencode-ai/sdk config types, so it is excluded from
`_limitKeyGuard` (which still protects context/output) and gated instead
by a new assertion in the integration test: without it, opencode dropping
support would silently restore auto-compaction and the fd leak.
Verified against the opencode 1.18.11 binary by enumerating the call
sites of Is() rather than textual hits on limit.input, since consumers
reach it transitively. The only other consumer, Pd() (preserve-recent
tokens, also used by manual /compact), clamps to 8000 both before and
after. Confirmed end-to-end under an isolated HOME that the sentinel
survives config validation into Provider.list() with limit.context
intact.
Manual /compact is unaffected. Opt back out with
`provider.cursor.options.autoCompaction: true`.
Tradeoff, documented in the README: this suppresses the proactive
threshold only, and opencode has no reactive context-overflow recovery
wired up for this provider, so its transcript is no longer trimmed
automatically. Ordinary turns send just the new message, but a cold
replay resends everything; if that overflows, the turn fails and
/compact is the manual recovery.
justin-carper
force-pushed
the
fix/disable-auto-compaction
branch
from
August 4, 2026 12:45
0e6ed22 to
3ebd601
Compare
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.
Problem
Two failures, one root: opencode runs its own compaction pass on Cursor sessions, and the Cursor agent already compacts itself.
1. The summary turn is rejected outright.
opencode's compaction turn declares zero tools. The Cursor agent runs its own tools regardless, and opencode's
SessionProcessorthrows on the first one.2. Every compaction permanently leaks guarded SQLite descriptors — and eventually kills the process.
Compaction rewrites the transcript, so the next turn no longer matches what the Cursor agent saw.
classifyTurncorrectly reports adivergence, and a fresh Cursor agent is created. Each distinctagentIdopens its own SQLite store:Those are never released.
SDKAgent.close()is only:— an analytics flush plus an executor-lease release. The checkpoint store lives in an
agentId-keyed map evicted solely bydispose()/deleteAgent(), neither of whichclose()calls.Apple's SQLite opens those files with
guarded_open_np(GUARD_CLOSE). Once enough accumulate, a staleclose()elsewhere in the process lands on a recycled descriptor number now owned by SQLite, and the kernel SIGKILLs opencode with an uncatchableEXC_GUARD(guard cookie0x08fd4dbfade2dead— Apple-documented as SQLite's). Observed 3×, each following a compaction → continue sequence; live processes were holding 3 and 7 distinct agent stores open at once.Why opencode's pass is redundant
The Cursor runtime self-compacts. From
@cursor/sdkdist/esm/357.js:So the agent has both an automatic threshold trigger and the manual one the Cursor CLI's
/compactuses. The SDK exposes no way to invoke it (RunOperationisstream | wait | cancel | conversation) and deliberately withholds the resultingsummary-*updates fromonDelta— but it runs regardless.The fix
opencode's trigger:
cfg.compaction.autois session-global with no provider scope, so it isn't usable here. Emit a largelimit.inputinstead (1_000_000_000, threshold ⇒ 999,980,000 — unreachable), leavinglimit.contexthonest so the TUI gauge and cost reporting keep working.The alternative lever,
limit.context: 0, also disables the trigger but blanks the context gauge — regressing #89. Rejected for that reason.Opt back out per-provider:
{ "provider": { "cursor": { "options": { "autoCompaction": true } } } }On
limit.inputbeing undeclaredopencode's runtime honors it — its Model schema is
q.Struct({ context: q.Finite, input: p$(q.Finite), output: q.Finite })— but the published@opencode-ai/sdkconfig types omit it in every version through 1.18.11. So it is excluded from_limitKeyGuard(which still protectscontext/output) and gated instead by a new integration-test assertion. Without that gate, opencode dropping support would be silent:tscgreen, unit suite green, auto-compaction quietly back along with the fd leak.The gate is mutation-verified — it exits
1with a diagnostic when emission is removed,0otherwise.Verification
npx tsc --noEmitnpx vitest runnpm run buildscripts/integration-test.shThe integration test runs a real opencode 1.18.11 against the packed plugin under an isolated
HOME, and confirms the emittedlimit: { context: 300000, input: 1000000000, output: 64000 }survives config validation intoProvider.list().Every new assertion was mutation-checked, including the option wiring:
existingOptions["autoCompaction"]is a string-key lookup thattsccannot protect, sotest/plugin-auto-compaction.test.tsdrives the realconfighook — renaming the key toautoCompactionXfails the suite.Method note: the sentinel's safety was established by enumerating the call sites of
Is(), not by greppinglimit.input, because consumers reach it transitively. The only other consumer,Pd()(preserve-recent-tokens, also used by manual/compact), clamps to8000both before and after the change.Tradeoff — please read before merging
This suppresses the proactive threshold trigger only. opencode's reactive
ContextOverflowErrorpath stays armed but is unreachable for this provider, because nothing maps Cursor's overflow error into it.Consequence: opencode's transcript is no longer trimmed automatically. Ordinary turns send only the new message to a running agent, but a cold replay — new session, expired agent, changed MCP set — resends everything. If that ever overflows the model, the turn fails with a provider error and
/compactis the manual recovery.Documented in the README's new Compaction section rather than papered over. Wiring Cursor's overflow error into the reactive path is the natural follow-up, deliberately left out of scope.
Also worth stating plainly: "the Cursor agent self-compacts reliably" is load-bearing here and only partly verified. The
preCompacthook demonstrably exists withtrigger: "auto"; that it fires soon enough to keep every payload under the model limit is not something this PR proves.Relationship to #91
Independent and complementary — this branch is cut from
mainand does not include #91. #91 makes tool parts safe on any no-tools turn, which still matters because manual/compactis unaffected by this change. Merge order doesn't matter.Not included
summary-*→compactionevent mapping is left in place. It is unreachable (the SDK withholds those updates fromonDelta), but removing the producer orphans ~8 consumer sites and a test that hand-injects the event — worth its own change rather than a half-removal here.