@desplega.ai/agent-swarm 1.131.0 → 1.133.1

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 (265) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-w53mbns9.js → actions-cskpayd7.js} +11 -9
  3. package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
  4. package/dist/{app-m9gwqsqv.js → app-ang77nbb.js} +8 -6
  5. package/dist/{assistant-bp65m6f3.js → assistant-b4exebdw.js} +16 -13
  6. package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
  7. package/dist/{boot-reembed-62p0at2k.js → boot-reembed-ps2aftpb.js} +9 -7
  8. package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-znyk3nxb.js} +10 -8
  9. package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
  10. package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-3ct8kgxw.js} +13 -9
  11. package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-akx4ntf2.js} +2 -2
  12. package/dist/{cli-t1xze5qh.js → cli-13w2hwkh.js} +1 -1
  13. package/dist/{cli-efxfnq6q.js → cli-1532szb1.js} +8 -6
  14. package/dist/{cli-d2tfkcga.js → cli-23d3rseb.js} +33 -17
  15. package/dist/{cli-nm5gtp1x.js → cli-2a75z9c5.js} +196 -7
  16. package/dist/{cli-fywms69x.js → cli-2t30rj0d.js} +1 -1
  17. package/dist/{cli-cvgjwnh1.js → cli-3c3tdgab.js} +10 -10
  18. package/dist/{cli-pm6xbh84.js → cli-3zzxa9jz.js} +46 -16
  19. package/dist/{cli-cdeg08wx.js → cli-4k6233gc.js} +1 -1
  20. package/dist/{cli-t55yw3a1.js → cli-5p2tz05r.js} +2 -2
  21. package/dist/{cli-0rcyq6d9.js → cli-5z2yhzvd.js} +125 -47
  22. package/dist/{cli-3kf51j15.js → cli-6zfq31nj.js} +9 -3
  23. package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
  24. package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
  25. package/dist/{cli-72q8m8gk.js → cli-9nfc2es8.js} +6 -6
  26. package/dist/{cli-5ze9rv97.js → cli-9ta3pwms.js} +11 -4
  27. package/dist/{cli-78xwvkhj.js → cli-avr0ns7z.js} +2 -2
  28. package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
  29. package/dist/{cli-2q72qz2s.js → cli-dgk20ft7.js} +15 -6
  30. package/dist/cli-et1bbwqf.js +13 -0
  31. package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
  32. package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
  33. package/dist/{cli-e7ckcg4v.js → cli-ftwpv451.js} +170 -387
  34. package/dist/{cli-g6b8x1h8.js → cli-g854s2dw.js} +1 -1
  35. package/dist/{cli-5kx36evn.js → cli-ggmdxeqa.js} +13 -8
  36. package/dist/cli-hkwfe3qj.js +530 -0
  37. package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
  38. package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
  39. package/dist/{cli-5v4rw6yd.js → cli-pvzqhbs9.js} +2 -2
  40. package/dist/cli-pzq5rjym.js +15 -0
  41. package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
  42. package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
  43. package/dist/cli-qys3rhhz.js +27 -0
  44. package/dist/{cli-rzbpmxdr.js → cli-r6tpm37h.js} +2 -2
  45. package/dist/{cli-qv61hp37.js → cli-rch80c76.js} +2 -2
  46. package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
  47. package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
  48. package/dist/{cli-psh3c4va.js → cli-sfd9v7sj.js} +1 -1
  49. package/dist/{cli-70xqj0z3.js → cli-sh3ytsyj.js} +49 -39
  50. package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
  51. package/dist/{cli-d3x3x1c9.js → cli-xdb4mpnp.js} +2 -2
  52. package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
  53. package/dist/{cli-yw8vghx2.js → cli-xv8t8p3c.js} +65 -32
  54. package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
  55. package/dist/{cli-t76vw5db.js → cli-y08jvxzx.js} +601 -140
  56. package/dist/{cli-a4jzdpa3.js → cli-zfbf3srj.js} +650 -245
  57. package/dist/{cli-a40rnf71.js → cli-zmq89k34.js} +6 -6
  58. package/dist/{cli-qmdnhpej.js → cli-zr9q011v.js} +23 -13
  59. package/dist/cli.js +22 -22
  60. package/dist/{codex-adapter-d24q75wk.js → codex-adapter-bef93pq4.js} +9 -7
  61. package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
  62. package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-5pkxys7x.js} +9 -7
  63. package/dist/{commands-c3nrc5hg.js → commands-e23s1bff.js} +9 -7
  64. package/dist/{db-nt7fg3tw.js → db-m3nabatn.js} +17 -5
  65. package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
  66. package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
  67. package/dist/{handlers-fkr6swat.js → handlers-akazedvr.js} +16 -13
  68. package/dist/{hook-77wpvnjc.js → hook-ze4cpf0v.js} +55 -41
  69. package/dist/{hook-g019yehy.js → hook-zfqyyenw.js} +10 -7
  70. package/dist/{http-jewj0syk.js → http-sja0n6kw.js} +730 -150
  71. package/dist/index-0gr8vd6k.js +39 -0
  72. package/dist/{index-7n2xpbd4.js → index-354vkhdv.js} +154 -74
  73. package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
  74. package/dist/{index-hfvggd0z.js → index-95t3cqq5.js} +16 -14
  75. package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
  76. package/dist/{index-6gm5he8h.js → index-vpn36mma.js} +15 -13
  77. package/dist/{keepalive-j0fjvqfd.js → keepalive-3sp3meqj.js} +16 -10
  78. package/dist/{lead-1n8x30xe.js → lead-mefeewkf.js} +39 -36
  79. package/dist/{maintenance-gvev11sa.js → maintenance-8tbrbjfg.js} +10 -8
  80. package/dist/mistral-conversations-00zrp7g4.js +717 -0
  81. package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
  82. package/dist/{onboard-pvm5b38w.js → onboard-mt2tgyjg.js} +7 -7
  83. package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
  84. package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
  85. package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
  86. package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
  87. package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
  88. package/dist/{otel-impl-p6jvgd9c.js → otel-impl-z6pxt3j9.js} +224 -9
  89. package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-btvqmj58.js} +1138 -408
  90. package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-kyq8cezh.js} +9 -7
  91. package/dist/{rbac-roles-cr80g96t.js → rbac-roles-3ryfrfna.js} +8 -6
  92. package/dist/{rbac-roles-30pspvy7.js → rbac-roles-6qfvbee1.js} +9 -7
  93. package/dist/{render-v2-xm457ng1.js → render-v2-p61ppjey.js} +8 -6
  94. package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-j56bbj4y.js} +8 -6
  95. package/dist/{setup-nytnmwwr.js → setup-tpr6c1kn.js} +2 -2
  96. package/dist/{worker-msytr2cw.js → worker-9w1v0mbb.js} +39 -36
  97. package/openapi.json +542 -16
  98. package/package.json +6 -6
  99. package/src/be/budget-refusal-notify.ts +14 -7
  100. package/src/be/db-queries/task-artifacts.ts +182 -0
  101. package/src/be/db.ts +783 -169
  102. package/src/be/events.ts +5 -0
  103. package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
  104. package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
  105. package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
  106. package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
  107. package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
  108. package/src/be/migrations/136_task_requester_provenance.sql +37 -0
  109. package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
  110. package/src/be/multi-runtime.ts +430 -0
  111. package/src/be/scripts/db.ts +43 -1
  112. package/src/be/scripts/extract-schema.ts +3 -1
  113. package/src/be/scripts/retention.ts +164 -0
  114. package/src/be/scripts/run-saved.ts +16 -1
  115. package/src/be/scripts/typecheck.ts +3 -2
  116. package/src/be/seed-skills/index.ts +7 -0
  117. package/src/be/steering.ts +1 -1
  118. package/src/be/swarm-config-guard.ts +18 -0
  119. package/src/commands/credential-wait.ts +54 -0
  120. package/src/commands/onboard/steps/core-credentials.tsx +1 -1
  121. package/src/commands/onboard/steps/custom-templates.tsx +1 -1
  122. package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
  123. package/src/commands/onboard/steps/health-check.tsx +1 -1
  124. package/src/commands/onboard/steps/start.tsx +1 -1
  125. package/src/commands/profile-sync-audit.ts +56 -0
  126. package/src/commands/profile-sync.ts +287 -15
  127. package/src/commands/provider-credentials.ts +46 -19
  128. package/src/commands/runner.ts +196 -33
  129. package/src/commands/templates.ts +22 -0
  130. package/src/github/handlers.ts +55 -9
  131. package/src/heartbeat/heartbeat.ts +71 -12
  132. package/src/hooks/hook.ts +49 -33
  133. package/src/http/active-sessions.ts +11 -1
  134. package/src/http/agents.ts +261 -30
  135. package/src/http/all-routes.ts +1 -0
  136. package/src/http/config.ts +13 -2
  137. package/src/http/core.ts +102 -4
  138. package/src/http/db-query-bounded.ts +313 -0
  139. package/src/http/db-query-shared.ts +249 -0
  140. package/src/http/db-query.ts +54 -42
  141. package/src/http/events.ts +2 -0
  142. package/src/http/index.ts +27 -2
  143. package/src/http/mcp-bridge.ts +37 -3
  144. package/src/http/mcp.ts +12 -0
  145. package/src/http/memory.ts +13 -2
  146. package/src/http/openapi.ts +1 -0
  147. package/src/http/poll.ts +70 -27
  148. package/src/http/route-def.ts +43 -9
  149. package/src/http/schedules.ts +2 -2
  150. package/src/http/scripts.ts +31 -4
  151. package/src/http/session-data.ts +44 -0
  152. package/src/http/tasks.ts +2 -2
  153. package/src/http/users.ts +74 -52
  154. package/src/http/webhooks.ts +5 -2
  155. package/src/http/workflows.ts +1 -4
  156. package/src/http/x.ts +14 -0
  157. package/src/integrations/kapso/inbound.ts +1 -1
  158. package/src/jira/outbound.ts +39 -8
  159. package/src/jira/webhook-lifecycle.ts +13 -2
  160. package/src/linear/outbound.ts +43 -10
  161. package/src/oauth/keepalive.ts +13 -2
  162. package/src/prompts/base-prompt.ts +1 -1
  163. package/src/prompts/session-templates.ts +45 -7
  164. package/src/providers/claude-adapter.ts +6 -0
  165. package/src/providers/claude-managed-models.ts +34 -10
  166. package/src/providers/codex-adapter.ts +6 -0
  167. package/src/providers/opencode-adapter.ts +6 -0
  168. package/src/providers/pi-mono-adapter.ts +8 -0
  169. package/src/providers/pricing-sources.md +24 -0
  170. package/src/queue-stall-alarm.ts +216 -0
  171. package/src/scheduler/scheduler.ts +7 -0
  172. package/src/script-workflows/executor.ts +2 -1
  173. package/src/scripts-runtime/executors/native.ts +2 -1
  174. package/src/scripts-runtime/executors/types.ts +5 -0
  175. package/src/scripts-runtime/loader.ts +5 -0
  176. package/src/scripts-runtime/swarm-config.ts +8 -0
  177. package/src/scripts-runtime/swarm-sdk.ts +7 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/tests/budget-claim-gate.test.ts +20 -0
  181. package/src/tests/claude-adapter.test.ts +32 -0
  182. package/src/tests/claude-managed-adapter.test.ts +38 -7
  183. package/src/tests/codex-adapter.test.ts +30 -0
  184. package/src/tests/create-task-validation.test.ts +128 -0
  185. package/src/tests/credential-wait.test.ts +67 -1
  186. package/src/tests/db-query.test.ts +711 -2
  187. package/src/tests/http-api-integration.test.ts +81 -0
  188. package/src/tests/http-users.test.ts +166 -1
  189. package/src/tests/identity-field-budget.test.ts +176 -0
  190. package/src/tests/mcp-tools-user.test.ts +34 -0
  191. package/src/tests/mcp-user-route.test.ts +17 -0
  192. package/src/tests/metrics-http.test.ts +44 -0
  193. package/src/tests/multi-runtime-registration.test.ts +2703 -0
  194. package/src/tests/openapi-response-contract.test.ts +24 -0
  195. package/src/tests/openapi-route-headers.test.ts +83 -0
  196. package/src/tests/opencode-adapter.test.ts +40 -0
  197. package/src/tests/outbound-listener-rejection.test.ts +138 -0
  198. package/src/tests/pi-mono-adapter.test.ts +98 -0
  199. package/src/tests/profile-sync-warning.test.ts +277 -0
  200. package/src/tests/profile-sync.test.ts +232 -6
  201. package/src/tests/prompt-template-session.test.ts +32 -3
  202. package/src/tests/queue-stall-alarm.test.ts +226 -0
  203. package/src/tests/requester-comms.test.ts +32 -0
  204. package/src/tests/runner-requester-profile.test.ts +38 -0
  205. package/src/tests/sandboxed-process.test.ts +45 -0
  206. package/src/tests/schedule-target-type.test.ts +38 -0
  207. package/src/tests/script-connections.test.ts +16 -3
  208. package/src/tests/scripts-external-api.test.ts +8 -3
  209. package/src/tests/scripts-http.test.ts +12 -0
  210. package/src/tests/scripts-retention.test.ts +384 -0
  211. package/src/tests/scripts-runtime-identity.test.ts +375 -0
  212. package/src/tests/scripts-runtime.test.ts +4 -3
  213. package/src/tests/sdk-allowlist.test.ts +47 -0
  214. package/src/tests/send-task-requested-by.test.ts +9 -0
  215. package/src/tests/session-costs.test.ts +506 -1
  216. package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
  217. package/src/tests/steering-core.test.ts +28 -0
  218. package/src/tests/task-artifacts.test.ts +386 -0
  219. package/src/tests/task-attachments-schema.test.ts +17 -1
  220. package/src/tests/task-completion-idempotency.test.ts +57 -0
  221. package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
  222. package/src/tests/update-profile-api.test.ts +67 -39
  223. package/src/tests/update-profile-auth.test.ts +48 -4
  224. package/src/tests/user-attribution-migration.test.ts +54 -0
  225. package/src/tests/user-identity.test.ts +72 -0
  226. package/src/tests/workflow-async-v2.test.ts +39 -1
  227. package/src/tests/workflow-http-v2.test.ts +3 -2
  228. package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
  229. package/src/tests/workflow-retry-v2.test.ts +45 -1
  230. package/src/tests/workflow-swarm-script.test.ts +14 -5
  231. package/src/tools/db-query.ts +18 -8
  232. package/src/tools/get-task-details.ts +24 -3
  233. package/src/tools/manage-user.ts +36 -3
  234. package/src/tools/poll-task.ts +93 -14
  235. package/src/tools/script-common.ts +5 -0
  236. package/src/tools/send-task.ts +1 -4
  237. package/src/tools/store-progress.ts +13 -2
  238. package/src/tools/swarm-config/delete-config.ts +9 -0
  239. package/src/tools/swarm-config/set-config.ts +3 -2
  240. package/src/tools/task-action.ts +30 -0
  241. package/src/tools/task-tool-ctx.ts +8 -1
  242. package/src/tools/update-profile.ts +16 -21
  243. package/src/tools/utils.ts +5 -0
  244. package/src/tools/workflows/trigger-workflow.ts +7 -1
  245. package/src/types.ts +165 -1
  246. package/src/utils/github-pull-request.ts +76 -0
  247. package/src/utils/identity-field-budget.ts +73 -0
  248. package/src/utils/multi-runtime.ts +20 -0
  249. package/src/utils/requester-comms.ts +24 -0
  250. package/src/utils/sandboxed-process.ts +40 -14
  251. package/src/workflows/engine.ts +22 -2
  252. package/src/workflows/executors/swarm-script.ts +24 -1
  253. package/src/workflows/resume.ts +19 -11
  254. package/src/workflows/retry-poller.ts +1 -0
  255. package/src/workflows/triggers.ts +4 -0
  256. package/templates/schedules/daily-compounding-reflection/content.md +12 -3
  257. package/templates/skills/db-query-guidance/config.json +14 -0
  258. package/templates/skills/db-query-guidance/content.md +57 -0
  259. package/dist/cli-3te4jdkm.js +0 -225
  260. package/dist/cli-bmze986d.js +0 -7
  261. package/dist/cli-kh3ba2zf.js +0 -607
  262. package/dist/index-hp1kdbjj.js +0 -36
  263. package/dist/mistral-conversations-81fwav0j.js +0 -30578
  264. package/dist/otel-nqa12wf3.js +0 -3149
  265. package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
package/src/be/db.ts CHANGED
@@ -116,17 +116,26 @@ import type {
116
116
  } from "../types";
117
117
  import {
118
118
  AgentAvatarSchema,
119
+ type CreateTaskOptions,
120
+ CreateTaskOptionsSchema,
119
121
  FollowUpConfigSchema,
120
122
  isTerminalTaskStatus,
121
123
  type ModelTier,
122
124
  parseModelTier,
123
125
  ReasoningEffortSchema,
124
126
  RoutingAffinitySchema,
127
+ SERVER_GENERATED_ATTACHMENT_CAPABILITY,
125
128
  SessionCostModelBreakdownSchema,
126
129
  } from "../types";
127
130
  import { deriveProviderFromKeyType } from "../utils/credentials";
128
131
  import { isEnvFlagEnabled } from "../utils/env-flag";
129
132
  import type { RateLimitWindowTelemetry } from "../utils/error-tracker";
133
+ import { extractGitHubPullRequestUrls } from "../utils/github-pull-request";
134
+ import {
135
+ type BudgetedIdentityField,
136
+ checkIdentityFieldBudget,
137
+ IdentityFieldBudgetError,
138
+ } from "../utils/identity-field-budget";
130
139
  import { getCurrentRequestUserId } from "../utils/request-auth-context";
131
140
  import { scrubSecrets } from "../utils/secret-scrubber";
132
141
  import { auditAssetKeys, enforceAssetKeyStartupAudit } from "./asset-key-audit";
@@ -181,6 +190,40 @@ export function isSqliteVecAvailable(): boolean {
181
190
  return sqliteVecAvailable;
182
191
  }
183
192
 
