@desplega.ai/agent-swarm 2.5.2 → 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 (236) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
  2. package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
  4. package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
  6. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
  16. package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
  17. package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
  22. package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
  24. package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  26. package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
  27. package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
  28. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  29. package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
  30. package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
  31. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  32. package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
  33. package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
  34. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  35. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  36. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  37. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  38. package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
  39. package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
  40. package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
  41. package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
  42. package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
  43. package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
  44. package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
  45. package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
  46. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  47. package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
  48. package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
  49. package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
  50. package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
  51. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  52. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  53. package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
  54. package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
  55. package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
  56. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  57. package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
  58. package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
  59. package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
  60. package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
  61. package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
  62. package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
  63. package/dist/cli.js +20 -20
  64. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
  65. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  66. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
  67. package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
  68. package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
  69. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
  70. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
  71. package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
  72. package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
  73. package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
  74. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  75. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
  76. package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
  77. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
  78. package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
  79. package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
  80. package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
  81. package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
  82. package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
  83. package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
  84. package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
  85. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  86. package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
  87. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  88. package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
  89. package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
  90. package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
  91. package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
  92. package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  94. package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
  95. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
  96. package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
  97. package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  98. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
  99. package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
  100. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
  101. package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
  102. package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
  103. package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
  104. package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
  105. package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
  106. package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
  107. package/openapi.json +3078 -2424
  108. package/package.json +7 -7
  109. package/plugin/commands/create-pr.md +2 -2
  110. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  111. package/src/be/approval-config-question.ts +389 -0
  112. package/src/be/automation-preflight.ts +37 -3
  113. package/src/be/db/approvals.ts +48 -0
  114. package/src/be/db/session-tree.ts +73 -15
  115. package/src/be/db/skill-search.ts +87 -0
  116. package/src/be/db/tasks/read.ts +29 -2
  117. package/src/be/db/tasks/write.ts +15 -5
  118. package/src/be/db/workflow-idempotency.ts +183 -0
  119. package/src/be/db/workflow-runs.ts +366 -8
  120. package/src/be/db.ts +92 -22
  121. package/src/be/migrations/239_skills_fts.sql +40 -0
  122. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  123. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  124. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  125. package/src/be/sealed-json.ts +1 -0
  126. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  127. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  128. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  129. package/src/be/sql-connections.ts +7 -1
  130. package/src/be/swarm-config-guard.ts +25 -0
  131. package/src/be/users.ts +9 -0
  132. package/src/commands/controller.ts +33 -0
  133. package/src/commands/runner.ts +6 -2
  134. package/src/extensions/apply-task-create.ts +57 -11
  135. package/src/extensions/catalog.generated.json +4 -4
  136. package/src/extensions/contract-types.generated.ts +1 -1
  137. package/src/extensions/contract.ts +25 -0
  138. package/src/extensions/dispatcher.ts +4 -0
  139. package/src/extensions/post-bridge.ts +8 -0
  140. package/src/hooks/context-bound-note.ts +196 -0
  141. package/src/hooks/hook.ts +12 -0
  142. package/src/http/approval-requests.ts +209 -38
  143. package/src/http/config.ts +70 -53
  144. package/src/http/idempotency-key.ts +117 -0
  145. package/src/http/sessions.ts +27 -2
  146. package/src/http/workflows.ts +129 -24
  147. package/src/integrations/chat-sdk/inbound.ts +5 -4
  148. package/src/prompts/context-bound-note.ts +48 -0
  149. package/src/prompts/session-templates.ts +25 -1
  150. package/src/providers/amp-adapter.ts +30 -8
  151. package/src/providers/claude-adapter.ts +29 -4
  152. package/src/providers/claude-managed-adapter.ts +26 -0
  153. package/src/providers/claude-sdk-session.ts +10 -0
  154. package/src/providers/claude-session-events.ts +13 -0
  155. package/src/providers/codex-adapter.ts +12 -0
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/opencode-adapter.ts +18 -0
  158. package/src/providers/pi-mono-adapter.ts +35 -0
  159. package/src/runtime-providers/docker.ts +6 -2
  160. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  161. package/src/tests/amp-adapter.test.ts +97 -0
  162. package/src/tests/approval-config-question.test.ts +702 -0
  163. package/src/tests/approval-requests.test.ts +10 -7
  164. package/src/tests/approval-sweeps.test.ts +12 -6
  165. package/src/tests/base-prompt.test.ts +1 -1
  166. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  167. package/src/tests/claude-hook-output.test.ts +79 -1
  168. package/src/tests/claude-managed-adapter.test.ts +275 -0
  169. package/src/tests/codex-adapter.test.ts +163 -0
  170. package/src/tests/context-bound-note.test.ts +269 -0
  171. package/src/tests/dsh-adapter.test.ts +1 -1
  172. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  173. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  174. package/src/tests/extensions-pre-task.test.ts +273 -0
  175. package/src/tests/fetch-resolved-env.test.ts +17 -0
  176. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  177. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  178. package/src/tests/fixtures/fake-amp.ts +22 -8
  179. package/src/tests/fixtures/held-template-render.ts +42 -0
  180. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  181. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  182. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  183. package/src/tests/openapi-route-headers.test.ts +2 -0
  184. package/src/tests/opencode-adapter.test.ts +134 -0
  185. package/src/tests/pi-mono-adapter.test.ts +105 -0
  186. package/src/tests/prompt-template-session.test.ts +4 -3
  187. package/src/tests/runner-push-delivery.test.ts +25 -0
  188. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  189. package/src/tests/runtime-providers-docker.test.ts +2 -0
  190. package/src/tests/script-connections.test.ts +9 -0
  191. package/src/tests/script-run-config-scope.test.ts +91 -3
  192. package/src/tests/session-logs.test.ts +70 -0
  193. package/src/tests/sessions-tree.test.ts +205 -1
  194. package/src/tests/skill-search-long-content.test.ts +193 -0
  195. package/src/tests/skill-search.test.ts +173 -22
  196. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  197. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  198. package/src/tests/workflow-idempotency.test.ts +915 -0
  199. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  200. package/src/tools/request-human-input.ts +27 -5
  201. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  202. package/src/types.ts +7 -0
  203. package/src/utils/context-window.ts +8 -6
  204. package/src/workflows/approval-cancel.ts +15 -13
  205. package/src/workflows/approval-notifications.ts +15 -4
  206. package/src/workflows/checkpoint.ts +72 -35
  207. package/src/workflows/engine.ts +262 -93
  208. package/src/workflows/executors/base.ts +6 -0
  209. package/src/workflows/executors/foreach.ts +2 -2
  210. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  211. package/src/workflows/executors/sub-workflow.ts +45 -16
  212. package/src/workflows/foreach-driver.ts +10 -3
  213. package/src/workflows/foreach-join.ts +29 -6
  214. package/src/workflows/recovery.ts +68 -1
  215. package/src/workflows/resume.ts +228 -77
  216. package/src/workflows/retry-poller.ts +74 -7
  217. package/src/workflows/task-step-routing.ts +29 -7
  218. package/templates/ai-toolbox.manifest.json +8 -8
  219. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  220. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  221. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  222. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  223. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  224. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  225. package/templates/skills/agent-browser/SKILL.md +4 -4
  226. package/templates/skills/agent-browser/content.md +4 -4
  227. package/templates/skills/code-quality/config.json +1 -1
  228. package/templates/skills/code-quality/content.md +2 -2
  229. package/templates/skills/delegate-work/content.md +29 -25
  230. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  231. package/templates/skills/qa/content.md +2 -2
  232. package/templates/skills/researching/content.md +2 -0
  233. package/templates/skills/turso-interaction/content.md +1 -1
  234. package/templates/skills/work-on-task/SKILL.md +1 -1
  235. package/templates/skills/work-on-task/content.md +1 -1
  236. package/templates/skills/workflow-iterate/content.md +8 -1
