@stigmer/runner 3.0.8-dev.20260613074252 → 3.0.8

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 (97) 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 +210 -79
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/message-translator.d.ts +35 -0
  8. package/dist/activities/execute-cursor/message-translator.js +114 -6
  9. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
  11. package/dist/activities/execute-cursor/prompt-builder.js +54 -0
  12. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  13. package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
  14. package/dist/activities/execute-cursor/workspace-setup.js +62 -30
  15. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/index.js +14 -4
  17. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
  19. package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
  20. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder.js +4 -5
  22. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/streaming-v3.js +3 -4
  24. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/streaming.d.ts +8 -0
  26. package/dist/activities/execute-deep-agent/streaming.js +3 -4
  27. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
  29. package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
  31. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  32. package/dist/config.d.ts +21 -0
  33. package/dist/config.js +12 -0
  34. package/dist/config.js.map +1 -1
  35. package/dist/in-flight.d.ts +35 -0
  36. package/dist/in-flight.js +61 -0
  37. package/dist/in-flight.js.map +1 -0
  38. package/dist/main.js +6 -3
  39. package/dist/main.js.map +1 -1
  40. package/dist/runner-manager.d.ts +2 -0
  41. package/dist/runner-manager.js +90 -29
  42. package/dist/runner-manager.js.map +1 -1
  43. package/dist/runner.d.ts +2 -0
  44. package/dist/runner.js +2 -0
  45. package/dist/runner.js.map +1 -1
  46. package/dist/shared/grpc-retry.d.ts +9 -20
  47. package/dist/shared/grpc-retry.js +9 -52
  48. package/dist/shared/grpc-retry.js.map +1 -1
  49. package/dist/shared/stall-watchdog.d.ts +68 -0
  50. package/dist/shared/stall-watchdog.js +102 -0
  51. package/dist/shared/stall-watchdog.js.map +1 -0
  52. package/dist/shared/status-offload.d.ts +84 -0
  53. package/dist/shared/status-offload.js +292 -0
  54. package/dist/shared/status-offload.js.map +1 -0
  55. package/dist/shared/status.d.ts +34 -3
  56. package/dist/shared/status.js +102 -9
  57. package/dist/shared/status.js.map +1 -1
  58. package/package.json +2 -2
  59. package/src/__tests__/config.test.ts +8 -0
  60. package/src/__tests__/in-flight.test.ts +84 -0
  61. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  62. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  63. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
  64. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
  65. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
  66. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
  67. package/src/activities/execute-cursor/hook-script.ts +85 -51
  68. package/src/activities/execute-cursor/index.ts +170 -35
  69. package/src/activities/execute-cursor/message-translator.ts +113 -6
  70. package/src/activities/execute-cursor/prompt-builder.ts +59 -0
  71. package/src/activities/execute-cursor/workspace-setup.ts +76 -44
  72. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  73. package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
  74. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
  75. package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
  76. package/src/activities/execute-deep-agent/index.ts +16 -4
  77. package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
  78. package/src/activities/execute-deep-agent/status-builder.ts +3 -5
  79. package/src/activities/execute-deep-agent/streaming-v3.ts +4 -4
  80. package/src/activities/execute-deep-agent/streaming.ts +13 -4
  81. package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
  82. package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
  83. package/src/config.ts +27 -0
  84. package/src/in-flight.ts +71 -0
  85. package/src/main.ts +7 -2
  86. package/src/runner-manager.ts +127 -33
  87. package/src/runner.ts +6 -0
  88. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  89. package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
  90. package/src/shared/__tests__/grpc-retry.test.ts +5 -134
  91. package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
  92. package/src/shared/__tests__/status-offload.test.ts +256 -0
  93. package/src/shared/__tests__/status.test.ts +199 -0
  94. package/src/shared/grpc-retry.ts +9 -72
  95. package/src/shared/stall-watchdog.ts +122 -0
  96. package/src/shared/status-offload.ts +342 -0
  97. package/src/shared/status.ts +142 -8
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { create, toBinary } from "@bufbuild/protobuf";
3
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
4
+ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
5
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
6
+ import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
7
+ import type { ArtifactStorage } from "../artifact-storage.js";
8
+ import {
9
+ offloadOversizedToolOutputs,
10
+ enforceStatusSizeLimit,
11
+ detectImagePayload,
12
+ } from "../status-offload.js";
13
+
14
+ function makeFakeStorage() {
15
+ const uploads: { key: string; size: number; contentType?: string }[] = [];
16
+ const storage: ArtifactStorage = {
17
+ upload: vi.fn(async (key: string, content: Buffer, contentType?: string) => {
18
+ uploads.push({ key, size: content.length, contentType });
19
+ return key;
20
+ }),
21
+ getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
22
+ exists: vi.fn(async () => true),
23
+ };
24
+ return { storage, uploads };
25
+ }
26
+
27
+ function statusWithToolCall(tc: ToolCall): AgentExecutionStatus {
28
+ return create(AgentExecutionStatusSchema, {
29
+ messages: [create(AgentMessageSchema, { toolCalls: [tc] })],
30
+ });
31
+ }
32
+
33
+ function encodedSize(status: AgentExecutionStatus): number {
34
+ return toBinary(AgentExecutionStatusSchema, status).length;
35
+ }
36
+
37
+ const BIG_BASE64_IMAGE = Buffer.from("x".repeat(4096)).toString("base64");
38
+
39
+ describe("detectImagePayload", () => {
40
+ it("extracts an MCP image content block", () => {
41
+ const result = JSON.stringify([
42
+ { type: "text", text: "Screenshot captured" },
43
+ { type: "image", data: BIG_BASE64_IMAGE, mimeType: "image/png" },
44
+ ]);
45
+ const img = detectImagePayload(result);
46
+ expect(img).not.toBeNull();
47
+ expect(img?.mimeType).toBe("image/png");
48
+ expect(img?.base64).toBe(BIG_BASE64_IMAGE);
49
+ });
50
+
51
+ it("extracts a raw data URL", () => {
52
+ const img = detectImagePayload(`data:image/jpeg;base64,${BIG_BASE64_IMAGE}`);
53
+ expect(img?.mimeType).toBe("image/jpeg");
54
+ expect(img?.base64).toBe(BIG_BASE64_IMAGE);
55
+ });
56
+
57
+ it("returns null for plain text", () => {
58
+ expect(detectImagePayload("just some logs")).toBeNull();
59
+ });
60
+
61
+ it("extracts an image from a serialized LangChain envelope (kwargs.content)", () => {
62
+ // Defensive shape: blocks nested under kwargs.content rather than a
63
+ // top-level array. The extractor normalizes to a top-level array, but
64
+ // detection must still cope if that ever drifts.
65
+ const result = JSON.stringify({
66
+ lc: 1,
67
+ type: "constructor",
68
+ id: ["langchain_core", "messages", "ToolMessage"],
69
+ kwargs: { content: [{ type: "image", data: BIG_BASE64_IMAGE, mimeType: "image/png" }] },
70
+ });
71
+ const img = detectImagePayload(result);
72
+ expect(img?.mimeType).toBe("image/png");
73
+ expect(img?.base64).toBe(BIG_BASE64_IMAGE);
74
+ });
75
+ });
76
+
77
+ describe("offloadOversizedToolOutputs", () => {
78
+ it("offloads an oversized image result and renders it inline-able", async () => {
79
+ const { storage, uploads } = makeFakeStorage();
80
+ const result = JSON.stringify([
81
+ { type: "image", data: BIG_BASE64_IMAGE, mimeType: "image/png" },
82
+ ]);
83
+ const tc = create(ToolCallSchema, { id: "tc-1", name: "screenshot", result });
84
+ const status = statusWithToolCall(tc);
85
+
86
+ await offloadOversizedToolOutputs(status, {
87
+ artifactStorage: storage,
88
+ executionId: "exec-1",
89
+ maxInlineBytes: 256,
90
+ });
91
+
92
+ const out = status.messages[0].toolCalls[0];
93
+ expect(out.outputRef).toBeDefined();
94
+ expect(out.outputRef?.isImage).toBe(true);
95
+ expect(out.outputRef?.mimeType).toBe("image/png");
96
+ expect(out.outputRef?.downloadUrl).toContain("artifacts/exec-1/toolcalls/tc-1.png");
97
+ // result is collapsed to a short label, no longer the giant blob.
98
+ expect(out.result.length).toBeLessThan(200);
99
+ expect(uploads).toHaveLength(1);
100
+ expect(uploads[0].contentType).toBe("image/png");
101
+ });
102
+
103
+ it("offloads oversized text with a preview head and text/plain upload", async () => {
104
+ const { storage, uploads } = makeFakeStorage();
105
+ const result = "LOG ".repeat(2000); // ~8 KB
106
+ const tc = create(ToolCallSchema, { id: "tc-2", name: "Shell", result });
107
+ const status = statusWithToolCall(tc);
108
+
109
+ await offloadOversizedToolOutputs(status, {
110
+ artifactStorage: storage,
111
+ executionId: "exec-1",
112
+ maxInlineBytes: 256,
113
+ });
114
+
115
+ const out = status.messages[0].toolCalls[0];
116
+ expect(out.outputRef?.isImage).toBe(false);
117
+ expect(out.outputRef?.mimeType).toBe("text/plain");
118
+ expect(out.outputRef?.truncatedPreview.length).toBeGreaterThan(0);
119
+ expect(out.result).toContain("view full output");
120
+ expect(uploads).toHaveLength(1);
121
+ expect(uploads[0].contentType).toBe("text/plain");
122
+ });
123
+
124
+ it("offloads a SMALL image regardless of size (output_ref is the only render path)", async () => {
125
+ const { storage, uploads } = makeFakeStorage();
126
+ // A tiny image, far below the inline byte budget. It must still offload,
127
+ // because the UI can only render an image through ToolCallOutputRef.
128
+ const smallImage = Buffer.from("tiny-png").toString("base64");
129
+ const result = JSON.stringify([
130
+ { type: "image", data: smallImage, mimeType: "image/png" },
131
+ ]);
132
+ const tc = create(ToolCallSchema, { id: "tc-img", name: "screenshot", result });
133
+ const status = statusWithToolCall(tc);
134
+ expect(result.length).toBeLessThan(256);
135
+
136
+ await offloadOversizedToolOutputs(status, {
137
+ artifactStorage: storage,
138
+ executionId: "exec-1",
139
+ maxInlineBytes: 256,
140
+ });
141
+
142
+ const out = status.messages[0].toolCalls[0];
143
+ expect(out.outputRef?.isImage).toBe(true);
144
+ expect(out.outputRef?.downloadUrl).toContain("artifacts/exec-1/toolcalls/tc-img.png");
145
+ expect(out.result).not.toContain(smallImage);
146
+ expect(uploads).toHaveLength(1);
147
+ expect(uploads[0].contentType).toBe("image/png");
148
+ });
149
+
150
+ it("leaves small results untouched (no artifact, no ref)", async () => {
151
+ const { storage, uploads } = makeFakeStorage();
152
+ const tc = create(ToolCallSchema, { id: "tc-3", name: "Read", result: "small output" });
153
+ const status = statusWithToolCall(tc);
154
+
155
+ await offloadOversizedToolOutputs(status, {
156
+ artifactStorage: storage,
157
+ executionId: "exec-1",
158
+ maxInlineBytes: 256,
159
+ });
160
+
161
+ const out = status.messages[0].toolCalls[0];
162
+ expect(out.outputRef).toBeUndefined();
163
+ expect(out.result).toBe("small output");
164
+ expect(uploads).toHaveLength(0);
165
+ });
166
+
167
+ it("is idempotent and dedupes uploads when result is re-inflated with identical bytes", async () => {
168
+ const { storage, uploads } = makeFakeStorage();
169
+ const result = "LOG ".repeat(2000);
170
+ const tc = create(ToolCallSchema, { id: "tc-4", name: "Shell", result });
171
+ const status = statusWithToolCall(tc);
172
+ const ctx = { artifactStorage: storage, executionId: "exec-1", maxInlineBytes: 256 };
173
+
174
+ await offloadOversizedToolOutputs(status, ctx);
175
+ expect(uploads).toHaveLength(1);
176
+
177
+ // Simulate mergeToolCallEvent re-inflating the inline result with the SAME
178
+ // bytes on a subsequent persist; the ref's content hash still matches.
179
+ status.messages[0].toolCalls[0].result = result;
180
+ await offloadOversizedToolOutputs(status, ctx);
181
+
182
+ expect(uploads).toHaveLength(1); // not re-uploaded
183
+ expect(status.messages[0].toolCalls[0].result).toContain("view full output");
184
+ });
185
+
186
+ it("keeps the encoded status small after offloading a large blob", async () => {
187
+ const { storage } = makeFakeStorage();
188
+ const result = JSON.stringify([
189
+ { type: "image", data: Buffer.from("y".repeat(500_000)).toString("base64"), mimeType: "image/png" },
190
+ ]);
191
+ const tc = create(ToolCallSchema, { id: "tc-5", name: "screenshot", result });
192
+ const status = statusWithToolCall(tc);
193
+ expect(encodedSize(status)).toBeGreaterThan(500_000);
194
+
195
+ await offloadOversizedToolOutputs(status, {
196
+ artifactStorage: storage,
197
+ executionId: "exec-1",
198
+ });
199
+
200
+ expect(encodedSize(status)).toBeLessThan(10_000);
201
+ });
202
+
203
+ it("falls back to inline truncation when the upload fails (never throws)", async () => {
204
+ const storage: ArtifactStorage = {
205
+ upload: vi.fn(async () => { throw new Error("storage down"); }),
206
+ getDownloadUrl: vi.fn(async (k: string) => k),
207
+ exists: vi.fn(async () => false),
208
+ };
209
+ const result = "LOG ".repeat(2000);
210
+ const tc = create(ToolCallSchema, { id: "tc-6", name: "Shell", result });
211
+ const status = statusWithToolCall(tc);
212
+ vi.spyOn(console, "warn").mockImplementation(() => {});
213
+
214
+ await expect(
215
+ offloadOversizedToolOutputs(status, {
216
+ artifactStorage: storage,
217
+ executionId: "exec-1",
218
+ maxInlineBytes: 256,
219
+ }),
220
+ ).resolves.toBeUndefined();
221
+
222
+ const out = status.messages[0].toolCalls[0];
223
+ expect(out.outputRef).toBeUndefined();
224
+ expect(out.result).toContain("offload failed");
225
+ expect(out.result.length).toBeLessThan(result.length);
226
+ });
227
+ });
228
+
229
+ describe("enforceStatusSizeLimit", () => {
230
+ it("returns false and changes nothing when under the limit", () => {
231
+ const tc = create(ToolCallSchema, { id: "tc", name: "Read", result: "tiny" });
232
+ const status = statusWithToolCall(tc);
233
+ expect(enforceStatusSizeLimit(status, 1024 * 1024)).toBe(false);
234
+ expect(status.messages[0].toolCalls[0].result).toBe("tiny");
235
+ });
236
+
237
+ it("elides the largest inline fields until the status fits", () => {
238
+ const big = "Z".repeat(50_000);
239
+ const status = create(AgentExecutionStatusSchema, {
240
+ messages: [
241
+ create(AgentMessageSchema, {
242
+ toolCalls: [
243
+ create(ToolCallSchema, { id: "a", name: "Shell", result: big }),
244
+ create(ToolCallSchema, { id: "b", name: "Read", result: big }),
245
+ ],
246
+ }),
247
+ ],
248
+ });
249
+ const before = encodedSize(status);
250
+ expect(before).toBeGreaterThan(80_000);
251
+
252
+ const elided = enforceStatusSizeLimit(status, 4_000);
253
+ expect(elided).toBe(true);
254
+ expect(encodedSize(status)).toBeLessThanOrEqual(4_000);
255
+ });
256
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
+ import { ConnectError, Code } from "@connectrpc/connect";
2
3
  import { create } from "@bufbuild/protobuf";
3
4
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
4
5
  import { ExecutionControlSignal, ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
@@ -123,6 +124,204 @@ describe("persistStatus", () => {
123
124
  expect(consoleSpy).toHaveBeenCalled();
124
125
  consoleSpy.mockRestore();
125
126
  });
127
+
128
+ it("hard-elides and retries once on a resource_exhausted (code 8) failure", async () => {
129
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
130
+ const updateStatus = vi
131
+ .fn()
132
+ .mockRejectedValueOnce({ code: 8, message: "resource_exhausted: exceeds maximum size" })
133
+ .mockResolvedValueOnce({ signal: ExecutionControlSignal.STOP });
134
+ const mockClient = { updateStatus } as any;
135
+ const status = create(AgentExecutionStatusSchema, {
136
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
137
+ });
138
+
139
+ const signal = await persistStatus(mockClient, "exec-too-big", status);
140
+ expect(signal).toBe(ExecutionControlSignal.STOP);
141
+ expect(updateStatus).toHaveBeenCalledTimes(2);
142
+ consoleSpy.mockRestore();
143
+ });
144
+
145
+ it("offloads oversized tool outputs before persisting when given an offload context", async () => {
146
+ const uploaded: string[] = [];
147
+ const offload = {
148
+ executionId: "exec-off",
149
+ artifactStorage: {
150
+ upload: vi.fn(async (key: string) => { uploaded.push(key); return key; }),
151
+ getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
152
+ exists: vi.fn(async () => true),
153
+ },
154
+ maxInlineBytes: 256,
155
+ };
156
+ const mockClient = {
157
+ updateStatus: vi.fn().mockResolvedValue({ signal: ExecutionControlSignal.UNSPECIFIED }),
158
+ } as any;
159
+ const { AgentMessageSchema, ToolCallSchema } = await import(
160
+ "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb"
161
+ );
162
+ const status = create(AgentExecutionStatusSchema, {
163
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
164
+ messages: [
165
+ create(AgentMessageSchema, {
166
+ toolCalls: [create(ToolCallSchema, { id: "t", name: "Shell", result: "X".repeat(5000) })],
167
+ }),
168
+ ],
169
+ });
170
+
171
+ await persistStatus(mockClient, "exec-off", status, { offload });
172
+
173
+ expect(uploaded).toHaveLength(1);
174
+ const [, persisted] = mockClient.updateStatus.mock.calls[0];
175
+ expect(persisted.messages[0].toolCalls[0].outputRef).toBeDefined();
176
+ expect(persisted.messages[0].toolCalls[0].result.length).toBeLessThan(5000);
177
+ });
178
+ });
179
+
180
+ // Migrated from grpc-retry.test.ts: the transient-backoff loop now lives inside
181
+ // persistStatus so every persist (streaming + terminal, every harness) shares
182
+ // one chokepoint that bounds size AND retries transient transport errors.
183
+ describe("persistStatus — transient retry", () => {
184
+ const noDelay = async () => {};
185
+ const emptyStatus = () => create(AgentExecutionStatusSchema, {});
186
+ function retryClient(impl: () => Promise<{ signal: ExecutionControlSignal }>) {
187
+ return { updateStatus: vi.fn(impl) } as any;
188
+ }
189
+
190
+ it("returns the signal on the first successful attempt", async () => {
191
+ const client = retryClient(async () => ({ signal: ExecutionControlSignal.STOP }));
192
+ const signal = await persistStatus(client, "exec-1", emptyStatus(), {
193
+ retry: { delayFn: noDelay },
194
+ });
195
+ expect(signal).toBe(ExecutionControlSignal.STOP);
196
+ expect(client.updateStatus).toHaveBeenCalledOnce();
197
+ });
198
+
199
+ it("retries a transient UNAVAILABLE and then succeeds", async () => {
200
+ let attempt = 0;
201
+ const client = retryClient(async () => {
202
+ attempt++;
203
+ if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
204
+ return { signal: ExecutionControlSignal.UNSPECIFIED };
205
+ });
206
+ const signal = await persistStatus(client, "exec-2", emptyStatus(), {
207
+ retry: { delayFn: noDelay },
208
+ });
209
+ expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
210
+ expect(attempt).toBe(2);
211
+ });
212
+
213
+ it("does not retry terminal errors (NOT_FOUND)", async () => {
214
+ let attempt = 0;
215
+ const client = retryClient(async () => {
216
+ attempt++;
217
+ throw new ConnectError("gone", Code.NotFound);
218
+ });
219
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
220
+ const signal = await persistStatus(client, "exec-3", emptyStatus(), {
221
+ retry: { delayFn: noDelay },
222
+ });
223
+ errorSpy.mockRestore();
224
+ expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
225
+ expect(attempt).toBe(1);
226
+ });
227
+
228
+ it("does not retry INTERNAL (neither retryable nor terminal)", async () => {
229
+ let attempt = 0;
230
+ const client = retryClient(async () => {
231
+ attempt++;
232
+ throw new ConnectError("internal", Code.Internal);
233
+ });
234
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
235
+ await persistStatus(client, "exec-int", emptyStatus(), {
236
+ retry: { maxRetries: 3, delayFn: noDelay },
237
+ });
238
+ errorSpy.mockRestore();
239
+ expect(attempt).toBe(1);
240
+ });
241
+
242
+ it("applies exponential backoff and gives up after the retry budget", async () => {
243
+ const delays: number[] = [];
244
+ const client = retryClient(async () => {
245
+ throw new ConnectError("down", Code.Unavailable);
246
+ });
247
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
248
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
249
+ const signal = await persistStatus(client, "exec-5", emptyStatus(), {
250
+ retry: {
251
+ baseDelayMs: 100,
252
+ backoffFactor: 2,
253
+ maxRetries: 3,
254
+ delayFn: async (ms) => { delays.push(ms); },
255
+ },
256
+ });
257
+ warnSpy.mockRestore();
258
+ errorSpy.mockRestore();
259
+ expect(delays).toEqual([100, 200, 400]);
260
+ expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
261
+ });
262
+
263
+ it("respects a custom backoff factor", async () => {
264
+ const delays: number[] = [];
265
+ const client = retryClient(async () => {
266
+ throw new ConnectError("down", Code.Unavailable);
267
+ });
268
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
269
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
270
+ await persistStatus(client, "exec-bf", emptyStatus(), {
271
+ retry: {
272
+ baseDelayMs: 50,
273
+ backoffFactor: 3,
274
+ maxRetries: 3,
275
+ delayFn: async (ms) => { delays.push(ms); },
276
+ },
277
+ });
278
+ warnSpy.mockRestore();
279
+ errorSpy.mockRestore();
280
+ expect(delays).toEqual([50, 150, 450]);
281
+ });
282
+
283
+ it("maxRetries = 0 means a single attempt", async () => {
284
+ let attempt = 0;
285
+ const client = retryClient(async () => {
286
+ attempt++;
287
+ throw new ConnectError("down", Code.Unavailable);
288
+ });
289
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
290
+ await persistStatus(client, "exec-0", emptyStatus(), {
291
+ retry: { maxRetries: 0, delayFn: noDelay },
292
+ });
293
+ errorSpy.mockRestore();
294
+ expect(attempt).toBe(1);
295
+ });
296
+
297
+ it("handles a mixed sequence: transient then terminal", async () => {
298
+ let attempt = 0;
299
+ const client = retryClient(async () => {
300
+ attempt++;
301
+ if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
302
+ throw new ConnectError("gone", Code.NotFound);
303
+ });
304
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
305
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
306
+ const signal = await persistStatus(client, "exec-mix", emptyStatus(), {
307
+ retry: { delayFn: noDelay },
308
+ });
309
+ warnSpy.mockRestore();
310
+ errorSpy.mockRestore();
311
+ expect(attempt).toBe(2);
312
+ expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
313
+ });
314
+
315
+ it("never throws regardless of error type", async () => {
316
+ const client = retryClient(async () => {
317
+ throw new ConnectError("server exploded", Code.Internal);
318
+ });
319
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
320
+ await expect(
321
+ persistStatus(client, "exec-7", emptyStatus(), { retry: { delayFn: noDelay } }),
322
+ ).resolves.toBe(ExecutionControlSignal.UNSPECIFIED);
323
+ errorSpy.mockRestore();
324
+ });
126
325
  });
127
326
 
128
327
  describe("reportSetupProgress", () => {
@@ -1,20 +1,15 @@
1
1
  /**
2
- * Exponential-backoff retry wrapper for gRPC status persistence.
2
+ * gRPC error classification for status persistence retries.
3
3
  *
4
- * Wraps the raw StigmerClient.updateStatus() call with retry logic
5
- * that classifies gRPC error codes as retryable vs terminal. A failed
6
- * status update must never crash the streaming loop on permanent
7
- * failure, the signal falls back to UNSPECIFIED.
8
- *
9
- * Used by the ExecuteDeepAgent streaming loop. The simpler
10
- * fire-and-forget persistStatus in shared/status.ts is preserved
11
- * for ExecuteCursor and non-streaming callers.
4
+ * Status persistence flows through a single chokepoint — `persistStatus` in
5
+ * status.ts which uses these helpers to decide whether a failed
6
+ * `updateStatus` should back off and retry (transient transport errors) or
7
+ * fail fast (deterministic errors). This module is intentionally just the
8
+ * classification policy + its options type, kept small and separately tested;
9
+ * the persist loop that consumes it lives with the rest of the persist logic.
12
10
  */
13
11
 
14
12
  import { ConnectError, Code } from "@connectrpc/connect";
15
- import { ExecutionControlSignal } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
16
- import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
17
- import type { StigmerClient } from "../client/stigmer-client.js";
18
13
 
19
14
  export interface RetryOptions {
20
15
  /** Base delay before the first retry (ms). Default: 100. */
@@ -38,6 +33,7 @@ const TERMINAL_CODES = new Set<Code>([
38
33
  Code.PermissionDenied,
39
34
  ]);
40
35
 
36
+ /** True for transient transport errors that are worth retrying with backoff. */
41
37
  export function isRetryableError(err: unknown): boolean {
42
38
  if (err instanceof ConnectError) {
43
39
  return RETRYABLE_CODES.has(err.code);
@@ -45,69 +41,10 @@ export function isRetryableError(err: unknown): boolean {
45
41
  return false;
46
42
  }
47
43
 
44
+ /** True for deterministic errors that retrying cannot fix. */
48
45
  export function isTerminalError(err: unknown): boolean {
49
46
  if (err instanceof ConnectError) {
50
47
  return TERMINAL_CODES.has(err.code);
51
48
  }
52
49
  return false;
53
50
  }
54
-
55
- function defaultDelay(ms: number): Promise<void> {
56
- return new Promise(resolve => setTimeout(resolve, ms));
57
- }
58
-
59
- /**
60
- * Persist execution status with exponential-backoff retry.
61
- *
62
- * Returns the ExecutionControlSignal from the server on success.
63
- * On permanent or exhausted-retry failure, logs the error and
64
- * returns UNSPECIFIED (never throws).
65
- */
66
- export async function persistWithRetry(
67
- client: StigmerClient,
68
- executionId: string,
69
- status: AgentExecutionStatus,
70
- options?: RetryOptions,
71
- ): Promise<ExecutionControlSignal> {
72
- const baseDelay = options?.baseDelayMs ?? 100;
73
- const factor = options?.backoffFactor ?? 2;
74
- const maxRetries = options?.maxRetries ?? 3;
75
- const delay = options?.delayFn ?? defaultDelay;
76
-
77
- let lastError: unknown;
78
-
79
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
80
- try {
81
- const response = await client.updateStatus(executionId, status);
82
- return response.signal;
83
- } catch (err: unknown) {
84
- lastError = err;
85
-
86
- if (isTerminalError(err)) {
87
- const code = (err as ConnectError).code;
88
- console.error(
89
- `[grpc-retry] Terminal error persisting status for ${executionId}: ` +
90
- `code=${Code[code]} (attempt ${attempt + 1}/${maxRetries + 1})`,
91
- );
92
- return ExecutionControlSignal.UNSPECIFIED;
93
- }
94
-
95
- if (!isRetryableError(err) || attempt === maxRetries) {
96
- break;
97
- }
98
-
99
- const delayMs = baseDelay * Math.pow(factor, attempt);
100
- console.warn(
101
- `[grpc-retry] Retryable error for ${executionId}: ` +
102
- `code=${err instanceof ConnectError ? Code[err.code] : "unknown"} ` +
103
- `(attempt ${attempt + 1}/${maxRetries + 1}, retry in ${delayMs}ms)`,
104
- );
105
- await delay(delayMs);
106
- }
107
- }
108
-
109
- console.error(
110
- `[grpc-retry] All retries exhausted for ${executionId}: ${lastError}`,
111
- );
112
- return ExecutionControlSignal.UNSPECIFIED;
113
- }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Progress-based stall watchdog.
3
+ *
4
+ * Long-running agent turns are kept alive against Temporal by a periodic
5
+ * keep-alive heartbeat (see {@link ./heartbeat.ts}). That heartbeat proves the
6
+ * runner *process* is alive, but it says nothing about whether the agent is
7
+ * making *progress*: a turn that wedges inside the harness stream loop (a tool
8
+ * call that never returns, a model connection that silently dies) keeps
9
+ * heartbeating forever and never times out. The execution then sits at
10
+ * EXECUTION_IN_PROGRESS indefinitely.
11
+ *
12
+ * This watchdog closes that gap. It is an *out-of-band* timer: callers report
13
+ * progress via {@link StallWatchdog.recordActivity} on every stream event AND
14
+ * every token delta, and the watchdog fires `onStall` once if no progress is
15
+ * reported for `stallMs`. "Out-of-band" is the deliberate correctness property
16
+ * — the check runs on its own timer rather than inside the consumer's loop, so
17
+ * it catches a true "no new events for N minutes" hang. An in-band check that
18
+ * only runs when the loop advances cannot fire while the loop is blocked
19
+ * awaiting the next event, which is exactly the wedge we need to detect.
20
+ *
21
+ * This module is the convergence target for stall detection across harnesses:
22
+ * the Cursor harness wires it here, and the native deep-agent harness has its
23
+ * own (harness-local, in-band) check that should later converge onto this one.
24
+ */
25
+
26
+ /**
27
+ * Canonical default stall window. Harnesses may override with a larger value
28
+ * when their tool calls routinely run longer without emitting stream activity.
29
+ */
30
+ export const DEFAULT_STALL_TIMEOUT_MS = 120_000;
31
+
32
+ /**
33
+ * Thrown / reported when an agent stream makes no progress for longer than the
34
+ * configured stall window. Carries the observed idle duration so callers can
35
+ * build an actionable, recognizable error message.
36
+ */
37
+ export class StallTimeoutError extends Error {
38
+ constructor(
39
+ public readonly stalledMs: number,
40
+ detail?: string,
41
+ ) {
42
+ super(
43
+ `Agent stream stalled: no activity for ${Math.round(stalledMs / 1000)}s` +
44
+ (detail ? ` (${detail})` : ""),
45
+ );
46
+ this.name = "StallTimeoutError";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Recognizable prefix on every stall-induced failure message. Single source of
52
+ * truth so callers (and any future UI/log keying) match on one constant.
53
+ */
54
+ export const STALL_ERROR_PREFIX = "[StallTimeoutError]";
55
+
56
+ /**
57
+ * Build the user-facing failure text for a stall. Keeps the canonical wording
58
+ * (prefix + actionable "Retry or resume.") next to the error it describes.
59
+ */
60
+ export function formatStallFailure(error: StallTimeoutError): string {
61
+ return `${STALL_ERROR_PREFIX} ${error.message}. Retry or resume.`;
62
+ }
63
+
64
+ export interface StallWatchdog {
65
+ /** Reset the idle timer. Call on every stream event AND every token delta. */
66
+ recordActivity(): void;
67
+ /** Disarm the watchdog. Idempotent; safe to call in a `finally`. */
68
+ stop(): void;
69
+ }
70
+
71
+ /**
72
+ * Start an out-of-band stall watchdog.
73
+ *
74
+ * @param stallMs Idle window after which `onStall` fires.
75
+ * @param onStall Invoked at most once with the observed idle duration (ms)
76
+ * when `Date.now() - lastActivityAt` exceeds `stallMs`. The
77
+ * watchdog disarms itself before invoking, so `onStall` runs
78
+ * exactly once even if its handler is slow.
79
+ *
80
+ * The poll interval is `stallMs / 4` capped at 15s: frequent enough to detect a
81
+ * stall promptly without busy-looping, and bounded so a large `stallMs` still
82
+ * polls on a sane cadence.
83
+ */
84
+ export function startStallWatchdog(
85
+ stallMs: number,
86
+ onStall: (idleMs: number) => void,
87
+ ): StallWatchdog {
88
+ let lastActivityAt = Date.now();
89
+ let fired = false;
90
+ let timer: ReturnType<typeof setInterval> | undefined;
91
+
92
+ const tickMs = Math.min(Math.max(Math.floor(stallMs / 4), 1), 15_000);
93
+
94
+ const disarm = (): void => {
95
+ if (timer !== undefined) {
96
+ clearInterval(timer);
97
+ timer = undefined;
98
+ }
99
+ };
100
+
101
+ timer = setInterval(() => {
102
+ if (fired) return;
103
+ const idleMs = Date.now() - lastActivityAt;
104
+ if (idleMs >= stallMs) {
105
+ fired = true;
106
+ disarm();
107
+ onStall(idleMs);
108
+ }
109
+ }, tickMs);
110
+ // Do not keep the event loop alive solely for the watchdog.
111
+ timer.unref?.();
112
+
113
+ return {
114
+ recordActivity(): void {
115
+ lastActivityAt = Date.now();
116
+ },
117
+ stop(): void {
118
+ fired = true;
119
+ disarm();
120
+ },
121
+ };
122
+ }