@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,7 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { z } from "zod";
4
+ import * as preflightAlerts from "../automation-preflight-alert";
4
5
  import * as db from "../be/db";
5
6
  import {
6
7
  closeDb,
@@ -26,9 +27,11 @@ import { MAX_SUB_WORKFLOW_DEPTH, SubWorkflowExecutor } from "../workflows/execut
26
27
  import { recoverIncompleteRuns } from "../workflows/recovery";
27
28
  import {
28
29
  cancelWorkflowRun,
30
+ resumeFromChildRun,
29
31
  resumeWaitState,
30
32
  setupWorkflowResumeListener,
31
33
  } from "../workflows/resume";
34
+ import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
32
35
 
33
36
  const TEST_DB_PATH = "./test-workflow-sub-workflow.sqlite";
34
37
 
@@ -383,3 +386,537 @@ describe("sub-workflow node", () => {
383
386
  expect(rows[0]?.n).toBe(1);
384
387
  });
385
388
  });
389
+
390
+ /** A parent run with one `sub-workflow` step row, as executeStep inserts it. */
391
+ async function parentStepRow(parent: Workflow): Promise<{ runId: string; stepId: string }> {
392
+ const runId = crypto.randomUUID();
393
+ const stepId = crypto.randomUUID();
394
+ await db.createWorkflowRun({ id: runId, workflowId: parent.id });
395
+ await db.updateWorkflowRun(runId, { context: {} });
396
+ await db.createWorkflowRunStep({
397
+ id: stepId,
398
+ runId,
399
+ nodeId: "child",
400
+ nodeType: "sub-workflow",
401
+ idempotencyKey: `${runId}:child:0`,
402
+ });
403
+ return { runId, stepId };
404
+ }
405
+
406
+ /** Runs the sub-workflow executor for the step, as executeStep or the retry poller would. */
407
+ function executeChildStep(parent: Workflow, childId: string, runId: string, stepId: string) {
408
+ return registry.get("sub-workflow").run({
409
+ config: { workflowId: childId, inputs: { n: 1 } },
410
+ context: {},
411
+ meta: { runId, stepId, nodeId: "child", workflowId: parent.id, dryRun: false },
412
+ });
413
+ }
414
+
415
+ async function setStatus(
416
+ table: "workflow_runs" | "workflow_run_steps",
417
+ id: string,
418
+ status: string,
419
+ ): Promise<void> {
420
+ await getDbClient().run(`UPDATE ${table} SET status = ? WHERE id = ?`, [status, id]);
421
+ }
422
+
423
+ async function fireChildWait(childRunId: string): Promise<void> {
424
+ const [childStep] = await getWorkflowRunStepsByRunId(childRunId);
425
+ const wait = await getWaitStateByStepId(childStep!.id);
426
+ await resumeWaitState(wait!.id, "fired", undefined, registry);
427
+ }
428
+
429
+ async function attemptsOf(workflowId: string): Promise<{ id: string; attempt: number }[]> {
430
+ return getDbClient().query<{ id: string; attempt: number }>(
431
+ "SELECT id, parentStepAttempt AS attempt FROM workflow_runs WHERE workflowId = ? ORDER BY parentStepAttempt",
432
+ [workflowId],
433
+ );
434
+ }
435
+
436
+ describe("sub-workflow crash recovery", () => {
437
+ test("recovery re-claims a parent step left running and reconnects to its child", async () => {
438
+ const child = await makeWorkflow(waitingChild);
439
+ const parent = await makeWorkflow(parentOf(child.id));
440
+ const runId = await startWorkflowExecution(parent, {}, registry);
441
+ const childRun = await childRunOf(child.id, "waiting");
442
+ const [step] = await getWorkflowRunStepsByRunId(runId);
443
+
444
+ // The crash hit after the child committed and before the step parked.
445
+ await setStatus("workflow_run_steps", step!.id, "running");
446
+ await setStatus("workflow_runs", runId, "running");
447
+ await recoverIncompleteRuns(registry);
448
+
449
+ const steps = await getWorkflowRunStepsByRunId(runId);
450
+ expect(steps.map((s) => [s.id, s.status, s.retryCount])).toEqual([[step!.id, "waiting", 0]]);
451
+ expect(await childRunsOf(child.id)).toHaveLength(1);
452
+
453
+ await fireChildWait(childRun.id);
454
+ const run = await waitForRun(runId, isDone);
455
+ expect(run.status).toBe("completed");
456
+ expect((run.context?.child as { runId: string }).runId).toBe(childRun.id);
457
+ });
458
+
459
+ test("recovery re-claims a running parent step in a run another branch parked", async () => {
460
+ const child = await makeWorkflow(waitingChild);
461
+ const parent = await makeWorkflow(parentOf(child.id));
462
+ const runId = await startWorkflowExecution(parent, {}, registry);
463
+ await childRunOf(child.id, "waiting");
464
+ const [step] = await getWorkflowRunStepsByRunId(runId);
465
+
466
+ await setStatus("workflow_run_steps", step!.id, "running");
467
+ expect((await getWorkflowRun(runId))?.status).toBe("waiting");
468
+ await recoverIncompleteRuns(registry);
469
+
470
+ const steps = await getWorkflowRunStepsByRunId(runId);
471
+ expect(steps.map((s) => [s.id, s.status])).toEqual([[step!.id, "waiting"]]);
472
+ expect(await childRunsOf(child.id)).toHaveLength(1);
473
+ });
474
+
475
+ test("a child committed before its walk started runs from its stored context", async () => {
476
+ const child = await makeWorkflow(instantChild);
477
+ const parent = await makeWorkflow(parentOf(child.id));
478
+ const { runId, stepId } = await parentStepRow(parent);
479
+ // What startWorkflowExecution commits for a child, with no walk after it.
480
+ const childRunId = crypto.randomUUID();
481
+ await getDbClient().transaction(async () => {
482
+ await db.createWorkflowRun({
483
+ id: childRunId,
484
+ workflowId: child.id,
485
+ parentStepId: stepId,
486
+ parentStepAttempt: 0,
487
+ });
488
+ await db.updateWorkflowRun(childRunId, { context: { trigger: { n: 1 } } });
489
+ });
490
+
491
+ await recoverIncompleteRuns(registry);
492
+
493
+ const run = await waitForRun(runId, isDone);
494
+ expect(run.status).toBe("completed");
495
+ const output = run.context?.child as { runId: string; outputs: Record<string, unknown> };
496
+ expect(output.runId).toBe(childRunId);
497
+ expect(output.outputs.check).toMatchObject({ passed: true });
498
+ expect(await childRunsOf(child.id)).toHaveLength(1);
499
+ });
500
+
501
+ test("recovery fails a child run that never stored its initial context", async () => {
502
+ const child = await makeWorkflow(instantChild);
503
+ const parent = await makeWorkflow(parentOf(child.id));
504
+ const { runId, stepId } = await parentStepRow(parent);
505
+ const childRunId = crypto.randomUUID();
506
+ await db.createWorkflowRun({ id: childRunId, workflowId: child.id, parentStepId: stepId });
507
+
508
+ await recoverIncompleteRuns(registry);
509
+
510
+ const childRun = await waitForRun(childRunId, isDone);
511
+ expect(childRun.status).toBe("failed");
512
+ expect(childRun.error).toContain("lost its initial context");
513
+ expect(await getWorkflowRunStepsByRunId(childRunId)).toHaveLength(0);
514
+ const run = await waitForRun(runId, isDone);
515
+ expect(run.status).toBe("failed");
516
+ expect(await childRunsOf(child.id)).toHaveLength(1);
517
+ });
518
+
519
+ test("concurrent executions of one attempt share one child", async () => {
520
+ const child = await makeWorkflow(waitingChild);
521
+ const parent = await makeWorkflow(parentOf(child.id));
522
+ const { runId, stepId } = await parentStepRow(parent);
523
+
524
+ const results = await Promise.all([
525
+ executeChildStep(parent, child.id, runId, stepId),
526
+ executeChildStep(parent, child.id, runId, stepId),
527
+ ]);
528
+
529
+ const [row] = await childRunsOf(child.id);
530
+ expect(await childRunsOf(child.id)).toHaveLength(1);
531
+ for (const result of results) expect(result).toMatchObject({ correlationId: row!.id });
532
+ });
533
+
534
+ test("a step that is no longer running starts no child", async () => {
535
+ const child = await makeWorkflow(waitingChild);
536
+ const parent = await makeWorkflow(parentOf(child.id));
537
+ const { runId, stepId } = await parentStepRow(parent);
538
+ await setStatus("workflow_run_steps", stepId, "cancelled");
539
+
540
+ const result = await executeChildStep(parent, child.id, runId, stepId);
541
+
542
+ expect(result.status).toBe("failed");
543
+ expect((result as { error?: string }).error).toContain("no longer running");
544
+ expect(await childRunsOf(child.id)).toHaveLength(0);
545
+ });
546
+
547
+ test("an existing child of another workflow is a conflict, not replaced", async () => {
548
+ const child = await makeWorkflow(waitingChild);
549
+ const other = await makeWorkflow(waitingChild);
550
+ const parent = await makeWorkflow(parentOf(child.id));
551
+ const { runId, stepId } = await parentStepRow(parent);
552
+ await executeChildStep(parent, child.id, runId, stepId);
553
+
554
+ const result = await executeChildStep(parent, other.id, runId, stepId);
555
+
556
+ expect(result.status).toBe("failed");
557
+ expect((result as { error?: string }).error).toContain(`not ${other.id}`);
558
+ expect(await childRunsOf(child.id)).toHaveLength(1);
559
+ expect(await childRunsOf(other.id)).toHaveLength(0);
560
+ });
561
+
562
+ test("a retry attempt starts its own child and the old one cannot settle it", () =>
563
+ withoutListener(async () => {
564
+ const child = await makeWorkflow(waitingChild);
565
+ const parent = await makeWorkflow(parentOf(child.id));
566
+ const { runId, stepId } = await parentStepRow(parent);
567
+ await executeChildStep(parent, child.id, runId, stepId);
568
+ const first = await childRunOf(child.id, "waiting");
569
+
570
+ // The retry poller re-runs the same step id with retryCount bumped.
571
+ await db.updateWorkflowRunStep(stepId, { retryCount: 1 });
572
+ const retried = await executeChildStep(parent, child.id, runId, stepId);
573
+ const again = await executeChildStep(parent, child.id, runId, stepId);
574
+ const attempts = await attemptsOf(child.id);
575
+ expect(attempts.map((a) => a.attempt)).toEqual([0, 1]);
576
+ const second = attempts[1]!.id;
577
+ expect(retried).toMatchObject({ correlationId: second });
578
+ expect(again).toMatchObject({ correlationId: second });
579
+ await waitForRun(second, (r) => r.status === "waiting");
580
+
581
+ await setStatus("workflow_run_steps", stepId, "waiting");
582
+ await setStatus("workflow_runs", runId, "waiting");
583
+ await fireChildWait(first.id);
584
+ expect((await getWorkflowRun(first.id))?.status).toBe("completed");
585
+ expect(await resumeFromChildRun(stepId, registry)).toBe(false);
586
+ await recoverIncompleteRuns(registry);
587
+ expect((await db.getWorkflowRunStep(stepId))?.status).toBe("waiting");
588
+
589
+ await fireChildWait(second);
590
+ expect(await resumeFromChildRun(stepId, registry)).toBe(true);
591
+ const step = await db.getWorkflowRunStep(stepId);
592
+ expect(step?.status).toBe("completed");
593
+ expect((step?.output as { runId: string }).runId).toBe(second);
594
+ }));
595
+
596
+ test("the live event and the recovery sweep settle the parent once", () =>
597
+ withoutListener(async () => {
598
+ const child = await makeWorkflow(waitingChild);
599
+ const parent = await makeWorkflow(parentOf(child.id));
600
+ const runId = await startWorkflowExecution(parent, {}, registry);
601
+ const childRun = await childRunOf(child.id, "waiting");
602
+ const [step] = await getWorkflowRunStepsByRunId(runId);
603
+ await fireChildWait(childRun.id);
604
+
605
+ const claims = await Promise.all([
606
+ resumeFromChildRun(step!.id, registry),
607
+ resumeFromChildRun(step!.id, registry),
608
+ ]);
609
+ expect(claims.filter(Boolean)).toHaveLength(1);
610
+ await recoverIncompleteRuns(registry);
611
+
612
+ expect((await getWorkflowRun(runId))?.status).toBe("completed");
613
+ expect(await getWorkflowRunStepsByRunId(runId)).toHaveLength(1);
614
+ }));
615
+
616
+ test("a cancelled parent run is not overwritten by its child finishing", async () => {
617
+ const child = await makeWorkflow(waitingChild);
618
+ const parent = await makeWorkflow(parentOf(child.id));
619
+ const runId = await startWorkflowExecution(parent, {}, registry);
620
+ const childRun = await childRunOf(child.id, "waiting");
621
+ await cancelWorkflowRun(runId, "stop");
622
+ const [step] = await getWorkflowRunStepsByRunId(runId);
623
+
624
+ await fireChildWait(childRun.id);
625
+ expect(await resumeFromChildRun(step!.id, registry)).toBe(false);
626
+ await recoverIncompleteRuns(registry);
627
+
628
+ expect((await getWorkflowRun(runId))?.status).toBe("cancelled");
629
+ expect((await db.getWorkflowRunStep(step!.id))?.status).not.toBe("completed");
630
+ });
631
+ });
632
+
633
+ /** Retries at once, so the poller picks the step up on its next tick. */
634
+ const immediateRetry = {
635
+ maxRetries: 1,
636
+ strategy: "static",
637
+ baseDelayMs: 0,
638
+ maxDelayMs: 0,
639
+ } as const;
640
+
641
+ /** A child that parks on a wait and then fails, so it fails after the parent step parked. */
642
+ const failsAfterParkChild: WorkflowDefinition = {
643
+ nodes: [
644
+ { id: "pause", type: "wait", config: { mode: "time", durationMs: 3_600_000 }, next: "bad" },
645
+ { id: "bad", type: "property-match", config: { conditions: [] } },
646
+ ],
647
+ };
648
+
649
+ function retryingParentOf(childId: string): WorkflowDefinition {
650
+ const def = parentOf(childId);
651
+ def.nodes[0]!.retry = { ...immediateRetry };
652
+ return def;
653
+ }
654
+
655
+ async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean): Promise<T> {
656
+ for (let i = 0; i < 250; i++) {
657
+ const value = await read();
658
+ if (done(value)) return value;
659
+ await Bun.sleep(20);
660
+ }
661
+ throw new Error("condition not reached");
662
+ }
663
+
664
+ describe("sub-workflow attempts", () => {
665
+ test("a stale execution writes nothing over a cancelled step", async () => {
666
+ // Cancels the parent run while the child is being prepared, as a user
667
+ // cancel landing mid-execution would.
668
+ class CancelFirstExecutor extends SubWorkflowExecutor {
669
+ override async run(input: ExecutorInput) {
670
+ await cancelWorkflowRun(input.meta.runId, "stop");
671
+ return super.run(input);
672
+ }
673
+ }
674
+ const cancelRegistry = createExecutorRegistry(deps);
675
+ cancelRegistry.register(new CancelFirstExecutor(deps, cancelRegistry));
676
+ const child = await makeWorkflow(waitingChild);
677
+ const parent = await makeWorkflow(retryingParentOf(child.id));
678
+
679
+ const runId = await startWorkflowExecution(parent, {}, cancelRegistry);
680
+
681
+ expect((await getWorkflowRun(runId))?.status).toBe("cancelled");
682
+ const [step] = await getWorkflowRunStepsByRunId(runId);
683
+ expect(step?.status).toBe("cancelled");
684
+ expect(step?.retryCount).toBe(0);
685
+ expect(step?.nextRetryAt ?? null).toBeNull();
686
+ expect(await childRunsOf(child.id)).toHaveLength(0);
687
+ });
688
+
689
+ test("a child cancelled before the step parked is not retried either", async () => {
690
+ // The cancel lands between the child's creation and the executor's read,
691
+ // so the executor settles the cancelled child synchronously.
692
+ class CancelChildExecutor extends SubWorkflowExecutor {
693
+ override async run(input: ExecutorInput) {
694
+ const started = await super.run(input);
695
+ const { correlationId } = started as { correlationId?: string };
696
+ if (correlationId) await cancelWorkflowRun(correlationId, "stop");
697
+ return super.run(input);
698
+ }
699
+ }
700
+ const cancelRegistry = createExecutorRegistry(deps);
701
+ cancelRegistry.register(new CancelChildExecutor(deps, cancelRegistry));
702
+ const child = await makeWorkflow(waitingChild);
703
+ const parent = await makeWorkflow(retryingParentOf(child.id));
704
+
705
+ const runId = await startWorkflowExecution(parent, {}, cancelRegistry);
706
+
707
+ const run = await waitForRun(runId, isDone);
708
+ expect(run.status).toBe("failed");
709
+ const [step] = await getWorkflowRunStepsByRunId(runId);
710
+ expect(step?.error).toContain("cancelled");
711
+ expect(step?.retryCount).toBe(0);
712
+ expect(step?.nextRetryAt ?? null).toBeNull();
713
+ expect(await childRunsOf(child.id)).toHaveLength(1);
714
+ });
715
+
716
+ test("a stale failure does not write its output", async () => {
717
+ class CancelThenFailExecutor extends BaseExecutor<z.ZodObject, z.ZodObject> {
718
+ readonly type = "test-cancel-then-fail";
719
+ readonly mode = "instant" as const;
720
+ readonly configSchema = z.object({});
721
+ readonly outputSchema = z.object({});
722
+
723
+ protected async execute(
724
+ _config: Record<string, never>,
725
+ _context: Readonly<Record<string, unknown>>,
726
+ meta: ExecutorInput["meta"],
727
+ ): Promise<ExecutorResult<Record<string, never>>> {
728
+ await cancelWorkflowRun(meta.runId, "stop");
729
+ return { status: "failed", error: "boom", output: { late: true } as never };
730
+ }
731
+ }
732
+ const staleRegistry = createExecutorRegistry(deps);
733
+ staleRegistry.register(new CancelThenFailExecutor(deps));
734
+ const wf = await makeWorkflow({
735
+ nodes: [{ id: "x", type: "test-cancel-then-fail", config: {} }],
736
+ });
737
+
738
+ const runId = await startWorkflowExecution(wf, {}, staleRegistry);
739
+
740
+ const [step] = await getWorkflowRunStepsByRunId(runId);
741
+ expect(step?.status).toBe("cancelled");
742
+ expect(step?.output).toBeUndefined();
743
+ expect((await getWorkflowRun(runId))?.status).toBe("cancelled");
744
+ });
745
+
746
+ test("a child failing after the step parked is retried as a new attempt", async () => {
747
+ const child = await makeWorkflow(failsAfterParkChild);
748
+ const parent = await makeWorkflow(retryingParentOf(child.id));
749
+ startRetryPoller(registry, 20);
750
+ try {
751
+ const runId = await startWorkflowExecution(parent, {}, registry);
752
+ const first = await childRunOf(child.id, "waiting");
753
+ const [step] = await getWorkflowRunStepsByRunId(runId);
754
+
755
+ await fireChildWait(first.id);
756
+
757
+ const attempts = await waitFor(
758
+ () => attemptsOf(child.id),
759
+ (rows) => rows.length === 2,
760
+ );
761
+ expect(attempts.map((a) => a.attempt)).toEqual([0, 1]);
762
+ expect((await getWorkflowRun(first.id))?.status).toBe("failed");
763
+ const second = await waitForRun(attempts[1]!.id, (r) => r.status === "waiting");
764
+ const parked = await waitFor(
765
+ () => db.getWorkflowRunStep(step!.id),
766
+ (row) => row?.status === "waiting",
767
+ );
768
+ expect(parked?.retryCount).toBe(1);
769
+ expect(await getWorkflowRunStepsByRunId(runId)).toHaveLength(1);
770
+
771
+ // The last attempt failing exhausts the policy and fails the run.
772
+ await fireChildWait(second.id);
773
+ const run = await waitForRun(runId, isDone);
774
+ expect(run.status).toBe("failed");
775
+ expect(run.error).toContain(second.id);
776
+ expect(await childRunsOf(child.id)).toHaveLength(2);
777
+ } finally {
778
+ stopRetryPoller();
779
+ }
780
+ });
781
+
782
+ test("a cancelled child is not retried", async () => {
783
+ const child = await makeWorkflow(waitingChild);
784
+ const parent = await makeWorkflow(retryingParentOf(child.id));
785
+ const runId = await startWorkflowExecution(parent, {}, registry);
786
+ const childRun = await childRunOf(child.id, "waiting");
787
+
788
+ await cancelWorkflowRun(childRun.id, "stop");
789
+
790
+ const run = await waitForRun(runId, isDone);
791
+ expect(run.status).toBe("failed");
792
+ const [step] = await getWorkflowRunStepsByRunId(runId);
793
+ expect(step?.retryCount).toBe(0);
794
+ expect(await childRunsOf(child.id)).toHaveLength(1);
795
+ });
796
+
797
+ test("runs expose the parent step attempt", async () => {
798
+ const child = await makeWorkflow(waitingChild);
799
+ const parent = await makeWorkflow(parentOf(child.id));
800
+ const { runId, stepId } = await parentStepRow(parent);
801
+ await executeChildStep(parent, child.id, runId, stepId);
802
+ await db.updateWorkflowRunStep(stepId, { retryCount: 1 });
803
+ await executeChildStep(parent, child.id, runId, stepId);
804
+
805
+ const [first, second] = await attemptsOf(child.id);
806
+ expect(await getWorkflowRun(first!.id, "display")).toMatchObject({
807
+ parentStepId: stepId,
808
+ parentStepAttempt: 0,
809
+ });
810
+ expect((await getWorkflowRun(second!.id, "display"))?.parentStepAttempt).toBe(1);
811
+ expect((await getWorkflowRun(runId))?.parentStepAttempt).toBeUndefined();
812
+
813
+ const page = await db.listWorkflowRunsPage(child.id, { limit: 10, offset: 0 });
814
+ expect(
815
+ page.runs
816
+ .map((r) => [r.parentStepId, r.parentStepAttempt])
817
+ .sort((a, b) => Number(a[1]) - Number(b[1])),
818
+ ).toEqual([
819
+ [stepId, 0],
820
+ [stepId, 1],
821
+ ]);
822
+ });
823
+
824
+ test("a child that needs setup keeps its parent link and attempt", async () => {
825
+ const notify = spyOn(preflightAlerts, "notifyAutomationPreflightFailure").mockResolvedValue(
826
+ undefined,
827
+ );
828
+ try {
829
+ const child = await createWorkflow({
830
+ name: `wf-${crypto.randomUUID()}`,
831
+ definition: instantChild,
832
+ requiredParams: ["REPO_URL"],
833
+ });
834
+ const parent = await makeWorkflow(parentOf(child.id));
835
+
836
+ const runId = await startWorkflowExecution(parent, {}, registry);
837
+
838
+ expect((await waitForRun(runId, isDone)).status).toBe("failed");
839
+ const [step] = await getWorkflowRunStepsByRunId(runId);
840
+ const [linked] = await childRunsOf(child.id);
841
+ const linkedRun = await getWorkflowRun(linked!.id);
842
+ expect(linkedRun).toMatchObject({ parentStepId: step!.id, parentStepAttempt: 0 });
843
+ expect(linkedRun?.status).toBe("failed");
844
+ expect(linkedRun?.error).toStartWith("needs_setup:");
845
+ expect(step?.error).toContain(linked!.id);
846
+
847
+ // A top-level trigger records its own unlinked failure, and the day's
848
+ // alert already went out.
849
+ const topLevel = await startWorkflowExecution(child, {}, registry);
850
+ expect(topLevel).not.toBe(linked!.id);
851
+ expect((await getWorkflowRun(topLevel))?.parentStepId).toBeUndefined();
852
+ expect(notify).toHaveBeenCalledTimes(1);
853
+ } finally {
854
+ notify.mockRestore();
855
+ }
856
+ });
857
+
858
+ test("concurrent child setup failures alert exactly once", async () => {
859
+ const notify = spyOn(preflightAlerts, "notifyAutomationPreflightFailure").mockResolvedValue(
860
+ undefined,
861
+ );
862
+ try {
863
+ const child = await createWorkflow({
864
+ name: `wf-${crypto.randomUUID()}`,
865
+ definition: instantChild,
866
+ requiredParams: ["REPO_URL"],
867
+ });
868
+ // Two entry nodes run in parallel, so both failures race for the alert.
869
+ const parent = await makeWorkflow({
870
+ nodes: ["a", "b"].map((id) => ({
871
+ id,
872
+ type: "sub-workflow",
873
+ config: { workflowId: child.id, inputs: { n: 1 } },
874
+ })),
875
+ });
876
+
877
+ const runId = await startWorkflowExecution(parent, {}, registry);
878
+
879
+ expect((await waitForRun(runId, isDone)).status).toBe("failed");
880
+ expect(await childRunsOf(child.id)).toHaveLength(2);
881
+ expect(notify).toHaveBeenCalledTimes(1);
882
+ } finally {
883
+ notify.mockRestore();
884
+ }
885
+ });
886
+
887
+ test("a legacy child started on a later attempt survives the upgrade", () =>
888
+ withoutListener(async () => {
889
+ // On main before migration 240: attempt 0 failed before starting a child
890
+ // (say, the child workflow was disabled), attempt 1 started it and parked.
891
+ const child = await makeWorkflow(waitingChild);
892
+ const parent = await makeWorkflow(parentOf(child.id));
893
+ const { runId, stepId } = await parentStepRow(parent);
894
+ await db.updateWorkflowRunStep(stepId, { retryCount: 1 });
895
+ await executeChildStep(parent, child.id, runId, stepId);
896
+ await setStatus("workflow_run_steps", stepId, "waiting");
897
+ await setStatus("workflow_runs", runId, "waiting");
898
+ const childRun = await childRunOf(child.id, "waiting");
899
+ // The column default the upgrade gives every legacy child.
900
+ await getDbClient().run("UPDATE workflow_runs SET parentStepAttempt = 0 WHERE id = ?", [
901
+ childRun.id,
902
+ ]);
903
+
904
+ // The backfill statement of migration 240, scoped to this child: the
905
+ // shared test database also holds post-migration multi-attempt children,
906
+ // which a real legacy database cannot have.
907
+ const migration = await Bun.file(
908
+ "src/be/migrations/240_workflow_run_parent_step_attempt.sql",
909
+ ).text();
910
+ const start = migration.indexOf("UPDATE workflow_runs");
911
+ const backfill = migration.slice(start, migration.indexOf(";", start));
912
+ await getDbClient().run(`${backfill} AND workflow_runs.id = ?`, [childRun.id]);
913
+ await recoverIncompleteRuns(registry);
914
+
915
+ expect((await getWorkflowRun(runId))?.status).toBe("waiting");
916
+ expect((await db.getWorkflowRunStep(stepId))?.status).toBe("waiting");
917
+ await fireChildWait(childRun.id);
918
+ expect(await resumeFromChildRun(stepId, registry)).toBe(true);
919
+ expect((await getWorkflowRun(runId))?.status).toBe("completed");
920
+ expect(await childRunsOf(child.id)).toHaveLength(1);
921
+ }));
922
+ });
@@ -1,5 +1,9 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
+ import {
4
+ ConfigQuestionSettingsSchema,
5
+ normalizeApprovalQuestions,
6
+ } from "@/be/approval-config-question";
3
7
  import { resolveTaskAuditUserId } from "@/be/audit-user";
