@stigmer/runner 3.15.2 → 3.15.3-dev.20260914121148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/__test-utils__/contract-subject.js +12 -9
  3. package/dist/activities/execute-cursor/__test-utils__/contract-subject.js.map +1 -1
  4. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +5 -2
  5. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +5 -2
  6. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
  7. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +11 -27
  8. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +13 -10
  9. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -1
  10. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +70 -26
  11. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +82 -32
  12. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -1
  13. package/dist/activities/execute-cursor/adapter.d.ts +4 -3
  14. package/dist/activities/execute-cursor/adapter.js +9 -5
  15. package/dist/activities/execute-cursor/adapter.js.map +1 -1
  16. package/dist/activities/execute-cursor/sdk-warmup.d.ts +15 -6
  17. package/dist/activities/execute-cursor/sdk-warmup.js +15 -6
  18. package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -1
  19. package/dist/activities/execute-cursor/session-lifecycle.d.ts +38 -59
  20. package/dist/activities/execute-cursor/session-lifecycle.js +59 -96
  21. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  22. package/dist/activities/execute-cursor/session-store.d.ts +100 -0
  23. package/dist/activities/execute-cursor/session-store.js +157 -0
  24. package/dist/activities/execute-cursor/session-store.js.map +1 -0
  25. package/dist/shared/attachment-vision.d.ts +2 -1
  26. package/dist/shared/attachment-vision.js.map +1 -1
  27. package/package.json +5 -5
  28. package/src/activities/execute-cursor/__test-utils__/__tests__/scripted-double.test.ts +31 -7
  29. package/src/activities/execute-cursor/__test-utils__/contract-subject.ts +10 -8
  30. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +5 -2
  31. package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +20 -28
  32. package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +94 -38
  33. package/src/activities/execute-cursor/__tests__/cursor-baseurl-routing.test.ts +10 -8
  34. package/src/activities/execute-cursor/__tests__/cursor-fetch-interceptor-bypass.test.ts +5 -4
  35. package/src/activities/execute-cursor/__tests__/cursor-generate-image-live.test.ts +7 -2
  36. package/src/activities/execute-cursor/__tests__/cursor-hook-protocol-live.test.ts +163 -0
  37. package/src/activities/execute-cursor/__tests__/cursor-sdk-auth-smoke.test.ts +13 -12
  38. package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +4 -1
  39. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +4 -1
  40. package/src/activities/execute-cursor/__tests__/hermetic/harness-contract.test.ts +3 -0
  41. package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +3 -0
  42. package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +4 -1
  43. package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +4 -1
  44. package/src/activities/execute-cursor/__tests__/hermetic/run-wait-arms.test.ts +4 -1
  45. package/src/activities/execute-cursor/__tests__/hermetic/stream-self-stop-arms.test.ts +4 -1
  46. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +3 -0
  47. package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +4 -1
  48. package/src/activities/execute-cursor/__tests__/hermetic/unattributed-hook-block.test.ts +4 -1
  49. package/src/activities/execute-cursor/__tests__/hermetic/workspace-lock-timeout.test.ts +3 -0
  50. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +86 -58
  51. package/src/activities/execute-cursor/__tests__/session-store.test.ts +220 -0
  52. package/src/activities/execute-cursor/adapter.ts +9 -5
  53. package/src/activities/execute-cursor/sdk-warmup.ts +15 -6
  54. package/src/activities/execute-cursor/session-lifecycle.ts +63 -109
  55. package/src/activities/execute-cursor/session-store.ts +187 -0
  56. package/src/shared/attachment-vision.ts +2 -1
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Unit tests for `session-store.ts`: where a session's Cursor SDK store lives,
3
+ * and the lifetime the runner owns for it (stigmer/stigmer#1053).
4
+ *
5
+ * The location rules are correctness-critical: the state root is keyed by
6
+ * sessionId and rooted at the durable workspace volume so native
7
+ * Agent.resume() survives restart/snapshot-restore and so sessions that share
8
+ * one volume (a workflow sandbox's child agent executions) never collide.
9
+ *
10
+ * The lifetime rules are what a revert of the adapter's release hooks would
11
+ * break: one open per session however many activities ask, the SAME instance
12
+ * on every ask, a release that disposes and forgets so the next ask reopens, a
13
+ * failed open that is not remembered, and a re-acquire under a different root
14
+ * that releases the store it held. `SessionStores` takes the opener, so these
15
+ * run against a recording fake and no SQLite file.
16
+ */
17
+
18
+ import { describe, it, expect, afterEach } from "vitest";
19
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { SessionStores, resolveSessionStoreLocation, type ReleasableStore, type SessionStoreLocation } from "../session-store.js";
24
+
25
+ const tempRoots: string[] = [];
26
+
27
+ function freshWorkspaceRoot(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "stigmer-session-store-test-"));
29
+ tempRoots.push(dir);
30
+ return dir;
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const dir of tempRoots.splice(0)) {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ describe("resolveSessionStoreLocation", () => {
40
+ it("derives the state root under the workspace volume (not $HOME), created eagerly", () => {
41
+ const workspaceRootDir = freshWorkspaceRoot();
42
+ const location = resolveSessionStoreLocation("ses-123", workspaceRootDir);
43
+
44
+ expect(location.stateRoot).toBe(join(workspaceRootDir, ".stigmer", "cursor-sdk-state", "ses-123"));
45
+ expect(location.stateRoot.startsWith(workspaceRootDir)).toBe(true);
46
+ // Created eagerly to prevent ENOENT on the SDK's first write.
47
+ expect(existsSync(location.stateRoot)).toBe(true);
48
+ expect(location.workspaceRef).toBe("stigmer-session:ses-123");
49
+ });
50
+
51
+ it("isolates two sessions sharing one workspace volume into distinct stores", () => {
52
+ const workspaceRootDir = freshWorkspaceRoot();
53
+ const a = resolveSessionStoreLocation("ses-aaa", workspaceRootDir);
54
+ const b = resolveSessionStoreLocation("ses-bbb", workspaceRootDir);
55
+
56
+ expect(a.stateRoot).not.toBe(b.stateRoot);
57
+ expect(a.workspaceRef).not.toBe(b.workspaceRef);
58
+ expect(a.stateRoot.startsWith(join(workspaceRootDir, ".stigmer"))).toBe(true);
59
+ expect(b.stateRoot.startsWith(join(workspaceRootDir, ".stigmer"))).toBe(true);
60
+ });
61
+
62
+ it("throws on an empty sessionId (would collide across sessions on a shared volume)", () => {
63
+ const workspaceRootDir = freshWorkspaceRoot();
64
+ expect(() => resolveSessionStoreLocation("", workspaceRootDir)).toThrow(/sessionId is required/);
65
+ });
66
+
67
+ it("throws on an empty workspaceRootDir (state must live on the durable volume)", () => {
68
+ expect(() => resolveSessionStoreLocation("ses-123", "")).toThrow(/workspaceRootDir is required/);
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // The lifetime
74
+ // ---------------------------------------------------------------------------
75
+
76
+ class FakeStore implements ReleasableStore {
77
+ disposeCalls = 0;
78
+ constructor(readonly location: SessionStoreLocation) {}
79
+ get stateRoot(): string {
80
+ return this.location.stateRoot;
81
+ }
82
+ async dispose(): Promise<void> {
83
+ this.disposeCalls++;
84
+ }
85
+ }
86
+
87
+ /** A recording opener; `failNext` makes the next open reject once. */
88
+ function recordingOpener() {
89
+ const opened: FakeStore[] = [];
90
+ let failNext: Error | undefined;
91
+ const open = async (location: SessionStoreLocation): Promise<FakeStore> => {
92
+ if (failNext) {
93
+ const err = failNext;
94
+ failNext = undefined;
95
+ throw err;
96
+ }
97
+ const store = new FakeStore(location);
98
+ opened.push(store);
99
+ return store;
100
+ };
101
+ return { open, opened, fail: (err: Error) => (failNext = err) };
102
+ }
103
+
104
+ describe("SessionStores", () => {
105
+ it("opens once per session and hands the same instance to every later acquire, concurrent ones included", async () => {
106
+ const workspaceRootDir = freshWorkspaceRoot();
107
+ const { open, opened } = recordingOpener();
108
+ const stores = new SessionStores(open);
109
+
110
+ // Two activities racing on one session (the agent cache's documented race).
111
+ const [a, b] = await Promise.all([
112
+ stores.acquire("ses-1", workspaceRootDir),
113
+ stores.acquire("ses-1", workspaceRootDir),
114
+ ]);
115
+ const c = await stores.acquire("ses-1", workspaceRootDir);
116
+
117
+ expect(opened).toHaveLength(1);
118
+ expect(a).toBe(b);
119
+ expect(b).toBe(c);
120
+ expect(a.location.workspaceRef).toBe("stigmer-session:ses-1");
121
+ expect(stores.size).toBe(1);
122
+ });
123
+
124
+ it("keeps one store per session, not one per process", async () => {
125
+ const workspaceRootDir = freshWorkspaceRoot();
126
+ const { open, opened } = recordingOpener();
127
+ const stores = new SessionStores(open);
128
+
129
+ const a = await stores.acquire("ses-a", workspaceRootDir);
130
+ const b = await stores.acquire("ses-b", workspaceRootDir);
131
+
132
+ expect(opened).toHaveLength(2);
133
+ expect(a).not.toBe(b);
134
+ expect(stores.size).toBe(2);
135
+ });
136
+
137
+ it("release disposes the session's store and forgets it, so a later acquire reopens", async () => {
138
+ const workspaceRootDir = freshWorkspaceRoot();
139
+ const { open, opened } = recordingOpener();
140
+ const stores = new SessionStores(open);
141
+
142
+ const first = await stores.acquire("ses-1", workspaceRootDir);
143
+ await stores.release("ses-1");
144
+
145
+ expect(first.disposeCalls).toBe(1);
146
+ expect(stores.size).toBe(0);
147
+
148
+ const second = await stores.acquire("ses-1", workspaceRootDir);
149
+ expect(second).not.toBe(first);
150
+ expect(opened).toHaveLength(2);
151
+ });
152
+
153
+ it("release of a session with no store is a no-op", async () => {
154
+ const { open, opened } = recordingOpener();
155
+ const stores = new SessionStores(open);
156
+
157
+ await expect(stores.release("ses-unknown")).resolves.toBeUndefined();
158
+ expect(opened).toHaveLength(0);
159
+ });
160
+
161
+ it("releaseAll disposes every held store and empties the map (worker shutdown)", async () => {
162
+ const workspaceRootDir = freshWorkspaceRoot();
163
+ const { open, opened } = recordingOpener();
164
+ const stores = new SessionStores(open);
165
+
166
+ await stores.acquire("ses-a", workspaceRootDir);
167
+ await stores.acquire("ses-b", workspaceRootDir);
168
+ await stores.releaseAll();
169
+
170
+ expect(opened.map((s) => s.disposeCalls)).toEqual([1, 1]);
171
+ expect(stores.size).toBe(0);
172
+ });
173
+
174
+ it("does not remember a failed open: the next acquire retries", async () => {
175
+ const workspaceRootDir = freshWorkspaceRoot();
176
+ const { open, opened, fail } = recordingOpener();
177
+ const stores = new SessionStores(open);
178
+
179
+ fail(new Error("disk full"));
180
+ await expect(stores.acquire("ses-1", workspaceRootDir)).rejects.toThrow("disk full");
181
+ expect(stores.size).toBe(0);
182
+
183
+ const store = await stores.acquire("ses-1", workspaceRootDir);
184
+ expect(opened).toEqual([store]);
185
+ expect(stores.size).toBe(1);
186
+ });
187
+
188
+ it("a re-acquire under a different workspace volume releases the held store and opens the new one", async () => {
189
+ const rootA = freshWorkspaceRoot();
190
+ const rootB = freshWorkspaceRoot();
191
+ const { open, opened } = recordingOpener();
192
+ const stores = new SessionStores(open);
193
+
194
+ const onA = await stores.acquire("ses-1", rootA);
195
+ const onB = await stores.acquire("ses-1", rootB);
196
+
197
+ // The handle for the old binding can never serve the new one (the same
198
+ // rule the agent cache applies to a fingerprint mismatch).
199
+ expect(onB).not.toBe(onA);
200
+ expect(onA.disposeCalls).toBe(1);
201
+ expect(onB.stateRoot.startsWith(rootB)).toBe(true);
202
+ expect(opened).toHaveLength(2);
203
+ expect(stores.size).toBe(1);
204
+ });
205
+
206
+ it("a dispose that throws is swallowed: the store is forgotten either way", async () => {
207
+ const workspaceRootDir = freshWorkspaceRoot();
208
+ const stores = new SessionStores(async (location) => {
209
+ const store = new FakeStore(location);
210
+ store.dispose = async () => {
211
+ throw new Error("handle already closed");
212
+ };
213
+ return store;
214
+ });
215
+
216
+ await stores.acquire("ses-1", workspaceRootDir);
217
+ await expect(stores.release("ses-1")).resolves.toBeUndefined();
218
+ expect(stores.size).toBe(0);
219
+ });
220
+ });
@@ -5,9 +5,10 @@
5
5
  * module boots, releases, and delegates.
6
6
  *
7
7
  * What this adapter owns: the proxy interceptors and the parked-agent cache
8
- * (worker lifetime), the parked agent per session (session lifetime), and one
9
- * engine turn (`runTurn`, through `turn.ts`). Everything else about a turn is
10
- * the runtime's (`harness/run-turn.ts`).
8
+ * (worker lifetime), the parked agent and the SDK store per session (session
9
+ * lifetime; `agent-session-cache.ts`, `session-store.ts`), and one engine turn
10
+ * (`runTurn`, through `turn.ts`). Everything else about a turn is the
11
+ * runtime's (`harness/run-turn.ts`).
11
12
  *
12
13
  * THIS MODULE'S STATIC GRAPH IS SDK-FREE, ON PURPOSE. The composition roots
13
14
  * import it (through `src/harness-adapters.ts`) BEFORE they boot it, and
@@ -57,6 +58,7 @@ import { closeAllCachedAgents, evictSessionAgent } from "./agent-session-cache.j
57
58
  import { CURSOR_CAPABILITIES } from "./cursor-capabilities.js";
58
59
  import { installFetchInterceptor } from "./fetch-interceptor.js";
59
60
  import { assertHttp2ConnectPatched, installHttp2Interceptor } from "./http2-interceptor.js";
61
+ import { releaseAllSessionStores, releaseSessionStore } from "./session-store.js";
60
62
  import type { CursorAdapterConfig } from "./turn-setup.js";
61
63
 
62
64
  /** The config slice this adapter reads per turn, taken at `boot`; a whole `Config` satisfies it. */
@@ -120,14 +122,16 @@ export function createCursorAdapter(): HarnessAdapter {
120
122
  booted = { config: resolveCursorConfig(bootConfig), runCursorTurn };
121
123
  },
122
124
 
123
- /** Close every parked agent (their executor leases and MCP subprocesses). */
125
+ /** Close every parked agent (their executor leases and MCP subprocesses), then every session's SDK store. */
124
126
  async shutdown(): Promise<void> {
125
127
  closeAllCachedAgents();
128
+ await releaseAllSessionStores();
126
129
  },
127
130
 
128
- /** The session is done on this host: close the agent parked for it, if any (#215). */
131
+ /** The session is done on this host: close the agent parked for it, if any (#215), then its SDK store (#1053). */
129
132
  async releaseSession(sessionId: string): Promise<void> {
130
133
  evictSessionAgent(sessionId);
134
+ await releaseSessionStore(sessionId);
131
135
  },
132
136
 
133
137
  async runTurn(input: TurnInput, sink: TurnSink): Promise<TurnOutcome> {
@@ -3,10 +3,19 @@
3
3
  *
4
4
  * The first `Agent.create` in a pod pays the SDK's platform construction:
5
5
  * four SQLite-backed stores (run/checkpoint/event stores + notifier) whose
6
- * first use loads the sqlite native binding and compiles the schema
7
- * migrations. The 2026-07-30 instrumented baseline (issue #209) measured
8
- * this at ~1.2s of the ~2.4s `resolve_agent` segment, with a 624ms floor —
9
- * roughly half the cost is per-process warm-up, not per-session work.
6
+ * first use compiles the schema migrations. The 2026-07-30 instrumented
7
+ * baseline (issue #209, @cursor/sdk 1.0.13) measured this at ~1.2s of the
8
+ * ~2.4s `resolve_agent` segment, with a 624ms floor — roughly half the cost
9
+ * was per-process warm-up, not per-session work. Part of that floor was the
10
+ * `sqlite3` native binding's load; since 1.0.31 (stigmer/stigmer#1053) the
11
+ * SDK's stores run on `node:sqlite`, which is inside the Node binary, and the
12
+ * session's store is opened by the runner itself (`session-store.ts`,
13
+ * `SqliteLocalAgentStore.open`) rather than through the platform this
14
+ * warm-up constructs. What this warm-up still saves at 1.0.31 is therefore a
15
+ * measurement, not a known quantity: its `durationMs` and the cold-start
16
+ * timeline answer it, and the harness program carries the question of
17
+ * whether to warm through the store the runner actually opens, or through
18
+ * the SDK's own `prewarmLocalWorkspace`, or not at all.
10
19
  *
11
20
  * Pool members idle between boot and claim, so constructing one throwaway
12
21
  * platform there moves that per-process cost off the user-visible path.
@@ -32,8 +41,8 @@ export interface SdkWarmupResult {
32
41
 
33
42
  /**
34
43
  * Construct (and abandon) one SDK agent platform on a throwaway temp-dir
35
- * state root, forcing the sqlite native binding + store schema work to
36
- * happen now instead of inside the first user-facing `Agent.create`.
44
+ * state root, forcing the store schema work to happen now instead of inside
45
+ * the first user-facing `Agent.create`.
37
46
  *
38
47
  * Total by construction — warm-up is an optimization and must never affect
39
48
  * the member's ability to serve. The throwaway state root is a few KB on
@@ -3,59 +3,63 @@
3
3
  *
4
4
  * SessionSpec.harness_state_id stores the Cursor agentId. This module handles
5
5
  * creating new agents (first execution), resuming existing agents
6
- * (subsequent executions), graceful fallback on resume failure, and
7
- * cleaning up agents (session deletion).
6
+ * (subsequent executions) and graceful fallback on resume failure.
8
7
  *
9
8
  * Two execution modes:
10
9
  *
11
- * - Local mode: Agent.create({ local: { cwd } }) with explicit
12
- * platform.workspaceRef/stateRoot for deterministic store keying.
13
- * Produces agent- prefixed IDs.
10
+ * - Local mode: Agent.create({ local: { cwd, dirs, store, ... } }) over the
11
+ * session's own SQLite store (`session-store.ts`). Produces agent- prefixed
12
+ * IDs.
14
13
  *
15
14
  * - Cloud mode (feature-flagged): Agent.create({ cloud: { repos } })
16
- * for git-backed workspaces. Produces bc- prefixed IDs. No platform
17
- * options — cloud state lives on Cursor's servers, not local SQLite.
15
+ * for git-backed workspaces. Produces bc- prefixed IDs. No store — cloud
16
+ * state lives on Cursor's servers, not local SQLite.
18
17
  *
19
- * Key SDK limitation: mcpServers are NOT persisted across Agent.resume().
20
- * They must be passed again on every resume call.
18
+ * Nothing under `local` survives Agent.resume(): `cwd`, `dirs`,
19
+ * `settingSources`, `store` and `enableAgentRetries` are re-supplied on every
20
+ * resume, exactly like `mcpServers`, `agents` and the model params. Omitting
21
+ * `cwd` re-roots the resumed agent at process.cwd() and loads the "project"
22
+ * setting source (the .cursor/hooks.json carrying the HITL approval hook) from
23
+ * the wrong directory, silently disabling the gate on every resumed turn;
24
+ * omitting `store` would make the SDK look the agent up in a default store
25
+ * derived from `cwd`, not the one the session's records are in.
21
26
  *
22
- * Platform store keying (local only): The Cursor SDK defaults to
23
- * process.cwd() for its internal state root lookup. In cloud sandboxes,
24
- * process.cwd() is the runner's app directory, not the workspace — causing
25
- * Agent.resume() to fail with "Agent not found". We pass explicit
26
- * platform.workspaceRef and platform.stateRoot derived from the Stigmer
27
- * sessionId to ensure deterministic store lookup regardless of process.cwd().
27
+ * Durability model: the session's SQLite store (agent records, runs,
28
+ * checkpoints) is the source of truth for conversation continuation. It lives
29
+ * under the durable workspace volume (`session-store.ts`) so Agent.resume()
30
+ * survives pod restart, reschedule, and snapshot restore. When resume
31
+ * nonetheless fails (store lost, corrupted, or agent unknown), this module
32
+ * creates a fresh agent and the caller starts a new turn from the user message
33
+ * plus re-injected instructions — there is no separate continuation store.
28
34
  *
29
- * Durability model: the SDK's local SQLite store (agent records, runs,
30
- * checkpoints) is the source of truth for conversation continuation. It is
31
- * persisted under the durable workspace volume (see resolvePlatformOptions)
32
- * so Agent.resume() survives pod restart, reschedule, and snapshot restore.
33
- * When resume nonetheless fails (store lost, corrupted, or agent unknown),
34
- * this module creates a fresh agent and the caller starts a new turn from
35
- * the user message plus re-injected instructions there is no separate
36
- * continuation store.
35
+ * Retries are the runner's, not the SDK's. `@cursor/sdk` 1.0.31 defaults
36
+ * `enableAgentRetries` to true for headless embedders (1.0.13 defaulted it to
37
+ * false); the runner already owns recovery from a dead or stalled transport —
38
+ * `withTimeout` around agent resolution, `resolveAgentWithTransportRecovery`
39
+ * (reset the transport, retry once), the two recovery spines in
40
+ * `turn-settle.ts`, and the stall detector so the SDK's layer is pinned OFF
41
+ * to keep one retry authority and the 1.0.13 timing of every provider fault.
42
+ * Whether to adopt the SDK's retries and delete ours is an open design
43
+ * question for the harness program, recorded there; it is not decided here.
37
44
  */
38
45
 
39
- import { mkdirSync } from "node:fs";
40
- import { join } from "node:path";
41
-
42
46
  import { Agent } from "@cursor/sdk";
43
47
  import type {
44
48
  SDKAgent,
45
- CursorAgentPlatformOptions,
46
49
  AgentDefinition,
50
+ LocalAgentOptions,
47
51
  ModelParameterValue,
48
52
  } from "@cursor/sdk";
53
+ import type { SqliteLocalAgentStore } from "@cursor/sdk/sqlite";
49
54
  import { withTimeout, TimeoutError } from "../../shared/with-timeout.js";
50
55
  import type { CloudRepo } from "../../shared/blueprint-resolver.js";
51
56
  import type { CursorMcpServerConfig } from "./cursor-mcp-config.js";
57
+ import { sessionStore } from "./session-store.js";
52
58
 
53
59
  // ---------------------------------------------------------------------------
54
60
  // Constants
55
61
  // ---------------------------------------------------------------------------
56
62
 
57
- const CURSOR_SDK_STATE_DIR = ".stigmer/cursor-sdk-state";
58
-
59
63
  /**
60
64
  * Cursor SDK setting sources loaded for LOCAL agents.
61
65
  *
@@ -199,64 +203,41 @@ export interface AgentResolution {
199
203
  // ---------------------------------------------------------------------------
200
204
 
201
205
  /**
202
- * Compute deterministic platform options for a Stigmer session.
203
- *
204
- * workspaceRef is a synthetic identifier (not a filesystem path) that
205
- * ensures the SDK's platform cache key is stable across activity
206
- * invocations regardless of process.cwd().
206
+ * The `local` options a session's agent is created AND resumed with — one
207
+ * builder so the two calls cannot drift (see the module header for why every
208
+ * field is re-supplied on resume).
207
209
  *
208
- * stateRoot is a session-isolated directory under the durable workspace
209
- * volume ({workspaceRootDir}/.stigmer/cursor-sdk-state/{sessionId}) where
210
- * the SDK persists its SQLite stores (agent records, runs, checkpoints).
211
- * Placing it on the workspace volume (rather than $HOME) makes native
212
- * Agent.resume() survive pod restart/reschedule and snapshot restore, and
213
- * keys it by sessionId so sessions sharing one volume (e.g. the child agent
214
- * executions of a workflow sandbox) never collide. Created eagerly to
215
- * prevent ENOENT on first SDK write.
216
- *
217
- * Both inputs are required and must be non-empty: the stateRoot is keyed by
218
- * sessionId, so an empty sessionId would collapse every session sharing the
219
- * volume onto the same store and corrupt their conversation state.
210
+ * Workspaces: the SDK takes ONE primary `cwd` (the default shell's directory
211
+ * and the agent's store scoping) plus `dirs` for the other roots of a
212
+ * multi-root workspace, merged cwd-first with duplicates dropped, so rules,
213
+ * skills and hooks load from every root. The session's first workspace
214
+ * directory is the primary, as it was when the SDK took the whole array.
220
215
  */
221
- export function resolvePlatformOptions(
216
+ async function localAgentOptions(
222
217
  sessionId: string,
223
218
  workspaceRootDir: string,
224
- ): CursorAgentPlatformOptions {
225
- if (!sessionId) {
226
- throw new Error(
227
- "resolvePlatformOptions: sessionId is required but was empty. The Cursor SDK " +
228
- "state store is keyed by sessionId; an empty value would collide across sessions " +
229
- "sharing a workspace volume (e.g. a workflow sandbox's child agent executions).",
230
- );
231
- }
232
- if (!workspaceRootDir) {
233
- throw new Error(
234
- "resolvePlatformOptions: workspaceRootDir is required but was empty. The Cursor " +
235
- "SDK state store must live on the durable workspace volume to survive restarts.",
236
- );
237
- }
238
- const stateRoot = join(workspaceRootDir, CURSOR_SDK_STATE_DIR, sessionId);
239
- mkdirSync(stateRoot, { recursive: true });
219
+ workspaceDirs: readonly string[],
220
+ ): Promise<LocalAgentOptions & { readonly store: SqliteLocalAgentStore }> {
221
+ const [cwd, ...dirs] = workspaceDirs;
222
+ const store = await sessionStore(sessionId, workspaceRootDir);
240
223
  return {
241
- workspaceRef: `stigmer-session:${sessionId}`,
242
- stateRoot,
224
+ cwd,
225
+ ...(dirs.length > 0 ? { dirs } : {}),
226
+ settingSources: [...LOCAL_SETTING_SOURCES],
227
+ store,
228
+ // The runner is the one retry authority (module header).
229
+ enableAgentRetries: false,
243
230
  };
244
231
  }
245
232
 
246
233
  /**
247
234
  * Create a new local Cursor Agent for the first execution in a session.
248
- *
249
- * Supports multi-workspace: passes string[] when multiple dirs, string when single.
250
235
  */
251
236
  export async function createAgent(options: CreateAgentOptions): Promise<SDKAgent> {
252
- const cwd = options.workspaceDirs.length === 1
253
- ? options.workspaceDirs[0]
254
- : options.workspaceDirs;
255
-
256
- const platform = resolvePlatformOptions(options.sessionId, options.workspaceRootDir);
237
+ const local = await localAgentOptions(options.sessionId, options.workspaceRootDir, options.workspaceDirs);
257
238
  console.log(
258
- `createAgent: sessionId=${options.sessionId}, workspaceRef=${platform.workspaceRef}, ` +
259
- `stateRoot=${platform.stateRoot}, process.cwd=${process.cwd()}`,
239
+ `createAgent: sessionId=${options.sessionId}, workspaceRef=${local.store.workspaceRef}, ` +
240
+ `stateRoot=${local.store.stateRoot}, process.cwd=${process.cwd()}`,
260
241
  );
261
242
 
262
243
  return Agent.create({
@@ -264,10 +245,9 @@ export async function createAgent(options: CreateAgentOptions): Promise<SDKAgent
264
245
  // Always a full selection — id AND params. A bare { id } would let the
265
246
  // catalog's default variant (account-influenced) pick the price (#357).
266
247
  model: { id: options.model, params: options.modelParams },
267
- local: { cwd, settingSources: [...LOCAL_SETTING_SOURCES] },
248
+ local,
268
249
  mcpServers: options.mcpServers as Record<string, any>,
269
250
  agents: options.agents,
270
- platform,
271
251
  });
272
252
  }
273
253
 
@@ -278,14 +258,10 @@ export async function createAgent(options: CreateAgentOptions): Promise<SDKAgent
278
258
  * propagate or fall back to a fresh agent with continuation context.
279
259
  */
280
260
  export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent> {
281
- const cwd = options.workspaceDirs.length === 1
282
- ? options.workspaceDirs[0]
283
- : options.workspaceDirs;
284
-
285
- const platform = resolvePlatformOptions(options.sessionId, options.workspaceRootDir);
261
+ const local = await localAgentOptions(options.sessionId, options.workspaceRootDir, options.workspaceDirs);
286
262
  console.log(
287
263
  `resumeAgent: agentId=${options.agentId}, sessionId=${options.sessionId}, ` +
288
- `workspaceRef=${platform.workspaceRef}, stateRoot=${platform.stateRoot}, ` +
264
+ `workspaceRef=${local.store.workspaceRef}, stateRoot=${local.store.stateRoot}, ` +
289
265
  `process.cwd=${process.cwd()}`,
290
266
  );
291
267
 
@@ -296,16 +272,9 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent
296
272
  // ledger, #357), but an id-only resume would fall back to the catalog
297
273
  // default variant for the new turns.
298
274
  model: options.model ? { id: options.model, params: options.modelParams } : undefined,
299
- // Neither cwd nor settingSources survive Agent.resume(); both must be
300
- // re-supplied every turn. Omitting cwd makes the SDK fall back to
301
- // process.cwd(), which re-roots the agent in the runner's own working
302
- // directory and loads the "project" setting source — the .cursor/hooks.json
303
- // carrying the HITL approval hook — from that wrong directory, silently
304
- // disabling the approval gate on every resumed turn.
305
- local: { cwd, settingSources: [...LOCAL_SETTING_SOURCES] },
275
+ local,
306
276
  mcpServers: options.mcpServers as Record<string, any>,
307
277
  agents: options.agents,
308
- platform,
309
278
  });
310
279
  }
311
280
 
@@ -317,7 +286,7 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent
317
286
  * Create a new cloud Cursor Agent for git-backed sessions.
318
287
  *
319
288
  * Cloud agents (bc- prefix) run on Cursor's servers with cloned repos.
320
- * No platform options — cloud state lives server-side, not in local SQLite.
289
+ * No store — cloud state lives server-side, not in local SQLite.
321
290
  * Model is optional — Cursor resolves the caller's configured default
322
291
  * when omitted.
323
292
  */
@@ -341,7 +310,7 @@ export async function createCloudAgent(options: CreateCloudAgentOptions): Promis
341
310
  *
342
311
  * Throws on failure — the caller (resolveAgent) decides whether to
343
312
  * propagate or fall back to a fresh cloud agent with continuation context.
344
- * No platform options — cloud state lives server-side.
313
+ * No store — cloud state lives server-side.
345
314
  */
346
315
  export async function resumeCloudAgent(options: ResumeCloudAgentOptions): Promise<SDKAgent> {
347
316
  console.log(
@@ -365,8 +334,8 @@ export async function resumeCloudAgent(options: ResumeCloudAgentOptions): Promis
365
334
  * graceful fallback if resume fails.
366
335
  *
367
336
  * The mode parameter determines which create/resume functions are used:
368
- * - "local": createAgent / resumeAgent (with platform options)
369
- * - "cloud": createCloudAgent / resumeCloudAgent (no platform options)
337
+ * - "local": createAgent / resumeAgent (over the session's store)
338
+ * - "cloud": createCloudAgent / resumeCloudAgent (no store)
370
339
  *
371
340
  * When harnessStateId is non-empty (subsequent execution):
372
341
  * 1. Attempt Agent.resume with mode-appropriate options.
@@ -536,18 +505,3 @@ export async function resolveAgentWithTransportRecovery(
536
505
  return attempt(true);
537
506
  }
538
507
  }
539
-
540
- /**
541
- * Dispose a Cursor Agent when a session is deleted.
542
- * Best-effort: logs and swallows errors.
543
- */
544
- export async function disposeAgent(agentId: string, apiKey: string): Promise<void> {
545
- try {
546
- await Agent.archive(agentId, { apiKey });
547
- } catch (err) {
548
- console.warn(
549
- `Failed to archive Cursor agent ${agentId}:`,
550
- err instanceof Error ? err.message : err,
551
- );
552
- }
553
- }