@stigmer/runner 3.11.0 → 3.12.0

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 (239) hide show
  1. package/README.md +2 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.d.ts +7 -0
  4. package/dist/activities/discover-mcp-server.js +6 -4
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/emit-event.d.ts +14 -2
  7. package/dist/activities/emit-event.js +52 -17
  8. package/dist/activities/emit-event.js.map +1 -1
  9. package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
  10. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  11. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  13. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  14. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  15. package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
  16. package/dist/activities/execute-cursor/error-classifier.js +34 -3
  17. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  18. package/dist/activities/execute-cursor/index.d.ts +52 -12
  19. package/dist/activities/execute-cursor/index.js +141 -67
  20. package/dist/activities/execute-cursor/index.js.map +1 -1
  21. package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
  22. package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
  23. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  24. package/dist/activities/execute-cursor/prompt-builder.d.ts +48 -11
  25. package/dist/activities/execute-cursor/prompt-builder.js +57 -10
  26. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  27. package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
  28. package/dist/activities/execute-cursor/turn-recovery.js +193 -0
  29. package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
  30. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +11 -0
  31. package/dist/activities/execute-deep-agent/attachment-injector.js +7 -1
  32. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +0 -15
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  36. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  37. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  39. package/dist/activities/execute-deep-agent/prompt-builder.js +14 -7
  40. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  42. package/dist/activities/execute-deep-agent/setup.js +22 -49
  43. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
  45. package/dist/activities/execute-deep-agent/shell-env.js +7 -9
  46. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  47. package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
  48. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  49. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
  50. package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
  51. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  52. package/dist/activities/hydrate-workflow-execution.js +8 -5
  53. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  54. package/dist/activities/run-command.d.ts +5 -2
  55. package/dist/activities/run-command.js +22 -10
  56. package/dist/activities/run-command.js.map +1 -1
  57. package/dist/activities/run-env.d.ts +40 -0
  58. package/dist/activities/run-env.js +66 -0
  59. package/dist/activities/run-env.js.map +1 -0
  60. package/dist/bootstrap.js +5 -0
  61. package/dist/bootstrap.js.map +1 -1
  62. package/dist/client/stigmer-client.d.ts +83 -26
  63. package/dist/client/stigmer-client.js +88 -24
  64. package/dist/client/stigmer-client.js.map +1 -1
  65. package/dist/config.d.ts +6 -5
  66. package/dist/config.js.map +1 -1
  67. package/dist/encryption/config.d.ts +41 -9
  68. package/dist/encryption/config.js +63 -23
  69. package/dist/encryption/config.js.map +1 -1
  70. package/dist/middleware/index.d.ts +3 -0
  71. package/dist/middleware/index.js +7 -0
  72. package/dist/middleware/index.js.map +1 -1
  73. package/dist/middleware/path-normalization.d.ts +57 -0
  74. package/dist/middleware/path-normalization.js +109 -0
  75. package/dist/middleware/path-normalization.js.map +1 -0
  76. package/dist/middleware/types.d.ts +8 -0
  77. package/dist/payload-codecs.d.ts +2 -1
  78. package/dist/payload-codecs.js +7 -3
  79. package/dist/payload-codecs.js.map +1 -1
  80. package/dist/runner-manager.js +17 -3
  81. package/dist/runner-manager.js.map +1 -1
  82. package/dist/runner.js +7 -3
  83. package/dist/runner.js.map +1 -1
  84. package/dist/shared/artifact-storage.d.ts +34 -1
  85. package/dist/shared/artifact-storage.js +53 -10
  86. package/dist/shared/artifact-storage.js.map +1 -1
  87. package/dist/shared/attachment-download-urls.d.ts +62 -0
  88. package/dist/shared/attachment-download-urls.js +79 -0
  89. package/dist/shared/attachment-download-urls.js.map +1 -0
  90. package/dist/shared/channel-attachment.d.ts +1 -1
  91. package/dist/shared/channel-attachment.js +1 -1
  92. package/dist/shared/checkpointer/http-saver.d.ts +4 -20
  93. package/dist/shared/checkpointer/http-saver.js +15 -57
  94. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  95. package/dist/shared/conversation-attachment.d.ts +2 -3
  96. package/dist/shared/conversation-attachment.js +2 -3
  97. package/dist/shared/conversation-attachment.js.map +1 -1
  98. package/dist/shared/conversation-catchup.d.ts +9 -2
  99. package/dist/shared/conversation-catchup.js +39 -6
  100. package/dist/shared/conversation-catchup.js.map +1 -1
  101. package/dist/shared/grpc-retry.d.ts +5 -1
  102. package/dist/shared/grpc-retry.js +5 -1
  103. package/dist/shared/grpc-retry.js.map +1 -1
  104. package/dist/shared/http-retry.d.ts +51 -9
  105. package/dist/shared/http-retry.js +60 -9
  106. package/dist/shared/http-retry.js.map +1 -1
  107. package/dist/shared/mcp-manager.d.ts +8 -4
  108. package/dist/shared/mcp-manager.js +30 -4
  109. package/dist/shared/mcp-manager.js.map +1 -1
  110. package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
  111. package/dist/shared/mcp-schema-sanitizer.js +197 -0
  112. package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
  113. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  114. package/dist/shared/mcp-transport-guard.js +2 -2
  115. package/dist/shared/model-client.d.ts +6 -0
  116. package/dist/shared/model-client.js +14 -3
  117. package/dist/shared/model-client.js.map +1 -1
  118. package/dist/shared/model-pricing-data.d.ts +2 -0
  119. package/dist/shared/model-pricing-data.js +13 -3
  120. package/dist/shared/model-pricing-data.js.map +1 -1
  121. package/dist/shared/model-registry.js +3 -2
  122. package/dist/shared/model-registry.js.map +1 -1
  123. package/dist/shared/plan-mode-permissions.d.ts +7 -0
  124. package/dist/shared/plan-mode-permissions.js +7 -0
  125. package/dist/shared/plan-mode-permissions.js.map +1 -1
  126. package/dist/shared/registry-endpoint.d.ts +20 -0
  127. package/dist/shared/registry-endpoint.js +25 -0
  128. package/dist/shared/registry-endpoint.js.map +1 -1
  129. package/dist/shared/runner-credential-keys.d.ts +24 -0
  130. package/dist/shared/runner-credential-keys.js +47 -0
  131. package/dist/shared/runner-credential-keys.js.map +1 -0
  132. package/dist/shared/synthesized-attachment.d.ts +4 -4
  133. package/dist/shared/synthesized-attachment.js +4 -4
  134. package/dist/workflow-engine/resolve.d.ts +19 -1
  135. package/dist/workflow-engine/resolve.js +37 -2
  136. package/dist/workflow-engine/resolve.js.map +1 -1
  137. package/dist/workflows/connect-mcp-server.js +2 -0
  138. package/dist/workflows/connect-mcp-server.js.map +1 -1
  139. package/dist/workflows/types.d.ts +8 -0
  140. package/package.json +3 -4
  141. package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
  142. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  143. package/src/__test-utils__/vitest-global-setup.ts +50 -0
  144. package/src/__tests__/bootstrap.test.ts +43 -0
  145. package/src/__tests__/encryption-codec.test.ts +53 -0
  146. package/src/__tests__/preflight.test.ts +15 -0
  147. package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
  148. package/src/activities/__tests__/error-classifier.test.ts +94 -2
  149. package/src/activities/__tests__/run-command.test.ts +221 -0
  150. package/src/activities/discover-mcp-server.ts +16 -2
  151. package/src/activities/emit-event.ts +65 -21
  152. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  153. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +173 -31
  154. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
  155. package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
  156. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  157. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  158. package/src/activities/execute-cursor/error-classifier.ts +40 -3
  159. package/src/activities/execute-cursor/index.ts +175 -74
  160. package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
  161. package/src/activities/execute-cursor/prompt-builder.ts +89 -22
  162. package/src/activities/execute-cursor/turn-recovery.ts +208 -0
  163. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +105 -0
  164. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  165. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  166. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  167. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  168. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
  169. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +71 -0
  170. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  171. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
  172. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
  173. package/src/activities/execute-deep-agent/attachment-injector.ts +20 -1
  174. package/src/activities/execute-deep-agent/index.ts +0 -19
  175. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  176. package/src/activities/execute-deep-agent/prompt-builder.ts +27 -15
  177. package/src/activities/execute-deep-agent/setup.ts +22 -73
  178. package/src/activities/execute-deep-agent/shell-env.ts +8 -9
  179. package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
  180. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
  181. package/src/activities/hydrate-workflow-execution.ts +8 -5
  182. package/src/activities/run-command.ts +25 -12
  183. package/src/activities/run-env.ts +79 -0
  184. package/src/bootstrap.ts +5 -0
  185. package/src/client/__tests__/stigmer-client.test.ts +104 -5
  186. package/src/client/stigmer-client.ts +128 -29
  187. package/src/config.ts +6 -5
  188. package/src/encryption/config.ts +87 -23
  189. package/src/middleware/__tests__/path-normalization.test.ts +140 -0
  190. package/src/middleware/index.ts +8 -0
  191. package/src/middleware/path-normalization.ts +125 -0
  192. package/src/middleware/types.ts +11 -0
  193. package/src/payload-codecs.ts +8 -2
  194. package/src/runner-manager.ts +21 -3
  195. package/src/runner.ts +7 -3
  196. package/src/shared/__tests__/artifact-storage.test.ts +154 -2
  197. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  198. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  199. package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
  200. package/src/shared/__tests__/http-retry.test.ts +5 -4
  201. package/src/shared/__tests__/mcp-manager.test.ts +53 -13
  202. package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
  203. package/src/shared/__tests__/model-client.test.ts +46 -0
  204. package/src/shared/__tests__/model-pricing.test.ts +62 -2
  205. package/src/shared/__tests__/model-registry.test.ts +22 -0
  206. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  207. package/src/shared/artifact-storage.ts +86 -10
  208. package/src/shared/attachment-download-urls.ts +98 -0
  209. package/src/shared/channel-attachment.ts +1 -1
  210. package/src/shared/checkpointer/http-saver.ts +15 -60
  211. package/src/shared/conversation-attachment.ts +2 -3
  212. package/src/shared/conversation-catchup.ts +39 -6
  213. package/src/shared/grpc-retry.ts +5 -1
  214. package/src/shared/http-retry.ts +98 -9
  215. package/src/shared/mcp-manager.ts +35 -4
  216. package/src/shared/mcp-schema-sanitizer.ts +224 -0
  217. package/src/shared/mcp-transport-guard.ts +2 -2
  218. package/src/shared/model-client.ts +21 -3
  219. package/src/shared/model-pricing-data.ts +22 -3
  220. package/src/shared/model-registry.ts +7 -2
  221. package/src/shared/plan-mode-permissions.ts +7 -0
  222. package/src/shared/registry-endpoint.ts +27 -0
  223. package/src/shared/runner-credential-keys.ts +46 -0
  224. package/src/shared/synthesized-attachment.ts +4 -4
  225. package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
  226. package/src/workflow-engine/resolve.ts +48 -2
  227. package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
  228. package/src/workflows/connect-mcp-server.ts +2 -0
  229. package/src/workflows/types.ts +8 -0
  230. package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
  231. package/dist/activities/execute-cursor/mcp-config.js +0 -39
  232. package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
  233. package/dist/shared/datastore-attachment.d.ts +0 -99
  234. package/dist/shared/datastore-attachment.js +0 -184
  235. package/dist/shared/datastore-attachment.js.map +0 -1
  236. package/src/activities/execute-cursor/mcp-config.ts +0 -66
  237. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  238. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  239. package/src/shared/datastore-attachment.ts +0 -209
