@desplega.ai/agent-swarm 2.5.3 → 2.5.4

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 (197) hide show
  1. package/dist/{acp-adapter-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
  2. package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
  3. package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
  4. package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
  6. package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
  11. package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
  13. package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
  16. package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
  17. package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
  22. package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
  24. package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
  26. package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
  27. package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
  28. package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
  29. package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
  30. package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
  31. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  32. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  33. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  34. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  35. package/dist/{cli-s53vp709.js → cli-eftzyh9y.js} +1 -1
  36. package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
  37. package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
  38. package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
  39. package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
  40. package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
  41. package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
  42. package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
  43. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  44. package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
  45. package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
  46. package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
  47. package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
  48. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  49. package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
  50. package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
  51. package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
  52. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  53. package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
  54. package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
  55. package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
  56. package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
  57. package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
  58. package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
  59. package/dist/cli.js +18 -18
  60. package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
  61. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  62. package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
  63. package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
  64. package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
  65. package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
  66. package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
  67. package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
  68. package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
  69. package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
  70. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  71. package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
  72. package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
  73. package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
  74. package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
  75. package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
  76. package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
  77. package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
  78. package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
  79. package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
  80. package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
  81. package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
  82. package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
  83. package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
  84. package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
  85. package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
  86. package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
  87. package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  88. package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
  89. package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
  90. package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
  91. package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  92. package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
  93. package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
  94. package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
  95. package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
  96. package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
  97. package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
  98. package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
  99. package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
  100. package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
  101. package/openapi.json +68 -3
  102. package/package.json +5 -5
  103. package/plugin/commands/create-pr.md +2 -2
  104. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  105. package/src/be/db/approvals.ts +48 -0
  106. package/src/be/db/tasks/write.ts +15 -5
  107. package/src/be/db/workflow-idempotency.ts +183 -0
  108. package/src/be/db/workflow-runs.ts +318 -7
  109. package/src/be/db.ts +67 -17
  110. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  111. package/src/be/sealed-json.ts +1 -0
  112. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  113. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  114. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  115. package/src/be/sql-connections.ts +7 -1
  116. package/src/be/swarm-config-guard.ts +2 -0
  117. package/src/be/users.ts +9 -0
  118. package/src/commands/controller.ts +33 -0
  119. package/src/commands/runner.ts +6 -2
  120. package/src/extensions/apply-task-create.ts +57 -11
  121. package/src/extensions/contract-types.generated.ts +1 -1
  122. package/src/extensions/contract.ts +25 -0
  123. package/src/extensions/dispatcher.ts +4 -0
  124. package/src/extensions/post-bridge.ts +8 -0
  125. package/src/hooks/context-bound-note.ts +196 -0
  126. package/src/hooks/hook.ts +12 -0
  127. package/src/http/config.ts +48 -16
  128. package/src/http/idempotency-key.ts +117 -0
  129. package/src/http/workflows.ts +129 -24
  130. package/src/prompts/context-bound-note.ts +48 -0
  131. package/src/prompts/session-templates.ts +25 -1
  132. package/src/providers/amp-adapter.ts +30 -8
  133. package/src/providers/claude-adapter.ts +29 -4
  134. package/src/providers/claude-managed-adapter.ts +26 -0
  135. package/src/providers/claude-sdk-session.ts +10 -0
  136. package/src/providers/claude-session-events.ts +13 -0
  137. package/src/providers/codex-adapter.ts +12 -0
  138. package/src/providers/opencode-adapter.ts +18 -0
  139. package/src/providers/pi-mono-adapter.ts +35 -0
  140. package/src/runtime-providers/docker.ts +6 -2
  141. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  142. package/src/tests/amp-adapter.test.ts +97 -0
  143. package/src/tests/approval-requests.test.ts +10 -7
  144. package/src/tests/approval-sweeps.test.ts +12 -6
  145. package/src/tests/base-prompt.test.ts +1 -1
  146. package/src/tests/claude-hook-output.test.ts +79 -1
  147. package/src/tests/claude-managed-adapter.test.ts +275 -0
  148. package/src/tests/codex-adapter.test.ts +163 -0
  149. package/src/tests/context-bound-note.test.ts +269 -0
  150. package/src/tests/extensions-pre-task.test.ts +273 -0
  151. package/src/tests/fetch-resolved-env.test.ts +17 -0
  152. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  153. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  154. package/src/tests/fixtures/fake-amp.ts +22 -8
  155. package/src/tests/fixtures/held-template-render.ts +42 -0
  156. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  157. package/src/tests/openapi-route-headers.test.ts +2 -0
  158. package/src/tests/opencode-adapter.test.ts +134 -0
  159. package/src/tests/pi-mono-adapter.test.ts +105 -0
  160. package/src/tests/prompt-template-session.test.ts +4 -3
  161. package/src/tests/runner-push-delivery.test.ts +25 -0
  162. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  163. package/src/tests/runtime-providers-docker.test.ts +2 -0
  164. package/src/tests/script-connections.test.ts +3 -0
  165. package/src/tests/script-run-config-scope.test.ts +91 -3
  166. package/src/tests/session-logs.test.ts +70 -0
  167. package/src/tests/sessions-tree.test.ts +37 -0
  168. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  169. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  170. package/src/tests/workflow-idempotency.test.ts +915 -0
  171. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  172. package/src/utils/context-window.ts +8 -6
  173. package/src/workflows/approval-cancel.ts +15 -13
  174. package/src/workflows/approval-notifications.ts +5 -1
  175. package/src/workflows/checkpoint.ts +6 -3
  176. package/src/workflows/engine.ts +30 -8
  177. package/src/workflows/executors/foreach.ts +2 -2
  178. package/src/workflows/foreach-driver.ts +10 -3
  179. package/src/workflows/foreach-join.ts +29 -6
  180. package/src/workflows/recovery.ts +6 -1
  181. package/src/workflows/resume.ts +168 -56
  182. package/src/workflows/retry-poller.ts +49 -3
  183. package/src/workflows/task-step-routing.ts +29 -7
  184. package/templates/ai-toolbox.manifest.json +8 -8
  185. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  186. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  187. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  188. package/templates/skills/agent-browser/SKILL.md +4 -4
  189. package/templates/skills/agent-browser/content.md +4 -4
  190. package/templates/skills/code-quality/config.json +1 -1
  191. package/templates/skills/code-quality/content.md +2 -2
  192. package/templates/skills/delegate-work/content.md +29 -25
  193. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  194. package/templates/skills/qa/content.md +2 -2
  195. package/templates/skills/researching/content.md +2 -0
  196. package/templates/skills/work-on-task/SKILL.md +1 -1
  197. package/templates/skills/work-on-task/content.md +1 -1
@@ -0,0 +1,488 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import * as db from "../be/db";
4
+ import {
5
+ closeDb,
6
+ completeTask,
7
+ createAgent,
8
+ createWorkflow,
9
+ failTask,
10
+ getTaskByWorkflowRunStepId,
11
+ getWorkflowRun,
12
+ getWorkflowRunStep,
13
+ getWorkflowRunStepsByRunId,
14
+ initDb,
15
+ updateWorkflowRunStep,
16
+ } from "../be/db";
17
+ import {
18
+ type AgentTask,
19
+ SUPERSEDED_WORKFLOW_TASK_REASON,
20
+ type WorkflowDefinition,
21
+ type WorkflowRun,
22
+ type WorkflowRunStep,
23
+ } from "../types";
24
+ import { startWorkflowExecution } from "../workflows/engine";
25
+ import { workflowEventBus } from "../workflows/event-bus";
26
+ import type { ExecutorDependencies } from "../workflows/executors/base";
27
+ import type { ForeachOutput } from "../workflows/executors/foreach";
28
+ import { createExecutorRegistry } from "../workflows/executors/registry";
29
+ import { drainForeachDrives } from "../workflows/foreach-driver";
30
+ import {
31
+ cancelWorkflowRun,
32
+ retryFailedRun,
33
+ setupWorkflowResumeListener,
34
+ } from "../workflows/resume";
35
+ import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
36
+
37
+ const TEST_DB_PATH = "./test-workflow-foreach-supersede-retry.sqlite";
38
+
39
+ const deps: ExecutorDependencies = {
40
+ db,
41
+ eventBus: workflowEventBus,
42
+ interpolate: (t: string) => t,
43
+ };
44
+ const registry = createExecutorRegistry(deps);
45
+
46
+ let teardown: (() => void) | undefined;
47
+ let agentId: string;
48
+
49
+ beforeAll(async () => {
50
+ await removeDbFiles();
51
+ initDb(TEST_DB_PATH);
52
+ agentId = (await createAgent({ name: "Fanned", status: "idle" })).id;
53
+ teardown = setupWorkflowResumeListener(workflowEventBus, registry);
54
+ });
55
+
56
+ afterEach(async () => {
57
+ stopRetryPoller();
58
+ await drainForeachDrives();
59
+ });
60
+
61
+ afterAll(async () => {
62
+ teardown?.();
63
+ stopRetryPoller();
64
+ await drainForeachDrives();
65
+ closeDb();
66
+ await removeDbFiles();
67
+ });
68
+
69
+ describe("supersede retry for a foreach agent-task child", () => {
70
+ test("a cut item is re-dispatched once and the join counts the retry's result", async () => {
71
+ const runId = await startRun(2, {});
72
+ const cut = await boundTask(runId, "fan#i0");
73
+
74
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
75
+ const queued = await waitForQueuedRetry(runId, "fan#i0");
76
+ expect(queued.retryCount).toBe(1);
77
+ expect(queued.error).toBe(SUPERSEDED_WORKFLOW_TASK_REASON);
78
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
79
+
80
+ // The sibling finishing does not close the join over the queued item.
81
+ await completeTask((await boundTask(runId, "fan#i1")).id, "i1 done");
82
+ await waitForStep(runId, "fan#i1", "completed");
83
+ expect((await stepByNodeId(runId, "fan"))!.status).toBe("waiting");
84
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
85
+
86
+ const retried = await redrive(runId, "fan#i0", cut);
87
+ expect((await stepByNodeId(runId, "fan#i0"))!.id).toBe(queued.id);
88
+
89
+ await completeTask(retried.id, "i0 done");
90
+ const run = await waitForRun(runId, (r) => r.status === "completed");
91
+ const aggregate = (run.context as Record<string, unknown>).fan as ForeachOutput;
92
+ expect(aggregate.okCount).toBe(2);
93
+ expect(aggregate.failedCount).toBe(0);
94
+ expect(aggregate.results.find((r) => r.itemKey === "i0")?.status).toBe("completed");
95
+ expect((await stepByNodeId(runId, "after"))?.status).toBe("completed");
96
+ expect((await children(runId)).filter((s) => s.nodeId === "fan#i0")).toHaveLength(1);
97
+ });
98
+
99
+ test("a second cut exhausts the retry and the item settles failed into the join", async () => {
100
+ const runId = await startRun(2, { failurePolicy: "collect-outcomes" });
101
+ const cut = await boundTask(runId, "fan#i0");
102
+
103
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
104
+ await waitForQueuedRetry(runId, "fan#i0");
105
+ const retried = await redrive(runId, "fan#i0", cut);
106
+
107
+ // Under collect-outcomes a failed item settles as completed-with-error,
108
+ // which the aggregate counts as failed.
109
+ await failTask(retried.id, SUPERSEDED_WORKFLOW_TASK_REASON);
110
+ const settled = await waitForStep(runId, "fan#i0", "completed");
111
+ expect(settled.error).toContain(SUPERSEDED_WORKFLOW_TASK_REASON);
112
+ expect(settled.retryCount).toBe(1);
113
+ expect(settled.nextRetryAt).toBeFalsy();
114
+ expect((await getTaskByWorkflowRunStepId(settled.id))!.id).toBe(retried.id);
115
+
116
+ await completeTask((await boundTask(runId, "fan#i1")).id, "i1 done");
117
+ const run = await waitForRun(runId, (r) => r.status === "completed");
118
+ const aggregate = (run.context as Record<string, unknown>).fan as ForeachOutput;
119
+ expect(aggregate.okCount).toBe(1);
120
+ expect(aggregate.failedCount).toBe(1);
121
+ expect(aggregate.results.find((r) => r.itemKey === "i0")?.status).toBe("failed");
122
+ });
123
+
124
+ test("a non-supersede item failure settles failed exactly as before", async () => {
125
+ const runId = await startRun(2, {});
126
+ const task = await boundTask(runId, "fan#i0");
127
+
128
+ await failTask(task.id, "agent crashed");
129
+ const run = await waitForRun(runId, (r) => r.status === "failed");
130
+ expect(run.error).toContain("agent crashed");
131
+ const failed = (await stepByNodeId(runId, "fan#i0"))!;
132
+ expect(failed.status).toBe("failed");
133
+ expect(failed.retryCount).toBe(0);
134
+ expect(failed.nextRetryAt).toBeFalsy();
135
+ expect((await getTaskByWorkflowRunStepId(failed.id))!.id).toBe(task.id);
136
+ });
137
+
138
+ test("parent retry {maxRetries:0} opts the items out", async () => {
139
+ const runId = await startRun(2, {}, { maxRetries: 0 });
140
+ const task = await boundTask(runId, "fan#i0");
141
+
142
+ await failTask(task.id, SUPERSEDED_WORKFLOW_TASK_REASON);
143
+ const run = await waitForRun(runId, (r) => r.status === "failed");
144
+ expect(run.error).toContain(SUPERSEDED_WORKFLOW_TASK_REASON);
145
+ const failed = (await stepByNodeId(runId, "fan#i0"))!;
146
+ expect(failed.retryCount).toBe(0);
147
+ expect(failed.nextRetryAt).toBeFalsy();
148
+ expect((await getTaskByWorkflowRunStepId(failed.id))!.id).toBe(task.id);
149
+ });
150
+
151
+ test("a queued item keeps its concurrency slot until the retry starts it", async () => {
152
+ const runId = await startRun(2, { concurrency: 1 });
153
+ const cut = await boundTask(runId, "fan#i0");
154
+
155
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
156
+ await waitForQueuedRetry(runId, "fan#i0");
157
+ await drainForeachDrives();
158
+ expect((await stepByNodeId(runId, "fan#i1"))!.status).toBe("pending");
159
+
160
+ const retried = await redrive(runId, "fan#i0", cut);
161
+ expect((await stepByNodeId(runId, "fan#i1"))!.status).toBe("pending");
162
+
163
+ await completeTask(retried.id, "i0 done");
164
+ const next = await waitFor(
165
+ async () => getTaskByWorkflowRunStepId((await stepByNodeId(runId, "fan#i1"))!.id),
166
+ "i1 started",
167
+ );
168
+ await completeTask(next.id, "i1 done");
169
+ const run = await waitForRun(runId, (r) => r.status === "completed");
170
+ expect(((run.context as Record<string, unknown>).fan as ForeachOutput).okCount).toBe(2);
171
+ });
172
+
173
+ test("fail-fast cancels a queued retry so the poller does not revive the run", async () => {
174
+ const runId = await startRun(2, {});
175
+ const cut = await boundTask(runId, "fan#i0");
176
+
177
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
178
+ await waitForQueuedRetry(runId, "fan#i0");
179
+ await failTask((await boundTask(runId, "fan#i1")).id, "agent crashed");
180
+ await waitForRun(runId, (r) => r.status === "failed");
181
+
182
+ const cancelled = (await stepByNodeId(runId, "fan#i0"))!;
183
+ expect(cancelled.status).toBe("cancelled");
184
+ expect(cancelled.nextRetryAt).toBeFalsy();
185
+
186
+ startRetryPoller(registry, 10);
187
+ await Bun.sleep(50);
188
+ expect((await getWorkflowRun(runId))!.status).toBe("failed");
189
+ expect(await getTaskByWorkflowRunStepId(cancelled.id)).toBeNull();
190
+ });
191
+ });
192
+
193
+ describe("a queued item retry never revives a failed run", () => {
194
+ test("another branch failing the run retires the queued retry", async () => {
195
+ const runId = await startParallelRun();
196
+ const cut = await boundTask(runId, "fan#i0");
197
+
198
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
199
+ const queued = await waitForQueuedRetry(runId, "fan#i0");
200
+ await failTask((await boundTask(runId, "other")).id, "parallel branch crashed");
201
+ await waitForRun(runId, (r) => r.status === "failed");
202
+
203
+ startRetryPoller(registry, 10);
204
+ const retired = await waitFor(async () => {
205
+ const step = await getWorkflowRunStep(queued.id);
206
+ return step?.nextRetryAt == null ? step : null;
207
+ }, "queued retry retired");
208
+ stopRetryPoller();
209
+ await drainForeachDrives();
210
+
211
+ expect(retired.status).toBe("failed");
212
+ expect(retired.error).toBe(SUPERSEDED_WORKFLOW_TASK_REASON);
213
+ expect((await getWorkflowRun(runId))!.status).toBe("failed");
214
+ expect(await getTaskByWorkflowRunStepId(queued.id)).toBeNull();
215
+
216
+ await expectRunRetryStartsItemOnce(runId, queued.id, cut);
217
+ });
218
+
219
+ test("a run retry supersedes an item retry still queued when the run failed", async () => {
220
+ const runId = await startParallelRun();
221
+ const cut = await boundTask(runId, "fan#i0");
222
+
223
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
224
+ const queued = await waitForQueuedRetry(runId, "fan#i0");
225
+ await failTask((await boundTask(runId, "other")).id, "parallel branch crashed");
226
+ await waitForRun(runId, (r) => r.status === "failed");
227
+
228
+ // No poller tick between the failure and the run retry: the item's
229
+ // queued retry is still due when the run goes live again.
230
+ await expectRunRetryStartsItemOnce(runId, queued.id, cut);
231
+ });
232
+
233
+ test("a cancelled run keeps its queued item retry inert", async () => {
234
+ const runId = await startRun(2, {});
235
+ const cut = await boundTask(runId, "fan#i0");
236
+
237
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
238
+ const queued = await waitForQueuedRetry(runId, "fan#i0");
239
+ await cancelWorkflowRun(runId);
240
+
241
+ startRetryPoller(registry, 10);
242
+ await Bun.sleep(50);
243
+ stopRetryPoller();
244
+ await drainForeachDrives();
245
+ expect((await getWorkflowRun(runId))!.status).toBe("cancelled");
246
+ expect((await getWorkflowRunStep(queued.id))!.status).toBe("failed");
247
+ expect(await getTaskByWorkflowRunStepId(queued.id)).toBeNull();
248
+ });
249
+
250
+ test("a sibling failing the run while the cut handler awaits records the cut", async () => {
251
+ const runId = await startRun(2, {});
252
+ const cut = await boundTask(runId, "fan#i0");
253
+ const cutStep = (await stepByNodeId(runId, "fan#i0"))!;
254
+ const sibling = await boundTask(runId, "fan#i1");
255
+ const { workflowId } = (await getWorkflowRun(runId))!;
256
+
257
+ // Hold the cut's task.failed handler at its getWorkflow read, after it
258
+ // saw a live run, until the sibling's fail-fast has failed the run.
259
+ let release!: () => void;
260
+ let reached!: () => void;
261
+ const gate = new Promise<void>((resolve) => {
262
+ release = resolve;
263
+ });
264
+ const paused = new Promise<void>((resolve) => {
265
+ reached = resolve;
266
+ });
267
+ const realGetWorkflow = db.getWorkflow;
268
+ let held = false;
269
+ const spy = spyOn(db, "getWorkflow").mockImplementation(async (id) => {
270
+ const workflow = await realGetWorkflow(id);
271
+ if (id === workflowId && !held) {
272
+ held = true;
273
+ reached();
274
+ await gate;
275
+ }
276
+ return workflow;
277
+ });
278
+ try {
279
+ await failTask(cut.id, SUPERSEDED_WORKFLOW_TASK_REASON);
280
+ await paused;
281
+ await failTask(sibling.id, "sibling crashed");
282
+ await waitForRun(runId, (r) => r.status === "failed");
283
+ release();
284
+
285
+ const settled = await waitFor(async () => {
286
+ const step = await stepByNodeId(runId, "fan#i0");
287
+ return step?.status === "failed" && step.nextRetryAt == null ? step : null;
288
+ }, "cut recorded as the item's outcome");
289
+ expect(settled.error).toBe(SUPERSEDED_WORKFLOW_TASK_REASON);
290
+ expect(settled.retryCount).toBe(0);
291
+ } finally {
292
+ release();
293
+ spy.mockRestore();
294
+ }
295
+
296
+ startRetryPoller(registry, 10);
297
+ await Bun.sleep(50);
298
+ stopRetryPoller();
299
+ await drainForeachDrives();
300
+ const run = (await getWorkflowRun(runId))!;
301
+ expect(run.status).toBe("failed");
302
+ expect(run.error).toContain("sibling crashed");
303
+ expect(await children(runId)).toHaveLength(2);
304
+
305
+ await expectRunRetryStartsItemOnce(runId, cutStep.id, cut);
306
+ });
307
+ });
308
+
309
+ /** `fan`, an agent-task foreach over the trigger items, then `after`. */
310
+ function fanOut(
311
+ extra: Record<string, unknown>,
312
+ retry?: Partial<NonNullable<WorkflowDefinition["nodes"][number]["retry"]>>,
313
+ ): WorkflowDefinition {
314
+ return {
315
+ nodes: [
316
+ {
317
+ id: "fan",
318
+ type: "foreach",
319
+ config: {
320
+ over: "{{trigger.items}}",
321
+ itemKey: "id",
322
+ ...extra,
323
+ body: { type: "agent-task", config: { agentId, template: "Item {{item.id}}" } },
324
+ },
325
+ ...(retry
326
+ ? {
327
+ retry: { strategy: "static", baseDelayMs: 0, maxDelayMs: 0, maxRetries: 1, ...retry },
328
+ }
329
+ : {}),
330
+ next: "after",
331
+ },
332
+ { id: "after", type: "notify", config: { channel: "swarm", template: "done" } },
333
+ ],
334
+ };
335
+ }
336
+
337
+ async function startRun(
338
+ count: number,
339
+ extra: Record<string, unknown>,
340
+ retry?: Partial<NonNullable<WorkflowDefinition["nodes"][number]["retry"]>>,
341
+ ): Promise<string> {
342
+ const workflow = await createWorkflow({
343
+ name: `foreach-supersede-${crypto.randomUUID()}`,
344
+ definition: fanOut(extra, retry),
345
+ });
346
+ const items = Array.from({ length: count }, (_, index) => ({ id: `i${index}` }));
347
+ const runId = await startWorkflowExecution(workflow, { items }, registry);
348
+ await drainForeachDrives();
349
+ return runId;
350
+ }
351
+
352
+ /** `fanOut` over two items, next to an independent `other` agent-task branch. */
353
+ async function startParallelRun(): Promise<string> {
354
+ const definition = fanOut({});
355
+ definition.nodes.push({
356
+ id: "other",
357
+ type: "agent-task",
358
+ config: { agentId, template: "other" },
359
+ });
360
+ const workflow = await createWorkflow({
361
+ name: `foreach-supersede-parallel-${crypto.randomUUID()}`,
362
+ definition,
363
+ });
364
+ const runId = await startWorkflowExecution(
365
+ workflow,
366
+ { items: [{ id: "i0" }, { id: "i1" }] },
367
+ registry,
368
+ );
369
+ await drainForeachDrives();
370
+ return runId;
371
+ }
372
+
373
+ /**
374
+ * Retry the failed run and give the poller time to act: `fan#i0` gets exactly
375
+ * one new attempt with one fresh task, and the earlier row stays failed.
376
+ */
377
+ async function expectRunRetryStartsItemOnce(
378
+ runId: string,
379
+ earlierStepId: string,
380
+ cut: AgentTask,
381
+ ): Promise<void> {
382
+ await retryFailedRun(runId, registry);
383
+ await drainForeachDrives();
384
+ startRetryPoller(registry, 10);
385
+ await Bun.sleep(50);
386
+ stopRetryPoller();
387
+ await drainForeachDrives();
388
+
389
+ const attempts = (await children(runId)).filter((step) => step.nodeId === "fan#i0");
390
+ expect(attempts).toHaveLength(2);
391
+ expect(attempts[0]!.id).toBe(earlierStepId);
392
+ expect(attempts[0]!.status).toBe("failed");
393
+ expect(attempts[0]!.nextRetryAt).toBeFalsy();
394
+ const fresh = await boundTask(runId, "fan#i0");
395
+ expect(fresh.id).not.toBe(cut.id);
396
+ const bound = await db
397
+ .getDbClient()
398
+ .query<{ id: string }>("SELECT id FROM agent_tasks WHERE workflowRunStepId = ?", [
399
+ attempts[1]!.id,
400
+ ]);
401
+ expect(bound).toHaveLength(1);
402
+ }
403
+
404
+ async function children(runId: string): Promise<WorkflowRunStep[]> {
405
+ return (await getWorkflowRunStepsByRunId(runId)).filter((step) => step.nodeId.startsWith("fan#"));
406
+ }
407
+
408
+ async function stepByNodeId(runId: string, nodeId: string): Promise<WorkflowRunStep | undefined> {
409
+ const steps = await getWorkflowRunStepsByRunId(runId);
410
+ return steps.filter((step) => step.nodeId === nodeId).at(-1);
411
+ }
412
+
413
+ async function boundTask(runId: string, nodeId: string): Promise<AgentTask> {
414
+ return waitFor(async () => {
415
+ const step = await stepByNodeId(runId, nodeId);
416
+ return step ? getTaskByWorkflowRunStepId(step.id) : null;
417
+ }, `task bound to ${nodeId}`);
418
+ }
419
+
420
+ /** Wait for the item to be queued for the poller, then make it due now. */
421
+ async function waitForQueuedRetry(runId: string, nodeId: string): Promise<WorkflowRunStep> {
422
+ const queued = await waitFor(async () => {
423
+ const step = await stepByNodeId(runId, nodeId);
424
+ return step?.status === "failed" && step.nextRetryAt != null ? step : null;
425
+ }, `${nodeId} queued for retry`);
426
+ await updateWorkflowRunStep(queued.id, { nextRetryAt: new Date(Date.now() - 1).toISOString() });
427
+ return queued;
428
+ }
429
+
430
+ /** Run the poller until a fresh task is bound to the item and it waits on it. */
431
+ async function redrive(runId: string, nodeId: string, previous: AgentTask): Promise<AgentTask> {
432
+ startRetryPoller(registry, 10);
433
+ try {
434
+ const task = await waitFor(async () => {
435
+ const step = await stepByNodeId(runId, nodeId);
436
+ if (step?.status !== "waiting") return null;
437
+ const bound = await getTaskByWorkflowRunStepId(step.id);
438
+ return bound && bound.id !== previous.id ? bound : null;
439
+ }, `${nodeId} re-dispatched`);
440
+ expect(task.status).not.toBe("failed");
441
+ return task;
442
+ } finally {
443
+ stopRetryPoller();
444
+ await drainForeachDrives();
445
+ }
446
+ }
447
+
448
+ async function waitForStep(
449
+ runId: string,
450
+ nodeId: string,
451
+ status: string,
452
+ ): Promise<WorkflowRunStep> {
453
+ return waitFor(async () => {
454
+ const step = await stepByNodeId(runId, nodeId);
455
+ return step?.status === status ? step : null;
456
+ }, `${nodeId} ${status}`);
457
+ }
458
+
459
+ async function waitForRun(
460
+ runId: string,
461
+ done: (run: WorkflowRun) => boolean,
462
+ ): Promise<WorkflowRun> {
463
+ return waitFor(async () => {
464
+ await drainForeachDrives();
465
+ const run = await getWorkflowRun(runId);
466
+ return run && done(run) ? run : null;
467
+ }, `run ${runId}`);
468
+ }
469
+
470
+ async function waitFor<T>(
471
+ probe: () => Promise<T | null | undefined | false>,
472
+ label: string,
473
+ timeoutMs = 3000,
474
+ ): Promise<T> {
475
+ const deadline = Date.now() + timeoutMs;
476
+ while (Date.now() < deadline) {
477
+ const value = await probe();
478
+ if (value) return value;
479
+ await Bun.sleep(10);
480
+ }
481
+ throw new Error(`Timed out waiting for ${label}`);
482
+ }
483
+
484
+ async function removeDbFiles(): Promise<void> {
485
+ for (const suffix of ["", "-wal", "-shm"]) {
486
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
487
+ }
488
+ }