authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:14:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 01:14:50-07:00
logacb90279e2adad19a63586944543fe7346499467
tree01f0265c5924788eba8f3d4c5545336af540ba8d
parent4cd0076ec65d51974dbe3efdd03be6ed5bf91c80
signature Signed by PGP key B5690EEEBB952194

fix(provider-cursor): Cursor text generation no longer leaks SQLite file handles on every call (#19310)

Takeover of #18154. Per-call SqliteLocalAgentStore so Cursor text generation closes its SQLite handles, and the agent now closes before its store on sign-out. Fixes #18151. Co-authored-by: Aaron Queen <bompus@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

6 files changed, 119 insertions(+), 5 deletions(-)

packages/provider-cursor/src/server/CursorAgentSdk.test.ts+1
......@@ -104,6 +104,7 @@ vi.mock("./sdk.ts", () => ({
104104 },
105105 },
106106 createAgentPlatform: vi.fn(),
107 SqliteLocalAgentStore: { open: vi.fn() },
107108}));
108109
109110const layerTest = CursorAgentSdk.layer.pipe(
packages/provider-cursor/src/server/CursorSdk.test.ts+1-1
......@@ -25,7 +25,7 @@ writeFileSync(
2525);
2626registerHooks({
2727 resolve(specifier, context, next) {
28 if (specifier === "@cursor/sdk") {
28 if (specifier === "@cursor/sdk" || specifier === "@cursor/sdk/sqlite") {
2929 return { url: pathToFileURL(stub).href, shortCircuit: true };
3030 }
3131 return next(specifier, context);
packages/provider-cursor/src/server/CursorSdk.ts+3-2
......@@ -3,7 +3,7 @@ import * as Context from "effect/Context";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
6import { Agent, createAgentPlatform } from "./sdk.ts";
6import { Agent, createAgentPlatform, SqliteLocalAgentStore } from "./sdk.ts";
77
88/**
99 * The Cursor Agent SDK runs in this process and spawns a shell for tool
......@@ -65,6 +65,7 @@ export class CursorSdk extends Context.Service<
6565 {
6666 readonly Agent: typeof Agent;
6767 readonly createAgentPlatform: typeof createAgentPlatform;
68 readonly SqliteLocalAgentStore: typeof SqliteLocalAgentStore;
6869 }
6970>()("@t3tools/provider-cursor/server/CursorSdk") {}
7071
......@@ -79,5 +80,5 @@ export const layer = Layer.effect(
7980 Effect.sync(() => {
8081 process.off("unhandledRejection", onUnhandledRejection);
8182 }),
82 ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform }))),
83 ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform, SqliteLocalAgentStore }))),
8384);
packages/provider-cursor/src/server/sdk.ts+3
......@@ -12,3 +12,6 @@ export const {
1212 CursorSdkError,
1313 InMemoryCredentialStore,
1414} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk");
15export const { SqliteLocalAgentStore } = requireCursorSdk(
16 "@cursor/sdk/sqlite",
17) as typeof import("@cursor/sdk/sqlite");
packages/provider-cursor/src/server/textGeneration.test.ts+99-1
......@@ -1,8 +1,10 @@
1import type { RunResult } from "@cursor/sdk";
1import type { RunResult, SdkCredentialStore, StoredSdkCredentials } from "@cursor/sdk";
22import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
33import { CursorSettings } from "../settings.ts";
44import { describe, expect, it } from "@effect/vitest";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
56import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
68import * as FileSystem from "effect/FileSystem";
79import * as Fiber from "effect/Fiber";
810import * as Layer from "effect/Layer";
......@@ -11,6 +13,7 @@ import * as Schema from "effect/Schema";
1113import { createModelSelection } from "@t3tools/shared/model";
1214import { beforeEach, vi } from "vite-plus/test";
1315
16import { makeCursorAuth } from "./auth.ts";
1417import { makeCursorTextGeneration } from "./textGeneration.ts";
1518import * as CursorSdk from "./CursorSdk.ts";
1619
......@@ -19,6 +22,8 @@ const cursorSdkMock = vi.hoisted(() => ({
1922 send: vi.fn<(prompt: string) => Promise<unknown>>(),
2023 close: vi.fn(),
2124 cancel: vi.fn(async () => {}),
25 storeOpen: vi.fn<(options: unknown) => Promise<unknown>>(),
26 storeDispose: vi.fn(async () => {}),
2227 prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({
2328 id: "run-cursor-text-generation-test",
2429 status: "finished",
......@@ -30,6 +35,7 @@ const cursorSdkMock = vi.hoisted(() => ({
3035vi.mock("./sdk.ts", () => ({
3136 Agent: { create: cursorSdkMock.create },
3237 createAgentPlatform: vi.fn(),
38 SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen },
3339}));
3440
3541let hasCustomPolicy = false;
......@@ -62,6 +68,9 @@ beforeEach(() => {
6268 });
6369 cursorSdkMock.close.mockClear();
6470 cursorSdkMock.cancel.mockClear();
71 cursorSdkMock.storeDispose.mockClear();
72 cursorSdkMock.storeOpen.mockReset();
73 cursorSdkMock.storeOpen.mockResolvedValue({ dispose: cursorSdkMock.storeDispose });
6574 cursorSdkMock.prompt.mockReset();
6675 cursorSdkMock.prompt.mockResolvedValue({
6776 id: "run-cursor-text-generation-test",
......@@ -139,6 +148,7 @@ describe("CursorTextGeneration", () => {
139148 },
140149 local: {
141150 cwd: "/isolated-text-generation",
151 store: { dispose: cursorSdkMock.storeDispose },
142152 autoReview: false,
143153 sandboxOptions: { enabled: true },
144154 settingSources: [],
......@@ -148,6 +158,93 @@ describe("CursorTextGeneration", () => {
148158 }).pipe(Effect.provide(layerTest)),
149159 );
150160
161 it.effect("disposes the agent store after the agent, whether the run succeeds or fails", () =>
162 Effect.gen(function* () {
163 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
164 CURSOR_API_KEY: "test-cursor-key",
165 });
166 const input = {
167 cwd: process.cwd(),
168 branch: "feature/cursor-store",
169 stagedSummary: "M README.md",
170 stagedPatch: "diff --git a/README.md b/README.md",
171 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
172 };
173
174 yield* textGeneration.generateCommitMessage(input);
175 expect(cursorSdkMock.storeOpen).toHaveBeenCalledWith(
176 expect.objectContaining({ workspaceRef: "/isolated-text-generation" }),
177 );
178 expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(1);
179 expect(cursorSdkMock.close.mock.invocationCallOrder[0]).toBeLessThan(
180 cursorSdkMock.storeDispose.mock.invocationCallOrder[0]!,
181 );
182
183 cursorSdkMock.prompt.mockResolvedValueOnce({
184 id: "run-cursor-text-generation-test",
185 status: "error",
186 });
187 yield* Effect.flip(textGeneration.generateCommitMessage(input));
188 expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(2);
189 }).pipe(Effect.provide(layerTest)),
190 );
191
192 it.effect("closes the agent before its store when sign-out interrupts a run", () =>
193 Effect.gen(function* () {
194 let credentials: StoredSdkCredentials | undefined = {
195 version: 1,
196 backendUrl: "https://api2.cursor.sh",
197 apiKey: "browser-key",
198 createdAtMs: 0,
199 };
200 const store: SdkCredentialStore = {
201 load: async () => credentials,
202 save: async (next) => {
203 credentials = next;
204 },
205 clear: async () => {
206 credentials = undefined;
207 },
208 };
209 const auth = yield* makeCursorAuth({
210 instanceId: ProviderInstanceId.make("cursor"),
211 displayName: "Cursor",
212 enabled: true,
213 store,
214 onChanged: () => Effect.void,
215 });
216 const started = Promise.withResolvers<void>();
217 cursorSdkMock.prompt.mockImplementationOnce(() => {
218 started.resolve();
219 return new Promise(() => {});
220 });
221 const generation = yield* makeCursorTextGeneration(
222 cursorSettings,
223 {},
224 auth.requireApiKey,
225 auth.withAccess,
226 );
227 const request = yield* generation
228 .generateCommitMessage({
229 cwd: process.cwd(),
230 branch: "feature/cursor-sign-out",
231 stagedSummary: "M README.md",
232 stagedPatch: "diff --git a/README.md b/README.md",
233 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
234 })
235 .pipe(Effect.forkScoped);
236 yield* Effect.promise(() => started.promise);
237
238 yield* auth.controller.logout(Effect.void);
239
240 expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true);
241 expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(1);
242 expect(cursorSdkMock.close.mock.invocationCallOrder[0]).toBeLessThan(
243 cursorSdkMock.storeDispose.mock.invocationCallOrder[0]!,
244 );
245 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, NodeCrypto.layer))),
246 );
247
151248 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>
152249 Effect.gen(function* () {
153250 cursorSdkMock.create.mockImplementationOnce(async () => {
......@@ -175,6 +272,7 @@ describe("CursorTextGeneration", () => {
175272 expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({
176273 local: {
177274 cwd: "/isolated-text-generation",
275 store: { dispose: cursorSdkMock.storeDispose },
178276 autoReview: false,
179277 sandboxOptions: { enabled: false },
180278 settingSources: [],
packages/provider-cursor/src/server/textGeneration.ts+12-1
......@@ -58,7 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
5858 withAccess?: CursorAuth["withAccess"],
5959) {
6060 const fs = yield* FileSystem.FileSystem;
61 const { Agent } = yield* CursorSdk.CursorSdk;
61 const { Agent, SqliteLocalAgentStore } = yield* CursorSdk.CursorSdk;
6262 const resolvedEnvironment = environment ?? process.env;
6363
6464 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
......@@ -98,12 +98,21 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
9898 });
9999 }
100100 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" });
101 // The SDK keeps SQLite handles open after `agent.close()` and only releases
102 // them from a store the caller disposes. Its state lives outside `cwd` so the
103 // agent's workspace stays empty.
104 const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-state-" });
105 const store = yield* Effect.acquireRelease(
106 Effect.tryPromise(() => SqliteLocalAgentStore.open({ workspaceRef: cwd, stateRoot })),
107 (store) => Effect.tryPromise(() => store.dispose()).pipe(Effect.ignore({ log: true })),
108 );
101109 const agentOptions = {
102110 apiKey,
103111 mode: "plan",
104112 model: cursorSdkModelSelection(modelSelection),
105113 local: {
106114 cwd,
115 store,
107116 autoReview: false,
108117 sandboxOptions: { enabled: true },
109118 settingSources: [],
......@@ -184,6 +193,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
184193
185194 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);
186195 }).pipe(
196 // Scope this call inside `withAccess`, so sign-out closes the agent before its store.
197 Effect.scoped,
187198 (effect) => (withAccess ? withAccess(effect) : effect),
188199 Effect.scoped,
189200 Effect.mapError((cause) =>