193
+ /**
194
+ * Resolve the sqlite-vec loadable extension path without opening a Database.
195
+ * Mirrors loadSqliteVec's env-var-first, npm-fallback resolution, but returns
196
+ * the path instead of loading it: the bounded db-query child process (see
197
+ * src/http/db-query-bounded.ts) opens its own read-only connection and must
198
+ * load the extension itself, so the parent resolves the path once and passes
199
+ * it along. The result is memoized — `undefined` is a valid resolved answer
200
+ * (extension genuinely unavailable) and is cached too, so a failing
201
+ * `require` doesn't retry on every query.
202
+ */
203
+ let cachedSqliteVecExtensionPath: string | undefined | null = null;
204
+
205
+ export function resolveSqliteVecExtensionPath(): string | undefined {
206
+ if (cachedSqliteVecExtensionPath !== null) return cachedSqliteVecExtensionPath;
207
+ const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
208
+ if (extensionPath) {
209
+ cachedSqliteVecExtensionPath = extensionPath;
210
+ return cachedSqliteVecExtensionPath;
211
+ }
212
+ try {
213
+ cachedSqliteVecExtensionPath = (
214
+ require("sqlite-vec") as { getLoadablePath(): string }
215
+ ).getLoadablePath();
216
+ } catch {
217
+ cachedSqliteVecExtensionPath = undefined;
218
+ }
219
+ return cachedSqliteVecExtensionPath;
220
+ }
221
+
222
+ /** Test-only: clear the memoized sqlite-vec extension path cache. */
223
+ export function __resetSqliteVecExtensionPathCacheForTests(): void {
224
+ cachedSqliteVecExtensionPath = null;
225
+ }
226
+
184
227
  function loadSqliteVec(database: Database): void {
185
228
  sqliteVecAvailable = false;
186
229
  try {
@@ -432,6 +475,13 @@ const VERSIONABLE_FIELDS: VersionableField[] = [
432
475
  "heartbeatMd",
433
476
  ];
434
477
 
478
+ const BUDGETED_IDENTITY_FIELDS: BudgetedIdentityField[] = [
479
+ "soulMd",
480
+ "identityMd",
481
+ "claudeMd",
482
+ "toolsMd",
483
+ ];
484
+
435
485
  function ensureAgentProfileColumns(database: Database): void {
436
486
  const existingColumns = new Set(
437
487
  database
@@ -775,6 +825,19 @@ export function updateAgentCredentialState(
775
825
  return row ? rowToAgent(row) : null;
776
826
  }
777
827
 
828
+ /**
829
+ * Record which env vars a worker is missing without touching status — the
830
+ * logical status is derived from runtime readiness in multi-runtime mode.
831
+ */
832
+ export function updateAgentCredentialMissing(agentId: string, missing: string[] | null): void {
833
+ const json = missing && missing.length > 0 ? JSON.stringify(missing) : null;
834
+ getDb()
835
+ .prepare(
836
+ "UPDATE agents SET credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
837
+ )
838
+ .run(json, agentId);
839
+ }
840
+
778
841
  export function createAgent(
779
842
  agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
780
843
  ): Agent {
@@ -1014,12 +1077,22 @@ export function deleteAgent(id: string): boolean {
1014
1077
  * Get the count of active (in_progress) tasks for an agent.
1015
1078
  * Used to determine current capacity usage.
1016
1079
  */
1080
+ /**
1081
+ * Tasks occupying one of the agent's concurrency slots.
1082
+ *
1083
+ * A claimed offer counts too — omitting it let a second concurrent poll take
1084
+ * another task past the limit. It is counted only through `offeredTo`, the
1085
+ * agent actually reviewing it: `agentId` on an offer may still be the lead
1086
+ * that created it, which would otherwise consume the lead's own capacity.
1087
+ */
1017
1088
  export function getActiveTaskCount(agentId: string): number {
1018
1089
  const result = getDb()
1019
- .prepare<{ count: number }, [string]>(
1020
- "SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status = 'in_progress'",
1090
+ .prepare<{ count: number }, [string, string]>(
1091
+ `SELECT COUNT(*) as count FROM agent_tasks
1092
+ WHERE (agentId = ? AND status = 'in_progress')
1093
+ OR (offeredTo = ? AND status = 'reviewing')`,
1021
1094
  )
1022
- .get(agentId);
1095
+ .get(agentId, agentId);
1023
1096
  return result?.count ?? 0;
1024
1097
  }
1025
1098
 
@@ -1202,6 +1275,7 @@ type AgentTaskRow = {
1202
1275
  credentialKeySuffix: string | null;
1203
1276
  credentialKeyType: string | null;
1204
1277
  requestedByUserId: string | null;
1278
+ requestedByUserIdInherited: number;
1205
1279
  swarmVersion: string | null;
1206
1280
  provider: string | null;
1207
1281
  providerMeta: string | null;
@@ -2143,13 +2217,29 @@ export function updateTaskVcs(
2143
2217
  vcsUrl: string;
2144
2218
  },
2145
2219
  ): AgentTask | null {
2146
- const row = getDb()
2147
- .prepare<AgentTaskRow, [string, string, number, string, string, string]>(
2148
- `UPDATE agent_tasks
2149
- SET vcsProvider = ?, vcsRepo = ?, vcsNumber = ?, vcsUrl = ?, lastUpdatedAt = ?
2150
- WHERE id = ? RETURNING *`,
2151
- )
2152
- .get(vcs.vcsProvider, vcs.vcsRepo, vcs.vcsNumber, vcs.vcsUrl, new Date().toISOString(), taskId);
2220
+ const row = getDb().transaction(() => {
2221
+ const updated = getDb()
2222
+ .prepare<AgentTaskRow, [string, string, number, string, string, string]>(
2223
+ `UPDATE agent_tasks
2224
+ SET vcsProvider = ?, vcsRepo = ?, vcsNumber = ?, vcsUrl = ?, lastUpdatedAt = ?
2225
+ WHERE id = ? RETURNING *`,
2226
+ )
2227
+ .get(
2228
+ vcs.vcsProvider,
2229
+ vcs.vcsRepo,
2230
+ vcs.vcsNumber,
2231
+ vcs.vcsUrl,
2232
+ new Date().toISOString(),
2233
+ taskId,
2234
+ );
2235
+ if (updated) {
2236
+ reconcileTaskPullRequestAttachments(taskId, updated.agentId, [
2237
+ updated.output,
2238
+ vcs.vcsProvider === "github" ? vcs.vcsUrl : null,
2239
+ ]);
2240
+ }
2241
+ return updated;
2242
+ })();
2153
2243
  return row ? rowToAgentTask(row) : null;
2154
2244
  }
2155
2245
 
@@ -2931,13 +3021,23 @@ export function completeTask(id: string, output?: string): AgentTask | null {
2931
3021
  return null;
2932
3022
  }
2933
3023
 
2934
- const finishedAt = new Date().toISOString();
2935
- let row = taskQueries.updateStatus().get("completed", finishedAt, id);
2936
- if (!row) return null;
3024
+ const row = getDb().transaction(() => {
3025
+ const finishedAt = new Date().toISOString();
3026
+ let completed = taskQueries.updateStatus().get("completed", finishedAt, id);
3027
+ if (!completed) return null;
2937
3028
 
2938
- if (output) {
2939
- row = taskQueries.setOutput().get(scrubSecrets(output), id);
2940
- }
3029
+ if (output) {
3030
+ completed = taskQueries.setOutput().get(scrubSecrets(output), id);
3031
+ }
3032
+ if (completed) {
3033
+ reconcileTaskPullRequestAttachments(id, completed.agentId, [
3034
+ completed.output,
3035
+ completed.vcsProvider === "github" ? completed.vcsUrl : null,
3036
+ ]);
3037
+ }
3038
+ return completed;
3039
+ })();
3040
+ if (!row) return null;
2941
3041
 
2942
3042
  if (row && oldTask) {
2943
3043
  emitTaskLifecycleTelemetryAfterCommit(
@@ -2961,8 +3061,8 @@ export function completeTask(id: string, output?: string): AgentTask | null {
2961
3061
  newValue: "completed",
2962
3062
  });
2963
3063
  } catch {}
2964
- try {
2965
- import("../workflows/event-bus").then(({ workflowEventBus }) => {
3064
+ import("../workflows/event-bus")
3065
+ .then(({ workflowEventBus }) => {
2966
3066
  workflowEventBus.emit("task.completed", {
2967
3067
  taskId: id,
2968
3068
  output,
@@ -2970,8 +3070,13 @@ export function completeTask(id: string, output?: string): AgentTask | null {
2970
3070
  workflowRunId: row.workflowRunId,
2971
3071
  workflowRunStepId: row.workflowRunStepId,
2972
3072
  });
2973
- });
2974
- } catch {}
3073
+ })
3074
+ .catch((err) =>
3075
+ console.error(
3076
+ "[db] task.completed event not emitted:",
3077
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
3078
+ ),
3079
+ );
2975
3080
  try {
2976
3081
  promotePendingSteeringForTask(id, "Task completed before steering was delivered");
2977
3082
  } catch (error) {
@@ -3022,8 +3127,8 @@ export function failTask(id: string, reason: string): AgentTask | null {
3022
3127
  metadata: { reason: scrubbedReason },
3023
3128
  });
3024
3129
  } catch {}
3025
- try {
3026
- import("../workflows/event-bus").then(({ workflowEventBus }) => {
3130
+ import("../workflows/event-bus")
3131
+ .then(({ workflowEventBus }) => {
3027
3132
  workflowEventBus.emit("task.failed", {
3028
3133
  taskId: id,
3029
3134
  failureReason: reason,
@@ -3031,8 +3136,13 @@ export function failTask(id: string, reason: string): AgentTask | null {
3031
3136
  workflowRunId: row.workflowRunId,
3032
3137
  workflowRunStepId: row.workflowRunStepId,
3033
3138
  });
3034
- });
3035
- } catch {}
3139
+ })
3140
+ .catch((err) =>
3141
+ console.error(
3142
+ "[db] task.failed event not emitted:",
3143
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
3144
+ ),
3145
+ );
3036
3146
  try {
3037
3147
  promotePendingSteeringForTask(id, "Task failed before steering was delivered");
3038
3148
  } catch (error) {
@@ -3070,7 +3180,16 @@ export function overwriteTerminalTaskResultText(
3070
3180
  patch.failureReason !== undefined
3071
3181
  ? scrubSecrets(patch.failureReason)
3072
3182
  : (task.failureReason ?? null);
3073
- const row = taskQueries.setTerminalResultText().get(output, failureReason, id) ?? null;
3183
+ const row = getDb().transaction(() => {
3184
+ const updated = taskQueries.setTerminalResultText().get(output, failureReason, id) ?? null;
3185
+ if (updated && patch.output !== undefined) {
3186
+ reconcileTaskPullRequestAttachments(id, updated.agentId, [
3187
+ updated.output,
3188
+ updated.vcsProvider === "github" ? updated.vcsUrl : null,
3189
+ ]);
3190
+ }
3191
+ return updated;
3192
+ })();
3074
3193
 
3075
3194
  return row ? rowToAgentTask(row) : task;
3076
3195
  }
@@ -3113,16 +3232,21 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
3113
3232
  metadata: reason ? { reason } : undefined,
3114
3233
  });
3115
3234
  } catch {}
3116
- try {
3117
- import("../workflows/event-bus").then(({ workflowEventBus }) => {
3235
+ import("../workflows/event-bus")
3236
+ .then(({ workflowEventBus }) => {
3118
3237
  workflowEventBus.emit("task.cancelled", {
3119
3238
  taskId: id,
3120
3239
  agentId: row.agentId,
3121
3240
  workflowRunId: row.workflowRunId,
3122
3241
  workflowRunStepId: row.workflowRunStepId,
3123
3242
  });
3124
- });
3125
- } catch {}
3243
+ })
3244
+ .catch((err) =>
3245
+ console.error(
3246
+ "[db] task.cancelled event not emitted:",
3247
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
3248
+ ),
3249
+ );
3126
3250
  try {
3127
3251
  promotePendingSteeringForTask(id, "Task was cancelled before steering was delivered");
3128
3252
  } catch (error) {
@@ -3203,8 +3327,8 @@ export function supersedeTask(
3203
3327
  metadata: { reason: args.reason, resumeTaskId: args.resumeTaskId },
3204
3328
  });
3205
3329
  } catch {}
3206
- try {
3207
- import("../workflows/event-bus").then(({ workflowEventBus }) => {
3330
+ import("../workflows/event-bus")
3331
+ .then(({ workflowEventBus }) => {
3208
3332
  workflowEventBus.emit("task.superseded", {
3209
3333
  taskId: id,
3210
3334
  reason: args.reason,
@@ -3213,8 +3337,13 @@ export function supersedeTask(
3213
3337
  workflowRunId: row.workflowRunId,
3214
3338
  workflowRunStepId: row.workflowRunStepId,
3215
3339
  });
3216
- });
3217
- } catch {}
3340
+ })
3341
+ .catch((err) =>
3342
+ console.error(
3343
+ "[db] task.superseded event not emitted:",
3344
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
3345
+ ),
3346
+ );
3218
3347
 
3219
3348
  try {
3220
3349
  cascadeFailDependents(id, "superseded");
@@ -3443,15 +3572,20 @@ export function updateTaskProgress(id: string, progress: string): AgentTask | nu
3443
3572
  newValue: scrubbedProgress,
3444
3573
  });
3445
3574
  } catch {}
3446
- try {
3447
- import("../workflows/event-bus").then(({ workflowEventBus }) => {
3575
+ import("../workflows/event-bus")
3576
+ .then(({ workflowEventBus }) => {
3448
3577
  workflowEventBus.emit("task.progress", {
3449
3578
  taskId: id,
3450
3579
  progress: scrubbedProgress,
3451
3580
  agentId: row.agentId,
3452
3581
  });
3453
- });
3454
- } catch {}
3582
+ })
3583
+ .catch((err) =>
3584
+ console.error(
3585
+ "[db] task.progress event not emitted:",
3586
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
3587
+ ),
3588
+ );
3455
3589
  }
3456
3590
  return row ? rowToAgentTask(row) : null;
3457
3591
  }
@@ -3592,6 +3726,101 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
3592
3726
  })();
