@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,10 +1,11 @@
1
- import type { WorkflowRunStatus } from "../../types";
1
+ import { TERMINAL_TASK_STATUSES, type WorkflowRunStatus } from "../../types";
2
2
  import { scrubSecrets } from "../../utils/secret-scrubber";
3
3
  import { scrubJsonValue } from "../scrub-json";
4
4
  import { getDbClient } from "./runtime";
5
5
 
6
6
  // Parent/child links between workflow runs (migration 203): a `sub-workflow`
7
- // step starts a child run that records the step in `parentStepId`.
7
+ // step starts a child run that records the step in `parentStepId`, and the
8
+ // step's `retryCount` at that time in `parentStepAttempt` (migration 240).
8
9
 
9
10
  const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
10
11
  "completed",
@@ -13,15 +14,371 @@ const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
13
14
  "skipped",
14
15
  ]);
15
16
 
16
- /** The id of the child run a `sub-workflow` step started, if any. */
17
- export async function getChildWorkflowRunId(parentStepId: string): Promise<string | null> {
17
+ /**
18
+ * Deepest run tree the lineage guard and the cascade cancel walk. Twice the
19
+ * sub-workflow nesting cap (MAX_SUB_WORKFLOW_DEPTH, 16), so any tree the
20
+ * engine admits fits; a deeper chain is corrupt and fails closed.
21
+ */
22
+ export const WORKFLOW_RUN_TREE_MAX_DEPTH = 32;
23
+
24
+ /** Thrown when a run's stored parent links cannot be walked: a cycle, or a chain past the depth bound. */
25
+ export class WorkflowRunTreeError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "WorkflowRunTreeError";
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Thrown when work is created for a workflow step that may no longer run: the
34
+ * step is not `running`, its run is not live, or the run's lineage is blocked
35
+ * (see `getRunLineageBlock`).
36
+ */
37
+ export class WorkflowStepNotExecutableError extends Error {
38
+ constructor(stepId: string, reason: string) {
39
+ super(`Workflow step ${stepId} may not start new work: ${reason}`);
40
+ this.name = "WorkflowStepNotExecutableError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Why the run and its ancestors forbid new engine work, or null when they
46
+ * allow it. Blocked when the run is missing, the run or an ancestor is
47
+ * `cancelled`, a stored parent link points at a missing step or run, the
48
+ * links form a cycle, or the chain is deeper than WORKFLOW_RUN_TREE_MAX_DEPTH.
49
+ * A completed or failed ancestor does not block: a child of an earlier
50
+ * attempt may outlive its parent's run. The run's own live status is left to
51
+ * the caller, since the retry claim also revives a `failed` run.
52
+ *
53
+ * Call it inside the transaction that writes the new work, so a cascade
54
+ * cancel either commits first and is seen here or sees the new row itself.
55
+ */
56
+ export async function getRunLineageBlock(runId: string): Promise<string | null> {
57
+ const rows = await getDbClient().query<{
58
+ id: string;
59
+ status: WorkflowRunStatus;
60
+ parentStepId: string | null;
61
+ depth: number;
62
+ }>(
63
+ `WITH RECURSIVE lineage(id, status, parentStepId, depth) AS (
64
+ SELECT id, status, parentStepId, 0 FROM workflow_runs WHERE id = ?
65
+ UNION ALL
66
+ SELECT r.id, r.status, r.parentStepId, l.depth + 1
67
+ FROM lineage l
68
+ JOIN workflow_run_steps s ON s.id = l.parentStepId
69
+ JOIN workflow_runs r ON r.id = s.runId
70
+ WHERE l.depth < ?
71
+ )
72
+ SELECT id, status, parentStepId, depth FROM lineage ORDER BY depth`,
73
+ [runId, WORKFLOW_RUN_TREE_MAX_DEPTH],
74
+ );
75
+ if (rows.length === 0) return `Workflow run ${runId} is missing`;
76
+ const seen = new Set<string>();
77
+ for (const row of rows) {
78
+ if (seen.has(row.id)) return `Workflow run ${runId} has a cycle in its parent links`;
79
+ seen.add(row.id);
80
+ if (row.status === "cancelled") {
81
+ return row.id === runId
82
+ ? `Workflow run ${runId} is cancelled`
83
+ : `Ancestor workflow run ${row.id} is cancelled`;
84
+ }
85
+ }
86
+ const top = rows[rows.length - 1]!;
87
+ if (top.parentStepId == null) return null;
88
+ if (top.depth >= WORKFLOW_RUN_TREE_MAX_DEPTH) {
89
+ return `Workflow run ${runId} is nested deeper than ${WORKFLOW_RUN_TREE_MAX_DEPTH} levels`;
90
+ }
91
+ return `Parent step ${top.parentStepId} of workflow run ${top.id} is missing`;
92
+ }
93
+
94
+ /**
95
+ * The run and every run below it, root first: children of every attempt of
96
+ * every step, through terminal runs too, since a run that ended can still have
97
+ * a live child of an earlier attempt. The tree comes from stored links only
98
+ * (child.parentStepId → step → run). There is no size limit: the engine admits
99
+ * any number of children per run, and a tree it admitted must stay
100
+ * cancellable. Throws WorkflowRunTreeError on a cycle or past `maxDepth`, so
101
+ * the caller's transaction rolls back whole.
102
+ */
103
+ export async function getWorkflowRunTreeIds(
104
+ rootId: string,
105
+ bounds: { maxDepth?: number } = {},
106
+ ): Promise<string[]> {
107
+ const maxDepth = bounds.maxDepth ?? WORKFLOW_RUN_TREE_MAX_DEPTH;
108
+ const ids = [rootId];
109
+ const seen = new Set(ids);
110
+ // One level per pass, so a cycle stops at its first repeated run instead of
111
+ // unrolling down to the depth bound.
112
+ let level = [rootId];
113
+ for (let depth = 1; level.length > 0; depth++) {
114
+ const next: string[] = [];
115
+ for (let i = 0; i < level.length; i += TREE_QUERY_CHUNK) {
116
+ const chunk = level.slice(i, i + TREE_QUERY_CHUNK);
117
+ const rows = await getDbClient().query<{ id: string }>(
118
+ `SELECT c.id
119
+ FROM workflow_run_steps s
120
+ JOIN workflow_runs c ON c.parentStepId = s.id
121
+ WHERE s.runId IN (${chunk.map(() => "?").join(", ")})`,
122
+ chunk,
123
+ );
124
+ for (const { id } of rows) {
125
+ // Each run has one parent link, so reaching a run twice means a cycle.
126
+ if (seen.has(id)) {
127
+ throw new WorkflowRunTreeError(`Workflow run tree of ${rootId} has a cycle at run ${id}`);
128
+ }
129
+ seen.add(id);
130
+ next.push(id);
131
+ }
132
+ }
133
+ if (next.length > 0 && depth > maxDepth) {
134
+ throw new WorkflowRunTreeError(
135
+ `Workflow run tree of ${rootId} is nested deeper than ${maxDepth} levels`,
136
+ );
137
+ }
138
+ ids.push(...next);
139
+ level = next;
140
+ }
141
+ return ids;
142
+ }
143
+
144
+ /** Parent runs per children query, well under SQLite's bound-parameter limit. */
145
+ const TREE_QUERY_CHUNK = 500;
146
+
147
+ /** A run's status alone, without opening its sealed context. Null when the run is missing. */
148
+ export async function getWorkflowRunStatus(runId: string): Promise<WorkflowRunStatus | null> {
149
+ const row = await getDbClient().get<{ status: WorkflowRunStatus }>(
150
+ "SELECT status FROM workflow_runs WHERE id = ?",
151
+ [runId],
152
+ );
153
+ return row?.status ?? null;
154
+ }
155
+
156
+ /** Run `fn` over `ids` in chunks small enough for one statement's bound parameters. */
157
+ async function forEachChunk(
158
+ ids: string[],
159
+ fn: (chunk: string[], placeholders: string) => Promise<void>,
160
+ ): Promise<void> {
161
+ for (let i = 0; i < ids.length; i += TREE_QUERY_CHUNK) {
162
+ const chunk = ids.slice(i, i + TREE_QUERY_CHUNK);
163
+ await fn(chunk, chunk.map(() => "?").join(", "));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Ids of the non-terminal tasks of the given runs, including tasks of earlier
169
+ * attempts: a retry detaches a task from its step, but the run link stays.
170
+ */
171
+ export async function getOpenWorkflowTreeTaskIds(runIds: string[]): Promise<string[]> {
172
+ const ids: string[] = [];
173
+ const terminal = TERMINAL_TASK_STATUSES.map(() => "?").join(", ");
174
+ await forEachChunk(runIds, async (chunk, placeholders) => {
175
+ const rows = await getDbClient().query<{ id: string }>(
176
+ `SELECT id FROM agent_tasks
177
+ WHERE workflowRunId IN (${placeholders}) AND status NOT IN (${terminal})`,
178
+ [...chunk, ...TERMINAL_TASK_STATUSES],
179
+ );
180
+ for (const row of rows) ids.push(row.id);
181
+ });
182
+ return ids;
183
+ }
184
+
185
+ /**
186
+ * Ids of tasks that a non-terminal task lists in `dependsOn`: the only
187
+ * dependents a cancel can fail. One scan over open tasks, so a cancel of many
188
+ * tasks looks for dependents once instead of once per task.
189
+ */
190
+ export async function getTaskIdsWithDependents(): Promise<Set<string>> {
191
+ const rows = await getDbClient().query<{ id: string }>(
192
+ `SELECT DISTINCT dep.value AS id
193
+ FROM agent_tasks t, json_each(t.dependsOn) AS dep
194
+ WHERE t.status NOT IN (${TERMINAL_TASK_STATUSES.map(() => "?").join(", ")})
195
+ AND t.dependsOn IS NOT NULL AND t.dependsOn != '[]'`,
196
+ [...TERMINAL_TASK_STATUSES],
197
+ );
198
+ return new Set(rows.map((row) => row.id));
199
+ }
200
+
201
+ /**
202
+ * True when any of the given runs still has work a cancel would close: a live
203
+ * run, a pending, running or waiting step, a queued retry, an open task or a
204
+ * pending approval. Reads only, with a LIMIT 1 probe per chunk.
205
+ */
206
+ export async function workflowRunTreeHasOpenWork(runIds: string[]): Promise<boolean> {
207
+ const terminal = TERMINAL_TASK_STATUSES.map(() => "?").join(", ");
208
+ const probes: Array<{ sql: (ids: string) => string; params: string[] }> = [
209
+ {
210
+ sql: (ids) =>
211
+ `SELECT 1 FROM workflow_runs WHERE id IN (${ids}) AND status IN ('running', 'waiting') LIMIT 1`,
212
+ params: [],
213
+ },
214
+ {
215
+ sql: (ids) => `SELECT 1 FROM workflow_run_steps
216
+ WHERE runId IN (${ids})
217
+ AND (status IN ('pending', 'running', 'waiting')
218
+ OR (status = 'failed' AND nextRetryAt IS NOT NULL))
219
+ LIMIT 1`,
220
+ params: [],
221
+ },
222
+ {
223
+ sql: (ids) =>
224
+ `SELECT 1 FROM agent_tasks WHERE workflowRunId IN (${ids}) AND status NOT IN (${terminal}) LIMIT 1`,
225
+ params: [...TERMINAL_TASK_STATUSES],
226
+ },
227
+ {
228
+ sql: (ids) =>
229
+ `SELECT 1 FROM approval_requests WHERE workflowRunId IN (${ids}) AND status = 'pending' LIMIT 1`,
230
+ params: [],
231
+ },
232
+ ];
233
+ let open = false;
234
+ await forEachChunk(runIds, async (chunk, placeholders) => {
235
+ for (const probe of probes) {
236
+ if (open) return;
237
+ if (await getDbClient().get(probe.sql(placeholders), [...chunk, ...probe.params])) {
238
+ open = true;
239
+ }
240
+ }
241
+ });
242
+ return open;
243
+ }
244
+
245
+ /**
246
+ * Cancel the pending, running and waiting steps of the given runs. The
247
+ * migration-140 trigger closes each cancelled step's pending approval.
248
+ */
249
+ export async function cancelOpenWorkflowTreeSteps(
250
+ runIds: string[],
251
+ reason: string,
252
+ finishedAt: string,
253
+ ): Promise<void> {
254
+ await forEachChunk(runIds, async (chunk, placeholders) => {
255
+ await getDbClient().run(
256
+ `UPDATE workflow_run_steps
257
+ SET status = 'cancelled', error = ?, finishedAt = ?
258
+ WHERE runId IN (${placeholders}) AND status IN ('pending', 'running', 'waiting')`,
259
+ [scrubSecrets(reason), finishedAt, ...chunk],
260
+ );
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Drop the pending retries of the failed steps of the given runs. The step
266
+ * keeps its status, error, output and retryCount; only the queued next
267
+ * attempt goes.
268
+ */
269
+ export async function disarmWorkflowTreeRetries(
270
+ runIds: string[],
271
+ finishedAt: string,
272
+ ): Promise<void> {
273
+ await forEachChunk(runIds, async (chunk, placeholders) => {
274
+ await getDbClient().run(
275
+ `UPDATE workflow_run_steps
276
+ SET nextRetryAt = NULL, finishedAt = COALESCE(finishedAt, ?)
277
+ WHERE runId IN (${placeholders}) AND status = 'failed' AND nextRetryAt IS NOT NULL`,
278
+ [finishedAt, ...chunk],
279
+ );
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Mark the given runs that are still running or waiting `cancelled`, without
285
+ * the per-run `workflow.child.finished` wake-up: it is for runs whose parent
286
+ * step the same cancel closes. A terminal run keeps its status and reason.
287
+ */
288
+ export async function cancelLiveWorkflowTreeRuns(
289
+ runIds: string[],
290
+ reason: string,
291
+ finishedAt: string,
292
+ ): Promise<void> {
293
+ await forEachChunk(runIds, async (chunk, placeholders) => {
294
+ await getDbClient().run(
295
+ `UPDATE workflow_runs
296
+ SET status = 'cancelled', error = ?, finishedAt = ?, lastUpdatedAt = ?
297
+ WHERE id IN (${placeholders}) AND status IN ('running', 'waiting')`,
298
+ [scrubSecrets(reason), finishedAt, finishedAt, ...chunk],
299
+ );
300
+ });
301
+ }
302
+
303
+ /** The id of the child run a `sub-workflow` step started on `attempt`, if any. */
304
+ export async function getChildWorkflowRunId(
305
+ parentStepId: string,
306
+ attempt: number,
307
+ ): Promise<string | null> {
18
308
  const row = await getDbClient().get<{ id: string }>(
19
- "SELECT id FROM workflow_runs WHERE parentStepId = ?",
20
- [parentStepId],
309
+ "SELECT id FROM workflow_runs WHERE parentStepId = ? AND parentStepAttempt = ?",
310
+ [parentStepId, attempt],
21
311
  );
22
312
  return row?.id ?? null;
23
313
  }
24
314
 
315
+ /**
316
+ * True while `attempt` of the step is executing in a live run: the step is
317
+ * `running` with that `retryCount` and no ancestor run is cancelled. A child
318
+ * run is created only then, so a cancelled run or tree, a step another attempt
319
+ * moved on, or a step already parked never gets a new child.
320
+ */
321
+ export async function isParentStepAttemptCurrent(
322
+ parentStepId: string,
323
+ attempt: number,
324
+ ): Promise<boolean> {
325
+ const row = await getDbClient().get<{ runId: string }>(
326
+ `SELECT r.id AS runId
327
+ FROM workflow_run_steps s
328
+ JOIN workflow_runs r ON r.id = s.runId
329
+ WHERE s.id = ? AND s.status = 'running' AND s.retryCount = ?
330
+ AND r.status IN ('running', 'waiting')`,
331
+ [parentStepId, attempt],
332
+ );
333
+ return row != null && (await getRunLineageBlock(row.runId)) == null;
334
+ }
335
+
336
+ /**
337
+ * Why `stepId` may not start new work (a task, an approval, a child run), or
338
+ * null when it may: the step is `running` in a live run whose lineage is not
339
+ * blocked. Call it inside the transaction that inserts the work.
340
+ */
341
+ export async function getStepExecutionBlock(stepId: string): Promise<string | null> {
342
+ const row = await getDbClient().get<{
343
+ stepStatus: string;
344
+ runId: string;
345
+ runStatus: WorkflowRunStatus;
346
+ }>(
347
+ `SELECT s.status AS stepStatus, r.id AS runId, r.status AS runStatus
348
+ FROM workflow_run_steps s
349
+ JOIN workflow_runs r ON r.id = s.runId
350
+ WHERE s.id = ?`,
351
+ [stepId],
352
+ );
353
+ if (!row) return "the step or its run is missing";
354
+ if (row.runStatus !== "running" && row.runStatus !== "waiting") {
355
+ return `its run is ${row.runStatus}`;
356
+ }
357
+ if (row.stepStatus !== "running") return `the step is ${row.stepStatus}`;
358
+ return getRunLineageBlock(row.runId);
359
+ }
360
+
361
+ /**
362
+ * `sub-workflow` steps left `running` in a live run: each is its node's latest
363
+ * row, so a row a newer one superseded is not listed. The caller skips the
364
+ * ones this process is still executing.
365
+ */
366
+ export async function getRunningSubWorkflowSteps(): Promise<
367
+ Array<{ stepId: string; runId: string; nodeId: string }>
368
+ > {
369
+ return getDbClient().query<{ stepId: string; runId: string; nodeId: string }>(
370
+ `SELECT s.id AS stepId, s.runId AS runId, s.nodeId AS nodeId
371
+ FROM workflow_run_steps s
372
+ JOIN workflow_runs r ON r.id = s.runId
373
+ WHERE s.status = 'running' AND s.nodeType = 'sub-workflow'
374
+ AND r.status IN ('running', 'waiting')
375
+ AND s.rowid = (
376
+ SELECT MAX(rowid) FROM workflow_run_steps
377
+ WHERE runId = s.runId AND nodeId = s.nodeId
378
+ )`,
379
+ );
380
+ }
381
+
25
382
  /** Where a child run came from: the `sub-workflow` step that started it and that step's run. */
26
383
  export type WorkflowRunParent = {
27
384
  runId: string;
@@ -65,12 +422,13 @@ export async function getWorkflowRunLineage(runId: string, maxDepth: number): Pr
65
422
  return rows.map((row) => row.workflowId);
66
423
  }
67
424
 
68
- /** Waiting `sub-workflow` steps whose child run is terminal or deleted. */
425
+ /** Waiting `sub-workflow` steps whose current attempt's child run is terminal or deleted. */
69
426
  export async function getSettledChildRunParentSteps(): Promise<string[]> {
70
427
  const rows = await getDbClient().query<{ id: string }>(
71
428
  `SELECT s.id
72
429
  FROM workflow_run_steps s
73
- LEFT JOIN workflow_runs c ON c.parentStepId = s.id
430
+ LEFT JOIN workflow_runs c
431
+ ON c.parentStepId = s.id AND c.parentStepAttempt = s.retryCount
74
432
  WHERE s.status = 'waiting' AND s.nodeType = 'sub-workflow'
75
433
  AND (c.id IS NULL OR c.status IN ('completed', 'failed', 'cancelled', 'skipped'))`,
76
434
  );
package/src/be/db.ts CHANGED
@@ -208,6 +208,7 @@ import {
208
208
  sessionPageFields,
209
209
  sessionSummaryQuery,
210
210
  } from "./db/session-tree";
211
+ import { searchSkillRowsRanked } from "./db/skill-search";
211
212
  import { classifyTaskHumanFree, reclassifyTaskHumanFree } from "./db/tasks/human-free";
212
213
  import {
213
214
  type AgentTaskRow,
@@ -221,7 +222,14 @@ import {
221
222
  rowToAgentTaskSummary,
222
223
  } from "./db/tasks/read";
223
224
  import { configureTaskWriteDependencies, failTask } from "./db/tasks/write";
224
- import { emitChildRunFinished, wakeParentsOfDeletedRuns } from "./db/workflow-runs";
225
+ import { tombstoneWorkflowIdempotencyKeys } from "./db/workflow-idempotency";
226
+ import {
227
+ emitChildRunFinished,
228
+ getRunLineageBlock,
229
+ getStepExecutionBlock,
230
+ WorkflowStepNotExecutableError,
231
+ wakeParentsOfDeletedRuns,
232
+ } from "./db/workflow-runs";
225
233
  import { scrubJsonValue } from "./scrub-json";
226
234
  import { openSealedJson, sealedJsonForDisplay, sealJson } from "./sealed-json";
227
235
  import { configSecretName, registerStoredSecret } from "./secret-registry";
@@ -2854,6 +2862,14 @@ export async function createTaskExtended(
2854
2862
  const claim = await getDbClient().transaction<
2855
2863
  { existing: ExistingTrackerContextWork } | { row: AgentTaskRow }
2856
2864
  >(async () => {
2865
+ // A workflow step's task commits only while the step may still start
2866
+ // work. Checked in the INSERT's transaction: a cascade cancel of the
2867
+ // step's run tree either commits first and is seen here, or sees and
2868
+ // cancels this task.
2869
+ if (options?.workflowRunStepId) {
2870
+ const blocked = await getStepExecutionBlock(options.workflowRunStepId);
2871
+ if (blocked) throw new WorkflowStepNotExecutableError(options.workflowRunStepId, blocked);
2872
+ }
2857
2873
  const existingTrackerWork = options?.bypassTrackerContextDedup
2858
2874
  ? null
2859
2875
  : await findExistingLinearTrackerContextWork(options?.contextKey);
@@ -4738,7 +4754,7 @@ export async function createSessionLogs(logs: {
4738
4754
  for (let start = 0; start < logs.lines.length; start += BATCH_SIZE) {
4739
4755
  // Yield to the event loop between batches.
4740
4756
  if (start > 0) await new Promise<void>((r) => setTimeout(r, 5));
4741
- const batch: { content: string; lineNumber: number }[] = [];
4757
+ const batch: string[] = [];
4742
4758
  const end = Math.min(start + BATCH_SIZE, logs.lines.length);
4743
4759
  for (let i = start; i < end; i++) {
4744
4760
  const line = logs.lines[i];
@@ -4747,10 +4763,22 @@ export async function createSessionLogs(logs: {
4747
4763
  // content that is already scrubbed at the adapter emit site. We scrub
4748
4764
  // again here so any future write path that bypasses the adapter still
4749
4765
  // lands clean text in the persistent session_logs table.
4750
- batch.push({ content: scrubSecrets(line), lineNumber: i });
4766
+ batch.push(scrubSecrets(line));
4751
4767
  }
4752
4768
  await getDbClient().transaction(async (tx) => {
4753
- for (const { content, lineNumber } of batch) {
4769
+ // The worker flushes one (sessionId, iteration) in many POSTs, so the
4770
+ // lineNumber continues from the newest stored row instead of restarting
4771
+ // at the POST's index. Reading the newest row by rowid walks the
4772
+ // sessionId index backwards and stops at the first hit, where MAX()
4773
+ // would scan the whole session. Rows written here are monotonic in
4774
+ // rowid, so this equals MAX + 1 for them.
4775
+ const last = await tx.get<{ lineNumber: number }>(
4776
+ `SELECT lineNumber FROM session_logs WHERE sessionId = ? AND iteration = ?
4777
+ ORDER BY rowid DESC LIMIT 1`,
4778
+ [logs.sessionId, logs.iteration],
4779
+ );
4780
+ let lineNumber = last ? last.lineNumber + 1 : 0;
4781
+ for (const content of batch) {
4754
4782
  await tx.run(
4755
4783
  `INSERT INTO session_logs (id, taskId, sessionId, iteration, cli, content, lineNumber, createdAt)
4756
4784
  VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
@@ -4761,7 +4789,7 @@ export async function createSessionLogs(logs: {
4761
4789
  logs.iteration,
4762
4790
  logs.cli,
4763
4791
  content,
4764
- lineNumber,
4792
+ lineNumber++,
4765
4793
  ],
4766
4794
  );
4767
4795
  }
@@ -4769,6 +4797,10 @@ export async function createSessionLogs(logs: {
4769
4797
  }
4770
4798
  }
4771
4799
 
4800
+ // Session log reads order by rowid, which is insert order. Rows written before
4801
+ // lineNumber was assigned server-side restart at 0 on every worker flush, so
4802
+ // only rowid orders them. The API never runs VACUUM (runbooks/db-retention.md);
4803
+ // a manual VACUUM may renumber rowids but copies rows in rowid order.
4772
4804
  export async function getSessionLogsByTaskId(
4773
4805
  taskId: string,
4774
4806
  limit?: number,
@@ -4776,16 +4808,16 @@ export async function getSessionLogsByTaskId(
4776
4808
  if (typeof limit === "number" && limit > 0) {
4777
4809
  const rows = await getDbClient().query<SessionLogRow>(
4778
4810
  `SELECT * FROM (
4779
- SELECT * FROM session_logs WHERE taskId = ?
4780
- ORDER BY iteration DESC, lineNumber DESC
4811
+ SELECT rowid AS seq, * FROM session_logs WHERE taskId = ?
4812
+ ORDER BY rowid DESC
4781
4813
  LIMIT ?
4782
- ) ORDER BY iteration ASC, lineNumber ASC`,
4814
+ ) ORDER BY seq ASC`,
4783
4815
  [taskId, limit],
4784
4816
  );
4785
4817
  return rows.map(rowToSessionLog);
4786
4818
  }
4787
4819
  const rows = await getDbClient().query<SessionLogRow>(
4788
- "SELECT * FROM session_logs WHERE taskId = ? ORDER BY iteration ASC, lineNumber ASC",
4820
+ "SELECT * FROM session_logs WHERE taskId = ? ORDER BY rowid ASC",
4789
4821
  [taskId],
4790
4822
  );
4791
4823
  return rows.map(rowToSessionLog);
@@ -4796,7 +4828,7 @@ export async function getSessionLogsBySession(
4796
4828
  iteration: number,
4797
4829
  ): Promise<SessionLog[]> {
4798
4830
  const rows = await getDbClient().query<SessionLogRow>(
4799
- "SELECT * FROM session_logs WHERE sessionId = ? AND iteration = ? ORDER BY lineNumber ASC",
4831
+ "SELECT * FROM session_logs WHERE sessionId = ? AND iteration = ? ORDER BY rowid ASC",
4800
4832
  [sessionId, iteration],
4801
4833
  );
4802
4834
  return rows.map(rowToSessionLog);
@@ -7789,7 +7821,9 @@ async function deleteWorkflowRows(id: string, source?: "api" | "mcp"): Promise<b
7789
7821
  await reclassifyTaskHumanFree(linkedTaskIds);
7790
7822
  // 4. Delete every user's favorite of it (no FK to the workflow)
7791
7823
  await client.run("DELETE FROM user_favorites WHERE itemType = 'workflow' AND itemId = ?", [id]);
7792
- // 5. Delete workflow
7824
+ // 5. Keep Idempotency-Keys that created or triggered it taken, without the stored response
7825
+ await tombstoneWorkflowIdempotencyKeys(id);
7826
+ // 6. Delete workflow
7793
7827
  const result = await client.run("DELETE FROM workflows WHERE id = ?", [id]);
7794
7828
  const deleted = result.changes > 0;
7795
7829
  if (deleted) {
@@ -7842,6 +7876,8 @@ type WorkflowRunRow = {
7842
7876
  finishedAt: string | null;
7843
7877
  /** The `sub-workflow` step that started this run (migration 203). */
7844
7878
  parentStepId?: string | null;
7879
+ /** That step's attempt (`retryCount`) when it started this run (migration 240). */
7880
+ parentStepAttempt?: number | null;
7845
7881
  };
7846
7882
 
7847
7883
  /**
@@ -7862,6 +7898,7 @@ function rowToWorkflowRun(row: WorkflowRunRow, view: WorkflowPayloadView = "repl
7862
7898
  lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
7863
7899
  finishedAt: normalizeDate(row.finishedAt) ?? undefined,
7864
7900
  parentStepId: row.parentStepId ?? undefined,
7901
+ parentStepAttempt: row.parentStepId ? (row.parentStepAttempt ?? 0) : undefined,
7865
7902
  };
7866
7903
  return defineWorkflowPayload(run, "context", row.context, row.context_replay, view, {
7867
7904
  runId: row.id,
@@ -7876,10 +7913,12 @@ export async function createWorkflowRun(data: {
7876
7913
  triggerData?: unknown;
7877
7914
  createdBy?: string;
7878
7915
  parentStepId?: string;
7916
+ /** The parent step's `retryCount` when it started this run (migration 240). */
7917
+ parentStepAttempt?: number;
7879
7918
  }): Promise<WorkflowRun> {
7880
7919
  const now = new Date().toISOString();
7881
7920
  const row = await getDbClient().get<WorkflowRunRow>(
7882
- `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7921
+ `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId, parentStepAttempt) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7883
7922
  [
7884
7923
  data.id,
7885
7924
  data.workflowId,
@@ -7888,6 +7927,7 @@ export async function createWorkflowRun(data: {
7888
7927
  data.triggerData ? scrubJsonValue(data.triggerData) : null,
7889
7928
  data.createdBy ?? null,
7890
7929
  data.parentStepId ?? null,
7930
+ data.parentStepAttempt ?? 0,
7891
7931
  ],
7892
7932
  );
7893
7933
  if (!row) throw new Error("Failed to create workflow run");
@@ -7992,9 +8032,10 @@ export type WorkflowRunListOptions = {
7992
8032
  };
7993
8033
 
7994
8034
  // Everything but `context`. `triggerData` stays: it is small, and stored
7995
- // scripts read it off list rows (dedupe by trigger, failure reports).
8035
+ // scripts read it off list rows (dedupe by trigger, failure reports). The
8036
+ // parent link and attempt stay so a list row identifies a child's invocation.
7996
8037
  const WORKFLOW_RUN_SUMMARY_COLUMNS =
7997
- "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt";
8038
+ "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt, parentStepId, parentStepAttempt";
7998
8039
 
7999
8040
  export type WorkflowRunPage = {
8000
8041
  runs: WorkflowRun[];
@@ -9779,15 +9820,29 @@ export async function createApprovalRequest(data: {
9779
9820
  const row = await getDbClient().transaction(async () => {
9780
9821
  let status: ApprovalRequest["status"] = "pending";
9781
9822
  let resolutionReason: string | null = null;
9782
- if (data.requireActionableWorkflow && data.workflowRunId && data.workflowRunStepId) {
9783
- const run = await getWorkflowRun(data.workflowRunId);
9784
- const step = await getWorkflowRunStep(data.workflowRunStepId);
9823
+ // A request linked to an existing run, with or without a step, is born
9824
+ // cancelled unless the run is live and its tree is not cancelled: a
9825
+ // pending request in a cancelled tree would be open work no cancel may
9826
+ // leave behind. A missing run is refused only on the workflow (HITL) path.
9827
+ const actionable = data.requireActionableWorkflow && data.workflowRunStepId;
9828
+ const run = data.workflowRunId ? await getWorkflowRun(data.workflowRunId, "display") : null;
9829
+ if (data.workflowRunId && (run || actionable)) {
9830
+ const step = actionable ? await getWorkflowRunStep(data.workflowRunStepId!) : undefined;
9785
9831
  if (!run || (run.status !== "running" && run.status !== "waiting")) {
9786
9832
  status = "cancelled";
9787
9833
  resolutionReason = run?.error ?? `Workflow run is ${run?.status ?? "missing"}`;
9788
- } else if (!step || step.runId !== run.id || step.status !== "running") {
9834
+ } else if (
9835
+ step !== undefined &&
9836
+ (!step || step.runId !== run.id || step.status !== "running")
9837
+ ) {
9789
9838
  status = "cancelled";
9790
9839
  resolutionReason = `Human-in-the-loop step is ${step?.status ?? "missing"}`;
9840
+ } else {
9841
+ const blocked = await getRunLineageBlock(run.id);
9842
+ if (blocked) {
9843
+ status = "cancelled";
9844
+ resolutionReason = blocked;
9845
+ }
9791
9846
  }
9792
9847
  }
9793
9848
 
@@ -10989,11 +11044,23 @@ export async function listSkills(filters?: SkillFilters): Promise<Skill[]> {
10989
11044
  return rows.map(rowToSkill);
10990
11045
  }
10991
11046
 
11047
+ /**
11048
+ * Ranked skill search (see searchSkillRowsRanked). Falls back to the unranked
11049
+ * substring match when the ranked query finds nothing, e.g. a partial word or
11050
+ * a query of only stopwords. The fallback also ranks an exact name first.
11051
+ */
10992
11052
  export async function searchSkills(
10993
11053
  query: string,
10994
11054
  limit = 20,
10995
11055
  includeContent = true,
10996
11056
  ): Promise<Skill[]> {
11057
+ const ranked = await searchSkillRowsRanked<SkillRow>(
11058
+ query,
11059
+ limit,
11060
+ includeContent === false ? SKILL_SLIM_COLUMNS : "skills.*",
11061
+ );
11062
+ if (ranked.length > 0) return ranked.map(rowToSkill);
11063
+
10997
11064
  const tokens = query.split(/\s+/).filter(Boolean);
10998
11065
  const columns = includeContent === false ? SKILL_SLIM_COLUMNS : "*";
10999
11066
  const searchClauses = tokens.map(() => "(name LIKE ? OR description LIKE ? OR content LIKE ?)");
@@ -11002,8 +11069,8 @@ export async function searchSkills(
11002
11069
  return [term, term, term];
11003
11070
  });
11004
11071
  const rows = await getDbClient().query<SkillRow>(
11005
- `SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY name ASC LIMIT ?`,
11006
- [...params, limit],
11072
+ `SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY lower(name) = lower(?) DESC, name ASC LIMIT ?`,
11073
+ [...params, query.trim(), limit],
11007
11074
  );
11008
11075
  return rows.map(rowToSkill);
11009
11076
  }
@@ -12872,7 +12939,8 @@ export async function listTaskTemplates(opts?: {
12872
12939
  /**
12873
12940
  * Walk the chain rooted at `rootTaskId`, ordered by `createdAt` (root first; `rowid` breaks
12874
12941
  * ties as idx_agent_tasks_created did). `CROSS JOIN` keeps the chain as the outer loop: with
12875
- * a plain JOIN, SQLite scanned all of agent_tasks to skip the sort.
12942
+ * a plain JOIN, SQLite scanned all of agent_tasks to skip the sort. Each task carries its
12943
+ * `totalCostUsd`, so the session header can total the whole chain, not only the root.
12876
12944
  */
12877
12945
  export async function getRootTaskChain(rootTaskId: string): Promise<AgentTask[]> {
12878
12946
  const rows = await getDbClient().query<AgentTaskRow>(
@@ -12882,7 +12950,9 @@ export async function getRootTaskChain(rootTaskId: string): Promise<AgentTask[]>
12882
12950
  SELECT t.id FROM agent_tasks t
12883
12951
  JOIN chain c ON t.parentTaskId = c.id
12884
12952
  )
12885
- SELECT t.* FROM chain
12953
+ SELECT t.*,
12954
+ (SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = t.id) AS totalCostUsd
12955
+ FROM chain
12886
12956
  CROSS JOIN agent_tasks t ON t.id = chain.id
12887
12957
  ORDER BY t.createdAt, t.rowid`,
12888
12958
  [rootTaskId],