@desplega.ai/agent-swarm 1.157.0 → 1.158.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 (256) hide show
  1. package/dist/{acp-adapter-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
  29. package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +29 -15
  47. package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
  59. package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +2 -1
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/modelsdev-reasoning.json +551 -0
  149. package/src/rbac/legacy-policy.ts +16 -0
  150. package/src/rbac/permissions.ts +4 -0
  151. package/src/scheduler/schedule-task.ts +3 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  154. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  155. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  156. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  157. package/src/server.ts +12 -7
  158. package/src/slack/app.ts +35 -4
  159. package/src/slack/connection-state.ts +17 -0
  160. package/src/tasks/context-key.ts +1 -0
  161. package/src/tasks/worker-follow-up.ts +6 -2
  162. package/src/tests/agent-task-activity.test.ts +74 -0
  163. package/src/tests/agents-list-model-display.test.ts +6 -1
  164. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  165. package/src/tests/approval-requests.test.ts +395 -0
  166. package/src/tests/approval-sweeps-config.test.ts +47 -0
  167. package/src/tests/approval-sweeps.test.ts +508 -0
  168. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  169. package/src/tests/apps-spike.test.ts +56 -1
  170. package/src/tests/apps-sync-engine.test.ts +20 -9
  171. package/src/tests/boot-seeds.test.ts +170 -0
  172. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  173. package/src/tests/claude-managed-adapter.test.ts +6 -0
  174. package/src/tests/config-key-filter.test.ts +80 -0
  175. package/src/tests/context-window.test.ts +1 -0
  176. package/src/tests/db-client-busy-retry.test.ts +96 -0
  177. package/src/tests/db-client.test.ts +60 -1
  178. package/src/tests/db-task-read-contract.test.ts +10 -0
  179. package/src/tests/db-task-write-contract.test.ts +9 -0
  180. package/src/tests/docs-index-footprint.test.js +3 -3
  181. package/src/tests/events-http.test.ts +103 -0
  182. package/src/tests/extensions-catalog.test.ts +22 -17
  183. package/src/tests/extensions-pre-task.test.ts +107 -0
  184. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  185. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  186. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  187. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  188. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  189. package/src/tests/heartbeat.test.ts +99 -3
  190. package/src/tests/hook-mcp-config.test.ts +29 -0
  191. package/src/tests/http-api-integration.test.ts +16 -2
  192. package/src/tests/kv-storage.test.ts +62 -0
  193. package/src/tests/linear-outbound-sync.test.ts +120 -5
  194. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  195. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  196. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  197. package/src/tests/memory-hybrid.test.ts +161 -1
  198. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  199. package/src/tests/memory.test.ts +22 -0
  200. package/src/tests/onboard-container-engine.test.ts +240 -0
  201. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  202. package/src/tests/pricing-routes.test.ts +14 -0
  203. package/src/tests/profile-sync-warning.test.ts +8 -4
  204. package/src/tests/promote-draft-task-route.test.ts +19 -0
  205. package/src/tests/provider-registration.test.ts +296 -42
  206. package/src/tests/rbac-engine.test.ts +30 -0
  207. package/src/tests/reasoning-effort.test.ts +8 -0
  208. package/src/tests/script-connections.test.ts +7 -0
  209. package/src/tests/sdk-allowlist.test.ts +39 -0
  210. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  211. package/src/tests/sessions-detail-batch.test.ts +250 -0
  212. package/src/tests/sessions.test.ts +118 -0
  213. package/src/tests/slack-config.test.ts +22 -0
  214. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  215. package/src/tests/status.test.ts +36 -1
  216. package/src/tests/swarm-diff.test.ts +316 -10
  217. package/src/tests/task-completion-idempotency.test.ts +2 -1
  218. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  219. package/src/tests/tool-annotations.test.ts +1 -1
  220. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  221. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  222. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  223. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  224. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  225. package/src/tests/workflow-http-v2.test.ts +99 -8
  226. package/src/tests/workflow-retry-validation.test.ts +56 -0
  227. package/src/tests/workflow-tla-races.test.ts +389 -0
  228. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  229. package/src/tools/cancel-approval-request.ts +57 -0
  230. package/src/tools/memory-search.ts +1 -0
  231. package/src/tools/request-human-input.ts +4 -2
  232. package/src/tools/send-task.ts +4 -0
  233. package/src/tools/templates.ts +19 -0
  234. package/src/tools/tool-config.ts +2 -1
  235. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  236. package/src/types.ts +38 -0
  237. package/src/utils/context-window.ts +1 -0
  238. package/src/workflows/approval-cancel.ts +152 -0
  239. package/src/workflows/approval-notifications.ts +84 -0
  240. package/src/workflows/checkpoint.ts +10 -5
  241. package/src/workflows/definition.ts +21 -0
  242. package/src/workflows/engine.ts +152 -21
  243. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  244. package/src/workflows/recovery.ts +100 -45
  245. package/src/workflows/resume.ts +49 -18
  246. package/src/workflows/retry-poller.ts +71 -21
  247. package/templates/extensions/README.md +1 -0
  248. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  249. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  250. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  251. package/templates/skills/apps/content.md +20 -1
  252. package/templates/skills/code-quality/content.md +26 -1
  253. package/templates/skills/pages/content.md +16 -4
  254. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  255. package/dist/cli-2daksqps.js +0 -378
  256. package/dist/rbac-roles-3gxmnb20.js +0 -37
package/src/types.ts CHANGED
@@ -770,6 +770,15 @@ export const CreateTaskOptionsSchema = z.object({
770
770
  * Lead-only control-plane authorization.
771
771
  */
772
772
  inheritParentRoutingAffinity: z.boolean().optional(),
773
+ /**
774
+ * `followUpConfig` belongs to one piece of work, not to the thread, so a
775
+ * child does NOT inherit the parent's by default. Set this to `true` only on
776
+ * a continuation of the SAME work (interrupted-task resume, reboot-sweep
777
+ * retry, defer-task wake-up, a Lead `resume` re-delegation) so the creator's
778
+ * onCompleted/onFailed still fires when that work truly ends. An explicit
779
+ * `followUpConfig` always wins.
780
+ */
781
+ inheritParentFollowUpConfig: z.boolean().optional(),
773
782
  followUpConfig: FollowUpConfigSchema.optional(),
774
783
  requestedByUserId: z.string().optional(),
775
784
  contextKey: z.string().optional(),
@@ -1425,6 +1434,8 @@ export const AgentLogEventTypeSchema = z.enum([
1425
1434
  "task_dispatch_rejected_affinity",
1426
1435
  "task_authorization_rejected",
1427
1436
  "task_recovery_authorization",
1437
+ // Reboot sweep moved a never-started dependent from the swept task to its retry
1438
+ "task_dependency_repointed",
1428
1439
  "task_released",
1429
1440
  // A settled task's settlement fired a deferred wait (metadata names the waiter)
1430
1441
  "task_deferred_wait_woke",
@@ -2412,6 +2423,26 @@ export type AgentTaskSummary = Pick<
2412
2423
  | "totalCostUsd"
2413
2424
  >;
2414
2425
 
2426
+ /**
2427
+ * `/api/tasks?fields=timeline` list item: only what the dashboard activity
2428
+ * timeline draws (lane, bar extent, label, hover stats). Same bounded `task`
2429
+ * preview as `AgentTaskSummary`.
2430
+ */
2431
+ export type AgentTaskTimelineItem = Pick<
2432
+ AgentTask,
2433
+ | "id"
2434
+ | "agentId"
2435
+ | "parentTaskId"
2436
+ | "task"
2437
+ | "title"
2438
+ | "status"
2439
+ | "createdAt"
2440
+ | "lastUpdatedAt"
2441
+ | "finishedAt"
2442
+ | "peakContextTokens"
2443
+ | "totalCostUsd"
2444
+ >;
2445
+
2415
2446
  export const PageVersionSchema = z
2416
2447
  .object({
2417
2448
  id: z.string(),
@@ -2594,6 +2625,12 @@ export const WorkflowRunSchema = z
2594
2625
  .openapi("WorkflowRun");
2595
2626
  export type WorkflowRun = z.infer<typeof WorkflowRunSchema>;
2596
2627
 
2628
+ /** List row of a run: no `context`, which `GET /api/workflow-runs/{id}` serves. */
2629
+ export const WorkflowRunSummarySchema = WorkflowRunSchema.omit({ context: true }).openapi(
2630
+ "WorkflowRunSummary",
2631
+ );
2632
+ export type WorkflowRunSummary = z.infer<typeof WorkflowRunSummarySchema>;
2633
+
2597
2634
  // --- Script Workflow Runs ---
2598
2635
 
2599
2636
  export const ScriptRunStatusSchema = z.enum([
@@ -3321,6 +3358,7 @@ export const PricingProviderSchema = z.enum([
3321
3358
  // adapter reports `totalCostUsd: 0`, so these rows settle at
3322
3359
  // `costSource: 'unpriced'`. Accepted here so the row is recorded at all.
3323
3360
  "acp",
3361
+ "dsh",
3324
3362
  ]);
3325
3363
  export type PricingProvider = z.infer<typeof PricingProviderSchema>;
3326
3364
 
@@ -33,6 +33,7 @@ const CONTEXT_WINDOW_DEFAULTS: Record<string, number> = {
33
33
  "claude-opus-5": 1_000_000,
34
34
  "claude-fable-5": 1_000_000,
35
35
  "claude-mythos-5": 1_000_000,
36
+ "claude-sonnet-5-5": 1_000_000,
36
37
  "claude-sonnet-5": 1_000_000,
37
38
  // Anthropic 4.x family
38
39
  "claude-opus-4-8": 1_000_000,
@@ -0,0 +1,152 @@
1
+ import {
2
+ type ApprovalRequest,
3
+ cancelApprovalRequestById,
4
+ getApprovalRequestById,
5
+ getDbClient,
6
+ getTaskById,
7
+ getWorkflowRun,
8
+ listCancelledApprovalRequestsForRun,
9
+ resolveApprovalRequest,
10
+ } from "../be/db";
11
+ import { can, type RbacPrincipal } from "../rbac";
12
+ import {
13
+ createApprovalFollowUpTask,
14
+ postApprovalCancellationUpdates,
15
+ } from "./approval-notifications";
16
+ import { cancelWorkflowRunRows } from "./resume";
17
+
18
+ export type CancelApprovalRequestResult =
19
+ | { ok: true; request: ApprovalRequest; alreadyCancelled: boolean; runCancelled: boolean }
20
+ | { ok: false; status: 403 | 404 | 409; message: string };
21
+
22
+ const LIVE_RUN_STATUSES = new Set(["running", "waiting"]);
23
+
24
+ /**
25
+ * Cancel 1 pending approval request. Shared by the HTTP route, the MCP tool,
26
+ * and the dashboard Discard button. A request that gates a live workflow run
27
+ * cancels that run too.
28
+ */
29
+ export async function cancelApprovalRequest(input: {
30
+ id: string;
31
+ reason?: string;
32
+ principal: RbacPrincipal;
33
+ resolvedBy: string | null;
34
+ source?: "http" | "mcp";
35
+ }): Promise<CancelApprovalRequestResult> {
36
+ const finalReason = input.reason ?? "Cancelled through the API";
37
+ let authorized = false;
38
+
39
+ // A null write means a concurrent change; re-read and apply the rules again.
40
+ for (let attempt = 0; attempt < 3; attempt++) {
41
+ const existing = await getApprovalRequestById(input.id);
42
+ if (!existing) return { ok: false, status: 404, message: "Approval request not found" };
43
+ if (existing.status === "cancelled") {
44
+ return { ok: true, request: existing, alreadyCancelled: true, runCancelled: false };
45
+ }
46
+ if (existing.status !== "pending") {
47
+ return {
48
+ ok: false,
49
+ status: 409,
50
+ message: `Approval request already resolved with status: ${existing.status}`,
51
+ };
52
+ }
53
+
54
+ if (!authorized) {
55
+ const ownerAgentId = existing.sourceTaskId
56
+ ? ((await getTaskById(existing.sourceTaskId))?.agentId ?? null)
57
+ : null;
58
+ const decision = can({
59
+ principal: input.principal,
60
+ verb: "approval.cancel.any",
61
+ resource: { kind: "owned", ownerAgentId },
62
+ source: input.source ?? "http",
63
+ });
64
+ if (!decision.allow) return { ok: false, status: 403, message: decision.reason };
65
+ authorized = true;
66
+ }
67
+
68
+ const result = await cancelApprovalRequestAndRun(input.id, {
69
+ reason: finalReason,
70
+ resolvedBy: input.resolvedBy,
71
+ });
72
+ if (!result) continue;
73
+ if ("timedOut" in result) {
74
+ return {
75
+ ok: false,
76
+ status: 409,
77
+ message: `Approval request expired at ${result.timedOut.expiresAt}`,
78
+ };
79
+ }
80
+ return {
81
+ ok: true,
82
+ request: result.request,
83
+ alreadyCancelled: false,
84
+ runCancelled: result.runCancelled,
85
+ };
86
+ }
87
+
88
+ return { ok: false, status: 409, message: "Approval request changed during cancellation" };
89
+ }
90
+
91
+ /**
92
+ * Cancel 1 pending request and its live workflow run in 1 transaction: both
93
+ * commit, or neither does. The Slack thread updates run after COMMIT.
94
+ * Returns null when the request was not pending. A pending request whose
95
+ * explicit expiresAt passed becomes timeout instead, in the same transaction.
96
+ */
97
+ export async function cancelApprovalRequestAndRun(
98
+ id: string,
99
+ data: { reason: string; resolvedBy: string | null; slackReason?: string },
100
+ ): Promise<
101
+ { request: ApprovalRequest; runCancelled: boolean } | { timedOut: ApprovalRequest } | null
102
+ > {
103
+ const client = getDbClient();
104
+ return await client.transaction(async () => {
105
+ // An elapsed explicit deadline wins over the cancel. A workflow run then
106
+ // routes on its timeout port through getStuckApprovalRuns, as for a late answer.
107
+ const current = await getApprovalRequestById(id);
108
+ if (
109
+ current?.status === "pending" &&
110
+ current.expiresAt &&
111
+ new Date(current.expiresAt) < new Date()
112
+ ) {
113
+ const timedOut = await resolveApprovalRequest(id, {
114
+ status: "timeout",
115
+ resolutionReason: `Timed out: the cancellation arrived after the deadline ${current.expiresAt}`,
116
+ });
117
+ if (!timedOut) return null;
118
+ await createApprovalFollowUpTask(timedOut, "hitl.timeout");
119
+ return { timedOut };
120
+ }
121
+
122
+ const request = await cancelApprovalRequestById(id, {
123
+ reason: data.reason,
124
+ resolvedBy: data.resolvedBy,
125
+ });
126
+ if (!request) return null;
127
+
128
+ // The request is cancelled first so it keeps this reason; the run cancel
129
+ // then skips it because it is no longer pending.
130
+ let runCancelled = false;
131
+ const runId = request.workflowRunId;
132
+ if (runId) {
133
+ const run = await getWorkflowRun(runId);
134
+ if (run && LIVE_RUN_STATUSES.has(run.status)) {
135
+ runCancelled = await cancelWorkflowRunRows(runId, data.reason);
136
+ }
137
+ }
138
+
139
+ const slackReason = data.slackReason ?? data.reason;
140
+ client.afterCommit(async () => {
141
+ await postApprovalCancellationUpdates([request], slackReason);
142
+ if (runCancelled && runId) {
143
+ // Other requests the run cancel closed get the run's reason.
144
+ const others = (await listCancelledApprovalRequestsForRun(runId)).filter(
145
+ (row) => row.id !== request.id,
146
+ );
147
+ await postApprovalCancellationUpdates(others, data.reason);
148
+ }
149
+ });
150
+ return { request, runCancelled };
151
+ });
152
+ }
@@ -2,9 +2,13 @@ import {
2
2
  type ApprovalRequest,
3
3
  claimApprovalCancellationNotification,
4
4
  completeApprovalCancellationNotificationClaim,
5
+ createTaskExtended,
6
+ getTaskById,
5
7
  releaseApprovalCancellationNotificationClaim,
6
8
  } from "../be/db";
9
+ import { resolveTemplate } from "../prompts/resolver";
7
10
  import { getSlackApp } from "../slack/app";
11
+ import { TERMINAL_TASK_STATUSES } from "../types";
8
12
  import { scrubSecrets } from "../utils/secret-scrubber";
9
13
 
10
14
  export interface ApprovalSlackClient {
@@ -70,3 +74,83 @@ export async function postApprovalCancellationUpdates(
70
74
  }
71
75
  }
72
76
  }
77
+
78
+ /**
79
+ * Create the `hitl-follow-up` task that tells the requesting agent how a
80
+ * standalone approval request ended. Returns false when no task is created.
81
+ */
82
+ export async function createApprovalFollowUpTask(
83
+ request: ApprovalRequest,
84
+ templateEventType: "hitl.follow_up" | "hitl.timeout",
85
+ ): Promise<boolean> {
86
+ if (request.workflowRunId || !request.sourceTaskId) return false;
87
+ const sourceTask = await getTaskById(request.sourceTaskId);
88
+ if (!sourceTask) return false;
89
+ // Timeouts only: the first sweep after deploy would otherwise create one task per finished backlog row.
90
+ // A human answer still gets a follow-up after the source task finishes, as before.
91
+ if (
92
+ templateEventType === "hitl.timeout" &&
93
+ (TERMINAL_TASK_STATUSES as readonly string[]).includes(sourceTask.status)
94
+ ) {
95
+ return false;
96
+ }
97
+
98
+ const { text: taskText } =
99
+ templateEventType === "hitl.follow_up"
100
+ ? resolveTemplate("hitl.follow_up", {
101
+ request_id: request.id,
102
+ title: request.title,
103
+ status: request.status,
104
+ responses: formatResponses(
105
+ request.questions as Array<{ id: string; type: string; label: string }>,
106
+ (request.responses ?? {}) as Record<string, unknown>,
107
+ ),
108
+ })
109
+ : resolveTemplate("hitl.timeout", {
110
+ request_id: request.id,
111
+ title: request.title,
112
+ expires_at: request.expiresAt ?? "",
113
+ reason: request.resolutionReason ?? "",
114
+ });
115
+
116
+ await createTaskExtended(taskText, {
117
+ agentId: sourceTask.agentId,
118
+ routingReason: sourceTask.agentId ? "continuity" : undefined,
119
+ routingSource: sourceTask.agentId ? "engine_default" : undefined,
120
+ parentTaskId: request.sourceTaskId,
121
+ source: "system",
122
+ taskType: "hitl-follow-up",
123
+ tags: ["hitl", "follow-up"],
124
+ // Explicit Slack metadata — parentTaskId auto-inherits too,
125
+ // but being explicit ensures the follow-up task always gets
126
+ // the right thread context even if inheritance logic changes.
127
+ slackChannelId: sourceTask.slackChannelId ?? undefined,
128
+ slackThreadTs: sourceTask.slackThreadTs ?? undefined,
129
+ slackUserId: sourceTask.slackUserId ?? undefined,
130
+ });
131
+ return true;
132
+ }
133
+
134
+ function formatResponses(
135
+ questions: Array<{ id: string; type: string; label: string }>,
136
+ responses: Record<string, unknown>,
137
+ ): string {
138
+ return questions
139
+ .map((q) => {
140
+ const answer = responses[q.id];
141
+ let answerText: string;
142
+ if (answer == null) {
143
+ answerText = "(no answer)";
144
+ } else if (q.type === "approval") {
145
+ const a = answer as { approved?: boolean; comment?: string };
146
+ answerText = a.approved ? "Approved" : "Rejected";
147
+ if (a.comment) answerText += ` — ${a.comment}`;
148
+ } else if (typeof answer === "object") {
149
+ answerText = JSON.stringify(answer);
150
+ } else {
151
+ answerText = String(answer);
152
+ }
153
+ return `- ${q.label}: ${answerText}`;
154
+ })
155
+ .join("\n");
156
+ }
@@ -1,4 +1,4 @@
1
- import { getDbClient, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
1
+ import { getDbClient, getWorkflowRun, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
2
2
  import type { RetryPolicy } from "../types";
3
3
 
4
4
  /**
@@ -66,10 +66,15 @@ export async function checkpointStepFailure(
66
66
 
67
67
  const markRunFailed = options?.markRunFailed ?? true;
68
68
  if (markRunFailed) {
69
- await updateWorkflowRun(runId, {
70
- status: "failed",
71
- error: `Step failed: ${error}`,
72
- finishedAt: now,
69
+ // A cancel or finalization that committed while the step ran wins.
70
+ await getDbClient().transaction(async () => {
71
+ const run = await getWorkflowRun(runId);
72
+ if (run?.status !== "running" && run?.status !== "waiting") return;
73
+ await updateWorkflowRun(runId, {
74
+ status: "failed",
75
+ error: `Step failed: ${error}`,
76
+ finishedAt: now,
77
+ });
73
78
  });
74
79
  }
75
80
 
@@ -65,6 +65,27 @@ export function findEntryNodes(def: WorkflowDefinition): WorkflowNode[] {
65
65
  return def.nodes.filter((n) => !targets.has(n.id));
66
66
  }
67
67
 
68
+ /**
69
+ * Pick the port a node routes on once validation has produced pass/fail.
70
+ *
71
+ * Validation decides when the node uses record-based `next` and the executor
72
+ * either chose no port or chose one that `next` does not declare. `script` and
73
+ * `swarm-script` always emit "success", so a `next: { pass, fail }` record would
74
+ * otherwise match nothing and the branch would end silently. An executor port
75
+ * that IS a key of `next` (property-match "true"/"false", a script node wired
76
+ * as `next: { success }`) stays authoritative.
77
+ */
78
+ export function resolveValidationPort(
79
+ next: WorkflowNode["next"],
80
+ executorPort: string | undefined,
81
+ validationPassed: boolean | undefined,
82
+ ): string | undefined {
83
+ if (validationPassed === undefined) return executorPort;
84
+ if (!next || typeof next !== "object" || Array.isArray(next)) return executorPort;
85
+ if (executorPort && Object.hasOwn(next, executorPort)) return executorPort;
86
+ return validationPassed ? "pass" : "fail";
87
+ }
88
+
68
89
  /**
69
90
  * Get successor node IDs for a given node and port.
70
91
  */
@@ -10,6 +10,7 @@ import {
10
10
  createWorkflowRun,
11
11
  createWorkflowRunStep,
12
12
  getCompletedStepNodeIds,
13
+ getCurrentStepForNode,
13
14
  getDbClient,
14
15
  getLatestStepForNode,
15
16
  getStepByIdempotencyKey,
@@ -24,7 +25,7 @@ import type { Workflow, WorkflowDefinition, WorkflowNode, WorkflowRunStep } from
24
25
  import { checkpointStep, checkpointStepFailure, checkpointStepWaiting } from "./checkpoint";
25
26
  import { loadCompletedStepRouting } from "./completed-step-routing";
26
27
  import { shouldSkipCooldown } from "./cooldown";
27
- import { findEntryNodes, getNextTargets, getSuccessors } from "./definition";
28
+ import { findEntryNodes, getNextTargets, getSuccessors, resolveValidationPort } from "./definition";
28
29
  import type { AsyncExecutorResult } from "./executors/base";
29
30
  import type { ExecutorRegistry } from "./executors/registry";
30
31
  import { FOREACH_TERMINAL_STEP_STATUSES, resolveForeachParent } from "./foreach-join";
@@ -202,10 +203,32 @@ interface StepResult {
202
203
  // settles, including checkpointing and routing between executor calls.
203
204
  const activeWalks = new Map<string, number>();
204
205
 
206
+ // Step rows an executeStep call in this process inserted and has not finished
207
+ // yet. Process-local like activeWalks: a crash clears it, so a `running` row
208
+ // left behind is not mistaken for a live execution.
209
+ const executingSteps = new Set<string>();
210
+
205
211
  export function isWorkflowRunActive(runId: string): boolean {
206
212
  return activeWalks.has(runId);
207
213
  }
208
214
 
215
+ /**
216
+ * Mark the run as owned by this process until the returned release runs.
217
+ * Recovery skips owned runs, so an executor outside walkGraph (the retry
218
+ * poller) holds the run for as long as it executes a step.
219
+ */
220
+ export function holdWorkflowRun(runId: string): () => void {
221
+ activeWalks.set(runId, (activeWalks.get(runId) ?? 0) + 1);
222
+ let released = false;
223
+ return () => {
224
+ if (released) return;
225
+ released = true;
226
+ const remaining = activeWalks.get(runId)! - 1;
227
+ if (remaining === 0) activeWalks.delete(runId);
228
+ else activeWalks.set(runId, remaining);
229
+ };
230
+ }
231
+
209
232
  /**
210
233
  * Event-loop style graph walker.
211
234
  *
@@ -223,13 +246,11 @@ export async function walkGraph(
223
246
  secretKeys: Set<string> = new Set(),
224
247
  options: WorkflowExecutionOptions = {},
225
248
  ): Promise<void> {
226
- activeWalks.set(runId, (activeWalks.get(runId) ?? 0) + 1);
249
+ const release = holdWorkflowRun(runId);
227
250
  try {
228
251
  await walkGraphOwned(def, runId, ctx, startNodes, registry, workflowId, secretKeys, options);
229
252
  } finally {
230
- const remaining = activeWalks.get(runId)! - 1;
231
- if (remaining === 0) activeWalks.delete(runId);
232
- else activeWalks.set(runId, remaining);
253
+ release();
233
254
  }
234
255
  }
235
256
 
@@ -293,6 +314,8 @@ async function walkGraphOwned(
293
314
  }
294
315
  }
295
316
 
317
+ const awaited = awaitedNodeIds(allSteps, activeEdges);
318
+
296
319
  // Seed with start nodes whose predecessors are all completed (convergence gate).
297
320
  // For entry nodes (no predecessors), skip if already completed — these are
298
321
  // re-walk/recovery scenarios where memoization should apply.
@@ -306,7 +329,9 @@ async function walkGraphOwned(
306
329
  }
307
330
  // Non-entry node — allow through even if completed (loop target).
308
331
  // Check predecessors are ready.
309
- const activePreds = preds.filter((predId) => activeEdges.has(`${predId}→${n.id}`));
332
+ const activePreds = preds.filter(
333
+ (predId) => activeEdges.has(`${predId}→${n.id}`) || awaited.has(predId),
334
+ );
310
335
  // If no active edges yet (first walk), check ALL structural predecessors
311
336
  const predsToCheck = activePreds.length > 0 ? activePreds : preds;
312
337
  return predsToCheck.every((p) => completedNodeIds.has(p));
@@ -376,12 +401,27 @@ async function walkGraphOwned(
376
401
  // Use executedInThisWalk (not completedNodeIds) to gate dedup — this
377
402
  // allows loop targets from prior walks to re-execute while preventing
378
403
  // double execution within the same walk.
404
+ // A sibling branch another walker is executing has no edge in this walk's
405
+ // activeEdges, so re-read the steps: a live predecessor holds the join,
406
+ // and one that completed elsewhere is rehydrated for the join's inputs.
379
407
  const readyNext: WorkflowNode[] = [];
408
+ const batchSteps = nextBatch.size > 0 ? await getWorkflowRunStepsByRunId(runId) : [];
409
+ const batchAwaited = awaitedNodeIds(batchSteps, activeEdges);
410
+ const batchLatest = latestStepByNode(batchSteps);
380
411
  for (const [nodeId, node] of nextBatch) {
381
412
  if (executedInThisWalk.has(nodeId)) continue; // Already done in this walk
382
413
 
383
414
  const allPreds = getAllPredecessors(def, nodeId);
384
- const activePreds = allPreds.filter((predId) => activeEdges.has(`${predId}→${nodeId}`));
415
+ for (const predId of allPreds) {
416
+ if (completedNodeIds.has(predId)) continue;
417
+ const latest = batchLatest.get(predId);
418
+ if (latest?.status !== "completed") continue;
419
+ completedNodeIds.add(predId);
420
+ if (latest.output !== undefined) ctx[predId] = latest.output;
421
+ }
422
+ const activePreds = allPreds.filter(
423
+ (predId) => activeEdges.has(`${predId}→${nodeId}`) || batchAwaited.has(predId),
424
+ );
385
425
  const allActivePredsCompleted = activePreds.every((p) => completedNodeIds.has(p));
386
426
 
387
427
  if (allActivePredsCompleted) {
@@ -405,6 +445,8 @@ async function walkGraphOwned(
405
445
  if (!run || run.status !== "running") return;
406
446
 
407
447
  const finalSteps = await getWorkflowRunStepsByRunId(runId);
448
+ // Another walker is still executing a branch; it finalizes the run.
449
+ if (hasRunningStep(finalSteps)) return;
408
450
  const hasWaitingSteps = finalSteps.some((s) => s.status === "waiting");
409
451
  const hasPendingRetries = finalSteps.some(
410
452
  (s) => s.status === "failed" && s.nextRetryAt != null,
@@ -453,6 +495,42 @@ async function walkGraphOwned(
453
495
  });
454
496
  }
455
497
 
498
+ /** Each node's latest step. Steps arrive in insert order (startedAt ASC). */
499
+ function latestStepByNode(steps: WorkflowRunStep[]): Map<string, WorkflowRunStep> {
500
+ const latest = new Map<string, WorkflowRunStep>();
501
+ for (const step of steps) latest.set(step.nodeId, step);
502
+ return latest;
503
+ }
504
+
505
+ /**
506
+ * Nodes a join must wait for even without an active edge to it: the node's
507
+ * latest step is running or waiting, or a predecessor routed to it and it has
508
+ * no step yet. A terminally failed node is not awaited, so partial failure
509
+ * still lets the join run. An older `running` row superseded by a newer one
510
+ * (crash recovery) and the `pending` row a user retry leaves behind are
511
+ * ignored because only the latest step counts.
512
+ */
513
+ function awaitedNodeIds(steps: WorkflowRunStep[], activeEdges: Set<string>): Set<string> {
514
+ const latest = latestStepByNode(steps);
515
+ const awaited = new Set<string>();
516
+ for (const [nodeId, step] of latest) {
517
+ if (step.status === "running" || step.status === "waiting") awaited.add(nodeId);
518
+ }
519
+ for (const edge of activeEdges) {
520
+ const target = edge.slice(edge.indexOf("→") + 1);
521
+ if (!latest.has(target)) awaited.add(target);
522
+ }
523
+ return awaited;
524
+ }
525
+
526
+ /** True while some node's latest step is still executing. */
527
+ export function hasRunningStep(steps: WorkflowRunStep[]): boolean {
528
+ for (const step of latestStepByNode(steps).values()) {
529
+ if (step.status === "running") return true;
530
+ }
531
+ return false;
532
+ }
533
+
456
534
  /**
457
535
  * Restore completed node outputs from their step checkpoints into workflow context.
458
536
  * Every recovery path must do this before resolving a node's declared inputs.
@@ -541,12 +619,28 @@ async function executeStep(
541
619
  // rides the INSERT itself — so the UNIQUE(idempotencyKey) index arbitrates
542
620
  // concurrent executions of the same node instead of an orphan step row
543
621
  // committing before a follow-up key UPDATE throws.
544
- const dedup = await getDbClient().transaction(async () => {
622
+ let claimedStepId: string | undefined;
623
+ const claimStep = async () => {
545
624
  const run = await getWorkflowRun(runId);
546
625
  if (!run || (run.status !== "running" && run.status !== "waiting")) {
547
626
  return { halted: true as const };
548
627
  }
549
628
 
629
+ // Another walker already started or finished this node for the same
630
+ // predecessor completions (two branch completions both walking the join,
631
+ // or a user retry reaching a branch the live walk is running). The
632
+ // row-count key below is always new, so only this read, inside the insert
633
+ // transaction, stops a second execution. Foreach re-enters its own
634
+ // non-terminal row below and keeps that path.
635
+ if (node.type !== "foreach") {
636
+ const current = await getCurrentStepForNode(runId, node.id, getAllPredecessors(def, node.id));
637
+ // A `running` row this process is not executing was orphaned by a
638
+ // crash; recovery must be able to run the node again.
639
+ if (current && (current.status !== "running" || executingSteps.has(current.id))) {
640
+ return { current };
641
+ }
642
+ }
643
+
550
644
  // Count existing steps for this node to determine the current iteration.
551
645
  const iteration = await getStepCountForNode(runId, node.id);
552
646
  const idempotencyKey = `${runId}:${node.id}:${iteration}`;
@@ -584,10 +678,27 @@ async function executeStep(
584
678
  idempotencyKey,
585
679
  });
586
680
  }
681
+ // Registered before the transaction commits, so no other walker can see
682
+ // this `running` row without also seeing it owned.
683
+ claimedStepId = stepId;
684
+ executingSteps.add(stepId);
587
685
  return { existingStep, stepId, deduped: false };
588
- });
686
+ };
687
+ const dedup = await getDbClient()
688
+ .transaction(claimStep)
689
+ .catch((err) => {
690
+ if (claimedStepId) executingSteps.delete(claimedStepId);
691
+ throw err;
692
+ });
589
693
 
590
694
  if ("halted" in dedup) return { outcome: "completed", successors: [] };
695
+ if ("current" in dedup && dedup.current) {
696
+ // The walker that owns the step routes its successors and finalizes the
697
+ // run; this one must not route them again.
698
+ if (dedup.current.status !== "completed") return { outcome: "waiting", successors: [] };
699
+ ctx[node.id] = dedup.current.output;
700
+ return { outcome: "completed", successors: [] };
701
+ }
591
702
 
592
703
  if (dedup.deduped && dedup.existingStep) {
593
704
  if (dedup.existingStep.status === "completed") {
@@ -601,9 +712,35 @@ async function executeStep(
601
712
  // Don't create a duplicate — just report as waiting.
602
713
  return { outcome: "waiting", successors: [] };
603
714
  }
604
- const existingStep = dedup.existingStep;
605
- const stepId = dedup.stepId;
715
+ try {
716
+ return await runClaimedStep(
717
+ def,
718
+ runId,
719
+ ctx,
720
+ node,
721
+ registry,
722
+ dedup.existingStep,
723
+ dedup.stepId,
724
+ workflowId,
725
+ options,
726
+ );
727
+ } finally {
728
+ executingSteps.delete(dedup.stepId);
729
+ }
730
+ }
606
731
 
732
+ /** Steps 3-10 of executeStep, for a step row this call inserted and owns. */
733
+ async function runClaimedStep(
734
+ def: WorkflowDefinition,
735
+ runId: string,
736
+ ctx: Record<string, unknown>,
737
+ node: WorkflowNode,
738
+ registry: ExecutorRegistry,
739
+ existingStep: WorkflowRunStep | null | undefined,
740
+ stepId: string,
741
+ workflowId?: string,
742
+ options: WorkflowExecutionOptions = {},
743
+ ): Promise<StepResult> {
607
744
  // 3. Get executor
608
745
  const executor = registry.get(node.type);
609
746
 
@@ -777,16 +914,10 @@ async function executeStep(
777
914
 
778
915
  // 8. Set nextPort from validation result for record-based routing
779
916
  // When validation determines pass/fail and the node uses port-based `next`,
780
- // route to the correct port instead of activating all ports.
781
- if (
782
- validationResult?.passed !== undefined &&
783
- !result.nextPort &&
784
- node.next &&
785
- typeof node.next === "object" &&
786
- !Array.isArray(node.next)
787
- ) {
788
- result.nextPort = validationResult.passed ? "pass" : "fail";
789
- }
917
+ // route to the correct port instead of activating all ports. An executor port
918
+ // that `next` does not declare (script's "success" vs `{ pass, fail }`) is
919
+ // overridden too, otherwise the branch would dead-end.
920
+ result.nextPort = resolveValidationPort(node.next, result.nextPort, validationResult?.passed);
790
921
 
791
922
  // 9. Checkpoint success
792
923
  await checkpointStep(runId, stepId, node.id, result, ctx);
@@ -380,7 +380,7 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
380
380
  elements: [
381
381
  {
382
382
  type: "mrkdwn",
383
- text: `⏱ Timeout: ${formatTimeout(config.timeout.seconds)} — auto-rejects if not responded`,
383
+ text: `⏱ Timeout: ${formatTimeout(config.timeout.seconds)} — times out if not responded`,
384
384
  },
385
385
  ],
386
386
  },