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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
  3. package/dist/activities/execute-cursor/hook-script.js +85 -51
  4. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  5. package/dist/activities/execute-cursor/index.js +220 -79
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/message-translator.d.ts +51 -9
  8. package/dist/activities/execute-cursor/message-translator.js +146 -20
  9. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  10. package/dist/activities/execute-cursor/persist-decision.d.ts +42 -0
  11. package/dist/activities/execute-cursor/persist-decision.js +30 -0
  12. package/dist/activities/execute-cursor/persist-decision.js.map +1 -0
  13. package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
  14. package/dist/activities/execute-cursor/prompt-builder.js +54 -0
  15. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  16. package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
  17. package/dist/activities/execute-cursor/workspace-setup.js +62 -30
  18. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/index.js +15 -5
  20. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
  22. package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
  23. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/status-builder.js +4 -5
  25. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/streaming-v3.js +4 -5
  27. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/streaming.d.ts +9 -1
  29. package/dist/activities/execute-deep-agent/streaming.js +4 -5
  30. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  31. package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
  32. package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
  34. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  35. package/dist/config.d.ts +21 -0
  36. package/dist/config.js +12 -0
  37. package/dist/config.js.map +1 -1
  38. package/dist/in-flight.d.ts +35 -0
  39. package/dist/in-flight.js +61 -0
  40. package/dist/in-flight.js.map +1 -0
  41. package/dist/runner-manager.d.ts +2 -0
  42. package/dist/runner-manager.js +90 -29
  43. package/dist/runner-manager.js.map +1 -1
  44. package/dist/runner.d.ts +2 -0
  45. package/dist/runner.js +2 -0
  46. package/dist/runner.js.map +1 -1
  47. package/dist/shared/grpc-retry.d.ts +9 -20
  48. package/dist/shared/grpc-retry.js +9 -52
  49. package/dist/shared/grpc-retry.js.map +1 -1
  50. package/dist/shared/stall-watchdog.d.ts +68 -0
  51. package/dist/shared/stall-watchdog.js +102 -0
  52. package/dist/shared/stall-watchdog.js.map +1 -0
  53. package/dist/shared/status-offload.d.ts +84 -0
  54. package/dist/shared/status-offload.js +292 -0
  55. package/dist/shared/status-offload.js.map +1 -0
  56. package/dist/shared/status.d.ts +34 -3
  57. package/dist/shared/status.js +102 -9
  58. package/dist/shared/status.js.map +1 -1
  59. package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.d.ts +4 -0
  60. package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.js +4 -0
  61. package/dist/shared/streaming-scheduler.js.map +1 -0
  62. package/package.json +2 -2
  63. package/src/__tests__/config.test.ts +8 -0
  64. package/src/__tests__/in-flight.test.ts +84 -0
  65. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  66. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  67. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
  68. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
  69. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +124 -12
  70. package/src/activities/execute-cursor/__tests__/persist-decision.test.ts +99 -0
  71. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
  72. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
  73. package/src/activities/execute-cursor/hook-script.ts +85 -51
  74. package/src/activities/execute-cursor/index.ts +187 -38
  75. package/src/activities/execute-cursor/message-translator.ts +146 -20
  76. package/src/activities/execute-cursor/persist-decision.ts +54 -0
  77. package/src/activities/execute-cursor/prompt-builder.ts +59 -0
  78. package/src/activities/execute-cursor/workspace-setup.ts +76 -44
  79. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  80. package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
  81. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
  82. package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
  83. package/src/activities/execute-deep-agent/index.ts +17 -5
  84. package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
  85. package/src/activities/execute-deep-agent/status-builder.ts +3 -5
  86. package/src/activities/execute-deep-agent/streaming-v3.ts +5 -5
  87. package/src/activities/execute-deep-agent/streaming.ts +14 -5
  88. package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
  89. package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
  90. package/src/config.ts +27 -0
  91. package/src/in-flight.ts +71 -0
  92. package/src/runner-manager.ts +127 -33
  93. package/src/runner.ts +6 -0
  94. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  95. package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
  96. package/src/shared/__tests__/grpc-retry.test.ts +5 -134
  97. package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
  98. package/src/shared/__tests__/status-offload.test.ts +256 -0
  99. package/src/shared/__tests__/status.test.ts +199 -0
  100. package/src/shared/grpc-retry.ts +9 -72
  101. package/src/shared/stall-watchdog.ts +122 -0
  102. package/src/shared/status-offload.ts +342 -0
  103. package/src/shared/status.ts +142 -8
  104. package/src/{activities/execute-deep-agent → shared}/streaming-scheduler.ts +4 -0
  105. package/dist/activities/execute-deep-agent/streaming-scheduler.js.map +0 -1
  106. /package/src/{activities/execute-deep-agent → shared}/__tests__/streaming-scheduler.test.ts +0 -0