@@ -56,6 +56,7 @@ describe("buildChatModel", () => {
56
56
  delete process.env.STIGMER_ANTHROPIC_BACKEND;
57
57
  delete process.env.STIGMER_OPENAI_BACKEND;
58
58
  delete process.env.CLOUD_ML_REGION;
59
+ delete process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS;
59
60
  });
60
61
 
61
62
  afterEach(() => {
@@ -215,6 +216,51 @@ describe("buildChatModel", () => {
215
216
  },
216
217
  });
217
218
  });
219
+
220
+ // The env default resolves INSIDE buildChatModel (#468): before this,
221
+ // each caller parsed STIGMER_LLM_REQUEST_TIMEOUT_MS itself, and every
222
+ // caller except the deep-agent main path — including its own sub-agent
223
+ // modelFactory — dropped the bound.
224
+ it("defaults to STIGMER_LLM_REQUEST_TIMEOUT_MS when the caller passes no timeout", async () => {
225
+ process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS = "7000";
226
+ mockRegistryResponse([
227
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
228
+ ]);
229
+
230
+ await buildChatModel({ modelName: "claude-haiku-4.5" });
231
+
232
+ expect(lastAnthropicArgs()).toMatchObject({
233
+ clientOptions: { timeout: 7000 },
234
+ maxRetries: 0,
235
+ });
236
+ });
237
+
238
+ it("lets an explicit caller timeout win over the env default", async () => {
239
+ process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS = "7000";
240
+ mockRegistryResponse([
241
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
242
+ ]);
243
+
244
+ await buildChatModel({ modelName: "claude-haiku-4.5", timeoutMs: 5000 });
245
+
246
+ expect(lastAnthropicArgs()).toMatchObject({ clientOptions: { timeout: 5000 } });
247
+ });
248
+
249
+ it.each(["0", "-1", "not-a-number", ""])(
250
+ "normalizes a non-positive env value (%j) to no bound",
251
+ async (envValue) => {
252
+ process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS = envValue;
253
+ mockRegistryResponse([
254
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
255
+ ]);
256
+
257
+ await buildChatModel({ modelName: "claude-haiku-4.5" });
258
+
259
+ const args = lastAnthropicArgs();
260
+ expect(args).not.toHaveProperty("clientOptions");
261
+ expect(args).not.toHaveProperty("maxRetries");
262
+ },
263
+ );
218
264
  });
