@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/__test-utils__/contract-subject.js +12 -9
- package/dist/activities/execute-cursor/__test-utils__/contract-subject.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +5 -2
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +5 -2
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +11 -27
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +13 -10
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +70 -26
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +82 -32
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -1
- package/dist/activities/execute-cursor/adapter.d.ts +4 -3
- package/dist/activities/execute-cursor/adapter.js +9 -5
- package/dist/activities/execute-cursor/adapter.js.map +1 -1
- package/dist/activities/execute-cursor/sdk-warmup.d.ts +15 -6
- package/dist/activities/execute-cursor/sdk-warmup.js +15 -6
- package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +38 -59
- package/dist/activities/execute-cursor/session-lifecycle.js +59 -96
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/session-store.d.ts +100 -0
- package/dist/activities/execute-cursor/session-store.js +157 -0
- package/dist/activities/execute-cursor/session-store.js.map +1 -0
- package/dist/shared/attachment-vision.d.ts +2 -1
- package/dist/shared/attachment-vision.js.map +1 -1
- package/package.json +5 -5
- package/src/activities/execute-cursor/__test-utils__/__tests__/scripted-double.test.ts +31 -7
- package/src/activities/execute-cursor/__test-utils__/contract-subject.ts +10 -8
- package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +5 -2
- package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +20 -28
- package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +94 -38
- package/src/activities/execute-cursor/__tests__/cursor-baseurl-routing.test.ts +10 -8
- package/src/activities/execute-cursor/__tests__/cursor-fetch-interceptor-bypass.test.ts +5 -4
- package/src/activities/execute-cursor/__tests__/cursor-generate-image-live.test.ts +7 -2
- package/src/activities/execute-cursor/__tests__/cursor-hook-protocol-live.test.ts +163 -0
- package/src/activities/execute-cursor/__tests__/cursor-sdk-auth-smoke.test.ts +13 -12
- package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/harness-contract.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/run-wait-arms.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/stream-self-stop-arms.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/unattributed-hook-block.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/workspace-lock-timeout.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +86 -58
- package/src/activities/execute-cursor/__tests__/session-store.test.ts +220 -0
- package/src/activities/execute-cursor/adapter.ts +9 -5
- package/src/activities/execute-cursor/sdk-warmup.ts +15 -6
- package/src/activities/execute-cursor/session-lifecycle.ts +63 -109
- package/src/activities/execute-cursor/session-store.ts +187 -0
- 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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
36
|
-
*
|
|
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)
|
|
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 } })
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
-
|
|
216
|
+
async function localAgentOptions(
|
|
222
217
|
sessionId: string,
|
|
223
218
|
workspaceRootDir: string,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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=${
|
|
259
|
-
`stateRoot=${
|
|
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
|
|
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
|
|
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=${
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
369
|
-
* - "cloud": createCloudAgent / resumeCloudAgent (no
|
|
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
|
-
}
|