@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,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getChildWorkflowRunId, getWorkflowRunLineage } from "../../be/db/workflow-runs";
3
3
  import type { ExecutorMeta, WorkflowRun } from "../../types";
4
- import { startWorkflowExecution } from "../engine";
4
+ import { ParentStepNotCurrentError, startWorkflowExecution } from "../engine";
5
5
  import type { ExecutorDependencies, ExecutorResult } from "./base";
6
6
  import { BaseExecutor } from "./base";
7
7
  import type { ExecutorRegistry } from "./registry";
@@ -35,15 +35,21 @@ const NON_NODE_CONTEXT_KEYS = new Set(["trigger", "input", "swarm", "workflow"])
35
35
  /**
36
36
  * The parent step's outcome for a child run: its result once the child
37
37
  * completed, an error once it failed, was cancelled or was skipped, and null
38
- * while it is still running or waiting.
38
+ * while it is still running or waiting. Only a failed child is retryable: a
39
+ * cancel or a skip is a decision, and a node retry must not override it. The
40
+ * synchronous (executor) and the parked (`resumeFromChildRun`) paths both
41
+ * apply this, so the same child end gets the same policy whenever it lands.
39
42
  */
40
43
  export function childRunOutcome(
41
44
  child: WorkflowRun,
42
- ): { output: SubWorkflowOutput } | { error: string } | null {
45
+ ): { output: SubWorkflowOutput } | { error: string; retryable: boolean } | null {
43
46
  if (child.status === "running" || child.status === "waiting") return null;
44
47
  if (child.status !== "completed") {
45
48
  const reason = child.error ? `: ${child.error}` : "";
46
- return { error: `Child workflow run ${child.id} ${child.status}${reason}` };
49
+ return {
50
+ error: `Child workflow run ${child.id} ${child.status}${reason}`,
51
+ retryable: child.status === "failed",
52
+ };
47
53
  }
48
54
  const outputs: Record<string, unknown> = {};
49
55
  for (const [key, value] of Object.entries(child.context ?? {})) {
@@ -56,8 +62,9 @@ export function childRunOutcome(
56
62
 
57
63
  /**
58
64
  * Runs another workflow as a child run and waits for it. The child run records
59
- * this step as its parent, so a re-executed step reconnects to it instead of
60
- * starting a second child. The resume path is `resumeFromChildRun`.
65
+ * this step and its attempt (`retryCount`) as its parent, so a re-executed or
66
+ * recovered step reconnects to it instead of starting a second child, while a
67
+ * retry starts a new one. The resume path is `resumeFromChildRun`.
61
68
  */
62
69
  export class SubWorkflowExecutor extends BaseExecutor<
63
70
  typeof SubWorkflowConfigSchema,
@@ -82,9 +89,12 @@ export class SubWorkflowExecutor extends BaseExecutor<
82
89
  ): Promise<ExecutorResult<SubWorkflowOutput>> {
83
90
  const { db } = this.deps;
84
91
 
85
- const existingId = await getChildWorkflowRunId(meta.stepId);
92
+ // The retry poller re-executes a failed step under the same id with a
93
+ // higher retryCount; that attempt must not settle on the previous child.
94
+ const attempt = (await db.getWorkflowRunStep(meta.stepId))?.retryCount ?? 0;
95
+ const existingId = await getChildWorkflowRunId(meta.stepId, attempt);
86
96
  const existing = existingId ? await db.getWorkflowRun(existingId) : null;
87
- if (existing) return this.settle(existing);
97
+ if (existing) return this.settle(existing, config.workflowId);
88
98
 
89
99
  // Capped at the max depth, so a long chain fails closed instead of
90
100
  // hiding a cycle past the end of the scan.
@@ -109,17 +119,34 @@ export class SubWorkflowExecutor extends BaseExecutor<
109
119
  }
110
120
 
111
121
  // Returns once the child run exists; the child walks on its own. A child
112
- // that already ended (cooldown, readiness, input failure) settles here.
113
- const childRunId = await startWorkflowExecution(workflow, config.inputs ?? {}, this.registry, {
114
- requestedByUserId: meta.requestedByUserId,
115
- parentStepId: meta.stepId,
116
- });
122
+ // that already ended (cooldown, readiness, input failure) settles here,
123
+ // and a concurrent execution of this attempt gets the child it created.
124
+ let childRunId: string;
125
+ try {
126
+ childRunId = await startWorkflowExecution(workflow, config.inputs ?? {}, this.registry, {
127
+ requestedByUserId: meta.requestedByUserId,
128
+ parentStepId: meta.stepId,
129
+ parentStepAttempt: attempt,
130
+ });
131
+ } catch (err) {
132
+ if (err instanceof ParentStepNotCurrentError) return { status: "failed", error: err.message };
133
+ throw err;
134
+ }
117
135
  const child = await db.getWorkflowRun(childRunId);
118
136
  if (!child) return { status: "failed", error: `Child workflow run ${childRunId} not found` };
119
- return this.settle(child);
137
+ return this.settle(child, config.workflowId);
120
138
  }
121
139
 
122
- private settle(child: WorkflowRun): ExecutorResult<SubWorkflowOutput> {
140
+ private settle(child: WorkflowRun, workflowId: string): ExecutorResult<SubWorkflowOutput> {
141
+ // The attempt already started a child of another workflow (the config
142
+ // changed under a recovered step). That is a conflict, not a reason to
143
+ // start a replacement next to it.
144
+ if (child.workflowId !== workflowId) {
145
+ return {
146
+ status: "failed",
147
+ error: `Child workflow run ${child.id} runs workflow ${child.workflowId}, not ${workflowId}`,
148
+ };
149
+ }
123
150
  const outcome = childRunOutcome(child);
124
151
  if (!outcome) {
125
152
  return {
@@ -129,7 +156,9 @@ export class SubWorkflowExecutor extends BaseExecutor<
129
156
  correlationId: child.id,
130
157
  } as ExecutorResult<SubWorkflowOutput>;
131
158
  }
132
- if ("error" in outcome) return { status: "failed", error: outcome.error };
159
+ if ("error" in outcome) {
160
+ return { status: "failed", error: outcome.error, retryable: outcome.retryable };
161
+ }
133
162
  return { status: "success", output: outcome.output, nextPort: "success" };
134
163
  }
135
164
  }
@@ -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 { 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";
@@ -20,6 +24,7 @@ import { FAILED_TASK_OUTPUT_PREFIX } from "./constants";
20
24
  import {
21
25
  failRunOnUnreadableReplay,
22
26
  findReadyNodes,
27
+ isStepExecuting,
23
28
  isWorkflowRunActive,
24
29
  walkGraph,
25
30
  } from "./engine";
@@ -76,6 +81,9 @@ export async function recoverIncompleteRuns(registry: ExecutorRegistry): Promise
76
81
  // --- Case 6: foreach fan-outs with queued or orphaned items ---
77
82
  recovered += await recoverForeachFanouts(registry);
78
83
 
84
+ // --- Case 7: sub-workflow steps a crash left running ---
85
+ recovered += await recoverOrphanedSubWorkflowSteps(registry);
86
+
79
87
  if (recovered > 0) {
80
88
  console.log(`[workflows] Recovered ${recovered} incomplete run(s)`);
81
89
  }
@@ -98,6 +106,13 @@ async function recoverRunningRuns(registry: ExecutorRegistry): Promise<number> {
98
106
  if (isWorkflowRunActive(runId)) continue;
99
107
  const run = await getWorkflowRun(runId);
100
108
  if (!run || run.status !== "running") continue;
109
+ // A child run commits with its initial context. One without it was
110
+ // created before that held and lost its trigger and inputs in a crash;
111
+ // walking it from an empty context would run its nodes on blanks.
112
+ if (run.parentStepId && run.context === undefined) {
113
+ await failRunningRun(runId, "Child workflow run lost its initial context in a restart");
114
+ continue;
115
+ }
101
116
 
102
117
  const workflow = await getWorkflow(run.workflowId);
103
118
  if (!workflow) continue;
@@ -146,6 +161,57 @@ async function recoverRunningRuns(registry: ExecutorRegistry): Promise<number> {
146
161
  return recovered;
147
162
  }
148
163
 
164
+ /** Fail a run that is still `running`; a cancel or finish that committed first wins. */
165
+ async function failRunningRun(runId: string, error: string): Promise<void> {
166
+ await getDbClient().transaction(async () => {
167
+ const run = await getWorkflowRun(runId);
168
+ if (run?.status !== "running") return;
169
+ await updateWorkflowRun(runId, {
170
+ status: "failed",
171
+ error,
172
+ finishedAt: new Date().toISOString(),
173
+ });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Re-enter `sub-workflow` steps a crash left `running`. The running-run case
179
+ * misses two shapes: a run another branch already parked in `waiting`, and a
180
+ * loop iteration of a node that completed before. Walking the node lets
181
+ * executeStep re-claim the same step row, so the executor reconnects to the
182
+ * child that attempt already started. Foreach items are left to the fan-out
183
+ * case: their synthetic node ids are not in the definition.
184
+ */
185
+ async function recoverOrphanedSubWorkflowSteps(registry: ExecutorRegistry): Promise<number> {
186
+ let recovered = 0;
187
+ for (const row of await getRunningSubWorkflowSteps()) {
188
+ try {
189
+ if (isWorkflowRunActive(row.runId) || isStepExecuting(row.stepId)) continue;
190
+ const run = await getWorkflowRun(row.runId);
191
+ if (!run || (run.status !== "running" && run.status !== "waiting")) continue;
192
+ const workflow = await getWorkflow(run.workflowId);
193
+ const node = workflow?.definition.nodes.find((n) => n.id === row.nodeId);
194
+ if (!workflow || !node) continue;
195
+ // DB reads above yield; a resume may have acquired the run meanwhile.
196
+ if (isWorkflowRunActive(row.runId)) continue;
197
+ await walkGraph(
198
+ workflow.definition,
199
+ row.runId,
200
+ (run.context ?? {}) as Record<string, unknown>,
201
+ [node],
202
+ registry,
203
+ workflow.id,
204
+ getSecretInputKeys(workflow.input),
205
+ );
206
+ recovered++;
207
+ } catch (err) {
208
+ if (await failRunOnUnreadableReplay(row.runId, err)) continue;
209
+ console.error(`[workflows] Failed to recover sub-workflow step ${row.stepId}:`, err);
210
+ }
211
+ }
212
+ return recovered;
213
+ }
214
+
149
215
  /** Nodes with a failed step the retry poller will pick up (`nextRetryAt` set). */
150
216
  function retryPendingNodeIds(steps: WorkflowRunStep[]): Set<string> {
151
217
  const nodeIds = new Set<string>();
@@ -165,6 +231,7 @@ async function completeIfSettled(runId: string, ctx: Record<string, unknown>): P
165
231
  return getDbClient().transaction(async () => {
166
232
  const run = await getWorkflowRun(runId);
167
233
  if (run?.status !== "running") return false;
234
+ if (await getRunLineageBlock(runId)) return false;
168
235
  const steps = await getWorkflowRunStepsByRunId(runId);
169
236
  if (retryPendingNodeIds(steps).size > 0) return false;
170
237
  const latest = new Map<string, WorkflowRunStep>();