@@ -1,6 +1,8 @@
1
1
  import {
2
+ type ApprovalRequest,
2
3
  cancelPendingApprovalRequestsForRun,
3
4
  cancelTask,
5
+ getApprovalRequestById,
4
6
  getCompletedStepNodeIds,
5
7
  getDbClient,
6
8
  getPendingEventWaitNames,
@@ -11,19 +13,33 @@ import {
11
13
  getWorkflowRun,
12
14
  getWorkflowRunStep,
13
15
  getWorkflowRunStepsByRunId,
14
- listCancelledApprovalRequestsForRun,
15
16
  listCancelledApprovalRequestsForStep,
16
17
  resolveWaitState,
17
18
  updateWorkflowRun,
18
19
  updateWorkflowRunStep,
19
20
  } from "../be/db";
20
- import { createForeachAttemptSteps, getChildWorkflowRunId } from "../be/db/workflow-runs";
21
- import type {
22
- WaitStateRow,
23
- WorkflowDefinition,
24
- WorkflowNode,
25
- WorkflowRun,
26
- WorkflowRunStep,
21
+ import { listUndeliveredCancelledApprovalRequestIds } from "../be/db/approvals";
22
+ import {
23
+ cancelLiveWorkflowTreeRuns,
24
+ cancelOpenWorkflowTreeSteps,
25
+ createForeachAttemptSteps,
26
+ disarmWorkflowTreeRetries,
27
+ getChildWorkflowRunId,
28
+ getOpenWorkflowTreeTaskIds,
29
+ getRunLineageBlock,
30
+ getTaskIdsWithDependents,
31
+ getWorkflowRunStatus,
32
+ getWorkflowRunTreeIds,
33
+ workflowRunTreeHasOpenWork,
34
+ } from "../be/db/workflow-runs";
35
+ import {
36
+ type RetryPolicy,
37
+ RetryPolicySchema,
38
+ type WaitStateRow,
39
+ type WorkflowDefinition,
40
+ type WorkflowNode,
41
+ type WorkflowRun,
42
+ type WorkflowRunStep,
27
43
  } from "../types";
28
44
  import { scrubSecrets } from "../utils/secret-scrubber";
29
45
  import {
@@ -31,6 +47,7 @@ import {
31
47
  postApprovalCancellationUpdates,
32
48
  } from "./approval-notifications";
33
49
  import { shapeApprovalResolution } from "./approval-resolution";
50
+ import { checkpointStepFailure } from "./checkpoint";
34
51
  import { loadCompletedStepRouting } from "./completed-step-routing";
35
52
  import { FAILED_TASK_OUTPUT_PREFIX } from "./constants";
36
53
  import { getNextTargets } from "./definition";
@@ -45,6 +62,7 @@ import { startForeachDrive } from "./foreach-driver";
45
62
  import {
46
63
  FOREACH_TERMINAL_STEP_STATUSES,
47
64
  type ForeachChildOutcome,
65
+ isRetryPending,
48
66
  parseSyntheticNodeId,
49
67
  resolveForeachParent,
50
68
  settleForeachChild,
@@ -275,12 +293,16 @@ export async function finalizeOrWait(runId: string): Promise<void> {
275
293
  // move a step into `waiting` between the read and the write, and a run
276
294
  // finalized from a stale snapshot strands that branch.
277
295
  await getDbClient().transaction(async () => {
296
+ // A cancel (or another finalizer) that committed after the caller's claim
297
+ // wins: a terminal run is never re-parked or completed from here.
298
+ const status = await getWorkflowRunStatus(runId);
299
+ if (status !== "running" && status !== "waiting") return;
300
+ if (await getRunLineageBlock(runId)) return;
278
301
  const steps = await getWorkflowRunStepsByRunId(runId);
279
302
  // A branch still executing belongs to a live walk, which finalizes the
280
303
  // run itself; its finalizer only acts on a `running` run.
281
304
  if (hasRunningStep(steps)) {
282
- const run = await getWorkflowRun(runId);
283
- if (run?.status === "waiting") await updateWorkflowRun(runId, { status: "running" });
305
+ if (status === "waiting") await updateWorkflowRun(runId, { status: "running" });
284
306
  return;
285
307
  }
286
308
  // A step queued for the retry poller is still live, like a waiting one.
@@ -518,6 +540,10 @@ export async function retryFailedRun(runId: string, registry: ExecutorRegistry):
518
540
  const attempts = await getDbClient().transaction(async () => {
519
541
  const current = await getWorkflowRun(runId);
520
542
  if (!current || current.status !== "failed") return null;
543
+ // A failed run inside a cancelled tree keeps its failure; the cancel
544
+ // forbids starting it again.
545
+ const blocked = await getRunLineageBlock(runId);
546
+ if (blocked) throw new Error(`Retry not started, run left failed: ${blocked}`);
521
547
  // Fan-outs with failed or cancelled items get one new attempt row per such
522
548
  // item; completed items are never rerun. Selected from the rows as of this
523
549
  // transaction: an in-flight sibling of a fail-fast fan-out can record its
@@ -528,6 +554,13 @@ export async function retryFailedRun(runId: string, registry: ExecutorRegistry):
528
554
  ]);
529
555
  const capError = foreachRetryCapError(currentSteps.length, attempts);
530
556
  if (capError) throw new Error(`Retry not started, run left failed: ${capError}`);
557
+ // An item retry still queued when the run failed gets a new attempt row
558
+ // below. Retire the queued one, or the poller starts the old row beside it.
559
+ for (const step of currentSteps) {
560
+ if (isRetryPending(step) && resolveForeachParent(workflow.definition, step.nodeId)) {
561
+ await updateWorkflowRunStep(step.id, { nextRetryAt: null });
562
+ }
563
+ }
531
564
  // A foreach item retries as a new attempt row instead of a reset: a fresh
532
565
  // step id is a fresh invocation identity for every body type, and the
533
566
  // failed row stays as history.
@@ -636,82 +669,162 @@ function foreachRetryCapError(
636
669
  return `foreach retry would reach ${maxSteps} total steps (WORKFLOW_MAX_STEPS_PER_RUN): ${existingSteps} existing + ${added} new attempts leaves no room for the retry walk`;
637
670
  }
638
671
 
672
+ const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled", "skipped"]);
673
+
674
+ /** What one `cancelWorkflowRunRows` call covered. */
675
+ export interface WorkflowRunCancelResult {
676
+ /** True when this call moved the root run to `cancelled`. */
677
+ cancelled: boolean;
678
+ /**
679
+ * The root and every run below it, root first, for the caller's Slack
680
+ * posts. Also set for a repeat cancel that found nothing open, so posts a
681
+ * failed earlier cancel left undelivered are retried. Empty when the root
682
+ * is missing or completed, failed or skipped.
683
+ */
684
+ runIds: string[];
685
+ }
686
+
639
687
  /**
640
- * The DB writes of a run cancel, with no Slack post. Returns false when the
641
- * run is missing or already terminal. Called inside a caller's transaction,
642
- * the writes join it (as a SAVEPOINT) and commit or roll back with it.
688
+ * The DB writes of a run cancel, with no Slack post. Cancels the run and its
689
+ * whole child tree (every attempt's child, through terminal runs) in one
690
+ * transaction:
691
+ * - every non-terminal step, its task, and the run's other open tasks;
692
+ * - pending approvals and queued step retries;
693
+ * - every non-terminal run. A terminal descendant keeps its status, result
694
+ * and reason; only its leftover open work is cancelled.
695
+ *
696
+ * A missing root, or one that is completed, failed or skipped, cancels
697
+ * nothing. An already cancelled root is walked again, so a repeat cancel
698
+ * finishes descendants an earlier cancel missed. A tree with a cycle or past
699
+ * its bounds throws and nothing commits.
700
+ *
701
+ * Called inside a caller's transaction, the writes join it (as a SAVEPOINT)
702
+ * and commit or roll back with it. Task lifecycle events and worker nudges
703
+ * queue behind COMMIT (afterCommit) and drop on rollback.
643
704
  */
644
- export async function cancelWorkflowRunRows(runId: string, cancelReason: string): Promise<boolean> {
645
- const terminalStatuses = ["completed", "failed", "cancelled", "skipped"];
705
+ export async function cancelWorkflowRunRows(
706
+ runId: string,
707
+ cancelReason: string,
708
+ ): Promise<WorkflowRunCancelResult> {
646
709
  const now = new Date().toISOString();
647
710
 
648
- // Step snapshot, task cancels, and both status writes commit together so a
649
- // step created after the snapshot cannot survive the cancel and a
650
- // concurrent cancel cannot interleave. Task lifecycle events queue behind
651
- // this transaction's COMMIT (afterCommit) and drop on rollback.
652
- return await getDbClient().transaction(async () => {
653
- const current = await getWorkflowRun(runId);
654
- if (!current || terminalStatuses.includes(current.status)) return false;
655
-
656
- // Cancel non-terminal steps and their associated tasks
657
- const steps = await getWorkflowRunStepsByRunId(runId);
658
- for (const step of steps) {
659
- if (terminalStatuses.includes(step.status)) continue;
660
-
661
- // Cancel any task linked to this step
662
- const task = await getTaskByWorkflowRunStepId(step.id);
663
- if (task) {
664
- await cancelTask(task.id, cancelReason);
665
- }
711
+ // A repeat cancel of a tree with nothing left open is answered by reads
712
+ // alone, without the write lock. The guards keep a cancelled tree from
713
+ // gaining new work, so the answer still holds when this returns.
714
+ // Its run ids still go back, so the caller retries Slack posts an earlier
715
+ // cancel could not deliver.
716
+ const settledTree = await getDbClient().transaction(
717
+ async () => {
718
+ if ((await getWorkflowRunStatus(runId)) !== "cancelled") return null;
719
+ const ids = await getWorkflowRunTreeIds(runId);
720
+ return (await workflowRunTreeHasOpenWork(ids)) ? null : ids;
721
+ },
722
+ { readOnly: true },
723
+ );
724
+ if (settledTree) return { cancelled: false, runIds: settledTree };
666
725
 
667
- await updateWorkflowRunStep(step.id, {
668
- status: "cancelled",
669
- error: cancelReason,
670
- finishedAt: now,
671
- });
726
+ // The tree snapshot and every write commit together. A child run, task or
727
+ // step that commits first is in the snapshot; one that commits after sees
728
+ // the cancelled rows in its own guard (getRunLineageBlock and the step
729
+ // claims) and is refused.
730
+ return await getDbClient().transaction(async () => {
731
+ const rootStatus = await getWorkflowRunStatus(runId);
732
+ if (!rootStatus || (TERMINAL_RUN_STATUSES.has(rootStatus) && rootStatus !== "cancelled")) {
733
+ return { cancelled: false, runIds: [] };
672
734
  }
673
735
 
674
- await cancelPendingApprovalRequestsForRun(runId, cancelReason);
675
-
676
- // Mark the run itself as cancelled
677
- await updateWorkflowRun(runId, {
678
- status: "cancelled",
679
- error: cancelReason,
680
- finishedAt: now,
681
- });
682
- return true;
736
+ const runIds = await getWorkflowRunTreeIds(runId);
737
+ // Every step task carries the run link (indexed), and a retry detaches
738
+ // only the step link, so this covers current and earlier attempts' tasks.
739
+ // Dependents are looked up once for the whole tree, and only when needed.
740
+ const openTaskIds = await getOpenWorkflowTreeTaskIds(runIds);
741
+ const withDependents =
742
+ openTaskIds.length > 0 ? await getTaskIdsWithDependents() : new Set<string>();
743
+ for (const taskId of openTaskIds) {
744
+ await cancelTask(taskId, cancelReason, { cascadeDependents: withDependents.has(taskId) });
745
+ }
746
+ await cancelOpenWorkflowTreeSteps(runIds, cancelReason, now);
747
+ await disarmWorkflowTreeRetries(runIds, now);
748
+ for (const id of runIds) await cancelPendingApprovalRequestsForRun(id, cancelReason);
749
+ // Descendants' parent steps were just cancelled, so only the root's own
750
+ // parent (when the root is a child) needs the child-finished wake-up.
751
+ await cancelLiveWorkflowTreeRuns(runIds.slice(1), cancelReason, now);
752
+ if (rootStatus !== "cancelled") {
753
+ await updateWorkflowRun(runId, { status: "cancelled", error: cancelReason, finishedAt: now });
754
+ }
755
+ return { cancelled: rootStatus !== "cancelled", runIds };
683
756
  });
684
757
  }
685
758
 
759
+ // Cancels in flight in this process, by run id. The API process owns the
760
+ // database, so this bounds concurrent walks of one tree.
761
+ const inFlightCancels = new Map<string, Promise<void>>();
762
+
686
763
  /**
687
- * Cancel a workflow run and all its non-terminal steps.
688
- * Also cancels any in-progress tasks spawned by waiting/running steps.
764
+ * Cancel a workflow run and its child tree (see `cancelWorkflowRunRows`), then
765
+ * post the Slack updates for every approval the cancel closed. The posts run
766
+ * after COMMIT, and a failed post is logged without undoing the cancel.
767
+ * Concurrent calls for the same run share one cancel and its result.
689
768
  */
690
- export async function cancelWorkflowRun(runId: string, reason?: string): Promise<void> {
691
- const run = await getWorkflowRun(runId);
769
+ export function cancelWorkflowRun(runId: string, reason?: string): Promise<void> {
770
+ const inFlight = inFlightCancels.get(runId);
771
+ if (inFlight) return inFlight;
772
+ const cancel = cancelWorkflowRunOnce(runId, reason).finally(() => {
773
+ inFlightCancels.delete(runId);
774
+ });
775
+ inFlightCancels.set(runId, cancel);
776
+ return cancel;
777
+ }
778
+
779
+ async function cancelWorkflowRunOnce(runId: string, reason?: string): Promise<void> {
780
+ const run = await getWorkflowRun(runId, "display");
692
781
  if (!run) throw new Error("Workflow run not found");
693
782
 
694
- const terminalStatuses = ["completed", "failed", "cancelled", "skipped"];
695
- if (terminalStatuses.includes(run.status) && run.status !== "cancelled") {
783
+ if (TERMINAL_RUN_STATUSES.has(run.status) && run.status !== "cancelled") {
696
784
  throw new Error(`Cannot cancel run in '${run.status}' state`);
697
785
  }
698
786
 
699
787
  const cancelReason = reason ?? "Cancelled by user";
700
- const applied = await cancelWorkflowRunRows(runId, cancelReason);
788
+ const { cancelled, runIds } = await cancelWorkflowRunRows(runId, cancelReason);
701
789
 
702
- // The migration trigger also catches direct step cancellation. Re-read after
703
- // commit so its rows are included alongside the run-wide sweep.
704
- const cancelledApprovals = await listCancelledApprovalRequestsForRun(runId);
705
- const latestRun = await getWorkflowRun(runId);
706
- if (!applied && latestRun?.status !== "cancelled") {
790
+ const latestRun = await getWorkflowRun(runId, "display");
791
+ if (!cancelled && latestRun?.status !== "cancelled") {
707
792
  throw new Error(`Cannot cancel run in '${latestRun?.status ?? "missing"}' state`);
708
793
  }
709
- await postApprovalCancellationUpdates(
710
- cancelledApprovals,
711
- applied ? cancelReason : (latestRun?.error ?? cancelReason),
794
+ await postTreeApprovalCancellations(
795
+ runIds,
796
+ cancelled ? cancelReason : (latestRun?.error ?? cancelReason),
712
797
  );
713
798
  }
714
799
 
800
+ /**
801
+ * Post the cancellation update of every cancelled approval in the given runs
802
+ * whose Slack post is not delivered yet. Re-read after commit: the migration
803
+ * trigger also cancels the approvals of a directly cancelled step. Each post
804
+ * is claimed, and a failed post releases its claim, so the next cancel of the
805
+ * tree retries it and a delivered post is never repeated.
806
+ */
807
+ export async function postTreeApprovalCancellations(
808
+ runIds: string[],
809
+ reason: string,
810
+ options: { exceptRequestId?: string } = {},
811
+ ): Promise<void> {
812
+ try {
813
+ const approvals: ApprovalRequest[] = [];
814
+ for (const id of await listUndeliveredCancelledApprovalRequestIds(runIds)) {
815
+ if (id === options.exceptRequestId) continue;
816
+ const approval = await getApprovalRequestById(id);
817
+ if (approval) approvals.push(approval);
818
+ }
819
+ if (approvals.length > 0) await postApprovalCancellationUpdates(approvals, reason);
820
+ } catch (err) {
821
+ console.error(
822
+ "[workflows] approval cancellation updates not posted:",
823
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
824
+ );
825
+ }
826
+ }
827
+
715
828
  /** Cancel one HITL step and close any approval request it gates. */
716
829
  export async function cancelWorkflowRunStep(
717
830
  stepId: string,
@@ -963,10 +1076,11 @@ async function resumeClaimedWait(
963
1076
  }
964
1077
 
965
1078
  /**
966
- * Resume a waiting `sub-workflow` step once its child run is terminal: a
967
- * completed child completes the step, any other end (or a deleted child) fails
968
- * the step and its run. Returns true when this call claimed the step. Safe to call repeatedly:
969
- * the live event and the recovery sweep both route here.
1079
+ * Resume a waiting `sub-workflow` step once its current attempt's child run is
1080
+ * terminal: a completed child completes the step, any other end (or a deleted
1081
+ * child) fails the step and its run. Returns true when this call claimed the
1082
+ * step. Safe to call repeatedly: the live event and the recovery sweep both
1083
+ * route here, and only the call that claims the step walks its successors.
970
1084
  */
971
1085
  export async function resumeFromChildRun(
972
1086
  parentStepId: string,
@@ -974,13 +1088,6 @@ export async function resumeFromChildRun(
974
1088
  ): Promise<boolean> {
975
1089
  const step = await getWorkflowRunStep(parentStepId);
976
1090
  if (!step || step.status !== "waiting" || step.nodeType !== "sub-workflow") return false;
977
- const childId = await getChildWorkflowRunId(parentStepId);
978
- const child = childId ? await getWorkflowRun(childId) : null;
979
- // The child row exists before the step parks, so a missing one was deleted.
980
- const outcome = child
981
- ? childRunOutcome(child)
982
- : { error: `Child workflow run of step ${parentStepId} was deleted` };
983
- if (!outcome) return false;
984
1091
 
985
1092
  let claimed = false;
986
1093
  await failClosedOnUnreadableReplay(step.runId, step.id, async () => {
@@ -990,10 +1097,34 @@ export async function resumeFromChildRun(
990
1097
  if (!workflow) return;
991
1098
 
992
1099
  const ctx = (run.context ?? {}) as Record<string, unknown>;
993
- // A foreach item's failure settles the item: under `onNodeFailure:
994
- // "continue"` it can close the join, so it routes like a success.
995
- const routing =
996
- "error" in outcome
1100
+ // The step's attempt, that attempt's child and the claim share one
1101
+ // transaction: a retry that re-parks the step under a newer attempt in
1102
+ // between must not be settled by the previous attempt's child.
1103
+ const routing = await getDbClient().transaction(async () => {
1104
+ const current = await getWorkflowRunStep(step.id);
1105
+ if (!current || current.status !== "waiting") return null;
1106
+ // Inside a cancelled tree a child's end settles nothing and queues no retry.
1107
+ if (await getRunLineageBlock(run.id)) return null;
1108
+ const childId = await getChildWorkflowRunId(step.id, current.retryCount);
1109
+ const child = childId ? await getWorkflowRun(childId) : null;
1110
+ // The child row exists before the step parks, so a missing one was deleted.
1111
+ const outcome = child
1112
+ ? childRunOutcome(child)
1113
+ : { error: `Child workflow run of step ${parentStepId} was deleted`, retryable: false };
1114
+ if (!outcome) return null;
1115
+ // A retryable end (a failed child) is this attempt failing: the node's
1116
+ // retry policy applies exactly as on the executor path. The poller
1117
+ // re-executes the step under the next attempt, which starts a new child.
1118
+ if ("error" in outcome && outcome.retryable) {
1119
+ const policy = childStepRetryPolicy(workflow.definition, step.nodeId);
1120
+ if (policy && current.retryCount < policy.maxRetries) {
1121
+ await checkpointStepFailure(run.id, step.id, outcome.error, current.retryCount, policy);
1122
+ return CHILD_RETRY_SCHEDULED;
1123
+ }
1124
+ }
1125
+ // A foreach item's failure settles the item: under `onNodeFailure:
1126
+ // "continue"` it can close the join, so it routes like a success.
1127
+ return "error" in outcome
997
1128
  ? await failStepAndRunIfWaiting(
998
1129
  workflow.definition,
999
1130
  step.id,
@@ -1012,6 +1143,12 @@ export async function resumeFromChildRun(
1012
1143
  ctx,
1013
1144
  registry,
1014
1145
  );
1146
+ });
1147
+ if (!routing) return;
1148
+ if (routing === CHILD_RETRY_SCHEDULED) {
1149
+ claimed = true;
1150
+ return;
1151
+ }
1015
1152
  if (!routing.claimed || routing.runFailed) {
1016
1153
  claimed = routing.claimed;
1017
1154
  return;
@@ -1036,6 +1173,20 @@ export async function resumeFromChildRun(
1036
1173
  return claimed;
1037
1174
  }
1038
1175
 
1176
+ const CHILD_RETRY_SCHEDULED = {
1177
+ claimed: true,
1178
+ runFailed: false,
1179
+ successors: [] as WorkflowNode[],
1180
+ } as const;
1181
+
1182
+ /** The `node.retry` of a top-level `sub-workflow` node; foreach items retry through their parent. */
1183
+ function childStepRetryPolicy(def: WorkflowDefinition, nodeId: string): RetryPolicy | null {
1184
+ const node = def.nodes.find((candidate) => candidate.id === nodeId);
1185
+ if (!node?.retry) return null;
1186
+ const parsed = RetryPolicySchema.safeParse(node.retry);
1187
+ return parsed.success ? parsed.data : null;
1188
+ }
1189
+
1039
1190
  // ─── 64KB firedPayload cap ──────────────────────────────────────────────────
1040
1191
 
1041
1192
  const FIRED_PAYLOAD_BYTE_CAP = 64 * 1024; // 64KB
@@ -6,6 +6,7 @@ import {
6
6
  getWorkflowRunStepsByRunId,
7
7
  updateWorkflowRun,
8
8
  } from "../be/db";
9
+ import { disarmWorkflowTreeRetries, getRunLineageBlock } from "../be/db/workflow-runs";
9
10
  import type { RetryPolicy } from "../types";
10
11
  import { checkpointStep, checkpointStepFailure, checkpointStepWaiting } from "./checkpoint";
11
12
  import { getSuccessors, resolveValidationPort } from "./definition";
@@ -17,7 +18,8 @@ import {
17
18
  } from "./engine";
18
19
  import type { AsyncExecutorResult } from "./executors/base";
19
20
  import type { ExecutorRegistry } from "./executors/registry";
20
- import { FOREACH_ITEMS_WAIT_FOR, parkAndDriveForeach } from "./foreach-driver";
21
+ import { FOREACH_ITEMS_WAIT_FOR, parkAndDriveForeach, runForeachDrive } from "./foreach-driver";
22
+ import { resolveForeachParent } from "./foreach-join";
21
23
  import { rebuildNodeExecutionInput } from "./step-input";
22
24
  import { runStepValidation } from "./validation";
23
25
 
@@ -45,6 +47,19 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
45
47
  const workflow = await getWorkflow(run.workflowId);
46
48
  if (!workflow) continue;
47
49
 
50
+ // A foreach child goes back to its fan-out's queue: the driver
51
+ // rebuilds the per-item config and starts it under the concurrency
52
+ // cap, and the item settles into the join like any other.
53
+ const foreachParent = resolveForeachParent(workflow.definition, step.nodeId);
54
+ if (foreachParent) {
55
+ if (!(await claimRetry(run.id, step.id, { requeueForeachChild: true }))) continue;
56
+ console.log(
57
+ `[workflows] Retrying foreach item ${step.nodeId} (attempt ${step.retryCount}) for run ${step.runId}`,
58
+ );
59
+ await runForeachDrive(run.id, foreachParent.id, registry);
60
+ continue;
61
+ }
62
+
48
63
  // Find the node definition for this step
49
64
  const node = workflow.definition.nodes.find((n) => n.id === step.nodeId);
50
65
  if (!node) continue;
@@ -61,6 +76,9 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
61
76
  // the run were read outside it: a cancel committing in between must
62
77
  // win, or the step re-executes inside a cancelled run.
63
78
  if (!(await claimRetry(run.id, step.id))) continue;
79
+ // Every result write below is fenced on this claim, so a cancel or
80
+ // another settle that commits while the executor runs wins.
81
+ const fence = { retryCount: step.retryCount };
64
82
 
65
83
  // Rebuild `run.id`, upstream outputs and the node's declared-inputs
66
84
  // context the way the original execution saw them. Without the alias
@@ -78,6 +96,8 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
78
96
  step.id,
79
97
  scriptBodyInterpolationError(node.id, scriptBodyUnresolved),
80
98
  step.retryCount,
99
+ undefined,
100
+ { fence },
81
101
  );
82
102
  continue;
83
103
  }
@@ -104,14 +124,17 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
104
124
  if (result.status === "failed") {
105
125
  // Re-failed — use the EXISTING retryCount from the step.
106
126
  // checkpointStepFailure handles marking run as failed if no retries left,
107
- // or setting nextRetryAt for the next poll cycle.
108
- const retryPolicy = node.retry || executor.retryPolicy;
127
+ // or setting nextRetryAt for the next poll cycle. A non-retryable
128
+ // failure skips the policy, as in executeStep.
129
+ const retryPolicy =
130
+ result.retryable === false ? undefined : node.retry || executor.retryPolicy;
109
131
  await checkpointStepFailure(
110
132
  run.id,
111
133
  step.id,
112
134
  result.error || "Retry failed",
113
135
  step.retryCount,
114
136
  retryPolicy,
137
+ { fence, output: result.output },
115
138
  );
116
139
  } else if (
117
140
  "async" in result &&
@@ -150,6 +173,12 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
150
173
  // find the parent already advanced.
151
174
  const waiting = await checkpointStepWaiting(run.id, step.id, ctx);
152
175
  if (!waiting) continue;
176
+ // As in executeStep: a child run that finished before the step
177
+ // parked had its event dropped.
178
+ if ((result as AsyncExecutorResult).waitFor === "workflow.child.finished") {
179
+ const { resumeFromChildRun } = await import("./resume");
180
+ await resumeFromChildRun(step.id, registry);
181
+ }
153
182
  } else {
154
183
  // Success! Re-run validation if configured before checkpointing.
155
184
  let validationPassed: boolean | undefined;
@@ -168,6 +197,8 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
168
197
  step.id,
169
198
  "Validation failed (mustPass)",
170
199
  step.retryCount,
200
+ undefined,
201
+ { fence },
171
202
  );
172
203
  continue;
173
204
  }
@@ -186,6 +217,7 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
186
217
  "Validation failed, retrying",
187
218
  step.retryCount,
188
219
  retryPolicy,
220
+ { fence },
189
221
  );
190
222
  continue;
191
223
  }
@@ -197,7 +229,9 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
197
229
  result.nextPort = resolveValidationPort(node.next, result.nextPort, validationPassed);
198
230
 
199
231
  // Validation passed (or no validation) — checkpoint and continue
200
- await checkpointStep(run.id, step.id, step.nodeId, result, ctx);
232
+ if (!(await checkpointStep(run.id, step.id, step.nodeId, result, ctx, { fence }))) {
233
+ continue;
234
+ }
201
235
 
202
236
  const port = result.nextPort || "default";
203
237
  const successors = getSuccessors(workflow.definition, step.nodeId, port);
@@ -219,7 +253,9 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
219
253
  if (await failRunOnUnreadableReplay(run.id, err, step.id)) continue;
220
254
  const errorMsg = err instanceof Error ? err.message : String(err);
221
255
  const retryPolicy = node.retry || executor.retryPolicy;
222
- await checkpointStepFailure(run.id, step.id, errorMsg, step.retryCount, retryPolicy);
256
+ await checkpointStepFailure(run.id, step.id, errorMsg, step.retryCount, retryPolicy, {
257
+ fence,
258
+ });
223
259
  }
224
260
  } catch (err) {
225
261
  if (await failRunOnUnreadableReplay(step.runId, err, step.id)) continue;
@@ -243,14 +279,44 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
243
279
  /**
244
280
  * Flip a retry-pending step to `running` only while it is still retry-pending
245
281
  * and its run is live, and revive a run failed by this step.
282
+ *
283
+ * `requeueForeachChild` flips a foreach child to `pending` instead, for the
284
+ * driver to claim, and clears the cut's error: the join reads `error` on a
285
+ * completed child as a failed item, so a stale one would count the retry's
286
+ * success as a failure.
287
+ *
288
+ * A queued child never revives a failed run: its retry does not settle, so
289
+ * something else failed the run. The queued retry is retired instead, leaving
290
+ * the item `failed` with the cut's error for a run retry to give a new attempt.
291
+ *
292
+ * A run inside a cancelled tree is never revived: its queued retry is dropped.
246
293
  */
247
- async function claimRetry(runId: string, stepId: string): Promise<boolean> {
294
+ async function claimRetry(
295
+ runId: string,
296
+ stepId: string,
297
+ options: { requeueForeachChild?: boolean } = {},
298
+ ): Promise<boolean> {
248
299
  return getDbClient().transaction(async () => {
249
300
  const run = await getWorkflowRun(runId);
250
301
  if (!run || !RETRYABLE_RUN_STATUSES.has(run.status)) return false;
302
+ if (await getRunLineageBlock(runId)) {
303
+ await disarmWorkflowTreeRetries([runId], new Date().toISOString());
304
+ return false;
305
+ }
306
+ if (options.requeueForeachChild && run.status === "failed") {
307
+ await getDbClient().run(
308
+ `UPDATE workflow_run_steps SET nextRetryAt = NULL, finishedAt = ?
309
+ WHERE id = ? AND runId = ? AND status = 'failed' AND nextRetryAt IS NOT NULL`,
310
+ [new Date().toISOString(), stepId, runId],
311
+ );
312
+ return false;
313
+ }
314
+ const set = options.requeueForeachChild
315
+ ? "status = 'pending', nextRetryAt = NULL, error = NULL"
316
+ : "status = 'running', nextRetryAt = NULL";
251
317
  const claimed = await getDbClient().get<{ id: string }>(
252
318
  `UPDATE workflow_run_steps
253
- SET status = 'running', nextRetryAt = NULL
319
+ SET ${set}
254
320
  WHERE id = ? AND runId = ? AND status = 'failed' AND nextRetryAt IS NOT NULL
255
321
  RETURNING id`,
256
322
  [stepId, runId],
@@ -272,6 +338,7 @@ async function completeRunIfSettled(runId: string, ctx: Record<string, unknown>)
272
338
  await getDbClient().transaction(async () => {
273
339
  const run = await getWorkflowRun(runId);
274
340
  if (!run || (run.status !== "running" && run.status !== "waiting")) return;
341
+ if (await getRunLineageBlock(runId)) return;
275
342
  const steps = await getWorkflowRunStepsByRunId(runId);
276
343
  const live = steps.some(
277
344
  (s) =>