Skip to content

fix(provider-cursor): Cursor agents release their memory when a session closes - #99

Merged
alexcstark merged 1 commit into
mainfrom
stark/cursor-agent-store-lifecycle
Nov 4, 2026
Merged

alexcstark merged 1 commit into
mainfrom
stark/cursor-agent-store-lifecycle

Conversation

@alexcstark

Copy link
Copy Markdown
Owner

The lax6e server's memory grew from 0.6 GB to 6.8 GB over six days of uptime, almost all of it native heap ([heap] 5.4 GB; the V8 heap was 1.35 GB). Process spawns copy the server's page tables, so that growth made every git spawn about 10× slower and drove the event-loop stalls that left uploads and threads stuck.

Cause
The Cursor chat path (CursorAgentSdk.ts) passes no local.store. In that case the SDK opens a default SqliteLocalAgentStore per workspace and caches it for the life of the process (a module-level Map in @cursor/sdk 1.0.35, unchanged in 1.0.38). Every agent created or resumed through it keeps its checkpoint blobs in memory as Uint8Array copies. Resuming an agent loads all blobs it ever wrote. Only deleting the agent evicts them, and agent.close() doesn't. Text generation already avoided this by owning and disposing its store (pingdotgg#19310). The chat path didn't.

Evidence (lax6e, upstream 0.1.0, measured 2026-11-04)

  • The old server held 91 Cursor agent store.db handles open. Exactly 91 agent stores had been written since that server started, holding 1.83 GB of blob data.
  • Hourly RSS growth fits about 4.4 MB per Cursor run vs about 0.6 MB per Claude or Codex run (correlation 0.44 vs 0.22), over 69 intervals.
  • Offline reproduction with the real SDK 1.0.35: seed 3 agents with 100 MB of checkpoints each, then resume and close them.
    • Default store: arrayBuffers 315 MB, still retained after close and GC.
    • Owned store disposed on close: arrayBuffers 1 MB.

Fix
The runner opens a SqliteLocalAgentStore per session when the caller passes none, and uses it for the agent and its run lookups (listRuns/cancelRun, which would otherwise reopen the default store). It disposes the store when the session closes, or when opening the agent fails. SqliteLocalAgentStore.open({ workspaceRef: cwd }) resolves the same default state root the SDK uses, so existing agents still resume; the reproduction resumed seeded agents through it.

Proof

  • CursorAgentSdk.test.ts: the close-order cases now also expect the store to be passed to the SDK and disposed after agent.close, including when close fails. The resume-retry cases expect the store in run lookups. With origin/main's runner, 4 of 7 fail; with this change, 7/7 pass.
  • provider-cursor tests: 131/131. tsc-rs passes for provider-cursor and apps/server. vp fmt --check and vp lint pass on the changed files.
  • Not yet verified on lax6e: its new build (b603e5bdfb) predates this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv

…on closes

Without a store, the Cursor SDK opens one per workspace and caches it for the
life of the process. Every agent created or resumed through it keeps all of its
checkpoint blobs in memory, and agent.close() does not release them. On a
long-running server this grew the native heap by several GB.

The runner now opens a SqliteLocalAgentStore for each session, uses it for the
agent and its run lookups, and disposes it when the session closes or fails to
open. The store resolves the same default state root, so existing agents resume.

Claude-Session: https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful main measurement shown below.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 20.9 KiB 20.9 KiB 0 B (0.0%) 29.3 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.2 KiB 21.2 KiB 0 B (0.0%) 29.3 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: 99c96b0 · PR result: f96f083 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@alexcstark
alexcstark merged commit ba5a198 into main Nov 4, 2026
25 checks passed
@alexcstark
alexcstark deleted the stark/cursor-agent-store-lifecycle branch November 4, 2026 18:26
alexcstark added a commit that referenced this pull request Nov 4, 2026
…on closes (#99)

Without a store, the Cursor SDK opens one per workspace and caches it for the
life of the process. Every agent created or resumed through it keeps all of its
checkpoint blobs in memory, and agent.close() does not release them. On a
long-running server this grew the native heap by several GB.

The runner now opens a SqliteLocalAgentStore for each session, uses it for the
agent and its run lookups, and disposes it when the session closes or fails to
open. The store resolves the same default state root, so existing agents resume.

Claude-Session: https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant