@stigmer/runner 3.10.0 → 3.11.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 (260) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +18 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +51 -57
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +55 -23
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/caller-identity.d.ts +23 -2
  100. package/dist/shared/caller-identity.js +36 -5
  101. package/dist/shared/caller-identity.js.map +1 -1
  102. package/dist/shared/channel-attachment.js +1 -0
  103. package/dist/shared/channel-attachment.js.map +1 -1
  104. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  105. package/dist/shared/checkpointer/http-saver.js +96 -8
  106. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  107. package/dist/shared/conversation-attachment.js +1 -0
  108. package/dist/shared/conversation-attachment.js.map +1 -1
  109. package/dist/shared/datastore-attachment.d.ts +50 -7
  110. package/dist/shared/datastore-attachment.js +93 -11
  111. package/dist/shared/datastore-attachment.js.map +1 -1
  112. package/dist/shared/http-retry.d.ts +43 -0
  113. package/dist/shared/http-retry.js +50 -0
  114. package/dist/shared/http-retry.js.map +1 -0
  115. package/dist/shared/llm-backend.d.ts +275 -0
  116. package/dist/shared/llm-backend.js +425 -0
  117. package/dist/shared/llm-backend.js.map +1 -0
  118. package/dist/shared/llm-proxy.d.ts +8 -0
  119. package/dist/shared/llm-proxy.js +15 -0
  120. package/dist/shared/llm-proxy.js.map +1 -1
  121. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  122. package/dist/shared/mcp-enabled-tools.js +86 -0
  123. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  124. package/dist/shared/mcp-manager.d.ts +3 -1
  125. package/dist/shared/mcp-manager.js +17 -4
  126. package/dist/shared/mcp-manager.js.map +1 -1
  127. package/dist/shared/mcp-resolver.d.ts +39 -2
  128. package/dist/shared/mcp-resolver.js +38 -2
  129. package/dist/shared/mcp-resolver.js.map +1 -1
  130. package/dist/shared/model-client.d.ts +12 -5
  131. package/dist/shared/model-client.js +138 -18
  132. package/dist/shared/model-client.js.map +1 -1
  133. package/dist/shared/model-error.js +198 -5
  134. package/dist/shared/model-error.js.map +1 -1
  135. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  136. package/dist/shared/plan-mode-permissions.js +28 -0
  137. package/dist/shared/plan-mode-permissions.js.map +1 -0
  138. package/dist/worker.d.ts +2 -1
  139. package/dist/worker.js +2 -4
  140. package/dist/worker.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +18 -0
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  144. package/dist/workflows/call-agent-orchestrator.js +1 -0
  145. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  146. package/dist/workflows/connect-mcp-server.js +7 -0
  147. package/dist/workflows/connect-mcp-server.js.map +1 -1
  148. package/dist/workflows/engine-core.js +23 -2
  149. package/dist/workflows/engine-core.js.map +1 -1
  150. package/dist/workflows/execute-from-execution.d.ts +1 -1
  151. package/dist/workflows/execute-from-execution.js +11 -1
  152. package/dist/workflows/execute-from-execution.js.map +1 -1
  153. package/package.json +8 -2
  154. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  155. package/src/__tests__/encryption-codec.test.ts +234 -0
  156. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  157. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  158. package/src/__tests__/preflight.test.ts +50 -2
  159. package/src/activities/__tests__/call-llm.test.ts +75 -0
  160. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  161. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  162. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  163. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  164. package/src/activities/call-llm.ts +9 -16
  165. package/src/activities/classify-tool-approvals.ts +34 -4
  166. package/src/activities/discover-mcp-server.ts +190 -32
  167. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  168. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  169. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +53 -0
  170. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  171. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  172. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  173. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  174. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  175. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  176. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  177. package/src/activities/execute-cursor/approval-state.ts +30 -1
  178. package/src/activities/execute-cursor/attachment-resolver.ts +31 -3
  179. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  180. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  181. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  182. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  183. package/src/activities/execute-cursor/hook-script.ts +74 -10
  184. package/src/activities/execute-cursor/index.ts +55 -71
  185. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  186. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  187. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  188. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +110 -8
  189. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  190. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  191. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  192. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  193. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  194. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  195. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  196. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  197. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  198. package/src/activities/execute-deep-agent/environment.ts +3 -1
  199. package/src/activities/execute-deep-agent/index.ts +20 -0
  200. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  201. package/src/activities/execute-deep-agent/setup.ts +76 -28
  202. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  203. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  204. package/src/activities/hydrate-workflow-execution.ts +3 -1
  205. package/src/activities/workflow-event-activities.ts +96 -69
  206. package/src/claimcheck/payload-codec.ts +33 -1
  207. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  208. package/src/client/stigmer-client.ts +32 -18
  209. package/src/encryption/config.ts +91 -0
  210. package/src/encryption/index.ts +3 -0
  211. package/src/encryption/payload-codec.ts +152 -0
  212. package/src/payload-codecs.ts +56 -0
  213. package/src/preflight.ts +45 -0
  214. package/src/runner-manager.ts +6 -24
  215. package/src/runner.ts +6 -25
  216. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  217. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  218. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  219. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  220. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  221. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  222. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  223. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  224. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  225. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  226. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  227. package/src/shared/__tests__/http-retry.test.ts +67 -0
  228. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  229. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  230. package/src/shared/__tests__/mcp-manager.test.ts +84 -2
  231. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  232. package/src/shared/__tests__/model-client.test.ts +154 -0
  233. package/src/shared/__tests__/model-error.test.ts +289 -1
  234. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  235. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  236. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  237. package/src/shared/approval-policy.ts +14 -7
  238. package/src/shared/attachment-naming.ts +78 -0
  239. package/src/shared/caller-identity.ts +40 -5
  240. package/src/shared/channel-attachment.ts +1 -0
  241. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  242. package/src/shared/checkpointer/http-saver.ts +117 -9
  243. package/src/shared/conversation-attachment.ts +1 -0
  244. package/src/shared/datastore-attachment.ts +106 -11
  245. package/src/shared/http-retry.ts +50 -0
  246. package/src/shared/llm-backend.ts +544 -0
  247. package/src/shared/llm-proxy.ts +15 -0
  248. package/src/shared/mcp-enabled-tools.ts +105 -0
  249. package/src/shared/mcp-manager.ts +21 -4
  250. package/src/shared/mcp-resolver.ts +73 -2
  251. package/src/shared/model-client.ts +161 -19
  252. package/src/shared/model-error.ts +222 -4
  253. package/src/shared/plan-mode-permissions.ts +30 -0
  254. package/src/worker.ts +4 -5
  255. package/src/workflow-engine/types.ts +18 -0
  256. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  257. package/src/workflows/call-agent-orchestrator.ts +10 -0
  258. package/src/workflows/connect-mcp-server.ts +7 -0
  259. package/src/workflows/engine-core.ts +23 -2
  260. package/src/workflows/execute-from-execution.ts +12 -2
