@stigmer/runner 3.4.0 → 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.
Files changed (128) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/attach-session.d.ts +34 -0
  3. package/dist/activities/attach-session.js +95 -0
  4. package/dist/activities/attach-session.js.map +1 -0
  5. package/dist/activities/discover-mcp-server.d.ts +9 -0
  6. package/dist/activities/discover-mcp-server.js +6 -1
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/connect-backfill.d.ts +2 -1
  9. package/dist/activities/execute-cursor/connect-backfill.js +2 -2
  10. package/dist/activities/execute-cursor/connect-backfill.js.map +1 -1
  11. package/dist/activities/execute-cursor/fetch-interceptor.js +31 -0
  12. package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.js +70 -3
  14. package/dist/activities/execute-cursor/index.js.map +1 -1
  15. package/dist/activities/execute-cursor/mcp-resolver.d.ts +7 -1
  16. package/dist/activities/execute-cursor/mcp-resolver.js +15 -2
  17. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  18. package/dist/activities/execute-cursor/sdk-warmup.d.ts +44 -0
  19. package/dist/activities/execute-cursor/sdk-warmup.js +61 -0
  20. package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -0
  21. package/dist/activities/execute-deep-agent/setup.js +48 -5
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +9 -1
  24. package/dist/activities/execute-deep-agent/subagent-transformer.js +19 -5
  25. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  26. package/dist/client/stigmer-client.d.ts +13 -1
  27. package/dist/client/stigmer-client.js +40 -5
  28. package/dist/client/stigmer-client.js.map +1 -1
  29. package/dist/client/token-claims.d.ts +9 -6
  30. package/dist/client/token-claims.js +22 -6
  31. package/dist/client/token-claims.js.map +1 -1
  32. package/dist/main.d.ts +8 -1
  33. package/dist/main.js +122 -2
  34. package/dist/main.js.map +1 -1
  35. package/dist/middleware/index.d.ts +0 -1
  36. package/dist/middleware/index.js +0 -1
  37. package/dist/middleware/index.js.map +1 -1
  38. package/dist/otel-metrics.d.ts +10 -1
  39. package/dist/otel-metrics.js +22 -1
  40. package/dist/otel-metrics.js.map +1 -1
  41. package/dist/pool-member.d.ts +61 -0
  42. package/dist/pool-member.js +51 -0
  43. package/dist/pool-member.js.map +1 -0
  44. package/dist/runner-manager.d.ts +7 -0
  45. package/dist/runner-manager.js +39 -1
  46. package/dist/runner-manager.js.map +1 -1
  47. package/dist/runner.js +15 -1
  48. package/dist/runner.js.map +1 -1
  49. package/dist/shared/cold-start-timing.d.ts +89 -0
  50. package/dist/shared/cold-start-timing.js +166 -0
  51. package/dist/shared/cold-start-timing.js.map +1 -0
  52. package/dist/shared/connect-backfill.d.ts +2 -1
  53. package/dist/shared/connect-backfill.js +4 -2
  54. package/dist/shared/connect-backfill.js.map +1 -1
  55. package/dist/shared/mcp-manager.d.ts +5 -15
  56. package/dist/shared/mcp-manager.js +5 -32
  57. package/dist/shared/mcp-manager.js.map +1 -1
  58. package/dist/shared/mcp-resolver.d.ts +15 -5
  59. package/dist/shared/mcp-resolver.js +23 -6
  60. package/dist/shared/mcp-resolver.js.map +1 -1
  61. package/dist/shared/mcp-transport-guard.d.ts +53 -0
  62. package/dist/shared/mcp-transport-guard.js +70 -0
  63. package/dist/shared/mcp-transport-guard.js.map +1 -0
  64. package/dist/shared/model-registry.d.ts +4 -6
  65. package/dist/shared/model-registry.js +4 -6
  66. package/dist/shared/model-registry.js.map +1 -1
  67. package/dist/shared/tool-kind.js +6 -0
  68. package/dist/shared/tool-kind.js.map +1 -1
  69. package/dist/tools/index.d.ts +27 -0
  70. package/dist/tools/index.js +27 -0
  71. package/dist/tools/index.js.map +1 -0
  72. package/dist/tools/think-tool.js.map +1 -0
  73. package/dist/tools/url-guard.d.ts +64 -0
  74. package/dist/tools/url-guard.js +211 -0
  75. package/dist/tools/url-guard.js.map +1 -0
  76. package/dist/tools/web-fetch-tool.d.ts +53 -0
  77. package/dist/tools/web-fetch-tool.js +216 -0
  78. package/dist/tools/web-fetch-tool.js.map +1 -0
  79. package/package.json +4 -2
  80. package/src/__tests__/otel-metrics.test.ts +6 -0
  81. package/src/__tests__/pool-member.test.ts +76 -0
  82. package/src/activities/__tests__/attach-session.test.ts +133 -0
  83. package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
  84. package/src/activities/attach-session.ts +112 -0
  85. package/src/activities/discover-mcp-server.ts +19 -1
  86. package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
  87. package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
  88. package/src/activities/execute-cursor/connect-backfill.ts +3 -0
  89. package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
  90. package/src/activities/execute-cursor/index.ts +72 -3
  91. package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
  92. package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
  93. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
  94. package/src/activities/execute-deep-agent/setup.ts +48 -7
  95. package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
  96. package/src/client/__tests__/stigmer-client.test.ts +77 -2
  97. package/src/client/__tests__/token-claims.test.ts +15 -1
  98. package/src/client/stigmer-client.ts +51 -6
  99. package/src/client/token-claims.ts +29 -6
  100. package/src/main.ts +139 -2
  101. package/src/middleware/index.ts +0 -1
  102. package/src/otel-metrics.ts +25 -1
  103. package/src/pool-member.ts +99 -0
  104. package/src/runner-manager.ts +52 -0
  105. package/src/runner.ts +15 -0
  106. package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
  107. package/src/shared/__tests__/connect-backfill.test.ts +11 -11
  108. package/src/shared/__tests__/mcp-manager.test.ts +2 -73
  109. package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
  110. package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
  111. package/src/shared/__tests__/model-registry.test.ts +6 -4
  112. package/src/shared/cold-start-timing.ts +197 -0
  113. package/src/shared/connect-backfill.ts +5 -1
  114. package/src/shared/mcp-manager.ts +4 -41
  115. package/src/shared/mcp-resolver.ts +29 -6
  116. package/src/shared/mcp-transport-guard.ts +81 -0
  117. package/src/shared/model-registry.ts +4 -6
  118. package/src/shared/tool-kind.ts +6 -0
  119. package/src/tools/__tests__/url-guard.test.ts +137 -0
  120. package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
  121. package/src/tools/index.ts +28 -0
  122. package/src/tools/url-guard.ts +232 -0
  123. package/src/tools/web-fetch-tool.ts +275 -0
  124. package/dist/middleware/think-tool.js.map +0 -1
  125. /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
  126. /package/dist/{middleware → tools}/think-tool.js +0 -0
  127. /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
  128. /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 = interceptor(next);
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
- "agentExecutionId" in scope
285
- ? { scope: { case: "agentExecutionId", value: scope.agentExecutionId } }
286
- : { scope: { case: "workflowExecutionId", value: scope.workflowExecutionId } });
287
- const res = await this.platformQuery.getRunnerScopedToken(input);
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 the `token_type` claim from a JWT without verifying it.
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 (e.g. a user's Auth0 token) — callers branch on the specific value,
22
- * so "unknown" and "absent" collapse to the same answer.
28
+ * claim — callers branch on the specific value, so "unknown" and "absent"
29
+ * collapse to the same answer.
23
30
  */
24
- export function tokenTypeOf(token: string | null | undefined): string | undefined {
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 tokenType = payload[CLAIM_TOKEN_TYPE];
33
- return typeof tokenType === "string" ? tokenType : undefined;
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
- * Two modes of operation:
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 runnerMode = process.env.STIGMER_RUNNER_MODE === "manager" ? "manager" : "static";
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
 
@@ -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";
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * OTel metric instrument registry for the unified runner.
3
3
  *
4
- * Matches Go workflow-runner's `pkg/otel/metrics.go` instrument names.
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
+ }