@stigmer/runner 3.0.8-dev.20260613085218 → 3.0.9-dev.20260615145121

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 (106) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
  3. package/dist/activities/execute-cursor/hook-script.js +85 -51
  4. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  5. package/dist/activities/execute-cursor/index.js +220 -79
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/message-translator.d.ts +51 -9
  8. package/dist/activities/execute-cursor/message-translator.js +146 -20
  9. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  10. package/dist/activities/execute-cursor/persist-decision.d.ts +42 -0
  11. package/dist/activities/execute-cursor/persist-decision.js +30 -0
  12. package/dist/activities/execute-cursor/persist-decision.js.map +1 -0
  13. package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
  14. package/dist/activities/execute-cursor/prompt-builder.js +54 -0
  15. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  16. package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
  17. package/dist/activities/execute-cursor/workspace-setup.js +62 -30
  18. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/index.js +15 -5
  20. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
  22. package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
  23. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/status-builder.js +4 -5
  25. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/streaming-v3.js +4 -5
  27. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/streaming.d.ts +9 -1
  29. package/dist/activities/execute-deep-agent/streaming.js +4 -5
  30. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  31. package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
  32. package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
  34. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  35. package/dist/config.d.ts +21 -0
  36. package/dist/config.js +12 -0
  37. package/dist/config.js.map +1 -1
  38. package/dist/in-flight.d.ts +35 -0
  39. package/dist/in-flight.js +61 -0
  40. package/dist/in-flight.js.map +1 -0
  41. package/dist/runner-manager.d.ts +2 -0
  42. package/dist/runner-manager.js +90 -29
  43. package/dist/runner-manager.js.map +1 -1
  44. package/dist/runner.d.ts +2 -0
  45. package/dist/runner.js +2 -0
  46. package/dist/runner.js.map +1 -1
  47. package/dist/shared/grpc-retry.d.ts +9 -20
  48. package/dist/shared/grpc-retry.js +9 -52
  49. package/dist/shared/grpc-retry.js.map +1 -1
  50. package/dist/shared/stall-watchdog.d.ts +68 -0
  51. package/dist/shared/stall-watchdog.js +102 -0
  52. package/dist/shared/stall-watchdog.js.map +1 -0
  53. package/dist/shared/status-offload.d.ts +84 -0
  54. package/dist/shared/status-offload.js +292 -0
  55. package/dist/shared/status-offload.js.map +1 -0
  56. package/dist/shared/status.d.ts +34 -3
  57. package/dist/shared/status.js +102 -9
  58. package/dist/shared/status.js.map +1 -1
  59. package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.d.ts +4 -0
  60. package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.js +4 -0
  61. package/dist/shared/streaming-scheduler.js.map +1 -0
  62. package/package.json +2 -2
  63. package/src/__tests__/config.test.ts +8 -0
  64. package/src/__tests__/in-flight.test.ts +84 -0
  65. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  66. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  67. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
  68. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
  69. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +124 -12
  70. package/src/activities/execute-cursor/__tests__/persist-decision.test.ts +99 -0
  71. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
  72. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
  73. package/src/activities/execute-cursor/hook-script.ts +85 -51
  74. package/src/activities/execute-cursor/index.ts +187 -38
  75. package/src/activities/execute-cursor/message-translator.ts +146 -20
  76. package/src/activities/execute-cursor/persist-decision.ts +54 -0
  77. package/src/activities/execute-cursor/prompt-builder.ts +59 -0
  78. package/src/activities/execute-cursor/workspace-setup.ts +76 -44
  79. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  80. package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
  81. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
  82. package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
  83. package/src/activities/execute-deep-agent/index.ts +17 -5
  84. package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
  85. package/src/activities/execute-deep-agent/status-builder.ts +3 -5
  86. package/src/activities/execute-deep-agent/streaming-v3.ts +5 -5
  87. package/src/activities/execute-deep-agent/streaming.ts +14 -5
  88. package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
  89. package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
  90. package/src/config.ts +27 -0
  91. package/src/in-flight.ts +71 -0
  92. package/src/runner-manager.ts +127 -33
  93. package/src/runner.ts +6 -0
  94. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  95. package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
  96. package/src/shared/__tests__/grpc-retry.test.ts +5 -134
  97. package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
  98. package/src/shared/__tests__/status-offload.test.ts +256 -0
  99. package/src/shared/__tests__/status.test.ts +199 -0
  100. package/src/shared/grpc-retry.ts +9 -72
  101. package/src/shared/stall-watchdog.ts +122 -0
  102. package/src/shared/status-offload.ts +342 -0
  103. package/src/shared/status.ts +142 -8
  104. package/src/{activities/execute-deep-agent → shared}/streaming-scheduler.ts +4 -0
  105. package/dist/activities/execute-deep-agent/streaming-scheduler.js.map +0 -1
  106. /package/src/{activities/execute-deep-agent → shared}/__tests__/streaming-scheduler.test.ts +0 -0
