@stigmer/runner 3.0.8-dev.20260613085218 → 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.
- 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 +210 -79
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +35 -0
- package/dist/activities/execute-cursor/message-translator.js +114 -6
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- 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 +14 -4
- 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 +3 -4
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +8 -0
- package/dist/activities/execute-deep-agent/streaming.js +3 -4
- 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/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__/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 +170 -35
- package/src/activities/execute-cursor/message-translator.ts +113 -6
- 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 +16 -4
- 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 +4 -4
- package/src/activities/execute-deep-agent/streaming.ts +13 -4
- 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
|
@@ -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", () => {
|
package/src/shared/grpc-retry.ts
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* gRPC error classification for status persistence retries.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
+
}
|