Skip to content

fix(model): stop opencode auto-compacting Cursor sessions - #92

Merged
justin-carper merged 1 commit into
mainfrom
fix/disable-auto-compaction
Aug 4, 2026
Merged

fix(model): stop opencode auto-compacting Cursor sessions#92
justin-carper merged 1 commit into
mainfrom
fix/disable-auto-compaction

Conversation

@justin-carper

Copy link
Copy Markdown
Collaborator

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.

Tool call not allowed while generating summary: cursor_context-mode_ctx_search

opencode's compaction turn declares zero tools. The Cursor agent runs its own tools regardless, and opencode's SessionProcessor throws 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. classifyTurn correctly reports a divergence, and a fresh Cursor agent is created. Each distinct agentId opens its own SQLite store:

~/.cursor/projects/<p>/sdk-agent-store/<h>/agents/agent-<id>/store.db  (+ -wal, -shm)

Those are never released. SDKAgent.close() is only:

close(){ this.awaitPendingPrAttributions().finally(()=>{(0,ne.rd)()}), this.releaseExecutorLease() }

— an analytics flush plus an executor-lease release. The checkpoint store lives in an agentId-keyed map evicted solely by dispose() / deleteAgent(), neither of which close() calls.

Apple's SQLite opens those files with guarded_open_np(GUARD_CLOSE). Once enough accumulate, a stale close() elsewhere in the process lands on a recycled descriptor number now owned by SQLite, and the kernel SIGKILLs opencode with an uncatchable EXC_GUARD (guard cookie 0x08fd4dbfade2dead — 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/sdk dist/esm/357.js:

case "preCompact": {
  trigger: "manual" === t.trigger ? "manual" : "auto",
  context_usage_percent, context_tokens, context_window_size,
  message_count, messages_to_compact, is_first_compaction

So the agent has both an automatic threshold trigger and the manual one the Cursor CLI's /compact uses. The SDK exposes no way to invoke it (RunOperation is stream | wait | cancel | conversation) and deliberately withholds the resulting summary-* updates from onDelta — but it runs regardless.

The fix

opencode's trigger:

function vl(e){ if (e.cfg.compaction?.auto === false) return false;
                if (e.model.limit.context === 0)      return false;
                return tokens >= Is(e) }
function Is(e){ ...
  return e.model.limit.input ? Math.max(0, e.model.limit.input - reserved)
                             : Math.max(0, limit.context - maxOutput) }

cfg.compaction.auto is session-global with no provider scope, so it isn't usable here. Emit a large limit.input instead (1_000_000_000, threshold ⇒ 999,980,000 — unreachable), leaving limit.context honest 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.input being undeclared

opencode'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/sdk config types omit it in every version through 1.18.11. So it is excluded from _limitKeyGuard (which still protects context/output) and gated instead by a new integration-test assertion. Without that gate, opencode dropping support would be silent: tsc green, unit suite green, auto-compaction quietly back along with the fd leak.

The gate is mutation-verified — it exits 1 with a diagnostic when emission is removed, 0 otherwise.

Verification

Check Result
npx tsc --noEmit clean
npx vitest run 513 passed (36 files; 507 on main)
npm run build success
scripts/integration-test.sh exit 0, all gates PASS

The integration test runs a real opencode 1.18.11 against the packed plugin under an isolated HOME, and confirms the emitted limit: { context: 300000, input: 1000000000, output: 64000 } survives config validation into Provider.list().

Every new assertion was mutation-checked, including the option wiring: existingOptions["autoCompaction"] is a string-key lookup that tsc cannot protect, so test/plugin-auto-compaction.test.ts drives the real config hook — renaming the key to autoCompactionX fails the suite.

Method note: the sentinel's safety was established by enumerating the call sites of Is(), not by grepping limit.input, because consumers reach it transitively. The only other consumer, Pd() (preserve-recent-tokens, also used by manual /compact), clamps to 8000 both before and after the change.

Tradeoff — please read before merging

This suppresses the proactive threshold trigger only. opencode's reactive ContextOverflowError path 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 /compact is 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 preCompact hook demonstrably exists with trigger: "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 main and does not include #91. #91 makes tool parts safe on any no-tools turn, which still matters because manual /compact is unaffected by this change. Merge order doesn't matter.

Not included

  • No change to classification, session pooling, transport, or the tool-display path.
  • The dead summary-*compaction event mapping is left in place. It is unreachable (the SDK withholds those updates from onDelta), 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.

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
justin-carper force-pushed the fix/disable-auto-compaction branch from 0e6ed22 to 3ebd601 Compare August 4, 2026 12:45
@justin-carper
justin-carper merged commit 27d11bb into main Aug 4, 2026
8 checks passed
@justin-carper
justin-carper deleted the fix/disable-auto-compaction branch August 4, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant