@desplega.ai/agent-swarm 1.161.0 → 1.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +3 -2
  2. package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
  3. package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
  4. package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
  5. package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
  6. package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
  7. package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
  8. package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
  9. package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
  10. package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
  11. package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
  12. package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
  13. package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
  14. package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
  15. package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
  16. package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
  17. package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
  18. package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
  19. package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
  20. package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
  21. package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
  22. package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
  23. package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
  24. package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
  25. package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
  26. package/dist/cli-bsky8g7w.js +449 -0
  27. package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
  28. package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
  29. package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
  30. package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
  31. package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
  32. package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
  33. package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
  34. package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
  35. package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
  36. package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
  37. package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
  38. package/dist/cli-kdkwd3yr.js +615 -0
  39. package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
  40. package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
  41. package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
  42. package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
  43. package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
  44. package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
  45. package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
  46. package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
  47. package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
  48. package/dist/cli-qjjvzwzd.js +222 -0
  49. package/dist/cli-qrgt5bqf.js +189 -0
  50. package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
  51. package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
  52. package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
  53. package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
  54. package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
  55. package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
  56. package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
  57. package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
  58. package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
  59. package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
  60. package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
  61. package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
  62. package/dist/cli-wbvnn6tk.js +861 -0
  63. package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
  64. package/dist/cli-wxkkd0e0.js +33 -0
  65. package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
  66. package/dist/cli-zp38xvtm.js +9487 -0
  67. package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
  68. package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
  69. package/dist/cli.js +32 -25
  70. package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
  71. package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
  72. package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
  73. package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
  74. package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
  75. package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
  76. package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
  77. package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
  78. package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
  79. package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
  80. package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
  81. package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
  82. package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
  83. package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
  84. package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
  85. package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
  86. package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
  87. package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
  88. package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
  89. package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
  90. package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
  91. package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
  92. package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
  93. package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
  94. package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
  95. package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
  96. package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
  97. package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
  98. package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
  99. package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
  100. package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
  101. package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
  102. package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
  103. package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
  104. package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
  105. package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
  106. package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
  107. package/dist/rbac-roles-vpk4ebm8.js +45 -0
  108. package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
  109. package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
  110. package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
  111. package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
  112. package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
  113. package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
  114. package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
  115. package/openapi.json +491 -12
  116. package/package.json +13 -6
  117. package/src/be/api-drain.ts +184 -0
  118. package/src/be/db/tasks/read.ts +31 -6
  119. package/src/be/db/tasks/write.ts +5 -4
  120. package/src/be/db.ts +42 -18
  121. package/src/be/memory/link-resolver.ts +29 -3
  122. package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
  123. package/src/be/onboarding.ts +7 -1
  124. package/src/be/seed-prompt-templates.ts +1 -0
  125. package/src/be/swarm-config-guard.ts +35 -16
  126. package/src/be/task-citations.ts +11 -5
  127. package/src/be/users.ts +3 -0
  128. package/src/cli.tsx +3 -2
  129. package/src/comb/agent-fs.ts +39 -0
  130. package/src/comb/config.ts +37 -0
  131. package/src/comb/markers.ts +37 -0
  132. package/src/comb/review-batch.ts +486 -0
  133. package/src/comb/templates.ts +88 -0
  134. package/src/commands/context-preamble.ts +4 -1
  135. package/src/commands/hook.ts +2 -1
  136. package/src/commands/runner.ts +262 -68
  137. package/src/commands/templates.ts +10 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/fs/agent-fs-provider.ts +106 -2
  140. package/src/heartbeat/heartbeat.ts +4 -0
  141. package/src/hooks/codex-hook.ts +25 -1
  142. package/src/hooks/hook.ts +13 -0
  143. package/src/hooks/pr-body-guard.ts +983 -0
  144. package/src/http/all-routes.ts +1 -0
  145. package/src/http/assets.ts +5 -17
  146. package/src/http/comb.ts +131 -0
  147. package/src/http/core.ts +2 -1
  148. package/src/http/fs.ts +9 -17
  149. package/src/http/index.ts +23 -9
  150. package/src/http/onboarding.ts +21 -2
  151. package/src/http/poll.ts +30 -3
  152. package/src/http/realtime.ts +62 -0
  153. package/src/http/request-principal.ts +22 -0
  154. package/src/http/status.ts +34 -2
  155. package/src/kv-reserved-namespaces.ts +10 -5
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/pi-mono-adapter.ts +123 -0
  158. package/src/rbac/legacy-policy.ts +1 -0
  159. package/src/rbac/permissions.ts +4 -0
  160. package/src/realtime/auth.ts +63 -1
  161. package/src/realtime/tickets.ts +52 -0
  162. package/src/realtime/transport.ts +51 -22
  163. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
  164. package/src/telemetry-context.ts +175 -0
  165. package/src/telemetry-contract/EVENTS.md +81 -0
  166. package/src/telemetry-contract/SOURCE.json +9 -0
  167. package/src/telemetry-contract/catalog.json +1335 -0
  168. package/src/telemetry-contract/index.ts +60 -0
  169. package/src/telemetry-contract/types.gen.ts +1971 -0
  170. package/src/telemetry-identity.ts +129 -0
  171. package/src/telemetry-snapshot.ts +211 -0
  172. package/src/telemetry-trigger.ts +125 -0
  173. package/src/telemetry-types.typetest.ts +40 -0
  174. package/src/telemetry.ts +271 -67
  175. package/src/tests/api-drain-e2e.test.ts +249 -0
  176. package/src/tests/api-drain-worker-e2e.test.ts +216 -0
  177. package/src/tests/api-drain.test.ts +351 -0
  178. package/src/tests/comb-config-guard.test.ts +38 -0
  179. package/src/tests/comb-links.test.ts +162 -0
  180. package/src/tests/comb-markers.test.ts +68 -0
  181. package/src/tests/comb-review-batch.test.ts +770 -0
  182. package/src/tests/comb-status.test.ts +152 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/dsh-adapter.test.ts +1 -1
  187. package/src/tests/favorites-agent-fs-path.test.ts +226 -0
  188. package/src/tests/memory-link-resolver.test.ts +34 -0
  189. package/src/tests/onboarding.test.ts +108 -0
  190. package/src/tests/pi-mono-adapter.test.ts +265 -2
  191. package/src/tests/pr-body-leaks.test.ts +789 -0
  192. package/src/tests/preload.ts +8 -0
  193. package/src/tests/rbac-engine.test.ts +19 -0
  194. package/src/tests/realtime-room-auth.test.ts +104 -1
  195. package/src/tests/realtime-tickets.test.ts +74 -0
  196. package/src/tests/realtime-transport.test.ts +131 -4
  197. package/src/tests/runner-api-drain.test.ts +301 -0
  198. package/src/tests/runner-fallback-output.test.ts +61 -0
  199. package/src/tests/runner-fenced-json-output.test.ts +178 -0
  200. package/src/tests/script-connections.test.ts +2 -0
  201. package/src/tests/send-task-requested-by.test.ts +133 -15
  202. package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
  203. package/src/tests/telemetry-context.test.ts +314 -0
  204. package/src/tests/telemetry-init.test.ts +51 -19
  205. package/src/tests/telemetry-snapshot.test.ts +229 -0
  206. package/src/tests/telemetry-trigger.test.ts +200 -0
  207. package/src/tests/template-registry-helpers.ts +1 -0
  208. package/src/tests/workflow-agent-task-retry.test.ts +363 -0
  209. package/src/tests/workflow-definition-telemetry.test.ts +4 -4
  210. package/src/tools/poll-task.ts +20 -1
  211. package/src/tools/send-task.ts +9 -1
  212. package/src/tools/task-action.ts +11 -0
  213. package/src/types.ts +2 -1
  214. package/src/utils/api-drain.ts +24 -0
  215. package/src/utils/constants.ts +57 -16
  216. package/src/utils/pr-body-leaks.ts +172 -0
  217. package/src/utils/task-attachment-links.ts +14 -8
  218. package/src/workflows/recovery.ts +26 -1
  219. package/src/workflows/resume.ts +31 -4
  220. package/src/workflows/task-step-routing.ts +75 -2
  221. package/tsconfig.json +3 -0
  222. package/dist/cli-tjf4cn8z.js +0 -9119
  223. package/dist/cli-tsk3nz8a.js +0 -582
  224. package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
