@desplega.ai/agent-swarm 2.5.3 → 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 (197) hide show
  1. package/dist/{acp-adapter-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
  2. package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
  3. package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
  4. package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
  6. package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
  11. package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
  13. package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
  16. package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
  17. package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
  22. package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
  24. package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
  26. package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
  27. package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
  28. package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
  29. package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
  30. package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
  31. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  32. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  33. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  34. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  35. package/dist/{cli-s53vp709.js → cli-eftzyh9y.js} +1 -1
  36. package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
  37. package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
  38. package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
  39. package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
  40. package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
  41. package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
  42. package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
  43. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  44. package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
  45. package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
  46. package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
  47. package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
  48. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  49. package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
  50. package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
  51. package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
  52. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  53. package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
  54. package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
  55. package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
  56. package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
  57. package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
  58. package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
  59. package/dist/cli.js +18 -18
  60. package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
  61. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  62. package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
  63. package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
  64. package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
  65. package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
  66. package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
  67. package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
  68. package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
  69. package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
  70. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  71. package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
  72. package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
  73. package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
  74. package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
  75. package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
  76. package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
  77. package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
  78. package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
  79. package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
  80. package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
  81. package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
  82. package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
  83. package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
  84. package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
  85. package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
  86. package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
  87. package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  88. package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
  89. package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
  90. package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
  91. package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  92. package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
  93. package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
  94. package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
  95. package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
  96. package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
  97. package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
  98. package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
  99. package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
  100. package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
  101. package/openapi.json +68 -3
  102. package/package.json +5 -5
  103. package/plugin/commands/create-pr.md +2 -2
  104. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  105. package/src/be/db/approvals.ts +48 -0
  106. package/src/be/db/tasks/write.ts +15 -5
  107. package/src/be/db/workflow-idempotency.ts +183 -0
  108. package/src/be/db/workflow-runs.ts +318 -7
  109. package/src/be/db.ts +67 -17
  110. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  111. package/src/be/sealed-json.ts +1 -0
  112. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  113. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  114. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  115. package/src/be/sql-connections.ts +7 -1
  116. package/src/be/swarm-config-guard.ts +2 -0
  117. package/src/be/users.ts +9 -0
  118. package/src/commands/controller.ts +33 -0
  119. package/src/commands/runner.ts +6 -2
  120. package/src/extensions/apply-task-create.ts +57 -11
  121. package/src/extensions/contract-types.generated.ts +1 -1
  122. package/src/extensions/contract.ts +25 -0
  123. package/src/extensions/dispatcher.ts +4 -0
  124. package/src/extensions/post-bridge.ts +8 -0
  125. package/src/hooks/context-bound-note.ts +196 -0
  126. package/src/hooks/hook.ts +12 -0
  127. package/src/http/config.ts +48 -16
  128. package/src/http/idempotency-key.ts +117 -0
  129. package/src/http/workflows.ts +129 -24
  130. package/src/prompts/context-bound-note.ts +48 -0
  131. package/src/prompts/session-templates.ts +25 -1
  132. package/src/providers/amp-adapter.ts +30 -8
  133. package/src/providers/claude-adapter.ts +29 -4
  134. package/src/providers/claude-managed-adapter.ts +26 -0
  135. package/src/providers/claude-sdk-session.ts +10 -0
  136. package/src/providers/claude-session-events.ts +13 -0
  137. package/src/providers/codex-adapter.ts +12 -0
  138. package/src/providers/opencode-adapter.ts +18 -0
  139. package/src/providers/pi-mono-adapter.ts +35 -0
  140. package/src/runtime-providers/docker.ts +6 -2
  141. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  142. package/src/tests/amp-adapter.test.ts +97 -0
  143. package/src/tests/approval-requests.test.ts +10 -7
  144. package/src/tests/approval-sweeps.test.ts +12 -6
  145. package/src/tests/base-prompt.test.ts +1 -1
  146. package/src/tests/claude-hook-output.test.ts +79 -1
  147. package/src/tests/claude-managed-adapter.test.ts +275 -0
  148. package/src/tests/codex-adapter.test.ts +163 -0
  149. package/src/tests/context-bound-note.test.ts +269 -0
  150. package/src/tests/extensions-pre-task.test.ts +273 -0
  151. package/src/tests/fetch-resolved-env.test.ts +17 -0
  152. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  153. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  154. package/src/tests/fixtures/fake-amp.ts +22 -8
  155. package/src/tests/fixtures/held-template-render.ts +42 -0
  156. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  157. package/src/tests/openapi-route-headers.test.ts +2 -0
  158. package/src/tests/opencode-adapter.test.ts +134 -0
  159. package/src/tests/pi-mono-adapter.test.ts +105 -0
  160. package/src/tests/prompt-template-session.test.ts +4 -3
  161. package/src/tests/runner-push-delivery.test.ts +25 -0
  162. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  163. package/src/tests/runtime-providers-docker.test.ts +2 -0
  164. package/src/tests/script-connections.test.ts +3 -0
  165. package/src/tests/script-run-config-scope.test.ts +91 -3
  166. package/src/tests/session-logs.test.ts +70 -0
  167. package/src/tests/sessions-tree.test.ts +37 -0
  168. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  169. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  170. package/src/tests/workflow-idempotency.test.ts +915 -0
  171. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  172. package/src/utils/context-window.ts +8 -6
  173. package/src/workflows/approval-cancel.ts +15 -13
  174. package/src/workflows/approval-notifications.ts +5 -1
  175. package/src/workflows/checkpoint.ts +6 -3
  176. package/src/workflows/engine.ts +30 -8
  177. package/src/workflows/executors/foreach.ts +2 -2
  178. package/src/workflows/foreach-driver.ts +10 -3
  179. package/src/workflows/foreach-join.ts +29 -6
  180. package/src/workflows/recovery.ts +6 -1
  181. package/src/workflows/resume.ts +168 -56
  182. package/src/workflows/retry-poller.ts +49 -3
  183. package/src/workflows/task-step-routing.ts +29 -7
  184. package/templates/ai-toolbox.manifest.json +8 -8
  185. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  186. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  187. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  188. package/templates/skills/agent-browser/SKILL.md +4 -4
  189. package/templates/skills/agent-browser/content.md +4 -4
  190. package/templates/skills/code-quality/config.json +1 -1
  191. package/templates/skills/code-quality/content.md +2 -2
  192. package/templates/skills/delegate-work/content.md +29 -25
  193. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  194. package/templates/skills/qa/content.md +2 -2
  195. package/templates/skills/researching/content.md +2 -0
  196. package/templates/skills/work-on-task/SKILL.md +1 -1
  197. package/templates/skills/work-on-task/content.md +1 -1
package/src/be/db.ts CHANGED
@@ -222,7 +222,14 @@ import {
222
222
  rowToAgentTaskSummary,
223
223
  } from "./db/tasks/read";
224
224
  import { configureTaskWriteDependencies, failTask } from "./db/tasks/write";
225
- 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";
226
233
  import { scrubJsonValue } from "./scrub-json";
227
234
  import { openSealedJson, sealedJsonForDisplay, sealJson } from "./sealed-json";
228
235
  import { configSecretName, registerStoredSecret } from "./secret-registry";
@@ -2855,6 +2862,14 @@ export async function createTaskExtended(
2855
2862
  const claim = await getDbClient().transaction<
2856
2863
  { existing: ExistingTrackerContextWork } | { row: AgentTaskRow }
2857
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
+ }
2858
2873
  const existingTrackerWork = options?.bypassTrackerContextDedup
2859
2874
  ? null
2860
2875
  : await findExistingLinearTrackerContextWork(options?.contextKey);
@@ -4739,7 +4754,7 @@ export async function createSessionLogs(logs: {
4739
4754
  for (let start = 0; start < logs.lines.length; start += BATCH_SIZE) {
4740
4755
  // Yield to the event loop between batches.
4741
4756
  if (start > 0) await new Promise<void>((r) => setTimeout(r, 5));
4742
- const batch: { content: string; lineNumber: number }[] = [];
4757
+ const batch: string[] = [];
4743
4758
  const end = Math.min(start + BATCH_SIZE, logs.lines.length);
4744
4759
  for (let i = start; i < end; i++) {
4745
4760
  const line = logs.lines[i];
@@ -4748,10 +4763,22 @@ export async function createSessionLogs(logs: {
4748
4763
  // content that is already scrubbed at the adapter emit site. We scrub
4749
4764
  // again here so any future write path that bypasses the adapter still
4750
4765
  // lands clean text in the persistent session_logs table.
4751
- batch.push({ content: scrubSecrets(line), lineNumber: i });
4766
+ batch.push(scrubSecrets(line));
4752
4767
  }
4753
4768
  await getDbClient().transaction(async (tx) => {
4754
- 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) {
4755
4782
  await tx.run(
4756
4783
  `INSERT INTO session_logs (id, taskId, sessionId, iteration, cli, content, lineNumber, createdAt)
4757
4784
  VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
@@ -4762,7 +4789,7 @@ export async function createSessionLogs(logs: {
4762
4789
  logs.iteration,
4763
4790
  logs.cli,
4764
4791
  content,
4765
- lineNumber,
4792
+ lineNumber++,
4766
4793
  ],
4767
4794
  );
4768
4795
  }
@@ -4770,6 +4797,10 @@ export async function createSessionLogs(logs: {
4770
4797
  }
4771
4798
  }
4772
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.
4773
4804
  export async function getSessionLogsByTaskId(
4774
4805
  taskId: string,
4775
4806
  limit?: number,
@@ -4777,16 +4808,16 @@ export async function getSessionLogsByTaskId(
4777
4808
  if (typeof limit === "number" && limit > 0) {
4778
4809
  const rows = await getDbClient().query<SessionLogRow>(
4779
4810
  `SELECT * FROM (
4780
- SELECT * FROM session_logs WHERE taskId = ?
4781
- ORDER BY iteration DESC, lineNumber DESC
4811
+ SELECT rowid AS seq, * FROM session_logs WHERE taskId = ?
4812
+ ORDER BY rowid DESC
4782
4813
  LIMIT ?
4783
- ) ORDER BY iteration ASC, lineNumber ASC`,
4814
+ ) ORDER BY seq ASC`,
4784
4815
  [taskId, limit],
4785
4816
  );
4786
4817
  return rows.map(rowToSessionLog);
4787
4818
  }
4788
4819
  const rows = await getDbClient().query<SessionLogRow>(
4789
- "SELECT * FROM session_logs WHERE taskId = ? ORDER BY iteration ASC, lineNumber ASC",
4820
+ "SELECT * FROM session_logs WHERE taskId = ? ORDER BY rowid ASC",
4790
4821
  [taskId],
4791
4822
  );
4792
4823
  return rows.map(rowToSessionLog);
@@ -4797,7 +4828,7 @@ export async function getSessionLogsBySession(
4797
4828
  iteration: number,
4798
4829
  ): Promise<SessionLog[]> {
4799
4830
  const rows = await getDbClient().query<SessionLogRow>(
4800
- "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",
4801
4832
  [sessionId, iteration],
4802
4833
  );
4803
4834
  return rows.map(rowToSessionLog);
@@ -7790,7 +7821,9 @@ async function deleteWorkflowRows(id: string, source?: "api" | "mcp"): Promise<b
7790
7821
  await reclassifyTaskHumanFree(linkedTaskIds);
7791
7822
  // 4. Delete every user's favorite of it (no FK to the workflow)
7792
7823
  await client.run("DELETE FROM user_favorites WHERE itemType = 'workflow' AND itemId = ?", [id]);
7793
- // 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
7794
7827
  const result = await client.run("DELETE FROM workflows WHERE id = ?", [id]);
7795
7828
  const deleted = result.changes > 0;
7796
7829
  if (deleted) {
@@ -9787,15 +9820,29 @@ export async function createApprovalRequest(data: {
9787
9820
  const row = await getDbClient().transaction(async () => {
9788
9821
  let status: ApprovalRequest["status"] = "pending";
9789
9822
  let resolutionReason: string | null = null;
9790
- if (data.requireActionableWorkflow && data.workflowRunId && data.workflowRunStepId) {
9791
- const run = await getWorkflowRun(data.workflowRunId);
9792
- 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;
9793
9831
  if (!run || (run.status !== "running" && run.status !== "waiting")) {
9794
9832
  status = "cancelled";
9795
9833
  resolutionReason = run?.error ?? `Workflow run is ${run?.status ?? "missing"}`;
9796
- } 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
+ ) {
9797
9838
  status = "cancelled";
9798
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
+ }
9799
9846
  }
9800
9847
  }
9801
9848
 
@@ -12892,7 +12939,8 @@ export async function listTaskTemplates(opts?: {
12892
12939
  /**
12893
12940
  * Walk the chain rooted at `rootTaskId`, ordered by `createdAt` (root first; `rowid` breaks
12894
12941
  * ties as idx_agent_tasks_created did). `CROSS JOIN` keeps the chain as the outer loop: with
12895
- * 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.
12896
12944
  */
12897
12945
  export async function getRootTaskChain(rootTaskId: string): Promise<AgentTask[]> {
12898
12946
  const rows = await getDbClient().query<AgentTaskRow>(
@@ -12902,7 +12950,9 @@ export async function getRootTaskChain(rootTaskId: string): Promise<AgentTask[]>
12902
12950
  SELECT t.id FROM agent_tasks t
12903
12951
  JOIN chain c ON t.parentTaskId = c.id
12904
12952
  )
12905
- 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
12906
12956
  CROSS JOIN agent_tasks t ON t.id = chain.id
12907
12957
  ORDER BY t.createdAt, t.rowid`,
12908
12958
  [rootTaskId],
@@ -0,0 +1,40 @@
1
+ -- Idempotency-Key records for POST /api/workflows and POST /api/workflows/{id}/trigger.
2
+ --
3
+ -- One row is one operation the server accepted under a client key. It commits
4
+ -- in the same transaction as the workflow (create) or the run and its initial
5
+ -- state (trigger), so a retry after a lost response, a restart or a concurrent
6
+ -- duplicate finds it and gets the original result instead of a second
7
+ -- workflow or run.
8
+ --
9
+ -- The scope is (principal, operation, targetId, keyHash): the caller's stable
10
+ -- identity (never the bearer value), the operation, the triggered workflow id
11
+ -- ('' for create) and a SHA-256 of the client key. requestFingerprint is a
12
+ -- SHA-256 of the canonical request; a different request under the same key is
13
+ -- a conflict. responseBody is the original response, sealed (sealJson). When
14
+ -- the workflow is deleted, responseBody is cleared and resourceDeletedAt set:
15
+ -- the key stays taken and is never accepted again.
16
+ --
17
+ -- No FK to workflows: a row outlives the workflow as that tombstone.
18
+
19
+ CREATE TABLE IF NOT EXISTS workflow_idempotency_keys (
20
+ id TEXT PRIMARY KEY,
21
+ principal TEXT NOT NULL,
22
+ operation TEXT NOT NULL CHECK (operation IN ('workflow.create', 'workflow.trigger')),
23
+ targetId TEXT NOT NULL DEFAULT '',
24
+ keyHash TEXT NOT NULL,
25
+ requestFingerprint TEXT NOT NULL,
26
+ workflowId TEXT NOT NULL,
27
+ runId TEXT,
28
+ responseStatus INTEGER NOT NULL,
29
+ responseBody TEXT,
30
+ resourceDeletedAt TEXT,
31
+ createdAt TEXT NOT NULL,
32
+ created_by TEXT,
33
+ updated_by TEXT
34
+ );
35
+
36
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_idempotency_keys_scope
37
+ ON workflow_idempotency_keys(principal, operation, targetId, keyHash);
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_workflow_idempotency_keys_workflowId
40
+ ON workflow_idempotency_keys(workflowId);
@@ -30,6 +30,7 @@ export const SEALED_REPLAY_COLUMNS: readonly { table: string; column: string }[]
30
30
  { table: "workflow_run_steps", column: "output_replay" },
31
31
  { table: "workflow_runs", column: "context_replay" },
32
32
  { table: "approval_requests", column: "responses" },
33
+ { table: "workflow_idempotency_keys", column: "responseBody" },
33
34
  ];
34
35
 
35
36
  /** SQL `EXISTS` arms matching a sealed value in each `SEALED_REPLAY_COLUMNS` entry. */
@@ -574,14 +574,14 @@ export default async function compoundInsights(args: any, ctx: any) {
574
574
  const rows = rowsToObjects(
575
575
  await ctx.swarm.db_query({
576
576
  sql: `WITH raw AS (
577
- SELECT sessionId, iteration, lineNumber, content,
577
+ SELECT rowid AS seq, sessionId, iteration, lineNumber, content,
578
578
  json_extract(content, '$.tool_name') as jsonToolName
579
579
  FROM session_logs
580
580
  WHERE createdAt > ${w}
581
581
  AND (content LIKE '%"type":"tool_use"%' OR json_extract(content, '$.tool_name') IS NOT NULL)
582
582
  )
583
583
  SELECT sessionId, iteration, lineNumber, jsonToolName, content
584
- FROM raw ORDER BY sessionId, iteration, lineNumber LIMIT 100`,
584
+ FROM raw ORDER BY sessionId, iteration, seq LIMIT 100`,
585
585
  }),
586
586
  );
587
587
  const bySession = new Map<string, string[]>();
@@ -574,14 +574,14 @@ export default async function compoundInsights(args: any, ctx: any) {
574
574
  const rows = rowsToObjects(
575
575
  await ctx.swarm.db_query({
576
576
  sql: `WITH raw AS (
577
- SELECT sessionId, iteration, lineNumber, content,
577
+ SELECT rowid AS seq, sessionId, iteration, lineNumber, content,
578
578
  json_extract(content, '$.tool_name') as jsonToolName
579
579
  FROM session_logs
580
580
  WHERE createdAt > ${w}
581
581
  AND (content LIKE '%"type":"tool_use"%' OR json_extract(content, '$.tool_name') IS NOT NULL)
582
582
  )
583
583
  SELECT sessionId, iteration, lineNumber, jsonToolName, content
584
- FROM raw ORDER BY sessionId, iteration, lineNumber LIMIT 100`,
584
+ FROM raw ORDER BY sessionId, iteration, seq LIMIT 100`,
585
585
  }),
586
586
  );
587
587
  const bySession = new Map<string, string[]>();
@@ -18,7 +18,7 @@
18
18
  "delegate-work": [
19
19
  {
20
20
  "path": "scripts/codex-exec.sh",
21
- "content": "#!/usr/bin/env bash\n#\n# codex-exec.sh — invoke `codex exec` (non-interactive) for delegated\n# implementation work. This is the SINGLE SOURCE OF TRUTH for the invocation\n# flags. The delegate-work skill calls only this script; its routing matrix\n# decides WHICH gpt-5.6 variant (-m) and effort (-e) each task gets:\n# luna = mechanical/bounded terra = everyday frozen-spec slice\n# sol = hard/long-horizon effort: medium|high|xhigh|max (never ultra)\n#\n# Usage:\n# codex-exec.sh [-m <model>] [-e <effort>] -C <workdir> -o <last-message-file> [-l <log-file>] \"PROMPT\"\n# printf '%s' \"$PROMPT\" | codex-exec.sh -C <workdir> -o <file> # prompt via stdin (preferred)\n#\n# Notes:\n# * -C sets codex's working root (the git worktree, for parallel isolation).\n# * -o writes codex's final message (its report) to a file you can read back.\n# * Reads the prompt from stdin when no positional PROMPT is given.\n# * In `exec` mode approvals are already \"never\", so nothing hangs waiting for\n# a human. The sandbox mode below bounds what codex may WRITE.\n#\n# Sandbox default: `workspace-write` — codex may freely edit files inside its\n# working root (the worktree) and read elsewhere on disk (so it can read the\n# plan by absolute path), but cannot write outside the workspace. This keeps\n# parallel worktree runs from clobbering each other or the main tree.\n#\n# Env overrides (flags -m/-e take precedence):\n# CODEX_MODEL default: gpt-5.6-sol gpt-5.6-sol | gpt-5.6-terra | gpt-5.6-luna\n# CODEX_EFFORT default: high reasoning effort (never \"fast\"/low)\n# CODEX_SANDBOX default: workspace-write (passed to codex's -s option)\n# CODEX_BYPASS set to 1 to use --dangerously-bypass-approvals-and-sandbox\n# (fully unsandboxed — opt in only when a phase must write\n# outside its worktree; overrides CODEX_SANDBOX)\nset -euo pipefail\n\nWORKDIR=\".\"\nOUTFILE=\"\"\nLOGFILE=\"\"\nPROMPT=\"\"\nMODEL_FLAG=\"\"\nEFFORT_FLAG=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n -m|--model) MODEL_FLAG=\"$2\"; shift 2 ;;\n -e|--effort) EFFORT_FLAG=\"$2\"; shift 2 ;;\n -C|--cd) WORKDIR=\"$2\"; shift 2 ;;\n -o|--out) OUTFILE=\"$2\"; shift 2 ;;\n -l|--log) LOGFILE=\"$2\"; shift 2 ;;\n --) shift; PROMPT=\"${1:-}\"; break ;;\n -*) echo \"codex-exec.sh: unknown flag: $1\" >&2; exit 2 ;;\n *) PROMPT=\"$1\"; shift ;;\n esac\ndone\n\nMODEL=\"${MODEL_FLAG:-${CODEX_MODEL:-gpt-5.6-sol}}\"\nEFFORT=\"${EFFORT_FLAG:-${CODEX_EFFORT:-high}}\"\nSANDBOX=\"${CODEX_SANDBOX:-workspace-write}\"\n\nargs=( exec\n -m \"$MODEL\"\n -c \"model_reasoning_effort=\\\"$EFFORT\\\"\"\n -C \"$WORKDIR\"\n)\nif [[ \"${CODEX_BYPASS:-0}\" == \"1\" ]]; then\n args+=( --dangerously-bypass-approvals-and-sandbox )\nelse\n args+=( -s \"$SANDBOX\" )\nfi\n[[ -n \"$OUTFILE\" ]] && args+=( -o \"$OUTFILE\" )\n\n# Prompt source: positional arg if present, otherwise stdin (`-`).\nif [[ -n \"$PROMPT\" ]]; then\n set -- \"$PROMPT\"\nelse\n set -- \"-\"\nfi\n\nif [[ -n \"$LOGFILE\" ]]; then\n codex \"${args[@]}\" \"$@\" 2>&1 | tee \"$LOGFILE\"\nelse\n codex \"${args[@]}\" \"$@\"\nfi\n"
21
+ "content": "#!/usr/bin/env bash\n#\n# codex-exec.sh — invoke `codex exec` (non-interactive) for delegated\n# implementation work. This is the SINGLE SOURCE OF TRUTH for the invocation\n# flags. The delegate-work skill calls only this script; its routing matrix\n# decides WHICH gpt-6 variant (-m) and effort (-e) each task gets:\n# gpt-6-luna = trivial/mechanical gpt-6.1-sol = default (medium) to hard (high/xhigh)\n# gpt-6-astra = escalation only effort: medium|high|xhigh|max (never ultra)\n#\n# Usage:\n# codex-exec.sh [-m <model>] [-e <effort>] -C <workdir> -o <last-message-file> [-l <log-file>] \"PROMPT\"\n# printf '%s' \"$PROMPT\" | codex-exec.sh -C <workdir> -o <file> # prompt via stdin (preferred)\n#\n# Notes:\n# * -C sets codex's working root (the git worktree, for parallel isolation).\n# * -o writes codex's final message (its report) to a file you can read back.\n# * Reads the prompt from stdin when no positional PROMPT is given.\n# * In `exec` mode approvals are already \"never\", so nothing hangs waiting for\n# a human. The sandbox mode below bounds what codex may WRITE.\n#\n# Sandbox default: `workspace-write` — codex may freely edit files inside its\n# working root (the worktree) and read elsewhere on disk (so it can read the\n# plan by absolute path), but cannot write outside the workspace. This keeps\n# parallel worktree runs from clobbering each other or the main tree.\n#\n# Env overrides (flags -m/-e take precedence):\n# CODEX_MODEL default: gpt-6.1-sol gpt-6.1-sol | gpt-6-astra | gpt-6-luna\n# CODEX_EFFORT default: high reasoning effort (never \"fast\"/low)\n# CODEX_SANDBOX default: workspace-write (passed to codex's -s option)\n# CODEX_BYPASS set to 1 to use --dangerously-bypass-approvals-and-sandbox\n# (fully unsandboxed — opt in only when a phase must write\n# outside its worktree; overrides CODEX_SANDBOX)\nset -euo pipefail\n\nWORKDIR=\".\"\nOUTFILE=\"\"\nLOGFILE=\"\"\nPROMPT=\"\"\nMODEL_FLAG=\"\"\nEFFORT_FLAG=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n -m|--model) MODEL_FLAG=\"$2\"; shift 2 ;;\n -e|--effort) EFFORT_FLAG=\"$2\"; shift 2 ;;\n -C|--cd) WORKDIR=\"$2\"; shift 2 ;;\n -o|--out) OUTFILE=\"$2\"; shift 2 ;;\n -l|--log) LOGFILE=\"$2\"; shift 2 ;;\n --) shift; PROMPT=\"${1:-}\"; break ;;\n -*) echo \"codex-exec.sh: unknown flag: $1\" >&2; exit 2 ;;\n *) PROMPT=\"$1\"; shift ;;\n esac\ndone\n\nMODEL=\"${MODEL_FLAG:-${CODEX_MODEL:-gpt-6.1-sol}}\"\nEFFORT=\"${EFFORT_FLAG:-${CODEX_EFFORT:-high}}\"\nSANDBOX=\"${CODEX_SANDBOX:-workspace-write}\"\n\nargs=( exec\n -m \"$MODEL\"\n -c \"model_reasoning_effort=\\\"$EFFORT\\\"\"\n -C \"$WORKDIR\"\n)\nif [[ \"${CODEX_BYPASS:-0}\" == \"1\" ]]; then\n args+=( --dangerously-bypass-approvals-and-sandbox )\nelse\n args+=( -s \"$SANDBOX\" )\nfi\n[[ -n \"$OUTFILE\" ]] && args+=( -o \"$OUTFILE\" )\n\n# Prompt source: positional arg if present, otherwise stdin (`-`).\nif [[ -n \"$PROMPT\" ]]; then\n set -- \"$PROMPT\"\nelse\n set -- \"-\"\nfi\n\nif [[ -n \"$LOGFILE\" ]]; then\n codex \"${args[@]}\" \"$@\" 2>&1 | tee \"$LOGFILE\"\nelse\n codex \"${args[@]}\" \"$@\"\nfi\n"
22
22
  }
23
23
  ],
24
24
  "design-docs": [
@@ -51,7 +51,13 @@ export async function resolveSqlDsn(
51
51
  // so the parameterless case goes through the tagged-template path instead. Never use `.simple()`.
52
52
  // The statement runs as a server-side cursor fetched in batches, so the API never holds more than
53
53
  // one batch past the caps. DECLARE only accepts SELECT, VALUES, TABLE and WITH ... SELECT.
54
- const asTemplate = (text: string): TemplateStringsArray => Object.assign([text], { raw: [text] });
54
+ // Bun >= 1.4.4 rejects an enumerable `raw`, so shape it like a real (frozen) template object.
55
+ const asTemplate = (text: string): TemplateStringsArray =>
56
+ Object.freeze(
57
+ Object.defineProperty([text], "raw", { value: Object.freeze([text]) }) as string[] & {
58
+ raw: readonly string[];
59
+ },
60
+ );
55
61
 
56
62
  const defaultExecutor: SqlExecutor = async (dsn, query, params, take) => {
57
63
  // Bun.SQL global, not `import { SQL } from "bun"`: the node-target CLI bundle cannot import "bun".
@@ -610,6 +610,8 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
610
610
  // (~80k chars) it risks the SIGTERM-143 context-saturation failure mode
611
611
  // the cap exists to prevent (see context-preamble.ts).
612
612
  ...boundedIntegerValidators(["CONTEXT_PREAMBLE_MAX_TOKENS"], 100, 20000),
613
+ // 0 turns the one-time context-bound note off; no model window reaches 10M.
614
+ ...boundedIntegerValidators(["CONTEXT_BOUND_NOTE_TOKENS"], 0, 10_000_000),
613
615
  MEMORY_MIN_SIMILARITY: (value) =>
614
616
  validateFloatRange("MEMORY_MIN_SIMILARITY", value, 0, 1, "between 0 and 1 inclusive"),
615
617
  MEMORY_ACCESS_BOOST_MAX: (value) =>
package/src/be/users.ts CHANGED
@@ -632,6 +632,15 @@ export async function mintScriptRunToken(
632
632
  return await insertSessionToken(agentId, null, null, ttlMs, null, undefined, null, null, true);
633
633
  }
634
634
 
635
+ /** Whether a session token was minted by `mintScriptRunToken`. */
636
+ export async function isScriptRunToken(tokenId: string): Promise<boolean> {
637
+ const row = await getDbClient().get<{ scriptRun: number }>(
638
+ "SELECT scriptRun FROM session_tokens WHERE id = ?",
639
+ [tokenId],
640
+ );
641
+ return row?.scriptRun === 1;
642
+ }
643
+
635
644
  export async function insertSessionToken(
636
645
  agentId: string,
637
646
  taskId: string | null,
@@ -1,6 +1,7 @@
1
1
  import { createRuntimeProvider } from "../runtime-providers";
2
2
  import { BuiltinController } from "../runtime-providers/controller";
3
3
  import {
4
+ CONTROLLER_PING_INTERVAL_MS,
4
5
  CONTROLLER_PROTOCOL_VERSION,
5
6
  type ControllerCommand,
6
7
  ControllerServerMessageSchema,
@@ -37,13 +38,21 @@ async function executeCommand(
37
38
  }
38
39
  }
39
40
 
41
+ // A network cut never closes the socket on this side, so the client watches inbound
42
+ // traffic. The API pings every CONTROLLER_PING_INTERVAL_MS and the client pings on
43
+ // each check; Bun surfaces both as `ping` / `pong` events. Three ping intervals
44
+ // without any frame means the link is half-open.
45
+ export const CONTROLLER_LIVENESS_TIMEOUT_MS = 3 * CONTROLLER_PING_INTERVAL_MS;
46
+
40
47
  export function startControllerClient(options: {
41
48
  apiUrl: string;
42
49
  token: string;
43
50
  provider: RuntimeProvider;
44
51
  target: RuntimeTargetConfig;
45
52
  onFatal?: () => void;
53
+ livenessTimeoutMs?: number;
46
54
  }): { stop: () => void } {
55
+ const livenessTimeoutMs = options.livenessTimeoutMs ?? CONTROLLER_LIVENESS_TIMEOUT_MS;
47
56
  const url = new URL("/api/controllers/connect", options.apiUrl);
48
57
  if (url.protocol !== "http:" && url.protocol !== "https:")
49
58
  throw new Error("Controller API URL must use HTTP or HTTPS");
@@ -52,6 +61,7 @@ export function startControllerClient(options: {
52
61
  let stopped = false;
53
62
  let socket: WebSocket | undefined;
54
63
  let reconnect: ReturnType<typeof setTimeout> | undefined;
64
+ let liveness: ReturnType<typeof setInterval> | undefined;
55
65
  let attempts = 0;
56
66
  const log = (message: string) => console.log(scrubSecrets(`[runtime-controller] ${message}`));
57
67
 
@@ -63,7 +73,28 @@ export function startControllerClient(options: {
63
73
  socket = ws;
64
74
  let controller: BuiltinController | undefined;
65
75
  let connectedAt = 0;
76
+ let lastTrafficAt = Date.now();
77
+ const markAlive = () => {
78
+ lastTrafficAt = Date.now();
79
+ };
80
+ ws.addEventListener("ping", markAlive);
81
+ ws.addEventListener("pong", markAlive);
82
+ clearInterval(liveness);
83
+ liveness = setInterval(
84
+ () => {
85
+ if (socket !== ws) return;
86
+ if (Date.now() - lastTrafficAt > livenessTimeoutMs) {
87
+ log(`No traffic for ${livenessTimeoutMs}ms. Dropping the connection`);
88
+ clearInterval(liveness);
89
+ ws.terminate();
90
+ return;
91
+ }
92
+ if (ws.readyState === WebSocket.OPEN) ws.ping();
93
+ },
94
+ Math.ceil(livenessTimeoutMs / 3),
95
+ );
66
96
  ws.onmessage = (event) => {
97
+ markAlive();
67
98
  void (async () => {
68
99
  const message = ControllerServerMessageSchema.parse(JSON.parse(String(event.data)));
69
100
  if (message.type === "welcome") {
@@ -106,6 +137,7 @@ export function startControllerClient(options: {
106
137
  ws.onerror = () => log("Connection failed");
107
138
  ws.onclose = (event) => {
108
139
  if (stopped || socket !== ws) return;
140
+ clearInterval(liveness);
109
141
  if (event.code === 1008) {
110
142
  stopped = true;
111
143
  log("Controller access rejected or token revoked. Mint a token and restart this process.");
@@ -129,6 +161,7 @@ export function startControllerClient(options: {
129
161
  stop: () => {
130
162
  stopped = true;
131
163
  if (reconnect) clearTimeout(reconnect);
164
+ clearInterval(liveness);
132
165
  socket?.close(1000, "Controller stopped");
133
166
  },
134
167
  };
@@ -3352,6 +3352,8 @@ export interface PollOptions {
3352
3352
  pollTimeout: number;
3353
3353
  /** Push scheduling fetches once, then returns control to the runner. */
3354
3354
  singlePoll?: boolean;
3355
+ /** Stop an open poll window before its next claim request. */
3356
+ shouldStop?: () => boolean;
3355
3357
  /** Distinguishes a failed fetch from an empty successful response. */
3356
3358
  onPollFailure?: () => void;
3357
3359
  since?: string; // Optional: for filtering finished tasks
@@ -3669,6 +3671,7 @@ async function pollForTriggerOnce(opts: PollOptions): Promise<Trigger | null> {
3669
3671
  }
3670
3672
 
3671
3673
  while (Date.now() - startTime < opts.pollTimeout) {
3674
+ if (opts.shouldStop?.()) return null;
3672
3675
  try {
3673
3676
  // Build URL with optional since parameter
3674
3677
  let url = `${opts.apiUrl}/api/poll`;
@@ -7099,7 +7102,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
7099
7102
  }
7100
7103
  }
7101
7104
 
7102
- // Only poll if we have capacity and the API is not draining
7105
+ // Only poll if we have capacity and neither the API nor worker is draining
7103
7106
  const schedule = pushDelivery.schedule({
7104
7107
  mode: state.deliveryMode ?? "poll",
7105
7108
  push: deliveryChecks.push,
@@ -7108,7 +7111,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
7108
7111
  exitAfterTasks === null
7109
7112
  ? state.maxConcurrent
7110
7113
  : Math.min(state.maxConcurrent, Math.max(0, exitAfterTasks - state.tasksProcessed)),
7111
- draining: state.apiDraining,
7114
+ draining: state.apiDraining || shuttingDown,
7112
7115
  lastPushPingAt,
7113
7116
  pollInterval: PollIntervalMs,
7114
7117
  });
@@ -7146,6 +7149,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
7146
7149
  runtimeInstanceId,
7147
7150
  pollTimeout: effectiveTimeout,
7148
7151
  singlePoll: schedule.singlePoll,
7152
+ shouldStop: () => shuttingDown,
7149
7153
  onPollFailure,
7150
7154
  harnessProvider: state.harnessProvider,
7151
7155
  onApiDrainSignal: (draining) => applyApiDrainSignal(state, role, draining),
@@ -2,7 +2,8 @@ import { explicitModelErrorForAgent } from "../be/model-validation";
2
2
  import type { RequestInfo } from "../tools/utils";
3
3
  import { type CreateTaskOptions, CreateTaskOptionsSchema } from "../types";
4
4
  import { scrubSecrets } from "../utils/secret-scrubber";
5
- import type { TaskCreateModify, TaskCreateOrigin } from "./contract";
5
+ import { workflowEventBus } from "../workflows/event-bus";
6
+ import type { TaskBlockedEvent, TaskCreateModify, TaskCreateOrigin } from "./contract";
6
7
  import { dispatchPre, extensionIdForAgent, listRegistered } from "./dispatcher";
7
8
 
8
9
  const MODIFIABLE_OPTION_KEYS = [
@@ -69,6 +70,14 @@ function warnDroppedKey(extensionName: string, key: string): void {
69
70
  );
70
71
  }
71
72
 
73
+ function scrubFields<T extends object>(fields: T): T {
74
+ return Object.fromEntries(
75
+ Object.entries(fields)
76
+ .filter(([, value]) => value !== undefined)
77
+ .map(([key, value]) => [key, typeof value === "string" ? scrubSecrets(value) : value]),
78
+ ) as T;
79
+ }
80
+
72
81
  export async function applyPreTaskCreate(
73
82
  args: ApplyPreTaskCreateArgs,
74
83
  ): Promise<ApplyPreTaskCreateResult> {
@@ -80,6 +89,48 @@ export async function applyPreTaskCreate(
80
89
  ? (`extension:${creatingExtension.record.name}` as const)
81
90
  : args.origin;
82
91
 
92
+ const blocked = (
93
+ reason: string,
94
+ extension: TaskBlockedEvent["extension"],
95
+ ): ApplyPreTaskCreateResult => {
96
+ const { options, requestInfo } = args;
97
+ const event: TaskBlockedEvent = {
98
+ extension,
99
+ reason: scrubSecrets(reason),
100
+ origin,
101
+ source: options.source,
102
+ requester: scrubFields({
103
+ userId: options.requestedByUserId,
104
+ agentId: requestInfo?.agentId ?? options.creatorAgentId,
105
+ slackUserId: options.slackUserId,
106
+ }),
107
+ context: scrubFields({
108
+ contextKey: options.contextKey ?? requestInfo?.contextKey,
109
+ scheduleId: options.scheduleId,
110
+ workflowRunId: options.workflowRunId,
111
+ workflowRunStepId: options.workflowRunStepId,
112
+ parentTaskId: options.parentTaskId,
113
+ vcsRepo: options.vcsRepo,
114
+ vcsNumber: options.vcsNumber,
115
+ slackChannelId: options.slackChannelId,
116
+ slackThreadTs: options.slackThreadTs,
117
+ }),
118
+ description: scrubSecrets(args.description).slice(0, 500),
119
+ };
120
+ try {
121
+ workflowEventBus.emit("task.create_blocked", {
122
+ ...event,
123
+ skipExtensionId: creatingExtensionId,
124
+ });
125
+ } catch (error) {
126
+ console.error(
127
+ "[extensions] task.create_blocked event not emitted:",
128
+ scrubSecrets(error instanceof Error ? error.message : String(error)),
129
+ );
130
+ }
131
+ return { kind: "blocked", reason: event.reason, extension };
132
+ };
133
+
83
134
  // The last extension that rewrote `model` or `agentId`. The final pair is re-checked
84
135
  // against the assignee's harness, because the caller validated the pre-hook pair only.
85
136
  let modelEditor: { id: string; name: string } | undefined;
@@ -125,11 +176,7 @@ export async function applyPreTaskCreate(
125
176
  );
126
177
 
127
178
  if (result.action === "block") {
128
- return {
129
- kind: "blocked",
130
- reason: result.reason,
131
- extension: result.extension,
132
- };
179
+ return blocked(result.reason, result.extension);
133
180
  }
134
181
  if (result.action === "continue") {
135
182
  return { kind: "proceed", description: args.description, options: args.options };
@@ -146,11 +193,10 @@ export async function applyPreTaskCreate(
146
193
  agentId: options.agentId,
147
194
  });
148
195
  if (modelError) {
149
- return {
150
- kind: "blocked",
151
- reason: `pre.task.create rewrite from extension "${modelEditor.name}" is invalid: ${modelError}`,
152
- extension: modelEditor,
153
- };
196
+ return blocked(
197
+ `pre.task.create rewrite from extension "${modelEditor.name}" is invalid: ${modelError}`,
198
+ modelEditor,
199
+ );
154
200
  }
155
201
  }
156
202
  return {