@stigmer/runner 3.4.1 → 3.5.1

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 (123) 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/tool-kind.js +6 -0
  65. package/dist/shared/tool-kind.js.map +1 -1
  66. package/dist/tools/index.d.ts +27 -0
  67. package/dist/tools/index.js +27 -0
  68. package/dist/tools/index.js.map +1 -0
  69. package/dist/tools/think-tool.js.map +1 -0
  70. package/dist/tools/url-guard.d.ts +64 -0
  71. package/dist/tools/url-guard.js +211 -0
  72. package/dist/tools/url-guard.js.map +1 -0
  73. package/dist/tools/web-fetch-tool.d.ts +53 -0
  74. package/dist/tools/web-fetch-tool.js +216 -0
  75. package/dist/tools/web-fetch-tool.js.map +1 -0
  76. package/package.json +4 -2
  77. package/src/__tests__/otel-metrics.test.ts +6 -0
  78. package/src/__tests__/pool-member.test.ts +76 -0
  79. package/src/activities/__tests__/attach-session.test.ts +133 -0
  80. package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
  81. package/src/activities/attach-session.ts +112 -0
  82. package/src/activities/discover-mcp-server.ts +19 -1
  83. package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
  84. package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
  85. package/src/activities/execute-cursor/connect-backfill.ts +3 -0
  86. package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
  87. package/src/activities/execute-cursor/index.ts +72 -3
  88. package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
  89. package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
  90. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
  91. package/src/activities/execute-deep-agent/setup.ts +48 -7
  92. package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
  93. package/src/client/__tests__/stigmer-client.test.ts +77 -2
  94. package/src/client/__tests__/token-claims.test.ts +15 -1
  95. package/src/client/stigmer-client.ts +51 -6
  96. package/src/client/token-claims.ts +29 -6
  97. package/src/main.ts +139 -2
  98. package/src/middleware/index.ts +0 -1
  99. package/src/otel-metrics.ts +25 -1
  100. package/src/pool-member.ts +99 -0
  101. package/src/runner-manager.ts +52 -0
  102. package/src/runner.ts +15 -0
  103. package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
  104. package/src/shared/__tests__/connect-backfill.test.ts +11 -11
  105. package/src/shared/__tests__/mcp-manager.test.ts +2 -73
  106. package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
  107. package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
  108. package/src/shared/cold-start-timing.ts +197 -0
  109. package/src/shared/connect-backfill.ts +5 -1
  110. package/src/shared/mcp-manager.ts +4 -41
  111. package/src/shared/mcp-resolver.ts +29 -6
  112. package/src/shared/mcp-transport-guard.ts +81 -0
  113. package/src/shared/tool-kind.ts +6 -0
  114. package/src/tools/__tests__/url-guard.test.ts +137 -0
  115. package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
  116. package/src/tools/index.ts +28 -0
  117. package/src/tools/url-guard.ts +232 -0
  118. package/src/tools/web-fetch-tool.ts +275 -0
  119. package/dist/middleware/think-tool.js.map +0 -1
  120. /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
  121. /package/dist/{middleware → tools}/think-tool.js +0 -0
  122. /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
  123. /package/src/{middleware → tools}/think-tool.ts +0 -0
