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", () => ({...@@ -104,6 +104,7 @@ vi.mock("./sdk.ts", () => ({
104 },104 },
105 },105 },
106 createAgentPlatform: vi.fn(),106 createAgentPlatform: vi.fn(),
107 SqliteLocalAgentStore: { open: vi.fn() },
107}));108}));
108109
109const layerTest = CursorAgentSdk.layer.pipe(110const layerTest = CursorAgentSdk.layer.pipe(
packages/provider-cursor/src/server/CursorSdk.test.ts+1-1
...@@ -25,7 +25,7 @@ writeFileSync(...@@ -25,7 +25,7 @@ writeFileSync(
25);25);
26registerHooks({26registerHooks({
27 resolve(specifier, context, next) {27 resolve(specifier, context, next) {
28 if (specifier === "@cursor/sdk") {28 if (specifier === "@cursor/sdk" || specifier === "@cursor/sdk/sqlite") {
29 return { url: pathToFileURL(stub).href, shortCircuit: true };29 return { url: pathToFileURL(stub).href, shortCircuit: true };
30 }30 }
31 return next(specifier, context);31 return next(specifier, context);
packages/provider-cursor/src/server/CursorSdk.ts+3-2
...@@ -3,7 +3,7 @@ import * as Context from "effect/Context";...@@ -3,7 +3,7 @@ import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55
6import { Agent, createAgentPlatform } from "./sdk.ts";6import { Agent, createAgentPlatform, SqliteLocalAgentStore } from "./sdk.ts";
77
8/**8/**
9 * The Cursor Agent SDK runs in this process and spawns a shell for tool9 * The Cursor Agent SDK runs in this process and spawns a shell for tool
...@@ -65,6 +65,7 @@ export class CursorSdk extends Context.Service<...@@ -65,6 +65,7 @@ export class CursorSdk extends Context.Service<
65 {65 {
66 readonly Agent: typeof Agent;66 readonly Agent: typeof Agent;
67 readonly createAgentPlatform: typeof createAgentPlatform;67 readonly createAgentPlatform: typeof createAgentPlatform;
68 readonly SqliteLocalAgentStore: typeof SqliteLocalAgentStore;
68 }69 }
69>()("@t3tools/provider-cursor/server/CursorSdk") {}70>()("@t3tools/provider-cursor/server/CursorSdk") {}
7071
...@@ -79,5 +80,5 @@ export const layer = Layer.effect(...@@ -79,5 +80,5 @@ export const layer = Layer.effect(
79 Effect.sync(() => {80 Effect.sync(() => {
80 process.off("unhandledRejection", onUnhandledRejection);81 process.off("unhandledRejection", onUnhandledRejection);
81 }),82 }),
82 ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform }))),83 ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform, SqliteLocalAgentStore }))),
83);84);
packages/provider-cursor/src/server/sdk.ts+3
...@@ -12,3 +12,6 @@ export const {...@@ -12,3 +12,6 @@ export const {
12 CursorSdkError,12 CursorSdkError,
13 InMemoryCredentialStore,13 InMemoryCredentialStore,
14} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk");14} = 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 @@...@@ -1,8 +1,10 @@
1import type { RunResult } from "@cursor/sdk";1import type { RunResult, SdkCredentialStore, StoredSdkCredentials } from "@cursor/sdk";
2import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";2import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
3import { CursorSettings } from "../settings.ts";3import { CursorSettings } from "../settings.ts";
4import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
6import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";9import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
...@@ -11,6 +13,7 @@ import * as Schema from "effect/Schema";...@@ -11,6 +13,7 @@ import * as Schema from "effect/Schema";
11import { createModelSelection } from "@t3tools/shared/model";13import { createModelSelection } from "@t3tools/shared/model";
12import { beforeEach, vi } from "vite-plus/test";14import { beforeEach, vi } from "vite-plus/test";
1315
16import { makeCursorAuth } from "./auth.ts";
14import { makeCursorTextGeneration } from "./textGeneration.ts";17import { makeCursorTextGeneration } from "./textGeneration.ts";
15import * as CursorSdk from "./CursorSdk.ts";18import * as CursorSdk from "./CursorSdk.ts";
1619
...@@ -19,6 +22,8 @@ const cursorSdkMock = vi.hoisted(() => ({...@@ -19,6 +22,8 @@ const cursorSdkMock = vi.hoisted(() => ({
19 send: vi.fn<(prompt: string) => Promise<unknown>>(),22 send: vi.fn<(prompt: string) => Promise<unknown>>(),
20 close: vi.fn(),23 close: vi.fn(),
21 cancel: vi.fn(async () => {}),24 cancel: vi.fn(async () => {}),
25 storeOpen: vi.fn<(options: unknown) => Promise<unknown>>(),
26 storeDispose: vi.fn(async () => {}),
22 prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({27 prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({
23 id: "run-cursor-text-generation-test",28 id: "run-cursor-text-generation-test",
24 status: "finished",29 status: "finished",
...@@ -30,6 +35,7 @@ const cursorSdkMock = vi.hoisted(() => ({...@@ -30,6 +35,7 @@ const cursorSdkMock = vi.hoisted(() => ({
30vi.mock("./sdk.ts", () => ({35vi.mock("./sdk.ts", () => ({
31 Agent: { create: cursorSdkMock.create },36 Agent: { create: cursorSdkMock.create },
32 createAgentPlatform: vi.fn(),37 createAgentPlatform: vi.fn(),
38 SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen },
33}));39}));
3440
35let hasCustomPolicy = false;41let hasCustomPolicy = false;
...@@ -62,6 +68,9 @@ beforeEach(() => {...@@ -62,6 +68,9 @@ beforeEach(() => {
62 });68 });
63 cursorSdkMock.close.mockClear();69 cursorSdkMock.close.mockClear();
64 cursorSdkMock.cancel.mockClear();70 cursorSdkMock.cancel.mockClear();
71 cursorSdkMock.storeDispose.mockClear();
72 cursorSdkMock.storeOpen.mockReset();
73 cursorSdkMock.storeOpen.mockResolvedValue({ dispose: cursorSdkMock.storeDispose });
65 cursorSdkMock.prompt.mockReset();74 cursorSdkMock.prompt.mockReset();
66 cursorSdkMock.prompt.mockResolvedValue({75 cursorSdkMock.prompt.mockResolvedValue({
67 id: "run-cursor-text-generation-test",76 id: "run-cursor-text-generation-test",
...@@ -139,6 +148,7 @@ describe("CursorTextGeneration", () => {...@@ -139,6 +148,7 @@ describe("CursorTextGeneration", () => {
139 },148 },
140 local: {149 local: {
141 cwd: "/isolated-text-generation",150 cwd: "/isolated-text-generation",
151 store: { dispose: cursorSdkMock.storeDispose },
142 autoReview: false,152 autoReview: false,
143 sandboxOptions: { enabled: true },153 sandboxOptions: { enabled: true },
144 settingSources: [],154 settingSources: [],
...@@ -148,6 +158,93 @@ describe("CursorTextGeneration", () => {...@@ -148,6 +158,93 @@ describe("CursorTextGeneration", () => {
148 }).pipe(Effect.provide(layerTest)),158 }).pipe(Effect.provide(layerTest)),
149 );159 );
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
151 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>248 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>
152 Effect.gen(function* () {249 Effect.gen(function* () {
153 cursorSdkMock.create.mockImplementationOnce(async () => {250 cursorSdkMock.create.mockImplementationOnce(async () => {
...@@ -175,6 +272,7 @@ describe("CursorTextGeneration", () => {...@@ -175,6 +272,7 @@ describe("CursorTextGeneration", () => {
175 expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({272 expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({
176 local: {273 local: {
177 cwd: "/isolated-text-generation",274 cwd: "/isolated-text-generation",
275 store: { dispose: cursorSdkMock.storeDispose },
178 autoReview: false,276 autoReview: false,
179 sandboxOptions: { enabled: false },277 sandboxOptions: { enabled: false },
180 settingSources: [],278 settingSources: [],
packages/provider-cursor/src/server/textGeneration.ts+12-1
...@@ -58,7 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -58,7 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
58 withAccess?: CursorAuth["withAccess"],58 withAccess?: CursorAuth["withAccess"],
59) {59) {
60 const fs = yield* FileSystem.FileSystem;60 const fs = yield* FileSystem.FileSystem;
61 const { Agent } = yield* CursorSdk.CursorSdk;61 const { Agent, SqliteLocalAgentStore } = yield* CursorSdk.CursorSdk;
62 const resolvedEnvironment = environment ?? process.env;62 const resolvedEnvironment = environment ?? process.env;
6363
64 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>64 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
...@@ -98,12 +98,21 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -98,12 +98,21 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
98 });98 });
99 }99 }
100 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" });100 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 );
101 const agentOptions = {109 const agentOptions = {
102 apiKey,110 apiKey,
103 mode: "plan",111 mode: "plan",
104 model: cursorSdkModelSelection(modelSelection),112 model: cursorSdkModelSelection(modelSelection),
105 local: {113 local: {
106 cwd,114 cwd,
115 store,
107 autoReview: false,116 autoReview: false,
108 sandboxOptions: { enabled: true },117 sandboxOptions: { enabled: true },
109 settingSources: [],118 settingSources: [],
...@@ -184,6 +193,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -184,6 +193,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
184193
185 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);194 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);
186 }).pipe(195 }).pipe(
196 // Scope this call inside `withAccess`, so sign-out closes the agent before its store.
197 Effect.scoped,
187 (effect) => (withAccess ? withAccess(effect) : effect),198 (effect) => (withAccess ? withAccess(effect) : effect),
188 Effect.scoped,199 Effect.scoped,
189 Effect.mapError((cause) =>200 Effect.mapError((cause) =>