@stigmer/runner 3.4.1 → 3.5.0
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/attach-session.d.ts +34 -0
- package/dist/activities/attach-session.js +95 -0
- package/dist/activities/attach-session.js.map +1 -0
- package/dist/activities/discover-mcp-server.d.ts +9 -0
- package/dist/activities/discover-mcp-server.js +6 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/connect-backfill.d.ts +2 -1
- package/dist/activities/execute-cursor/connect-backfill.js +2 -2
- package/dist/activities/execute-cursor/connect-backfill.js.map +1 -1
- package/dist/activities/execute-cursor/fetch-interceptor.js +31 -0
- package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +70 -3
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +7 -1
- package/dist/activities/execute-cursor/mcp-resolver.js +15 -2
- package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/sdk-warmup.d.ts +44 -0
- package/dist/activities/execute-cursor/sdk-warmup.js +61 -0
- package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +48 -5
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +9 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +19 -5
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +13 -1
- package/dist/client/stigmer-client.js +40 -5
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +9 -6
- package/dist/client/token-claims.js +22 -6
- package/dist/client/token-claims.js.map +1 -1
- package/dist/main.d.ts +8 -1
- package/dist/main.js +122 -2
- package/dist/main.js.map +1 -1
- package/dist/middleware/index.d.ts +0 -1
- package/dist/middleware/index.js +0 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/otel-metrics.d.ts +10 -1
- package/dist/otel-metrics.js +22 -1
- package/dist/otel-metrics.js.map +1 -1
- package/dist/pool-member.d.ts +61 -0
- package/dist/pool-member.js +51 -0
- package/dist/pool-member.js.map +1 -0
- package/dist/runner-manager.d.ts +7 -0
- package/dist/runner-manager.js +39 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +15 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/cold-start-timing.d.ts +89 -0
- package/dist/shared/cold-start-timing.js +166 -0
- package/dist/shared/cold-start-timing.js.map +1 -0
- package/dist/shared/connect-backfill.d.ts +2 -1
- package/dist/shared/connect-backfill.js +4 -2
- package/dist/shared/connect-backfill.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +5 -15
- package/dist/shared/mcp-manager.js +5 -32
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +15 -5
- package/dist/shared/mcp-resolver.js +23 -6
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/mcp-transport-guard.d.ts +53 -0
- package/dist/shared/mcp-transport-guard.js +70 -0
- package/dist/shared/mcp-transport-guard.js.map +1 -0
- package/dist/shared/tool-kind.js +6 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/tools/index.d.ts +27 -0
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/think-tool.js.map +1 -0
- package/dist/tools/url-guard.d.ts +64 -0
- package/dist/tools/url-guard.js +211 -0
- package/dist/tools/url-guard.js.map +1 -0
- package/dist/tools/web-fetch-tool.d.ts +53 -0
- package/dist/tools/web-fetch-tool.js +216 -0
- package/dist/tools/web-fetch-tool.js.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/otel-metrics.test.ts +6 -0
- package/src/__tests__/pool-member.test.ts +76 -0
- package/src/activities/__tests__/attach-session.test.ts +133 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
- package/src/activities/attach-session.ts +112 -0
- package/src/activities/discover-mcp-server.ts +19 -1
- package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
- package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
- package/src/activities/execute-cursor/connect-backfill.ts +3 -0
- package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
- package/src/activities/execute-cursor/index.ts +72 -3
- package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
- package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
- package/src/activities/execute-deep-agent/setup.ts +48 -7
- package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
- package/src/client/__tests__/stigmer-client.test.ts +77 -2
- package/src/client/__tests__/token-claims.test.ts +15 -1
- package/src/client/stigmer-client.ts +51 -6
- package/src/client/token-claims.ts +29 -6
- package/src/main.ts +139 -2
- package/src/middleware/index.ts +0 -1
- package/src/otel-metrics.ts +25 -1
- package/src/pool-member.ts +99 -0
- package/src/runner-manager.ts +52 -0
- package/src/runner.ts +15 -0
- package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
- package/src/shared/__tests__/connect-backfill.test.ts +11 -11
- package/src/shared/__tests__/mcp-manager.test.ts +2 -73
- package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
- package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
- package/src/shared/cold-start-timing.ts +197 -0
- package/src/shared/connect-backfill.ts +5 -1
- package/src/shared/mcp-manager.ts +4 -41
- package/src/shared/mcp-resolver.ts +29 -6
- package/src/shared/mcp-transport-guard.ts +81 -0
- package/src/shared/tool-kind.ts +6 -0
- package/src/tools/__tests__/url-guard.test.ts +137 -0
- package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
- package/src/tools/index.ts +28 -0
- package/src/tools/url-guard.ts +232 -0
- package/src/tools/web-fetch-tool.ts +275 -0
- package/dist/middleware/think-tool.js.map +0 -1
- /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
- /package/dist/{middleware → tools}/think-tool.js +0 -0
- /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
- /package/src/{middleware → tools}/think-tool.ts +0 -0
|
@@ -51,10 +51,19 @@ function fakeTokenOfType(tokenType: string): string {
|
|
|
51
51
|
return `${b64({ alg: "RS256" })}.${b64({ token_type: tokenType })}.sig`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Run the request through the full interceptor chain, composed the same way
|
|
56
|
+
* the transport composes it (first interceptor outermost). The client
|
|
57
|
+
* registers more than just the auth interceptor (e.g. OTel trace
|
|
58
|
+
* propagation), so invoking a single one by index would silently test the
|
|
59
|
+
* wrong thing.
|
|
60
|
+
*/
|
|
54
61
|
async function runInterceptor(req: ReturnType<typeof makeRequest>) {
|
|
55
|
-
const interceptor = capturedInterceptors[0]!;
|
|
56
62
|
const next = vi.fn().mockResolvedValue({ ok: true });
|
|
57
|
-
const handler =
|
|
63
|
+
const handler = capturedInterceptors.reduceRight(
|
|
64
|
+
(inner, interceptor) => interceptor(inner),
|
|
65
|
+
next as (req: any) => Promise<any>,
|
|
66
|
+
);
|
|
58
67
|
await handler(req as any);
|
|
59
68
|
return { next, req };
|
|
60
69
|
}
|
|
@@ -330,6 +339,72 @@ describe("StigmerClient", () => {
|
|
|
330
339
|
});
|
|
331
340
|
});
|
|
332
341
|
|
|
342
|
+
describe("getRunnerScopedToken scope mapping", () => {
|
|
343
|
+
function clientWithPlatformQuery(response: Record<string, unknown>) {
|
|
344
|
+
const client = new StigmerClient({ endpoint: "http://localhost", token: null });
|
|
345
|
+
const getRunnerScopedToken = vi.fn().mockResolvedValue(response);
|
|
346
|
+
// Reach into the private generated client: the mocked createClient()
|
|
347
|
+
// returned {}, so install the method it would have provided.
|
|
348
|
+
(client as any).platformQuery = { getRunnerScopedToken };
|
|
349
|
+
return { client, rpc: getRunnerScopedToken };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it("maps agentExecutionId onto its oneof arm", async () => {
|
|
353
|
+
const { client, rpc } = clientWithPlatformQuery({ runnerScopedToken: "tok" });
|
|
354
|
+
|
|
355
|
+
await client.getRunnerScopedToken({ agentExecutionId: "aex_1" });
|
|
356
|
+
|
|
357
|
+
const input = rpc.mock.calls[0]![0];
|
|
358
|
+
expect(input.scope).toEqual({ case: "agentExecutionId", value: "aex_1" });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("maps workflowExecutionId onto its oneof arm", async () => {
|
|
362
|
+
const { client, rpc } = clientWithPlatformQuery({ runnerScopedToken: "tok" });
|
|
363
|
+
|
|
364
|
+
await client.getRunnerScopedToken({ workflowExecutionId: "wfx_1" });
|
|
365
|
+
|
|
366
|
+
const input = rpc.mock.calls[0]![0];
|
|
367
|
+
expect(input.scope).toEqual({ case: "workflowExecutionId", value: "wfx_1" });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("maps poolClaimSessionId onto the pool_claim message arm", async () => {
|
|
371
|
+
const { client, rpc } = clientWithPlatformQuery({ runnerScopedToken: "tok" });
|
|
372
|
+
|
|
373
|
+
await client.getRunnerScopedToken({ poolClaimSessionId: "ses_1" });
|
|
374
|
+
|
|
375
|
+
const input = rpc.mock.calls[0]![0];
|
|
376
|
+
expect(input.scope.case).toBe("poolClaim");
|
|
377
|
+
expect(input.scope.value.sessionId).toBe("ses_1");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("authenticates with the caller token per-call when one is supplied (pool attach)", async () => {
|
|
381
|
+
const { client, rpc } = clientWithPlatformQuery({ runnerScopedToken: "tok" });
|
|
382
|
+
|
|
383
|
+
await client.getRunnerScopedToken({ poolClaimSessionId: "ses_1" }, "pool-tok");
|
|
384
|
+
|
|
385
|
+
expect(rpc).toHaveBeenCalledWith(
|
|
386
|
+
expect.anything(),
|
|
387
|
+
{ headers: { authorization: "Bearer pool-tok" } },
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("passes no call options without a caller token", async () => {
|
|
392
|
+
const { client, rpc } = clientWithPlatformQuery({ runnerScopedToken: "tok" });
|
|
393
|
+
|
|
394
|
+
await client.getRunnerScopedToken({ agentExecutionId: "aex_1" });
|
|
395
|
+
|
|
396
|
+
expect(rpc).toHaveBeenCalledWith(expect.anything(), undefined);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("returns undefined when the server mints nothing (presence-based contract)", async () => {
|
|
400
|
+
const { client } = clientWithPlatformQuery({ runnerScopedToken: "" });
|
|
401
|
+
|
|
402
|
+
const scoped = await client.getRunnerScopedToken({ poolClaimSessionId: "ses_1" });
|
|
403
|
+
|
|
404
|
+
expect(scoped).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
333
408
|
describe("getExecutionContextByExecutionId per-call credential", () => {
|
|
334
409
|
it("passes the scoped token as a per-call authorization header", async () => {
|
|
335
410
|
const client = new StigmerClient({ endpoint: "http://localhost", token: null });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { tokenTypeOf, isEmbeddedRunnerToken } from "../token-claims.js";
|
|
2
|
+
import { tokenTypeOf, isEmbeddedRunnerToken, sessionIdClaimOf } from "../token-claims.js";
|
|
3
3
|
|
|
4
4
|
/** Build an unsigned JWT-shaped token with the given payload. */
|
|
5
5
|
function fakeJwt(payload: Record<string, unknown>): string {
|
|
@@ -32,6 +32,20 @@ describe("tokenTypeOf", () => {
|
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
describe("sessionIdClaimOf", () => {
|
|
36
|
+
it("extracts the session_id claim of a session sandbox token", () => {
|
|
37
|
+
expect(sessionIdClaimOf(fakeJwt({ token_type: "sandbox", session_id: "ses_1" })))
|
|
38
|
+
.toBe("ses_1");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns undefined when the claim is absent or non-string", () => {
|
|
42
|
+
expect(sessionIdClaimOf(fakeJwt({ token_type: "pool_sandbox" }))).toBeUndefined();
|
|
43
|
+
expect(sessionIdClaimOf(fakeJwt({ session_id: 7 }))).toBeUndefined();
|
|
44
|
+
expect(sessionIdClaimOf(null)).toBeUndefined();
|
|
45
|
+
expect(sessionIdClaimOf("garbage")).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
35
49
|
describe("isEmbeddedRunnerToken", () => {
|
|
36
50
|
it("is true only for token_type=embedded_runner", () => {
|
|
37
51
|
expect(isEmbeddedRunnerToken(fakeJwt({ token_type: "embedded_runner" }))).toBe(true);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createClient, type Client, type Transport } from "@connectrpc/connect";
|
|
12
12
|
import { createGrpcTransport } from "@connectrpc/connect-node";
|
|
13
|
+
import { context as otelContext, propagation } from "@opentelemetry/api";
|
|
13
14
|
import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
|
|
14
15
|
import { AgentExecutionQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/query_pb";
|
|
15
16
|
import { ExecutionContextQueryController } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/query_pb";
|
|
@@ -87,10 +88,31 @@ export interface RunnerScopedToken {
|
|
|
87
88
|
/**
|
|
88
89
|
* Names the unit of dispatched work a scoped runner token should serve.
|
|
89
90
|
* Exactly one id must be set — mirrors the proto oneof.
|
|
91
|
+
*
|
|
92
|
+
* `poolClaimSessionId` is the warm-pool attach exchange: a pool sandbox
|
|
93
|
+
* presenting its pool_sandbox credential for the session it was claimed for
|
|
94
|
+
* (the server authorizes against the claim record, not the caller's FGA
|
|
95
|
+
* relations). The execution arms remain embedded_runner-only.
|
|
90
96
|
*/
|
|
91
97
|
export type RunnerScopedTokenScope =
|
|
92
98
|
| { agentExecutionId: string }
|
|
93
|
-
| { workflowExecutionId: string }
|
|
99
|
+
| { workflowExecutionId: string }
|
|
100
|
+
| { poolClaimSessionId: string };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Map the scope union onto the proto oneof init shape. The narrowing chain is
|
|
104
|
+
* exhaustive: adding a variant to {@link RunnerScopedTokenScope} breaks the
|
|
105
|
+
* final branch's type until it is mapped here.
|
|
106
|
+
*/
|
|
107
|
+
function toRunnerScopedTokenOneof(scope: RunnerScopedTokenScope) {
|
|
108
|
+
if ("agentExecutionId" in scope) {
|
|
109
|
+
return { case: "agentExecutionId", value: scope.agentExecutionId } as const;
|
|
110
|
+
}
|
|
111
|
+
if ("workflowExecutionId" in scope) {
|
|
112
|
+
return { case: "workflowExecutionId", value: scope.workflowExecutionId } as const;
|
|
113
|
+
}
|
|
114
|
+
return { case: "poolClaim", value: { sessionId: scope.poolClaimSessionId } } as const;
|
|
115
|
+
}
|
|
94
116
|
|
|
95
117
|
/**
|
|
96
118
|
* A shared mutable token reference. When provided, the interceptor
|
|
@@ -145,6 +167,19 @@ export class StigmerClient {
|
|
|
145
167
|
this.transport = createGrpcTransport({
|
|
146
168
|
baseUrl: options.endpoint,
|
|
147
169
|
interceptors: [
|
|
170
|
+
// W3C trace-context propagation: stamps `traceparent` (and baggage)
|
|
171
|
+
// from the active OTel context onto every outgoing RPC, so a runner
|
|
172
|
+
// span and the server-side request span join into one distributed
|
|
173
|
+
// trace (the server's GrpcRequestTraceIdInterceptor already parses
|
|
174
|
+
// traceparent). When OTel is not initialized — the OSS default, no
|
|
175
|
+
// OTEL_EXPORTER_OTLP_ENDPOINT — the global propagator is a no-op and
|
|
176
|
+
// this adds nothing to the request.
|
|
177
|
+
(next) => async (req) => {
|
|
178
|
+
propagation.inject(otelContext.active(), req.header, {
|
|
179
|
+
set: (carrier, key, value) => carrier.set(key, value),
|
|
180
|
+
});
|
|
181
|
+
return next(req);
|
|
182
|
+
},
|
|
148
183
|
// Credential selection lives here — one tested decision point — rather
|
|
149
184
|
// than at call sites. Precedence:
|
|
150
185
|
//
|
|
@@ -276,15 +311,25 @@ export class StigmerClient {
|
|
|
276
311
|
* sandbox token a cloud sandbox runner receives at provisioning. Returns
|
|
277
312
|
* undefined when the server does not mint (OSS, or no signing key) —
|
|
278
313
|
* presence-based, like the bootstrap token fields.
|
|
314
|
+
*
|
|
315
|
+
* `callerToken` authenticates the exchange per-call instead of the
|
|
316
|
+
* process-wide runner credential. The warm-pool attach uses it: a pool
|
|
317
|
+
* member exchanges with its pool_sandbox token, which is not the runner
|
|
318
|
+
* credential the interceptor would pick for this method.
|
|
279
319
|
*/
|
|
280
320
|
async getRunnerScopedToken(
|
|
281
321
|
scope: RunnerScopedTokenScope,
|
|
322
|
+
callerToken?: string,
|
|
282
323
|
): Promise<RunnerScopedToken | undefined> {
|
|
283
|
-
const input = create(GetRunnerScopedTokenInputSchema,
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
324
|
+
const input = create(GetRunnerScopedTokenInputSchema, {
|
|
325
|
+
scope: toRunnerScopedTokenOneof(scope),
|
|
326
|
+
});
|
|
327
|
+
const res = await this.platformQuery.getRunnerScopedToken(
|
|
328
|
+
input,
|
|
329
|
+
callerToken
|
|
330
|
+
? { headers: { authorization: `Bearer ${callerToken}` } }
|
|
331
|
+
: undefined,
|
|
332
|
+
);
|
|
288
333
|
if (!res.runnerScopedToken) {
|
|
289
334
|
return undefined;
|
|
290
335
|
}
|
|
@@ -12,16 +12,26 @@
|
|
|
12
12
|
/** The `token_type` claim value of a bootstrap-minted embedded runner token. */
|
|
13
13
|
export const TOKEN_TYPE_EMBEDDED_RUNNER = "embedded_runner";
|
|
14
14
|
|
|
15
|
+
/** The `token_type` claim value of a session sandbox token. */
|
|
16
|
+
export const TOKEN_TYPE_SANDBOX = "sandbox";
|
|
17
|
+
|
|
18
|
+
/** The `token_type` claim value of a warm-pool member's pre-claim credential. */
|
|
19
|
+
export const TOKEN_TYPE_POOL_SANDBOX = "pool_sandbox";
|
|
20
|
+
|
|
15
21
|
const CLAIM_TOKEN_TYPE = "token_type";
|
|
22
|
+
const CLAIM_SESSION_ID = "session_id";
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
|
-
* Extract
|
|
25
|
+
* Extract one string claim from a JWT without verifying it.
|
|
19
26
|
*
|
|
20
27
|
* Returns undefined for null/malformed tokens and for tokens without the
|
|
21
|
-
* claim
|
|
22
|
-
*
|
|
28
|
+
* claim — callers branch on the specific value, so "unknown" and "absent"
|
|
29
|
+
* collapse to the same answer.
|
|
23
30
|
*/
|
|
24
|
-
|
|
31
|
+
function claimOf(
|
|
32
|
+
token: string | null | undefined,
|
|
33
|
+
claim: string,
|
|
34
|
+
): string | undefined {
|
|
25
35
|
if (!token) return undefined;
|
|
26
36
|
const parts = token.split(".");
|
|
27
37
|
if (parts.length !== 3) return undefined;
|
|
@@ -29,13 +39,26 @@ export function tokenTypeOf(token: string | null | undefined): string | undefine
|
|
|
29
39
|
const payload = JSON.parse(
|
|
30
40
|
Buffer.from(parts[1]!, "base64url").toString("utf8"),
|
|
31
41
|
) as Record<string, unknown>;
|
|
32
|
-
const
|
|
33
|
-
return typeof
|
|
42
|
+
const value = payload[claim];
|
|
43
|
+
return typeof value === "string" ? value : undefined;
|
|
34
44
|
} catch {
|
|
35
45
|
return undefined;
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
/** Extract the `token_type` claim (e.g. a user's Auth0 token has none). */
|
|
50
|
+
export function tokenTypeOf(token: string | null | undefined): string | undefined {
|
|
51
|
+
return claimOf(token, CLAIM_TOKEN_TYPE);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract the `session_id` claim of a session-scoped token. A claimed pool
|
|
56
|
+
* member reads its post-claim identity from this on restart.
|
|
57
|
+
*/
|
|
58
|
+
export function sessionIdClaimOf(token: string | null | undefined): string | undefined {
|
|
59
|
+
return claimOf(token, CLAIM_SESSION_ID);
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
/** Whether the token is a bootstrap-minted `embedded_runner` credential. */
|
|
40
63
|
export function isEmbeddedRunnerToken(token: string | null | undefined): boolean {
|
|
41
64
|
return tokenTypeOf(token) === TOKEN_TYPE_EMBEDDED_RUNNER;
|
package/src/main.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* CLI entry point for the unified runner service.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Three modes of operation:
|
|
7
7
|
*
|
|
8
8
|
* 1. Static mode (default): Reads configuration from environment variables,
|
|
9
9
|
* initializes OTel, creates a single-queue runner. Used by CLI daemon and
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* stdin, manages per-session Workers dynamically. Used by the desktop app
|
|
14
14
|
* via the IPC protocol. Responses go to stdout; logs go to stderr.
|
|
15
15
|
*
|
|
16
|
+
* 3. Pool mode (STIGMER_POOL_MEMBER_ID set): A warm-pool cloud sandbox. Boots
|
|
17
|
+
* the runner manager, then serves whatever its Secret-injected credential
|
|
18
|
+
* says it is: a pool_sandbox token means "blank member" (poll the
|
|
19
|
+
* sandbox:{memberId} control queue for a claim), a session token means
|
|
20
|
+
* "claimed member that restarted" (go straight to that session's queue).
|
|
21
|
+
* See pool-member.ts for the identity model.
|
|
22
|
+
*
|
|
16
23
|
* OTel is initialized here (not in the factory) because it mutates
|
|
17
24
|
* global state that the consumer should control when embedding the
|
|
18
25
|
* runner as a library.
|
|
@@ -23,11 +30,13 @@ import { readFileSync, readdirSync } from "node:fs";
|
|
|
23
30
|
import { join, relative } from "node:path";
|
|
24
31
|
import { createInterface } from "node:readline";
|
|
25
32
|
import { preflightNodeRuntime } from "./preflight.js";
|
|
33
|
+
import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
|
|
26
34
|
import { loadConfig } from "./config.js";
|
|
27
35
|
import { initTracing, initMetrics } from "./otel.js";
|
|
28
36
|
import { createStigmerRunner } from "./runner.js";
|
|
29
37
|
import { createStigmerRunnerManager } from "./runner-manager.js";
|
|
30
38
|
import type { StigmerRunnerManager } from "./runner-manager.js";
|
|
39
|
+
import { decidePoolBoot, registerPoolMemberContext } from "./pool-member.js";
|
|
31
40
|
import { buildReadyMessage } from "./ipc-protocol.js";
|
|
32
41
|
import type { IpcCommand, IpcResponse } from "./ipc-protocol.js";
|
|
33
42
|
|
|
@@ -170,6 +179,121 @@ async function runManagerMode(config: import("./config.js").Config): Promise<voi
|
|
|
170
179
|
await manager.shutdown();
|
|
171
180
|
}
|
|
172
181
|
|
|
182
|
+
// ─── Pool Mode ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function runPoolMode(
|
|
185
|
+
config: import("./config.js").Config,
|
|
186
|
+
memberId: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const otelShutdown = await initTracing("stigmer-runner");
|
|
189
|
+
const metricsShutdown = await initMetrics("stigmer-runner");
|
|
190
|
+
|
|
191
|
+
// The Secret-injected credential is the single source of truth for what this
|
|
192
|
+
// process serves (the control plane rewrites the Secret at claim time, and
|
|
193
|
+
// secretKeyRef env is read only at pod start — so a restart lands here with
|
|
194
|
+
// the post-claim identity). Decide before any expensive boot work.
|
|
195
|
+
const intent = decidePoolBoot(config.stigmerToken);
|
|
196
|
+
if (intent.kind === "invalid") {
|
|
197
|
+
throw new Error(`Pool member ${memberId} cannot boot: ${intent.reason}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const manager = await createStigmerRunnerManager({
|
|
201
|
+
temporalAddress: config.temporalAddress,
|
|
202
|
+
temporalNamespace: config.temporalNamespace,
|
|
203
|
+
stigmerEndpoint: config.stigmerBackendEndpoint,
|
|
204
|
+
stigmerToken: config.stigmerToken ?? undefined,
|
|
205
|
+
cursorApiKey: config.cursorApiKey || undefined,
|
|
206
|
+
workspaceRootDir: config.workspaceRootDir,
|
|
207
|
+
maxConcurrentActivitiesPerSession: config.maxConcurrentActivities,
|
|
208
|
+
proxyEndpoint: config.proxyEndpoint ?? undefined,
|
|
209
|
+
primaryModel: config.primaryModel,
|
|
210
|
+
checkpointerType: config.checkpointerType,
|
|
211
|
+
checkpointerProxyEndpoint: config.checkpointerProxyEndpoint ?? undefined,
|
|
212
|
+
cloudModeEnabled: config.cloudModeEnabled,
|
|
213
|
+
executionMode: config.mode,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
let taskQueue: string;
|
|
217
|
+
if (intent.kind === "pool-control") {
|
|
218
|
+
registerPoolMemberContext({
|
|
219
|
+
memberId,
|
|
220
|
+
poolToken: config.stigmerToken!,
|
|
221
|
+
manager,
|
|
222
|
+
});
|
|
223
|
+
await manager.addPoolControl(memberId);
|
|
224
|
+
taskQueue = `sandbox:${memberId}`;
|
|
225
|
+
} else {
|
|
226
|
+
// Claimed member after a restart: the pool row is gone and nothing will
|
|
227
|
+
// dispatch on the control queue again, so no pool context, no control
|
|
228
|
+
// worker — just serve the session this pod already belongs to.
|
|
229
|
+
console.warn(
|
|
230
|
+
`[pool-member] ${memberId} restarted post-claim; ` +
|
|
231
|
+
`resuming session ${intent.sessionId}`,
|
|
232
|
+
);
|
|
233
|
+
await manager.addSession(intent.sessionId);
|
|
234
|
+
taskQueue = `session:${intent.sessionId}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The boot window the pool exists to hide from users: emitted with pool
|
|
238
|
+
// context so the baseline harness can separate member pre-boots (background,
|
|
239
|
+
// free) from user-facing boots.
|
|
240
|
+
markBoot("worker_polling");
|
|
241
|
+
emitRunnerBootTiming({
|
|
242
|
+
task_queue: taskQueue,
|
|
243
|
+
mode: config.mode,
|
|
244
|
+
pool_member_id: memberId,
|
|
245
|
+
pool_rehydrated: intent.kind === "claimed-session",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
console.warn(`[pool-member] ${memberId} ready (queue=${taskQueue})`);
|
|
249
|
+
|
|
250
|
+
// Idle-time warm-up (issue #209): pay the Cursor SDK's per-process SQLite
|
|
251
|
+
// cost now, while the member waits for a claim, instead of inside the
|
|
252
|
+
// claimed session's first resolve_agent. Fire-and-forget by design —
|
|
253
|
+
// pool-control members only (a claimed-session restart serves immediately
|
|
254
|
+
// and must not compete with its own executions).
|
|
255
|
+
if (intent.kind === "pool-control") {
|
|
256
|
+
const { warmCursorSdkStateStores } = await import("./activities/execute-cursor/sdk-warmup.js");
|
|
257
|
+
void warmCursorSdkStateStores().then((result) => {
|
|
258
|
+
if (result.warmed) {
|
|
259
|
+
console.log(`[pool-member] Cursor SDK state stores warmed in ${result.durationMs}ms`);
|
|
260
|
+
} else {
|
|
261
|
+
console.warn(
|
|
262
|
+
`[pool-member] Cursor SDK warm-up skipped (non-fatal): ${result.error} ` +
|
|
263
|
+
`(${result.durationMs}ms)`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Park until terminated; the workers poll in the background. Unlike manager
|
|
270
|
+
// mode there is no stdin driver — Kubernetes signals are the only exit.
|
|
271
|
+
await new Promise<void>((resolve) => {
|
|
272
|
+
let shutdownRequested = false;
|
|
273
|
+
const shutdown = (signal: string) => {
|
|
274
|
+
if (shutdownRequested) {
|
|
275
|
+
console.warn("Shutdown already in progress, ignoring duplicate signal");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
shutdownRequested = true;
|
|
279
|
+
console.warn(`[pool-member] Received ${signal}, shutting down gracefully...`);
|
|
280
|
+
void manager.shutdown().then(resolve, (err) => {
|
|
281
|
+
console.error("[pool-member] Shutdown failed:", err);
|
|
282
|
+
resolve();
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
286
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (metricsShutdown) {
|
|
290
|
+
await metricsShutdown();
|
|
291
|
+
}
|
|
292
|
+
if (otelShutdown) {
|
|
293
|
+
await otelShutdown();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
173
297
|
// ─── Static Mode ─────────────────────────────────────────────────────────────
|
|
174
298
|
|
|
175
299
|
async function runStaticMode(config: import("./config.js").Config): Promise<void> {
|
|
@@ -299,10 +423,14 @@ async function main(): Promise<void> {
|
|
|
299
423
|
}
|
|
300
424
|
process.exit(1);
|
|
301
425
|
}
|
|
426
|
+
// Cold-start timeline: this first mark's span covers Node startup +
|
|
427
|
+
// entrypoint module loading + preflight (origin = process start).
|
|
428
|
+
markBoot("node_start_and_preflight");
|
|
302
429
|
|
|
303
430
|
checkBuildFreshness();
|
|
304
431
|
|
|
305
432
|
const config = loadConfig();
|
|
433
|
+
markBoot("config_loaded");
|
|
306
434
|
|
|
307
435
|
// Route ALL Cursor SDK traffic through the Stigmer proxy in proxy mode.
|
|
308
436
|
//
|
|
@@ -327,7 +455,11 @@ async function main(): Promise<void> {
|
|
|
327
455
|
process.env.CURSOR_API_BASE_URL = proxyBase;
|
|
328
456
|
}
|
|
329
457
|
|
|
330
|
-
const
|
|
458
|
+
const poolMemberId = process.env.STIGMER_POOL_MEMBER_ID?.trim() || undefined;
|
|
459
|
+
const runnerMode =
|
|
460
|
+
process.env.STIGMER_RUNNER_MODE === "manager" ? "manager"
|
|
461
|
+
: poolMemberId ? "pool"
|
|
462
|
+
: "static";
|
|
331
463
|
console.warn(
|
|
332
464
|
`[runner] mode=${runnerMode}, proxy=${config.proxyEndpoint ?? "none"}, ` +
|
|
333
465
|
`hasToken=${!!config.stigmerToken}, workspace=${config.workspaceRootDir}, ` +
|
|
@@ -343,6 +475,11 @@ async function main(): Promise<void> {
|
|
|
343
475
|
process.exit(0);
|
|
344
476
|
}
|
|
345
477
|
|
|
478
|
+
if (runnerMode === "pool") {
|
|
479
|
+
await runPoolMode(config, poolMemberId!);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
346
483
|
await runStaticMode(config);
|
|
347
484
|
}
|
|
348
485
|
|
package/src/middleware/index.ts
CHANGED
|
@@ -24,7 +24,6 @@ import { createCostCapMiddleware, type CostCapMiddleware } from "./cost-cap.js";
|
|
|
24
24
|
import { createErrorHintsMiddleware } from "./error-hints.js";
|
|
25
25
|
import { createOtelSpansMiddleware } from "./otel-spans.js";
|
|
26
26
|
|
|
27
|
-
export { createThinkTool } from "./think-tool.js";
|
|
28
27
|
export type { GracefulStopMiddleware } from "./graceful-stop.js";
|
|
29
28
|
export type { CostCapMiddleware } from "./cost-cap.js";
|
|
30
29
|
export type { StigmerMiddleware, MiddlewareStackConfig } from "./types.js";
|
package/src/otel-metrics.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OTel metric instrument registry for the unified runner.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Instrument names follow the platform-wide `stigmer.*` convention shared
|
|
5
|
+
* with stigmer-service (SigNoz renders the dots as underscores). The
|
|
6
|
+
* cold-start instruments (runner boot / execution setup / pool attach) are
|
|
7
|
+
* sandbox-runner-specific — they mirror the `stigmer_timing` stdout
|
|
8
|
+
* timelines (see `shared/cold-start-timing.ts`) as dashboard aggregates and
|
|
9
|
+
* have no counterpart in other runners.
|
|
10
|
+
*
|
|
5
11
|
* All instruments are created lazily on first access. When no
|
|
6
12
|
* MeterProvider is configured, they are no-ops (OTel global fallback).
|
|
7
13
|
*/
|
|
@@ -20,6 +26,9 @@ export interface RunnerInstruments {
|
|
|
20
26
|
readonly llmCallCount: Counter;
|
|
21
27
|
readonly llmTokensInput: Counter;
|
|
22
28
|
readonly llmTokensOutput: Counter;
|
|
29
|
+
readonly runnerBootDuration: Histogram;
|
|
30
|
+
readonly executionSetupDuration: Histogram;
|
|
31
|
+
readonly poolAttachDuration: Histogram;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
let instruments: RunnerInstruments | null = null;
|
|
@@ -63,6 +72,21 @@ export async function getInstruments(): Promise<RunnerInstruments> {
|
|
|
63
72
|
unit: "{token}",
|
|
64
73
|
description: "Total output tokens produced across LLM calls",
|
|
65
74
|
}),
|
|
75
|
+
// Cold-start timelines as dashboard aggregates (warm-agent-surfaces).
|
|
76
|
+
// Attribute cardinality is bounded by the emitter's whitelist in
|
|
77
|
+
// cold-start-timing.ts: `mode` / `harness` only, never per-pod values.
|
|
78
|
+
runnerBootDuration: meter.createHistogram("stigmer.runner.boot.duration", {
|
|
79
|
+
unit: "ms",
|
|
80
|
+
description: "Runner process boot: Node start to Temporal worker polling",
|
|
81
|
+
}),
|
|
82
|
+
executionSetupDuration: meter.createHistogram("stigmer.execution.setup.duration", {
|
|
83
|
+
unit: "ms",
|
|
84
|
+
description: "Per-execution setup: activity start to agent ready to stream",
|
|
85
|
+
}),
|
|
86
|
+
poolAttachDuration: meter.createHistogram("stigmer.sandbox.pool.attach.duration", {
|
|
87
|
+
unit: "ms",
|
|
88
|
+
description: "Warm-pool attach hand-off on the member: token exchange to session worker polling",
|
|
89
|
+
}),
|
|
66
90
|
};
|
|
67
91
|
|
|
68
92
|
return instruments;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm-pool member context.
|
|
3
|
+
*
|
|
4
|
+
* A pool member is a blank, pre-booted cloud sandbox (selected by the
|
|
5
|
+
* STIGMER_POOL_MEMBER_ID env) that polls a control queue
|
|
6
|
+
* `sandbox:{memberId}` until the control plane claims it for a session.
|
|
7
|
+
* The claim arrives as the AttachSession activity, which needs two things
|
|
8
|
+
* only the pool boot path knows: the member's identity/credential and a
|
|
9
|
+
* handle to the runner manager (to attach the session worker).
|
|
10
|
+
*
|
|
11
|
+
* This module is that hand-off point — a module-level registry mirroring
|
|
12
|
+
* `in-flight.ts` and the shutdown-signal registry: the boot path writes,
|
|
13
|
+
* the activity reads via the exported getter with zero handle to the boot
|
|
14
|
+
* closure, and every non-pool process (static, desktop manager, tests)
|
|
15
|
+
* simply reads undefined, which is the activity's inert state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
TOKEN_TYPE_POOL_SANDBOX,
|
|
20
|
+
TOKEN_TYPE_SANDBOX,
|
|
21
|
+
tokenTypeOf,
|
|
22
|
+
sessionIdClaimOf,
|
|
23
|
+
} from "./client/token-claims.js";
|
|
24
|
+
|
|
25
|
+
/** The manager surface the attach path needs — kept minimal for testability. */
|
|
26
|
+
export interface PoolAttachTarget {
|
|
27
|
+
addSession(sessionId: string): Promise<void>;
|
|
28
|
+
updateToken(token: string | null): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PoolMemberContext {
|
|
32
|
+
/** The pool member id this process was provisioned as. */
|
|
33
|
+
readonly memberId: string;
|
|
34
|
+
/**
|
|
35
|
+
* The pool_sandbox credential the member booted with. Captured at
|
|
36
|
+
* registration because the manager's tokenRef is overwritten with the
|
|
37
|
+
* session token during attach — the exchange itself must authenticate
|
|
38
|
+
* with the pool credential.
|
|
39
|
+
*/
|
|
40
|
+
readonly poolToken: string;
|
|
41
|
+
/** The live runner manager hosting this member's workers. */
|
|
42
|
+
readonly manager: PoolAttachTarget;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let _context: PoolMemberContext | undefined;
|
|
46
|
+
|
|
47
|
+
/** Called once by the pool boot path after the manager is up. */
|
|
48
|
+
export function registerPoolMemberContext(context: PoolMemberContext): void {
|
|
49
|
+
_context = context;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Undefined everywhere except a booted pool member — the inert switch. */
|
|
53
|
+
export function getPoolMemberContext(): PoolMemberContext | undefined {
|
|
54
|
+
return _context;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test hook: pool membership is process-lifetime state in production. */
|
|
58
|
+
export function clearPoolMemberContext(): void {
|
|
59
|
+
_context = undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Boot decision ───────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What a pool-configured process should do, decided from its credential.
|
|
66
|
+
*
|
|
67
|
+
* The Secret-injected STIGMER_TOKEN is the single source of truth for a pool
|
|
68
|
+
* member's identity across restarts: the token is read into the pod's env only
|
|
69
|
+
* at container start, and the control plane rewrites the Secret to the session
|
|
70
|
+
* token when the member is claimed. So a fresh member sees pool_sandbox and
|
|
71
|
+
* polls its control queue, while a claimed member that restarted sees the
|
|
72
|
+
* session token and must go straight to serving that session — its pool row
|
|
73
|
+
* is gone and nothing will ever dispatch on the control queue again.
|
|
74
|
+
*/
|
|
75
|
+
export type PoolBootIntent =
|
|
76
|
+
| { kind: "pool-control" }
|
|
77
|
+
| { kind: "claimed-session"; sessionId: string }
|
|
78
|
+
| { kind: "invalid"; reason: string };
|
|
79
|
+
|
|
80
|
+
export function decidePoolBoot(token: string | null | undefined): PoolBootIntent {
|
|
81
|
+
const tokenType = tokenTypeOf(token);
|
|
82
|
+
if (tokenType === TOKEN_TYPE_POOL_SANDBOX) {
|
|
83
|
+
return { kind: "pool-control" };
|
|
84
|
+
}
|
|
85
|
+
if (tokenType === TOKEN_TYPE_SANDBOX) {
|
|
86
|
+
const sessionId = sessionIdClaimOf(token);
|
|
87
|
+
if (!sessionId) {
|
|
88
|
+
return {
|
|
89
|
+
kind: "invalid",
|
|
90
|
+
reason: "session sandbox token carries no session_id claim",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { kind: "claimed-session", sessionId };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
kind: "invalid",
|
|
97
|
+
reason: `expected a pool_sandbox or sandbox token, got token_type=${tokenType ?? "none"}`,
|
|
98
|
+
};
|
|
99
|
+
}
|