@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
@@ -10,7 +10,7 @@ export const registerCancelWorkflowRunTool = (server: McpServer) => {
10
10
  title: "Cancel Workflow Run",
11
11
  annotations: { destructiveHint: true },
12
12
  description:
13
- "Cancel a running or waiting workflow run. Cancels all non-terminal steps and their associated tasks.",
13
+ "Cancel a running or waiting workflow run and every child run below it. Cancels their non-terminal steps, tasks, pending approvals and queued retries in one transaction; finished child runs keep their status. Repeating the cancel on a cancelled run finishes any child runs still open.",
14
14
  inputSchema: z.object({
15
15
  runId: z.string().uuid().describe("Workflow run ID to cancel"),
16
16
  reason: z.string().optional().describe("Optional reason for cancellation"),
@@ -67,7 +67,12 @@ function catalogContext(provider: string, model: string): number | undefined {
67
67
  * Unknown models get a conservative 200k so percent math stays finite.
68
68
  */
69
69
  export function getContextWindowSize(model: string): number {
70
- if (!model) return DEFAULT_CONTEXT_WINDOW;
70
+ return getKnownContextWindowSize(model) ?? DEFAULT_CONTEXT_WINDOW;
71
+ }
72
+
73
+ /** {@link getContextWindowSize} without the fallback: undefined when the catalog has no window. */
74
+ export function getKnownContextWindowSize(model: string | undefined): number | undefined {
75
+ if (!model) return undefined;
71
76
  const slash = model.indexOf("/");
72
77
  if (slash > 0) {
73
78
  const scoped = catalogContext(model.slice(0, slash), model.slice(slash + 1));
@@ -83,11 +88,8 @@ export function getContextWindowSize(model: string): number {
83
88
  if (dated) return dated;
84
89
  }
85
90
  const shortname = resolveClaudeShortname(model);
86
- if (shortname) {
87
- const resolved = catalogContext("anthropic", shortname);
88
- if (resolved) return resolved;
89
- }
90
- return DEFAULT_CONTEXT_WINDOW;
91
+ if (shortname) return catalogContext("anthropic", shortname);
92
+ return undefined;
91
93
  }
92
94
 
93
95
  /**
@@ -4,16 +4,15 @@ import {
4
4
  getApprovalRequestById,
5
5
  getDbClient,
6
6
  getTaskById,
7
- getWorkflowRun,
8
- listCancelledApprovalRequestsForRun,
9
7
  resolveApprovalRequest,
10
8
  } from "../be/db";
9
+ import { getWorkflowRunStatus } from "../be/db/workflow-runs";
11
10
  import { can, type RbacPrincipal } from "../rbac";
12
11
  import {
13
12
  createApprovalFollowUpTask,
14
13
  postApprovalCancellationUpdates,
15
14
  } from "./approval-notifications";
16
- import { cancelWorkflowRunRows } from "./resume";
15
+ import { cancelWorkflowRunRows, postTreeApprovalCancellations } from "./resume";
17
16
 
18
17
  export type CancelApprovalRequestResult =
19
18
  | { ok: true; request: ApprovalRequest; alreadyCancelled: boolean; runCancelled: boolean }
@@ -126,25 +125,28 @@ export async function cancelApprovalRequestAndRun(
126
125
  if (!request) return null;
127
126
 
128
127
  // The request is cancelled first so it keeps this reason; the run cancel
129
- // then skips it because it is no longer pending.
128
+ // then skips it because it is no longer pending. The run's child tree is
129
+ // cancelled with it.
130
130
  let runCancelled = false;
131
+ let treeRunIds: string[] = [];
131
132
  const runId = request.workflowRunId;
132
133
  if (runId) {
133
- const run = await getWorkflowRun(runId);
134
- if (run && LIVE_RUN_STATUSES.has(run.status)) {
135
- runCancelled = await cancelWorkflowRunRows(runId, data.reason);
134
+ const status = await getWorkflowRunStatus(runId);
135
+ if (status && LIVE_RUN_STATUSES.has(status)) {
136
+ const result = await cancelWorkflowRunRows(runId, data.reason);
137
+ runCancelled = result.cancelled;
138
+ treeRunIds = result.runIds;
136
139
  }
137
140
  }
138
141
 
139
142
  const slackReason = data.slackReason ?? data.reason;
140
143
  client.afterCommit(async () => {
141
144
  await postApprovalCancellationUpdates([request], slackReason);
142
- if (runCancelled && runId) {
143
- // Other requests the run cancel closed get the run's reason.
144
- const others = (await listCancelledApprovalRequestsForRun(runId)).filter(
145
- (row) => row.id !== request.id,
146
- );
147
- await postApprovalCancellationUpdates(others, data.reason);
145
+ if (runCancelled) {
146
+ // Other requests the tree cancel closed get the run's reason.
147
+ await postTreeApprovalCancellations(treeRunIds, data.reason, {
148
+ exceptRequestId: request.id,
149
+ });
148
150
  }
149
151
  });
150
152
  return { request, runCancelled };
@@ -9,6 +9,7 @@ import {
9
9
  releaseApprovalCancellationNotificationClaim,
10
10
  updateApprovalRequestNotifications,
11
11
  } from "../be/db";
12
+ import { approvalCancellationNotificationKey } from "../be/db/approvals";
12
13
  import { resolveTemplate } from "../prompts/resolver";
13
14
  import { getSlackApp } from "../slack/app";
14
15
  import { resolveSlackConversationId } from "../slack/dm";
@@ -263,7 +264,10 @@ export async function postApprovalCancellationUpdates(
263
264
 
264
265
  for (const notification of slackThreads) {
265
266
  if (notification.channel !== "slack" || !notification.messageTs) continue;
266
- const notificationKey = `${notification.target}:${notification.messageTs}`;
267
+ const notificationKey = approvalCancellationNotificationKey(
268
+ notification.target,
269
+ notification.messageTs,
270
+ );
267
271
  const claim = await claimApprovalCancellationNotification(approval.id, notificationKey);
268
272
  if (!claim) continue;
269
273
  try {
@@ -1,12 +1,14 @@
1
1
  import { getDbClient, getWorkflowRun, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
2
+ import { getRunLineageBlock } from "../be/db/workflow-runs";
2
3
  import type { RetryPolicy } from "../types";
3
4
 
4
5
  /**
5
6
  * The execution a checkpoint belongs to: the step attempt (`retryCount`) that
6
7
  * claimed the row. A fenced checkpoint writes only while that attempt still
7
8
  * owns the row: the step is `running` with that `retryCount` in a `running` or
8
- * `waiting` run. A cancel, a newer attempt or another settle that committed
9
- * meanwhile wins, and the stale execution writes nothing.
9
+ * `waiting` run with no cancelled ancestor. A cancel, a newer attempt or
10
+ * another settle that committed meanwhile wins, and the stale execution
11
+ * writes nothing.
10
12
  */
11
13
  export type StepFence = { retryCount: number };
12
14
 
@@ -19,7 +21,7 @@ async function holdsFence(runId: string, stepId: string, fence: StepFence): Prom
19
21
  AND r.status IN ('running', 'waiting')`,
20
22
  [stepId, runId, fence.retryCount],
21
23
  );
22
- return row != null;
24
+ return row != null && (await getRunLineageBlock(runId)) == null;
23
25
  }
24
26
 
25
27
  /**
@@ -129,6 +131,7 @@ export async function checkpointStepWaiting(
129
131
  options: { keepRunStatus?: boolean } = {},
130
132
  ): Promise<boolean> {
131
133
  return getDbClient().transaction(async () => {
134
+ if (await getRunLineageBlock(runId)) return false;
132
135
  const step = await getDbClient().get<{ id: string }>(
133
136
  `UPDATE workflow_run_steps
134
137
  SET status = 'waiting'
@@ -21,7 +21,11 @@ import {
21
21
  updateWorkflowRun,
22
22
  updateWorkflowRunStep,
23
23
  } from "../be/db";
24
- import { getChildWorkflowRunId, isParentStepAttemptCurrent } from "../be/db/workflow-runs";
24
+ import {
25
+ getChildWorkflowRunId,
26
+ getRunLineageBlock,
27
+ isParentStepAttemptCurrent,
28
+ } from "../be/db/workflow-runs";
25
29
  import { WorkflowReplayStateError } from "../be/workflow-replay";
26
30
  import { telemetry } from "../telemetry";
27
31
  import type { Workflow, WorkflowDefinition, WorkflowNode, WorkflowRunStep } from "../types";
@@ -67,6 +71,13 @@ export interface WorkflowExecutionOptions {
67
71
  parentStepId?: string;
68
72
  /** The parent step's `retryCount`; each attempt gets its own child run. */
69
73
  parentStepAttempt?: number;
74
+ /**
75
+ * Runs inside the transaction that commits a top-level run (or reuses the
76
+ * day's setup-failure run), after the run row is written. A throw rolls the
77
+ * run back and nothing else happens: no walk, no alert, no telemetry. The
78
+ * HTTP trigger records its `Idempotency-Key` here.
79
+ */
80
+ accept?: (run: { runId: string; skipped: boolean }) => Promise<void>;
70
81
  }
71
82
 
72
83
  /**
@@ -111,12 +122,16 @@ export async function startWorkflowExecution(
111
122
  await getAutomationSetupStates(),
112
123
  );
113
124
  if (preflight.state === "needs_setup" && !options.parentStepId) {
114
- const { runId, recorded } = await recordWorkflowPreflightFailure({
115
- workflowId: workflow.id,
116
- triggerType: options.triggerType ?? "manual",
117
- triggerData,
118
- failureReason: preflight.failureReason!,
119
- createdBy: options.requestedByUserId,
125
+ const { runId, recorded } = await getDbClient().transaction(async () => {
126
+ const failure = await recordWorkflowPreflightFailure({
127
+ workflowId: workflow.id,
128
+ triggerType: options.triggerType ?? "manual",
129
+ triggerData,
130
+ failureReason: preflight.failureReason!,
131
+ createdBy: options.requestedByUserId,
132
+ });
133
+ await options.accept?.({ runId: failure.runId, skipped: false });
134
+ return failure;
120
135
  });
121
136
  if (recorded) await notifyAutomationPreflightFailure(preflight);
122
137
  return runId;
@@ -276,7 +291,8 @@ async function buildInitialRunState(
276
291
  * executing and returns the child that attempt already started instead of a
277
292
  * second one; the unique (parentStepId, parentStepAttempt) index backs this
278
293
  * across processes. A setup failure claims the day's alert in the same
279
- * transaction, so concurrent failures alert exactly once.
294
+ * transaction, so concurrent failures alert exactly once. `options.accept`
295
+ * runs last in it.
280
296
  */
281
297
  async function createRunWithInitialState(
282
298
  workflow: Workflow,
@@ -314,6 +330,7 @@ async function createRunWithInitialState(
314
330
  initial.status === "failed" && initial.preflight
315
331
  ? await isFirstWorkflowPreflightFailureToday(workflow.id, runId)
316
332
  : undefined;
333
+ await options.accept?.({ runId, skipped: initial.status === "skipped" });
317
334
  return { runId, created: true, preflightAlert };
318
335
  });
319
336
  }
@@ -606,6 +623,8 @@ async function walkGraphOwned(
606
623
  await getDbClient().transaction(async () => {
607
624
  const run = await getWorkflowRun(runId);
608
625
  if (!run || run.status !== "running") return;
626
+ // Inside a cancelled tree the run's outcome is the cancel, not its steps.
627
+ if (await getRunLineageBlock(runId)) return;
609
628
 
610
629
  const finalSteps = await getWorkflowRunStepsByRunId(runId);
611
630
  // Another walker is still executing a branch; it finalizes the run.
@@ -793,6 +812,9 @@ async function executeStep(
793
812
  if (!run || (run.status !== "running" && run.status !== "waiting")) {
794
813
  return { halted: true as const };
795
814
  }
815
+ // A live run under a cancelled ancestor (or with a broken parent link)
816
+ // starts no step: the cancel covers its whole tree.
817
+ if (await getRunLineageBlock(runId)) return { halted: true as const };
796
818
 
797
819
  // Another walker already started or finished this node for the same
798
820
  // predecessor completions (two branch completions both walking the join,
@@ -6,7 +6,7 @@ import { getSuccessors } from "../definition";
6
6
  import { foreachBodyTypeError } from "../foreach-body";
7
7
  import {
8
8
  buildForeachAggregate,
9
- FOREACH_TERMINAL_STEP_STATUSES,
9
+ isForeachChildSettled,
10
10
  parseSyntheticNodeId,
11
11
  } from "../foreach-join";
12
12
  import { getMaxWorkflowStepsPerRun } from "../limits";
@@ -144,7 +144,7 @@ export class ForeachExecutor extends BaseExecutor<
144
144
  const childSteps = (await this.deps.db.getWorkflowRunStepsByRunId(meta.runId)).filter(
145
145
  (step) => parseSyntheticNodeId(step.nodeId)?.parentNodeId === meta.nodeId,
146
146
  );
147
- if (childSteps.every((step) => FOREACH_TERMINAL_STEP_STATUSES.has(step.status))) {
147
+ if (childSteps.every(isForeachChildSettled)) {
148
148
  return { status: "success", output: buildForeachAggregate(childSteps) };
149
149
  }
150
150
 
@@ -11,6 +11,7 @@ import {
11
11
  updateWorkflowRun,
12
12
  updateWorkflowRunStep,
13
13
  } from "../be/db";
14
+ import { getRunLineageBlock, getWorkflowRunStatus } from "../be/db/workflow-runs";
14
15
  import type {
15
16
  Workflow,
16
17
  WorkflowDefinition,
@@ -41,6 +42,7 @@ import {
41
42
  cancelQueuedForeachChildren,
42
43
  FOREACH_TERMINAL_STEP_STATUSES,
43
44
  type ForeachChildOutcome,
45
+ isRetryPending,
44
46
  latestForeachChildren,
45
47
  settleForeachChild,
46
48
  } from "./foreach-join";
@@ -352,8 +354,8 @@ async function parkRunIfRunning(runId: string): Promise<void> {
352
354
 
353
355
  /**
354
356
  * Flip `pending` children to `running`, in item order, while fewer than the
355
- * fan-out's `concurrency` cap are in flight (`running` or `waiting`, latest
356
- * attempt rows only), and collect unowned `running` ones. Ownership is registered before commit, so no other
357
+ * fan-out's `concurrency` cap are in flight (`running`, `waiting` or queued
358
+ * for a retry, latest attempt rows only), and collect unowned `running` ones. Ownership is registered before commit, so no other
357
359
  * drive can see a claimed row without also seeing it owned.
358
360
  */
359
361
  async function claimChildren(
@@ -367,6 +369,10 @@ async function claimChildren(
367
369
  // so concurrent drives (a settlement's drive and the recovery sweep)
368
370
  // serialize here and never exceed the cap together.
369
371
  await getDbClient().transaction(async () => {
372
+ // A cancel of the run (or of an ancestor) that committed first wins.
373
+ const status = await getWorkflowRunStatus(runId);
374
+ if (status !== "running" && status !== "waiting") return;
375
+ if (await getRunLineageBlock(runId)) return;
370
376
  const children = latestForeachChildren(
371
377
  def,
372
378
  parentNode.id,
@@ -379,7 +385,8 @@ async function claimChildren(
379
385
  }
380
386
  }
381
387
  const inFlight = children.filter(
382
- (child) => child.status === "running" || child.status === "waiting",
388
+ (child) =>
389
+ child.status === "running" || child.status === "waiting" || isRetryPending(child),
383
390
  ).length;
384
391
  let free = foreachConcurrencyCap(parentNode) - inFlight;
385
392
  for (const child of children) {
@@ -7,6 +7,7 @@ import {
7
7
  updateWorkflowRun,
8
8
  updateWorkflowRunStep,
9
9
  } from "../be/db";
10
+ import { getRunLineageBlock } from "../be/db/workflow-runs";
10
11
  import type {
11
12
  WorkflowDefinition,
12
13
  WorkflowNode,
@@ -27,6 +28,22 @@ export const FOREACH_TERMINAL_STEP_STATUSES = new Set([
27
28
  "skipped",
28
29
  ]);
29
30
 
31
+ /**
32
+ * A failed step queued for the retry poller (`nextRetryAt` set). A foreach
33
+ * child in this state is still in flight: it is not settled and holds its
34
+ * concurrency slot until the poller re-queues it.
35
+ */
36
+ export function isRetryPending(step: Pick<WorkflowRunStep, "status" | "nextRetryAt">): boolean {
37
+ return step.status === "failed" && step.nextRetryAt != null;
38
+ }
39
+
40
+ /** A foreach child whose outcome is final: terminal and not queued for a retry. */
41
+ export function isForeachChildSettled(
42
+ step: Pick<WorkflowRunStep, "status" | "nextRetryAt">,
43
+ ): boolean {
44
+ return FOREACH_TERMINAL_STEP_STATUSES.has(step.status) && !isRetryPending(step);
45
+ }
46
+
30
47
  export interface SyntheticNodeId {
31
48
  parentNodeId: string;
32
49
  itemKey: string;
@@ -74,8 +91,9 @@ export async function joinForeach(
74
91
 
75
92
  const children = latestForeachChildren(def, parent.id, await getWorkflowRunStepsByRunId(runId));
76
93
 
77
- // `pending` (queued, not started) is non-terminal like running and waiting.
78
- if (children.some((step) => !FOREACH_TERMINAL_STEP_STATUSES.has(step.status))) {
94
+ // `pending` (queued, not started) and a child queued for a retry are
95
+ // non-terminal like running and waiting.
96
+ if (children.some((step) => !isForeachChildSettled(step))) {
79
97
  return { joined: false, parentNodeId: parent.id, successors: [] };
80
98
  }
81
99
 
@@ -244,6 +262,8 @@ export async function settleForeachChild(
244
262
  });
245
263
 
246
264
  if (run.status !== "running" && run.status !== "waiting") return unclaimed;
265
+ // The outcome stays on the item as history; a cancelled tree routes nothing.
266
+ if (await getRunLineageBlock(runId)) return unclaimed;
247
267
 
248
268
  if (outcome.status !== "completed" && effectiveFailurePolicy(parent, def) === "fail-fast") {
249
269
  const reason = options.runError ?? outcome.error ?? `foreach item ${current.nodeId} failed`;
@@ -270,8 +290,9 @@ export async function settleForeachChild(
270
290
 
271
291
  /**
272
292
  * Fail-fast: cancel every queued (`pending`) child of the fan-out so none of
273
- * them starts. In-flight children are left to finish and record their outcome.
274
- * A run retry gives each cancelled item a new attempt.
293
+ * them starts, and every child queued for a retry so the retry poller does not
294
+ * revive the failed run. In-flight children are left to finish and record their
295
+ * outcome. A run retry gives each cancelled item a new attempt.
275
296
  */
276
297
  export async function cancelQueuedForeachChildren(
277
298
  def: WorkflowDefinition,
@@ -294,9 +315,11 @@ export async function cancelQueuedForeachChildren(
294
315
  await getWorkflowRunStepsByRunId(runId),
295
316
  );
296
317
  for (const child of children) {
297
- if (child.status !== "pending") continue;
318
+ if (child.status !== "pending" && !isRetryPending(child)) continue;
298
319
  const row = await getDbClient().get<{ id: string }>(
299
- "UPDATE workflow_run_steps SET status = 'cancelled', error = ?, finishedAt = ? WHERE id = ? AND status = 'pending' RETURNING id",
320
+ `UPDATE workflow_run_steps SET status = 'cancelled', error = ?, finishedAt = ?, nextRetryAt = NULL
321
+ WHERE id = ? AND (status = 'pending' OR (status = 'failed' AND nextRetryAt IS NOT NULL))
322
+ RETURNING id`,
300
323
  [error, finishedAt, child.id],
301
324
  );
302
325
  if (row) cancelled++;
@@ -12,7 +12,11 @@ import {
12
12
  resolveApprovalRequest,
13
13
  updateWorkflowRun,
14
14
  } from "../be/db";
15
- import { getRunningSubWorkflowSteps, getSettledChildRunParentSteps } from "../be/db/workflow-runs";
15
+ import {
16
+ getRunLineageBlock,
17
+ getRunningSubWorkflowSteps,
18
+ getSettledChildRunParentSteps,
19
+ } from "../be/db/workflow-runs";
16
20
  import type { WorkflowRunStep } from "../types";
17
21
  import { shapeApprovalResolution } from "./approval-resolution";
18
22
  import { loadCompletedStepRouting } from "./completed-step-routing";
@@ -227,6 +231,7 @@ async function completeIfSettled(runId: string, ctx: Record<string, unknown>): P
227
231
  return getDbClient().transaction(async () => {
228
232
  const run = await getWorkflowRun(runId);
229
233
  if (run?.status !== "running") return false;
234
+ if (await getRunLineageBlock(runId)) return false;
230
235
  const steps = await getWorkflowRunStepsByRunId(runId);
231
236
  if (retryPendingNodeIds(steps).size > 0) return false;
232
237
  const latest = new Map<string, WorkflowRunStep>();