@@ -35,6 +35,7 @@ import {
35
35
  } from "../providers/index.ts";
36
36
  import type { SteerDeliveryResult } from "../providers/types.ts";
37
37
  import { initTelemetry, telemetry } from "../telemetry.ts";
38
+ import { mapTriggerSurface } from "../telemetry-context.ts";
38
39
  import {
39
40
  isTerminalTaskStatus,
40
41
  type ModelTierOverrides,
@@ -45,6 +46,7 @@ import {
45
46
  resolveTaskModelSelection,
46
47
  type SteeringMessage,
47
48
  } from "../types.ts";
49
+ import { isApiDrainingResponse } from "../utils/api-drain.ts";
48
50
  import { getApiKey } from "../utils/api-key.ts";
49
51
  import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
50
52
  import { isCodexAuthFailureReason } from "../utils/codex-auth-failure.ts";
@@ -716,8 +718,12 @@ export function scheduleSteeringDispatch(
716
718
  return true;
717
719
  }
718
720
 
719
- /** Ping the server to indicate activity and update status */
720
- async function pingServer(config: ApiConfig, _role: string): Promise<void> {
721
+ /**
722
+ * Ping the server to indicate activity and update status. Resolves to whether
723
+ * the API is draining, or undefined when it did not answer (down, or a 5xx
724
+ * from a proxy), so a gap in service never reads as "stopped draining".
725
+ */
726
+ export async function pingServer(config: ApiConfig, _role: string): Promise<boolean | undefined> {
721
727
  const headers: Record<string, string> = {
722
728
  "X-Agent-ID": config.agentId,
723
729
  };
@@ -729,12 +735,15 @@ async function pingServer(config: ApiConfig, _role: string): Promise<void> {
729
735
  }
730
736
 
731
737
  try {
732
- await fetch(`${config.apiUrl}/ping`, {
738
+ const response = await fetch(`${config.apiUrl}/ping`, {
733
739
  method: "POST",
734
740
  headers,
735
741
  });
742
+ if (response.status >= 500) return undefined;
743
+ return isApiDrainingResponse(response);
736
744
  } catch {
737
745
  // Silently fail - server might not be running
746
+ return undefined;
738
747
  }
739
748
  }
740
749
 
@@ -1577,7 +1586,7 @@ async function validateProviderOutputIfNeeded(
1577
1586
  config: ApiConfig,
1578
1587
  taskId: string,
1579
1588
  providerOutput: string,
1580
- ): Promise<{ ok: true } | { ok: false; failReason: string }> {
1589
+ ): Promise<{ ok: true; output?: string } | { ok: false; failReason: string }> {
1581
1590
  const headers: Record<string, string> = {
1582
1591
  "Content-Type": "application/json",
1583
1592
  };
@@ -1598,29 +1607,65 @@ async function validateProviderOutputIfNeeded(
1598
1607
  return { ok: true };
1599
1608
  }
1600
1609
 
1601
- let parsed: unknown;
1602
- try {
1603
- parsed = JSON.parse(providerOutput);
1604
- } catch {
1605
- return {
1606
- ok: false,
1607
- failReason:
1608
- "Structured output required by outputSchema but provider output was not valid JSON",
1609
- };
1610
+ // The first candidate is the raw text, so a final message that is already
1611
+ // bare JSON keeps its exact bytes. Later candidates recover JSON that the
1612
+ // model wrapped in a ```json fence or a line of prose.
1613
+ let firstValidationErrors: string[] | undefined;
1614
+ for (const [index, candidate] of jsonOutputCandidates(providerOutput).entries()) {
1615
+ let parsed: unknown;
1616
+ try {
1617
+ parsed = JSON.parse(candidate);
1618
+ } catch {
1619
+ continue;
1620
+ }
1621
+ const validationErrors = validateJsonSchema(taskData.outputSchema, parsed);
1622
+ if (validationErrors.length === 0) {
1623
+ return index === 0 ? { ok: true } : { ok: true, output: candidate };
1624
+ }
1625
+ firstValidationErrors ??= validationErrors;
1610
1626
  }
1611
1627
 
1612
- const validationErrors = validateJsonSchema(taskData.outputSchema, parsed);
1613
- if (validationErrors.length > 0) {
1628
+ if (firstValidationErrors) {
1614
1629
  return {
1615
1630
  ok: false,
1616
- failReason: `Structured output did not match outputSchema: ${validationErrors.join("; ")}`,
1631
+ failReason: `Structured output did not match outputSchema: ${firstValidationErrors.join("; ")}`,
1617
1632
  };
1618
1633
  }
1634
+ return {
1635
+ ok: false,
1636
+ failReason:
1637
+ "Structured output required by outputSchema but provider output was not valid JSON",
1638
+ };
1619
1639
  } catch {
1620
1640
  return { ok: true };
1621
1641
  }
1642
+ }
1622
1643
 
1623
- return { ok: true };
1644
+ /**
1645
+ * Texts that may hold the JSON in an agent's final message, in the order to
1646
+ * try them: the whole message, each fenced code block from last to first, then
1647
+ * the span from the first `{` (or `[`) to the last `}` (or `]`). Agents often
1648
+ * answer a schema-bound task with "Here is the result:" plus a ```json block
1649
+ * instead of calling store-progress, and `JSON.parse` on the whole message
1650
+ * fails on that. Fences run last to first because an earlier fence is often an
1651
+ * example or a draft that also matches the schema, and the answer comes last.
1652
+ */
1653
+ function jsonOutputCandidates(text: string): string[] {
1654
+ const candidates = [text];
1655
+ const fenced: string[] = [];
1656
+ for (const match of text.matchAll(/```[a-zA-Z]*[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/g)) {
1657
+ if (match[1]?.trim()) fenced.push(match[1].trim());
1658
+ }
1659
+ candidates.push(...fenced.reverse());
1660
+ for (const [open, close] of [
1661
+ ["{", "}"],
1662
+ ["[", "]"],
1663
+ ] as const) {
1664
+ const start = text.indexOf(open);
1665
+ const end = text.lastIndexOf(close);
1666
+ if (start >= 0 && end > start) candidates.push(text.slice(start, end + 1));
1667
+ }
1668
+ return [...new Set(candidates)];
1624
1669
  }
1625
1670
 
1626
1671
  export async function ensureTaskFinished(
@@ -1658,7 +1703,7 @@ export async function ensureTaskFinished(
1658
1703
  // the no-providerOutput path and the providerOutput-failed-schema-validation
1659
1704
  // path below, so a schema'd task ending in free-form prose falls through to
1660
1705
  // the same extraction fallback instead of hard-failing.
1661
- const applyFallback = (fallback: FallbackResult) => {
1706
+ const applyFallback = (fallback: FallbackResult, rejectedOutputReason?: string) => {
1662
1707
  console.log(`[${role}] Task ${taskId.slice(0, 8)} fallback result: ${fallback.kind}`);
1663
1708
  switch (fallback.kind) {
1664
1709
  case "extracted":
@@ -1677,7 +1722,11 @@ export async function ensureTaskFinished(
1677
1722
  case "schema-fail":
1678
1723
  status = "failed";
1679
1724
  body.status = "failed";
1680
- body.failureReason = fallback.failReason;
1725
+ // Keep why the provider's final message was rejected; the fallback's
1726
+ // own reason alone ("not provided via store-progress") hides it.
1727
+ body.failureReason = rejectedOutputReason
1728
+ ? `${fallback.failReason}. Final message rejected: ${rejectedOutputReason}`
1729
+ : fallback.failReason;
1681
1730
  break;
1682
1731
  case "fetch-error":
1683
1732
  body.output = `Process completed (could not verify task state: ${fallback.error})`;
@@ -1693,7 +1742,7 @@ export async function ensureTaskFinished(
1693
1742
  } else if (providerOutput) {
1694
1743
  const validation = await validateProviderOutputIfNeeded(config, taskId, providerOutput);
1695
1744
  if (validation.ok) {
1696
- body.output = providerOutput;
1745
+ body.output = validation.output ?? providerOutput;
1697
1746
  } else {
1698
1747
  // The task declared an outputSchema but the provider's final message
1699
1748
  // is free-form prose that doesn't satisfy it. Don't convert this into
@@ -1707,7 +1756,7 @@ export async function ensureTaskFinished(
1707
1756
  adapterType,
1708
1757
  providerOutput,
1709
1758
  );
1710
- applyFallback(fallback);
1759
+ applyFallback(fallback, validation.failReason);
1711
1760
  }
1712
1761
  } else {
1713
1762
  // Try structured output fallback if the task has an outputSchema
@@ -2310,6 +2359,8 @@ function setupShutdownHandlers(
2310
2359
  `[${role}] Superseding ${state.activeTasks.size} remaining task(s) for resume after restart...`,
2311
2360
  );
2312
2361
  for (const [taskId, task] of state.activeTasks) {
2362
+ // Already superseded (or terminal) server-side, e.g. by the API-drain handoff.
2363
+ if (task.serverTerminalStatus) continue;
2313
2364
  console.log(`[${role}] Superseding task ${taskId.slice(0, 8)}`);
2314
2365
  task.session.abort("graceful_shutdown").catch(() => {});
2315
2366
  if (apiConfig) {
@@ -2348,6 +2399,9 @@ function setupShutdownHandlers(
2348
2399
  if (apiConfig) {
2349
2400
  telemetry.session("ended", {
2350
2401
  agentId: apiConfig.agentId,
2402
+ // A runner session spans many tasks, so it belongs to none.
2403
+ taskId: null,
2404
+ trigger_surface: null,
2351
2405
  durationMs: state ? Date.now() - state.startedAt : undefined,
2352
2406
  tasksProcessed: state?.tasksProcessed ?? 0,
2353
2407
  });
@@ -2412,6 +2466,10 @@ export interface RunningTask {
2412
2466
  promise: Promise<ProviderResult>;
2413
2467
  /** The trigger type that caused this task to be spawned */
2414
2468
  triggerType?: string;
2469
+ /** Surface that started the task's whole chain (root task source); tags session telemetry */
2470
+ triggerSurface?: string | null;
2471
+ /** `users.id` of the requester; hashed into `user_ref` on session telemetry */
2472
+ requestedByUserId?: string;
2415
2473
  /** Set when the promise resolves, enabling non-blocking completion checks */
2416
2474
  result: ProviderResult | null;
2417
2475
  /** Deferred cursor updates for channel_activity triggers — committed after success */
@@ -2459,10 +2517,14 @@ export interface RunningTask {
2459
2517
  abortRequestedAt?: number;
2460
2518
  /**
2461
2519
  * Terminal status the server already holds for this task, found by
2462
- * `reconcileActiveTasks`. Completion then only frees the slot and leaves
2463
- * the server's output and failureReason alone.
2520
+ * `reconcileActiveTasks` or set by an API-drain handoff. Completion then only
2521
+ * frees the slot and leaves the server's output and failureReason alone.
2464
2522
  */
2465
2523
  serverTerminalStatus?: string;
2524
+ /** API-drain handoff calls made for this task (see `handOffTasksForApiDrain`). */
2525
+ drainHandoffAttempts?: number;
2526
+ /** The API answered the drain handoff for this task, so it is not retried. */
2527
+ drainHandoffSettled?: boolean;
2466
2528
  }
2467
2529
 
2468
2530
  /** Runner state for tracking concurrent tasks */
@@ -2494,6 +2556,12 @@ export interface RunnerState {
2494
2556
  * value is the reset time in ms. Read by `selectCredential` (T5).
2495
2557
  */
2496
2558
  modelWindowBlocks: Map<string, number>;
2559
+ /**
2560
+ * The API is draining (it sent `X-Swarm-Draining`): in-flight tasks are handed
2561
+ * off and no new work is taken until a response arrives without the header.
2562
+ * Unset against an API that never drains.
2563
+ */
2564
+ apiDraining?: boolean;
2497
2565
  }
2498
2566
 
2499
2567
  /** Buffer for session logs */
@@ -2937,6 +3005,15 @@ async function triggerHeartbeatSweep(config: ApiConfig): Promise<boolean> {
2937
3005
  }
2938
3006
  }
2939
3007
 
3008
+ /**
3009
+ * `trigger_surface` for a worker event: the server-resolved root surface of the
3010
+ * task, or null when the server did not send one (an older server, or a
3011
+ * session with no task).
3012
+ */
3013
+ function workerTriggerSurface(raw: string | null | undefined) {
3014
+ return raw ? mapTriggerSurface(raw) : null;
3015
+ }
3016
+
2940
3017
  /** Trigger types returned by the poll API */
2941
3018
  interface Trigger {
2942
3019
  type:
@@ -2947,6 +3024,8 @@ interface Trigger {
2947
3024
  | "channel_activity"
2948
3025
  | "budget_refused";
2949
3026
  taskId?: string;
3027
+ /** Surface that started the task's whole chain (server-resolved); tags session telemetry. */
3028
+ triggerSurface?: string;
2950
3029
  task?: unknown;
2951
3030
  mentionsCount?: number;
2952
3031
  count?: number;
@@ -2992,7 +3071,7 @@ interface Trigger {
2992
3071
  }
2993
3072
 
2994
3073
  /** Options for polling */
2995
- interface PollOptions {
3074
+ export interface PollOptions {
2996
3075
  apiUrl: string;
2997
3076
  apiKey: string;
2998
3077
  agentId: string;
@@ -3003,6 +3082,8 @@ interface PollOptions {
3003
3082
  since?: string; // Optional: for filtering finished tasks
3004
3083
  /** Live harness provider; keys the MODEL_TIER_* overrides sent with the poll. */
3005
3084
  harnessProvider?: ProviderName;
3085
+ /** Told, on every answered poll, whether the API is draining. */
3086
+ onApiDrainSignal?: (draining: boolean) => void;
3006
3087
  }
3007
3088
 
3008
3089
  /**
@@ -3197,8 +3278,8 @@ export async function registerAgent(opts: {
3197
3278
  return {};
3198
3279
  }
3199
3280
 
3200
- /** Poll for triggers via HTTP API */
3201
- async function pollForTrigger(opts: PollOptions): Promise<Trigger | null> {
3281
+ /** Poll for triggers via HTTP API. Exported for tests. */
3282
+ export async function pollForTrigger(opts: PollOptions): Promise<Trigger | null> {
3202
3283
  if (!isPollTracingEnabled()) {
3203
3284
  return pollForTriggerOnce(opts);
3204
3285
  }
@@ -3262,10 +3343,15 @@ async function pollForTriggerOnce(opts: PollOptions): Promise<Trigger | null> {
3262
3343
  continue;
3263
3344
  }
3264
3345
 
3346
+ const draining = isApiDrainingResponse(response);
3347
+ opts.onApiDrainSignal?.(draining);
3348
+
3265
3349
  const data = (await response.json()) as { trigger: Trigger | null };
3266
3350
  if (data.trigger) {
3267
3351
  return data.trigger;
3268
3352
  }
3353
+ // A draining API dispatches nothing: end the window so the loop can hand off.
3354
+ if (draining) return null;
3269
3355
  } catch (error) {
3270
3356
  console.warn(`[runner] Poll request error: ${error}`);
3271
3357
  }
@@ -3722,6 +3808,10 @@ async function spawnProviderProcess(
3722
3808
  runnerSessionId: string;
3723
3809
  iteration: number;
3724
3810
  taskId?: string;
3811
+ /** Surface that started the task chain, for session telemetry. */
3812
+ triggerSurface?: string | null;
3813
+ /** `users.id` of the requester; hashed into `user_ref` on session telemetry. */
3814
+ requestedByUserId?: string;
3725
3815
  model?: string;
3726
3816
  modelTier?: string;
3727
3817
  /** Server claim-time resolution (task.resolvedModel); wins over the local one. */
@@ -4496,21 +4586,28 @@ async function spawnProviderProcess(
4496
4586
  "gen_ai.usage.output_tokens": result.cost.outputTokens ?? 0,
4497
4587
  "agentswarm.cost.total_usd": result.cost.totalCostUsd ?? 0,
4498
4588
  });
4499
- telemetry.session("cost", {
4500
- agentId: opts.agentId,
4501
- model: result.cost.model,
4502
- provider: result.cost.provider ?? opts.harnessProvider,
4503
- inputTokens: result.cost.inputTokens ?? 0,
4504
- outputTokens: result.cost.outputTokens ?? 0,
4505
- cacheReadTokens: result.cost.cacheReadTokens,
4506
- cacheWriteTokens: result.cost.cacheWriteTokens,
4507
- reasoningOutputTokens: result.cost.reasoningOutputTokens,
4508
- thinkingTokens: result.cost.thinkingTokens,
4509
- totalCostUsd: result.cost.totalCostUsd,
4510
- durationMs: result.cost.durationMs,
4511
- numTurns: result.cost.numTurns,
4512
- isError: result.cost.isError,
4513
- });
4589
+ telemetry.session(
4590
+ "cost",
4591
+ {
4592
+ agentId: opts.agentId,
4593
+ // Lets cost be split by the surface that started the work.
4594
+ taskId: realTaskId ?? null,
4595
+ trigger_surface: workerTriggerSurface(opts.triggerSurface),
4596
+ model: result.cost.model,
4597
+ provider: result.cost.provider ?? opts.harnessProvider,
4598
+ inputTokens: result.cost.inputTokens ?? 0,
4599
+ outputTokens: result.cost.outputTokens ?? 0,
4600
+ cacheReadTokens: result.cost.cacheReadTokens,
4601
+ cacheWriteTokens: result.cost.cacheWriteTokens,
4602
+ reasoningOutputTokens: result.cost.reasoningOutputTokens,
4603
+ thinkingTokens: result.cost.thinkingTokens,
4604
+ totalCostUsd: result.cost.totalCostUsd,
4605
+ durationMs: result.cost.durationMs,
4606
+ numTurns: result.cost.numTurns,
4607
+ isError: result.cost.isError,
4608
+ },
4609
+ { userId: opts.requestedByUserId ?? null },
4610
+ );
4514
4611
  try {
4515
4612
  await saveCostData(
4516
4613
  { ...result.cost, taskId: realTaskId, sessionId: opts.runnerSessionId },
@@ -4635,6 +4732,8 @@ async function spawnProviderProcess(
4635
4732
  startTime: new Date(),
4636
4733
  promise,
4637
4734
  result: null,
4735
+ triggerSurface: opts.triggerSurface,
4736
+ requestedByUserId: opts.requestedByUserId,
4638
4737
  credentialInfo,
4639
4738
  // Snapshot the provider + local-env trait of the adapter this session is
4640
4739
  // spawned on, so the session-end sync decision survives a live provider
@@ -4697,6 +4796,69 @@ function requestSessionAbort(task: RunningTask, reason: string, at: number): voi
4697
4796
  .catch(() => {});
4698
4797
  }
4699
4798
 
4799
+ /** Handoff calls per task while the API drains; a call the API answered is never repeated. */
4800
+ export const API_DRAIN_HANDOFF_MAX_ATTEMPTS = 3;
4801
+
4802
+ /**
4803
+ * Fold the API's drain signal (the `X-Swarm-Draining` header on a ping or poll
4804
+ * answer) into `state`. `undefined` means the API did not answer: keep the last
4805
+ * known state. Exported for tests.
4806
+ */
4807
+ export function applyApiDrainSignal(
4808
+ state: RunnerState,
4809
+ role: string,
4810
+ draining: boolean | undefined,
4811
+ ): void {
4812
+ if (draining === undefined) return;
4813
+ if (draining && !state.apiDraining) {
4814
+ console.log(`[${role}] API is draining: handing off in-flight tasks, taking no new work`);
4815
+ } else if (!draining && state.apiDraining) {
4816
+ console.log(`[${role}] API stopped draining: resuming normal polling`);
4817
+ }
4818
+ state.apiDraining = draining;
4819
+ }
4820
+
4821
+ /**
4822
+ * The API is draining, so it still serves but will stop soon: supersede each
4823
+ * in-flight task now, while the call can land, instead of in the SIGTERM
4824
+ * handler, which compose runs after the API is gone. Same API call and reason
4825
+ * as the SIGTERM handoff, so the resume follow-up is identical.
4826
+ *
4827
+ * A task the API superseded is marked server-terminal and its session aborted;
4828
+ * completion then frees the slot without a finish call. A failed call leaves
4829
+ * the task running, up to `API_DRAIN_HANDOFF_MAX_ATTEMPTS`, and the SIGTERM
4830
+ * handler stays the fallback. Exported for tests.
4831
+ */
4832
+ export async function handOffTasksForApiDrain(
4833
+ state: RunnerState,
4834
+ role: string,
4835
+ apiConfig: ApiConfig,
4836
+ ): Promise<void> {
4837
+ for (const [taskId, task] of state.activeTasks) {
4838
+ // A settled session reports its own result through `checkCompletedProcesses`.
4839
+ if (task.result !== null || task.drainHandoffSettled) continue;
4840
+ const attempts = task.drainHandoffAttempts ?? 0;
4841
+ if (attempts >= API_DRAIN_HANDOFF_MAX_ATTEMPTS) continue;
4842
+ task.drainHandoffAttempts = attempts + 1;
4843
+
4844
+ const outcome = await supersedeTaskViaAPI(apiConfig, role, taskId, "graceful_shutdown");
4845
+ if (!outcome.ok) {
4846
+ console.warn(
4847
+ `[${role}] Drain handoff for task ${taskId.slice(0, 8)} failed (attempt ${attempts + 1}/${API_DRAIN_HANDOFF_MAX_ATTEMPTS}); the task keeps running`,
4848
+ );
4849
+ continue;
4850
+ }
4851
+ task.drainHandoffSettled = true;
4852
+ if (outcome.kind === "resumed" || outcome.kind === "workflow-failed") {
4853
+ task.serverTerminalStatus = outcome.kind === "resumed" ? "superseded" : "failed";
4854
+ console.log(`[${role}] Handed off task ${taskId.slice(0, 8)} ahead of the API stopping`);
4855
+ requestSessionAbort(task, "graceful_shutdown", Date.now());
4856
+ }
4857
+ // "alreadyFinished": the task ended on its own. "rejected": the API refused
4858
+ // the supersede on purpose. Either way the session follows its normal path.
4859
+ }
4860
+ }
4861
+
4700
4862
  /**
4701
4863
  * Keep `state.activeTasks` in line with the server, so a session whose promise
4702
4864
  * never settles cannot hold an execution slot forever:
@@ -4794,6 +4956,8 @@ export async function checkCompletedProcesses(
4794
4956
  taskId: string;
4795
4957
  result: ProviderResult;
4796
4958
  triggerType?: string;
4959
+ triggerSurface?: string | null;
4960
+ requestedByUserId?: string;
4797
4961
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
4798
4962
  workingDir?: string;
4799
4963
  credentialInfo?: RunningTask["credentialInfo"];
@@ -4817,6 +4981,8 @@ export async function checkCompletedProcesses(
4817
4981
  taskId,
4818
4982
  result: task.result,
4819
4983
  triggerType: task.triggerType,
4984
+ triggerSurface: task.triggerSurface,
4985
+ requestedByUserId: task.requestedByUserId,
4820
4986
  cursorUpdates: task.cursorUpdates,
4821
4987
  workingDir: task.workingDir,
4822
4988
  credentialInfo: task.credentialInfo,
@@ -4836,6 +5002,8 @@ export async function checkCompletedProcesses(
4836
5002
  for (const {
4837
5003
  taskId,
4838
5004
  result,
5005
+ triggerSurface,
5006
+ requestedByUserId,
4839
5007
  cursorUpdates,
4840
5008
  workingDir,
4841
5009
  credentialInfo,
@@ -4951,31 +5119,44 @@ export async function checkCompletedProcesses(
4951
5119
  ),
4952
5120
  );
4953
5121
 
4954
- telemetry.taskEvent("session_completed", {
4955
- taskId,
4956
- agentId: apiConfig.agentId,
4957
- provider: result.cost?.provider ?? harnessProvider,
4958
- model: result.cost?.model ?? model,
4959
- harnessVariant,
4960
- harnessVersion:
4961
- typeof harnessVariantMeta?.version === "string" ||
4962
- typeof harnessVariantMeta?.version === "number"
4963
- ? String(harnessVariantMeta.version)
4964
- : undefined,
4965
- exitCode: result.exitCode,
4966
- isError: result.exitCode !== 0,
4967
- durationMs,
4968
- });
4969
- const sessionTelemetryEvent = resolveSessionTelemetryEvent(result);
4970
- if (sessionTelemetryEvent) {
4971
- telemetry.session(sessionTelemetryEvent, {
5122
+ const sessionTriggerSurface = workerTriggerSurface(triggerSurface);
5123
+ const sessionActor = { userId: requestedByUserId ?? null };
5124
+ telemetry.taskEvent(
5125
+ "session_completed",
5126
+ {
5127
+ taskId,
5128
+ trigger_surface: sessionTriggerSurface,
4972
5129
  agentId: apiConfig.agentId,
4973
- errorCategory: normalizeSessionErrorCategory(result.errorCategory),
4974
5130
  provider: result.cost?.provider ?? harnessProvider,
4975
- model: result.cost?.model ?? model,
4976
- durationMs: result.cost?.durationMs ?? durationMs,
4977
- wasRateLimited: result.rateLimitResetAt != null,
4978
- });
5131
+ model: result.cost?.model ?? model ?? "unknown",
5132
+ harnessVariant,
5133
+ harnessVersion:
5134
+ typeof harnessVariantMeta?.version === "string" ||
5135
+ typeof harnessVariantMeta?.version === "number"
5136
+ ? String(harnessVariantMeta.version)
5137
+ : undefined,
5138
+ exitCode: result.exitCode,
5139
+ isError: result.exitCode !== 0,
5140
+ durationMs,
5141
+ },
5142
+ sessionActor,
5143
+ );
5144
+ const sessionTelemetryEvent = resolveSessionTelemetryEvent(result);
5145
+ if (sessionTelemetryEvent) {
5146
+ telemetry.session(
5147
+ sessionTelemetryEvent,
5148
+ {
5149
+ agentId: apiConfig.agentId,
5150
+ taskId,
5151
+ trigger_surface: sessionTriggerSurface,
5152
+ errorCategory: normalizeSessionErrorCategory(result.errorCategory),
5153
+ provider: result.cost?.provider ?? harnessProvider,
5154
+ model: result.cost?.model ?? model ?? "unknown",
5155
+ durationMs: result.cost?.durationMs ?? durationMs,
5156
+ wasRateLimited: result.rateLimitResetAt != null,
5157
+ },
5158
+ sessionActor,
5159
+ );
4979
5160
  }
4980
5161
  state.tasksProcessed += 1;
4981
5162
 
@@ -5245,6 +5426,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
5245
5426
  }
5246
5427
  telemetry.session("started", {
5247
5428
  agentId,
5429
+ // The boot session is not tied to a task.
5430
+ taskId: null,
5431
+ trigger_surface: null,
5248
5432
  harnessProvider: bootProvider,
5249
5433
  model: bootModel || undefined,
5250
5434
  role,
@@ -6307,8 +6491,13 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
6307
6491
  const taskReconcileIntervalMs = resolveTaskReconcileIntervalMs();
6308
6492
 
6309
6493
  while (true) {
6310
- // Ping server on each iteration to keep status updated
6311
- await pingServer(apiConfig, role);
6494
+ // Ping server on each iteration to keep status updated. The answer also
6495
+ // carries the API's drain signal, which reaches workers at capacity that
6496
+ // do not poll.
6497
+ applyApiDrainSignal(state, role, await pingServer(apiConfig, role));
6498
+ if (state.apiDraining) {
6499
+ await handOffTasksForApiDrain(state, role, apiConfig);
6500
+ }
6312
6501
 
6313
6502
  // Check for completed processes first and ensure tasks are marked as finished
6314
6503
  await checkCompletedProcesses(state, role, apiConfig, cancelledSignaled);
@@ -6412,8 +6601,10 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
6412
6601
  }
6413
6602
  }
6414
6603
 
6415
- // Only poll if we have capacity
6416
- if (state.activeTasks.size < state.maxConcurrent) {
6604
+ // Only poll if we have capacity and the API is not draining
6605
+ if (state.apiDraining) {
6606
+ await Bun.sleep(1000);
6607
+ } else if (state.activeTasks.size < state.maxConcurrent) {
6417
6608
  if (Date.now() - lastOrphanRecoveryAt > ORPHAN_RECOVERY_INTERVAL_MS) {
6418
6609
  lastOrphanRecoveryAt = Date.now();
6419
6610
  const recoveredOrphans = await recoverOrphanedInProgressTasks(apiConfig);
@@ -6439,6 +6630,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
6439
6630
  runtimeInstanceId,
6440
6631
  pollTimeout: effectiveTimeout,
6441
6632
  harnessProvider: state.harnessProvider,
6633
+ onApiDrainSignal: (draining) => applyApiDrainSignal(state, role, draining),
6442
6634
  });
6443
6635
 
6444
6636
  if (trigger) {
@@ -6792,6 +6984,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
6792
6984
  runnerSessionId: taskRunnerSessionId,
6793
6985
  iteration,
6794
6986
  taskId: trigger.taskId,
6987
+ triggerSurface: trigger.triggerSurface,
6988
+ requestedByUserId: trigger.requestedBy?.id,
6795
6989
  model: taskModel,
6796
6990
  modelTier: taskModelTier,
6797
6991
  resolvedModel: (trigger.task as { resolvedModel?: string } | undefined)
@@ -72,6 +72,16 @@ Audit event: {{event_id}}. {{recovery_guidance}}
72
72
  category: "task_lifecycle",
73
73
  });
74
74
 
75
+ // Sent at most once per session by the pi adapter when the model's final turn
76
+ // carries no text and no tool call (see PiMonoSession.repromptAfterEmptyFinalTurn).
77
+ registerTemplate({
78
+ eventType: "task.nudge.empty_final_turn",
79
+ header: "",
80
+ defaultBody: `Your last turn returned no text and no tool call. Finish now: if the task needs a result, call store-progress with it.`,
81
+ variables: [],
82
+ category: "task_lifecycle",
83
+ });
84
+
75
85
  registerTemplate({
76
86
  eventType: "task.trigger.unread_mentions",
77
87
  header: "",