@@ -24,10 +24,22 @@ import {
24
24
  } from "@temporalio/worker";
25
25
  import type { PayloadCodec } from "@temporalio/common";
26
26
  import type { Config } from "./config.js";
27
+ import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS } from "./config.js";
27
28
  import type { WorkerActivities } from "./worker.js";
28
29
  import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
29
30
  import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
30
31
  import { createRunnerTokenCoordinator } from "./runner-token-coordinator.js";
32
+ // Per-task-queue in-flight activity tracking lives in ./in-flight.ts so the
33
+ // activity interceptor (no manager-closure handle) and unit tests can reach it.
34
+ // This is what keeps a session worker alive while ExecuteCursor is running, so a
35
+ // view close can no longer reap the worker mid-run.
36
+ import {
37
+ activityStartedOnQueue,
38
+ activityFinishedOnQueue,
39
+ inFlightCountForQueue,
40
+ setQueueDrainCallback,
41
+ forgetQueue,
42
+ } from "./in-flight.js";
31
43
 
32
44
  const SESSION_QUEUE_PREFIX = "session:";
33
45
  const WFEXEC_QUEUE_PREFIX = "wfexec:";
@@ -76,6 +88,9 @@ export interface RunnerManagerOptions {
76
88
  /** Default LLM model identifier. @default "gpt-4.1" */
77
89
  readonly primaryModel?: string;
78
90
 
91
+ /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
92
+ readonly cursorStreamStallTimeoutMs?: number;
93
+
79
94
  /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
80
95
  readonly checkpointerType?: "memory" | "http";
81
96
 
@@ -141,6 +156,12 @@ interface ManagedSession {
141
156
  worker: Worker;
142
157
  runPromise: Promise<void>;
143
158
  shutdownController: AbortController;
159
+ /**
160
+ * Set when a remove was requested while an activity was still in flight, so
161
+ * the worker is kept alive in the background and torn down only once the last
162
+ * activity finishes. Cleared if the session is re-opened before it drains.
163
+ */
164
+ pendingClose: boolean;
144
165
  }
145
166
 
146
167
  /**
@@ -324,7 +345,84 @@ export async function createStigmerRunnerManager(
324
345
  );
325
346
  });
326
347
 
327
- return { worker, runPromise, shutdownController };
348
+ return { worker, runPromise, shutdownController, pendingClose: false };
349
+ }
350
+
351
+ /**
352
+ * Re-opening a session/execution whose teardown was deferred: cancel the
353
+ * pending close so the worker keeps serving the reused queue. Returns true
354
+ * when an existing managed worker was found (caller should not recreate one).
355
+ */
356
+ function reuseExistingWorker(
357
+ registry: Map<string, ManagedSession>,
358
+ id: string,
359
+ taskQueue: string,
360
+ kind: string,
361
+ ): boolean {
362
+ const existing = registry.get(id);
363
+ if (!existing) return false;
364
+ if (existing.pendingClose) {
365
+ existing.pendingClose = false;
366
+ setQueueDrainCallback(taskQueue, undefined);
367
+ console.log(`[runner-manager] Re-opened ${kind} ${id}; cancelled deferred teardown`);
368
+ }
369
+ return true;
370
+ }
371
+
372
+ /**
373
+ * Graceful teardown of a managed worker. Never aborts: callers only reach the
374
+ * actual teardown once no activity is in flight, so a plain worker.shutdown()
375
+ * drains the (idle) queue cleanly. The abort-before-shutdown that used to live
376
+ * here is what killed running activities on a view close.
377
+ */
378
+ async function teardownManaged(
379
+ registry: Map<string, ManagedSession>,
380
+ id: string,
381
+ taskQueue: string,
382
+ kind: string,
383
+ ): Promise<void> {
384
+ const managed = registry.get(id);
385
+ if (!managed) return;
386
+ managed.worker.shutdown();
387
+ await managed.runPromise;
388
+ registry.delete(id);
389
+ shutdownSignals.delete(taskQueue);
390
+ _shutdownSignalRegistry.delete(taskQueue);
391
+ forgetQueue(taskQueue);
392
+ console.log(`[runner-manager] Removed ${kind} ${id} (active=${registry.size})`);
393
+ }
394
+
395
+ /**
396
+ * Remove a managed worker, deferring teardown while activities are in flight.
397
+ * This is the server-side safety invariant that lets a run continue in the
398
+ * background after its session view closes: the worker is reaped only when the
399
+ * last activity finishes (or immediately if the queue is already idle).
400
+ */
401
+ async function removeManaged(
402
+ registry: Map<string, ManagedSession>,
403
+ id: string,
404
+ taskQueue: string,
405
+ kind: string,
406
+ ): Promise<void> {
407
+ const managed = registry.get(id);
408
+ if (!managed) return;
409
+
410
+ if (inFlightCountForQueue(taskQueue) > 0) {
411
+ managed.pendingClose = true;
412
+ setQueueDrainCallback(taskQueue, () => {
413
+ // Skip if a full shutdown() is already reaping every worker, so we
414
+ // never call worker.shutdown() twice on the same worker.
415
+ if (shuttingDown) return;
416
+ void teardownManaged(registry, id, taskQueue, kind);
417
+ });
418
+ console.log(
419
+ `[runner-manager] Deferring teardown of ${kind} ${id} — ` +
420
+ `${inFlightCountForQueue(taskQueue)} activity(ies) still in flight (runs in background)`,
421
+ );
422
+ return;
423
+ }
424
+
425
+ await teardownManaged(registry, id, taskQueue, kind);
328
426
  }
329
427
 
330
428
  return {
@@ -332,11 +430,11 @@ export async function createStigmerRunnerManager(
332
430
  if (shuttingDown) {
333
431
  throw new Error("RunnerManager is shutting down");
334
432
  }
335
- if (sessions.has(sessionId)) {
433
+ const taskQueue = SESSION_QUEUE_PREFIX + sessionId;
434
+ if (reuseExistingWorker(sessions, sessionId, taskQueue, "session")) {
336
435
  return;
337
436
  }
338
437
 
339
- const taskQueue = SESSION_QUEUE_PREFIX + sessionId;
340
438
  const managed = await createWorkerOnQueue(taskQueue);
341
439
  sessions.set(sessionId, managed);
342
440
  console.log(
@@ -345,20 +443,8 @@ export async function createStigmerRunnerManager(
345
443
  },
346
444
 
347
445
  async removeSession(sessionId: string): Promise<void> {
348
- const session = sessions.get(sessionId);
349
- if (!session) {
350
- return;
351
- }
352
-
353
- const taskQueue = SESSION_QUEUE_PREFIX + sessionId;
354
- session.shutdownController.abort();
355
- session.worker.shutdown();
356
- await session.runPromise;
357
- sessions.delete(sessionId);
358
- shutdownSignals.delete(taskQueue);
359
- _shutdownSignalRegistry.delete(taskQueue);
360
- console.log(
361
- `[runner-manager] Removed session ${sessionId} (active=${sessions.size})`,
446
+ await removeManaged(
447
+ sessions, sessionId, SESSION_QUEUE_PREFIX + sessionId, "session",
362
448
  );
363
449
  },
364
450
 
@@ -370,11 +456,11 @@ export async function createStigmerRunnerManager(
370
456
  if (shuttingDown) {
371
457
  throw new Error("RunnerManager is shutting down");
372
458
  }
373
- if (workflowExecutions.has(executionId)) {
459
+ const taskQueue = WFEXEC_QUEUE_PREFIX + executionId;
460
+ if (reuseExistingWorker(workflowExecutions, executionId, taskQueue, "workflow execution")) {
374
461
  return;
375
462
  }
376
463
 
377
- const taskQueue = WFEXEC_QUEUE_PREFIX + executionId;
378
464
  const managed = await createWorkerOnQueue(taskQueue);
379
465
  workflowExecutions.set(executionId, managed);
380
466
  console.log(
@@ -383,20 +469,9 @@ export async function createStigmerRunnerManager(
383
469
  },
384
470
 
385
471
  async removeWorkflowExecution(executionId: string): Promise<void> {
386
- const execution = workflowExecutions.get(executionId);
387
- if (!execution) {
388
- return;
389
- }
390
-
391
- const taskQueue = WFEXEC_QUEUE_PREFIX + executionId;
392
- execution.shutdownController.abort();
393
- execution.worker.shutdown();
394
- await execution.runPromise;
395
- workflowExecutions.delete(executionId);
396
- shutdownSignals.delete(taskQueue);
397
- _shutdownSignalRegistry.delete(taskQueue);
398
- console.log(
399
- `[runner-manager] Removed workflow execution ${executionId} (active=${workflowExecutions.size})`,
472
+ await removeManaged(
473
+ workflowExecutions, executionId, WFEXEC_QUEUE_PREFIX + executionId,
474
+ "workflow execution",
400
475
  );
401
476
  },
402
477
 
@@ -499,6 +574,8 @@ export function mapManagerOptionsToConfig(
499
574
  checkpointerProxyEndpoint:
500
575
  options.checkpointerProxyEndpoint ?? options.proxyEndpoint ?? null,
501
576
  primaryModel: options.primaryModel ?? "gpt-4.1",
577
+ cursorStreamStallTimeoutMs:
578
+ options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
502
579
  };
503
580
  }
504
581
 
@@ -600,6 +677,23 @@ async function buildInterceptorConfig(): Promise<InterceptorConfig> {
600
677
  let sinks: InjectedSinks<any> = { ...createWorkflowMetricsSinks() };
601
678
  const workflowInterceptorModules: string[] = [];
602
679
 
680
+ // In-flight activity counter: keeps a session/wfexec worker alive while one of
681
+ // its activities (notably the long ExecuteCursor) is running, so a view close
682
+ // can no longer reap the worker mid-run. Always installed; cheap and global.
683
+ activityInterceptors.push((ctx) => ({
684
+ inbound: {
685
+ async execute(input, next) {
686
+ const taskQueue = ctx.info.taskQueue;
687
+ activityStartedOnQueue(taskQueue);
688
+ try {
689
+ return await next(input);
690
+ } finally {
691
+ activityFinishedOnQueue(taskQueue);
692
+ }
693
+ },
694
+ },
695
+ }));
696
+
603
697
  if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
604
698
  const { OpenTelemetryActivityInboundInterceptor, makeWorkflowExporter } =
605
699
  await import("@temporalio/interceptors-opentelemetry");
package/src/runner.ts CHANGED
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { homedir, tmpdir } from "node:os";
18
18
  import type { PayloadCodec } from "@temporalio/common";
19
19
  import type { Config } from "./config.js";
20
+ import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS } from "./config.js";
20
21
  import type { WorkerActivities } from "./worker.js";
21
22
  import { resolveRunnerBootstrap } from "./bootstrap.js";
22
23
 
@@ -65,6 +66,9 @@ export interface StigmerRunnerOptions {
65
66
  /** Default LLM model identifier. @default "gpt-4.1" */
66
67
  readonly primaryModel?: string;
67
68
 
69
+ /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
70
+ readonly cursorStreamStallTimeoutMs?: number;
71
+
68
72
  /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
69
73
  readonly checkpointerType?: "memory" | "http";
70
74
 
@@ -268,6 +272,8 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
268
272
  ?? options.proxyEndpoint
269
273
  ?? null,
270
274
  primaryModel: options.primaryModel ?? "gpt-4.1",
275
+ cursorStreamStallTimeoutMs:
276
+ options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
271
277
  };
272
278
  }
273
279
 
@@ -315,6 +315,7 @@ describe("loadArtifactStorageConfig", () => {
315
315
  checkpointerType: "memory" as const,
316
316
  checkpointerProxyEndpoint: null,
317
317
  primaryModel: "gpt-4.1",
318
+ cursorStreamStallTimeoutMs: 180000,
318
319
  };
319
320
 
320
321
  afterEach(() => {
@@ -1,30 +1,13 @@
1
1
  /**
2
- * Extended gRPC retry tests — ported from Python test_grpc_retry.py.
3
- *
4
- * Covers config validation, backoff timing, INTERNAL error handling,
5
- * mixed error sequences, and max-retries edge cases beyond the core
6
- * 15 tests in grpc-retry.test.ts.
2
+ * Extended gRPC error-classification tests — ported from Python
3
+ * test_grpc_retry.py. Covers additional status codes beyond the core
4
+ * grpc-retry.test.ts. The persist-with-backoff loop that consumes these
5
+ * classifiers is tested in status.test.ts ("persistStatus — transient retry").
7
6
  */
8
7
 
9
- import { describe, it, expect, vi } from "vitest";
8
+ import { describe, it, expect } from "vitest";
10
9
  import { ConnectError, Code } from "@connectrpc/connect";
11
- import { create } from "@bufbuild/protobuf";
12
- import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
13
- import { ExecutionControlSignal } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
- import { persistWithRetry, isRetryableError, isTerminalError } from "../grpc-retry.js";
15
- import type { StigmerClient } from "../../client/stigmer-client.js";
16
-
17
- function makeStatus() {
18
- return create(AgentExecutionStatusSchema, {});
19
- }
20
-
21
- function mockClient(
22
- impl: (...args: unknown[]) => Promise<{ signal: ExecutionControlSignal }>,
23
- ): StigmerClient {
24
- return { updateStatus: impl } as unknown as StigmerClient;
25
- }
26
-
27
- const noDelay = async () => {};
10
+ import { isRetryableError, isTerminalError } from "../grpc-retry.js";
28
11
 
29
12
  describe("isRetryableError — extended codes", () => {
30
13
  it("returns false for INTERNAL", () => {
@@ -53,124 +36,3 @@ describe("isTerminalError — extended codes", () => {
53
36
  expect(isTerminalError(new Error("random"))).toBe(false);
54
37
  });
55
38
  });
56
-
57
- describe("persistWithRetry — extended scenarios", () => {
58
- it("does not retry INTERNAL errors (neither retryable nor terminal)", async () => {
59
- let attempt = 0;
60
- const client = mockClient(async () => {
61
- attempt++;
62
- throw new ConnectError("internal", Code.Internal);
63
- });
64
-
65
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66
- await persistWithRetry(client, "exec-int", makeStatus(), {
67
- maxRetries: 3,
68
- delayFn: noDelay,
69
- });
70
- errorSpy.mockRestore();
71
-
72
- expect(attempt).toBe(1);
73
- });
74
-
75
- it("does not retry ALREADY_EXISTS", async () => {
76
- let attempt = 0;
77
- const client = mockClient(async () => {
78
- attempt++;
79
- throw new ConnectError("exists", Code.AlreadyExists);
80
- });
81
-
82
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
83
- await persistWithRetry(client, "exec-ae", makeStatus(), {
84
- maxRetries: 3,
85
- delayFn: noDelay,
86
- });
87
- errorSpy.mockRestore();
88
-
89
- expect(attempt).toBe(1);
90
- });
91
-
92
- it("handles mixed error sequence: retryable then terminal", async () => {
93
- let attempt = 0;
94
- const client = mockClient(async () => {
95
- attempt++;
96
- if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
97
- throw new ConnectError("gone", Code.NotFound);
98
- });
99
-
100
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
101
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
102
- const signal = await persistWithRetry(client, "exec-mix", makeStatus(), {
103
- delayFn: noDelay,
104
- });
105
- warnSpy.mockRestore();
106
- errorSpy.mockRestore();
107
-
108
- expect(attempt).toBe(2);
109
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
110
- });
111
-
112
- it("handles retryable then success", async () => {
113
- let attempt = 0;
114
- const client = mockClient(async () => {
115
- attempt++;
116
- if (attempt <= 2) throw new ConnectError("timeout", Code.DeadlineExceeded);
117
- return { signal: ExecutionControlSignal.STOP };
118
- });
119
-
120
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
121
- const signal = await persistWithRetry(client, "exec-ret", makeStatus(), {
122
- maxRetries: 5,
123
- delayFn: noDelay,
124
- });
125
- warnSpy.mockRestore();
126
-
127
- expect(signal).toBe(ExecutionControlSignal.STOP);
128
- expect(attempt).toBe(3);
129
- });
130
-
131
- it("maxRetries = 0 means one attempt only", async () => {
132
- let attempt = 0;
133
- const client = mockClient(async () => {
134
- attempt++;
135
- throw new ConnectError("down", Code.Unavailable);
136
- });
137
-
138
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
139
- await persistWithRetry(client, "exec-0", makeStatus(), {
140
- maxRetries: 0,
141
- delayFn: noDelay,
142
- });
143
- errorSpy.mockRestore();
144
-
145
- expect(attempt).toBe(1);
146
- });
147
-
148
- it("respects custom backoff factor", async () => {
149
- const delays: number[] = [];
150
- const client = mockClient(async () => {
151
- throw new ConnectError("down", Code.Unavailable);
152
- });
153
-
154
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
155
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
156
- await persistWithRetry(client, "exec-bf", makeStatus(), {
157
- baseDelayMs: 50,
158
- backoffFactor: 3,
159
- maxRetries: 3,
160
- delayFn: async (ms) => { delays.push(ms); },
161
- });
162
- warnSpy.mockRestore();
163
- errorSpy.mockRestore();
164
-
165
- expect(delays).toEqual([50, 150, 450]);
166
- });
167
-
168
- it("uses default options when none provided", async () => {
169
- const client = mockClient(async () => ({
170
- signal: ExecutionControlSignal.UNSPECIFIED,
171
- }));
172
-
173
- const signal = await persistWithRetry(client, "exec-default", makeStatus());
174
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
175
- });
176
- });
@@ -1,26 +1,10 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { describe, it, expect } from "vitest";
2
2
  import { ConnectError, Code } from "@connectrpc/connect";
3
- import { create } from "@bufbuild/protobuf";
4
- import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
- import { ExecutionControlSignal } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
6
- import {
7
- persistWithRetry,
8
- isRetryableError,
9
- isTerminalError,
10
- } from "../grpc-retry.js";
11
- import type { StigmerClient } from "../../client/stigmer-client.js";
3
+ import { isRetryableError, isTerminalError } from "../grpc-retry.js";
12
4
 
13
- function makeStatus() {
14
- return create(AgentExecutionStatusSchema, {});
15
- }
16
-
17
- function mockClient(
18
- updateStatusImpl: (...args: unknown[]) => Promise<{ signal: ExecutionControlSignal }>,
19
- ): StigmerClient {
20
- return { updateStatus: updateStatusImpl } as unknown as StigmerClient;
21
- }
22
-
23
- const noDelay = async () => {};
5
+ // The persist-with-backoff loop that consumes these classifiers lives in
6
+ // status.ts and is exercised by status.test.ts ("persistStatus — transient
7
+ // retry"). This file covers the classification policy in isolation.
24
8
 
25
9
  describe("isRetryableError", () => {
26
10
  it("returns true for UNAVAILABLE", () => {
@@ -57,116 +41,3 @@ describe("isTerminalError", () => {
57
41
  expect(isTerminalError(new ConnectError("down", Code.Unavailable))).toBe(false);
58
42
  });
59
43
  });
60
-
61
- describe("persistWithRetry", () => {
62
- it("returns signal on successful first attempt", async () => {
63
- const client = mockClient(async () => ({
64
- signal: ExecutionControlSignal.STOP,
65
- }));
66
-
67
- const signal = await persistWithRetry(client, "exec-1", makeStatus(), {
68
- delayFn: noDelay,
69
- });
70
-
71
- expect(signal).toBe(ExecutionControlSignal.STOP);
72
- });
73
-
74
- it("retries on UNAVAILABLE and succeeds", async () => {
75
- let attempt = 0;
76
- const client = mockClient(async () => {
77
- attempt++;
78
- if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
79
- return { signal: ExecutionControlSignal.UNSPECIFIED };
80
- });
81
-
82
- const signal = await persistWithRetry(client, "exec-2", makeStatus(), {
83
- delayFn: noDelay,
84
- });
85
-
86
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
87
- expect(attempt).toBe(2);
88
- });
89
-
90
- it("does not retry terminal errors (NOT_FOUND)", async () => {
91
- let attempt = 0;
92
- const client = mockClient(async () => {
93
- attempt++;
94
- throw new ConnectError("gone", Code.NotFound);
95
- });
96
-
97
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
98
- const signal = await persistWithRetry(client, "exec-3", makeStatus(), {
99
- delayFn: noDelay,
100
- });
101
- errorSpy.mockRestore();
102
-
103
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
104
- expect(attempt).toBe(1);
105
- });
106
-
107
- it("returns UNSPECIFIED after all retries exhausted", async () => {
108
- const client = mockClient(async () => {
109
- throw new ConnectError("down", Code.Unavailable);
110
- });
111
-
112
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
113
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
114
- const signal = await persistWithRetry(client, "exec-4", makeStatus(), {
115
- maxRetries: 3,
116
- delayFn: noDelay,
117
- });
118
- warnSpy.mockRestore();
119
- errorSpy.mockRestore();
120
-
121
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
122
- });
123
-
124
- it("uses correct backoff delays", async () => {
125
- const delays: number[] = [];
126
- const client = mockClient(async () => {
127
- throw new ConnectError("down", Code.Unavailable);
128
- });
129
-
130
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
131
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
132
- await persistWithRetry(client, "exec-5", makeStatus(), {
133
- baseDelayMs: 100,
134
- backoffFactor: 2,
135
- maxRetries: 3,
136
- delayFn: async (ms) => { delays.push(ms); },
137
- });
138
- warnSpy.mockRestore();
139
- errorSpy.mockRestore();
140
-
141
- expect(delays).toEqual([100, 200, 400]);
142
- });
143
-
144
- it("handles non-ConnectError by not retrying", async () => {
145
- let attempt = 0;
146
- const client = mockClient(async () => {
147
- attempt++;
148
- throw new Error("unexpected");
149
- });
150
-
151
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
152
- const signal = await persistWithRetry(client, "exec-6", makeStatus(), {
153
- delayFn: noDelay,
154
- });
155
- errorSpy.mockRestore();
156
-
157
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
158
- expect(attempt).toBe(1);
159
- });
160
-
161
- it("never throws regardless of error type", async () => {
162
- const client = mockClient(async () => {
163
- throw new ConnectError("server exploded", Code.Internal);
164
- });
165
-
166
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
167
- await expect(
168
- persistWithRetry(client, "exec-7", makeStatus(), { delayFn: noDelay }),
169
- ).resolves.toBe(ExecutionControlSignal.UNSPECIFIED);
170
- errorSpy.mockRestore();
171
- });
172
- });