4
8
  import { createApprovalRequest, getAgentCurrentTask } from "@/be/db";
5
9
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
@@ -8,9 +12,14 @@ import { getAppUrl } from "@/utils/constants";
8
12
  const QuestionSchema = z.object({
9
13
  id: z.string().describe("Unique ID for the question (used as key in responses)"),
10
14
  type: z
11
- .enum(["approval", "text", "single-select", "multi-select", "boolean"])
15
+ .enum(["approval", "text", "single-select", "multi-select", "boolean", "config"])
12
16
  .describe("Question type"),
13
- label: z.string().describe("The question text displayed to the user"),
17
+ label: z
18
+ .string()
19
+ .optional()
20
+ .describe(
21
+ "The question text displayed to the user (required except for config, which defaults to the key)",
22
+ ),
14
23
  required: z.boolean().optional().describe("Whether this question is required (default: true)"),
15
24
  description: z
16
25
  .string()
@@ -31,6 +40,9 @@ const QuestionSchema = z.object({
31
40
  minSelections: z.number().int().min(0).optional().describe("Min selections (for multi-select)"),
32
41
  maxSelections: z.number().int().min(1).optional().describe("Max selections (for multi-select)"),
33
42
  defaultValue: z.boolean().optional().describe("Default value (for boolean type)"),
43
+ config: ConfigQuestionSettingsSchema.optional().describe(
44
+ "Settings for a config question: the key and scope to set, plus options or defaultValue for non-secret keys",
45
+ ),
34
46
  });
35
47
 
36
48
  export const registerRequestHumanInputTool = (server: McpServer) => {
@@ -42,7 +54,8 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
42
54
  description:
43
55
  "Create an approval request and return at once with the request id and URL. The answer arrives later as a hitl-follow-up task. " +
44
56
  "Supports multiple question types: approval (yes/no), text, single-select, " +
45
- "multi-select, and boolean. Returns the request ID and URL for the human to respond. " +
57
+ "multi-select, boolean, and config. Returns the request ID and URL for the human to respond. " +
58
+ "config asks a person to set one swarm config key on the dashboard; secrets are stored encrypted and you receive only {key, scope, isSecret, status, updatedAt}, never the value. " +
46
59
  "The dashboard renders each label and description as markdown: put long content in description, fence code with a language, and put an image URL alone on its own line to show it. " +
47
60
  "No Slack message is sent; share the URL yourself.",
48
61
  inputSchema: z.object({
@@ -90,12 +103,21 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
90
103
  }
91
104
  }
92
105
 
106
+ const approvers = { policy: "any" as const };
107
+ const normalized = await normalizeApprovalQuestions(questions, {
108
+ approvers,
109
+ callerAgentId: requestInfo.agentId,
110
+ });
111
+ if (!normalized.ok) {
112
+ return toolErr(normalized.error, { data: { yourAgentId: requestInfo.agentId } });
113
+ }
114
+
93
115
  const id = crypto.randomUUID();
94
116
  const request = await createApprovalRequest({
95
117
  id,
96
118
  title,
97
- questions,
98
- approvers: { policy: "any" },
119
+ questions: normalized.questions,
120
+ approvers,
99
121
  sourceTaskId: sourceTaskId ?? undefined,
100
122
  timeoutSeconds,
101
123
  createdBy: (await resolveTaskAuditUserId(sourceTaskId, requestInfo.agentId)) ?? undefined,
@@ -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"),
package/src/types.ts CHANGED
@@ -2958,6 +2958,13 @@ export const WorkflowRunSchema = z
2958
2958
  finishedAt: z.string().optional(),
2959
2959
  /** The `sub-workflow` step that started this run. Unset on a top-level run. */
2960
2960
  parentStepId: z.string().optional(),
2961
+ /**
2962
+ * The parent step's attempt (its `retryCount`) that started this run. A
2963
+ * step has at most one child per attempt, so `(parentStepId,
2964
+ * parentStepAttempt)` identifies the invocation; a node `retry` adds a
2965
+ * child with the next attempt. Unset on a top-level run.
2966
+ */
2967
+ parentStepAttempt: z.number().int().min(0).optional(),
2961
2968
  })
2962
2969
  .openapi("WorkflowRun");
2963
2970
 
@@ -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
  /**