@@ -300,7 +300,10 @@ describe("StatusBuilder", () => {
300
300
  expect(tc.error).toBe("permission denied");
301
301
  });
302
302
 
303
- it("truncates long results", () => {
303
+ it("stores the full result faithfully (no builder-level truncation)", () => {
304
+ // Size-bounding is owned by the persist chokepoint (offload + enforce),
305
+ // not the builder. The builder must reflect the stream verbatim so binary
306
+ // content (e.g. a screenshot's base64) survives intact for offload.
304
307
  const sb = makeBuilder();
305
308
  sb.processEvent(chatStreamEvent("run-1", "text"));
306
309
  sb.processEvent(toolStartEvent("tool-run-1", "read"));
@@ -309,8 +312,8 @@ describe("StatusBuilder", () => {
309
312
  sb.processEvent(toolEndEvent("tool-run-1", longResult));
310
313
 
311
314
  const tc = sb.currentStatus.messages[0].toolCalls[0];
312
- expect(tc.result.length).toBeLessThan(longResult.length);
313
- expect(tc.result).toContain("[truncated:");
315
+ expect(tc.result).toBe(longResult);
316
+ expect(tc.result).not.toContain("[truncated:");
314
317
  });
315
318
 
316
319
  it("sets forceNextUpdate", () => {
@@ -524,4 +524,74 @@ describe("streamExecutionV3", () => {
524
524
  expect(status.streamingUsage!.inputTokens).toBe(10n);
525
525
  });
526
526
  });
527
+
528
+ describe("image offload through the persist chokepoint", () => {
529
+ it("offloads an MCP image tool result to a renderable ToolCallOutputRef", async () => {
530
+ // End-to-end: a tool returns image content blocks; the builder stores them
531
+ // faithfully (no truncation), and the persist chokepoint offloads the image
532
+ // to artifact storage so the persisted ToolCall carries outputRef.isImage.
533
+ const uploads: { key: string; size: number; contentType?: string }[] = [];
534
+ const artifactStorage = {
535
+ upload: vi.fn(async (key: string, content: Buffer, contentType?: string) => {
536
+ uploads.push({ key, size: content.length, contentType });
537
+ return key;
538
+ }),
539
+ getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
540
+ exists: vi.fn(async () => true),
541
+ } as any;
542
+
543
+ const base64 = Buffer.from("PNGBYTES".repeat(64)).toString("base64");
544
+ const imageEnvelope = {
545
+ lc: 1,
546
+ type: "constructor",
547
+ id: ["langchain_core", "messages", "ToolMessage"],
548
+ kwargs: {
549
+ status: "success",
550
+ content: [{ type: "image", data: base64, mimeType: "image/png" }],
551
+ tool_call_id: "toolu_1",
552
+ },
553
+ };
554
+
555
+ const events = [
556
+ makeEvent(0, "messages", { event: "message-start", run_id: "r1" }),
557
+ makeToolStartedEvent(1, "screenshot", {}),
558
+ makeEvent(2, "tools", {
559
+ event: "tool-finished",
560
+ tool_call_id: "toolu_1",
561
+ output: imageEnvelope,
562
+ }, { namespace: ["tools:toolu_1"] }),
563
+ makeEvent(3, "messages", { event: "message-finish", run_id: "r1" }),
564
+ ];
565
+ const graph = mockV3Graph(events, { messages: [] });
566
+
567
+ const persisted: any[] = [];
568
+ const updateStatus = vi.fn(async (_id: string, status: any) => {
569
+ persisted.push(status);
570
+ return { signal: 0 };
571
+ });
572
+
573
+ const promise = streamExecutionV3(baseDeps({
574
+ agentGraph: graph,
575
+ client: { updateStatus } as any,
576
+ offload: { artifactStorage, executionId: "exec-v3-test" },
577
+ }));
578
+ await vi.runAllTimersAsync();
579
+ await promise;
580
+
581
+ // Find a persisted snapshot whose tool call carries the image ref.
582
+ const refTc = persisted
583
+ .flatMap((s) => s.messages)
584
+ .flatMap((m: any) => m.toolCalls)
585
+ .find((tc: any) => tc?.id === "toolu_1" && tc?.outputRef);
586
+
587
+ expect(refTc).toBeDefined();
588
+ expect(refTc.outputRef.isImage).toBe(true);
589
+ expect(refTc.outputRef.mimeType).toBe("image/png");
590
+ expect(refTc.outputRef.downloadUrl).toContain("artifacts/exec-v3-test/toolcalls/toolu_1.png");
591
+ // The base64 must not survive inline on the persisted result.
592
+ expect(refTc.result).not.toContain(base64);
593
+ expect(uploads).toHaveLength(1);
594
+ expect(uploads[0].contentType).toBe("image/png");
595
+ });
596
+ });
527
597
  });
@@ -16,13 +16,14 @@ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/a
16
16
  import { ExecutionPhase, InteractionMode, MessageType, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
17
17
  import { activityStarted, activityFinished } from "../../idle-watchdog.js";
18
18
  import { persistStatus, slimStatus, utcTimestamp } from "../../shared/status.js";
19
+ import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
19
20
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
20
21
  import { classifyTool } from "../../shared/tool-kind.js";
21
22
  import type { Config } from "../../config.js";
22
23
  import { StigmerClient } from "../../client/stigmer-client.js";
23
24
  import { performSetup, type SetupResult } from "./setup.js";
24
25
  import { streamExecution, type StreamResult } from "./streaming.js";
25
- import { loadStreamingConfig } from "./streaming-scheduler.js";
26
+ import { loadStreamingConfig } from "../../shared/streaming-scheduler.js";
26
27
  import { StatusBuilder } from "./status-builder.js";
27
28
  import { InlinePublisher } from "./inline-publisher.js";
28
29
  import { WriteBackCoordinator } from "./writeback-coordinator.js";
@@ -48,6 +49,16 @@ export function createDeepAgentActivities(config: Config) {
48
49
 
49
50
  setup = await performSetup({ config, client, executionId, threadId });
50
51
 
52
+ // Single offload context for every persist in this execution: spill
53
+ // oversized tool outputs (e.g. computer-use screenshots) to artifact
54
+ // storage so the UI can render them, and keep the status under the
55
+ // gRPC cap. Threaded into the streaming loop and reused for the
56
+ // terminal persists below so the guard is never skipped.
57
+ const statusOffload: ToolOutputOffloadContext = {
58
+ artifactStorage: setup.artifactStorage,
59
+ executionId,
60
+ };
61
+
51
62
  const initialStatus = create(AgentExecutionStatusSchema, {});
52
63
  const statusBuilder = new StatusBuilder(executionId, initialStatus);
53
64
 
@@ -77,7 +88,7 @@ export function createDeepAgentActivities(config: Config) {
77
88
  }),
78
89
  ],
79
90
  });