@@ -25,6 +25,10 @@ import {
25
25
  import type { PayloadCodec } from "@temporalio/common";
26
26
  import type { Config } from "./config.js";
27
27
  import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
28
+ // Boot marks mirror the static runner's segment names where the work is the
29
+ // same. Inert unless a mode emits the boot timeline (pool mode does; the
30
+ // desktop manager never calls emitRunnerBootTiming, so marks cost nothing).
31
+ import { markBoot } from "./shared/cold-start-timing.js";
28
32
  import type { WorkerActivities } from "./worker.js";
29
33
  import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
30
34
  import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
@@ -43,6 +47,10 @@ import {
43
47
 
44
48
  const SESSION_QUEUE_PREFIX = "session:";
45
49
  const WFEXEC_QUEUE_PREFIX = "wfexec:";
50
+ // Warm-pool control queue — polled by exactly one pool member, carrying only
51
+ // the ProbePoolMember/AttachSession claim activities (never session work).
52
+ // Mirrors SessionDispatchService's convention: lowercase kind, colon, id.
53
+ const POOL_CONTROL_QUEUE_PREFIX = "sandbox:";
46
54
 
47
55
  /**
48
56
  * Module-level registry of shutdown signals per task queue.
@@ -142,6 +150,14 @@ export interface StigmerRunnerManager {
142
150
  /** List currently active workflow execution IDs. */
143
151
  activeWorkflowExecutions(): string[];
144
152
 
153
+ /**
154
+ * Start the warm-pool control worker — a Worker polling sandbox:{memberId}
155
+ * for the claim activities (ProbePoolMember, AttachSession). Called once by
156
+ * the pool boot path; the attach flow then adds the claimed session via
157
+ * {@link addSession} like any other host.
158
+ */
159
+ addPoolControl(memberId: string): Promise<void>;
160
+
145
161
  /**
146
162
  * Push a refreshed *control-plane* auth token (the host's durable credential,
147
163
  * e.g. the desktop's Auth0 token) to all activity clients.
@@ -250,6 +266,7 @@ export async function createStigmerRunnerManager(
250
266
  // facade unpatched (otherwise BiDi streams would silently 401). No-op when
251
267
  // the interceptor is unconfigured (no proxy/token).
252
268
  await assertHttp2ConnectPatched();
269
+ markBoot("interceptors_installed");
253
270
 
254
271
  // The token coordinator owns the proxy credential (x-stigmer-auth) and its
255
272
  // refresh lifecycle. It writes ONLY its sinks (the interceptors plus the gRPC
@@ -287,6 +304,7 @@ export async function createStigmerRunnerManager(
287
304
  temporalAddress: bootstrap.temporalAddress,
288
305
  temporalNamespace: bootstrap.temporalNamespace,
289
306
  };
307
+ markBoot("bootstrap_resolved");
290
308
 
291
309
  // Adopt the minted proxy token (cloud): it diverges from the control-plane
292
310
  // token from here on, and the coordinator refreshes it before expiry.
@@ -313,11 +331,13 @@ export async function createStigmerRunnerManager(
313
331
  setExecutionContextRef(getExecutionContext());
314
332
 
315
333
  const activities = await createAllActivities(config);
334
+ markBoot("activities_imported");
316
335
  const payloadCodec = await createPayloadCodec(config);
317
336
 
318
337
  const connection = await NativeConnection.connect({
319
338
  address: config.temporalAddress,
320
339
  });
340
+ markBoot("connection_opened");
321
341
 
322
342
  const interceptorConfig = await buildInterceptorConfig();
323
343
 
@@ -336,10 +356,13 @@ export async function createStigmerRunnerManager(
336
356
  });
337
357
  console.log("[runner-manager] Workflow code bundled successfully");
338
358
  }
359
+ markBoot("workflow_bundle_ready");
339
360
 
340
361
  const sessions = new Map<string, ManagedSession>();
341
362
  const workflowExecutions = new Map<string, ManagedSession>();
342
363
  const shutdownSignals = new Map<string, AbortController>();
364
+ // At most one per process: a pool member IS its control worker's identity.
365
+ let poolControl: { taskQueue: string; managed: ManagedSession } | null = null;
343
366
  let shuttingDown = false;
344
367
 
345
368
  async function createWorkerOnQueue(taskQueue: string): Promise<ManagedSession> {
@@ -502,6 +525,23 @@ export async function createStigmerRunnerManager(
502
525
  return Array.from(workflowExecutions.keys());
503
526
  },
504
527
 
528
+ async addPoolControl(memberId: string): Promise<void> {
529
+ if (shuttingDown) {
530
+ throw new Error("RunnerManager is shutting down");
531
+ }
532
+ if (poolControl) {
533
+ throw new Error(
534
+ `Pool control worker already polling ${poolControl.taskQueue} — ` +
535
+ "a process is exactly one pool member",
536
+ );
537
+ }
538
+ const taskQueue = POOL_CONTROL_QUEUE_PREFIX + memberId;
539
+ poolControl = { taskQueue, managed: await createWorkerOnQueue(taskQueue) };
540
+ console.log(
541
+ `[runner-manager] Added pool control worker (queue=${taskQueue})`,
542
+ );
543
+ },
544
+
505
545
  updateToken(token: string | null): void {
506
546
  // Writes the control-plane credential. The coordinator decides whether the
507
547
  // proxy credential follows it: only when no token has been minted (so the
@@ -541,10 +581,19 @@ export async function createStigmerRunnerManager(
541
581
  },
542
582
  ),
543
583
  ];
584
+ if (poolControl) {
585
+ const control = poolControl;
586
+ shutdownPromises.push((async () => {
587
+ control.managed.worker.shutdown();
588
+ await control.managed.runPromise;
589
+ console.log(`[runner-manager] Pool control worker (${control.taskQueue}) stopped`);
590
+ })());
591
+ }
544
592
 
545
593
  await Promise.all(shutdownPromises);
546
594
  sessions.clear();
547
595
  workflowExecutions.clear();
596
+ poolControl = null;
548
597
  connection.close();
549
598
  console.log("[runner-manager] All workers stopped, connection closed");
550
599
  },
@@ -629,6 +678,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
629
678
  { createHydrateWorkflowActivities },
630
679
  { createWorkflowEventActivities },
631
680
  { createPromoteTaskOutputActivities },
681
+ { createAttachSessionActivities },
632
682
  ] = await Promise.all([
633
683
  import("./activities/execute-cursor/index.js"),
634
684
  import("./activities/execute-deep-agent/index.js"),
@@ -646,6 +696,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
646
696
  import("./activities/hydrate-workflow-execution.js"),
647
697
  import("./activities/workflow-event-activities.js"),
648
698
  import("./activities/promote-task-output.js"),
699
+ import("./activities/attach-session.js"),
649
700
  ]);
650
701
 
651
702
  return {
@@ -665,6 +716,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
665
716
  ...createHydrateWorkflowActivities(config),
666
717
  ...createWorkflowEventActivities(),
667
718
  ...createPromoteTaskOutputActivities(),
719
+ ...createAttachSessionActivities(config),
668
720
  };
669
721
  }
670
722
 
package/src/runner.ts CHANGED
@@ -20,6 +20,7 @@ import type { Config } from "./config.js";
20
20
  import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
21
21
  import type { WorkerActivities } from "./worker.js";
22
22
  import { resolveRunnerBootstrap } from "./bootstrap.js";
23
+ import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
23
24
 
24
25
  /**
25
26
  * Configuration for creating a Stigmer runner.
@@ -179,6 +180,7 @@ export async function createStigmerRunner(
179
180
  // facade unpatched (otherwise BiDi streams would silently 401). No-op when
180
181
  // the interceptor is unconfigured (no proxy/token).
181
182
  await assertHttp2ConnectPatched();
183
+ markBoot("interceptors_installed");
182
184
 
183
185
  // Resolve Temporal coordinates after the http2 patch is in place (discovery
184
186
  // dials the control plane through connect-node). Explicit address wins;
@@ -201,6 +203,7 @@ export async function createStigmerRunner(
201
203
  temporalAddress: coordinates.temporalAddress,
202
204
  temporalNamespace: coordinates.temporalNamespace,
203
205
  };
206
+ markBoot("bootstrap_resolved");
204
207
 
205
208
  const { setExecutionContextRef } = await import(
206
209
  "./activities/execute-cursor/rejection-capture.js"
@@ -208,6 +211,7 @@ export async function createStigmerRunner(
208
211
  setExecutionContextRef(getExecutionContext());
209
212
 
210
213
  const activities = await createAllActivities(config);
214
+ markBoot("activities_imported");
211
215
 
212
216
  console.log(
213
217
  `[runner] Registered activities: ${Object.keys(activities).join(", ")}`,
@@ -222,10 +226,18 @@ export async function createStigmerRunner(
222
226
 
223
227
  const { startWorker } = await import("./worker.js");
224
228
  const worker = await startWorker({ config, activities, payloadCodec });
229
+ markBoot("worker_created");
225
230
 
226
231
  return {
227
232
  async start() {
228
233
  console.log("Worker ready, polling for tasks...");
234
+ // The boot timeline ends here: the worker is about to poll, so the
235
+ // sandbox can now receive its first activity. The final mark closes the
236
+ // worker_created → about-to-poll span — without it, total_ms would stop
237
+ // at worker_created and understate the boot time the warm-pool go/no-go
238
+ // decision reads.
239
+ markBoot("worker_polling");
240
+ emitRunnerBootTiming({ task_queue: config.taskQueue, mode: config.mode });
229
241
  await worker.run();
230
242
  console.log("Worker stopped");
231
243
  },
@@ -320,6 +332,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
320
332
  { createHydrateWorkflowActivities },
321
333
  { createWorkflowEventActivities },
322
334
  { createPromoteTaskOutputActivities },
335
+ { createAttachSessionActivities },
323
336
  ] = await Promise.all([
324
337
  import("./activities/execute-cursor/index.js"),
325
338
  import("./activities/execute-deep-agent/index.js"),
@@ -337,6 +350,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
337
350
  import("./activities/hydrate-workflow-execution.js"),
338
351
  import("./activities/workflow-event-activities.js"),
339
352
  import("./activities/promote-task-output.js"),
353
+ import("./activities/attach-session.js"),
340
354
  ]);
341
355
 
342
356
  return {
@@ -356,6 +370,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
356
370
  ...createHydrateWorkflowActivities(config),
357
371
  ...createWorkflowEventActivities(),
358
372
  ...createPromoteTaskOutputActivities(),
373
+ ...createAttachSessionActivities(config),
359
374
  };
360
375
  }
361
376
 
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Unit tests for the cold-start timing instrumentation.
3
+ *
4
+ * The warm-agent-surfaces baseline report derives its waterfall from these
5
+ * emissions, so the invariants pinned here are load-bearing for a spend
6
+ * decision: segments must partition the timeline (total == sum, no gaps),
7
+ * the emitted line must keep its stable shape (`stigmer_timing` selector,
8
+ * context spread, `segments` with per-segment `start_ms`/`duration_ms`),
9
+ * and the process-boot timeline must emit exactly once — a worker restart
10
+ * inside one process must never produce a bogus second timeline.
11
+ *
12
+ * The boot singleton is module-level state, so those tests re-import the
13
+ * module fresh via `vi.resetModules()` to get an unlatched instance.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+ import { TimingRecorder, emitTimingLog, recordTimingMetric } from "../cold-start-timing.js";
18
+
19
+ /**
20
+ * Captures histogram records made through the instrument registry, so the
21
+ * metric-mirror tests can assert on instrument choice and attribute
22
+ * whitelisting. `failGetInstruments` simulates a broken registry to pin the
23
+ * "telemetry never breaks the runner" posture.
24
+ */
25
+ const metricsMock = vi.hoisted(() => ({
26
+ recorded: [] as Array<{ instrument: string; value: number; attrs?: Record<string, string> }>,
27
+ failGetInstruments: false,
28
+ }));
29
+
30
+ vi.mock("../../otel-metrics.js", () => ({
31
+ getInstruments: async () => {
32
+ if (metricsMock.failGetInstruments) {
33
+ throw new Error("registry unavailable");
34
+ }
35
+ const capture = (instrument: string) => ({
36
+ record: (value: number, attrs?: Record<string, string>) => {
37
+ metricsMock.recorded.push({ instrument, value, attrs });
38
+ },
39
+ });
40
+ return {
41
+ runnerBootDuration: capture("runner_boot"),
42
+ executionSetupDuration: capture("execution_setup"),
43
+ poolAttachDuration: capture("pool_attach"),
44
+ };
45
+ },
46
+ }));
47
+
48
+ /** Parse the single JSON line the mocked console.log captured. */
49
+ function loggedJson(spy: ReturnType<typeof vi.spyOn>): Record<string, unknown> {
50
+ expect(spy).toHaveBeenCalledTimes(1);
51
+ return JSON.parse(spy.mock.calls[0]![0] as string);
52
+ }
53
+
54
+ afterEach(() => {
55
+ vi.restoreAllMocks();
56
+ });
57
+
58
+ describe("TimingRecorder", () => {
59
+ it("partitions the timeline: total equals the sum of segment durations", () => {
60
+ const recorder = new TimingRecorder();
61
+ recorder.mark("first");
62
+ recorder.mark("second");
63
+ recorder.mark("third");
64
+
65
+ const segments = recorder.snapshot();
66
+ expect(segments.map((s) => s.name)).toEqual(["first", "second", "third"]);
67
+
68
+ const sum = segments.reduce((acc, s) => acc + s.durationMs, 0);
69
+ // Each value is independently rounded to 0.1ms, so allow rounding drift.
70
+ expect(Math.abs(recorder.totalMs() - sum)).toBeLessThanOrEqual(
71
+ 0.05 * (segments.length + 1),
72
+ );
73
+ });
74
+
75
+ it("leaves no gaps: each segment starts where the previous one ended", () => {
76
+ const recorder = new TimingRecorder();
77
+ recorder.mark("a");
78
+ recorder.mark("b");
79
+ recorder.mark("c");
80
+
81
+ const segments = recorder.snapshot();
82
+ for (let i = 1; i < segments.length; i++) {
83
+ const previousEnd = segments[i - 1]!.startMs + segments[i - 1]!.durationMs;
84
+ expect(Math.abs(segments[i]!.startMs - previousEnd)).toBeLessThanOrEqual(0.11);
85
+ }
86
+ expect(segments[0]!.startMs).toBe(0);
87
+ });
88
+
89
+ it("measures offsets from an explicit origin (the process-boot case)", () => {
90
+ const recorder = new TimingRecorder(0);
91
+ recorder.mark("node_start");
92
+ // Origin 0 means the first segment's duration covers everything since
93
+ // process start — it must be a real, positive elapsed time.
94
+ expect(recorder.snapshot()[0]!.durationMs).toBeGreaterThan(0);
95
+ expect(recorder.totalMs()).toBeGreaterThan(0);
96
+ });
97
+
98
+ it("reports zero total before any mark", () => {
99
+ expect(new TimingRecorder().totalMs()).toBe(0);
100
+ expect(new TimingRecorder().snapshot()).toHaveLength(0);
101
+ });
102
+ });
103
+
104
+ describe("emitTimingLog", () => {
105
+ it("emits one line with the stable shape the baseline report parses", () => {
106
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
107
+ const recorder = new TimingRecorder();
108
+ recorder.mark("only_segment");
109
+
110
+ emitTimingLog("execution_setup", {
111
+ execution_id: "exe_123",
112
+ session_id: "ses_456",
113
+ harness: "cursor",
114
+ skipped: undefined,
115
+ nullable: null,
116
+ }, recorder);
117
+
118
+ const line = loggedJson(spy);
119
+ expect(line.stigmer_timing).toBe("execution_setup");
120
+ expect(line.execution_id).toBe("exe_123");
121
+ expect(line.session_id).toBe("ses_456");
122
+ // undefined context values are dropped by JSON.stringify; null survives.
123
+ expect("skipped" in line).toBe(false);
124
+ expect(line.nullable).toBeNull();
125
+ expect(line.total_ms).toBeTypeOf("number");
126
+
127
+ const segments = line.segments as Array<Record<string, unknown>>;
128
+ expect(segments).toHaveLength(1);
129
+ expect(segments[0]!.name).toBe("only_segment");
130
+ expect(segments[0]!.start_ms).toBeTypeOf("number");
131
+ expect(segments[0]!.duration_ms).toBeTypeOf("number");
132
+ });
133
+
134
+ it("never throws, even when the payload cannot be serialized", () => {
135
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
136
+ const cyclic: Record<string, unknown> = {};
137
+ cyclic.self = cyclic;
138
+
139
+ expect(() =>
140
+ // The context type forbids objects, but telemetry must survive a caller
141
+ // getting it wrong at runtime — that is the module's stated posture.
142
+ emitTimingLog("evt", cyclic as never, new TimingRecorder()),
143
+ ).not.toThrow();
144
+ expect(spy).not.toHaveBeenCalled();
145
+ });
146
+ });
147
+
148
+ describe("timing metrics mirror", () => {
149
+ beforeEach(() => {
150
+ metricsMock.recorded.length = 0;
151
+ metricsMock.failGetInstruments = false;
152
+ });
153
+
154
+ it("mirrors each mapped timeline onto its histogram with whitelisted attrs only", async () => {
155
+ recordTimingMetric("runner_boot", {
156
+ mode: "cloud",
157
+ pool_member_id: "pm_secret",
158
+ task_queue: "sandbox:pm_secret",
159
+ }, 6200);
160
+ recordTimingMetric("execution_setup", {
161
+ harness: "cursor",
162
+ execution_id: "exe_123",
163
+ session_id: "ses_456",
164
+ }, 3000);
165
+ recordTimingMetric("pool_attach", {
166
+ pool_member_id: "pm_secret",
167
+ session_id: "ses_456",
168
+ }, 400);
169
+
170
+ await vi.waitFor(() => expect(metricsMock.recorded).toHaveLength(3));
171
+
172
+ const byInstrument = Object.fromEntries(
173
+ metricsMock.recorded.map((r) => [r.instrument, r]),
174
+ );
175
+ expect(byInstrument.runner_boot!.value).toBe(6200);
176
+ // The whitelist is the cardinality guard: ids must never become attrs.
177
+ expect(byInstrument.runner_boot!.attrs).toEqual({ mode: "cloud" });
178
+ expect(byInstrument.execution_setup!.value).toBe(3000);
179
+ expect(byInstrument.execution_setup!.attrs).toEqual({ harness: "cursor" });
180
+ expect(byInstrument.pool_attach!.value).toBe(400);
181
+ expect(byInstrument.pool_attach!.attrs).toBeUndefined();
182
+ });
183
+
184
+ it("emitTimingLog drives the mirror, so call sites need no metric code", async () => {
185
+ vi.spyOn(console, "log").mockImplementation(() => {});
186
+ const recorder = new TimingRecorder();
187
+ recorder.mark("only_segment");
188
+
189
+ emitTimingLog("execution_setup", { harness: "native", execution_id: "exe_1" }, recorder);
190
+
191
+ await vi.waitFor(() => expect(metricsMock.recorded).toHaveLength(1));
192
+ expect(metricsMock.recorded[0]!.instrument).toBe("execution_setup");
193
+ expect(metricsMock.recorded[0]!.attrs).toEqual({ harness: "native" });
194
+ });
195
+
196
+ it("records nothing for unmapped events (control-plane timelines)", async () => {
197
+ recordTimingMetric("execution_create", { org: "acme" }, 500);
198
+ recordTimingMetric("pool_attach", {}, 1);
199
+
200
+ // The mapped event landing proves the unmapped one was skipped, without
201
+ // relying on an arbitrary sleep.
202
+ await vi.waitFor(() => expect(metricsMock.recorded).toHaveLength(1));
203
+ expect(metricsMock.recorded[0]!.instrument).toBe("pool_attach");
204
+ });
205
+
206
+ it("survives a broken instrument registry without throwing or logging", async () => {
207
+ metricsMock.failGetInstruments = true;
208
+
209
+ expect(() => recordTimingMetric("runner_boot", { mode: "cloud" }, 100)).not.toThrow();
210
+ // Give the floating promise a tick to reject and be swallowed.
211
+ await new Promise((resolve) => setTimeout(resolve, 10));
212
+ expect(metricsMock.recorded).toHaveLength(0);
213
+ });
214
+ });
215
+
216
+ describe("boot timeline singleton", () => {
217
+ // Each test re-imports the module for a fresh, unlatched singleton.
218
+ async function freshModule() {
219
+ vi.resetModules();
220
+ return import("../cold-start-timing.js");
221
+ }
222
+
223
+ it("emits runner_boot exactly once; later emits are no-ops", async () => {
224
+ const mod = await freshModule();
225
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
226
+
227
+ mod.markBoot("config_loaded");
228
+ mod.emitRunnerBootTiming({ task_queue: "session:ses_1", mode: "cloud" });
229
+ mod.emitRunnerBootTiming({ task_queue: "session:ses_1", mode: "cloud" });
230
+
231
+ const line = loggedJson(spy);
232
+ expect(line.stigmer_timing).toBe("runner_boot");
233
+ expect(line.task_queue).toBe("session:ses_1");
234
+ });
235
+
236
+ it("ignores marks after the boot timeline has emitted", async () => {
237
+ const mod = await freshModule();
238
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
239
+
240
+ mod.markBoot("config_loaded");
241
+ mod.markBoot("worker_polling");
242
+ mod.emitRunnerBootTiming({ mode: "cloud" });
243
+ mod.markBoot("late_mark_must_not_appear");
244
+
245
+ const line = loggedJson(spy);
246
+ const names = (line.segments as Array<{ name: string }>).map((s) => s.name);
247
+ expect(names).toEqual(["config_loaded", "worker_polling"]);
248
+ });
249
+
250
+ it("closes the timeline at the final mark: total covers every named span", async () => {
251
+ const mod = await freshModule();
252
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
253
+
254
+ mod.markBoot("worker_created");
255
+ await new Promise((resolve) => setTimeout(resolve, 5));
256
+ mod.markBoot("worker_polling");
257
+ mod.emitRunnerBootTiming({ mode: "cloud" });
258
+
259
+ const line = loggedJson(spy);
260
+ const segments = line.segments as Array<{ name: string; duration_ms: number }>;
261
+ const last = segments[segments.length - 1]!;
262
+ // The regression this pins: without a final mark before emit, the
263
+ // worker_created → polling span would vanish from total_ms entirely.
264
+ expect(last.name).toBe("worker_polling");
265
+ expect(last.duration_ms).toBeGreaterThan(0);
266
+ const sum = segments.reduce((acc, s) => acc + s.duration_ms, 0);
267
+ expect(Math.abs((line.total_ms as number) - sum)).toBeLessThanOrEqual(
268
+ 0.05 * (segments.length + 1),
269
+ );
270
+ });
271
+ });
@@ -124,7 +124,7 @@ describe("backfillMcpServersIfNeeded", () => {
124
124
  const client = makeMockClient();
125
125
 
126
126
  const result = await backfillMcpServersIfNeeded(
127
- client, servers, [], {}, "org",
127
+ client, servers, [], {}, "org", "stdio-allowed",
128
128
  );
129
129
 
130
130
  expect(result).toBe(servers);
@@ -137,7 +137,7 @@ describe("backfillMcpServersIfNeeded", () => {
137
137
  const client = makeMockClient();
138
138
 
139
139
  const result = await backfillMcpServersIfNeeded(
140
- client, servers, [], {}, "org",
140
+ client, servers, [], {}, "org", "stdio-allowed",
141
141
  );
142
142
 
143
143
  expect(result).toBe(servers);
@@ -155,7 +155,7 @@ describe("backfillMcpServersIfNeeded", () => {
155
155
  });
156
156
 
157
157
  const result = await backfillMcpServersIfNeeded(
158
- client, servers, usages, {}, "org",
158
+ client, servers, usages, {}, "org", "stdio-allowed",
159
159
  );
160
160
 
161
161
  expect(client.getMcpServerByReference).toHaveBeenCalledOnce();
@@ -181,7 +181,7 @@ describe("backfillMcpServersIfNeeded", () => {
181
181
  });
182
182
 
183
183
  const result = await backfillMcpServersIfNeeded(
184
- client, servers, usages, {}, "org",
184
+ client, servers, usages, {}, "org", "stdio-allowed",
185
185
  );
186
186
 
187
187
  expect(client.connectMcpServer).toHaveBeenCalledOnce();
@@ -206,7 +206,7 @@ describe("backfillMcpServersIfNeeded", () => {
206
206
  });
207
207
 
208
208
  await backfillMcpServersIfNeeded(
209
- client, servers, usages, mergedEnv, "org",
209
+ client, servers, usages, mergedEnv, "org", "stdio-allowed",
210
210
  );
211
211
 
212
212
  expect(client.connectMcpServer).toHaveBeenCalledWith(
@@ -224,7 +224,7 @@ describe("backfillMcpServersIfNeeded", () => {
224
224
  });
225
225
 
226
226
  const result = await backfillMcpServersIfNeeded(
227
- client, servers, usages, {}, "org",
227
+ client, servers, usages, {}, "org", "stdio-allowed",
228
228
  );
229
229
 
230
230
  expect(result).toBe(servers);
@@ -242,7 +242,7 @@ describe("backfillMcpServersIfNeeded", () => {
242
242
  });
243
243
 
244
244
  const result = await backfillMcpServersIfNeeded(
245
- client, servers, usages, {}, "org",
245
+ client, servers, usages, {}, "org", "stdio-allowed",
246
246
  );
247
247
 
248
248
  expect(result).toBe(servers);
@@ -261,7 +261,7 @@ describe("backfillMcpServersIfNeeded", () => {
261
261
  });
262
262
 
263
263
  await backfillMcpServersIfNeeded(
264
- client, servers, usages, {}, "org", onHeartbeat,
264
+ client, servers, usages, {}, "org", "stdio-allowed", onHeartbeat,
265
265
  );
266
266
 
267
267
  expect(onHeartbeat).toHaveBeenCalledTimes(2);
@@ -274,7 +274,7 @@ describe("backfillMcpServersIfNeeded", () => {
274
274
  const client = makeMockClient();
275
275
 
276
276
  const result = await backfillMcpServersIfNeeded(
277
- client, servers, [], {}, "org",
277
+ client, servers, [], {}, "org", "stdio-allowed",
278
278
  );
279
279
 
280
280
  expect(client.getMcpServerByReference).not.toHaveBeenCalled();
@@ -294,7 +294,7 @@ describe("backfillMcpServersIfNeeded", () => {
294
294
  });
295
295
 
296
296
  const result = await backfillMcpServersIfNeeded(
297
- client, servers, usages, {}, "org",
297
+ client, servers, usages, {}, "org", "stdio-allowed",
298
298
  );
299
299
 
300
300
  expect(client.connectMcpServer).not.toHaveBeenCalled();
@@ -337,7 +337,7 @@ describe("backfillMcpServersIfNeeded", () => {
337
337
  });
338
338
 
339
339
  const result = await backfillMcpServersIfNeeded(
340
- client, servers, usages, {}, "org",
340
+ client, servers, usages, {}, "org", "stdio-allowed",
341
341
  );
342
342
 
343
343
  expect(callCount).toBe(2);
@@ -1,9 +1,5 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import {
3
- isCloudCompatibleCommand,
4
- warnCloudIncompatibleServers,
5
- toMcpClientConfig,
6
- } from "../mcp-manager.js";
1
+ import { describe, it, expect } from "vitest";
2
+ import { toMcpClientConfig } from "../mcp-manager.js";
7
3
  import type { ResolvedMcpServer } from "../mcp-resolver.js";
8
4
 
9
5
  function makeServer(overrides: Partial<ResolvedMcpServer>): ResolvedMcpServer {
@@ -17,73 +13,6 @@ function makeServer(overrides: Partial<ResolvedMcpServer>): ResolvedMcpServer {
17
13
  };
18
14
  }
19
15
 
20
- describe("isCloudCompatibleCommand", () => {
21
- it.each(["npx", "node", "uvx", "python", "python3"])(
22
- "returns true for '%s'",
23
- (cmd) => {
24
- expect(isCloudCompatibleCommand(cmd)).toBe(true);
25
- },
26
- );
27
-
28
- it.each(["/usr/local/bin/npx", "/home/user/.local/bin/uvx"])(
29
- "returns true for full paths to known commands: '%s'",
30
- (cmd) => {
31
- expect(isCloudCompatibleCommand(cmd)).toBe(true);
32
- },
33
- );
34
-
35
- it.each(["go", "my-custom-binary", "/usr/bin/mcp-server", "docker"])(
36
- "returns false for '%s'",
37
- (cmd) => {
38
- expect(isCloudCompatibleCommand(cmd)).toBe(false);
39
- },
40
- );
41
- });
42
-
43
- describe("warnCloudIncompatibleServers", () => {
44
- it("does nothing when not in cloud mode", () => {
45
- const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
46
- warnCloudIncompatibleServers(
47
- [makeServer({ command: "go", connectionType: "stdio" })],
48
- false,
49
- );
50
- expect(spy).not.toHaveBeenCalled();
51
- spy.mockRestore();
52
- });
53
-
54
- it("warns for non-installable stdio commands in cloud mode", () => {
55
- const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
56
- warnCloudIncompatibleServers(
57
- [makeServer({ slug: "custom", command: "my-binary", connectionType: "stdio" })],
58
- true,
59
- );
60
- expect(spy).toHaveBeenCalledOnce();
61
- expect(spy.mock.calls[0][0]).toContain("my-binary");
62
- expect(spy.mock.calls[0][0]).toContain("custom");
63
- spy.mockRestore();
64
- });
65
-
66
- it("does not warn for npx commands in cloud mode", () => {
67
- const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
68
- warnCloudIncompatibleServers(
69
- [makeServer({ command: "npx", connectionType: "stdio" })],
70
- true,
71
- );
72
- expect(spy).not.toHaveBeenCalled();
73
- spy.mockRestore();
74
- });
75
-
76
- it("does not warn for HTTP servers in cloud mode", () => {
77
- const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
78
- warnCloudIncompatibleServers(
79
- [makeServer({ connectionType: "http", url: "https://mcp.example.com" })],
80
- true,
81
- );
82
- expect(spy).not.toHaveBeenCalled();
83
- spy.mockRestore();
84
- });
85
- });
86
-
87
16
  describe("toMcpClientConfig", () => {
88
17
  it("maps stdio servers to the client config format", () => {
89
18
  const servers = [