@stigmer/runner 3.12.3 → 3.12.4
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/call-http.js +12 -0
- package/dist/activities/call-http.js.map +1 -1
- package/dist/activities/call-llm.d.ts +18 -0
- package/dist/activities/call-llm.js +56 -2
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
- package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
- package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
- package/dist/activities/execute-cursor/index.js +61 -28
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
- package/dist/activities/execute-cursor/service-tier.js +5 -21
- package/dist/activities/execute-cursor/service-tier.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +14 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
- package/dist/activities/execute-deep-agent/shell-env.js +10 -7
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/config.js +10 -5
- package/dist/config.js.map +1 -1
- package/dist/encryption/config.js +7 -2
- package/dist/encryption/config.js.map +1 -1
- package/dist/main.js +12 -6
- package/dist/main.js.map +1 -1
- package/dist/payload-codecs.js +2 -1
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +19 -6
- package/dist/runner.js.map +1 -1
- package/dist/shared/fingerprint-secret.d.ts +3 -2
- package/dist/shared/fingerprint-secret.js +5 -3
- package/dist/shared/fingerprint-secret.js.map +1 -1
- package/dist/shared/llm-backend.js +8 -1
- package/dist/shared/llm-backend.js.map +1 -1
- package/dist/shared/model-client.d.ts +15 -0
- package/dist/shared/model-client.js +57 -13
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +5 -0
- package/dist/shared/registry-endpoint.js +7 -1
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +26 -1
- package/dist/shared/runner-credential-keys.js +34 -1
- package/dist/shared/runner-credential-keys.js.map +1 -1
- package/dist/shared/runner-credential-store.d.ts +77 -0
- package/dist/shared/runner-credential-store.js +111 -0
- package/dist/shared/runner-credential-store.js.map +1 -0
- package/dist/shared/service-tier.d.ts +55 -0
- package/dist/shared/service-tier.js +67 -0
- package/dist/shared/service-tier.js.map +1 -0
- package/dist/shared/skill-writer.js +2 -2
- package/dist/shared/skill-writer.js.map +1 -1
- package/dist/shared/zip-extract.d.ts +10 -3
- package/dist/shared/zip-extract.js +10 -3
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
- package/dist/workflow-engine/tasks/call-function.js +49 -5
- package/dist/workflow-engine/tasks/call-function.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +6 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +36 -8
- package/dist/workflows/engine-core.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/call-http.test.ts +36 -0
- package/src/activities/__tests__/call-llm.test.ts +77 -0
- package/src/activities/call-http.ts +17 -0
- package/src/activities/call-llm.ts +78 -2
- package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
- package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
- package/src/activities/execute-cursor/index.ts +66 -20
- package/src/activities/execute-cursor/service-tier.ts +5 -29
- package/src/activities/execute-cursor/skill-resolver.ts +1 -1
- package/src/activities/execute-deep-agent/setup.ts +15 -0
- package/src/activities/execute-deep-agent/shell-env.ts +10 -7
- package/src/config.ts +10 -5
- package/src/encryption/config.ts +8 -2
- package/src/main.ts +16 -6
- package/src/payload-codecs.ts +2 -1
- package/src/runner-manager.ts +29 -6
- package/src/runner.ts +25 -6
- package/src/shared/__tests__/model-client.test.ts +99 -0
- package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
- package/src/shared/__tests__/zip-extract.test.ts +46 -11
- package/src/shared/fingerprint-secret.ts +5 -3
- package/src/shared/llm-backend.ts +7 -1
- package/src/shared/model-client.ts +76 -13
- package/src/shared/registry-endpoint.ts +9 -1
- package/src/shared/runner-credential-keys.ts +36 -1
- package/src/shared/runner-credential-store.ts +115 -0
- package/src/shared/service-tier.ts +78 -0
- package/src/shared/skill-writer.ts +2 -2
- package/src/shared/zip-extract.ts +14 -7
- package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
- package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
- package/src/workflow-engine/tasks/call-function.ts +74 -13
- package/src/workflow-engine/types.ts +6 -0
- package/src/workflows/engine-core.ts +39 -8
|
@@ -236,6 +236,83 @@ describe("callLlmAction", () => {
|
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
describe("schema validation failure channels (#686)", () => {
|
|
240
|
+
const schemaConfig: LlmCallConfig = {
|
|
241
|
+
model: "gpt-4o-mini",
|
|
242
|
+
prompt: "classify",
|
|
243
|
+
response_schema: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: { answer: { type: "number" } },
|
|
246
|
+
required: ["answer"],
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it("default policy throws LLM_SCHEMA_VALIDATION after exactly one model call (no blind retry loop)", async () => {
|
|
251
|
+
process.env.OPENAI_API_KEY = "sk-test";
|
|
252
|
+
mockFetchWithRegistry(() => openAIJsonResponse('{"wrong_key": true}'));
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
callLlmAction(schemaConfig, {}, "exec-1"),
|
|
256
|
+
).rejects.toMatchObject({ type: "LLM_SCHEMA_VALIDATION" });
|
|
257
|
+
|
|
258
|
+
// Before maxRetries was pinned to 0, LangChain blind-retried the
|
|
259
|
+
// identical call ~7 times on every schema miss (#686).
|
|
260
|
+
const llmCalls = mockFetch.mock.calls.filter(
|
|
261
|
+
(call: unknown[]) => !(call[0] as string).includes("model-registry"),
|
|
262
|
+
);
|
|
263
|
+
expect(llmCalls).toHaveLength(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("soft policies return parse_error instead of throwing so the engine can retry/branch", async () => {
|
|
267
|
+
process.env.OPENAI_API_KEY = "sk-test";
|
|
268
|
+
mockFetchWithRegistry(() => openAIJsonResponse('{"wrong_key": true}'));
|
|
269
|
+
|
|
270
|
+
const result = await callLlmAction(
|
|
271
|
+
{ ...schemaConfig, on_invalid: "ON_INVALID_RETRY", max_retries: 2 },
|
|
272
|
+
{},
|
|
273
|
+
"exec-1",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(result.parse_error).toContain("answer");
|
|
277
|
+
expect(result.result).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("a conforming response with a soft policy carries no parse_error", async () => {
|
|
281
|
+
process.env.OPENAI_API_KEY = "sk-test";
|
|
282
|
+
mockFetchWithRegistry(() => openAIJsonResponse('{"answer": 42}'));
|
|
283
|
+
|
|
284
|
+
const result = await callLlmAction(
|
|
285
|
+
{ ...schemaConfig, on_invalid: "ON_INVALID_RETRY" },
|
|
286
|
+
{},
|
|
287
|
+
"exec-1",
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(result.parse_error).toBeUndefined();
|
|
291
|
+
expect(result.result).toEqual({ answer: 42 });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("task timeout (#686)", () => {
|
|
296
|
+
it("times out with non-retryable LLM_TIMEOUT when config.timeout is breached", async () => {
|
|
297
|
+
process.env.OPENAI_API_KEY = "sk-test";
|
|
298
|
+
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
|
|
299
|
+
if ((url as string).includes("model-registry")) {
|
|
300
|
+
return Promise.resolve(new Response("{}", { status: 404 }));
|
|
301
|
+
}
|
|
302
|
+
// Hang until the SDK's timeout signal aborts the request.
|
|
303
|
+
return new Promise((_, reject) => {
|
|
304
|
+
init?.signal?.addEventListener("abort", () =>
|
|
305
|
+
reject(Object.assign(new Error("Request timed out."), { name: "APIConnectionTimeoutError" })),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await expect(
|
|
311
|
+
callLlmAction({ model: "gpt-4o-mini", prompt: "hi", timeout: 1 }, {}, "exec-1"),
|
|
312
|
+
).rejects.toMatchObject({ type: "LLM_TIMEOUT", nonRetryable: true });
|
|
313
|
+
}, 15_000);
|
|
314
|
+
});
|
|
315
|
+
|
|
239
316
|
describe("direct mode — Anthropic", () => {
|
|
240
317
|
it("calls Anthropic API and returns structured result", async () => {
|
|
241
318
|
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
|
@@ -101,10 +101,27 @@ export async function callHttpAction(
|
|
|
101
101
|
fetchOptions.redirect = "manual";
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// Honor the author's timeout_seconds (#686). Non-retryable by design:
|
|
105
|
+
// the workflow author declared the budget, so a breach is a task failure
|
|
106
|
+
// they can catch with try/catch — Temporal re-running the same slow call
|
|
107
|
+
// up to 5 times would multiply the wait without changing the outcome.
|
|
108
|
+
const timeoutMs = resolved.timeout_seconds ? resolved.timeout_seconds * 1000 : undefined;
|
|
109
|
+
if (timeoutMs !== undefined) {
|
|
110
|
+
fetchOptions.signal = AbortSignal.timeout(timeoutMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
let response: Response;
|
|
105
114
|
try {
|
|
106
115
|
response = await fetch(uri, fetchOptions);
|
|
107
116
|
} catch (err: unknown) {
|
|
117
|
+
if (timeoutMs !== undefined && err instanceof Error &&
|
|
118
|
+
(err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
119
|
+
throw ApplicationFailure.nonRetryable(
|
|
120
|
+
`HTTP ${method} ${uri} timed out after ${resolved.timeout_seconds}s (task timeout_seconds)`,
|
|
121
|
+
"HTTP_CALL_TIMEOUT",
|
|
122
|
+
{ timeoutSeconds: resolved.timeout_seconds },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
108
125
|
throw new Error(
|
|
109
126
|
`HTTP ${method} ${uri} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
110
127
|
);
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { computeLlmCostMicros, ensureLoaded as ensurePricingLoaded } from "../shared/model-pricing.js";
|
|
32
32
|
import { resolveToApiModelId } from "../shared/model-registry.js";
|
|
33
33
|
import { buildChatModel } from "../shared/model-client.js";
|
|
34
|
+
import { getRunnerSecret } from "../shared/runner-credential-store.js";
|
|
34
35
|
import { checkDirectCredentials } from "../shared/llm-backend.js";
|
|
35
36
|
import { classifyModelCallError } from "../shared/model-error.js";
|
|
36
37
|
|
|
@@ -41,7 +42,25 @@ export interface LlmCallConfig {
|
|
|
41
42
|
readonly response_schema?: Record<string, unknown>;
|
|
42
43
|
readonly temperature?: number;
|
|
43
44
|
readonly max_tokens?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Author-declared call budget in seconds (proto: LlmCallTaskConfig.timeout,
|
|
47
|
+
* 1-600). Bounds the provider request via buildChatModel's timeout seam;
|
|
48
|
+
* a breach fails non-retryably with LLM_TIMEOUT (#686). The engine widens
|
|
49
|
+
* the activity's startToClose to fit values above the default 5m.
|
|
50
|
+
*/
|
|
44
51
|
readonly timeout?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Schema-validation policy (proto: LlmCallTaskConfig.on_invalid). The
|
|
54
|
+
* retry/fallback ORCHESTRATION lives in the workflow engine
|
|
55
|
+
* (call-function.ts, mirroring call-agent.ts); this activity only reads
|
|
56
|
+
* it to pick the failure channel: soft policies get a `parse_error`
|
|
57
|
+
* result the engine can act on, the default throws LLM_SCHEMA_VALIDATION.
|
|
58
|
+
*/
|
|
59
|
+
readonly on_invalid?: string;
|
|
60
|
+
/** Engine-owned (see on_invalid); accepted here so config passes through. */
|
|
61
|
+
readonly max_retries?: number;
|
|
62
|
+
/** Engine-owned (see on_invalid); accepted here so config passes through. */
|
|
63
|
+
readonly fallback_task?: string;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
export interface LlmCallResult {
|
|
@@ -142,6 +161,18 @@ function classifyAndThrowLlmError(
|
|
|
142
161
|
);
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Matches the request-timeout errors the provider SDKs raise when
|
|
166
|
+
* buildChatModel's timeout bound fires (OpenAI: APIConnectionTimeoutError
|
|
167
|
+
* "Request timed out."; Anthropic mirrors the shape). Only consulted when
|
|
168
|
+
* the task declared an explicit `timeout`, so the loose message match
|
|
169
|
+
* cannot reclassify errors on unbudgeted calls.
|
|
170
|
+
*/
|
|
171
|
+
function isRequestTimeoutError(err: unknown): boolean {
|
|
172
|
+
if (!(err instanceof Error)) return false;
|
|
173
|
+
return err.name === "APIConnectionTimeoutError" || /timed?\s*out/i.test(err.message);
|
|
174
|
+
}
|
|
175
|
+
|
|
145
176
|
export async function callLlmAction(
|
|
146
177
|
config: LlmCallConfig,
|
|
147
178
|
runtimeEnv: Record<string, unknown>,
|
|
@@ -162,7 +193,10 @@ export async function callLlmAction(
|
|
|
162
193
|
await ensurePricingLoaded().catch(() => {});
|
|
163
194
|
|
|
164
195
|
const proxyEndpoint = process.env.STIGMER_PROXY_ENDPOINT;
|
|
165
|
-
|
|
196
|
+
// Per-call store read: rotation (manager updateToken / static renewal)
|
|
197
|
+
// must be visible to in-flight workers, exactly like the env read it
|
|
198
|
+
// replaced (#508).
|
|
199
|
+
const stigmerToken = getRunnerSecret("STIGMER_TOKEN");
|
|
166
200
|
const proxyActive = !!(proxyEndpoint && stigmerToken);
|
|
167
201
|
|
|
168
202
|
console.log(
|
|
@@ -185,6 +219,12 @@ export async function callLlmAction(
|
|
|
185
219
|
// Anthropic requires an explicit maxTokens; preserve the 4096 default here
|
|
186
220
|
// (buildChatModel intentionally imposes none). resolvedModel is already an
|
|
187
221
|
// API id, so buildChatModel's resolve step is a no-op for it.
|
|
222
|
+
//
|
|
223
|
+
// maxRetries: 0 — Temporal owns retries for this activity (callProxy
|
|
224
|
+
// retries up to 5x). LangChain's default retry loop underneath would
|
|
225
|
+
// multiply that, and it also blind-retries schema-validation failures:
|
|
226
|
+
// before this was pinned to 0, one non-conforming structured response
|
|
227
|
+
// burned ~7 identical model calls before surfacing (#686).
|
|
188
228
|
const { model } = await buildChatModel({
|
|
189
229
|
modelName: resolvedModel,
|
|
190
230
|
proxyEndpoint: proxyActive ? proxyEndpoint : undefined,
|
|
@@ -192,6 +232,10 @@ export async function callLlmAction(
|
|
|
192
232
|
headerScope: { workflowExecutionId: executionId },
|
|
193
233
|
temperature: config.temperature,
|
|
194
234
|
maxTokens: provider === "anthropic" ? (config.max_tokens ?? 4096) : config.max_tokens,
|
|
235
|
+
// The author's per-call budget rides the same seam as the operator's
|
|
236
|
+
// STIGMER_LLM_REQUEST_TIMEOUT_MS (#468); an explicit value wins there.
|
|
237
|
+
timeoutMs: config.timeout ? config.timeout * 1000 : undefined,
|
|
238
|
+
maxRetries: 0,
|
|
195
239
|
});
|
|
196
240
|
|
|
197
241
|
const messages: (HumanMessage | SystemMessage)[] = [];
|
|
@@ -200,6 +244,14 @@ export async function callLlmAction(
|
|
|
200
244
|
}
|
|
201
245
|
messages.push(new HumanMessage(config.prompt));
|
|
202
246
|
|
|
247
|
+
// Soft schema-failure channel: when the engine will orchestrate
|
|
248
|
+
// ON_INVALID_RETRY / ON_INVALID_FALLBACK, a validation miss is a signal
|
|
249
|
+
// (parse_error result), not a failure — the engine re-prompts or
|
|
250
|
+
// branches. ON_INVALID_FAIL (and unset) keeps the throwing contract.
|
|
251
|
+
const softSchemaFailure =
|
|
252
|
+
config.on_invalid === "ON_INVALID_RETRY" ||
|
|
253
|
+
config.on_invalid === "ON_INVALID_FALLBACK";
|
|
254
|
+
|
|
203
255
|
let result: LlmCallResult;
|
|
204
256
|
|
|
205
257
|
try {
|
|
@@ -231,7 +283,31 @@ export async function callLlmAction(
|
|
|
231
283
|
};
|
|
232
284
|
}
|
|
233
285
|
} catch (err) {
|
|
234
|
-
|
|
286
|
+
if (softSchemaFailure && err instanceof z.ZodError) {
|
|
287
|
+
// Usage is unrecoverable here — the structured runnable throws
|
|
288
|
+
// before exposing the raw response (same loss as the throwing path).
|
|
289
|
+
result = {
|
|
290
|
+
input_tokens: 0,
|
|
291
|
+
output_tokens: 0,
|
|
292
|
+
result: undefined,
|
|
293
|
+
model: modelId,
|
|
294
|
+
provider,
|
|
295
|
+
parse_error: err.errors
|
|
296
|
+
.map((e) => `${e.path.join(".")}: ${e.message}`)
|
|
297
|
+
.join("; "),
|
|
298
|
+
};
|
|
299
|
+
} else if (config.timeout && isRequestTimeoutError(err)) {
|
|
300
|
+
// The author declared this budget in the task config; breaching it is
|
|
301
|
+
// a task failure they can catch, not a transient to retry — Temporal
|
|
302
|
+
// re-running the same over-budget call 5x would multiply the wait.
|
|
303
|
+
throw ApplicationFailure.nonRetryable(
|
|
304
|
+
`LLM call for model "${modelId}" timed out after ${config.timeout}s (task timeout)`,
|
|
305
|
+
"LLM_TIMEOUT",
|
|
306
|
+
{ timeoutSeconds: config.timeout },
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
classifyAndThrowLlmError(err, modelId, provider, proxyActive);
|
|
310
|
+
}
|
|
235
311
|
}
|
|
236
312
|
|
|
237
313
|
const costMicros = computeLlmCostMicros(config.model, result.input_tokens, result.output_tokens);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
cacheSessionAgent,
|
|
4
|
+
closeAllCachedAgents,
|
|
5
|
+
computeAgentFingerprint,
|
|
6
|
+
evictSessionAgent,
|
|
7
|
+
takeCachedAgent,
|
|
8
|
+
_parkedAgentCountForTests,
|
|
9
|
+
_resetAgentSessionCacheForTests,
|
|
10
|
+
type CacheableAgent,
|
|
11
|
+
} from "../agent-session-cache.js";
|
|
12
|
+
|
|
13
|
+
function makeAgent(agentId = "agent-1"): CacheableAgent & { close: ReturnType<typeof vi.fn> } {
|
|
14
|
+
return { agentId, close: vi.fn() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const FP = "fingerprint-a";
|
|
18
|
+
const OTHER_FP = "fingerprint-b";
|
|
19
|
+
|
|
20
|
+
describe("agent-session-cache", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
_resetAgentSessionCacheForTests();
|
|
23
|
+
delete process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
_resetAgentSessionCacheForTests();
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("checkout semantics", () => {
|
|
32
|
+
it("park then take returns the same agent without closing it", () => {
|
|
33
|
+
const agent = makeAgent();
|
|
34
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
35
|
+
|
|
36
|
+
const taken = takeCachedAgent("ses-1", FP, "agent-1");
|
|
37
|
+
|
|
38
|
+
expect(taken).toBe(agent);
|
|
39
|
+
expect(agent.close).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("checkout is exclusive — a second take misses", () => {
|
|
43
|
+
cacheSessionAgent("ses-1", makeAgent(), FP);
|
|
44
|
+
|
|
45
|
+
expect(takeCachedAgent("ses-1", FP, "agent-1")).toBeDefined();
|
|
46
|
+
expect(takeCachedAgent("ses-1", FP, "agent-1")).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("an empty expectedAgentId (first-execution shape) still matches", () => {
|
|
50
|
+
const agent = makeAgent();
|
|
51
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
52
|
+
|
|
53
|
+
expect(takeCachedAgent("ses-1", FP, "")).toBe(agent);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("misses for a different session", () => {
|
|
57
|
+
cacheSessionAgent("ses-1", makeAgent(), FP);
|
|
58
|
+
|
|
59
|
+
expect(takeCachedAgent("ses-2", FP, "agent-1")).toBeUndefined();
|
|
60
|
+
expect(_parkedAgentCountForTests()).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("reuse guards", () => {
|
|
65
|
+
it("fingerprint drift closes the parked agent and misses", () => {
|
|
66
|
+
const agent = makeAgent();
|
|
67
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
68
|
+
|
|
69
|
+
const taken = takeCachedAgent("ses-1", OTHER_FP, "agent-1");
|
|
70
|
+
|
|
71
|
+
expect(taken).toBeUndefined();
|
|
72
|
+
expect(agent.close).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(_parkedAgentCountForTests()).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("agentId mismatch (harnessStateId replaced elsewhere) closes and misses", () => {
|
|
77
|
+
const agent = makeAgent("agent-old");
|
|
78
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
79
|
+
|
|
80
|
+
const taken = takeCachedAgent("ses-1", FP, "agent-new");
|
|
81
|
+
|
|
82
|
+
expect(taken).toBeUndefined();
|
|
83
|
+
expect(agent.close).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("lifetime bounds", () => {
|
|
88
|
+
it("re-parking a session closes the displaced agent", () => {
|
|
89
|
+
const first = makeAgent("agent-1");
|
|
90
|
+
const second = makeAgent("agent-2");
|
|
91
|
+
cacheSessionAgent("ses-1", first, FP);
|
|
92
|
+
cacheSessionAgent("ses-1", second, FP);
|
|
93
|
+
|
|
94
|
+
expect(first.close).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(takeCachedAgent("ses-1", FP, "agent-2")).toBe(second);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("idle TTL evicts and closes", () => {
|
|
99
|
+
vi.useFakeTimers();
|
|
100
|
+
process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS = "1000";
|
|
101
|
+
const agent = makeAgent();
|
|
102
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
103
|
+
|
|
104
|
+
vi.advanceTimersByTime(1001);
|
|
105
|
+
|
|
106
|
+
expect(agent.close).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(_parkedAgentCountForTests()).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("checkout before the TTL fires disarms the eviction timer", () => {
|
|
111
|
+
vi.useFakeTimers();
|
|
112
|
+
process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS = "1000";
|
|
113
|
+
const agent = makeAgent();
|
|
114
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
115
|
+
|
|
116
|
+
const taken = takeCachedAgent("ses-1", FP, "agent-1");
|
|
117
|
+
vi.advanceTimersByTime(5000);
|
|
118
|
+
|
|
119
|
+
expect(taken).toBe(agent);
|
|
120
|
+
expect(agent.close).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("the LRU cap closes the oldest parked agent", () => {
|
|
124
|
+
vi.useFakeTimers();
|
|
125
|
+
const first = makeAgent("agent-0");
|
|
126
|
+
cacheSessionAgent("ses-0", first, FP);
|
|
127
|
+
for (let i = 1; i < 32; i++) {
|
|
128
|
+
vi.advanceTimersByTime(1);
|
|
129
|
+
cacheSessionAgent(`ses-${i}`, makeAgent(`agent-${i}`), FP);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
vi.advanceTimersByTime(1);
|
|
133
|
+
cacheSessionAgent("ses-32", makeAgent("agent-32"), FP);
|
|
134
|
+
|
|
135
|
+
expect(first.close).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(_parkedAgentCountForTests()).toBe(32);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("an empty sessionId is never parked — the lease releases immediately", () => {
|
|
140
|
+
const agent = makeAgent();
|
|
141
|
+
cacheSessionAgent("", agent, FP);
|
|
142
|
+
|
|
143
|
+
expect(agent.close).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(_parkedAgentCountForTests()).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("explicit release", () => {
|
|
149
|
+
it("evictSessionAgent closes the parked agent", () => {
|
|
150
|
+
const agent = makeAgent();
|
|
151
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
152
|
+
|
|
153
|
+
evictSessionAgent("ses-1");
|
|
154
|
+
|
|
155
|
+
expect(agent.close).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(_parkedAgentCountForTests()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("closeAllCachedAgents closes everything (worker shutdown)", () => {
|
|
160
|
+
const a = makeAgent("agent-a");
|
|
161
|
+
const b = makeAgent("agent-b");
|
|
162
|
+
cacheSessionAgent("ses-a", a, FP);
|
|
163
|
+
cacheSessionAgent("ses-b", b, FP);
|
|
164
|
+
|
|
165
|
+
closeAllCachedAgents();
|
|
166
|
+
|
|
167
|
+
expect(a.close).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(b.close).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(_parkedAgentCountForTests()).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a close() that throws never breaks eviction", () => {
|
|
173
|
+
const agent = makeAgent();
|
|
174
|
+
agent.close.mockImplementation(() => {
|
|
175
|
+
throw new Error("already disposed");
|
|
176
|
+
});
|
|
177
|
+
cacheSessionAgent("ses-1", agent, FP);
|
|
178
|
+
|
|
179
|
+
expect(() => evictSessionAgent("ses-1")).not.toThrow();
|
|
180
|
+
expect(_parkedAgentCountForTests()).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("computeAgentFingerprint", () => {
|
|
185
|
+
const baseOptions = {
|
|
186
|
+
apiKey: "sk-secret-key",
|
|
187
|
+
model: "gpt-5",
|
|
188
|
+
modelParams: [{ key: "tier", value: "standard" }],
|
|
189
|
+
workspaceDirs: ["/workspace/app"],
|
|
190
|
+
mcpServers: { github: { command: "npx", env: { TOKEN: "t-1" } } },
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
it("is stable across key ordering", () => {
|
|
194
|
+
const reordered = {
|
|
195
|
+
mcpServers: { github: { env: { TOKEN: "t-1" }, command: "npx" } },
|
|
196
|
+
workspaceDirs: ["/workspace/app"],
|
|
197
|
+
modelParams: [{ key: "tier", value: "standard" }],
|
|
198
|
+
model: "gpt-5",
|
|
199
|
+
apiKey: "sk-secret-key",
|
|
200
|
+
};
|
|
201
|
+
expect(computeAgentFingerprint(baseOptions)).toBe(computeAgentFingerprint(reordered));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("changes when any acquisition input changes", () => {
|
|
205
|
+
const base = computeAgentFingerprint(baseOptions);
|
|
206
|
+
expect(computeAgentFingerprint({ ...baseOptions, apiKey: "sk-rotated" })).not.toBe(base);
|
|
207
|
+
expect(computeAgentFingerprint({ ...baseOptions, model: "gpt-5-mini" })).not.toBe(base);
|
|
208
|
+
expect(
|
|
209
|
+
computeAgentFingerprint({
|
|
210
|
+
...baseOptions,
|
|
211
|
+
mcpServers: { github: { command: "npx", env: { TOKEN: "t-2" } } },
|
|
212
|
+
}),
|
|
213
|
+
).not.toBe(base);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("never embeds the raw api key", () => {
|
|
217
|
+
expect(computeAgentFingerprint(baseOptions)).not.toContain("sk-secret-key");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -19,10 +19,10 @@ vi.mock("@cursor/sdk", () => ({
|
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
|
-
resolveEffectiveServiceTier,
|
|
23
22
|
resolveServiceTierParams,
|
|
24
23
|
resetCatalogCacheForTests,
|
|
25
24
|
} from "../service-tier.js";
|
|
25
|
+
import { resolveEffectiveServiceTier } from "../../../shared/service-tier.js";
|
|
26
26
|
|
|
27
27
|
const CATALOG = [
|
|
28
28
|
{
|