3593
3727
  }
3594
3728
 
3729
+ const GENERATED_PULL_REQUEST_PROVIDER_ID = "github";
3730
+ const GENERATED_PULL_REQUEST_INTENT = "task-deliverable";
3731
+ const GENERATED_PULL_REQUEST_DESCRIPTION = "Pull request shipped by this task";
3732
+ const GENERATED_PULL_REQUEST_SOURCE = "task-pull-request-recorder";
3733
+
3734
+ function pullRequestKey(pullRequest: { owner: string; repo: string; number: number }): string {
3735
+ return `${pullRequest.owner.toLowerCase()}/${pullRequest.repo.toLowerCase()}#${pullRequest.number}`;
3736
+ }
3737
+
3738
+ /**
3739
+ * Persist PRs detected by server-owned task lifecycle paths. Existing URL
3740
+ * attachments win regardless of display name, so an agent-authored row and an
3741
+ * automatic row never duplicate the same task deliverable. A capability key
3742
+ * rejected by AttachmentInputSchema marks server-generated rows so
3743
+ * reconciliation cannot mistake caller-authored metadata for provenance.
3744
+ */
3745
+ export function recordTaskPullRequestAttachments(
3746
+ taskId: string,
3747
+ agentId: string | null,
3748
+ text: string | null | undefined,
3749
+ ): TaskAttachment[] {
3750
+ const pullRequests = extractGitHubPullRequestUrls(text);
3751
+ if (pullRequests.length === 0) return [];
3752
+
3753
+ const existingPullRequests = new Set(
3754
+ getDb()
3755
+ .prepare<{ url: string }, [string]>(
3756
+ "SELECT url FROM task_attachments WHERE task_id = ? AND kind = 'url' AND url IS NOT NULL",
3757
+ )
3758
+ .all(taskId)
3759
+ .flatMap((row) => extractGitHubPullRequestUrls(row.url))
3760
+ .map(pullRequestKey),
3761
+ );
3762
+ const stored: TaskAttachment[] = [];
3763
+ for (const pullRequest of pullRequests) {
3764
+ const dedupeKey = pullRequestKey(pullRequest);
3765
+ if (existingPullRequests.has(dedupeKey)) continue;
3766
+ stored.push(
3767
+ insertTaskAttachment({
3768
+ taskId,
3769
+ agentId,
3770
+ name: `GitHub pull request #${pullRequest.number}`,
3771
+ kind: "url",
3772
+ url: pullRequest.url,
3773
+ providerId: GENERATED_PULL_REQUEST_PROVIDER_ID,
3774
+ capabilities: {
3775
+ [SERVER_GENERATED_ATTACHMENT_CAPABILITY]: GENERATED_PULL_REQUEST_SOURCE,
3776
+ },
3777
+ intent: GENERATED_PULL_REQUEST_INTENT,
3778
+ description: GENERATED_PULL_REQUEST_DESCRIPTION,
3779
+ }),
3780
+ );
3781
+ existingPullRequests.add(dedupeKey);
3782
+ }
3783
+ return stored;
3784
+ }
3785
+
3786
+ /** Reconcile only lifecycle-generated PR rows against their current source text. */
3787
+ function reconcileTaskPullRequestAttachments(
3788
+ taskId: string,
3789
+ agentId: string | null,
3790
+ sourceTexts: Array<string | null | undefined>,
3791
+ ): TaskAttachment[] {
3792
+ const desiredPullRequests = sourceTexts.flatMap(extractGitHubPullRequestUrls);
3793
+ const desiredKeys = new Set(desiredPullRequests.map(pullRequestKey));
3794
+ const generatedRows = getDb()
3795
+ .prepare<{ id: string; name: string; url: string }, [string, string]>(
3796
+ `SELECT id, name, url
3797
+ FROM task_attachments
3798
+ WHERE task_id = ?
3799
+ AND kind = 'url'
3800
+ AND url IS NOT NULL
3801
+ AND json_extract(capabilities, '$.${SERVER_GENERATED_ATTACHMENT_CAPABILITY}') = ?`,
3802
+ )
3803
+ .all(taskId, GENERATED_PULL_REQUEST_SOURCE);
3804
+
3805
+ for (const row of generatedRows) {
3806
+ const pullRequest = extractGitHubPullRequestUrls(row.url)[0];
3807
+ const expectedName = pullRequest ? `GitHub pull request #${pullRequest.number}` : null;
3808
+ if (
3809
+ !pullRequest ||
3810
+ row.name !== expectedName ||
3811
+ !desiredKeys.has(pullRequestKey(pullRequest))
3812
+ ) {
3813
+ getDb().run("DELETE FROM task_attachments WHERE id = ?", [row.id]);
3814
+ }
3815
+ }
3816
+
3817
+ return recordTaskPullRequestAttachments(
3818
+ taskId,
3819
+ agentId,
3820
+ desiredPullRequests.map((pullRequest) => pullRequest.url).join("\n"),
3821
+ );
3822
+ }
3823
+
3595
3824
  function insertTaskAttachmentRow(input: InsertTaskAttachmentInput): TaskAttachment {
3596
3825
  const db = getDb();
3597
3826
 
@@ -4638,89 +4867,11 @@ export function getAllLogs(limit?: number): AgentLog[] {
4638
4867
  // Task Pool Operations
4639
4868
  // ============================================================================
4640
4869
 
4641
- export interface CreateTaskOptions {
4642
- key?: string;
4643
- agentId?: string | null;
4644
- creatorAgentId?: string;
4645
- source?: AgentTaskSource;
4646
- taskType?: string;
4647
- tags?: string[];
4648
- priority?: number;
4649
- dependsOn?: string[];
4650
- offeredTo?: string;
4651
- status?: "backlog" | "unassigned"; // Explicitly set initial status
4652
- slackChannelId?: string;
4653
- slackThreadTs?: string;
4654
- /** Exact Slack message that directly triggered this task; never inherited. */
4655
- slackTriggerMessageTs?: string;
4656
- slackUserId?: string;
4657
- /**
4658
- * Opt out of the residual Slack/contextKey normalization below (see the
4659
- * "Residual-mismatch guard" comment near the INSERT): a deliberate
4660
- * cross-channel/thread dispatch (e.g. `send-task`'s
4661
- * `overrideSlackContext: true`) sets this so its explicit slackChannelId/
4662
- * slackThreadTs survive even when they disagree with a slack-family
4663
- * `contextKey`/parent. Trusted callers that don't set this get normalized —
4664
- * this boundary must not let a caller silently persist a mismatch.
4665
- */
4666
- overrideSlackContext?: boolean;
4667
- vcsProvider?: "github" | "gitlab";
4668
- vcsRepo?: string;
4669
- vcsEventType?: string;
4670
- vcsNumber?: number;
4671
- vcsCommentId?: number;
4672
- vcsAuthor?: string;
4673
- vcsUrl?: string;
4674
- vcsInstallationId?: number;
4675
- vcsNodeId?: string;
4676
- agentmailInboxId?: string;
4677
- agentmailMessageId?: string;
4678
- agentmailThreadId?: string;
4679
- mentionMessageId?: string;
4680
- mentionChannelId?: string;
4681
- dir?: string;
4682
- parentTaskId?: string;
4683
- model?: string;
4684
- modelTier?: ModelTier;
4685
- effort?: ReasoningEffort;
4686
- scheduleId?: string;
4687
- workflowRunId?: string;
4688
- workflowRunStepId?: string;
4689
- sourceTaskId?: string;
4690
- /**
4691
- * Optional JSON Schema the agent's final output must conform to.
4692
- *
4693
- * Enforced via the MCP `store-progress` tool (validated in
4694
- * `src/tools/store-progress.ts`). NOT enforced when the task runs on
4695
- * default-mode Devin (no MCP) — see runbooks/harness-providers.md
4696
- * ("Per-task outputSchema support"). Callers reading `task.output` for
4697
- * a schema'd task should be defensive about JSON parsing.
4698
- */
4699
- outputSchema?: Record<string, unknown>;
4700
- /**
4701
- * When a `parentTaskId` is set, the child inherits the parent's `outputSchema`
4702
- * by default. Set this to `false` to opt out — used by control-plane children
4703
- * (e.g. the Lead `reroute-decision` task) that must inherit Slack/VCS context
4704
- * from the parent but must NOT be forced to satisfy the original work's output
4705
- * contract on completion (which would block the control task — DES-523).
4706
- */
4707
- inheritParentOutputSchema?: boolean;
4708
- followUpConfig?: FollowUpConfig;
4709
- requestedByUserId?: string;
4710
- contextKey?: string;
4711
- /**
4712
- * Internal control-plane escape hatch for continuations that MUST coexist
4713
- * with their active Linear parent (for example promoted steering). General
4714
- * task creation keeps tracker-context dedup enabled.
4715
- */
4716
- bypassTrackerContextDedup?: boolean;
4717
- /**
4718
- * Routing-affinity snapshot gating pool eligibility (see
4719
- * `isAgentEligibleForTask`). Inherited from the parent (via `parentTaskId`)
4720
- * when not explicitly set — same treatment as `vcsRepo`/`contextKey`.
4721
- */
4722
- routingAffinity?: RoutingAffinity;
4723
- }
4870
+ // The runtime contract for task creation lives in src/types.ts
4871
+ // (`CreateTaskOptionsSchema`); `createTaskExtended` parses options against it.
4872
+ // The type is re-exported so existing importers (src/tasks/sibling-awareness.ts)
4873
+ // keep resolving it from this module.
4874
+ export type { CreateTaskOptions } from "../types";
4724
4875
 
4725
4876
  /**
4726
4877
  * Find recent tasks within a time window for deduplication checks.
@@ -4759,6 +4910,15 @@ export function findRecentSimilarTasks(opts: {
4759
4910
  }
4760
4911
 
4761
4912
  export function createTaskExtended(task: string, options?: CreateTaskOptions): AgentTask {
4913
+ if (typeof task !== "string" || task.trim().length === 0) {
4914
+ throw new Error("createTaskExtended: 'task' must be a non-empty string");
4915
+ }
4916
+ // Single runtime enforcement point for every task write (REST, MCP,
4917
+ // scripts bridge, webhooks, scheduler, internal callers). Reject, never
4918
+ // coerce: a bad shape throws before anything reaches the INSERT; absent
4919
+ // fields keep the `??` defaults at the bind site below.
4920
+ options = CreateTaskOptionsSchema.parse(options ?? {});
4921
+ let requestedByUserIdInherited = false;
4762
4922
  const id = crypto.randomUUID();
4763
4923
  const now = new Date().toISOString();
4764
4924
  const status: AgentTaskStatus = options?.offeredTo
@@ -4894,6 +5054,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
4894
5054
  // Attribution
4895
5055
  if (parent.requestedByUserId && !options.requestedByUserId) {
4896
5056
  options.requestedByUserId = parent.requestedByUserId;
5057
+ requestedByUserIdInherited = true;
4897
5058
  }
4898
5059
  if (parent.key && !options.key) {
4899
5060
  options.key = parent.key;
@@ -5009,8 +5170,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
5009
5170
  vcsInstallationId, vcsNodeId,
5010
5171
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
5011
5172
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, modelTier, effort, scheduleId,
5012
- workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, routingAffinity, swarmVersion, createdAt, lastUpdatedAt, created_by, updated_by
5013
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
5173
+ workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, requestedByUserIdInherited, contextKey, routingAffinity, swarmVersion, createdAt, lastUpdatedAt, created_by, updated_by
5174
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
5014
5175
  )
5015
5176
  .get(
5016
5177
  id,
@@ -5055,6 +5216,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
5055
5216
  options?.outputSchema ? JSON.stringify(options.outputSchema) : null,
5056
5217
  options?.followUpConfig ? JSON.stringify(options.followUpConfig) : null,
5057
5218
  options?.requestedByUserId ?? null,
5219
+ requestedByUserIdInherited ? 1 : 0,
5058
5220
  options?.contextKey ?? null,
5059
5221
  options?.routingAffinity ? JSON.stringify(options.routingAffinity) : null,
5060
5222
  pkg.version,
@@ -5088,8 +5250,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
5088
5250
  (task) => task !== null,
5089
5251
  );
5090
5252
 
5091
- try {
5092
- import("../workflows/event-bus").then(({ workflowEventBus }) => {
5253
+ import("../workflows/event-bus")
5254
+ .then(({ workflowEventBus }) => {
5093
5255
  workflowEventBus.emit("task.created", {
5094
5256
  taskId: row.id,
5095
5257
  task: row.task,
@@ -5099,8 +5261,13 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
5099
5261
  workflowRunId: row.workflowRunId,
5100
5262
  workflowRunStepId: row.workflowRunStepId,
5101
5263
  });
5102
- });
5103
- } catch {}
5264
+ })
5265
+ .catch((err) =>
5266
+ console.error(
5267
+ "[db] task.created event not emitted:",
5268
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
5269
+ ),
5270
+ );
5104
5271
 
5105
5272
  return rowToAgentTask(row);
5106
5273
  }
@@ -5556,6 +5723,7 @@ export {
5556
5723
  export function updateAgentProfile(
5557
5724
  id: string,
5558
5725
  updates: {
5726
+ name?: string;
5559
5727
  description?: string;
5560
5728
  role?: string;
5561
5729
  capabilities?: string[];
@@ -5579,6 +5747,31 @@ export function updateAgentProfile(
5579
5747
  .get(id);
5580
5748
  if (!current) return null;
5581
5749
 
5750
+ for (const field of BUDGETED_IDENTITY_FIELDS) {
5751
+ const nextValue = updates[field];
5752
+ if (nextValue === undefined) continue;
5753
+
5754
+ const result = checkIdentityFieldBudget({
5755
+ field,
5756
+ currentValue: current[field] ?? "",
5757
+ nextValue,
5758
+ });
5759
+ if (!result.ok) {
5760
+ throw new IdentityFieldBudgetError(
5761
+ result,
5762
+ computeContentHash(current[field] ?? ""),
5763
+ computeContentHash(nextValue),
5764
+ );
5765
+ }
5766
+ }
5767
+
5768
+ if (updates.name !== undefined) {
5769
+ const existingAgent = database
5770
+ .prepare<AgentRow, [string, string]>("SELECT * FROM agents WHERE name = ? AND id != ?")
5771
+ .get(updates.name, id);
5772
+ if (existingAgent) throw new Error("Agent name already exists");
5773
+ }
5774
+
5582
5775
  // Create context versions for changed fields
5583
5776
  for (const field of VERSIONABLE_FIELDS) {
5584
5777
  const newValue = updates[field];
@@ -5631,6 +5824,7 @@ export function updateAgentProfile(
5631
5824
  string | null,
5632
5825
  string | null,
5633
5826
  string | null,
5827
+ string | null,
5634
5828
  number,
5635
5829
  string | null,
5636
5830
  string,
@@ -5638,6 +5832,7 @@ export function updateAgentProfile(
5638
5832
  ]
5639
5833
  >(
5640
5834
  `UPDATE agents SET
5835
+ name = COALESCE(?, name),
5641
5836
  description = COALESCE(?, description),
5642
5837
  role = COALESCE(?, role),
5643
5838
  capabilities = COALESCE(?, capabilities),
@@ -5652,6 +5847,7 @@ export function updateAgentProfile(
5652
5847
  WHERE id = ? RETURNING *`,
5653
5848
  )
5654
5849
  .get(
5850
+ updates.name ?? null,
5655
5851
  updates.description ?? null,
5656
5852
  updates.role ?? null,
5657
5853
  updates.capabilities ? JSON.stringify(updates.capabilities) : null,
@@ -5672,23 +5868,7 @@ export function updateAgentProfile(
5672
5868
  }
5673
5869
 
5674
5870
  export function updateAgentName(id: string, newName: string): Agent | null {
5675
- // Check if another agent already has this name
5676
- const existingAgent = getDb()
5677
- .prepare<AgentRow, [string, string]>("SELECT * FROM agents WHERE name = ? AND id != ?")
5678
- .get(newName, id);
5679
-
5680
- if (existingAgent) {
5681
- throw new Error("Agent name already exists");
5682
- }
5683
-
5684
- const now = new Date().toISOString();
5685
- const row = getDb()
5686
- .prepare<AgentRow, [string, string, string]>(
5687
- "UPDATE agents SET name = ?, lastUpdatedAt = ? WHERE id = ? RETURNING *",
5688
- )
5689
- .get(newName, now, id);
5690
-
5691
- return row ? rowToAgent(row) : null;
5871
+ return updateAgentProfile(id, { name: newName });
5692
5872
  }
5693
5873
 
5694
5874
  // ============================================================================
@@ -6873,8 +7053,30 @@ export interface SessionCostSummaryTotals {
6873
7053
  totalDurationMs: number;
6874
7054
  totalSessions: number;
6875
7055
  avgCostPerSession: number;
6876
- /** Share of `totalCostUsd` whose task carries a human requester. */
7056
+ /**
7057
+ * Cost of tasks with a human requester, over `attributableCostUsd` (not
7058
+ * `totalCostUsd`) — see `attributableCostUsd` for why. Also excludes cost on
7059
+ * a structurally-human-free task that happens to carry a stale/inherited
7060
+ * `requestedByUserId` (measured 2026-08-19: ~5% of the naive-attributed
7061
+ * population), so the two totals stay a consistent partition.
7062
+ */
6877
7063
  attributedCostUsd: number;
7064
+ /**
7065
+ * The corrected coverage denominator: `totalCostUsd` minus the cost of
7066
+ * structurally-human-free tasks (heartbeat/boot-triage, scheduled runs, and
7067
+ * `source='system'` follow-ups whose parent itself had no human requester —
7068
+ * i.e. follow-ups of the swarm's own self-maintenance, not of human work).
7069
+ * These tasks have no human requester *by construction*; stamping one on
7070
+ * them would be a lie, not coverage, so they don't belong in the
7071
+ * denominator at all. `attributedCostUsd / attributableCostUsd` is the
7072
+ * number to show as "coverage" — dividing by `totalCostUsd` instead
7073
+ * silently deflates it with a population that could never have scored.
7074
+ */
7075
+ attributableCostUsd: number;
7076
+ /** Cost of the structurally-human-free population excluded from `attributableCostUsd`. */
7077
+ excludedCostUsd: number;
7078
+ /** Distinct tasks behind `excludedCostUsd` — surfaced so the UI can name the exclusion count, not just wave at a percentage. */
7079
+ excludedTaskCount: number;
6878
7080
  }
6879
7081
 
6880
7082
  export interface SessionCostDailyRow {
@@ -6907,6 +7109,60 @@ export interface SessionCostByUserRow {
6907
7109
  /** `opts.userId` sentinel selecting spend with no human requester. */
6908
7110
  export const UNATTRIBUTED_USER_ID = "unattributed";
6909
7111
 
7112
+ // Structurally-human-free tasks and their descendants. An explicitly supplied
7113
+ // requester is a human handoff and stops propagation; a requester copied from
7114
+ // the parent does not.
7115
+ const HUMAN_FREE_TASKS_CTE = `human_free_tasks(id) AS (
7116
+ SELECT task.id
7117
+ FROM agent_tasks task
7118
+ LEFT JOIN agent_tasks parent ON parent.id = task.parentTaskId
7119
+ WHERE COALESCE(task.taskType, '') IN ('heartbeat', 'heartbeat-checklist', 'boot-triage')
7120
+ OR COALESCE(task.tags, '[]') LIKE '%"heartbeat"%'
7121
+ OR (COALESCE(task.source, '') = 'schedule' AND task.requestedByUserId IS NULL)
7122
+ OR (
7123
+ task.parentTaskId IS NULL
7124
+ AND COALESCE(task.source, '') = 'workflow'
7125
+ AND task.requestedByUserId IS NULL
7126
+ AND EXISTS (
7127
+ SELECT 1
7128
+ FROM workflow_runs run
7129
+ WHERE run.id = task.workflowRunId
7130
+ AND run.triggerType = 'schedule'
7131
+ AND run.created_by IS NULL
7132
+ )
7133
+ )
7134
+ OR (
7135
+ COALESCE(task.source, '') = 'system'
7136
+ AND parent.id IS NOT NULL
7137
+ AND parent.requestedByUserId IS NULL
7138
+ )
7139
+
7140
+ UNION
7141
+
7142
+ SELECT child.id
7143
+ FROM agent_tasks child
7144
+ JOIN human_free_tasks parent ON child.parentTaskId = parent.id
7145
+ WHERE child.requestedByUserId IS NULL
7146
+ OR child.requestedByUserIdInherited = 1
7147
+ )`;
7148
+ const HUMAN_FREE_SQL = "EXISTS (SELECT 1 FROM human_free_tasks WHERE id = t.id)";
7149
+ const ROOT_HUMAN_FREE_SQL = `(
7150
+ COALESCE(t.taskType, '') IN ('heartbeat', 'heartbeat-checklist', 'boot-triage')
7151
+ OR COALESCE(t.tags, '[]') LIKE '%"heartbeat"%'
7152
+ OR (COALESCE(t.source, '') = 'schedule' AND t.requestedByUserId IS NULL)
7153
+ OR (
7154
+ COALESCE(t.source, '') = 'workflow'
7155
+ AND t.requestedByUserId IS NULL
7156
+ AND EXISTS (
7157
+ SELECT 1
7158
+ FROM workflow_runs run
7159
+ WHERE run.id = t.workflowRunId
7160
+ AND run.triggerType = 'schedule'
7161
+ AND run.created_by IS NULL
7162
+ )
7163
+ )
7164
+ )`;
7165
+
6910
7166
  export function getSessionCostSummary(opts: {
6911
7167
  startDate?: string;
6912
7168
  endDate?: string;
@@ -6925,6 +7181,15 @@ export function getSessionCostSummary(opts: {
6925
7181
  // through the task (same shape as `getDailySpendForUser`). Every column is
6926
7182
  // `sc.`-qualified because `createdAt`/`agentId` exist on both sides.
6927
7183
  const from = "FROM session_costs sc LEFT JOIN agent_tasks t ON t.id = sc.taskId";
7184
+
7185
+ // Structurally-human-free: the swarm maintaining itself, with no human
7186
+ // requester by construction — heartbeat/boot-triage tasks, scheduled runs
7187
+ // without a human creator (including workflow roots launched by such a
7188
+ // schedule), and `source='system'` follow-ups whose parent itself has no
7189
+ // human requester. That classification propagates through descendants while
7190
+ // they remain unattributed, so autonomous fan-out cannot leak back into the
7191
+ // denominator. An explicitly attributed child is an independent handoff and
7192
+ // stops propagation down that branch.
6928
7193
  const conditions: string[] = [];
6929
7194
  const params: string[] = [];
6930
7195
 
@@ -6941,9 +7206,9 @@ export function getSessionCostSummary(opts: {
6941
7206
  params.push(opts.agentId);
6942
7207
  }
6943
7208
  if (opts.userId === UNATTRIBUTED_USER_ID) {
6944
- conditions.push("t.requestedByUserId IS NULL");
7209
+ conditions.push(`(t.requestedByUserId IS NULL OR ${HUMAN_FREE_SQL})`);
6945
7210
  } else if (opts.userId) {
6946
- conditions.push("t.requestedByUserId = ?");
7211
+ conditions.push(`(t.requestedByUserId = ? AND NOT ${HUMAN_FREE_SQL})`);
6947
7212
  params.push(opts.userId);
6948
7213
  }
6949
7214
 
@@ -6959,11 +7224,15 @@ export function getSessionCostSummary(opts: {
6959
7224
  totalDurationMs: number;
6960
7225
  totalSessions: number;
6961
7226
  attributedCostUsd: number;
7227
+ attributableCostUsd: number;
7228
+ excludedCostUsd: number;
7229
+ excludedTaskCount: number;
6962
7230
  };
6963
7231
 
6964
7232
  const totalsRow = getDb()
6965
7233
  .prepare<TotalsRow, string[]>(
6966
- `SELECT
7234
+ `WITH RECURSIVE ${HUMAN_FREE_TASKS_CTE}
7235
+ SELECT
6967
7236
  COALESCE(SUM(sc.totalCostUsd), 0) as totalCostUsd,
6968
7237
  COALESCE(SUM(sc.inputTokens), 0) as totalInputTokens,
6969
7238
  COALESCE(SUM(sc.outputTokens), 0) as totalOutputTokens,
@@ -6971,8 +7240,13 @@ export function getSessionCostSummary(opts: {
6971
7240
  COALESCE(SUM(sc.cacheWriteTokens), 0) as totalCacheWriteTokens,
6972
7241
  COALESCE(SUM(sc.durationMs), 0) as totalDurationMs,
6973
7242
  COUNT(*) as totalSessions,
6974
- COALESCE(SUM(CASE WHEN t.requestedByUserId IS NOT NULL THEN sc.totalCostUsd ELSE 0 END), 0)
6975
- as attributedCostUsd
7243
+ COALESCE(SUM(CASE WHEN t.requestedByUserId IS NOT NULL AND NOT ${HUMAN_FREE_SQL}
7244
+ THEN sc.totalCostUsd ELSE 0 END), 0) as attributedCostUsd,
7245
+ COALESCE(SUM(CASE WHEN NOT ${HUMAN_FREE_SQL}
7246
+ THEN sc.totalCostUsd ELSE 0 END), 0) as attributableCostUsd,
7247
+ COALESCE(SUM(CASE WHEN ${HUMAN_FREE_SQL}
7248
+ THEN sc.totalCostUsd ELSE 0 END), 0) as excludedCostUsd,
7249
+ COUNT(DISTINCT CASE WHEN ${HUMAN_FREE_SQL} THEN t.id END) as excludedTaskCount
6976
7250
  ${from} ${where}`,
6977
7251
  )
6978
7252
  .get(...params);
@@ -6993,6 +7267,9 @@ export function getSessionCostSummary(opts: {
6993
7267
  totalSessions: 0,
6994
7268
  avgCostPerSession: 0,
6995
7269
  attributedCostUsd: 0,
7270
+ attributableCostUsd: 0,
7271
+ excludedCostUsd: 0,
7272
+ excludedTaskCount: 0,
6996
7273
  };
6997
7274
 
6998
7275
  // Daily breakdown
@@ -7010,7 +7287,8 @@ export function getSessionCostSummary(opts: {
7010
7287
  },
7011
7288
  string[]
7012
7289
  >(
7013
- `SELECT
7290
+ `WITH RECURSIVE ${HUMAN_FREE_TASKS_CTE}
7291
+ SELECT
7014
7292
  DATE(sc.createdAt) as date,
7015
7293
  COALESCE(SUM(sc.totalCostUsd), 0) as costUsd,
7016
7294
  COALESCE(SUM(sc.inputTokens), 0) as inputTokens,
@@ -7038,7 +7316,8 @@ export function getSessionCostSummary(opts: {
7038
7316
  },
7039
7317
  string[]
7040
7318
  >(
7041
- `SELECT
7319
+ `WITH RECURSIVE ${HUMAN_FREE_TASKS_CTE}
7320
+ SELECT
7042
7321
  sc.agentId as agentId,
7043
7322
  COALESCE(SUM(sc.totalCostUsd), 0) as costUsd,
7044
7323
  COALESCE(SUM(sc.inputTokens), 0) as inputTokens,
@@ -7052,22 +7331,22 @@ export function getSessionCostSummary(opts: {
7052
7331
  .all(...params);
7053
7332
  }
7054
7333
 
7055
- // Per-requester breakdown. The `userId IS NULL` bucket is a real row, not a
7056
- // gap: autonomous spend (heartbeat, boot triage) has no human requester and
7057
- // must stay visible rather than being folded into a person.
7334
+ // Per-requester breakdown. Structurally-human-free work is assigned to the
7335
+ // autonomous bucket even if a stale requester id survives on the task.
7058
7336
  let byUser: SessionCostByUserRow[] = [];
7059
7337
  if (groupBy === "user" || groupBy === "both") {
7060
7338
  byUser = getDb()
7061
7339
  .prepare<SessionCostByUserRow, string[]>(
7062
- `SELECT
7063
- t.requestedByUserId as userId,
7340
+ `WITH RECURSIVE ${HUMAN_FREE_TASKS_CTE}
7341
+ SELECT
7342
+ CASE WHEN ${HUMAN_FREE_SQL} THEN NULL ELSE t.requestedByUserId END as userId,
7064
7343
  COALESCE(SUM(sc.totalCostUsd), 0) as costUsd,
7065
7344
  COALESCE(SUM(sc.inputTokens), 0) as inputTokens,
7066
7345
  COALESCE(SUM(sc.outputTokens), 0) as outputTokens,
7067
7346
  COUNT(DISTINCT sc.taskId) as tasks,
7068
7347
  COALESCE(SUM(sc.durationMs), 0) as durationMs
7069
7348
  ${from} ${where}
7070
- GROUP BY t.requestedByUserId
7349
+ GROUP BY CASE WHEN ${HUMAN_FREE_SQL} THEN NULL ELSE t.requestedByUserId END
7071
7350
  ORDER BY costUsd DESC`,
7072
7351
  )
7073
7352
  .all(...params);
@@ -7076,6 +7355,235 @@ export function getSessionCostSummary(opts: {
7076
7355
  return { totals, daily, byAgent, byUser };
7077
7356
  }
7078
7357
 
7358
+ // --- Per-person attribution (four-metric view) ---
7359
+
7360
+ export interface AttributionByPersonRow {
7361
+ userId: string;
7362
+ /** Root tasks (`parentTaskId IS NULL`) only — counting fan-out children would let
7363
+ * whoever triggers the biggest decomposition win by accident. */
7364
+ problemsInitiated: number;
7365
+ /** Roots that completed AND show shippable evidence — see `getAttributionByPerson` doc. */
7366
+ problemsShipped: number;
7367
+ /** Distinct agents engaged across the person's entire task tree (not root-scoped —
7368
+ * a root that fans out to nine agents is nine agents of reach). */
7369
+ agentsReached: number;
7370
+ reposReached: number;
7371
+ surfacesReached: number;
7372
+ /**
7373
+ * Share of root tasks completed without a re-dispatch or a human correction.
7374
+ * Deliberately `null`: distinguishing a legitimate multi-agent fan-out from a
7375
+ * correction-driven re-dispatch isn't computable from a single query pass
7376
+ * (would need to inspect *why* a child task was created, which isn't a stored
7377
+ * fact), and a task-count-based proxy would misclassify normal delegation as
7378
+ * rework. Render as "not yet computed" — do not invent a stand-in.
7379
+ */
7380
+ firstPassYield: null;
7381
+ }
7382
+
7383
+ /**
7384
+ * Four metrics per human requester, reported side by side — never summed into
7385
+ * a composite score. Scope is root tasks (`problemsInitiated`/`problemsShipped`)
7386
+ * or the person's entire task tree (`*Reached`); `requestedByUserId IS NULL`
7387
+ * (autonomous work) and structurally-human-free rows with a stale/inherited
7388
+ * requester (see `HUMAN_FREE_SQL` in `getSessionCostSummary`) are excluded —
7389
+ * neither belongs to a person.
7390
+ *
7391
+ * "Problems shipped" detection walks each root's full task tree, preferring a
7392
+ * `task_attachments` row (`kind='url'` matching a GitHub PR or GitLab MR URL,
7393
+ * or `kind='page'` for a published artifact) and falling back to the same
7394
+ * provider-specific URL match on any task output. GitLab matching is host
7395
+ * agnostic so self-hosted instances are covered. This does NOT detect a closed ticket (no
7396
+ * Linear/Jira issue-state table exists locally) — "shipped" undercounts
7397
+ * ticket-only outcomes until the real artifacts join lands.
7398
+ */
7399
+ export function getAttributionByPerson(opts: {
7400
+ startDate?: string;
7401
+ endDate?: string;
7402
+ }): AttributionByPersonRow[] {
7403
+ const conditions: string[] = ["t.requestedByUserId IS NOT NULL"];
7404
+ const params: string[] = [];
7405
+ if (opts.startDate) {
7406
+ conditions.push("t.createdAt >= ?");
7407
+ params.push(opts.startDate);
7408
+ }
7409
+ if (opts.endDate) {
7410
+ // A date-only value represents the whole UTC day. Preserve inclusive
7411
+ // timestamp semantics for callers that provide an exact instant.
7412
+ const dateOnlyEnd = /^\d{4}-\d{2}-\d{2}$/.test(opts.endDate)
7413
+ ? new Date(`${opts.endDate}T00:00:00.000Z`)
7414
+ : null;
7415
+ if (
7416
+ dateOnlyEnd &&
7417
+ !Number.isNaN(dateOnlyEnd.getTime()) &&
7418
+ dateOnlyEnd.toISOString().slice(0, 10) === opts.endDate
7419
+ ) {
7420
+ dateOnlyEnd.setUTCDate(dateOnlyEnd.getUTCDate() + 1);
7421
+ conditions.push("t.createdAt < ?");
7422
+ params.push(dateOnlyEnd.toISOString());
7423
+ } else {
7424
+ conditions.push("t.createdAt <= ?");
7425
+ params.push(opts.endDate);
7426
+ }
7427
+ }
7428
+ const where = `WHERE ${conditions.join(" AND ")}`;
7429
+
7430
+ type RootRow = { userId: string; initiated: number; shipped: number };
7431
+ const rootRows = getDb()
7432
+ .prepare<RootRow, string[]>(
7433
+ `WITH RECURSIVE selected_roots(id, requestedByUserId, status, output) AS (
7434
+ SELECT t.id, t.requestedByUserId, t.status, t.output
7435
+ FROM agent_tasks t
7436
+ ${where} AND t.parentTaskId IS NULL AND NOT ${ROOT_HUMAN_FREE_SQL}
7437
+ ),
7438
+ task_tree(rootId, taskId, output) AS (
7439
+ SELECT id, id, output
7440
+ FROM selected_roots
7441
+
7442
+ UNION ALL
7443
+
7444
+ SELECT tree.rootId, child.id, child.output
7445
+ FROM agent_tasks child
7446
+ JOIN task_tree tree ON child.parentTaskId = tree.taskId
7447
+ )
7448
+ SELECT
7449
+ t.requestedByUserId as userId,
7450
+ COUNT(*) as initiated,
7451
+ SUM(CASE WHEN t.status = 'completed' AND (
7452
+ EXISTS (
7453
+ SELECT 1
7454
+ FROM task_tree tree
7455
+ JOIN task_attachments ta ON ta.task_id = tree.taskId
7456
+ WHERE tree.rootId = t.id
7457
+ AND ta.kind = 'url'
7458
+ AND (
7459
+ ta.url LIKE '%github.com/%/pull/%'
7460
+ OR ta.url LIKE '%/-/merge_requests/%'
7461
+ )
7462
+ )
7463
+ OR EXISTS (
7464
+ SELECT 1 FROM task_attachments ta
7465
+ JOIN task_tree tree ON tree.taskId = ta.task_id
7466
+ WHERE tree.rootId = t.id AND ta.kind = 'page'
7467
+ )
7468
+ OR EXISTS (
7469
+ SELECT 1 FROM task_tree tree
7470
+ WHERE tree.rootId = t.id
7471
+ AND (
7472
+ tree.output LIKE '%github.com/%/pull/%'
7473
+ OR tree.output LIKE '%/-/merge_requests/%'
7474
+ )
7475
+ )
7476
+ ) THEN 1 ELSE 0 END) as shipped
7477
+ FROM selected_roots t
7478
+ GROUP BY t.requestedByUserId`,
7479
+ )
7480
+ .all(...params);
7481
+
7482
+ type ReachRow = {
7483
+ userId: string;
7484
+ agentsReached: number;
7485
+ reposReached: number;
7486
+ surfacesReached: number;
7487
+ };
7488
+ const reachRows = getDb()
7489
+ .prepare<ReachRow, string[]>(
7490
+ `WITH RECURSIVE report_tasks AS (
7491
+ SELECT t.*
7492
+ FROM agent_tasks t
7493
+ ${where}
7494
+ ),
7495
+ task_ancestry(
7496
+ taskId, id, parentTaskId, requestedByUserId, requestedByUserIdInherited,
7497
+ taskType, tags, source, workflowRunId
7498
+ ) AS (
7499
+ SELECT
7500
+ id, id, parentTaskId, requestedByUserId, requestedByUserIdInherited,
7501
+ taskType, tags, source, workflowRunId
7502
+ FROM report_tasks
7503
+
7504
+ UNION ALL
7505
+
7506
+ SELECT
7507
+ child.taskId, parent.id, parent.parentTaskId, parent.requestedByUserId,
7508
+ parent.requestedByUserIdInherited, parent.taskType, parent.tags,
7509
+ parent.source, parent.workflowRunId
7510
+ FROM agent_tasks parent
7511
+ JOIN task_ancestry child ON parent.id = child.parentTaskId
7512
+ WHERE child.requestedByUserId IS NULL
7513
+ OR child.requestedByUserIdInherited = 1
7514
+ ),
7515
+ human_free_report_tasks(id) AS (
7516
+ SELECT DISTINCT ancestor.taskId
7517
+ FROM task_ancestry ancestor
7518
+ LEFT JOIN agent_tasks parent ON parent.id = ancestor.parentTaskId
7519
+ WHERE COALESCE(ancestor.taskType, '') IN ('heartbeat', 'heartbeat-checklist', 'boot-triage')
7520
+ OR COALESCE(ancestor.tags, '[]') LIKE '%"heartbeat"%'
7521
+ OR (COALESCE(ancestor.source, '') = 'schedule' AND ancestor.requestedByUserId IS NULL)
7522
+ OR (
7523
+ ancestor.parentTaskId IS NULL
7524
+ AND COALESCE(ancestor.source, '') = 'workflow'
7525
+ AND ancestor.requestedByUserId IS NULL
7526
+ AND EXISTS (
7527
+ SELECT 1
7528
+ FROM workflow_runs run
7529
+ WHERE run.id = ancestor.workflowRunId
7530
+ AND run.triggerType = 'schedule'
7531
+ AND run.created_by IS NULL
7532
+ )
7533
+ )
7534
+ OR (
7535
+ COALESCE(ancestor.source, '') = 'system'
7536
+ AND parent.id IS NOT NULL
7537
+ AND parent.requestedByUserId IS NULL
7538
+ )
7539
+ )
7540
+ SELECT
7541
+ t.requestedByUserId as userId,
7542
+ COUNT(DISTINCT t.agentId) as agentsReached,
7543
+ COUNT(DISTINCT t.vcsRepo) as reposReached,
7544
+ COUNT(DISTINCT t.source) as surfacesReached
7545
+ FROM report_tasks t
7546
+ WHERE NOT EXISTS (SELECT 1 FROM human_free_report_tasks WHERE id = t.id)
7547
+ GROUP BY t.requestedByUserId`,
7548
+ )
7549
+ .all(...params);
7550
+
7551
+ const byUser = new Map<string, AttributionByPersonRow>();
7552
+ for (const r of rootRows) {
7553
+ byUser.set(r.userId, {
7554
+ userId: r.userId,
7555
+ problemsInitiated: r.initiated,
7556
+ problemsShipped: r.shipped,
7557
+ agentsReached: 0,
7558
+ reposReached: 0,
7559
+ surfacesReached: 0,
7560
+ firstPassYield: null,
7561
+ });
7562
+ }
7563
+ for (const r of reachRows) {
7564
+ const existing = byUser.get(r.userId);
7565
+ if (existing) {
7566
+ existing.agentsReached = r.agentsReached;
7567
+ existing.reposReached = r.reposReached;
7568
+ existing.surfacesReached = r.surfacesReached;
7569
+ } else {
7570
+ // Reach with no root task in-window (e.g. only fan-out children whose
7571
+ // root predates the window) — still a real person row.
7572
+ byUser.set(r.userId, {
7573
+ userId: r.userId,
7574
+ problemsInitiated: 0,
7575
+ problemsShipped: 0,
7576
+ agentsReached: r.agentsReached,
7577
+ reposReached: r.reposReached,
7578
+ surfacesReached: r.surfacesReached,
7579
+ firstPassYield: null,
7580
+ });
7581
+ }
7582
+ }
7583
+
7584
+ return Array.from(byUser.values());
7585
+ }
7586
+
7079
7587
  // --- Dashboard cost summary (P4) ---
7080
7588
 
7081
7589
  export interface DashboardCostSummary {
@@ -8565,6 +9073,7 @@ export function insertActiveSession(session: {
8565
9073
  inboxMessageId?: string;
8566
9074
  taskDescription?: string;
8567
9075
  runnerSessionId?: string;
9076
+ runtimeInstanceId?: string;
8568
9077
  }): ActiveSession {
8569
9078
  const id = crypto.randomUUID();
8570
9079
  const now = new Date().toISOString();
@@ -8580,12 +9089,13 @@ export function insertActiveSession(session: {
8580
9089
  string | null,
8581
9090
  string | null,
8582
9091
  string | null,
9092
+ string | null,
8583
9093
  string,
8584
9094
  string,
8585
9095
  ]
8586
9096
  >(
8587
- `INSERT INTO active_sessions (id, agentId, taskId, triggerType, inboxMessageId, taskDescription, runnerSessionId, startedAt, lastHeartbeatAt)
8588
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9097
+ `INSERT INTO active_sessions (id, agentId, taskId, triggerType, inboxMessageId, taskDescription, runnerSessionId, runtimeInstanceId, startedAt, lastHeartbeatAt)
9098
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8589
9099
  RETURNING *`,
8590
9100
  )
8591
9101
  .get(
@@ -8596,6 +9106,7 @@ export function insertActiveSession(session: {
8596
9106
  session.inboxMessageId ?? null,
8597
9107
  session.taskDescription ?? null,
8598
9108
  session.runnerSessionId ?? null,
9109
+ session.runtimeInstanceId ?? null,
8599
9110
  now,
8600
9111
  now,
8601
9112
  );
@@ -9208,9 +9719,11 @@ type WorkflowRunRow = {
9208
9719
  id: string;
9209
9720
  workflowId: string;
9210
9721
  status: string;
9722
+ triggerType: string;
9211
9723
  triggerData: string | null;
9212
9724
  context: string | null;
9213
9725
  error: string | null;
9726
+ created_by: string | null;
9214
9727
  startedAt: string;
9215
9728
  lastUpdatedAt: string;
9216
9729
  finishedAt: string | null;
@@ -9224,6 +9737,7 @@ function rowToWorkflowRun(row: WorkflowRunRow): WorkflowRun {
9224
9737
  triggerData: row.triggerData ? JSON.parse(row.triggerData) : undefined,
9225
9738
  context: row.context ? (JSON.parse(row.context) as Record<string, unknown>) : undefined,
9226
9739
  error: row.error ?? undefined,
9740
+ createdBy: row.created_by ?? undefined,
9227
9741
  startedAt: normalizeDateRequired(row.startedAt),
9228
9742
  lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
9229
9743
  finishedAt: normalizeDate(row.finishedAt) ?? undefined,
@@ -9233,14 +9747,23 @@ function rowToWorkflowRun(row: WorkflowRunRow): WorkflowRun {
9233
9747
  export function createWorkflowRun(data: {
9234
9748
  id: string;
9235
9749
  workflowId: string;
9750
+ triggerType?: "schedule" | "manual" | "event" | "api";
9236
9751
  triggerData?: unknown;
9752
+ createdBy?: string;
9237
9753
  }): WorkflowRun {
9238
9754
  const now = new Date().toISOString();
9239
9755
  const row = getDb()
9240
- .prepare<WorkflowRunRow, [string, string, string, string | null]>(
9241
- `INSERT INTO workflow_runs (id, workflowId, startedAt, triggerData) VALUES (?, ?, ?, ?) RETURNING *`,
9756
+ .prepare<WorkflowRunRow, [string, string, string, string, string | null, string | null]>(
9757
+ `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
9242
9758
  )
9243
- .get(data.id, data.workflowId, now, data.triggerData ? JSON.stringify(data.triggerData) : null);
9759
+ .get(
9760
+ data.id,
9761
+ data.workflowId,
9762
+ data.triggerType ?? "manual",
9763
+ now,
9764
+ data.triggerData ? JSON.stringify(data.triggerData) : null,
9765
+ data.createdBy ?? null,
9766
+ );
9244
9767
  if (!row) throw new Error("Failed to create workflow run");
9245
9768
  return rowToWorkflowRun(row);
9246
9769
  }
@@ -13161,13 +13684,104 @@ export function updateUser(
13161
13684
  return row ? rowToUser(row) : null;
13162
13685
  }
13163
13686
 
13164
- export function deleteUser(id: string): boolean {
13165
- // Clear any task references before deleting
13166
- getDb()
13167
- .prepare("UPDATE agent_tasks SET requestedByUserId = NULL WHERE requestedByUserId = ?")
13168
- .run(id);
13169
- const result = getDb().prepare("DELETE FROM users WHERE id = ?").run(id);
13170
- return result.changes > 0;
13687
+ type UserReferenceRow = {
13688
+ tableName: string;
13689
+ columnName: string;
13690
+ };
13691
+
13692
+ function quoteSqlIdentifier(identifier: string): string {
13693
+ return `"${identifier.replaceAll('"', '""')}"`;
13694
+ }
13695
+
13696
+ export function deleteUser(id: string, replacementUserId?: string): boolean {
13697
+ if (replacementUserId === id) {
13698
+ throw new Error("Replacement user must differ from deleted user");
13699
+ }
13700
+
13701
+ const database = getDb();
13702
+ return database.transaction(() => {
13703
+ const userExists = database
13704
+ .prepare<{ present: number }, [string]>(
13705
+ "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?) AS present",
13706
+ )
13707
+ .get(id)?.present;
13708
+ if (!userExists) return false;
13709
+
13710
+ if (replacementUserId) {
13711
+ const replacementExists = database
13712
+ .prepare<{ present: number }, [string]>(
13713
+ "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?) AS present",
13714
+ )
13715
+ .get(replacementUserId)?.present;
13716
+ if (!replacementExists) throw new Error("Replacement user not found");
13717
+ }
13718
+
13719
+ // Preserve rows that carry user attribution but lack ON DELETE semantics.
13720
+ // Schema discovery keeps this correct as new nullable user audit columns are added.
13721
+ // Migration 103 accidentally dropped the scheduled_tasks audit FKs while
13722
+ // recreating that table, so discover those two known logical references as
13723
+ // well until the table is next rebuilt with its original constraints.
13724
+ const references = database
13725
+ .prepare<UserReferenceRow, []>(
13726
+ `SELECT tables.name AS tableName, foreign_keys."from" AS columnName
13727
+ FROM sqlite_schema AS tables
13728
+ JOIN pragma_foreign_key_list(tables.name) AS foreign_keys
13729
+ JOIN pragma_table_info(tables.name) AS columns
13730
+ ON columns.name = foreign_keys."from"
13731
+ WHERE tables.type = 'table'
13732
+ AND foreign_keys."table" = 'users'
13733
+ AND foreign_keys.on_delete IN ('NO ACTION', 'RESTRICT')
13734
+ AND columns."notnull" = 0
13735
+ UNION ALL
13736
+ SELECT 'scheduled_tasks' AS tableName, columns.name AS columnName
13737
+ FROM pragma_table_info('scheduled_tasks') AS columns
13738
+ WHERE columns.name IN ('created_by', 'updated_by')
13739
+ AND columns."notnull" = 0
13740
+ AND NOT EXISTS (
13741
+ SELECT 1
13742
+ FROM pragma_foreign_key_list('scheduled_tasks') AS foreign_keys
13743
+ WHERE foreign_keys."from" = columns.name
13744
+ AND foreign_keys."table" = 'users'
13745
+ )`,
13746
+ )
13747
+ .all();
13748
+ const replacement = replacementUserId ?? null;
13749
+ for (const reference of references) {
13750
+ const table = quoteSqlIdentifier(reference.tableName);
13751
+ const column = quoteSqlIdentifier(reference.columnName);
13752
+ database
13753
+ .prepare<unknown, [string | null, string]>(
13754
+ `UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`,
13755
+ )
13756
+ .run(replacement, id);
13757
+ }
13758
+
13759
+ // Workflow context is persisted JSON rather than a relational column, but
13760
+ // it exposes the same requester identity to downstream interpolation. Keep
13761
+ // it consistent with workflow_runs.created_by inside this transaction.
13762
+ if (replacementUserId) {
13763
+ database
13764
+ .prepare<unknown, [string, string]>(
13765
+ `UPDATE workflow_runs
13766
+ SET context = json_set(context, '$.swarm.requestedByUserId', ?)
13767
+ WHERE json_valid(context)
13768
+ AND json_extract(context, '$.swarm.requestedByUserId') = ?`,
13769
+ )
13770
+ .run(replacementUserId, id);
13771
+ } else {
13772
+ database
13773
+ .prepare<unknown, [string]>(
13774
+ `UPDATE workflow_runs
13775
+ SET context = json_remove(context, '$.swarm.requestedByUserId')
13776
+ WHERE json_valid(context)
13777
+ AND json_extract(context, '$.swarm.requestedByUserId') = ?`,
13778
+ )
13779
+ .run(id);
13780
+ }
13781
+
13782
+ const result = database.prepare("DELETE FROM users WHERE id = ?").run(id);
13783
+ return result.changes > 0;
13784
+ })();
13171
13785
  }
13172
13786
 
13173
13787
  // ============================================================================