80
- await persistStatus(client, executionId, failedStatus);
91
+ await persistStatus(client, executionId, failedStatus, { offload: statusOffload });
81
92
  return slimStatus(failedStatus);
82
93
  }
83
94
 
@@ -113,6 +124,7 @@ export function createDeepAgentActivities(config: Config) {
113
124
  client,
114
125
  initialStatus,
115
126
  streamingConfig,
127
+ offload: statusOffload,
116
128
  gracefulStop: setup.gracefulStop,
117
129
  inlinePublisher,
118
130
  writebackCoordinator: writebackCoordinator ?? undefined,
@@ -137,7 +149,7 @@ export function createDeepAgentActivities(config: Config) {
137
149
 
138
150
  if (result.terminalStatus) {
139
151
  if (initialStatus.phase === ExecutionPhase.EXECUTION_PAUSED) {
140
- await persistStatus(client, executionId, initialStatus);
152
+ await persistStatus(client, executionId, initialStatus, { offload: statusOffload });
141
153
  console.log(`[ExecuteDeepAgent] Paused for execution ${executionId}: events=${result.eventsProcessed}`);
142
154
  throw new CancelledFailure("Activity paused by orchestrator");
143
155
  }
@@ -189,7 +201,7 @@ export function createDeepAgentActivities(config: Config) {
189
201
  }
190
202
  initialStatus.messages.push(aiMsg);
191
203
 
192
- await persistStatus(client, executionId, initialStatus);
204
+ await persistStatus(client, executionId, initialStatus, { offload: statusOffload });
193
205
  return slimStatus(initialStatus);
194
206
  }
195
207
  }
@@ -257,7 +269,7 @@ export function createDeepAgentActivities(config: Config) {
257
269
  });