219
265
 
220
266
  // ─── Provider backends (STIGMER_ANTHROPIC_BACKEND) ─────────────────────────
@@ -1,6 +1,11 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { computeLlmCostMicros, computeTurnCost, getModelPricing, resolveModelId, ensureLoaded } from "../model-pricing.js";
3
- import type { ModelPricing } from "../model-pricing-data.js";
3
+ import {
4
+ getPricingTable,
5
+ _resetPricingCache,
6
+ DEFAULT_PRICING,
7
+ type ModelPricing,
8
+ } from "../model-pricing-data.js";
4
9
 
5
10
  describe("computeLlmCostMicros", () => {
6
11
  it("falls back to DEFAULT_PRICING when registry is not loaded", () => {
@@ -83,3 +88,58 @@ describe("getModelPricing", () => {
83
88
  expect(pricing.model).toBe("custom-model");
84
89
  });
85
90
  });
91
+
92
+ describe("getPricingTable failure caching (#468)", () => {
93
+ beforeEach(() => {
94
+ _resetPricingCache();
95
+ vi.restoreAllMocks();
96
+ });
97
+
98
+ afterEach(() => {
99
+ _resetPricingCache();
100
+ vi.useRealTimers();
101
+ });
102
+
103
+ const registryResponse = () =>
104
+ new Response(
105
+ JSON.stringify({
106
+ models: [
107
+ {
108
+ id: "claude-sonnet-4-6",
109
+ displayName: "Claude Sonnet",
110
+ costTier: "standard",
111
+ pricing: {
112
+ inputPricePerMillion: 3.0,
113
+ outputPricePerMillion: 15.0,
114
+ cacheWritePricePerMillion: 3.75,
115
+ cacheReadPricePerMillion: 0.3,
116
+ },
117
+ },
118
+ ],
119
+ }),
120
+ { status: 200, headers: { "Content-Type": "application/json" } },
121
+ );
122
+
123
+ it("retries after the short failure TTL instead of pinning DEFAULT_PRICING for an hour", async () => {
124
+ // The model-registry.ts failure-cache policy, applied here: wrong default
125
+ // rates for cost tracking must not persist a full success TTL.
126
+ vi.useFakeTimers();
127
+ const fetchSpy = vi
128
+ .spyOn(globalThis, "fetch")
129
+ .mockRejectedValueOnce(new Error("network error"))
130
+ .mockResolvedValueOnce(registryResponse());
131
+
132
+ // First call fails and degrades to the default table.
133
+ expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
134
+
135
+ // Within the failure TTL the fallback stays cached (no refetch).
136
+ vi.advanceTimersByTime(30_000);
137
+ expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
138
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
139
+
140
+ // Past the failure TTL the registry is refetched and real rates recover.
141
+ vi.advanceTimersByTime(31_000);
142
+ expect((await getPricingTable())[0]?.model).toBe("claude-sonnet-4-6");
143
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
144
+ });
145
+ });
@@ -223,6 +223,28 @@ describe("resolveToApiModelId", () => {
223
223
  expect(result).toBe("claude-haiku-4.5");
224
224
  });
225
225
 
226
+ it("recovers from a transient network failure without engaging the failure cache (#468)", async () => {
227
+ // undici's retryable shape (TypeError) — the shared bounded-backoff loop
228
+ // absorbs it, so resolution succeeds on the SAME call and the 60 s
229
+ // degraded window never opens. Pre-#468 code failed the fetch outright.
230
+ const fetchSpy = vi
231
+ .spyOn(globalThis, "fetch")
232
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
233
+ .mockResolvedValueOnce(
234
+ new Response(
235
+ JSON.stringify({
236
+ models: [
237
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic", costTier: "economy", harness: "native" },
238
+ ],
239
+ }),
240
+ { status: 200, headers: { "Content-Type": "application/json" } },
241
+ ),
242
+ );
243
+
244
+ expect(await resolveToApiModelId("claude-haiku-4.5")).toBe("claude-haiku-4-5-20251001");
245
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
246
+ });
247
+
226
248
  it("retries after the short failure TTL instead of caching the failure for an hour", async () => {
227
249
  vi.useFakeTimers();
228
250
  const fetchSpy = vi
@@ -1,17 +1,14 @@
1
1
  /**
2
- * Composition of ALL THREE synthesized attachments through
3
- * injectSynthesizedAttachment — the first test to chain them the way
4
- * both harness call sites do (datastore, then channels, then
5
- * conversation, each after resolve + backfill). Per-slug independence
6
- * is the property that makes a third attachment safe to add: replacing
7
- * one reserved slug must never disturb its siblings.
2
+ * Composition of the synthesized attachments through
3
+ * injectSynthesizedAttachment — chained the way both harness call sites
4
+ * do (channels, then conversation, each after resolve + backfill).
5
+ * Per-slug independence is the property that makes another attachment
6
+ * safe to add: replacing one reserved slug must never disturb its
7
+ * siblings.
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from "vitest";
11
11
  import type { MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
12
- import { create } from "@bufbuild/protobuf";
13
- import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
14
- import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
15
12
 
16
13
  import {
17
14
  CHANNEL_ATTACHMENT_SLUG,
@@ -21,10 +18,6 @@ import {
21
18
  CONVERSATION_ATTACHMENT_SLUG,
22
19
  synthesizeConversationAttachment,
23
20
  } from "../conversation-attachment.js";
24
- import {
25
- DATASTORE_ATTACHMENT_SLUG,
26
- synthesizeDatastoreAttachment,
27
- } from "../datastore-attachment.js";
28
21
  import { injectSynthesizedAttachment } from "../synthesized-attachment.js";
29
22
  import type { ResolvedMcpServer } from "../mcp-resolver.js";
30
23
 
@@ -44,47 +37,34 @@ const userServer: ResolvedMcpServer = {
44
37
  discoveredCapabilitiesEmpty: false,
45
38
  };
46
39
 
47
- function allThree(): ResolvedMcpServer[] {
48
- const datastore = synthesizeDatastoreAttachment(
49
- [create(DatastoreUsageSchema, {
50
- datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
51
- })],
52
- options,
53
- )!;
40
+ function bothAttachments(): ResolvedMcpServer[] {
54
41
  const channels = synthesizeChannelAttachment(
55
42
  [{ channel: { channel: "isc-whatsapp", provider: "whatsapp" } as MessagingChannel, templates: [] }],
56
43
  options,
57
44
  )!;
58
45
  const conversation = synthesizeConversationAttachment("agch_1", options)!;
59
46
 
60
- // The harness order at both call sites: datastore, channels, conversation.
61
- let servers = injectSynthesizedAttachment([userServer], datastore, "datastore records");
62
- servers = injectSynthesizedAttachment(servers, channels, "channel messaging");
47
+ // The harness order at both call sites: channels, then conversation.
48
+ let servers = injectSynthesizedAttachment([userServer], channels, "channel messaging");
63
49
  return injectSynthesizedAttachment(servers, conversation, "conversation participation");
64
50
  }
65
51
 
66
- describe("three synthesized attachments in one chain", () => {
67
- it("composes all three after the user's servers, in injection order", () => {
68
- expect(allThree().map((s) => s.slug)).toEqual([
52
+ describe("synthesized attachments in one chain", () => {
53
+ it("composes after the user's servers, in injection order", () => {
54
+ expect(bothAttachments().map((s) => s.slug)).toEqual([
69
55
  "github",
70
- DATASTORE_ATTACHMENT_SLUG,
71
56
  CHANNEL_ATTACHMENT_SLUG,
72
57
  CONVERSATION_ATTACHMENT_SLUG,
73
58
  ]);
74
59
  });
75
60
 
76
61
  it("each rides its own bridge route with the shared credential", () => {
77
- const bySlug = new Map(allThree().map((s) => [s.slug, s]));
78
- expect(bySlug.get(DATASTORE_ATTACHMENT_SLUG)?.url).toBe("https://mcp.stigmer.ai/records");
62
+ const bySlug = new Map(bothAttachments().map((s) => [s.slug, s]));
79
63
  expect(bySlug.get(CHANNEL_ATTACHMENT_SLUG)?.url).toBe("https://mcp.stigmer.ai/channels");
80
64
  expect(bySlug.get(CONVERSATION_ATTACHMENT_SLUG)?.url).toBe(
81
65
  "https://mcp.stigmer.ai/conversation",
82
66
  );
83
- for (const slug of [
84
- DATASTORE_ATTACHMENT_SLUG,
85
- CHANNEL_ATTACHMENT_SLUG,
86
- CONVERSATION_ATTACHMENT_SLUG,
87
- ]) {
67
+ for (const slug of [CHANNEL_ATTACHMENT_SLUG, CONVERSATION_ATTACHMENT_SLUG]) {
88
68
  expect(bySlug.get(slug)?.headers).toEqual({ Authorization: "Bearer sandbox-token" });
89
69
  }
90
70
  });
@@ -96,20 +76,18 @@ describe("three synthesized attachments in one chain", () => {
96
76
  slug: CONVERSATION_ATTACHMENT_SLUG,
97
77
  url: "https://evil.example.com",
98
78
  };
99
- const conversation = synthesizeConversationAttachment("agch_1", options)!;
100
- const datastore = synthesizeDatastoreAttachment(
101
- [create(DatastoreUsageSchema, {
102
- datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
103
- })],
79
+ const channels = synthesizeChannelAttachment(
80
+ [{ channel: { channel: "isc-whatsapp", provider: "whatsapp" } as MessagingChannel, templates: [] }],
104
81
  options,
105
82
  )!;
83
+ const conversation = synthesizeConversationAttachment("agch_1", options)!;
106
84
 
107
- let servers = injectSynthesizedAttachment([impostor, userServer], datastore, "datastore records");
85
+ let servers = injectSynthesizedAttachment([impostor, userServer], channels, "channel messaging");
108
86
  servers = injectSynthesizedAttachment(servers, conversation, "conversation participation");
109
87
 
110
88
  expect(servers.map((s) => s.slug)).toEqual([
111
89
  "github",
112
- DATASTORE_ATTACHMENT_SLUG,
90
+ CHANNEL_ATTACHMENT_SLUG,
113
91
  CONVERSATION_ATTACHMENT_SLUG,
114
92
  ]);
115
93
  expect(servers.find((s) => s.slug === CONVERSATION_ATTACHMENT_SLUG)?.url).toBe(
@@ -13,6 +13,11 @@
13
13
  * The runner never holds R2/S3 credentials — in cloud mode it calls the proxy
14
14
  * to obtain a presigned upload URL, then PUTs content over plain HTTPS.
15
15
  *
16
+ * Every proxy-backend request rides the shared bounded-backoff loop
17
+ * (http-retry.ts) with a per-request abort timeout, so a momentary
18
+ * stigmer-service interruption is retried and a hung connection cannot
19
+ * stall an agent turn unboundedly (stigmer/stigmer#468).
20
+ *
16
21
  * DD-6: No direct R2 backend. Local + Proxy only.
17
22
  */
18
23
 
@@ -20,10 +25,21 @@ import { mkdir, writeFile, readFile, access, rm } from "node:fs/promises";
20
25
  import { dirname, join, resolve, sep } from "node:path";
21
26
  import { homedir } from "node:os";
22
27
  import type { Config } from "../config.js";
28
+ import type { RetryOptions } from "./grpc-retry.js";
29
+ import { fetchWithRetry, type FetchRetryPolicy } from "./http-retry.js";
23
30
 
24
31
  // ── Interface ────────────────────────────────────────────────────────
25
32
 
26
33
  export interface ArtifactStorage {
34
+ /**
35
+ * What kind of URL {@link getDownloadUrl} mints — self-described by the
36
+ * backend so consumers (the attachment hand-off prompt wording,
37
+ * attachment-download-urls.ts) can never disagree with the storage actually
38
+ * in use. "presigned": time-limited, single-object, remotely fetchable.
39
+ * "local-serve": the stigmer-server's unauthenticated loopback serve URL,
40
+ * reachable only from this machine.
41
+ */
42
+ readonly downloadUrlKind: "presigned" | "local-serve";
27
43
  upload(key: string, content: Buffer, contentType?: string): Promise<string>;
28
44
  getDownloadUrl(key: string): Promise<string>;
29
45
  /**
@@ -45,6 +61,7 @@ export type ArtifactStorageType = "local" | "proxy";
45
61
  // ── Local Backend ────────────────────────────────────────────────────
46
62
 
47
63
  export class LocalArtifactStorage implements ArtifactStorage {
64
+ readonly downloadUrlKind = "local-serve" as const;
48
65
  private readonly basePath: string;
49
66
  private readonly serveUrlBase: string;
50
67
 
@@ -117,13 +134,65 @@ export class LocalArtifactStorage implements ArtifactStorage {
117
134
 
118
135
  // ── Proxy Backend ────────────────────────────────────────────────────
119
136
 
137
+ /** Retry policy plus the two per-request bounds; every field is defaulted. */
138
+ export interface ProxyArtifactStorageOptions extends RetryOptions {
139
+ /** Bound for proxy presign calls and the 1-byte exists probe. Default 30 s. */
140
+ readonly requestTimeoutMs?: number;
141
+ /**
142
+ * Bound for the presigned R2 PUT/GET, which move whole artifact payloads
143
+ * (claim-check offloads, tool outputs, file-review captures — MB scale).
144
+ * Deliberately wider than requestTimeoutMs so a legitimate slow transfer
145
+ * is never aborted by a bound sized for JSON round-trips. Default 120 s.
146
+ */
147
+ readonly transferTimeoutMs?: number;
148
+ }
149
+
150
+ /**
151
+ * Default transient-retry policy (stigmer/stigmer#468): the checkpointer's
152
+ * budget — sized to span a pod-restart reroute, ~7.75 s of delays across
153
+ * 5 retries. Retrying is safe at every site: the presign POSTs are
154
+ * idempotent mints, the PUT re-sends identical bytes to the same key (a
155
+ * keyed overwrite), and the GETs are reads.
156
+ */
157
+ const DEFAULT_RETRY = {
158
+ baseDelayMs: 250,
159
+ backoffFactor: 2,
160
+ maxRetries: 5,
161
+ requestTimeoutMs: 30_000,
162
+ transferTimeoutMs: 120_000,
163
+ } as const;
164
+
120
165
  export class ProxyArtifactStorage implements ArtifactStorage {
166
+ readonly downloadUrlKind = "presigned" as const;
121
167
  private readonly baseUrl: string;
122
168
  private readonly authTokenSource: ProxyAuthTokenSource;
123
-
124
- constructor(proxyEndpoint: string, authToken: ProxyAuthTokenSource) {
169
+ /** Governs the proxy presign calls and the exists probe. */
170
+ private readonly requestPolicy: FetchRetryPolicy;
171
+ /** Governs the presigned R2 transfers; same budget, wider timeout. */
172
+ private readonly transferPolicy: FetchRetryPolicy;
173
+
174
+ constructor(
175
+ proxyEndpoint: string,
176
+ authToken: ProxyAuthTokenSource,
177
+ options: ProxyArtifactStorageOptions = {},
178
+ ) {
125
179
  this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/artifacts`;
126
180
  this.authTokenSource = authToken;
181
+ const shared = {
182
+ label: "ProxyArtifactStorage",
183
+ baseDelayMs: options.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
184
+ backoffFactor: options.backoffFactor ?? DEFAULT_RETRY.backoffFactor,
185
+ maxRetries: options.maxRetries ?? DEFAULT_RETRY.maxRetries,
186
+ delayFn: options.delayFn,
187
+ };
188
+ this.requestPolicy = {
189
+ ...shared,
190
+ requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_RETRY.requestTimeoutMs,
191
+ };
192
+ this.transferPolicy = {
193
+ ...shared,
194
+ requestTimeoutMs: options.transferTimeoutMs ?? DEFAULT_RETRY.transferTimeoutMs,
195
+ };
127
196
  }
128
197
 
129
198
  /**
@@ -141,14 +210,14 @@ export class ProxyArtifactStorage implements ArtifactStorage {
141
210
  async upload(key: string, content: Buffer, contentType?: string): Promise<string> {
142
211
  const ct = contentType ?? "application/octet-stream";
143
212
 
144
- const presignResp = await fetch(`${this.baseUrl}/presigned-upload-url`, {
213
+ const presignResp = await fetchWithRetry(`${this.baseUrl}/presigned-upload-url`, {
145
214
  method: "POST",
146
215
  headers: {
147
216
  "Authorization": `Bearer ${this.authToken}`,
148
217
  "Content-Type": "application/json",
149
218
  },
150
219
  body: JSON.stringify({ key, content_type: ct }),
151
- });
220
+ }, this.requestPolicy);
152
221
  if (!presignResp.ok) {
153
222
  throw new Error(
154
223
  `Failed to get presigned upload URL (HTTP ${presignResp.status}): ` +
@@ -180,11 +249,14 @@ export class ProxyArtifactStorage implements ArtifactStorage {
180
249
  uploadHeaders["Content-Type"] = ct;
181
250
  }
182
251
 
183
- const putResp = await fetch(data.url, {
252
+ // Retrying the PUT alone (not the presign+PUT pair) is deliberate: the
253
+ // retry budget's few seconds of delays sit far inside presign validity,
254
+ // and re-sending the same Buffer to the same key is a keyed overwrite.
255
+ const putResp = await fetchWithRetry(data.url, {
184
256
  method: "PUT",
185
257
  headers: uploadHeaders,
186
258
  body: content,
187
- });
259
+ }, this.transferPolicy);
188
260
  if (!putResp.ok) {
189
261
  throw new Error(
190
262
  `Presigned upload failed (HTTP ${putResp.status}): ` +
@@ -196,14 +268,14 @@ export class ProxyArtifactStorage implements ArtifactStorage {
196
268
  }
197
269
 
198
270
  async getDownloadUrl(key: string): Promise<string> {
199
- const resp = await fetch(`${this.baseUrl}/presigned-download-url`, {
271
+ const resp = await fetchWithRetry(`${this.baseUrl}/presigned-download-url`, {
200
272
  method: "POST",
201
273
  headers: {
202
274
  "Authorization": `Bearer ${this.authToken}`,
203
275
  "Content-Type": "application/json",
204
276
  },
205
277
  body: JSON.stringify({ key }),
206
- });
278
+ }, this.requestPolicy);
207
279
  if (!resp.ok) {
208
280
  throw new Error(
209
281
  `Failed to get presigned download URL (HTTP ${resp.status}): ` +
@@ -216,7 +288,7 @@ export class ProxyArtifactStorage implements ArtifactStorage {
216
288
 
217
289
  async download(key: string): Promise<Buffer> {
218
290
  const url = await this.getDownloadUrl(key);
219
- const resp = await fetch(url);
291
+ const resp = await fetchWithRetry(url, undefined, this.transferPolicy);
220
292
  if (!resp.ok) {
221
293
  throw new Error(
222
294
  `Artifact download failed (HTTP ${resp.status}) for key '${key}': ` +
@@ -242,7 +314,11 @@ export class ProxyArtifactStorage implements ArtifactStorage {
242
314
  // addressed CAS blob dedup) then re-uploads, which is idempotent.
243
315
  return false;
244
316
  }
245
- const resp = await fetch(url, { headers: { Range: "bytes=0-0" } });
317
+ const resp = await fetchWithRetry(
318
+ url,
319
+ { headers: { Range: "bytes=0-0" } },
320
+ this.requestPolicy,
321
+ );
246
322
  await resp.arrayBuffer().catch(() => undefined); // drain the <=1-byte body
247
323
  if (resp.status === 404) return false;
248
324
  if (resp.status === 200 || resp.status === 206 || resp.status === 416) return true;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Download-URL hand-off for execution attachments (issue #532) — the single
3
+ * owner of the mint policy and the prompt wording that lets an agent pass an
4
+ * attachment to a tool whose backend cannot read the sandbox filesystem.
5
+ *
6
+ * Both harnesses (Cursor and deep-agent) materialize attachments to disk and
7
+ * list the workspace paths in their input-files prompt section; that story is
8
+ * unchanged and this module never touches it. What this module adds is the
9
+ * *remote hand-off* story: a per-attachment download URL, minted from the
10
+ * artifact storage the runner already downloads through, surfaced beside the
11
+ * path so the model can quote a short URL string in a tool-call argument
12
+ * instead of the impossible alternative (base64 through the model caps out in
13
+ * the tens of KB; real attachments run 2–8 MB).
14
+ *
15
+ * Mint rule: any attachment with a `storageKey` and a usable storage gets a
16
+ * URL, regardless of which branch materialized the bytes — this covers the
17
+ * local-mode fast path when an uploaded copy also exists. Attachments with no
18
+ * storage key (pure CLI-local files) and extracted ZIP entries (no
19
+ * attachment-level object) get no URL, and their listing is unchanged.
20
+ *
21
+ * Failure is non-fatal and silent in the prompt: the file IS materialized —
22
+ * only the remote hand-off affordance is absent — so a presign hiccup must
23
+ * not abort a turn the way a missing input does (the resolver/injector
24
+ * fail-hard doctrine covers inputs, not affordances). This mirrors how vision
25
+ * delivery degrades without killing materialization. The degrade is logged;
26
+ * the URL value itself is never logged (a presigned URL in the log pipeline
27
+ * would outlive its purpose).
28
+ *
29
+ * What the URL actually is depends on the storage backend, and the prompt
30
+ * must not overpromise: proxy storage mints genuinely presigned, time-limited,
31
+ * single-object URLs a remote service can fetch; local storage returns the
32
+ * stigmer-server's loopback serve URL, which only same-machine tools can
33
+ * reach. The backend self-describes via {@link ArtifactStorage.downloadUrlKind}
34
+ * and {@link downloadUrlDisclosureLine} words each kind honestly.
35
+ */
36
+
37
+ import type { ArtifactStorage } from "./artifact-storage.js";
38
+
39
+ /**
40
+ * What kind of URL a storage backend's `getDownloadUrl` mints. A property of
41
+ * the backend, not of the runner's execution mode: storage follows transport
42
+ * (see loadArtifactStorageConfig), so a local desktop runner on a cloud proxy
43
+ * mints real presigned URLs.
44
+ */
45
+ export type DownloadUrlKind = "presigned" | "local-serve";
46
+
47
+ /**
48
+ * Mint a download URL for one attachment, or `undefined` when there is
49
+ * nothing to mint (no key / no storage) or the mint fails (logged, non-fatal
50
+ * — see module doc). The caller spreads the result into its per-attachment
51
+ * record with the conditional-spread convention, so an unminted URL leaves no
52
+ * field behind.
53
+ */
54
+ export async function mintAttachmentDownloadUrl(
55
+ storage: ArtifactStorage | undefined,
56
+ storageKey: string,
57
+ filename: string,
58
+ ): Promise<string | undefined> {
59
+ if (!storageKey || !storage) return undefined;
60
+ try {
61
+ return await storage.getDownloadUrl(storageKey);
62
+ } catch (err) {
63
+ console.warn(
64
+ `[attachment-download-urls] could not mint a download URL for ` +
65
+ `'${filename}' (key: ${storageKey}) — the file is materialized and the ` +
66
+ `turn proceeds without one: ${err instanceof Error ? err.message : String(err)}`,
67
+ );
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The shared hand-off wording both harnesses embed into their input-files
74
+ * prompt section when at least one listed file carries a download URL (each
75
+ * wraps it in its own section framing). Kept here so the two prompts never
76
+ * drift apart in what they promise the agent.
77
+ *
78
+ * Each kind is worded to its real capability. "Time-limited" is deliberately
79
+ * unquantified: the presign TTL belongs to the serving side (the cloud
80
+ * proxy's constant today) and hardcoding it here would silently drift.
81
+ */
82
+ export function downloadUrlDisclosureLine(kind: DownloadUrlKind): string {
83
+ switch (kind) {
84
+ case "presigned":
85
+ return (
86
+ "Where a file lists a download URL, you can pass that URL to tools " +
87
+ "whose backends cannot read this workspace's filesystem (e.g. remote " +
88
+ "services) — the tool fetches the file's contents itself. These URLs " +
89
+ "are time-limited and each grants access to its single file only."
90
+ );
91
+ case "local-serve":
92
+ return (
93
+ "Where a file lists a download URL, it is served by the local Stigmer " +
94
+ "server and is reachable only from this machine — tools running on " +
95
+ "this machine can fetch it, but remote services cannot."
96
+ );
97
+ }
98
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The runner-synthesized channel messaging attachment (proactive-messaging
3
- * DD-006 D7/D8) — the datastore records attachment's structural twin.
3
+ * DD-006 D7/D8).
4
4
  *
5
5
  * When the control plane says an agent serves at least one
6
6
  * proactive-messaging channel (the `listMessagingChannels` discovery