@desplega.ai/agent-swarm 2.6.0 → 2.7.0

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 (213) hide show
  1. package/README.md +2 -0
  2. package/dist/{acp-adapter-dgfakfpw.js → acp-adapter-ma1js5n7.js} +6 -6
  3. package/dist/{actions-fbqma8aa.js → actions-1vhmtvg9.js} +18 -18
  4. package/dist/{amp-adapter-en0jy6hs.js → amp-adapter-sxqqvmdk.js} +3 -3
  5. package/dist/{app-k8a5cgpp.js → app-mwx1d5a9.js} +3 -3
  6. package/dist/{assistant-a16433gr.js → assistant-yx1p6g82.js} +20 -20
  7. package/dist/{boot-reembed-f4nn561n.js → boot-reembed-5h37a2c2.js} +1 -1
  8. package/dist/{boot-reembed-6c1sjfnr.js → boot-reembed-7h78vmvv.js} +2 -1
  9. package/dist/{boot-scrub-sweep-d4w9gajf.js → boot-scrub-sweep-8mawd4gw.js} +3 -3
  10. package/dist/{boot-seeds-9bmkspyw.js → boot-seeds-ca84dhnn.js} +4 -4
  11. package/dist/{claude-adapter-nys1kjxa.js → claude-adapter-wqt4hfkq.js} +7 -7
  12. package/dist/{claude-managed-adapter-55207973.js → claude-managed-adapter-6fjqtr2z.js} +2 -2
  13. package/dist/{claude-managed-setup-3cm35gey.js → claude-managed-setup-rqv98sea.js} +2 -2
  14. package/dist/{claude-sdk-session-nw5yd8cp.js → claude-sdk-session-f29f3b2x.js} +7 -7
  15. package/dist/{cli-sxgtf5fw.js → cli-07q03xm2.js} +1 -1
  16. package/dist/{cli-k21gx5ht.js → cli-1hhbzkbb.js} +6 -6
  17. package/dist/{cli-v49fkghp.js → cli-1m9x8dr5.js} +1 -1
  18. package/dist/{cli-24j1jeeb.js → cli-2pvzmas1.js} +3 -3
  19. package/dist/{cli-w2656ka1.js → cli-2y7v133v.js} +7 -7
  20. package/dist/{cli-g8ka5j4j.js → cli-48gxjmcz.js} +94 -36
  21. package/dist/{cli-2sjhpsns.js → cli-54sj64fc.js} +1 -1
  22. package/dist/{cli-aj8djyqt.js → cli-60sty27p.js} +2 -2
  23. package/dist/{cli-4hxrf0p4.js → cli-6a9qfa8b.js} +308 -47
  24. package/dist/{cli-n7asmce6.js → cli-6mgdba42.js} +3 -3
  25. package/dist/{cli-gf95q3bm.js → cli-6srv653c.js} +3 -3
  26. package/dist/{cli-9abdgn5t.js → cli-86ehn4s5.js} +3 -3
  27. package/dist/{cli-bqztfyve.js → cli-8mbc9za9.js} +1 -1
  28. package/dist/{cli-a2rr0fmg.js → cli-8wmj18cz.js} +1 -1
  29. package/dist/{cli-c2habvsf.js → cli-9gsm2rzp.js} +8 -8
  30. package/dist/{cli-4rf0f5vg.js → cli-9pshx6rd.js} +4 -2
  31. package/dist/{cli-abkm71vr.js → cli-a6ds2epe.js} +1 -1
  32. package/dist/{cli-esakngh8.js → cli-a89zdqz9.js} +1 -1
  33. package/dist/{cli-3ndmpxxv.js → cli-ay40dnj3.js} +2 -2
  34. package/dist/{cli-kf4jree9.js → cli-aypd09nf.js} +1 -1
  35. package/dist/{cli-6y51ryva.js → cli-b3yzyc57.js} +1 -1
  36. package/dist/{cli-ag2yrnt5.js → cli-c668vvcf.js} +2 -2
  37. package/dist/{cli-5sckpqcx.js → cli-d20qvpr2.js} +1 -1
  38. package/dist/{cli-a34pr3w1.js → cli-e1595bgx.js} +2 -2
  39. package/dist/{cli-m70bajzr.js → cli-f12mr20z.js} +1 -1
  40. package/dist/{cli-dqygeeh5.js → cli-f1e0ptjj.js} +10 -1
  41. package/dist/{cli-mvjz5an3.js → cli-f4dbh7a5.js} +7 -7
  42. package/dist/{cli-az25fn5f.js → cli-gsfk58nw.js} +2 -2
  43. package/dist/{cli-1tem6ns9.js → cli-gt4h8cxw.js} +3 -2
  44. package/dist/{cli-cz37jqss.js → cli-h5jeg46p.js} +2 -2
  45. package/dist/{cli-emhjcj2y.js → cli-he9gbdx0.js} +60 -10
  46. package/dist/{cli-3fwqy71j.js → cli-j0ppywdr.js} +38 -31
  47. package/dist/{cli-5rj83bam.js → cli-j3y0x2n4.js} +34 -33
  48. package/dist/{cli-3gfvmzht.js → cli-j9bpmykh.js} +73 -25
  49. package/dist/{cli-m5a9m0ak.js → cli-kv7sbc2c.js} +3 -3
  50. package/dist/{cli-g20jjwt4.js → cli-m30k9777.js} +12 -12
  51. package/dist/{cli-683y0b3a.js → cli-m5e802r7.js} +2 -2
  52. package/dist/{cli-nn6vx2mq.js → cli-ptfq202x.js} +5 -3
  53. package/dist/{cli-d2dpf95n.js → cli-qj442fws.js} +1 -1
  54. package/dist/{cli-03gg9pd1.js → cli-rd5m70gj.js} +4 -4
  55. package/dist/{cli-gnsb1zxa.js → cli-s09j9vp5.js} +1 -1
  56. package/dist/{cli-zqne1qcj.js → cli-s0mgx214.js} +2 -2
  57. package/dist/{cli-sqma6vw1.js → cli-sgqm2582.js} +5 -5
  58. package/dist/{cli-nq677csy.js → cli-sjctjr48.js} +1 -1
  59. package/dist/{cli-3aqd4jaa.js → cli-twctnkng.js} +3 -3
  60. package/dist/{cli-j6rktc91.js → cli-v6shg7na.js} +1 -1
  61. package/dist/{cli-7enjwya9.js → cli-vz78s5wm.js} +1 -1
  62. package/dist/{cli-art7c1zh.js → cli-wqr7cea4.js} +4 -4
  63. package/dist/{cli-hfeb68z4.js → cli-x37r7qwf.js} +11 -3
  64. package/dist/{cli-ffvsj2qw.js → cli-yd2nreqw.js} +2 -2
  65. package/dist/{cli-ca6banwz.js → cli-ymjpkxgn.js} +6 -6
  66. package/dist/{cli-mtnj4fwa.js → cli-yxbsmx56.js} +5 -5
  67. package/dist/cli.js +18 -16
  68. package/dist/{codex-adapter-d95q4qsn.js → codex-adapter-zk59x2zh.js} +5 -5
  69. package/dist/{codex-hook-brpnycn5.js → codex-hook-xyjhvxwd.js} +1 -1
  70. package/dist/{codex-session-runner-gdwd8h0c.js → codex-session-runner-kh6s4j3n.js} +5 -5
  71. package/dist/{commands-jqmgk54b.js → commands-68et1qv2.js} +2 -2
  72. package/dist/{controller-z3jq589f.js → controller-fyrbnzg4.js} +2 -2
  73. package/dist/{controllers-connect-r2za39ef.js → controllers-connect-nep9h2xv.js} +19 -19
  74. package/dist/{cursor-adapter-4mr4jgkw.js → cursor-adapter-yss3pg7t.js} +1 -1
  75. package/dist/{db-w36wwb5g.js → db-28gjyvxj.js} +2 -2
  76. package/dist/{db-ppv2tze7.js → db-8ftwvzba.js} +3 -3
  77. package/dist/{dispatcher-pv5h77vf.js → dispatcher-hayexmkr.js} +17 -17
  78. package/dist/{dsh-adapter-d4pdpb3r.js → dsh-adapter-46wawff9.js} +1 -1
  79. package/dist/{easter-egg-3d-4sp4dvmc.js → easter-egg-3d-8d1e0h4m.js} +1 -1
  80. package/dist/{execute-jev839y1.js → execute-cjrfxfnm.js} +1 -1
  81. package/dist/{external-channel-y6nkzhnn.js → external-channel-xbmxcg09.js} +2 -2
  82. package/dist/{grok-adapter-t08b9ntm.js → grok-adapter-ag4wks2a.js} +7 -7
  83. package/dist/handlers-tzmevqb5.js +41 -0
  84. package/dist/{heartbeat-vn31xwdk.js → heartbeat-psfvcgwp.js} +20 -20
  85. package/dist/{hook-6d0zbvvt.js → hook-8pz04697.js} +5 -5
  86. package/dist/{hook-cpjs8q51.js → hook-cyq4mryn.js} +6 -6
  87. package/dist/{http-9c6medda.js → http-haj1nd6k.js} +109 -114
  88. package/dist/{index-qhjf2htm.js → index-jwt2bj6f.js} +261 -96
  89. package/dist/{index-g6k9tctj.js → index-q52f47pe.js} +1 -1
  90. package/dist/{index-pkma1yf8.js → index-syvhnvtw.js} +1 -1
  91. package/dist/{index-yqgbfgaj.js → index-y1ajx04a.js} +20 -20
  92. package/dist/{index-k0yt1tvq.js → index-z5h22pes.js} +17 -17
  93. package/dist/{index-38t35h0n.js → index-zyq8ct52.js} +17 -17
  94. package/dist/{keepalive-em0117bp.js → keepalive-xdvsamvj.js} +3 -3
  95. package/dist/{lead-23e7z59t.js → lead-hqg57kdp.js} +35 -35
  96. package/dist/{maintenance-kbme3qy9.js → maintenance-4y2xzh3a.js} +1 -1
  97. package/dist/{oauth-refresh-sweep-xysj4yse.js → oauth-refresh-sweep-jd4r7zbs.js} +8 -3
  98. package/dist/{onboard-p6q0vz97.js → onboard-18447q9q.js} +5 -5
  99. package/dist/{opencode-adapter-esv3k9yn.js → opencode-adapter-26b2frdf.js} +5 -5
  100. package/dist/{otel-impl-54qhqh1z.js → otel-impl-f9tgr8yw.js} +1 -1
  101. package/dist/{pi-mono-adapter-mymbwhj0.js → pi-mono-adapter-qn6qbga1.js} +33 -12
  102. package/dist/{pricing-refresh-yb75wv33.js → pricing-refresh-dw2tgb53.js} +3 -3
  103. package/dist/{rbac-roles-1vp2mw4p.js → rbac-roles-aygm2760.js} +2 -2
  104. package/dist/{rbac-roles-dexhc9x8.js → rbac-roles-s4nwj2m3.js} +1 -1
  105. package/dist/{render-v2-tjay5hhf.js → render-v2-yfvdfr0x.js} +3 -3
  106. package/dist/{resume-8ayf37g5.js → resume-1bmvjbf3.js} +7 -7
  107. package/dist/{runtime-hqe5cqcd.js → runtime-f7tpzedy.js} +1 -1
  108. package/dist/{session-access-fdhp987s.js → session-access-r5garej6.js} +4 -4
  109. package/dist/{session-source-backfill-qgtc86z1.js → session-source-backfill-424wzv0r.js} +2 -2
  110. package/dist/{setup-4rrmqcq3.js → setup-8tygvxkq.js} +1 -1
  111. package/dist/{virtual-modules-52km00d6.js → virtual-modules-xsw323b3.js} +2 -2
  112. package/dist/{work-objects-p28fgd70.js → work-objects-ggnghpgf.js} +20 -20
  113. package/dist/{worker-e3w7vye1.js → worker-sqjd1nqf.js} +35 -35
  114. package/openapi.json +69 -1
  115. package/package.json +5 -3
  116. package/src/be/automation-preflight.ts +2 -0
  117. package/src/be/db/api-keys.ts +9 -0
  118. package/src/be/db/workflow-runs.ts +36 -5
  119. package/src/be/db.ts +80 -22
  120. package/src/be/memory/task-learning.ts +155 -0
  121. package/src/be/migrations/245_script_executor.sql +3 -0
  122. package/src/be/oauth-refresh-sweep.ts +2 -1
  123. package/src/be/scripts/boot-reembed.ts +2 -0
  124. package/src/be/scripts/db.ts +14 -4
  125. package/src/be/scripts/embeddings.ts +2 -0
  126. package/src/be/scripts/run-saved.ts +1 -0
  127. package/src/be/scripts/typecheck.ts +9 -3
  128. package/src/be/seed-skills/index.ts +7 -0
  129. package/src/commands/onboard/compose-generator.ts +5 -3
  130. package/src/commands/provider-credentials.ts +2 -1
  131. package/src/heartbeat/heartbeat.ts +14 -1
  132. package/src/heartbeat/script-runner-probe.ts +47 -0
  133. package/src/http/api-keys.ts +24 -9
  134. package/src/http/idempotency-key.ts +1 -1
  135. package/src/http/index.ts +3 -3
  136. package/src/http/pages-public.ts +38 -5
  137. package/src/http/scripts.ts +27 -0
  138. package/src/http/workflows.ts +10 -0
  139. package/src/http/x.ts +5 -0
  140. package/src/prompts/base-prompt.ts +21 -1
  141. package/src/prompts/session-templates.ts +17 -3
  142. package/src/providers/amp-adapter.ts +1 -1
  143. package/src/providers/dsh-adapter.ts +1 -1
  144. package/src/providers/pi-mono-adapter.ts +41 -11
  145. package/src/scheduler/scheduler.ts +2 -1
  146. package/src/scripts-runtime/eval-harness.ts +8 -1
  147. package/src/scripts-runtime/executors/native.ts +22 -16
  148. package/src/scripts-runtime/executors/quickjs-prelude.ts +103 -1
  149. package/src/scripts-runtime/executors/quickjs-runner.ts +2 -1
  150. package/src/scripts-runtime/executors/quickjs.ts +7 -0
  151. package/src/scripts-runtime/executors/registry.ts +32 -0
  152. package/src/scripts-runtime/executors/types.ts +7 -0
  153. package/src/scripts-runtime/host-validated-args.ts +133 -0
  154. package/src/scripts-runtime/loader.ts +30 -3
  155. package/src/scripts-runtime/swarm-sdk.ts +9 -2
  156. package/src/scripts-runtime/types/stdlib.d.ts +9 -1
  157. package/src/scripts-runtime/types/swarm-sdk.d.ts +9 -1
  158. package/src/tests/api-key-status-by-suffix.test.ts +242 -0
  159. package/src/tests/base-prompt.test.ts +70 -2
  160. package/src/tests/community-linear-delivery-loop.test.ts +2099 -0
  161. package/src/tests/credential-check.test.ts +4 -2
  162. package/src/tests/docs-index.test.ts +135 -0
  163. package/src/tests/dsh-adapter.test.ts +1 -1
  164. package/src/tests/fetch-resolved-env.test.ts +16 -2
  165. package/src/tests/heartbeat-checklist.test.ts +89 -0
  166. package/src/tests/heartbeat-script-runner-probe.test.ts +83 -0
  167. package/src/tests/oauth-refresh-sweep.test.ts +34 -1
  168. package/src/tests/onboard-compose.test.ts +20 -0
  169. package/src/tests/opencode-adapter.test.ts +2 -1
  170. package/src/tests/pages-frame-ancestors.test.ts +162 -0
  171. package/src/tests/pi-codemode.test.ts +2 -1
  172. package/src/tests/pi-mono-adapter.test.ts +54 -0
  173. package/src/tests/pi-native-mcp.test.ts +13 -1
  174. package/src/tests/pi-tool-exposure.test.ts +8 -8
  175. package/src/tests/probe-tool-schema-tokens.test.ts +144 -0
  176. package/src/tests/prompt-template-session.test.ts +4 -3
  177. package/src/tests/request-composition-derive.test.ts +329 -0
  178. package/src/tests/script-connections.test.ts +2 -0
  179. package/src/tests/script-executor-conformance.test.ts +36 -1
  180. package/src/tests/script-executor-quickjs-pool.test.ts +50 -0
  181. package/src/tests/script-executor-quickjs.test.ts +140 -0
  182. package/src/tests/script-host-validated-args-native.test.ts +196 -0
  183. package/src/tests/script-per-script-executor.test.ts +415 -0
  184. package/src/tests/scripts-external-api.test.ts +45 -0
  185. package/src/tests/sdk-allowlist.test.ts +3 -2
  186. package/src/tests/self-improvement.test.ts +4 -4
  187. package/src/tests/store-progress-learning.test.ts +288 -0
  188. package/src/tests/ui-logs-parser.test.ts +180 -0
  189. package/src/tests/workflow-engine-v2.test.ts +116 -1
  190. package/src/tests/workflow-mcp-trigger-schema.test.ts +209 -0
  191. package/src/tests/workflow-run-finished-event.test.ts +263 -0
  192. package/src/tests/workflow-swarm-script.test.ts +34 -0
  193. package/src/tools/script-common.ts +1 -0
  194. package/src/tools/script-run.ts +7 -0
  195. package/src/tools/script-upsert.ts +7 -0
  196. package/src/tools/store-progress.ts +67 -1
  197. package/src/tools/workflows/trigger-workflow.ts +104 -10
  198. package/src/types.ts +12 -1
  199. package/src/utils/credentials.ts +5 -2
  200. package/src/workflows/engine.ts +18 -16
  201. package/src/workflows/executors/swarm-script.ts +5 -0
  202. package/src/workflows/foreach-driver.ts +5 -11
  203. package/src/workflows/node-timeout.ts +30 -0
  204. package/templates/community/linear-delivery-loop/PLAYBOOK.md +323 -0
  205. package/templates/community/linear-delivery-loop/README.md +38 -0
  206. package/templates/community/linear-delivery-loop/config.example.json +31 -0
  207. package/templates/community/linear-delivery-loop/lead-prompt.md +34 -0
  208. package/templates/community/linear-delivery-loop/schedule.json +10 -0
  209. package/templates/community/linear-delivery-loop/scripts/linear-delivery-setup.ts +148 -0
  210. package/templates/community/linear-delivery-loop/scripts/linear-delivery-tick.ts +1781 -0
  211. package/templates/skills/swarm-docs/config.json +14 -0
  212. package/templates/skills/swarm-docs/content.md +144 -0
  213. package/dist/handlers-eqsnmv95.js +0 -41