258
270
  }
259
271
 
260
- await persistStatus(client, executionId, initialStatus);
272
+ await persistStatus(client, executionId, initialStatus, { offload: statusOffload });
261
273
 
262
274
  console.log(
263
275
  `[ExecuteDeepAgent] Completed for execution ${executionId}: ` +
@@ -69,14 +69,34 @@ export function toBigInt(value: unknown): bigint {
69
69
 
70
70
  // ── Tool Result Extraction ─────────────────────────────────────────
71
71
 
72
- export const MAX_TOOL_RESULT_CHARS = 50_000;
72
+ /**
73
+ * Serialize a LangChain message `content` field into the canonical tool-result
74
+ * string.
75
+ *
76
+ * Text-only content is a plain string and passes through unchanged. Multimodal
77
+ * content (image, or mixed text+image — e.g. a computer-use screenshot) is an
78
+ * array of content blocks; we serialize the blocks array ITSELF, not the
79
+ * surrounding message envelope, so the result lands in the exact top-level-array
80
+ * shape the persist-time offload (`detectImagePayload`/`contentBlocks` in
81
+ * status-offload.ts) consumes to lift the image out into a renderable
82
+ * `ToolCallOutputRef`. Serializing the envelope instead would bury the base64
83
+ * one level deeper and defeat that detection.
84
+ *
85
+ * Returns undefined when `content` is neither a string nor an array, letting the
86
+ * caller fall back to serializing whatever else it holds.
87
+ */
88
+ function serializeToolContent(content: unknown): string | undefined {
89
+ if (typeof content === "string") return content;
90
+ if (Array.isArray(content)) return JSON.stringify(content);
91
+ return undefined;
92
+ }
73
93
 
74
94
  export function extractToolResult(data: Record<string, unknown>): string {
75
95
  const output = data.output;
76
96
  if (typeof output === "string") return output;
77
97
  if (typeof output === "object" && output !== null) {
78
- const content = (output as Record<string, unknown>).content;
79
- if (typeof content === "string") return content;
98
+ const fromContent = serializeToolContent((output as Record<string, unknown>).content);
99
+ if (fromContent !== undefined) return fromContent;
80
100
  }
81
101
  try {
82
102
  return JSON.stringify(output ?? data);
@@ -96,9 +116,11 @@ export function extractToolResultV3(output: unknown): string {
96
116
  const obj = output as Record<string, unknown>;
97
117
  const kwargs = obj.kwargs as Record<string, unknown> | undefined;
98
118
  if (kwargs) {
99
- if (typeof kwargs.content === "string") return kwargs.content;
119
+ const fromKwargs = serializeToolContent(kwargs.content);
120
+ if (fromKwargs !== undefined) return fromKwargs;
100
121
  }
101
- if (typeof obj.content === "string") return obj.content;
122
+ const fromContent = serializeToolContent(obj.content);
123
+ if (fromContent !== undefined) return fromContent;
102
124
  }
103
125
  try {
104
126
  return JSON.stringify(output);
@@ -34,7 +34,6 @@ import {
34
34
  UsageAccumulator,
35
35
  extractToolResult,
36
36
  sanitizeArgsPreview,
37
- MAX_TOOL_RESULT_CHARS,
38
37
  } from "./status-builder-shared.js";
39
38
 
40
39
  /** Minimal LangGraph streamEvents v2 event shape. */
@@ -290,11 +289,10 @@ export class StatusBuilder {
290
289
  tc.status = ToolCallStatus.TOOL_CALL_FAILED;
291
290
  tc.error = errorMsg;
292
291
  } else {
292
+ // Faithful result only; payload size is bounded at the persist chokepoint
293
+ // (see v3 builder note). Truncating here would corrupt image base64.
293
294
  tc.status = ToolCallStatus.TOOL_CALL_COMPLETED;
294
- const result = extractToolResult(event.data);
295
- tc.result = result.length > MAX_TOOL_RESULT_CHARS
296
- ? result.slice(0, MAX_TOOL_RESULT_CHARS) + `\n[truncated: ${result.length} chars total]`
297
- : result;
295
+ tc.result = extractToolResult(event.data);
298
296
  }
299
297
 
300
298
  tc.completedAt = utcTimestamp();
@@ -16,9 +16,8 @@ import { ExecutionControlSignal, ExecutionPhase } from "@stigmer/protos/ai/stigm
16
16
  import { createV3EventRecorder, type V3ProtocolEvent } from "./v3-event-recorder.js";
17
17
  import { normalize } from "./v3-protocol-normalizer.js";
18
18
  import { V3StatusBuilder } from "./v3-status-builder.js";
19
- import { StreamingUpdateScheduler } from "./streaming-scheduler.js";
20
- import { persistWithRetry } from "../../shared/grpc-retry.js";
21
- import { slimStatus } from "../../shared/status.js";
19
+ import { StreamingUpdateScheduler } from "../../shared/streaming-scheduler.js";
20
+ import { persistStatus, slimStatus } from "../../shared/status.js";
22
21
  import { StreamingSideEffects } from "./streaming-side-effects.js";
23
22
  import {
24
23
  handlePause,
@@ -49,6 +48,7 @@ export async function streamExecutionV3(
49
48
  initialStatus,
50
49
  streamingConfig,
51
50
  retryOptions,
51
+ offload,
52
52
  stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS,
53
53
  heartbeatFn,
54
54
  isCancelledFn,
@@ -118,11 +118,11 @@ export async function streamExecutionV3(
118
118
  }
119
119
 
120
120
  statusBuilder.syncSubAgentExecutions();
121
- const signal = await persistWithRetry(
121
+ const signal = await persistStatus(
122
122
  client,
123
123
  executionId,
124
124
  statusBuilder.currentStatus,
125
- retryOptions,
125
+ { offload, retry: retryOptions },
126
126
  );
127
127
  scheduler.markUpdateSent(eventsProcessed);
128
128
 
@@ -32,9 +32,10 @@ import {
32
32
  import {
33
33
  StreamingUpdateScheduler,
34
34
  type StreamingConfig,
35
- } from "./streaming-scheduler.js";
36
- import { persistWithRetry, type RetryOptions } from "../../shared/grpc-retry.js";
37
- import { slimStatus, utcTimestamp } from "../../shared/status.js";
35
+ } from "../../shared/streaming-scheduler.js";
36
+ import { type RetryOptions } from "../../shared/grpc-retry.js";
37
+ import { persistStatus, slimStatus, utcTimestamp } from "../../shared/status.js";
38
+ import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
38
39
  import type { StigmerClient } from "../../client/stigmer-client.js";
39
40
  import type { GracefulStopMiddleware } from "../../middleware/index.js";
40
41
  import type { InlinePublisher } from "./inline-publisher.js";
@@ -59,6 +60,13 @@ export interface StreamDependencies {
59
60
  readonly initialStatus: AgentExecutionStatus;
60
61
  readonly streamingConfig?: StreamingConfig;
61
62
  readonly retryOptions?: RetryOptions;
63
+ /**
64
+ * Offload context for the persist chokepoint. When set, oversized tool
65
+ * outputs (e.g. computer-use screenshots) are spilled to artifact storage so
66
+ * the UI can render them; when omitted, the aggregate size backstop still
67
+ * keeps the payload under the gRPC cap.
68
+ */
69
+ readonly offload?: ToolOutputOffloadContext;
62
70
  readonly stallTimeoutMs?: number;
63
71
  /** Temporal heartbeat. Injected so the loop is testable without Temporal. */
64
72
  readonly heartbeatFn?: (details: Record<string, unknown>) => void;
@@ -112,6 +120,7 @@ async function streamExecutionV2(
112
120
  initialStatus,
113
121
  streamingConfig,
114
122
  retryOptions,
123
+ offload,
115
124
  stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS,
116
125
  heartbeatFn,
117
126
  isCancelledFn,
@@ -179,11 +188,11 @@ async function streamExecutionV2(
179
188
  statusBuilder.clearForceFlag();
180
189
  }
181
190
 
182
- const signal = await persistWithRetry(
191
+ const signal = await persistStatus(
183
192
  client,
184
193
  executionId,
185
194
  statusBuilder.currentStatus,
186
- retryOptions,
195
+ { offload, retry: retryOptions },
187
196
  );
188
197
  scheduler.markUpdateSent(eventsProcessed);
189
198
 
@@ -29,7 +29,7 @@ import {
29
29
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
30
30
  import { utcTimestamp } from "../../shared/status.js";
31
31
  import { classifyTool } from "../../shared/tool-kind.js";
32
- import { extractToolResultV3, MAX_TOOL_RESULT_CHARS } from "./status-builder-shared.js";
32
+ import { extractToolResultV3 } from "./status-builder-shared.js";
33
33
  import type { StigmerRunEvent, V3UsagePayload } from "./v3-events.js";
34
34
 
35
35
  // ── Per-SubAgent State ───────────────────────────────────────────────────────
@@ -299,11 +299,10 @@ export class SubAgentTracker {
299
299
  const tc = state.toolCalls.get(callId);
300
300
  if (!tc) return;
301
301
 
302
- const result = extractToolResultV3(output);
302
+ // Faithful result only; payload size is bounded at the persist chokepoint
303
+ // (see v3 builder note). Truncating here would corrupt image base64.
303
304
  tc.status = ToolCallStatus.TOOL_CALL_COMPLETED;
304
- tc.result = result.length > MAX_TOOL_RESULT_CHARS
305
- ? result.slice(0, MAX_TOOL_RESULT_CHARS) + `\n[truncated: ${result.length} chars total]`
306
- : result;
305
+ tc.result = extractToolResultV3(output);
307
306
  tc.completedAt = utcTimestamp();
308
307
  tc.isStreaming = false;
309
308
  state.toolArgBuffers.delete(callId);
@@ -36,7 +36,6 @@ import {
36
36
  UsageAccumulator,
37
37
  extractToolResultV3,
38
38
  sanitizeArgsPreview,
39
- MAX_TOOL_RESULT_CHARS,
40
39
  } from "./status-builder-shared.js";
41
40
  import { SubAgentTracker } from "./subagent-tracker.js";
42
41
 
@@ -296,11 +295,12 @@ export class V3StatusBuilder implements ExecutionStatusWriter {
296
295
  const tc = this.state.toolCalls.get(callId);
297
296
  if (!tc) return;
298
297
 
299
- const result = extractToolResultV3(output);
298
+ // Store the faithful result; bounding the gRPC payload is owned solely by
299
+ // the persist chokepoint (offload + enforce in status.ts/status-offload.ts).
300
+ // Truncating here would corrupt binary content (e.g. a screenshot's base64)
301
+ // before offload can lift it into a renderable ToolCallOutputRef.
300
302
  tc.status = ToolCallStatus.TOOL_CALL_COMPLETED;
301
- tc.result = result.length > MAX_TOOL_RESULT_CHARS
302
- ? result.slice(0, MAX_TOOL_RESULT_CHARS) + `\n[truncated: ${result.length} chars total]`
303
- : result;
303
+ tc.result = extractToolResultV3(output);
304
304
  tc.completedAt = utcTimestamp();
305
305
  tc.isStreaming = false;
306
306
  this.state.toolStartTimes.delete(callId);
package/src/config.ts CHANGED
@@ -28,6 +28,15 @@ import { mkdirSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { homedir, tmpdir } from "node:os";
30
30
 
31
+ /**
32
+ * Default no-progress bound for the Cursor harness stream (ms). Larger than the
33
+ * shared DEFAULT_STALL_TIMEOUT_MS (120s) because opaque MCP / GUI tool calls
34
+ * can run for minutes while emitting no stream activity. Single source of truth
35
+ * for env-loaded ({@link loadConfig}) and options-mapped (runner / manager)
36
+ * config so the three construction sites never drift.
37
+ */
38
+ export const DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS = 180_000;
39
+
31
40
  export interface Config {
32
41
  readonly taskQueue: string;
33
42
  readonly temporalAddress: string;
@@ -61,6 +70,19 @@ export interface Config {
61
70
  readonly checkpointerType: "memory" | "http";
62
71
  readonly checkpointerProxyEndpoint: string | null;
63
72
  readonly primaryModel: string;
73
+ /**
74
+ * No-progress bound for the Cursor harness stream (milliseconds). If no
75
+ * stream event or token delta arrives for this long, the stall watchdog
76
+ * (see activities/execute-cursor + shared/stall-watchdog.ts) cancels the run
77
+ * and fails the execution with a StallTimeoutError rather than hanging at
78
+ * EXECUTION_IN_PROGRESS forever.
79
+ *
80
+ * Larger than the shared DEFAULT_STALL_TIMEOUT_MS (120s) because opaque MCP /
81
+ * GUI tool calls can legitimately run for minutes while emitting no stream
82
+ * activity. This bounds no-progress time only; it is orthogonal to Temporal's
83
+ * heartbeatTimeout (process liveness) and the 30s keep-alive heartbeat.
84
+ */
85
+ readonly cursorStreamStallTimeoutMs: number;
64
86
  /** Shared mutable token reference for dynamic token updates (manager mode). */
65
87
  readonly stigmerTokenRef?: { current: string | null };
66
88
  }
@@ -119,6 +141,10 @@ export function loadConfig(): Config {
119
141
 
120
142
  const primaryModel = process.env.STIGMER_PRIMARY_MODEL ?? "gpt-4.1";
121
143
 
144
+ const cursorStreamStallTimeoutMs = process.env.CURSOR_STREAM_STALL_TIMEOUT_MS
145
+ ? parseInt(process.env.CURSOR_STREAM_STALL_TIMEOUT_MS, 10)
146
+ : DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS;
147
+
122
148
  return {
123
149
  taskQueue,
124
150
  temporalAddress,
@@ -135,6 +161,7 @@ export function loadConfig(): Config {
135
161
  checkpointerType,
136
162
  checkpointerProxyEndpoint,
137
163
  primaryModel,
164
+ cursorStreamStallTimeoutMs,
138
165
  };
139
166
  }
140
167
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Per-task-queue in-flight activity tracking.
3
+ *
4
+ * A session/wfexec worker must NOT be torn down while one of its activities
5
+ * (notably the long-running ExecuteCursor) is still running: doing so abandons
6
+ * the activity and removes the only poller on its queue, turning a harmless UI
7
+ * navigation into an "Activity task timed out" dead-end. The activity inbound
8
+ * interceptor increments on every activity start and decrements on finish;
9
+ * runner-manager's removeSession/removeWorkflowExecution consult the count and
10
+ * defer teardown until it drains.
11
+ *
12
+ * This lives in its own module (rather than inside the runner-manager closure)
13
+ * so the interceptor — which has no handle to that closure — and unit tests can
14
+ * both reach it. Keyed by task queue (`session:{id}` / `wfexec:{id}`), it is
15
+ * process-global, mirroring the shutdown-signal registry.
16
+ */
17
+
18
+ interface InFlightEntry {
19
+ count: number;
20
+ /** Invoked once when count returns to 0; used to run a deferred teardown. */
21
+ onDrained?: () => void;
22
+ }
23
+
24
+ const registry = new Map<string, InFlightEntry>();
25
+
26
+ /** Record that an activity started on the given task queue. */
27
+ export function activityStartedOnQueue(taskQueue: string): void {
28
+ const entry = registry.get(taskQueue) ?? { count: 0 };
29
+ entry.count++;
30
+ registry.set(taskQueue, entry);
31
+ }
32
+
33
+ /**
34
+ * Record that an activity finished on the given task queue. When the count
35
+ * returns to zero, any registered drain callback fires exactly once.
36
+ */
37
+ export function activityFinishedOnQueue(taskQueue: string): void {
38
+ const entry = registry.get(taskQueue);
39
+ if (!entry) return;
40
+ entry.count = Math.max(0, entry.count - 1);
41
+ if (entry.count === 0 && entry.onDrained) {
42
+ const onDrained = entry.onDrained;
43
+ entry.onDrained = undefined;
44
+ onDrained();
45
+ }
46
+ }
47
+
48
+ /** Current number of in-flight activities on the task queue (0 if unknown). */
49
+ export function inFlightCountForQueue(taskQueue: string): number {
50
+ return registry.get(taskQueue)?.count ?? 0;
51
+ }
52
+
53
+ /**
54
+ * Register (or clear) a callback to run when the queue next drains to zero
55
+ * in-flight activities. No-op if the queue has no entry (count already 0); the
56
+ * caller handles the already-idle case by tearing down immediately.
57
+ */
58
+ export function setQueueDrainCallback(taskQueue: string, cb: (() => void) | undefined): void {
59
+ const entry = registry.get(taskQueue);
60
+ if (entry) entry.onDrained = cb;
61
+ }
62
+
63
+ /** Forget all tracking for a queue (called after its worker is torn down). */
64
+ export function forgetQueue(taskQueue: string): void {
65
+ registry.delete(taskQueue);
66
+ }
67
+
68
+ /** Test-only: clear the entire registry between cases. */
69
+ export function __resetInFlightRegistryForTests(): void {
70
+ registry.clear();
71
+ }