@@ -29,9 +29,17 @@ async function serializeForProxy(obj: unknown): Promise<{ type: string; binary:
29
29
  describe("HttpCheckpointSaver", () => {
30
30
  let saver: HttpCheckpointSaver;
31
31
  let fetchSpy: ReturnType<typeof vi.fn>;
32
+ let recordedDelays: number[];
32
33
 
33
34
  beforeEach(() => {
34
- saver = new HttpCheckpointSaver(PROXY, TOKEN);
35
+ // delayFn injected so retry-path tests record the backoff schedule
36
+ // instead of sleeping through it (~7.75 s of real delays otherwise).
37
+ recordedDelays = [];
38
+ saver = new HttpCheckpointSaver(PROXY, TOKEN, {
39
+ delayFn: async (ms) => {
40
+ recordedDelays.push(ms);
41
+ },
42
+ });
35
43
  fetchSpy = vi.fn();
36
44
  vi.stubGlobal("fetch", fetchSpy);
37
45
  });
@@ -163,6 +171,193 @@ describe("HttpCheckpointSaver", () => {
163
171
  });
164
172
  });
165
173
 
174
+ // The bounded-backoff loop (cloud#188). Classification policy itself is
175
+ // covered by shared/__tests__/http-retry.test.ts; these cases pin the
176
+ // loop's behavior at the saver's call sites: transient failures recover,
177
+ // deterministic failures never retry, budget exhaustion still fails
178
+ // loudly, and 404 semantics survive unchanged.
179
+ describe("retry behavior", () => {
180
+ const ok404 = { status: 404, ok: false, statusText: "Not Found" };
181
+ const err503 = { status: 503, ok: false, statusText: "Service Unavailable" };
182
+ const err400 = { status: 400, ok: false, statusText: "Bad Request" };
183
+ const err401 = { status: 401, ok: false, statusText: "Unauthorized" };
184
+
185
+ function makeCheckpoint(): Checkpoint {
186
+ return {
187
+ v: 4,
188
+ id: "cp-retry",
189
+ ts: new Date().toISOString(),
190
+ channel_values: {},
191
+ channel_versions: {},
192
+ versions_seen: {},
193
+ };
194
+ }
195
+ const md: CheckpointMetadata = { source: "loop", step: 0, parents: {} };
196
+
197
+ it("recovers a put from a transient 503", async () => {
198
+ fetchSpy
199
+ .mockResolvedValueOnce(err503)
200
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
201
+
202
+ const result = await saver.put(makeConfig(), makeCheckpoint(), md, {});
203
+ expect(result.configurable?.checkpoint_id).toBe("cp-retry");
204
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
205
+ expect(recordedDelays).toEqual([250]);
206
+ });
207
+
208
+ it("recovers a put from a network-level rejection (undici TypeError)", async () => {
209
+ fetchSpy
210
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
211
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
212
+
213
+ await expect(saver.put(makeConfig(), makeCheckpoint(), md, {})).resolves.toBeDefined();
214
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
215
+ });
216
+
217
+ it("backs off exponentially across consecutive transient failures", async () => {
218
+ fetchSpy
219
+ .mockResolvedValueOnce(err503)
220
+ .mockResolvedValueOnce(err503)
221
+ .mockResolvedValueOnce(err503)
222
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
223
+
224
+ await saver.put(makeConfig(), makeCheckpoint(), md, {});
225
+ expect(recordedDelays).toEqual([250, 500, 1000]);
226
+ });
227
+
228
+ it("fails loudly when the budget is exhausted on a retryable status", async () => {
229
+ fetchSpy.mockResolvedValue(err503);
230
+
231
+ await expect(saver.put(makeConfig(), makeCheckpoint(), md, {}))
232
+ .rejects.toThrow("Checkpoint PUT failed: 503");
233
+ // 1 initial attempt + 5 retries (the default budget).
234
+ expect(fetchSpy).toHaveBeenCalledTimes(6);
235
+ expect(recordedDelays).toEqual([250, 500, 1000, 2000, 4000]);
236
+ });
237
+
238
+ it("rethrows the original error when the budget is exhausted on network rejections", async () => {
239
+ const netErr = new TypeError("fetch failed");
240
+ fetchSpy.mockRejectedValue(netErr);
241
+
242
+ await expect(saver.putWrites(makeConfig(), [["messages", { a: 1 }]], "t1"))
243
+ .rejects.toBe(netErr);
244
+ expect(fetchSpy).toHaveBeenCalledTimes(6);
245
+ });
246
+
247
+ it("never retries a deterministic 400", async () => {
248
+ fetchSpy.mockResolvedValue(err400);
249
+
250
+ await expect(saver.put(makeConfig(), makeCheckpoint(), md, {}))
251
+ .rejects.toThrow("Checkpoint PUT failed: 400");
252
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
253
+ expect(recordedDelays).toEqual([]);
254
+ });
255
+
256
+ it("never retries a rejected credential (401)", async () => {
257
+ fetchSpy.mockResolvedValue(err401);
258
+
259
+ await expect(saver.getTuple(makeConfig())).rejects.toThrow("Checkpoint GET failed: 401");
260
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("never retries a manual abort (AbortError)", async () => {
264
+ const abortErr = new DOMException("This operation was aborted", "AbortError");
265
+ fetchSpy.mockRejectedValue(abortErr);
266
+
267
+ await expect(saver.getTuple(makeConfig())).rejects.toBe(abortErr);
268
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
269
+ });
270
+
271
+ it("retries a hung request aborted by the per-request timeout (TimeoutError)", async () => {
272
+ fetchSpy
273
+ .mockRejectedValueOnce(
274
+ new DOMException("The operation was aborted due to timeout", "TimeoutError"),
275
+ )
276
+ .mockResolvedValueOnce(ok404);
277
+
278
+ await expect(saver.getTuple(makeConfig())).resolves.toBeUndefined();
279
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
280
+ });
281
+
282
+ it("preserves getTuple's 404 semantics without retrying", async () => {
283
+ fetchSpy.mockResolvedValue(ok404);
284
+
285
+ await expect(saver.getTuple(makeConfig())).resolves.toBeUndefined();
286
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
287
+ });
288
+
289
+ it("preserves deleteThread's 404 tolerance without retrying", async () => {
290
+ fetchSpy.mockResolvedValue(ok404);
291
+
292
+ await expect(saver.deleteThread("thread-1")).resolves.toBeUndefined();
293
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
294
+ });
295
+
296
+ it("bounds every request with an abort signal", async () => {
297
+ fetchSpy.mockResolvedValue(ok404);
298
+
299
+ await saver.getTuple(makeConfig());
300
+ const [, opts] = fetchSpy.mock.calls[0];
301
+ expect(opts.signal).toBeInstanceOf(AbortSignal);
302
+ });
303
+
304
+ it("surfaces a pending-writes read failure instead of silently degrading", async () => {
305
+ // The checkpoint GET succeeds; the follow-up writes GET fails on every
306
+ // attempt. Pre-#188 code mapped this to "no pending writes" and
307
+ // returned a tuple stripped of resume state.
308
+ const cp = makeCheckpoint();
309
+ const cpSer = await serializeForProxy(cp);
310
+ const mdSer = await serializeForProxy(md);
311
+ const checkpointDoc = {
312
+ thread_id: "thread-1",
313
+ checkpoint_ns: "",
314
+ checkpoint_id: cp.id,
315
+ type: cpSer.type,
316
+ checkpoint: cpSer.binary,
317
+ metadata_type: mdSer.type,
318
+ metadata: mdSer.binary,
319
+ };
320
+ fetchSpy.mockImplementation((url: string) => {
321
+ if (url.includes("/writes")) return Promise.resolve(err503);
322
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(checkpointDoc) });
323
+ });
324
+
325
+ await expect(saver.getTuple(makeConfig())).rejects.toThrow(
326
+ "Checkpoint writes GET failed: 503",
327
+ );
328
+ });
329
+
330
+ it("recovers the pending-writes read from a transient failure", async () => {
331
+ const cp = makeCheckpoint();
332
+ const cpSer = await serializeForProxy(cp);
333
+ const mdSer = await serializeForProxy(md);
334
+ const checkpointDoc = {
335
+ thread_id: "thread-1",
336
+ checkpoint_ns: "",
337
+ checkpoint_id: cp.id,
338
+ type: cpSer.type,
339
+ checkpoint: cpSer.binary,
340
+ metadata_type: mdSer.type,
341
+ metadata: mdSer.binary,
342
+ };
343
+ let writesCalls = 0;
344
+ fetchSpy.mockImplementation((url: string) => {
345
+ if (url.includes("/writes")) {
346
+ writesCalls++;
347
+ return writesCalls === 1
348
+ ? Promise.resolve(err503)
349
+ : Promise.resolve({ ok: true, json: () => Promise.resolve({ writes: [] }) });
350
+ }
351
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(checkpointDoc) });
352
+ });
353
+
354
+ const tuple = await saver.getTuple(makeConfig());
355
+ expect(tuple?.checkpoint.id).toBe(cp.id);
356
+ expect(tuple?.pendingWrites).toEqual([]);
357
+ expect(writesCalls).toBe(2);
358
+ });
359
+ });
360
+
166
361
  describe("URL construction", () => {
167
362
  it("strips trailing slashes from proxy endpoint", () => {
168
363
  const s = new HttpCheckpointSaver("https://proxy.test///", "tok");
@@ -13,6 +13,19 @@
13
13
  *
14
14
  * The Java proxy calls Document.parse(json) which handles $binary
15
15
  * natively, and doc.toJson() emits the same format on reads.
16
+ *
17
+ * Every request runs through {@link HttpCheckpointSaver.fetchWithRetry}:
18
+ * bounded exponential backoff on transient failures (classified by
19
+ * http-retry.ts) with a per-request abort timeout. This loop is the ONLY
20
+ * retry layer between an agent execution and its checkpoints — the deep
21
+ * agent activity is deliberately non-retryable at the Temporal level
22
+ * (maximumAttempts: 1; replaying a whole agent run is not safe), so before
23
+ * this existed a single dropped request killed the execution
24
+ * (stigmer/stigmer-cloud#188). Retrying puts is safe by server contract:
25
+ * CheckpointStore documents putCheckpoint/putWrite as keyed save-or-replace
26
+ * upserts. Budget exhaustion still fails loudly — checkpoints are not
27
+ * lossy-tolerable (unlike status updates, see status.ts), and silently
28
+ * dropping one would corrupt resume state.
16
29
  */
17
30
 
18
31
  import type { RunnableConfig } from "@langchain/core/runnables";
@@ -25,6 +38,8 @@ import {
25
38
  type ChannelVersions,
26
39
  } from "@langchain/langgraph-checkpoint";
27
40
  import type { CheckpointMetadata, PendingWrite } from "@langchain/langgraph-checkpoint";
41
+ import type { RetryOptions } from "../grpc-retry.js";
42
+ import { isRetryableFetchError, isRetryableHttpStatus } from "../http-retry.js";
28
43
 
29
44
  function encodeB64(data: Uint8Array): string {
30
45
  if (typeof Buffer !== "undefined") {
@@ -77,17 +92,104 @@ function configOrg(config: RunnableConfig): string | undefined {
77
92
  return config.configurable?.org as string | undefined;
78
93
  }
79
94
 
95
+ /** Retry policy plus the per-request bound; every field is defaulted. */
96
+ export interface HttpCheckpointSaverOptions extends RetryOptions {
97
+ /**
98
+ * Milliseconds before an in-flight request is aborted and the attempt is
99
+ * classified retryable. Without it a hung connection (the realistic
100
+ * unplanned-pod-kill symptom) stalls forever and the retry never engages.
101
+ */
102
+ readonly requestTimeoutMs?: number;
103
+ }
104
+
105
+ /**
106
+ * Default transient-retry policy. Wider than status.ts's DEFAULT_PERSIST_RETRY
107
+ * (3 retries, ~700 ms) on purpose: a dropped status update is lossy-tolerable,
108
+ * a dropped checkpoint kills the execution, so the budget here is sized to
109
+ * span a pod-restart reroute (~7.75 s of delays across 5 retries). Worst case
110
+ * with every attempt hanging — 6 x 30 s + delays, ~3.7 min — stays well inside
111
+ * the deep-agent activity's 1 h startToCloseTimeout (no heartbeatTimeout).
112
+ */
113
+ const DEFAULT_RETRY = {
114
+ baseDelayMs: 250,
115
+ backoffFactor: 2,
116
+ maxRetries: 5,
117
+ requestTimeoutMs: 30_000,
118
+ } as const;
119
+
120
+ function defaultDelay(ms: number): Promise<void> {
121
+ return new Promise((resolve) => setTimeout(resolve, ms));
122
+ }
123
+
80
124
  export class HttpCheckpointSaver extends BaseCheckpointSaver {
81
125
  private readonly baseUrl: string;
82
126
  private readonly headers: Record<string, string>;
83
-
84
- constructor(proxyEndpoint: string, authToken: string) {
127
+ private readonly baseDelayMs: number;
128
+ private readonly backoffFactor: number;
129
+ private readonly maxRetries: number;
130
+ private readonly requestTimeoutMs: number;
131
+ private readonly delay: (ms: number) => Promise<void>;
132
+
133
+ constructor(
134
+ proxyEndpoint: string,
135
+ authToken: string,
136
+ options: HttpCheckpointSaverOptions = {},
137
+ ) {
85
138
  super();
86
139
  this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/checkpoints`;
87
140
  this.headers = {
88
141
  Authorization: `Bearer ${authToken}`,
89
142
  "Content-Type": "application/json",
90
143
  };
144
+ this.baseDelayMs = options.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs;
145
+ this.backoffFactor = options.backoffFactor ?? DEFAULT_RETRY.backoffFactor;
146
+ this.maxRetries = options.maxRetries ?? DEFAULT_RETRY.maxRetries;
147
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_RETRY.requestTimeoutMs;
148
+ this.delay = options.delayFn ?? defaultDelay;
149
+ }
150
+
151
+ /**
152
+ * `fetch` with bounded exponential backoff on transient failures and a
153
+ * per-request abort timeout.
154
+ *
155
+ * Composition contract: on a non-retryable status — or when the budget is
156
+ * exhausted on a retryable one — the last `Response` is RETURNED, so every
157
+ * call site keeps its own `resp.ok` / 404 handling unchanged; this wrapper
158
+ * changes when a response arrives, never what call sites do with it. It
159
+ * throws only what `fetch` itself throws: a network-level rejection that is
160
+ * non-retryable or has exhausted the budget.
161
+ */
162
+ private async fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
163
+ for (let attempt = 0; ; attempt++) {
164
+ const canRetry = attempt < this.maxRetries;
165
+ let resp: Response;
166
+ try {
167
+ resp = await fetch(url, {
168
+ ...init,
169
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
170
+ });
171
+ } catch (err) {
172
+ if (canRetry && isRetryableFetchError(err)) {
173
+ await this.backOff(attempt, String(err));
174
+ continue;
175
+ }
176
+ throw err;
177
+ }
178
+ if (canRetry && isRetryableHttpStatus(resp.status)) {
179
+ await this.backOff(attempt, `HTTP ${resp.status} ${resp.statusText}`);
180
+ continue;
181
+ }
182
+ return resp;
183
+ }
184
+ }
185
+
186
+ private async backOff(attempt: number, reason: string): Promise<void> {
187
+ const delayMs = this.baseDelayMs * Math.pow(this.backoffFactor, attempt);
188
+ console.warn(
189
+ `[HttpCheckpointSaver] retryable failure ` +
190
+ `(attempt ${attempt + 1}/${this.maxRetries + 1}, retry in ${delayMs}ms): ${reason}`,
191
+ );
192
+ await this.delay(delayMs);
91
193
  }
92
194
 
93
195
  private async serializeTyped(obj: unknown): Promise<[string, BinaryObj]> {
@@ -113,7 +215,7 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
113
215
  params.set("checkpoint_id", checkpointId);
114
216
  }
115
217
 
116
- const resp = await fetch(`${this.baseUrl}/checkpoint?${params}`, {
218
+ const resp = await this.fetchWithRetry(`${this.baseUrl}/checkpoint?${params}`, {
117
219
  headers: this.headers,
118
220
  });
119
221
  if (resp.status === 404) return undefined;
@@ -143,7 +245,7 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
143
245
  params.set("before", beforeId);
144
246
  }
145
247
 
146
- const resp = await fetch(`${this.baseUrl}/checkpoints?${params}`, {
248
+ const resp = await this.fetchWithRetry(`${this.baseUrl}/checkpoints?${params}`, {
147
249
  headers: this.headers,
148
250
  });
149
251
  if (!resp.ok) {
@@ -183,7 +285,7 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
183
285
  const orgId = configOrg(config);
184
286
  if (orgId) doc.org_id = orgId;
185
287
 
186
- const resp = await fetch(`${this.baseUrl}/checkpoint`, {
288
+ const resp = await this.fetchWithRetry(`${this.baseUrl}/checkpoint`, {
187
289
  method: "PUT",
188
290
  headers: this.headers,
189
291
  body: JSON.stringify(doc),
@@ -203,7 +305,7 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
203
305
 
204
306
  async deleteThread(threadId: string): Promise<void> {
205
307
  const params = new URLSearchParams({ thread_id: threadId });
206
- const resp = await fetch(`${this.baseUrl}/thread?${params}`, {
308
+ const resp = await this.fetchWithRetry(`${this.baseUrl}/thread?${params}`, {
207
309
  method: "DELETE",
208
310
  headers: this.headers,
209
311
  });
@@ -238,7 +340,7 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
238
340
  return doc;
239
341
  }));
240
342
 
241
- const resp = await fetch(`${this.baseUrl}/writes`, {
343
+ const resp = await this.fetchWithRetry(`${this.baseUrl}/writes`, {
242
344
  method: "PUT",
243
345
  headers: this.headers,
244
346
  body: JSON.stringify({ writes: docs }),
@@ -272,7 +374,7 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
272
374
  };
273
375
  }
274
376
 
275
- const writesResp = await fetch(
377
+ const writesResp = await this.fetchWithRetry(
276
378
  `${this.baseUrl}/writes?${new URLSearchParams({
277
379
  thread_id: threadId,
278
380
  checkpoint_ns: checkpointNs,
@@ -280,8 +382,14 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
280
382
  })}`,
281
383
  { headers: this.headers },
282
384
  );
385
+ // Fail loudly, never degrade: this used to map ANY non-ok response to "no
386
+ // pending writes", which silently stripped resume state on a transient
387
+ // 500 — a checkpoint would load without the writes recorded against it.
388
+ if (!writesResp.ok) {
389
+ throw new Error(`Checkpoint writes GET failed: ${writesResp.status} ${writesResp.statusText}`);
390
+ }
283
391
  const pendingWrites = await this.parseWrites(
284
- writesResp.ok ? (await writesResp.json()) as Record<string, any> : {},
392
+ (await writesResp.json()) as Record<string, any>,
285
393
  );
286
394
 
287
395
  return {
@@ -105,6 +105,7 @@ export function synthesizeConversationAttachment(
105
105
  slug: CONVERSATION_ATTACHMENT_SLUG,
106
106
  toolApprovals: [],
107
107
  pinnedToolApprovals: [],
108
+ toolApprovalOverrides: [],
108
109
  discoveredCapabilitiesEmpty: false,
109
110
  connectionType: "http",
110
111
  url: options.bridgeEndpoint.replace(/\/+$/, "") + CONVERSATION_ROUTE,
@@ -22,8 +22,12 @@
22
22
  * OSS callers resolve as the local principal (T05 R2).
23
23
  *
24
24
  * Approval-free by construction (DD-001 SD-3): empty toolApprovals +
25
- * pinnedToolApprovals mean mergeApprovalPolicies emits no entries for
26
- * this server — zero classifier involvement. `discoveredCapabilitiesEmpty`
25
+ * pinnedToolApprovals + toolApprovalOverrides mean mergeApprovalPolicies
26
+ * emits no entries for this server — zero classifier involvement, and no
27
+ * proto surface can target it (the attachment has no McpServerUsage, and
28
+ * since issue #349 an agent's overrides are scoped to their own usage's
29
+ * server, so a same-named override elsewhere cannot reach this one).
30
+ * `discoveredCapabilitiesEmpty`
27
31
  * is false and the attachment has no McpServerUsage, so the connect
28
32
  * backfill (whose destructiveHint tightener would force-gate
29
33
  * delete_record — silently skipped on channels under UNATTENDED mode)
@@ -63,6 +67,7 @@ export function synthesizeDatastoreAttachment(
63
67
  slug: DATASTORE_ATTACHMENT_SLUG,
64
68
  toolApprovals: [],
65
69
  pinnedToolApprovals: [],
70
+ toolApprovalOverrides: [],
66
71
  discoveredCapabilitiesEmpty: false,
67
72
  };
68
73
 
@@ -90,25 +95,115 @@ export function synthesizeDatastoreAttachment(
90
95
  }
91
96
 
92
97
  /**
93
- * The `<available_datastores>` prompt section (DD-005 SD-5, the
94
- * skills-section precedent): names the attached datastores and points
95
- * the model at describe_datastore first. Shared by both harnesses so
96
- * the section text cannot drift (the sender-identity precedent).
98
+ * The five record tools the records roster always serves (DD-005; the
99
+ * mcp-server's records roster registers all five unconditionally for the
100
+ * agent audience — only the `org` argument shape varies by audience, see
101
+ * mcp-server/src/domains/records/tools.ts). Because the roster never
102
+ * legitimately narrows, any of these missing from the connected toolset
103
+ * means the store is degraded (issue #325) — the prompt section and the
104
+ * reconciliation both derive from this one list so they cannot drift.
97
105
  */
98
- export function formatDatastoresSection(datastoreUsages: DatastoreUsage[]): string {
99
- const slugs = datastoreUsages
106
+ export const EXPECTED_RECORD_TOOLS = [
107
+ "describe_datastore",
108
+ "find_records",
109
+ "insert_record",
110
+ "update_record",
111
+ "delete_record",
112
+ ] as const;
113
+
114
+ /**
115
+ * Reconcile the record tools actually connected against the roster
116
+ * contract (issue #325). Returns the expected tools absent from
117
+ * `actualToolNames` — empty means healthy. Names are bare tool names
118
+ * exactly as reported by tools/list (the mcp-enabled-tools identity
119
+ * space); extraneous names are ignored.
120
+ */
121
+ export function missingRecordTools(actualToolNames: Iterable<string>): string[] {
122
+ const present = new Set(actualToolNames);
123
+ return EXPECTED_RECORD_TOOLS.filter((name) => !present.has(name));
124
+ }
125
+
126
+ /**
127
+ * The operator-facing degradation notice (issue #325): pushed as a
128
+ * MESSAGE_SYSTEM row on the execution status so "declared N datastores,
129
+ * X/5 record tools connected" is visible without reading a transcript.
130
+ * Lives HERE with the rest of the datastore-attachment wording — the
131
+ * harness only threads it.
132
+ */
133
+ export function formatDatastoreDegradationNotice(
134
+ declaredCount: number,
135
+ missing: readonly string[],
136
+ ): string {
137
+ const connected = EXPECTED_RECORD_TOOLS.length - missing.length;
138
+ return (
139
+ `Datastore record tools unavailable this turn: declared ` +
140
+ `${declaredCount} datastore(s), ${connected}/${EXPECTED_RECORD_TOOLS.length} ` +
141
+ `record tools connected (missing: ${missing.join(", ")}). The agent has ` +
142
+ `been instructed to disclose this instead of answering from memory.`
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The datastores prompt section (DD-005 SD-5, the skills-section
148
+ * precedent). Shared by both harnesses so the section text cannot drift
149
+ * (the sender-identity precedent). Two honest renderings (issue #325):
150
+ *
151
+ * - Healthy (`missingToolNames` empty or omitted — the Cursor harness
152
+ * always calls it this way: the Cursor SDK connects MCP itself, so
153
+ * that harness can never observe the live roster):
154
+ * `<available_datastores>` names the attached datastores, points the
155
+ * model at describe_datastore first, and carries a standing
156
+ * failure-disclosure instruction — the only mechanism that covers
157
+ * tools which connected but fail at call time (the WhatsApp-pilot
158
+ * outage shape).
159
+ * - Degraded (deep-agent only, from missingRecordTools against the
160
+ * connected roster): `<unavailable_datastores>` instead — the section
161
+ * must not promise tools the agent does not have. Names the declared
162
+ * datastores, states which record tools are missing, and instructs
163
+ * plain disclosure over improvisation.
164
+ */
165
+ export function formatDatastoresSection(
166
+ datastoreUsages: DatastoreUsage[],
167
+ missingToolNames: readonly string[] = [],
168
+ ): string {
169
+ const entries = datastoreUsages
100
170
  .map((u) => u.datastoreRef?.slug)
101
- .filter((s): s is string => !!s);
102
- const entries = slugs.map((slug) => `- ${slug}`);
171
+ .filter((s): s is string => !!s)
172
+ .map((slug) => `- ${slug}`);
173
+
174
+ if (missingToolNames.length > 0) {
175
+ return [
176
+ "<unavailable_datastores>",
177
+ "This agent is configured to use the datastores listed below, but the",
178
+ "following record tools failed to connect this turn:",
179
+ missingToolNames.join(", ") + ".",
180
+ "Treat these datastores as unreachable for any operation that needs a",
181
+ "missing tool.",
182
+ "",
183
+ ...entries,
184
+ "",
185
+ "When the user's request depends on one of these datastores, tell them",
186
+ "plainly that the datastore cannot be reached right now. Never answer",
187
+ "from memory, guess, or improvise a substitute for what the datastore",
188
+ "would have returned.",
189
+ "</unavailable_datastores>",
190
+ ].join("\n");
191
+ }
192
+
103
193
  return [
104
194
  "<available_datastores>",
105
195
  "You have access to the following datastores through the record tools",
106
- "(describe_datastore, find_records, insert_record, update_record, delete_record).",
196
+ `(${EXPECTED_RECORD_TOOLS.join(", ")}).`,
107
197
  "Before your first operation against a datastore, call describe_datastore to",
108
198
  "learn its collections, field encodings, and which operations you are allowed",
109
199
  "to perform.",
110
200
  "",
111
201
  ...entries,
202
+ "",
203
+ "If any record tool is missing from your toolset or a record tool call",
204
+ "fails, the datastore is unreachable: tell the user plainly that you cannot",
205
+ "reach it right now, and do not answer from memory, guess, or improvise a",
206
+ "substitute for what it would have returned.",
112
207
  "</available_datastores>",
113
208
  ].join("\n");
114
209
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * HTTP error classification for side-channel proxy retries.
3
+ *
4
+ * The runner reaches stigmer-service's Side-Channel Proxy (`/v1/proxy/...`)
5
+ * over plain `fetch` from several clients — the checkpoint saver today;
6
+ * artifact storage, the LLM proxy, and the registry endpoint are candidates
7
+ * (stigmer/stigmer#468). This module is the one place that answers "is this
8
+ * HTTP failure worth retrying?", the `fetch` twin of grpc-retry.ts: it holds
9
+ * the classification policy only, kept small and separately tested. The
10
+ * bounded-backoff loop that consumes it lives with each client (the
11
+ * grpc-retry/status.ts split).
12
+ *
13
+ * The policy is grounded in what the proxy actually emits, not HTTP folklore.
14
+ * CheckpointerProxyController returns exactly four deterministic errors —
15
+ * 400 (malformed document), 403 (FGA deny), 404 (no such checkpoint), and
16
+ * 413 (the 4 MB cap) — and never a transient status. Transient failures
17
+ * reach the runner only as 5xx (Spring's error handler, ingress 502-504),
18
+ * 408/429 (infrastructure between runner and service), or as network-level
19
+ * fetch rejections. 401/403 stay terminal deliberately: retrying cannot fix
20
+ * an expired or rejected credential.
21
+ */
22
+
23
+ /**
24
+ * True for HTTP statuses that signal a transient condition worth retrying:
25
+ * 408 (request timeout), 429 (throttled), and all 5xx. Every other status is
26
+ * deterministic — the proxy's 4xx errors mean the same request will fail the
27
+ * same way forever.
28
+ */
29
+ export function isRetryableHttpStatus(status: number): boolean {
30
+ return status === 408 || status === 429 || status >= 500;
31
+ }
32
+
33
+ /**
34
+ * True for `fetch` rejections that signal a transient transport condition:
35
+ *
36
+ * - `TypeError` — undici's network-failure shape (`fetch failed`, with the
37
+ * ECONNREFUSED/ECONNRESET/DNS cause attached). A deterministic TypeError
38
+ * (e.g. a malformed URL) also matches, which is accepted: such a bug fails
39
+ * every attempt including the first test run, and the bounded budget caps
40
+ * the wasted retries at seconds.
41
+ * - `TimeoutError` — the rejection shape of `AbortSignal.timeout()`, i.e. a
42
+ * hung connection whose attempt we bounded ourselves.
43
+ *
44
+ * A manual abort (`AbortError`) is deliberately NOT retryable: the caller
45
+ * cancelled on purpose.
46
+ */
47
+ export function isRetryableFetchError(err: unknown): boolean {
48
+ if (err instanceof TypeError) return true;
49
+ return err instanceof Error && err.name === "TimeoutError";
50
+ }