@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
- package/dist/activities/execute-cursor/hook-script.js +85 -51
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +220 -79
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +51 -9
- package/dist/activities/execute-cursor/message-translator.js +146 -20
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/persist-decision.d.ts +42 -0
- package/dist/activities/execute-cursor/persist-decision.js +30 -0
- package/dist/activities/execute-cursor/persist-decision.js.map +1 -0
- package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
- package/dist/activities/execute-cursor/prompt-builder.js +54 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
- package/dist/activities/execute-cursor/workspace-setup.js +62 -30
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +15 -5
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +4 -5
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +4 -5
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +9 -1
- package/dist/activities/execute-deep-agent/streaming.js +4 -5
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
- package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/in-flight.d.ts +35 -0
- package/dist/in-flight.js +61 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +90 -29
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +9 -20
- package/dist/shared/grpc-retry.js +9 -52
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/stall-watchdog.d.ts +68 -0
- package/dist/shared/stall-watchdog.js +102 -0
- package/dist/shared/stall-watchdog.js.map +1 -0
- package/dist/shared/status-offload.d.ts +84 -0
- package/dist/shared/status-offload.js +292 -0
- package/dist/shared/status-offload.js.map +1 -0
- package/dist/shared/status.d.ts +34 -3
- package/dist/shared/status.js +102 -9
- package/dist/shared/status.js.map +1 -1
- package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.d.ts +4 -0
- package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.js +4 -0
- package/dist/shared/streaming-scheduler.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/__tests__/in-flight.test.ts +84 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +124 -12
- package/src/activities/execute-cursor/__tests__/persist-decision.test.ts +99 -0
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
- package/src/activities/execute-cursor/hook-script.ts +85 -51
- package/src/activities/execute-cursor/index.ts +187 -38
- package/src/activities/execute-cursor/message-translator.ts +146 -20
- package/src/activities/execute-cursor/persist-decision.ts +54 -0
- package/src/activities/execute-cursor/prompt-builder.ts +59 -0
- package/src/activities/execute-cursor/workspace-setup.ts +76 -44
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
- package/src/activities/execute-deep-agent/index.ts +17 -5
- package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
- package/src/activities/execute-deep-agent/status-builder.ts +3 -5
- package/src/activities/execute-deep-agent/streaming-v3.ts +5 -5
- package/src/activities/execute-deep-agent/streaming.ts +14 -5
- package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
- package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
- package/src/config.ts +27 -0
- package/src/in-flight.ts +71 -0
- package/src/runner-manager.ts +127 -33
- package/src/runner.ts +6 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
- package/src/shared/__tests__/grpc-retry.test.ts +5 -134
- package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
- package/src/shared/__tests__/status-offload.test.ts +256 -0
- package/src/shared/__tests__/status.test.ts +199 -0
- package/src/shared/grpc-retry.ts +9 -72
- package/src/shared/stall-watchdog.ts +122 -0
- package/src/shared/status-offload.ts +342 -0
- package/src/shared/status.ts +142 -8
- package/src/{activities/execute-deep-agent → shared}/streaming-scheduler.ts +4 -0
- package/dist/activities/execute-deep-agent/streaming-scheduler.js.map +0 -1
- /package/src/{activities/execute-deep-agent → shared}/__tests__/streaming-scheduler.test.ts +0 -0
package/src/runner-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
|
@@ -1,30 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Extended gRPC
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
10
9
|
import { ConnectError, Code } from "@connectrpc/connect";
|
|
11
|
-
import {
|
|
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
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { ConnectError, Code } from "@connectrpc/connect";
|
|
3
|
-
import {
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
});
|