@@ -13,6 +13,11 @@ import {
13
13
  updateAgentStatusFromCapacity,
14
14
  updateTaskProgress,
15
15
  } from "@/be/db";
16
+ import {
17
+ LEARNING_MAX_TAGS,
18
+ storeTaskLearning,
19
+ type TaskLearningResult,
20
+ } from "@/be/memory/task-learning";
16
21
  import {
17
22
  CitationInputSchema,
18
23
  getTaskCitations,
@@ -114,8 +119,26 @@ export const storeProgressOutputSchema = swarmToolOutputSchema({
114
119
  .describe(
115
120
  "Present only when this progress text reads as blocked-waiting: milliseconds since the task's prior update. Drives the store-progress nudge toward defer-task.",
116
121
  ),
122
+ learning: z
123
+ .looseObject({
124
+ status: z.string().optional(),
125
+ key: z.string().optional(),
126
+ memoryId: z.string().optional(),
127
+ workstream: z.string().optional(),
128
+ error: z.string().optional(),
129
+ })
130
+ .optional()
131
+ .describe(
132
+ "Present when the call carried a learning: stored, replayed (same text already stored for this task), or failed with error.",
133
+ ),
117
134
  });
118
135
 
136
+ function learningSentence(result: TaskLearningResult): string {
137
+ if (result.status === "stored") return `Learning stored at ${result.key}.`;
138
+ if (result.status === "replayed") return `Learning already stored at ${result.key}; skipped.`;
139
+ return `Learning not stored: ${result.error}.`;
140
+ }
141
+
119
142
  export const registerStoreProgressTool = (server: McpServer) => {
120
143
  createToolRegistrar(server)(
121
144
  "store-progress",
@@ -165,6 +188,19 @@ export const registerStoreProgressTool = (server: McpServer) => {
165
188
  .describe(
166
189
  'Claim sources, upserted by index across calls. Use only for factual claims the reader cannot already see in the thread or task tree, 1-2 sources; none on delegation, routing, acks, or status replies. Reference each one in output with [citation:N], or set general: true for a source that backs the whole answer (it renders under "General sources"). ref per kind: task = task UUID; memory = memory UUID (optional quote must appear verbatim in it); github = owner/repo#N, owner/repo@<sha>, or a github.com pull, issues, or commit URL; slack = permalink or channel/ts; agent-fs = file path; page = page id; script-run = script run id; url = http(s) URL. The first completing call is refused, and the task stays in progress, if a marker has no entry, a citation fails validation, or a non-general citation is unreferenced; the response lists each problem. After one refusal, completion proceeds and those markers and sources are dropped from rendered output. At most 50 citations per call/task, refs up to 2048 characters, labels up to 200. Invalid or oversized batches are ignored; existing indices can still be updated at capacity.',
167
190
  ),
191
+ learning: z
192
+ .union([
193
+ z.string(),
194
+ z.object({
195
+ text: z.string(),
196
+ workstream: z.string().optional(),
197
+ tags: z.array(z.string()).optional(),
198
+ }),
199
+ ])
200
+ .optional()
201
+ .describe(
202
+ `A lesson to keep. Add one only when it would change how the next agent works: a gotcha, a dead end, a pattern that worked. Not a progress line. Stored as a swarm memory at /learnings/<workstream>/<slug>, tagged "learning" and tied to this task. The first line of text is the title. At most ~500 tokens and ${LEARNING_MAX_TAGS} kebab tags. workstream defaults to the task's repo name, else "general". Send it on or before the completing call: a call on a finished task stores nothing. A failed learning write is reported in the result and never blocks the progress update.`,
203
+ ),
168
204
  persistMemory: z
169
205
  .boolean()
170
206
  .optional()
@@ -193,6 +229,7 @@ export const registerStoreProgressTool = (server: McpServer) => {
193
229
  failureReason,
194
230
  attachments,
195
231
  citations,
232
+ learning,
196
233
  persistMemory,
197
234
  force,
198
235
  },
@@ -332,6 +369,7 @@ export const registerStoreProgressTool = (server: McpServer) => {
332
369
  }
333
370
  }
334
371
 
372
+ let learningWrite: Promise<TaskLearningResult> | undefined;
335
373
  const result = await getDbClient().transaction(async () => {
336
374
  const agent = await getAgentById(requestInfo.agentId ?? "");
337
375
 
@@ -435,6 +473,29 @@ export const registerStoreProgressTool = (server: McpServer) => {
435
473
  await upsertTaskCitations(taskId, citations);
436
474
  }
437
475
 
476
+ // A finished task takes no new writes, learnings included.
477
+ if (learning && isTerminal) {
478
+ learningWrite = Promise.resolve({
479
+ status: "failed",
480
+ error: "the task is already finished; use memory-store",
481
+ });
482
+ } else if (learning) {
483
+ // Post-commit, so a rolled-back progress write never leaves a learning
484
+ // behind, and a failed learning write never rolls back the progress.
485
+ learningWrite = new Promise((resolve) => {
486
+ getDbClient().afterCommit(() =>
487
+ storeTaskLearning({
488
+ agentId: agent.id,
489
+ isLead: agent.isLead === true,
490
+ task: existingTask,
491
+ learning,
492
+ }).then(resolve, (err) =>
493
+ resolve({ status: "failed", error: scrubSecrets(String(err)) }),
494
+ ),
495
+ );
496
+ });
497
+ }
498
+
438
499
  // Idempotency guard: short-circuit terminal-status writes (completed/failed)
439
500
  // BEFORE any side-effects fire (event emission, memory write, follow-up task,
440
501
  // business-use ensure). Without this, a multi-session race causes duplicate
@@ -627,7 +688,11 @@ export const registerStoreProgressTool = (server: McpServer) => {
627
688
  });
628
689
  }
629
690
 
630
- const { success, message } = result;
691
+ const learningResult = await learningWrite;
692
+ const { success } = result;
693
+ const message = learningResult
694
+ ? `${result.message} ${learningSentence(learningResult)}`
695
+ : result.message;
631
696
  const task = result.task
632
697
  ? {
633
698
  id: result.task.id,
@@ -646,6 +711,7 @@ export const registerStoreProgressTool = (server: McpServer) => {
646
711
  typeof result.blockedWaitingElapsedMs === "number"
647
712
  ? { blockedWaitingElapsedMs: result.blockedWaitingElapsedMs }
648
713
  : {}),
714
+ ...(learningResult ? { learning: learningResult } : {}),
649
715
  };
650
716
  const warnings =
651
717
  success && status === "completed"
@@ -2,10 +2,42 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { resolveTaskAuditUserId } from "@/be/audit-user";
4
4
  import { getWorkflow, getWorkflowRun } from "@/be/db";
5
+ import {
6
+ findAcceptedWorkflowOperation,
7
+ IdempotencyKeyTakenError,
8
+ recordAcceptedWorkflowOperation,
9
+ type WorkflowIdempotencyClaim,
10
+ workflowRequestFingerprint,
11
+ } from "@/be/db/workflow-idempotency";
12
+ import { IDEMPOTENCY_KEY_PATTERN } from "@/http/idempotency-key";
5
13
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
6
14
  import { getExecutorRegistry, startWorkflowExecution } from "@/workflows";
7
15
  import { TriggerSchemaError } from "@/workflows/engine";
8
16
 
17
+ /**
18
+ * Answer from the run already accepted under this key, or null when there is
19
+ * none. Same record and rules as the HTTP trigger's `Idempotency-Key`.
20
+ */
21
+ async function replayAcceptedTrigger(claim: WorkflowIdempotencyClaim) {
22
+ const accepted = await findAcceptedWorkflowOperation(claim);
23
+ if (!accepted) return null;
24
+ if (accepted.fingerprint !== claim.fingerprint) {
25
+ return toolErr("idempotencyKey was already used to trigger this workflow with other data.", {
26
+ data: { idempotencyConflict: true },
27
+ });
28
+ }
29
+ if (accepted.resourceDeletedAt !== null) {
30
+ return toolErr(
31
+ "The workflow this idempotencyKey triggered was deleted; send a new key to start over.",
32
+ );
33
+ }
34
+ const { runId, skipped } = accepted.body as { runId: string; skipped: boolean };
35
+ return toolOk("Run already accepted under this idempotencyKey; nothing new was started.", {
36
+ details: `Replayed the run accepted under this idempotencyKey — run ID: ${runId}.`,
37
+ data: { runId, skipped, replayed: true },
38
+ });
39
+ }
40
+
9
41
  export const registerTriggerWorkflowTool = (server: McpServer) => {
10
42
  createToolRegistrar(server)(
11
43
  "trigger-workflow",
@@ -21,16 +53,55 @@ export const registerTriggerWorkflowTool = (server: McpServer) => {
21
53
  .record(z.string(), z.unknown())
22
54
  .optional()
23
55
  .describe("Optional data to pass as trigger context to the workflow"),
56
+ wait: z
57
+ .boolean()
58
+ .optional()
59
+ .describe(
60
+ "Default true: return after the run's instant steps ran, when it finishes or parks at its first async step. " +
61
+ "false: return as soon as the run is created and walk it in the background. Use false from a script or step that only needs the runId.",
62
+ ),
63
+ idempotencyKey: z
64
+ .string()
65
+ .regex(IDEMPOTENCY_KEY_PATTERN)
66
+ .optional()
67
+ .describe(
68
+ "Optional, 1-255 visible ASCII characters, scoped to the calling agent and this workflow. " +
69
+ "The first trigger accepted under a key starts one run; the key is recorded in the run's own transaction and never expires. " +
70
+ "A retry with the same key and triggerData starts nothing and returns that run (replayed: true), even if the earlier call was killed before it saw the answer. " +
71
+ "The same key with other triggerData is an error with idempotencyConflict: true.",
72
+ ),
24
73
  }),
25
74
  outputSchema: swarmToolOutputSchema({
26
75
  runId: z.string().optional(),
27
76
  skipped: z.boolean().optional(),
77
+ replayed: z.boolean().optional(),
78
+ idempotencyConflict: z.boolean().optional(),
28
79
  validationErrors: z.array(z.string()).optional(),
29
80
  triggerSchema: z.record(z.string(), z.unknown()).optional(),
30
81
  }),
31
82
  },
32
- async ({ id, triggerData }, requestInfo) => {
83
+ async ({ id, triggerData, wait, idempotencyKey }, requestInfo) => {
33
84
  try {
85
+ if (idempotencyKey !== undefined && !requestInfo.agentId) {
86
+ return toolErr("idempotencyKey needs an identified caller (X-Agent-ID).");
87
+ }
88
+ const claim: WorkflowIdempotencyClaim | undefined =
89
+ idempotencyKey === undefined
90
+ ? undefined
91
+ : {
92
+ principal: `agent:${requestInfo.agentId}`,
93
+ key: idempotencyKey,
94
+ operation: "workflow.trigger",
95
+ targetId: id,
96
+ fingerprint: workflowRequestFingerprint("workflow.trigger", triggerData ?? {}),
97
+ };
98
+ if (claim) {
99
+ // Before the enabled, cooldown and trigger-schema checks: an
100
+ // accepted trigger replays even after the workflow changed.
101
+ const replayed = await replayAcceptedTrigger(claim);
102
+ if (replayed) return replayed;
103
+ }
104
+
34
105
  const workflow = await getWorkflow(id);
35
106
  if (!workflow) {
36
107
  return toolErr(`Workflow not found: ${id}`);
@@ -38,21 +109,44 @@ export const registerTriggerWorkflowTool = (server: McpServer) => {
38
109
  if (!workflow.enabled) {
39
110
  return toolErr(`Workflow "${workflow.name}" is disabled.`);
40
111
  }
41
- const runId = await startWorkflowExecution(
42
- workflow,
43
- triggerData ?? {},
44
- getExecutorRegistry(),
45
- {
112
+ let accepted: { runId: string; skipped: boolean } | undefined;
113
+ let runId: string;
114
+ try {
115
+ runId = await startWorkflowExecution(workflow, triggerData ?? {}, getExecutorRegistry(), {
46
116
  triggerType: "manual",
47
117
  requestedByUserId:
48
118
  (await resolveTaskAuditUserId(requestInfo.sourceTaskId, requestInfo.agentId)) ??
49
119
  undefined,
50
- },
51
- );
120
+ detach: wait === false,
121
+ // Recorded in the run's own transaction: the key exists if and
122
+ // only if the run committed.
123
+ accept: claim
124
+ ? async (run) => {
125
+ const response = { runId: run.runId, skipped: run.skipped };
126
+ await recordAcceptedWorkflowOperation(claim, {
127
+ workflowId: workflow.id,
128
+ runId: run.runId,
129
+ status: 201,
130
+ body: response,
131
+ });
132
+ accepted = response;
133
+ }
134
+ : undefined,
135
+ });
136
+ } catch (err) {
137
+ // A concurrent call with this key committed its run first; this one
138
+ // created none.
139
+ if (claim && err instanceof IdempotencyKeyTakenError) {
140
+ const replayed = await replayAcceptedTrigger(claim);
141
+ if (replayed) return replayed;
142
+ }
143
+ throw err;
144
+ }
52
145
 
53
146
  // Check if the run was skipped due to cooldown
54
- const run = await getWorkflowRun(runId);
55
- const skipped = run?.status === "skipped";
147
+ const skipped = accepted
148
+ ? accepted.skipped
149
+ : (await getWorkflowRun(runId))?.status === "skipped";
56
150
 
57
151
  if (skipped) {
58
152
  return toolOk(`Workflow "${workflow.name}" skipped (cooldown).`, {
package/src/types.ts CHANGED
@@ -2282,7 +2282,14 @@ export const ExecutorMetaSchema = z
2282
2282
  inputCtx: z.record(z.string(), z.unknown()).optional(),
2283
2283
  })
2284
2284
  .openapi("ExecutorMeta");
2285
- export type ExecutorMeta = z.infer<typeof ExecutorMetaSchema>;
2285
+ export type ExecutorMeta = z.infer<typeof ExecutorMetaSchema> & {
2286
+ /**
2287
+ * Aborted when the step's watchdog budget runs out (`startStepDeadline`).
2288
+ * Runtime-only, never serialized. An executor with a long or one-shot side
2289
+ * effect should forward it so a timed-out step stops instead of finishing.
2290
+ */
2291
+ signal?: AbortSignal;
2292
+ };
2286
2293
 
2287
2294
  // --- Validation ---
2288
2295
 
@@ -3203,6 +3210,9 @@ export type ScriptScope = z.infer<typeof ScriptScopeSchema>;
3203
3210
  export const ScriptFsModeSchema = z.enum(["none", "workspace-rw"]);
3204
3211
  export type ScriptFsMode = z.infer<typeof ScriptFsModeSchema>;
3205
3212
 
3213
+ export const ScriptExecutorNameSchema = z.enum(["native", "quickjs"]);
3214
+ export type ScriptExecutorName = z.infer<typeof ScriptExecutorNameSchema>;
3215
+
3206
3216
  export const ScriptRecordSchema = z
3207
3217
  .object({
3208
3218
  id: z.string(),
@@ -3219,6 +3229,7 @@ export const ScriptRecordSchema = z
3219
3229
  isScratch: z.boolean(),
3220
3230
  typeChecked: z.boolean(),
3221
3231
  fsMode: ScriptFsModeSchema,
3232
+ executor: ScriptExecutorNameSchema.nullable().default(null),
3222
3233
  createdByAgentId: z.string().nullable(),
3223
3234
  createdAt: z.string(),
3224
3235
  updatedAt: z.string(),
@@ -406,10 +406,13 @@ async function fetchAvailableIndices(
406
406
  for (const envVar of poolVars) {
407
407
  const val = env[envVar];
408
408
  if (val) {
409
- const totalKeys = val.includes(",") ? val.split(",").filter((s) => s.trim()).length : 1;
409
+ // Status is matched by key suffix, so a key at the same index in another
410
+ // agent's or repo's pool never blocks one of ours (issue #2425).
411
+ const keySuffixes = splitCredentialPool(val).map((key) => key.slice(-5));
412
+ const totalKeys = keySuffixes.length;
410
413
  try {
411
414
  const resp = await fetch(
412
- `${apiUrl}/api/keys/available?keyType=${encodeURIComponent(envVar)}&totalKeys=${totalKeys}${modelParam}`,
415
+ `${apiUrl}/api/keys/available?keyType=${encodeURIComponent(envVar)}&totalKeys=${totalKeys}${modelParam}&keySuffixes=${encodeURIComponent(keySuffixes.join(","))}`,
413
416
  { headers: { Authorization: `Bearer ${apiKey}` } },
414
417
  );
415
418
  if (resp.ok) {
@@ -51,7 +51,7 @@ import { FOREACH_TERMINAL_STEP_STATUSES, resolveForeachParent } from "./foreach-
51
51
  import { getSecretInputKeys, redactSecretsForStorage, resolveInputs } from "./input";
52
52
  import { validateJsonSchema } from "./json-schema-validator";
53
53
  import { getMaxWorkflowStepsPerRun } from "./limits";
54
- import { resolveNodeTimeoutMs } from "./node-timeout";
54
+ import { resolveNodeTimeoutMs, startStepDeadline } from "./node-timeout";
55
55
  import { findWorkflowReadinessProblems, formatReadinessRunError } from "./readiness";
56
56
  import { deepInterpolate } from "./template";
57
57
  import { runStepValidation, type ValidationRunResult } from "./validation";
@@ -78,6 +78,13 @@ export interface WorkflowExecutionOptions {
78
78
  * HTTP trigger records its `Idempotency-Key` here.
79
79
  */
80
80
  accept?: (run: { runId: string; skipped: boolean }) => Promise<void>;
81
+ /**
82
+ * Return once the run is committed and walk it in the background, as a
83
+ * child run does. By default a top-level start resolves only after the
84
+ * walk reaches its end or its first async step, so a caller that only
85
+ * needs the run id waits for every instant step before it.
86
+ */
87
+ detach?: boolean;
81
88
  }
82
89
 
83
90
  /**
@@ -209,9 +216,12 @@ export async function startWorkflowExecution(
209
216
  options,
210
217
  );
211
218
  // A child run walks on its own, outside the parent executor's timeout. Its
212
- // terminal status wakes the parent step (`resumeFromChildRun`).
213
- if (options.parentStepId) {
214
- walk.catch((err) => console.error(`[workflows] Child workflow run ${runId} failed:`, err));
219
+ // terminal status wakes the parent step (`resumeFromChildRun`). A detached
220
+ // top-level run walks the same way; the walk holds the run before its
221
+ // first await, so releasing the start hold below leaves no gap.
222
+ if (options.parentStepId || options.detach) {
223
+ const kind = options.parentStepId ? "Child" : "Detached";
224
+ walk.catch((err) => console.error(`[workflows] ${kind} workflow run ${runId} failed:`, err));
215
225
  return runId;
216
226
  }
217
227
  await walk;
@@ -1045,7 +1055,7 @@ async function runClaimedStep(
1045
1055
  inputCtx: interpolationCtx,
1046
1056
  };
1047
1057
 
1048
- const timeoutMs = resolveNodeTimeoutMs(node);
1058
+ const deadline = startStepDeadline(resolveNodeTimeoutMs(node));
1049
1059
 
1050
1060
  let result: Awaited<ReturnType<typeof executor.run>>;
1051
1061
  try {
@@ -1053,10 +1063,10 @@ async function runClaimedStep(
1053
1063
  executor.run({
1054
1064
  config: interpolatedConfig,
1055
1065
  context: executionCtx,
1056
- meta,
1066
+ meta: { ...meta, signal: deadline.signal },
1057
1067
  }),
1058
- timeoutPromise(timeoutMs),
1059
- ]);
1068
+ deadline.expired,
1069
+ ]).finally(deadline.clear);
1060
1070
  } catch (err) {
1061
1071
  const errorMsg = err instanceof Error ? err.message : String(err);
1062
1072
 
@@ -1459,11 +1469,3 @@ export function scriptBodyInterpolationError(nodeId: string, tokens: string[]):
1459
1469
  "Pass dynamic trigger or upstream values through config.args and read them from argv."
1460
1470
  );
1461
1471
  }
1462
-
1463
- // ─── Helpers ───────────────────────────────────────────────
1464
-
1465
- function timeoutPromise(ms: number): Promise<never> {
1466
- return new Promise((_, reject) => {
1467
- setTimeout(() => reject(new Error(`Step timed out after ${ms}ms`)), ms);
1468
- });
1469
- }
@@ -123,6 +123,7 @@ export class SwarmScriptExecutor extends BaseExecutor<
123
123
  source: resolved.source,
124
124
  args: config.args,
125
125
  fsMode: "none",
126
+ executor: resolved.script.executor,
126
127
  agentId: agentId ?? "workflow",
127
128
  mcpBaseUrl: getMcpBaseUrl(),
128
129
  egressSecrets: credentials.egressSecrets,
@@ -132,6 +133,10 @@ export class SwarmScriptExecutor extends BaseExecutor<
132
133
  apiConnections: getScriptApiConnectionDescriptors({ agentId: agentId ?? undefined }),
133
134
  mcpConnections: getScriptMcpConnectionDescriptors({ agentId: agentId ?? undefined }),
134
135
  timeoutMs: config.timeoutMs,
136
+ // The step watchdog aborts this when the node budget runs out, which
137
+ // kills the script process: a step reported as timed out must not go
138
+ // on to perform the script's side effects.
139
+ signal: meta.signal,
135
140
  });
136
141
  if (output.exitCode === 0 && !output.error && !output.runtimeError) {
137
142
  await touchScratchScriptLastUsed(resolved.script.id);
@@ -47,7 +47,7 @@ import {
47
47
  settleForeachChild,
48
48
  } from "./foreach-join";
49
49
  import { getSecretInputKeys } from "./input";
50
- import { resolveNodeTimeoutMs } from "./node-timeout";
50
+ import { resolveNodeTimeoutMs, startStepDeadline } from "./node-timeout";
51
51
  import { rebuildNodeExecutionInput } from "./step-input";
52
52
 
53
53
  export { FOREACH_ITEMS_WAIT_FOR };
@@ -480,27 +480,21 @@ async function startChild(input: {
480
480
  }
481
481
 
482
482
  const executor = registry.get(bodyType);
483
- const timeoutMs = resolveNodeTimeoutMs({ type: bodyType, config });
484
- let timer: ReturnType<typeof setTimeout> | undefined;
483
+ const deadline = startStepDeadline(resolveNodeTimeoutMs({ type: bodyType, config }));
485
484
  let result: Awaited<ReturnType<typeof executor.run>>;
486
485
  try {
487
486
  result = await Promise.race([
488
487
  executor.run({
489
488
  config,
490
489
  context: { ...ctx, ...bodyCtx },
491
- meta: { ...meta, stepId: child.id, nodeId: child.nodeId },
492
- }),
493
- new Promise<never>((_, reject) => {
494
- timer = setTimeout(
495
- () => reject(new Error(`Step timed out after ${timeoutMs}ms`)),
496
- timeoutMs,
497
- );
490
+ meta: { ...meta, stepId: child.id, nodeId: child.nodeId, signal: deadline.signal },
498
491
  }),
492
+ deadline.expired,
499
493
  ]);
500
494
  } catch (err) {
501
495
  return { status: "failed", error: err instanceof Error ? err.message : String(err) };
502
496
  } finally {
503
- if (timer) clearTimeout(timer);
497
+ deadline.clear();
504
498
  }
505
499
 
506
500
  if (result.status === "failed") {
@@ -19,3 +19,33 @@ export function resolveNodeTimeoutMs(node: Pick<WorkflowNode, "type" | "config">
19
19
  if (node.type === "script" && typeof config.timeout === "number") return config.timeout;
20
20
  return DEFAULT_NODE_TIMEOUT_MS;
21
21
  }
22
+
23
+ export interface StepDeadline {
24
+ /** Aborted when the budget runs out. The executor gets it as `meta.signal`. */
25
+ signal: AbortSignal;
26
+ /** Rejects with "Step timed out after <ms>ms" when the budget runs out. */
27
+ expired: Promise<never>;
28
+ /** Stop the timer once the executor call settled. */
29
+ clear: () => void;
30
+ }
31
+
32
+ /**
33
+ * The watchdog for one executor call. Racing `expired` alone settles the step
34
+ * but leaves the executor running, so a step reported as timed out could still
35
+ * perform its side effects. On expiry the deadline also aborts `signal`, so an
36
+ * executor that forwards it (swarm-script kills its script process) stops.
37
+ */
38
+ export function startStepDeadline(timeoutMs: number): StepDeadline {
39
+ const controller = new AbortController();
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ const expired = new Promise<never>((_, reject) => {
42
+ timer = setTimeout(() => {
43
+ const error = new Error(`Step timed out after ${timeoutMs}ms`);
44
+ // Reject first so the race settles on the timeout, not on the
45
+ // executor's "killed" result that the abort produces.
46
+ reject(error);
47
+ controller.abort(error);
48
+ }, timeoutMs);
49
+ });
50
+ return { signal: controller.signal, expired, clear: () => clearTimeout(timer) };
51
+ }