@desplega.ai/agent-swarm 1.145.0 → 1.147.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 (221) hide show
  1. package/README.md +2 -1
  2. package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
  3. package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
  4. package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
  5. package/dist/assistant-kt31dj2h.js +236 -0
  6. package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
  7. package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
  8. package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
  9. package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
  10. package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
  11. package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
  12. package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
  13. package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
  14. package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
  15. package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
  16. package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
  17. package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
  18. package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
  19. package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
  20. package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
  21. package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
  22. package/dist/cli-9d8ntetj.js +42 -0
  23. package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
  24. package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
  25. package/dist/cli-d8brzxjb.js +190 -0
  26. package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
  27. package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
  28. package/dist/cli-ep19k3mb.js +19 -0
  29. package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
  30. package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
  31. package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
  32. package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
  33. package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
  34. package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
  35. package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
  36. package/dist/cli-m9sxbkhm.js +779 -0
  37. package/dist/cli-mmemxxdc.js +823 -0
  38. package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
  39. package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
  40. package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
  41. package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
  42. package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
  43. package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
  44. package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
  45. package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
  46. package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
  47. package/dist/cli.js +13 -13
  48. package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
  49. package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
  50. package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
  51. package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
  52. package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
  53. package/dist/handlers-0aspcejm.js +603 -0
  54. package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
  55. package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
  56. package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
  57. package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
  58. package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
  59. package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
  60. package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
  61. package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
  62. package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
  63. package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
  64. package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
  65. package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
  66. package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
  67. package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
  68. package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
  69. package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
  70. package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
  71. package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
  72. package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
  73. package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
  74. package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
  75. package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
  76. package/openapi.json +263 -12
  77. package/package.json +1 -1
  78. package/plugin/commands/start-worker.md +4 -9
  79. package/plugin/pi-skills/start-worker/SKILL.md +4 -9
  80. package/src/be/db/agents.ts +556 -0
  81. package/src/be/db/context-versions.ts +117 -0
  82. package/src/be/db/runtime.ts +406 -0
  83. package/src/be/db/tasks/read.ts +1167 -0
  84. package/src/be/db/tasks/write.ts +953 -0
  85. package/src/be/db.ts +584 -3429
  86. package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
  87. package/src/be/migrations/148_pages_svg.sql +123 -0
  88. package/src/be/migrations/149_session_tokens.sql +24 -0
  89. package/src/be/scripts/typecheck.ts +1 -0
  90. package/src/be/seed-pricing.ts +13 -8
  91. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  92. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  93. package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
  94. package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
  95. package/src/be/seed-scripts/index.ts +17 -0
  96. package/src/be/seed-skills/index.ts +14 -0
  97. package/src/be/swarm-config-guard.ts +5 -2
  98. package/src/be/task-attachment-store.ts +70 -0
  99. package/src/be/users.ts +77 -0
  100. package/src/commands/attachments-section.ts +37 -0
  101. package/src/commands/claude-managed-setup.ts +45 -3
  102. package/src/commands/claude-md-session.ts +408 -0
  103. package/src/commands/context-preamble.ts +15 -3
  104. package/src/commands/profile-sync.ts +205 -5
  105. package/src/commands/provider-credentials.ts +60 -27
  106. package/src/commands/runner.ts +63 -58
  107. package/src/commands/templates.ts +25 -2
  108. package/src/hooks/hook.ts +26 -67
  109. package/src/http/agents.ts +40 -1
  110. package/src/http/auth.ts +13 -1
  111. package/src/http/fs.ts +21 -51
  112. package/src/http/index.ts +2 -0
  113. package/src/http/mcp.ts +32 -0
  114. package/src/http/pages-public.ts +20 -4
  115. package/src/http/pages.ts +11 -3
  116. package/src/http/poll.ts +4 -0
  117. package/src/http/repos.ts +3 -3
  118. package/src/http/sessions.ts +82 -0
  119. package/src/http/tasks.ts +10 -2
  120. package/src/http/utils.ts +79 -1
  121. package/src/prompts/session-templates.ts +12 -8
  122. package/src/providers/acp-adapter.ts +62 -8
  123. package/src/providers/pi-mono-adapter.ts +23 -6
  124. package/src/providers/pricing-sources.md +11 -0
  125. package/src/scheduler/scheduler.test.ts +49 -2
  126. package/src/scheduler/scheduler.ts +4 -0
  127. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  128. package/src/scripts-runtime/types/stdlib.d.ts +8 -0
  129. package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
  130. package/src/server.ts +2 -0
  131. package/src/slack/assistant.ts +87 -46
  132. package/src/slack/handlers.test.ts +1 -3
  133. package/src/slack/handlers.ts +102 -87
  134. package/src/slack/inbound-files.ts +466 -0
  135. package/src/slack/templates.ts +14 -0
  136. package/src/tasks/task-terminal-effects.ts +158 -0
  137. package/src/tests/acp-adapter.test.ts +369 -117
  138. package/src/tests/budget-claim-gate.test.ts +1 -0
  139. package/src/tests/build-pi-skills.test.ts +5 -7
  140. package/src/tests/claude-managed-setup.test.ts +52 -4
  141. package/src/tests/claude-md-session.test.ts +603 -0
  142. package/src/tests/codex-skill-resolver.test.ts +19 -0
  143. package/src/tests/context-versioning.test.ts +107 -0
  144. package/src/tests/cors-allowlist.test.ts +309 -0
  145. package/src/tests/credential-check.test.ts +157 -14
  146. package/src/tests/db-agents.test.ts +91 -0
  147. package/src/tests/db-context-versions.test.ts +37 -0
  148. package/src/tests/db-extraction-contract.test.ts +45 -0
  149. package/src/tests/db-runtime.test.ts +70 -0
  150. package/src/tests/db-task-read-contract.test.ts +624 -0
  151. package/src/tests/db-task-write-contract.test.ts +653 -0
  152. package/src/tests/db-tasks-read.test.ts +183 -0
  153. package/src/tests/db-tasks-write.test.ts +230 -0
  154. package/src/tests/defer-task.test.ts +389 -0
  155. package/src/tests/docs-index-footprint.test.js +2 -2
  156. package/src/tests/e2e-visuals-stage.test.ts +151 -0
  157. package/src/tests/file-lock.test.ts +144 -0
  158. package/src/tests/fixtures/file-lock-holder.ts +13 -0
  159. package/src/tests/fixtures/hold-file-lock.ts +25 -0
  160. package/src/tests/heartbeat-expiry.test.ts +188 -0
  161. package/src/tests/http-api-integration.test.ts +78 -1
  162. package/src/tests/pages-public-html.test.ts +43 -0
  163. package/src/tests/pages-svg-migration.test.ts +42 -0
  164. package/src/tests/preload.ts +4 -1
  165. package/src/tests/profile-sync-warning.test.ts +16 -0
  166. package/src/tests/profile-sync.test.ts +126 -0
  167. package/src/tests/prompt-template-session.test.ts +20 -4
  168. package/src/tests/provider-registration.test.ts +93 -0
  169. package/src/tests/rbac-e2e-helpers.test.ts +53 -0
  170. package/src/tests/rbac-e2e-helpers.ts +19 -2
  171. package/src/tests/reload-config.test.ts +26 -0
  172. package/src/tests/routing-affinity-inventory.test.ts +1 -1
  173. package/src/tests/runner-attachments-section.test.ts +22 -2
  174. package/src/tests/runner-context-preamble.test.ts +30 -0
  175. package/src/tests/runner-deployment-resume.test.ts +107 -0
  176. package/src/tests/runner-repo-autostash.test.ts +12 -0
  177. package/src/tests/script-connections.test.ts +4 -0
  178. package/src/tests/seed-scripts.test.ts +2 -2
  179. package/src/tests/session-tokens.test.ts +279 -0
  180. package/src/tests/slack-file-tools-attachments.test.ts +408 -0
  181. package/src/tests/slack-inbound-files.test.ts +847 -0
  182. package/src/tests/star-history-refresh.test.ts +102 -0
  183. package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
  184. package/src/tests/swarm-repos.test.ts +20 -0
  185. package/src/tests/tool-annotations.test.ts +4 -3
  186. package/src/tests/ui-logs-parser.test.ts +176 -0
  187. package/src/tools/create-page.ts +4 -2
  188. package/src/tools/defer-task.ts +239 -0
  189. package/src/tools/repos/update-repo.ts +2 -2
  190. package/src/tools/schedules/create-schedule.ts +1 -0
  191. package/src/tools/schedules/list-schedules.ts +1 -0
  192. package/src/tools/schedules/patch-schedule.ts +1 -0
  193. package/src/tools/schedules/update-schedule.ts +1 -0
  194. package/src/tools/slack-download-file.ts +60 -29
  195. package/src/tools/slack-read.ts +73 -32
  196. package/src/tools/store-progress.ts +16 -146
  197. package/src/tools/tool-config.ts +1 -0
  198. package/src/tools/utils.ts +21 -0
  199. package/src/types.ts +37 -4
  200. package/src/utils/acp-session-token.ts +56 -0
  201. package/src/utils/file-lock.ts +111 -0
  202. package/src/utils/heartbeat-expiry.ts +78 -0
  203. package/src/utils/request-auth-context.ts +2 -0
  204. package/src/utils/task-attachment-links.ts +22 -0
  205. package/templates/skills/code-quality/content.md +8 -5
  206. package/templates/skills/download-task-attachment/content.md +2 -1
  207. package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
  208. package/templates/skills/review-offered-task/config.json +14 -0
  209. package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
  210. package/templates/skills/scheduling/content.md +4 -0
  211. package/templates/skills/slack-interaction/content.md +2 -2
  212. package/templates/skills/work-on-task/SKILL.md +16 -0
  213. package/templates/skills/work-on-task/config.json +14 -0
  214. package/templates/skills/work-on-task/content.md +11 -0
  215. package/dist/assistant-0qt42nzg.js +0 -212
  216. package/dist/cli-fs1kkqgr.js +0 -317
  217. package/dist/cli-jnf5zghe.js +0 -269
  218. package/dist/cli-n1q8q14d.js +0 -177
  219. package/dist/handlers-yfp1qn81.js +0 -592
  220. package/plugin/commands/work-on-task.md +0 -39
  221. package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
package/src/be/db.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { Database } from "bun:sqlite";
2
- import { parseProviderMeta } from "@/utils/provider-metadata.ts";
1
+ import type { Database } from "bun:sqlite";
3
2
  import pkg from "../../package.json";
4
3
  import { defaultAssetKey, normalizeAssetKey } from "../assets/key";
5
4
  import {
@@ -8,7 +7,6 @@ import {
8
7
  matchesDefaultClaudeMd,
9
8
  matchesDefaultIdentityMd,
10
9
  } from "../prompts/defaults";
11
- import { configureDbResolver } from "../prompts/resolver";
12
10
  import { realtimeBus } from "../realtime/bus";
13
11
  import { slackChannelFromContextKey } from "../tasks/slack-routing";
14
12
  import { _resolveIntegrationType, emitIntegrationConnected, telemetry } from "../telemetry";
@@ -16,14 +14,11 @@ import type {
16
14
  ActiveSession,
17
15
  Agent,
18
16
  AgentAvatar,
19
- AgentCredStatus,
20
17
  AgentLog,
21
18
  AgentLogEventType,
22
19
  AgentMcpServer,
23
20
  AgentSkill,
24
- AgentStatus,
25
21
  AgentTask,
26
- AgentTaskSource,
27
22
  AgentTaskStatus,
28
23
  AgentTaskSummary,
29
24
  AgentWithTasks,
@@ -35,16 +30,13 @@ import type {
35
30
  BudgetRefusalCause,
36
31
  BudgetRefusalNotification,
37
32
  BudgetScope,
38
- ChangeSource,
39
33
  Channel,
40
34
  ChannelMessage,
41
35
  ChannelType,
42
36
  ContextSnapshot,
43
37
  ContextSnapshotEventType,
44
- ContextVersion,
45
38
  CooldownConfig,
46
39
  FavoriteItemType,
47
- FollowUpConfig,
48
40
  InboxItemState,
49
41
  InboxItemStatus,
50
42
  InboxItemType,
@@ -71,12 +63,12 @@ import type {
71
63
  PricingProvider,
72
64
  PricingRow,
73
65
  PricingTokenClass,
66
+ ProfileExpectedHashes,
67
+ ProfileSyncConflict,
74
68
  PromptTemplate,
75
69
  PromptTemplateHistory,
76
70
  ProviderName,
77
- ReasoningEffort,
78
71
  RepoGuidelines,
79
- RoutingAffinity,
80
72
  ScheduledTask,
81
73
  ScheduledTaskSummary,
82
74
  ScriptRun,
@@ -123,22 +115,18 @@ import type {
123
115
  WorkflowVersion,
124
116
  } from "../types";
125
117
  import {
126
- AgentAvatarSchema,
127
118
  type CreateTaskOptions,
128
119
  CreateTaskOptionsSchema,
129
- FollowUpConfigSchema,
130
120
  isTerminalTaskStatus,
131
121
  type ModelTier,
132
122
  parseModelTier,
133
- ReasoningEffortSchema,
134
- RoutingAffinitySchema,
135
123
  SERVER_GENERATED_ATTACHMENT_CAPABILITY,
136
124
  SessionCostModelBreakdownSchema,
137
125
  } from "../types";
138
126
  import { deriveProviderFromKeyType } from "../utils/credentials";
139
- import { isEnvFlagEnabled } from "../utils/env-flag";
140
127
  import type { RateLimitWindowTelemetry } from "../utils/error-tracker";
141
128
  import { extractGitHubPullRequestUrls } from "../utils/github-pull-request";
129
+ import { validateHeartbeatExpiry } from "../utils/heartbeat-expiry";
142
130
  import {
143
131
  type BudgetedIdentityField,
144
132
  checkIdentityFieldBudget,
@@ -146,20 +134,167 @@ import {
146
134
  } from "../utils/identity-field-budget";
147
135
  import { getCurrentRequestUserId } from "../utils/request-auth-context";
148
136
  import { registerVolatileSecret, scrubSecrets } from "../utils/secret-scrubber";
149
- import { auditAssetKeys, enforceAssetKeyStartupAudit } from "./asset-key-audit";
150
- import { migrateLegacyCredentialBindingBlob } from "./connection-bindings-blob-migration";
151
- import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
137
+ import { auditAssetKeys } from "./asset-key-audit";
138
+ import { decryptSecret, encryptSecret, getEncryptionKey } from "./crypto";
152
139
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
153
- import { createBunSqliteClient, type DbClient } from "./db-client";
154
- import { runMigrations } from "./migrations/runner";
155
- import { autoEncryptLegacyOAuthSecrets } from "./oauth-encryption-backfill";
156
- import { seedDefaultTemplates } from "./seed-prompt-templates";
140
+ import {
141
+ type AgentRow,
142
+ configureAgentDependencies,
143
+ getActiveTaskCount,
144
+ getAgentById,
145
+ getAllAgents,
146
+ isAgentEligibleForTask,
147
+ rowToAgent,
148
+ } from "./db/agents";
149
+ import {
150
+ computeContentHash,
151
+ createContextVersion,
152
+ getLatestContextVersion,
153
+ } from "./db/context-versions";
154
+ import { getDb, getDbClient } from "./db/runtime";
155
+ import {
156
+ type AgentTaskRow,
157
+ configureTaskReadDependencies,
158
+ type ExistingTrackerContextWork,
159
+ findExistingLinearTrackerContextWork,
160
+ getTaskById,
161
+ getTasksByAgentId,
162
+ rowToAgentTask,
163
+ rowToAgentTaskSummary,
164
+ } from "./db/tasks/read";
165
+ import { configureTaskWriteDependencies, failTask } from "./db/tasks/write";
157
166
  import { promotePendingSteeringForTask } from "./steering";
158
167
  import { isReservedConfigKey, reservedKeyError } from "./swarm-config-guard";
159
168
  import { emitTaskStarted } from "./task-lifecycle-events";
160
169
 
161
- let db: Database | null = null;
162
- let sqliteVecAvailable = false;
170
+ export {
171
+ buildRoutingAffinityFromAgent,
172
+ createAgent,
173
+ deleteAgent,
174
+ getActiveTaskCount,
175
+ getAgentById,
176
+ getAgentHarnessProviders,
177
+ getAllAgents,
178
+ getLeadAgent,
179
+ getRemainingCapacity,
180
+ hasCapacity,
181
+ incrementEmptyPollCount,
182
+ isAgentEligibleForTask,
183
+ isPoolAffinityEnforcementEnabled,
184
+ listAgentsWithCredStatusByProvider,
185
+ MAX_EMPTY_POLLS,
186
+ resetEmptyPollCount,
187
+ setAgentHarnessProvider,
188
+ shouldBlockPolling,
189
+ updateAgentActivity,
190
+ updateAgentCredentialMissing,
191
+ updateAgentCredentialState,
192
+ updateAgentCredStatus,
193
+ updateAgentMaxTasks,
194
+ updateAgentProvider,
195
+ updateAgentStatus,
196
+ updateAgentStatusFromCapacity,
197
+ } from "./db/agents";
198
+ export {
199
+ computeContentHash,
200
+ createContextVersion,
201
+ getContextVersion,
202
+ getContextVersionHistory,
203
+ getLatestContextVersion,
204
+ } from "./db/context-versions";
205
+
206
+ configureAgentDependencies({
207
+ createLogEntry: (entry) => createLogEntry(entry),
208
+ installSystemDefaultSkillsForAgent: (agentId) => installSystemDefaultSkillsForAgent(agentId),
209
+ });
210
+
211
+ export {
212
+ __resetSqliteVecExtensionPathCacheForTests,
213
+ closeDb,
214
+ getDb,
215
+ getDbClient,
216
+ initDb,
217
+ isSqliteVecAvailable,
218
+ resolveSqliteVecExtensionPath,
219
+ } from "./db/runtime";
220
+
221
+ export {
222
+ type ExistingTrackerContextWork,
223
+ type ExistingTrackerContextWorkReason,
224
+ findCompletedTaskInThread,
225
+ findExistingLinearTrackerContextWork,
226
+ findRecentCancelledTaskInThread,
227
+ findTaskByGitHub,
228
+ findTaskByVcs,
229
+ getAgentCurrentTask,
230
+ getAgentWorkingOnThread,
231
+ getAllTasks,
232
+ getChildTasks,
233
+ getCompletedSlackTasks,
234
+ getInProgressSlackTasks,
235
+ getInProgressTasksByContextKey,
236
+ getLatestActiveTaskInThread,
237
+ getLatestLeadTaskInThread,
238
+ getLatestScriptRunStepTaskByContextKey,
239
+ getLatestTaskByContextKey,
240
+ getMostRecentTaskInThread,
241
+ getPendingSlackRelayTasks,
242
+ getRecentlyFinishedWorkerTasks,
243
+ getSlackTasksMissingTree,
244
+ getTaskById,
245
+ getTaskStats,
246
+ getTasksByAgentId,
247
+ getTasksByStatus,
248
+ getTasksCount,
249
+ hasNonTerminalRerouteDecisionChild,
250
+ hasNonTerminalResumeChild,
251
+ markFinalizedSlackRelaysDelivered,
252
+ markSlackRelayAttempted,
253
+ markSlackRelayDelivered,
254
+ markTasksNotified,
255
+ resetTasksNotified,
256
+ type TaskFilters,
257
+ } from "./db/tasks/read";
258
+
259
+ export {
260
+ assignUnassignedTaskPending,
261
+ backfillSupersedeTaskResumeTaskId,
262
+ cancelTask,
263
+ completeTask,
264
+ createTask,
265
+ deleteTask,
266
+ failTask,
267
+ getOrphanedInProgressTasksForAgent,
268
+ getPausedTasksForAgent,
269
+ getPendingTaskForAgent,
270
+ getRecentlyCancelledTasksForAgent,
271
+ overwriteTerminalTaskResultText,
272
+ pauseTask,
273
+ resetOrphanedInProgressTasksForAgent,
274
+ resumeTask,
275
+ startTask,
276
+ supersedeTask,
277
+ updateTaskClaudeSessionId,
278
+ updateTaskProgress,
279
+ updateTaskTitle,
280
+ } from "./db/tasks/write";
281
+
282
+ configureTaskWriteDependencies({
283
+ createLogEntry: (...args) => createLogEntry(...args),
284
+ checkDependencies: (...args) => checkDependencies(...args),
285
+ reconcileTaskPullRequestAttachments: (...args) => reconcileTaskPullRequestAttachments(...args),
286
+ emitTaskLifecycleTelemetryAfterCommit: (...args) =>
287
+ emitTaskLifecycleTelemetryAfterCommit(...args),
288
+ taskContextForTelemetry: (...args) => taskContextForTelemetry(...args),
289
+ promotePendingSteeringForTask: (...args) => promotePendingSteeringForTask(...args),
290
+ cascadeFailDependents: (...args) => cascadeFailDependents(...args),
291
+ });
292
+
293
+ configureTaskReadDependencies({
294
+ checkDependencies: (taskId) => checkDependencies(taskId),
295
+ assetKeyPrefixPattern: (input) => assetKeyPrefixPattern(input),
296
+ previewText: (text, maxChars) => previewText(text, maxChars),
297
+ });
163
298
 
164
299
  type TaskTelemetryProps = Parameters<typeof telemetry.taskEvent>[1];
165
300
  type TaskTelemetryContext = {
@@ -211,303 +346,6 @@ function taskContextForTelemetry(task: AgentTask): TaskTelemetryContext {
211
346
  return context;
212
347
  }
213
348
 
214
- export function isSqliteVecAvailable(): boolean {
215
- return sqliteVecAvailable;
216
- }
217
-
218
- /**
219
- * Resolve the sqlite-vec loadable extension path without opening a Database.
220
- * Mirrors loadSqliteVec's env-var-first, npm-fallback resolution, but returns
221
- * the path instead of loading it: the bounded db-query child process (see
222
- * src/http/db-query-bounded.ts) opens its own read-only connection and must
223
- * load the extension itself, so the parent resolves the path once and passes
224
- * it along. The result is memoized — `undefined` is a valid resolved answer
225
- * (extension genuinely unavailable) and is cached too, so a failing
226
- * `require` doesn't retry on every query.
227
- */
228
- let cachedSqliteVecExtensionPath: string | undefined | null = null;
229
-
230
- export function resolveSqliteVecExtensionPath(): string | undefined {
231
- if (cachedSqliteVecExtensionPath !== null) return cachedSqliteVecExtensionPath;
232
- const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
233
- if (extensionPath) {
234
- cachedSqliteVecExtensionPath = extensionPath;
235
- return cachedSqliteVecExtensionPath;
236
- }
237
- try {
238
- cachedSqliteVecExtensionPath = (
239
- require("sqlite-vec") as { getLoadablePath(): string }
240
- ).getLoadablePath();
241
- } catch {
242
- cachedSqliteVecExtensionPath = undefined;
243
- }
244
- return cachedSqliteVecExtensionPath;
245
- }
246
-
247
- /** Test-only: clear the memoized sqlite-vec extension path cache. */
248
- export function __resetSqliteVecExtensionPathCacheForTests(): void {
249
- cachedSqliteVecExtensionPath = null;
250
- }
251
-
252
- function loadSqliteVec(database: Database): void {
253
- sqliteVecAvailable = false;
254
- try {
255
- const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
256
- if (extensionPath) {
257
- database.loadExtension(extensionPath);
258
- } else {
259
- const sqliteVec = require("sqlite-vec");
260
- sqliteVec.load(database);
261
- }
262
- sqliteVecAvailable = true;
263
- console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
264
- } catch (err) {
265
- console.warn(
266
- "[db] sqlite-vec not available, falling back to in-memory cosine:",
267
- (err as Error).message,
268
- );
269
- }
270
- }
271
-
272
- export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
273
- if (db) {
274
- return db;
275
- }
276
-
277
- // Fast path for tests: restore from pre-built template that already has
278
- // migrations, seeds, and all post-init work baked in. Only the per-connection
279
- // PRAGMA and the in-memory resolver function need to be set.
280
- const templateGlobals = globalThis as typeof globalThis & {
281
- __testMigrationTemplate?: Uint8Array;
282
- };
283
- const templateBytes = templateGlobals.__testMigrationTemplate;
284
- if (templateBytes) {
285
- db = Database.deserialize(templateBytes);
286
- db.run("PRAGMA busy_timeout = 5000;");
287
- db.run("PRAGMA foreign_keys = ON;");
288
- loadSqliteVec(db);
289
- configureDbResolver(resolvePromptTemplate);
290
- enforceAssetKeyStartupAudit(db);
291
- // Ensure the encryption key is resolved even when restoring from the test
292
- // template. The cache may have been cleared via __resetEncryptionKeyForTests
293
- // between test suites; this call is a no-op if the cache is already warm.
294
- resolveEncryptionKey(dbPath);
295
- return db;
296
- }
297
-
298
- db = new Database(dbPath, { create: true });
299
- console.log(`Database initialized at ${dbPath}`);
300
-
301
- const database = db;
302
- database.run("PRAGMA journal_mode = WAL;");
303
- database.run("PRAGMA busy_timeout = 5000;");
304
- database.run("PRAGMA foreign_keys = ON;");
305
- database.run("PRAGMA synchronous = NORMAL;");
306
- database.run("PRAGMA cache_size = -64000;");
307
- database.run("PRAGMA mmap_size = 268435456;");
308
- database.run("PRAGMA temp_store = MEMORY;");
309
-
310
- // Load sqlite-vec extension for vector search.
311
- // In compiled binaries (`bun build --compile`) the JS lives in /$bunfs/ and
312
- // `require.resolve("sqlite-vec-<platform>/vec0.so")` can't find the native
313
- // asset — so we prefer an explicit filesystem path when set, and only fall
314
- // back to the npm resolver for normal dev runs.
315
- loadSqliteVec(database);
316
-
317
- // Run database migrations (schema creation + incremental changes)
318
- try {
319
- runMigrations(database);
320
- } catch (error) {
321
- db = null;
322
- try {
323
- database.close();
324
- } catch (closeError) {
325
- console.error("[migrations] Failed to close database after migration failure:", closeError);
326
- }
327
- throw error;
328
- }
329
-
330
- // Compatibility migration for legacy databases that predate profile fields
331
- ensureAgentProfileColumns(database);
332
-
333
- // Migration: Remove restrictive CHECK constraint on agent_tasks.status
334
- // Old databases have CHECK(status IN ('pending','in_progress','completed','failed'))
335
- // which blocks 'cancelled', 'paused', 'offered', 'unassigned' statuses
336
- try {
337
- const taskSchemaInfo = db
338
- .prepare<{ sql: string | null }, []>(
339
- "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'agent_tasks'",
340
- )
341
- .get();
342
-
343
- const schemaSql = taskSchemaInfo?.sql ?? "";
344
- const hasStatusCheck = /status\s+TEXT\b[^,]*\bCHECK\s*\(\s*status\s+IN\s*\(/i.test(schemaSql);
345
- const statusAllowsCancelled = /status\s+IN\s*\([^)]*'cancelled'/i.test(schemaSql);
346
- const needsStatusMigration = hasStatusCheck && !statusAllowsCancelled;
347
-
348
- if (needsStatusMigration) {
349
- console.log("[Migration] Removing restrictive CHECK constraint on agent_tasks.status");
350
- db.run("PRAGMA foreign_keys=off");
351
-
352
- // Migrations run before this compatibility guard, so a legacy table now
353
- // carries every current column. Rebuilding from an old hard-coded column
354
- // list would silently discard later fields. Derive the replacement
355
- // schema, copy list, indexes, and triggers from the live table instead;
356
- // remove only the obsolete status CHECK.
357
- const rebuiltSchemaSql = schemaSql
358
- .replace(
359
- /^(CREATE TABLE\s+(?:IF NOT EXISTS\s+)?)(?:"agent_tasks"|agent_tasks)/i,
360
- "$1agent_tasks_new",
361
- )
362
- .replace(/\s+CHECK\s*\(\s*status\s+IN\s*\([^)]*\)\s*\)/i, "");
363
- if (rebuiltSchemaSql === schemaSql || !/agent_tasks_new/i.test(rebuiltSchemaSql)) {
364
- throw new Error("Could not derive agent_tasks schema without legacy status CHECK");
365
- }
366
- const columns = db
367
- .prepare<{ name: string }, []>('PRAGMA table_info("agent_tasks")')
368
- .all()
369
- .map((column) => `"${column.name.replaceAll('"', '""')}"`)
370
- .join(", ");
371
- const schemaObjects = db
372
- .prepare<{ sql: string }, []>(
373
- `SELECT sql FROM sqlite_master
374
- WHERE tbl_name = 'agent_tasks'
375
- AND type IN ('index', 'trigger')
376
- AND sql IS NOT NULL
377
- ORDER BY type, name`,
378
- )
379
- .all()
380
- .map((row) => row.sql);
381
-
382
- database.transaction(() => {
383
- database.run("DROP TABLE IF EXISTS agent_tasks_new");
384
- database.run(rebuiltSchemaSql);
385
- database.run(`INSERT INTO agent_tasks_new (${columns}) SELECT ${columns} FROM agent_tasks`);
386
- database.run("DROP TABLE agent_tasks");
387
- database.run("ALTER TABLE agent_tasks_new RENAME TO agent_tasks");
388
- for (const sql of schemaObjects) database.run(sql);
389
- })();
390
-
391
- db.run("PRAGMA foreign_keys=on");
392
- console.log("[Migration] Successfully removed CHECK constraint on agent_tasks.status");
393
- }
394
- } catch (e) {
395
- console.error("[Migration] Failed to update agent_tasks CHECK constraint:", e);
396
- try {
397
- db.run("PRAGMA foreign_keys=on");
398
- } catch (cleanupError) {
399
- console.error("[Migration] Failed to re-enable SQLite foreign_keys pragma:", cleanupError);
400
- }
401
- throw e;
402
- }
403
-
404
- // Mandatory namespace invariant: structural corruption is fatal before the
405
- // API starts listening. Unknown personal users/provider drift remain
406
- // readable warnings so operators can repair them through the audit surface.
407
- enforceAssetKeyStartupAudit(database);
408
-
409
- // Backfill: Seed v1 for existing agents that don't have any context versions yet
410
- seedContextVersions();
411
-
412
- // Inject DB resolver into the prompt template resolver (DI to avoid worker/API boundary violation)
413
- configureDbResolver(resolvePromptTemplate);
414
-
415
- // Seed default prompt templates from the in-memory code registry
416
- seedDefaultTemplates();
417
-
418
- const hasExistingEncryptedSecrets =
419
- (database
420
- .prepare<{ present: number }, []>(
421
- `SELECT EXISTS(
422
- SELECT 1 FROM swarm_config WHERE isSecret = 1 AND encrypted = 1
423
- UNION ALL
424
- SELECT 1 FROM oauth_apps
425
- WHERE clientSecretEncrypted = 1 AND clientSecret IS NOT NULL
426
- UNION ALL
427
- SELECT 1 FROM oauth_authorizations WHERE tokensEncrypted = 1
428
- ) AS present`,
429
- )
430
- .get()?.present ?? 0) === 1;
431
-
432
- // Track whether user provided the key (for backup decision)
433
- const userProvidedKey = !!(
434
- process.env.SECRETS_ENCRYPTION_KEY?.length || process.env.SECRETS_ENCRYPTION_KEY_FILE?.length
435
- );
436
-
437
- // Resolve the secrets encryption key after migrations so we can tell whether
438
- // this DB already contains encrypted secret rows (must reuse an explicit or
439
- // on-disk key) or is still plaintext-only (safe to generate a new key before
440
- // auto-migrating legacy plaintext rows).
441
- resolveEncryptionKey(dbPath, { allowGenerate: !hasExistingEncryptedSecrets });
442
-
443
- // Migration 117 carries plaintext tracker OAuth rows with explicit flags;
444
- // encrypt them only after the shared key has been resolved. This pass is
445
- // idempotent and intentionally fatal on failure so boot never continues
446
- // with OAuth credentials left in plaintext.
447
- try {
448
- autoEncryptLegacyOAuthSecrets(database);
449
- } catch (err) {
450
- console.error(
451
- `[oauth-encryption] FATAL: failed to auto-encrypt legacy OAuth secrets: ${(err as Error).message}`,
452
- );
453
- throw err;
454
- }
455
-
456
- // Auto-encrypt any legacy plaintext secrets that predate the encryption
457
- // feature. Runs after all compatibility guards; failures are fatal because
458
- // continuing would leave secrets at rest in plaintext — the opposite of the
459
- // guarantee this feature provides.
460
- try {
461
- autoEncryptLegacyPlaintextSecrets(database, dbPath, { createBackup: !userProvidedKey });
462
- } catch (err) {
463
- console.error(
464
- `[secrets] FATAL: failed to auto-encrypt legacy secrets: ${(err as Error).message}`,
465
- );
466
- throw err;
467
- }
468
-
469
- // Retire the legacy SCRIPT_CREDENTIAL_BINDINGS swarm-config blob: promote any
470
- // remaining entries to relational rows so the credential broker is
471
- // relational-only. Idempotent; failures are fatal because a silently-dropped
472
- // binding would leave scripts unable to authenticate.
473
- try {
474
- migrateLegacyCredentialBindingBlob(database);
475
- } catch (err) {
476
- console.error(
477
- `[credential-bindings] FATAL: failed to migrate legacy credential binding blob: ${(err as Error).message}`,
478
- );
479
- throw err;
480
- }
481
-
482
- return db;
483
- }
484
-
485
- export function getDb(path?: string): Database {
486
- if (!db) {
487
- return initDb(path ?? process.env.DATABASE_PATH);
488
- }
489
- return db;
490
- }
491
-
492
- export function closeDb(): void {
493
- if (db) {
494
- db.close();
495
- db = null;
496
- }
497
- sqliteVecAvailable = false;
498
- }
499
-
500
- // Async seam over the shared connection. The client resolves the underlying
501
- // handle per operation via getDb(), so close/reopen cycles need no reset.
502
- let dbClientInstance: DbClient | null = null;
503
-
504
- export function getDbClient(): DbClient {
505
- if (!dbClientInstance) {
506
- dbClientInstance = createBunSqliteClient(() => getDb());
507
- }
508
- return dbClientInstance;
509
- }
510
-
511
349
  // ============================================================================
512
350
  // Context Versioning
513
351
  // ============================================================================
@@ -528,1408 +366,241 @@ const BUDGETED_IDENTITY_FIELDS: BudgetedIdentityField[] = [
528
366
  "toolsMd",
529
367
  ];
530
368
 
531
- function ensureAgentProfileColumns(database: Database): void {
532
- // `PRAGMA table_info` on a nonexistent table returns an empty result set
533
- // rather than erroring, which used to make every column below look
534
- // "missing" on a table that was never created and throw `no such table:
535
- // agents` from the ALTER below. This is a legacy-compat shim for
536
- // pre-migration-system databases; runMigrations() (and its own
537
- // assertNotEmptyDatabase guard) is what's responsible for the `agents`
538
- // table existing at all, and already fails loudly if it doesn't. This
539
- // function must never be the thing that crashes startup instead.
540
- const agentsTableExists = database
541
- .prepare<{ name: string }, []>(
542
- "SELECT name FROM sqlite_master WHERE type='table' AND name='agents'",
543
- )
544
- .get();
545
- if (!agentsTableExists) {
546
- console.warn("[Migration] agents table does not exist yet — skipping profile column backfill");
547
- return;
548
- }
369
+ // ============================================================================
370
+ // AgentTask Queries
371
+ // ============================================================================
549
372
 
550
- const existingColumns = new Set(
551
- database
552
- .prepare<{ name: string }, []>("PRAGMA table_info(agents)")
553
- .all()
554
- .map((row) => row.name),
373
+ export async function getSlackRenderV2ActivatedAt(): Promise<string | null> {
374
+ const row = await getDbClient().get<{ activated_at: string }>(
375
+ `SELECT activated_at FROM slack_render_v2_state WHERE id = 1`,
555
376
  );
556
-
557
- for (const column of VERSIONABLE_FIELDS) {
558
- if (!existingColumns.has(column)) {
559
- try {
560
- database.run(`ALTER TABLE agents ADD COLUMN ${column} TEXT`);
561
- } catch (error) {
562
- console.error(`[Migration] Failed to add missing agents.${column} column`, error);
563
- throw error;
564
- }
565
- }
566
- }
567
- }
568
-
569
- export function computeContentHash(content: string): string {
570
- const hasher = new Bun.CryptoHasher("sha256");
571
- hasher.update(content);
572
- return hasher.digest("hex");
573
- }
574
-
575
- type ContextVersionRow = {
576
- id: string;
577
- agentId: string;
578
- field: string;
579
- content: string;
580
- version: number;
581
- changeSource: string;
582
- changedByAgentId: string | null;
583
- changeReason: string | null;
584
- contentHash: string;
585
- previousVersionId: string | null;
586
- createdAt: string;
587
- };
588
-
589
- function rowToContextVersion(row: ContextVersionRow): ContextVersion {
590
- return {
591
- id: row.id,
592
- agentId: row.agentId,
593
- field: row.field as VersionableField,
594
- content: row.content,
595
- version: row.version,
596
- changeSource: row.changeSource as ChangeSource,
597
- changedByAgentId: row.changedByAgentId,
598
- changeReason: row.changeReason,
599
- contentHash: row.contentHash,
600
- previousVersionId: row.previousVersionId,
601
- createdAt: row.createdAt,
602
- };
377
+ return row?.activated_at ?? null;
603
378
  }
604
379
 
605
- export async function createContextVersion(params: {
606
- agentId: string;
607
- field: VersionableField;
608
- content: string;
609
- version: number;
610
- changeSource: ChangeSource;
611
- changedByAgentId?: string | null;
612
- changeReason?: string | null;
613
- contentHash: string;
614
- previousVersionId?: string | null;
615
- }): Promise<ContextVersion> {
616
- const id = crypto.randomUUID();
617
- const now = new Date().toISOString();
618
-
619
- const row = await getDbClient().get<ContextVersionRow>(
620
- `INSERT INTO context_versions (id, agentId, field, content, version, changeSource, changedByAgentId, changeReason, contentHash, previousVersionId, createdAt)
621
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
622
- [
623
- id,
624
- params.agentId,
625
- params.field,
626
- params.content,
627
- params.version,
628
- params.changeSource,
629
- params.changedByAgentId ?? null,
630
- params.changeReason ?? null,
631
- params.contentHash,
632
- params.previousVersionId ?? null,
633
- now,
634
- ],
380
+ export async function ensureSlackRenderV2Activation(): Promise<string> {
381
+ const activatedAt = new Date().toISOString();
382
+ await getDbClient().run(
383
+ `INSERT INTO slack_render_v2_state (id, activated_at)
384
+ VALUES (1, ?)
385
+ ON CONFLICT(id) DO NOTHING`,
386
+ [activatedAt],
635
387
  );
636
-
637
- if (!row) throw new Error("Failed to create context version");
638
- return rowToContextVersion(row);
388
+ const persisted = await getSlackRenderV2ActivatedAt();
389
+ if (!persisted) throw new Error("Failed to persist Slack render v2 activation");
390
+ return persisted;
639
391
  }
640
392
 
641
- export async function getLatestContextVersion(
642
- agentId: string,
643
- field: VersionableField,
644
- ): Promise<ContextVersion | null> {
645
- const row = await getDbClient().get<ContextVersionRow>(
646
- `SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT 1`,
647
- [agentId, field],
393
+ export async function getSlackDelegationActivatedAt(): Promise<string | null> {
394
+ const row = await getDbClient().get<{ delegation_activated_at: string | null }>(
395
+ `SELECT delegation_activated_at FROM slack_render_v2_state WHERE id = 1`,
648
396
  );
649
-
650
- return row ? rowToContextVersion(row) : null;
397
+ return row?.delegation_activated_at ?? null;
651
398
  }
652
399
 
653
- export async function getContextVersion(id: string): Promise<ContextVersion | null> {
654
- const row = await getDbClient().get<ContextVersionRow>(
655
- `SELECT * FROM context_versions WHERE id = ?`,
656
- [id],
400
+ export async function ensureSlackDelegationActivation(): Promise<string> {
401
+ const activatedAt = new Date().toISOString();
402
+ await getDbClient().run(
403
+ `INSERT INTO slack_render_v2_state (id, activated_at, delegation_activated_at)
404
+ VALUES (1, ?, ?)
405
+ ON CONFLICT(id) DO UPDATE SET
406
+ delegation_activated_at = COALESCE(slack_render_v2_state.delegation_activated_at, excluded.delegation_activated_at)`,
407
+ [activatedAt, activatedAt],
657
408
  );
658
-
659
- return row ? rowToContextVersion(row) : null;
409
+ const persisted = await getSlackDelegationActivatedAt();
410
+ if (!persisted) throw new Error("Failed to persist Slack render v2 delegation activation");
411
+ return persisted;
660
412
  }
661
413
 
662
- export async function getContextVersionHistory(params: {
663
- agentId: string;
664
- field?: VersionableField;
665
- limit?: number;
666
- }): Promise<ContextVersion[]> {
667
- const limit = params.limit ?? 10;
668
-
669
- if (params.field) {
670
- const rows = await getDbClient().query<ContextVersionRow>(
671
- `SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT ?`,
672
- [params.agentId, params.field, limit],
673
- );
674
- return rows.map(rowToContextVersion);
675
- }
414
+ export type SlackMessageKind = "tree" | "outcome" | "agent";
415
+ export type SlackConclusionKind = "complete" | "timeout";
676
416
 
677
- const rows = await getDbClient().query<ContextVersionRow>(
678
- `SELECT * FROM context_versions WHERE agentId = ? ORDER BY createdAt DESC LIMIT ?`,
679
- [params.agentId, limit],
680
- );
681
- return rows.map(rowToContextVersion);
417
+ export interface SlackMessageRecord {
418
+ id: string;
419
+ contextKey: string;
420
+ channelId: string;
421
+ threadTs: string;
422
+ ts: string;
423
+ kind: SlackMessageKind;
424
+ taskId?: string;
425
+ permalink?: string;
426
+ finalizedAt?: string;
427
+ streamChunksAppended: number;
428
+ conclusionKind?: SlackConclusionKind;
429
+ createdAt: string;
430
+ updatedAt: string;
682
431
  }
683
432
 
684
- /**
685
- * Seed v1 context versions for existing agents that don't have any versions yet.
686
- * Called during migration.
687
- */
688
- function seedContextVersions(): void {
689
- const database = getDb();
690
- const agents = database
691
- .prepare<
692
- {
693
- id: string;
694
- soulMd: string | null;
695
- identityMd: string | null;
696
- toolsMd: string | null;
697
- claudeMd: string | null;
698
- setupScript: string | null;
699
- heartbeatMd: string | null;
700
- },
701
- []
702
- >(`SELECT id, soulMd, identityMd, toolsMd, claudeMd, setupScript, heartbeatMd FROM agents`)
703
- .all();
704
-
705
- for (const agent of agents) {
706
- for (const field of VERSIONABLE_FIELDS) {
707
- const content = agent[field];
708
- if (!content) continue;
709
-
710
- // Check if a version already exists for this agent+field
711
- const existing = database
712
- .prepare<{ id: string }, [string, string]>(
713
- `SELECT id FROM context_versions WHERE agentId = ? AND field = ? LIMIT 1`,
714
- )
715
- .get(agent.id, field);
716
- if (existing) continue;
717
-
718
- const id = crypto.randomUUID();
719
- const hash = computeContentHash(content);
720
- const now = new Date().toISOString();
433
+ const PENDING_SLACK_MESSAGE_TS_PREFIX = "pending:";
721
434
 
722
- database
723
- .prepare(
724
- `INSERT INTO context_versions (id, agentId, field, content, version, changeSource, contentHash, createdAt)
725
- VALUES (?, ?, ?, ?, 1, 'system', ?, ?)`,
726
- )
727
- .run(id, agent.id, field, content, hash, now);
728
- }
729
- }
435
+ export function isPendingSlackMessage(record: SlackMessageRecord): boolean {
436
+ return record.ts.startsWith(PENDING_SLACK_MESSAGE_TS_PREFIX);
730
437
  }
731
438
 
732
- // ============================================================================
733
- // Agent Queries
734
- // ============================================================================
735
-
736
- type AgentRow = {
439
+ type SlackMessageRow = {
737
440
  id: string;
738
- name: string;
739
- isLead: number;
740
- status: AgentStatus;
741
- description: string | null;
742
- role: string | null;
743
- capabilities: string | null;
744
- maxTasks: number | null;
745
- emptyPollCount: number | null;
746
- claudeMd: string | null;
747
- soulMd: string | null;
748
- identityMd: string | null;
749
- setupScript: string | null;
750
- toolsMd: string | null;
751
- heartbeatMd: string | null;
752
- lastActivityAt: string | null;
753
- provider: string | null;
754
- createdAt: string;
755
- lastUpdatedAt: string;
756
- /** JSON array of env-var names; populated only when status is `waiting_for_credentials`. */
757
- credentialMissing: string | null;
758
- /** Phase 1.5: per-agent harness provider pushed on worker registration. */
759
- harness_provider: string | null;
760
- /** Migration 055: worker-self-reported credential snapshot (JSON of AgentCredStatus). NULL = unreported. */
761
- cred_status: string | null;
762
- /** Migration 119: custom avatar (JSON of AgentAvatar). NULL = deterministic hash-derived fallback. */
763
- avatar: string | null;
441
+ context_key: string;
442
+ channel_id: string;
443
+ thread_ts: string;
444
+ ts: string;
445
+ kind: SlackMessageKind;
446
+ task_id: string | null;
447
+ permalink: string | null;
448
+ finalized_at: string | null;
449
+ stream_chunks_appended: number;
450
+ conclusion_kind: SlackConclusionKind | null;
451
+ created_at: string;
452
+ updated_at: string;
764
453
  };
765
454
 
766
- /** Safe-parse the `avatar` JSON column. Malformed/invalid content (e.g. a
767
- * hand-edited row, or a future downgrade) falls back to `null` so rendering
768
- * always has a deterministic path — never throws. */
769
- function parseAgentAvatar(raw: string | null): AgentAvatar | null {
770
- if (!raw) return null;
771
- try {
772
- const parsed = AgentAvatarSchema.safeParse(JSON.parse(raw));
773
- return parsed.success ? parsed.data : null;
774
- } catch {
775
- return null;
776
- }
777
- }
778
-
779
- /**
780
- * Map an agent row to the `Agent` shape. When `slim` is true the six identity
781
- * markdown blobs (`claudeMd`/`soulMd`/`identityMd`/`toolsMd`/`heartbeatMd`/
782
- * `setupScript`) are omitted — they bloat list responses by ~16 KB/agent and
783
- * are never needed at the swarm-overview level. Fetch them via
784
- * `GET /api/agents/{id}` when required.
785
- */
786
- function rowToAgent(row: AgentRow, slim = false): Agent {
787
- const base: Agent = {
788
- id: row.id,
789
- name: row.name,
790
- isLead: row.isLead === 1,
791
- status: row.status,
792
- description: row.description ?? undefined,
793
- role: row.role ?? undefined,
794
- capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
795
- maxTasks: row.maxTasks ?? 1,
796
- emptyPollCount: row.emptyPollCount ?? 0,
797
- lastActivityAt: row.lastActivityAt ?? undefined,
798
- provider: (row.provider as ProviderName | null) ?? undefined,
799
- harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
800
- createdAt: row.createdAt,
801
- lastUpdatedAt: row.lastUpdatedAt,
802
- credentialMissing: row.credentialMissing
803
- ? (JSON.parse(row.credentialMissing) as string[])
804
- : null,
805
- credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
806
- avatar: parseAgentAvatar(row.avatar),
807
- };
808
- if (slim) return base;
455
+ function rowToSlackMessage(row: SlackMessageRow): SlackMessageRecord {
809
456
  return {
810
- ...base,
811
- claudeMd: row.claudeMd ?? undefined,
812
- soulMd: row.soulMd ?? undefined,
813
- identityMd: row.identityMd ?? undefined,
814
- setupScript: row.setupScript ?? undefined,
815
- toolsMd: row.toolsMd ?? undefined,
816
- heartbeatMd: row.heartbeatMd ?? undefined,
457
+ id: row.id,
458
+ contextKey: row.context_key,
459
+ channelId: row.channel_id,
460
+ threadTs: row.thread_ts,
461
+ ts: row.ts,
462
+ kind: row.kind,
463
+ taskId: row.task_id ?? undefined,
464
+ permalink: row.permalink ?? undefined,
465
+ finalizedAt: row.finalized_at ?? undefined,
466
+ streamChunksAppended: row.stream_chunks_appended,
467
+ conclusionKind: row.conclusion_kind ?? undefined,
468
+ createdAt: row.created_at,
469
+ updatedAt: row.updated_at,
817
470
  };
818
471
  }
819
472
 
820
- /**
821
- * Phase 3 of the worker credential safe-loop plan.
822
- *
823
- * `ready=true` clears the waiting state — the agent transitions to `idle`
824
- * and the dispatcher will start handing it tasks again.
825
- *
826
- * `ready=false` parks the agent on `waiting_for_credentials` with the env-var
827
- * names it's blocked on. The capacity dispatch query already filters
828
- * `status === 'idle'` so the new value is implicitly excluded with no other
829
- * code change.
830
- */
831
- export async function updateAgentCredentialState(
832
- agentId: string,
833
- ready: boolean,
834
- missing: string[] | null,
835
- ): Promise<Agent | null> {
836
- const prev = await getAgentById(agentId);
837
- const status: AgentStatus = ready ? "idle" : "waiting_for_credentials";
838
- const missingJson = ready ? null : missing && missing.length > 0 ? JSON.stringify(missing) : null;
839
- const row = await getDbClient().get<AgentRow>(
840
- "UPDATE agents SET status = ?, credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
841
- [status, missingJson, agentId],
842
- );
843
- // Only clear the accumulated empty-poll count on a genuine recovery
844
- // (waiting_for_credentials -> ready), so routine post-task `ready:true`
845
- // reports don't clobber a legitimately accumulated count and defeat the
846
- // MAX_EMPTY_POLLS polling gate.
847
- if (ready && prev?.status === "waiting_for_credentials") await resetEmptyPollCount(agentId);
848
- return row ? rowToAgent(row) : null;
849
- }
850
-
851
- /**
852
- * Record which env vars a worker is missing without touching status — the
853
- * logical status is derived from runtime readiness in multi-runtime mode.
854
- */
855
- export async function updateAgentCredentialMissing(
856
- agentId: string,
857
- missing: string[] | null,
858
- ): Promise<void> {
859
- const json = missing && missing.length > 0 ? JSON.stringify(missing) : null;
860
- await getDbClient().run(
861
- "UPDATE agents SET credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
862
- [json, agentId],
863
- );
864
- }
865
-
866
- export async function createAgent(
867
- agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
868
- ): Promise<Agent> {
869
- const id = agent.id ?? crypto.randomUUID();
870
- const maxTasks = agent.maxTasks ?? 1;
871
- const row = await getDbClient().get<AgentRow>(
872
- "INSERT INTO agents (id, name, isLead, status, maxTasks, provider, harness_provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
473
+ export async function recordSlackMessage(input: {
474
+ contextKey: string;
475
+ channelId: string;
476
+ threadTs: string;
477
+ ts: string;
478
+ kind: SlackMessageKind;
479
+ taskId?: string;
480
+ permalink?: string;
481
+ finalized?: boolean;
482
+ streamChunksAppended?: number;
483
+ conclusionKind?: SlackConclusionKind;
484
+ actorId?: string;
485
+ }): Promise<SlackMessageRecord> {
486
+ const id = crypto.randomUUID();
487
+ const now = new Date().toISOString();
488
+ const row = await getDbClient().get<SlackMessageRow>(
489
+ `INSERT INTO slack_messages (
490
+ id, context_key, channel_id, thread_ts, ts, kind, task_id, permalink,
491
+ finalized_at, stream_chunks_appended, conclusion_kind, created_at, updated_at, created_by, updated_by
492
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
493
+ ON CONFLICT(channel_id, ts) DO UPDATE SET
494
+ context_key = excluded.context_key,
495
+ kind = excluded.kind,
496
+ task_id = COALESCE(excluded.task_id, slack_messages.task_id),
497
+ permalink = COALESCE(excluded.permalink, slack_messages.permalink),
498
+ finalized_at = COALESCE(excluded.finalized_at, slack_messages.finalized_at),
499
+ stream_chunks_appended = MAX(
500
+ excluded.stream_chunks_appended,
501
+ slack_messages.stream_chunks_appended
502
+ ),
503
+ conclusion_kind = COALESCE(excluded.conclusion_kind, slack_messages.conclusion_kind),
504
+ updated_at = excluded.updated_at,
505
+ updated_by = excluded.updated_by
506
+ RETURNING *`,
873
507
  [
874
508
  id,
875
- agent.name,
876
- agent.isLead ? 1 : 0,
877
- agent.status,
878
- maxTasks,
879
- agent.provider ?? null,
880
- agent.harnessProvider ?? null,
509
+ input.contextKey,
510
+ input.channelId,
511
+ input.threadTs,
512
+ input.ts,
513
+ input.kind,
514
+ input.taskId ?? null,
515
+ input.permalink ?? null,
516
+ input.finalized ? now : null,
517
+ input.streamChunksAppended ?? 0,
518
+ input.conclusionKind ?? null,
519
+ now,
520
+ now,
521
+ input.actorId ?? null,
522
+ input.actorId ?? null,
881
523
  ],
882
524
  );
883
- if (!row) throw new Error("Failed to create agent");
884
- try {
885
- await installSystemDefaultSkillsForAgent(id);
886
- } catch (err) {
887
- console.warn(
888
- "[db] Failed to install system-default skills for new agent:",
889
- (err as Error).message,
890
- );
891
- }
892
- try {
893
- await createLogEntry({ eventType: "agent_joined", agentId: id, newValue: agent.status });
894
- } catch {}
895
- return rowToAgent(row);
896
- }
897
-
898
- export async function getAgentById(id: string): Promise<Agent | null> {
899
- const row = await getDbClient().get<AgentRow>("SELECT * FROM agents WHERE id = ?", [id]);
900
- return row ? rowToAgent(row) : null;
901
- }
902
-
903
- export async function getAllAgents(opts?: { slim?: boolean }): Promise<Agent[]> {
904
- const rows = await getDbClient().query<AgentRow>("SELECT * FROM agents ORDER BY name");
905
- return rows.map((row) => rowToAgent(row, opts?.slim ?? false));
906
- }
907
-
908
- export async function getLeadAgent(): Promise<Agent | null> {
909
- const leads = (await getAllAgents()).filter((a) => a.isLead);
910
- // Prefer a usable (non-offline) lead so callers route to one that can actually
911
- // poll — e.g. an old offline lead must not shadow a live replacement. Falls
912
- // back to any lead (incl. offline) so existing "is there a lead at all?"
913
- // semantics are preserved; callers that require a live lead must check
914
- // `status` themselves (see escalateUnreclaimedResumes).
915
- return leads.find((a) => a.status !== "offline") ?? leads[0] ?? null;
525
+ if (!row) throw new Error("Failed to record Slack message");
526
+ return rowToSlackMessage(row);
916
527
  }
917
528
 
918
- export async function updateAgentStatus(id: string, status: AgentStatus): Promise<Agent | null> {
919
- const oldAgent = await getAgentById(id);
920
- const row = await getDbClient().get<AgentRow>(
921
- "UPDATE agents SET status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
922
- [status, id],
923
- );
924
- if (row && oldAgent) {
925
- try {
926
- await createLogEntry({
927
- eventType: "agent_status_change",
928
- agentId: id,
929
- oldValue: oldAgent.status,
930
- newValue: status,
931
- });
932
- } catch {}
529
+ export async function reserveSlackMessage(input: {
530
+ contextKey: string;
531
+ channelId: string;
532
+ threadTs: string;
533
+ kind: "tree" | "outcome";
534
+ taskId?: string;
535
+ actorId?: string;
536
+ }): Promise<{ record: SlackMessageRecord; created: boolean }> {
537
+ if (input.kind === "outcome" && !input.taskId) {
538
+ throw new Error("Outcome Slack message reservations require a task ID");
933
539
  }
934
- return row ? rowToAgent(row) : null;
935
- }
936
540
 
937
- export async function updateAgentMaxTasks(id: string, maxTasks: number): Promise<Agent | null> {
938
- const row = await getDbClient().get<AgentRow>(
939
- `UPDATE agents SET maxTasks = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
940
- WHERE id = ? RETURNING *`,
941
- [maxTasks, id],
541
+ const id = crypto.randomUUID();
542
+ const now = new Date().toISOString();
543
+ const pendingTs = `${PENDING_SLACK_MESSAGE_TS_PREFIX}${id}`;
544
+ const inserted = await getDbClient().get<SlackMessageRow>(
545
+ `INSERT INTO slack_messages (
546
+ id, context_key, channel_id, thread_ts, ts, kind, task_id,
547
+ stream_chunks_appended, created_at, updated_at, created_by, updated_by
548
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
549
+ ON CONFLICT DO NOTHING
550
+ RETURNING *`,
551
+ [
552
+ id,
553
+ input.contextKey,
554
+ input.channelId,
555
+ input.threadTs,
556
+ pendingTs,
557
+ input.kind,
558
+ input.taskId ?? null,
559
+ now,
560
+ now,
561
+ input.actorId ?? null,
562
+ input.actorId ?? null,
563
+ ],
942
564
  );
943
- return row ? rowToAgent(row) : null;
944
- }
565
+ if (inserted) return { record: rowToSlackMessage(inserted), created: true };
945
566
 
946
- export async function updateAgentProvider(
947
- id: string,
948
- provider: ProviderName,
949
- ): Promise<Agent | null> {
950
- const row = await getDbClient().get<AgentRow>(
951
- `UPDATE agents SET provider = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
952
- WHERE id = ? RETURNING *`,
953
- [provider, id],
954
- );
955
- return row ? rowToAgent(row) : null;
567
+ const existing =
568
+ input.kind === "tree"
569
+ ? await getSlackTreeMessageByThread(input.channelId, input.threadTs)
570
+ : await getSlackOutcomeMessage(input.taskId!);
571
+ if (!existing) throw new Error("Failed to reserve Slack message");
572
+ return { record: existing, created: false };
956
573
  }
957
574
 
958
- /**
959
- * Phase 1.5 (cloud-personalization): set the per-agent `harness_provider`
960
- * column. Pass `null` to clear. Validation against the canonical provider
961
- * list happens at the API layer via `ProviderNameSchema`.
962
- *
963
- * Returns the updated row, or null if the agent does not exist.
964
- */
965
- export async function setAgentHarnessProvider(
575
+ export async function bindSlackMessageTimestamp(
966
576
  id: string,
967
- provider: ProviderName | null,
968
- ): Promise<Agent | null> {
969
- const row = await getDbClient().get<AgentRow>(
970
- `UPDATE agents SET harness_provider = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
971
- WHERE id = ? RETURNING *`,
972
- [provider, id],
577
+ ts: string,
578
+ options: { streamChunksAppended?: number; renderedThrough?: string } = {},
579
+ ): Promise<SlackMessageRecord | null> {
580
+ const now = new Date().toISOString();
581
+ const row = await getDbClient().get<SlackMessageRow>(
582
+ `UPDATE slack_messages SET
583
+ ts = ?,
584
+ stream_chunks_appended = MAX(stream_chunks_appended, ?),
585
+ updated_at = COALESCE(?, ?)
586
+ WHERE id = ? AND ts LIKE 'pending:%'
587
+ RETURNING *`,
588
+ [ts, options.streamChunksAppended ?? 0, options.renderedThrough ?? null, now, id],
973
589
  );
974
- return row ? rowToAgent(row) : null;
590
+ return row ? rowToSlackMessage(row) : null;
975
591
  }
976
592
 
977
- /**
978
- * Migration 055 — write the worker-self-reported credential snapshot.
979
- * Pass `null` to clear (e.g. on agent re-registration). Validation against
980
- * the JSON shape happens at the API layer via `AgentCredStatusSchema`.
981
- *
982
- * Worker reports this alongside the existing `updateAgentCredentialState`
983
- * call; we keep the writes in two functions so the dispatch pattern stays
984
- * one-row-one-fact, and the PATCH handler can choose which to call based
985
- * on which fields the request body carried.
986
- */
987
- export async function updateAgentCredStatus(
593
+ export async function updateSlackMessageRecord(
988
594
  id: string,
989
- credStatus: AgentCredStatus | null,
990
- ): Promise<Agent | null> {
991
- const json = credStatus ? JSON.stringify(credStatus) : null;
992
- const row = await getDbClient().get<AgentRow>(
993
- `UPDATE agents SET cred_status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
994
- WHERE id = ? RETURNING *`,
995
- [json, id],
996
- );
997
- return row ? rowToAgent(row) : null;
998
- }
999
-
1000
- /**
1001
- * Migration 055 — read all agents whose `harness_provider` matches a given
1002
- * provider, with their reported `cred_status`. Used by the credential-status
1003
- * API endpoint to roll up "is this provider working across the fleet?".
1004
- *
1005
- * Agents with NULL `cred_status` (never reported, or CRED_CHECK_DISABLE=1)
1006
- * are still returned — the caller surfaces them as "unreported".
1007
- */
1008
- export async function listAgentsWithCredStatusByProvider(provider: string): Promise<Agent[]> {
1009
- const rows = await getDbClient().query<AgentRow>(
1010
- `SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`,
1011
- [provider],
1012
- );
1013
- return rows.map((row) => rowToAgent(row));
1014
- }
1015
-
1016
- /**
1017
- * Phase 1.5 (cloud-personalization): aggregate count of registered agents
1018
- * by `harness_provider`. NULL rows (agents that registered before the
1019
- * migration or never pushed a value) are excluded — they show up in the
1020
- * total agent count but not here.
1021
- *
1022
- * Used by future fleet displays. Not consumed in this phase.
1023
- */
1024
- export async function getAgentHarnessProviders(): Promise<
1025
- Array<{ provider: string; count: number }>
1026
- > {
1027
- const rows = await getDbClient().query<{ provider: string; count: number }>(
1028
- `SELECT harness_provider AS provider, COUNT(*) AS count
1029
- FROM agents
1030
- WHERE harness_provider IS NOT NULL
1031
- GROUP BY harness_provider
1032
- ORDER BY harness_provider`,
1033
- );
1034
- return rows.map((r) => ({ provider: r.provider, count: r.count }));
1035
- }
1036
-
1037
- export async function updateAgentActivity(id: string): Promise<void> {
1038
- await getDbClient().run(
1039
- `UPDATE agents SET lastActivityAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
1040
- [id],
1041
- );
1042
- }
1043
-
1044
- // ============================================================================
1045
- // Agent Poll Tracking Functions
1046
- // ============================================================================
1047
-
1048
- /** Maximum consecutive empty polls before agent should stop polling */
1049
- export const MAX_EMPTY_POLLS = 2;
1050
-
1051
- /**
1052
- * Increment the empty poll count for an agent.
1053
- * Returns the new count after incrementing.
1054
- */
1055
- export async function incrementEmptyPollCount(agentId: string): Promise<number> {
1056
- const row = await getDbClient().get<{ emptyPollCount: number }>(
1057
- `UPDATE agents
1058
- SET emptyPollCount = emptyPollCount + 1,
1059
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1060
- WHERE id = ?
1061
- RETURNING emptyPollCount`,
1062
- [agentId],
1063
- );
1064
- return row?.emptyPollCount ?? 0;
1065
- }
1066
-
1067
- /**
1068
- * Reset the empty poll count for an agent to zero.
1069
- * Called when a task is assigned or agent re-registers.
1070
- */
1071
- export async function resetEmptyPollCount(agentId: string): Promise<void> {
1072
- await getDbClient().run(
1073
- `UPDATE agents
1074
- SET emptyPollCount = 0,
1075
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1076
- WHERE id = ?`,
1077
- [agentId],
1078
- );
1079
- }
1080
-
1081
- /**
1082
- * Check if an agent has exceeded the maximum empty poll count.
1083
- */
1084
- export async function shouldBlockPolling(agentId: string): Promise<boolean> {
1085
- const agent = await getAgentById(agentId);
1086
- return (agent?.emptyPollCount ?? 0) >= MAX_EMPTY_POLLS;
1087
- }
1088
-
1089
- export async function deleteAgent(id: string): Promise<boolean> {
1090
- const agent = await getAgentById(id);
1091
- if (agent) {
1092
- try {
1093
- await createLogEntry({ eventType: "agent_left", agentId: id, oldValue: agent.status });
1094
- } catch {}
1095
- }
1096
- const result = await getDbClient().run("DELETE FROM agents WHERE id = ?", [id]);
1097
- return result.changes > 0;
1098
- }
1099
-
1100
- // ============================================================================
1101
- // Agent Capacity Functions
1102
- // ============================================================================
1103
-
1104
- /**
1105
- * Get the count of active (in_progress) tasks for an agent.
1106
- * Used to determine current capacity usage.
1107
- */
1108
- /**
1109
- * Tasks occupying one of the agent's concurrency slots.
1110
- *
1111
- * A claimed offer counts too — omitting it let a second concurrent poll take
1112
- * another task past the limit. It is counted only through `offeredTo`, the
1113
- * agent actually reviewing it: `agentId` on an offer may still be the lead
1114
- * that created it, which would otherwise consume the lead's own capacity.
1115
- */
1116
- export async function getActiveTaskCount(agentId: string): Promise<number> {
1117
- const result = await getDbClient().get<{ count: number }>(
1118
- `SELECT COUNT(*) as count FROM agent_tasks
1119
- WHERE (agentId = ? AND status = 'in_progress')
1120
- OR (offeredTo = ? AND status = 'reviewing')`,
1121
- [agentId, agentId],
1122
- );
1123
- return result?.count ?? 0;
1124
- }
1125
-
1126
- /**
1127
- * Check if an agent has capacity to accept more tasks.
1128
- */
1129
- export async function hasCapacity(agentId: string): Promise<boolean> {
1130
- const agent = await getAgentById(agentId);
1131
- if (!agent) return false;
1132
- const activeCount = await getActiveTaskCount(agentId);
1133
- return activeCount < (agent.maxTasks ?? 1);
1134
- }
1135
-
1136
- /**
1137
- * Get remaining capacity (available task slots) for an agent.
1138
- */
1139
- export async function getRemainingCapacity(agentId: string): Promise<number> {
1140
- const agent = await getAgentById(agentId);
1141
- if (!agent) return 0;
1142
- const activeCount = await getActiveTaskCount(agentId);
1143
- return Math.max(0, (agent.maxTasks ?? 1) - activeCount);
1144
- }
1145
-
1146
- /**
1147
- * Update agent status based on current capacity.
1148
- * Agent is 'busy' when any tasks are in progress, 'idle' when none.
1149
- * Does not modify 'offline' status.
1150
- */
1151
- export async function updateAgentStatusFromCapacity(agentId: string): Promise<void> {
1152
- const agent = await getAgentById(agentId);
1153
- if (!agent || agent.status === "offline") return;
1154
- // `waiting_for_credentials` is owned by the worker's credential-wait
1155
- // tick — task-completion shouldn't accidentally promote a blocked agent
1156
- // back to idle.
1157
- if (agent.status === "waiting_for_credentials") return;
1158
-
1159
- const activeCount = await getActiveTaskCount(agentId);
1160
- const newStatus = activeCount > 0 ? "busy" : "idle";
1161
-
1162
- if (agent.status !== newStatus) {
1163
- await updateAgentStatus(agentId, newStatus);
1164
- }
1165
- }
1166
-
1167
- // ============================================================================
1168
- // Routing Affinity (interrupted/pooled task role & capability gating)
1169
- // ============================================================================
1170
-
1171
- /**
1172
- * Kill-switch for the pool eligibility gate (`isAgentEligibleForTask` and its
1173
- * callers: `claimTask`, `assignUnassignedTaskPending`,
1174
- * `getUnassignedTaskIdsForAgent`). ON by default. Set to `0` to restore
1175
- * pre-affinity behavior verbatim — mirrors the `HEARTBEAT_PIN_*_RESUME`
1176
- * rollback convention. A function (read dynamically), not a module-load-time
1177
- * const, so it can be toggled mid-test (see `isGracefulResumePinEnabled` in
1178
- * src/tasks/worker-follow-up.ts for the same pattern).
1179
- */
1180
- export function isPoolAffinityEnforcementEnabled(): boolean {
1181
- return isEnvFlagEnabled("POOL_AFFINITY_ENFORCEMENT", true);
1182
- }
1183
-
1184
- /**
1185
- * Snapshot an agent's role/harness/capabilities into a `RoutingAffinity`
1186
- * blob, for stamping onto a continuation task (resume, retry) at the moment
1187
- * of interruption. Returns `null` when the agent row is already gone —
1188
- * callers fall back to the parent's own (inherited) `routingAffinity` via
1189
- * `createTaskExtended`'s parentTaskId inheritance block.
1190
- */
1191
- export async function buildRoutingAffinityFromAgent(
1192
- agentId: string,
1193
- ): Promise<RoutingAffinity | null> {
1194
- const agent = await getAgentById(agentId);
1195
- if (!agent) return null;
1196
- return {
1197
- sourceAgentId: agent.id,
1198
- role: agent.role,
1199
- harnessProvider: agent.harnessProvider ?? agent.provider ?? undefined,
1200
- capabilities: agent.capabilities ?? [],
1201
- };
1202
- }
1203
-
1204
- /**
1205
- * The single eligibility gate every pool consumer (poll auto-claim,
1206
- * `task-action claim`, `autoAssignPoolTasks`) MUST use before handing a task
1207
- * to an agent. Exact-match on the snapshotted role string (no keyword
1208
- * taxonomy in v1); `harnessProvider` is informational only and never
1209
- * enforced (native session resume is deprecated). Missing role data on
1210
- * either side is treated as INELIGIBLE — never fail-open to "anyone" — so a
1211
- * capability-only requirement (no `role` set) can only ever be claimed by
1212
- * its `sourceAgentId`, and otherwise queues until the starvation escalation
1213
- * hands it to the Lead. Lead-only work is different: any Lead may claim it
1214
- * (subject to explicitly required capabilities), because its source/role is
1215
- * only recovery provenance and must not turn a worker's old role into a
1216
- * constraint on the Lead pool.
1217
- */
1218
- export function isAgentEligibleForTask(
1219
- agent: Pick<Agent, "id" | "isLead" | "role" | "capabilities">,
1220
- task: Pick<AgentTask, "routingAffinity" | "routingAffinityInvalid">,
1221
- ): boolean {
1222
- const affinity = task.routingAffinity;
1223
- // A malformed persisted blob is a security boundary failure, not an
1224
- // untagged task. Quarantine it from every assignment/claim path.
1225
- if (task.routingAffinityInvalid) return false;
1226
- if (!affinity) return true; // Untagged task — unchanged behavior.
1227
-
1228
- const requiredCapabilities = affinity.capabilities ?? [];
1229
- const hasRequiredCapabilities = () => {
1230
- const agentCapabilities = new Set(agent.capabilities ?? []);
1231
- return requiredCapabilities.every((cap) => agentCapabilities.has(cap));
1232
- };
1233
- // Lead-only is an authorization boundary, never a best-effort pool hint or
1234
- // a source-agent exception. Its explicit capability requirements remain
1235
- // enforced even if the role/capability affinity kill-switch is enabled.
1236
- // Do not require an unrelated worker role/source to match a Lead-only task.
1237
- if (affinity.leadOnly) return agent.isLead && hasRequiredCapabilities();
1238
- if (!isPoolAffinityEnforcementEnabled()) return true;
1239
-
1240
- if (affinity.sourceAgentId && affinity.sourceAgentId === agent.id) return true; // Own work.
1241
-
1242
- if (!agent.role || !affinity.role) return false; // Missing role data — no fail-open.
1243
- if (agent.role !== affinity.role) return false;
1244
-
1245
- return hasRequiredCapabilities();
1246
- }
1247
-
1248
- // ============================================================================
1249
- // AgentTask Queries
1250
- // ============================================================================
1251
-
1252
- type AgentTaskRow = {
1253
- id: string;
1254
- key: string;
1255
- agentId: string | null;
1256
- creatorAgentId: string | null;
1257
- task: string;
1258
- title: string | null;
1259
- status: AgentTaskStatus;
1260
- source: AgentTaskSource;
1261
- taskType: string | null;
1262
- tags: string | null;
1263
- priority: number;
1264
- dependsOn: string | null;
1265
- offeredTo: string | null;
1266
- offeredAt: string | null;
1267
- acceptedAt: string | null;
1268
- rejectionReason: string | null;
1269
- slackChannelId: string | null;
1270
- slackThreadTs: string | null;
1271
- slackTriggerMessageTs: string | null;
1272
- slackUserId: string | null;
1273
- slackReplySent: number;
1274
- slackProgressMessageTs: string | null;
1275
- slackTreeRootMessageTs: string | null;
1276
- vcsProvider: string | null;
1277
- vcsRepo: string | null;
1278
- vcsEventType: string | null;
1279
- vcsNumber: number | null;
1280
- vcsCommentId: number | null;
1281
- vcsAuthor: string | null;
1282
- vcsUrl: string | null;
1283
- vcsInstallationId: number | null;
1284
- vcsNodeId: string | null;
1285
- agentmailInboxId: string | null;
1286
- agentmailMessageId: string | null;
1287
- agentmailThreadId: string | null;
1288
- mentionMessageId: string | null;
1289
- mentionChannelId: string | null;
1290
- dir: string | null;
1291
- parentTaskId: string | null;
1292
- claudeSessionId: string | null;
1293
- model: string | null;
1294
- modelTier: string | null;
1295
- effort: string | null;
1296
- scheduleId: string | null;
1297
- workflowRunId: string | null;
1298
- workflowRunStepId: string | null;
1299
- outputSchema: string | null;
1300
- followUpConfig: string | null;
1301
- contextKey: string | null;
1302
- createdAt: string;
1303
- lastUpdatedAt: string;
1304
- finishedAt: string | null;
1305
- notifiedAt: string | null;
1306
- failureReason: string | null;
1307
- output: string | null;
1308
- progress: string | null;
1309
- compactionCount: number | null;
1310
- peakContextPercent: number | null;
1311
- peakContextTokens: number | null;
1312
- contextWindowSize: number | null;
1313
- was_paused: number;
1314
- credentialKeySuffix: string | null;
1315
- credentialKeyType: string | null;
1316
- requestedByUserId: string | null;
1317
- requestedByUserIdInherited: number;
1318
- swarmVersion: string | null;
1319
- provider: string | null;
1320
- providerMeta: string | null;
1321
- harnessVariant: string | null;
1322
- harnessVariantMeta: string | null;
1323
- totalCostUsd?: number | null;
1324
- routingAffinity: string | null;
1325
- };
1326
-
1327
- function rowToAgentTask(row: AgentTaskRow): AgentTask {
1328
- let followUpConfig: FollowUpConfig | undefined;
1329
- if (row.followUpConfig) {
1330
- try {
1331
- const parsed = FollowUpConfigSchema.safeParse(JSON.parse(row.followUpConfig));
1332
- if (parsed.success) {
1333
- followUpConfig = parsed.data;
1334
- } else {
1335
- console.warn(
1336
- `[db] Ignoring invalid agent_tasks.followUpConfig for task ${row.id}:`,
1337
- parsed.error.message,
1338
- );
1339
- }
1340
- } catch (error) {
1341
- console.warn(
1342
- `[db] Ignoring malformed agent_tasks.followUpConfig for task ${row.id}:`,
1343
- error instanceof Error ? error.message : String(error),
1344
- );
1345
- followUpConfig = undefined;
1346
- }
1347
- }
1348
-
1349
- let routingAffinity: RoutingAffinity | undefined;
1350
- let routingAffinityInvalid = false;
1351
- if (row.routingAffinity) {
1352
- try {
1353
- const parsed = RoutingAffinitySchema.safeParse(JSON.parse(row.routingAffinity));
1354
- if (parsed.success) {
1355
- routingAffinity = parsed.data;
1356
- } else {
1357
- routingAffinityInvalid = true;
1358
- console.warn(
1359
- `[db] Quarantining invalid agent_tasks.routingAffinity for task ${row.id}:`,
1360
- parsed.error.message,
1361
- );
1362
- }
1363
- } catch (error) {
1364
- routingAffinityInvalid = true;
1365
- console.warn(
1366
- `[db] Quarantining malformed agent_tasks.routingAffinity for task ${row.id}:`,
1367
- error instanceof Error ? error.message : String(error),
1368
- );
1369
- }
1370
- }
1371
-
1372
- return {
1373
- id: row.id,
1374
- key: row.key,
1375
- agentId: row.agentId,
1376
- creatorAgentId: row.creatorAgentId ?? undefined,
1377
- task: row.task,
1378
- title: row.title ?? undefined,
1379
- status: row.status,
1380
- source: row.source,
1381
- taskType: row.taskType ?? undefined,
1382
- tags: row.tags ? JSON.parse(row.tags) : [],
1383
- priority: row.priority ?? 50,
1384
- dependsOn: row.dependsOn ? JSON.parse(row.dependsOn) : [],
1385
- offeredTo: row.offeredTo ?? undefined,
1386
- offeredAt: row.offeredAt ?? undefined,
1387
- acceptedAt: row.acceptedAt ?? undefined,
1388
- rejectionReason: row.rejectionReason ?? undefined,
1389
- slackChannelId: row.slackChannelId ?? undefined,
1390
- slackThreadTs: row.slackThreadTs ?? undefined,
1391
- slackTriggerMessageTs: row.slackTriggerMessageTs ?? undefined,
1392
- slackUserId: row.slackUserId ?? undefined,
1393
- slackReplySent: !!row.slackReplySent,
1394
- slackProgressMessageTs: row.slackProgressMessageTs ?? undefined,
1395
- slackTreeRootMessageTs: row.slackTreeRootMessageTs ?? undefined,
1396
- vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
1397
- vcsRepo: row.vcsRepo ?? undefined,
1398
- vcsEventType: row.vcsEventType ?? undefined,
1399
- vcsNumber: row.vcsNumber ?? undefined,
1400
- vcsCommentId: row.vcsCommentId ?? undefined,
1401
- vcsAuthor: row.vcsAuthor ?? undefined,
1402
- vcsUrl: row.vcsUrl ?? undefined,
1403
- vcsInstallationId: row.vcsInstallationId ?? undefined,
1404
- vcsNodeId: row.vcsNodeId ?? undefined,
1405
- agentmailInboxId: row.agentmailInboxId ?? undefined,
1406
- agentmailMessageId: row.agentmailMessageId ?? undefined,
1407
- agentmailThreadId: row.agentmailThreadId ?? undefined,
1408
- mentionMessageId: row.mentionMessageId ?? undefined,
1409
- mentionChannelId: row.mentionChannelId ?? undefined,
1410
- dir: row.dir ?? undefined,
1411
- parentTaskId: row.parentTaskId ?? undefined,
1412
- claudeSessionId: row.claudeSessionId ?? undefined,
1413
- model: row.model ?? undefined,
1414
- modelTier: parseModelTier(row.modelTier) ?? undefined,
1415
- effort: ReasoningEffortSchema.safeParse(row.effort).success
1416
- ? (row.effort as ReasoningEffort)
1417
- : undefined,
1418
- scheduleId: row.scheduleId ?? undefined,
1419
- workflowRunId: row.workflowRunId ?? undefined,
1420
- workflowRunStepId: row.workflowRunStepId ?? undefined,
1421
- outputSchema: row.outputSchema ? JSON.parse(row.outputSchema) : undefined,
1422
- followUpConfig,
1423
- contextKey: row.contextKey ?? undefined,
1424
- compactionCount: row.compactionCount ?? undefined,
1425
- peakContextPercent: row.peakContextPercent ?? undefined,
1426
- peakContextTokens: row.peakContextTokens ?? undefined,
1427
- contextWindowSize: row.contextWindowSize ?? undefined,
1428
- createdAt: row.createdAt,
1429
- lastUpdatedAt: row.lastUpdatedAt,
1430
- finishedAt: row.finishedAt ?? undefined,
1431
- notifiedAt: row.notifiedAt ?? undefined,
1432
- failureReason: row.failureReason ?? undefined,
1433
- output: row.output ?? undefined,
1434
- progress: row.progress ?? undefined,
1435
- wasPaused: !!row.was_paused,
1436
- credentialKeySuffix: row.credentialKeySuffix ?? undefined,
1437
- credentialKeyType: row.credentialKeyType ?? undefined,
1438
- requestedByUserId: row.requestedByUserId ?? undefined,
1439
- swarmVersion: row.swarmVersion ?? undefined,
1440
- provider: (row.provider as ProviderName | null) ?? undefined,
1441
- providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
1442
- harnessVariant: row.harnessVariant ?? undefined,
1443
- harnessVariantMeta: row.harnessVariantMeta ? JSON.parse(row.harnessVariantMeta) : undefined,
1444
- totalCostUsd: row.totalCostUsd ?? undefined,
1445
- routingAffinity,
1446
- routingAffinityInvalid: routingAffinityInvalid || undefined,
1447
- };
1448
- }
1449
-
1450
- /**
1451
- * Slim list-row mapper — truncates the `task` text to a bounded preview and
1452
- * drops completion/integration/context blobs (`output`, `failureReason`,
1453
- * `providerMeta`, all `vcs*`/`slack*`/`agentmail*`/`credential*`/`mention*` and
1454
- * context-window fields). The preview is long enough for pool-triage; the full
1455
- * brief is on `get-task-details` / `GET /api/tasks/{id}`.
1456
- */
1457
- function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
1458
- const t = rowToAgentTask(row);
1459
- return {
1460
- id: t.id,
1461
- key: t.key,
1462
- agentId: t.agentId,
1463
- creatorAgentId: t.creatorAgentId,
1464
- task: previewText(t.task, TASK_PREVIEW_LENGTH),
1465
- title: t.title,
1466
- status: t.status,
1467
- source: t.source,
1468
- taskType: t.taskType,
1469
- tags: t.tags,
1470
- priority: t.priority,
1471
- dependsOn: t.dependsOn,
1472
- offeredTo: t.offeredTo,
1473
- acceptedAt: t.acceptedAt,
1474
- parentTaskId: t.parentTaskId,
1475
- scheduleId: t.scheduleId,
1476
- model: t.model,
1477
- modelTier: t.modelTier,
1478
- effort: t.effort,
1479
- provider: t.provider,
1480
- requestedByUserId: t.requestedByUserId,
1481
- progress: t.progress,
1482
- createdAt: t.createdAt,
1483
- lastUpdatedAt: t.lastUpdatedAt,
1484
- finishedAt: t.finishedAt,
1485
- peakContextPercent: t.peakContextPercent,
1486
- totalCostUsd: t.totalCostUsd,
1487
- };
1488
- }
1489
-
1490
- export async function createTask(
1491
- agentId: string,
1492
- task: string,
1493
- options?: {
1494
- source?: AgentTaskSource;
1495
- slackChannelId?: string;
1496
- slackThreadTs?: string;
1497
- slackUserId?: string;
595
+ updates: {
596
+ permalink?: string;
597
+ finalized?: boolean;
598
+ streamChunksAppended?: number;
599
+ conclusionKind?: SlackConclusionKind;
600
+ actorId?: string;
601
+ touchUpdatedAt?: boolean;
1498
602
  },
1499
- ): Promise<AgentTask> {
1500
- const id = crypto.randomUUID();
1501
- const source = options?.source ?? "mcp";
1502
- const row = await getDbClient().get<AgentTaskRow>(
1503
- `INSERT INTO agent_tasks (id, "key", agentId, task, status, source, slackChannelId, slackThreadTs, slackUserId, swarmVersion, createdAt, lastUpdatedAt)
1504
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *`,
1505
- [
1506
- id,
1507
- defaultAssetKey("task", id),
1508
- agentId,
1509
- task,
1510
- "pending",
1511
- source,
1512
- options?.slackChannelId ?? null,
1513
- options?.slackThreadTs ?? null,
1514
- options?.slackUserId ?? null,
1515
- pkg.version,
1516
- ],
1517
- );
1518
- if (!row) throw new Error("Failed to create task");
1519
- try {
1520
- await createLogEntry({
1521
- eventType: "task_created",
1522
- agentId,
1523
- taskId: id,
1524
- newValue: "pending",
1525
- metadata: { source },
1526
- });
1527
- } catch {}
1528
- return rowToAgentTask(row);
1529
- }
1530
-
1531
- /**
1532
- * In-process dedup for `task_dispatch_rejected_affinity` logging in
1533
- * `getPendingTaskForAgent` below — that function runs on every poll tick for
1534
- * every agent with a directly-assigned pending task, so an unresolved skip
1535
- * (e.g. a corrupt/misassigned legacy row) would otherwise write one log row
1536
- * per poll forever. Keyed by taskId; per-process, like `alarmActive` in
1537
- * `queue-stall-alarm.ts` — an API restart re-arms it, which is fine since the
1538
- * point is "don't spam," not "log exactly once ever."
1539
- */
1540
- const AFFINITY_DISPATCH_SKIP_LOG_COOLDOWN_MS = 5 * 60 * 1000;
1541
- const lastAffinityDispatchSkipLoggedAt = new Map<string, number>();
1542
-
1543
- async function logAffinityDispatchSkip(
1544
- agent: Pick<Agent, "id" | "isLead" | "role">,
1545
- task: Pick<AgentTask, "id" | "routingAffinity" | "routingAffinityInvalid">,
1546
- ): Promise<void> {
1547
- const now = Date.now();
1548
- const lastLoggedAt = lastAffinityDispatchSkipLoggedAt.get(task.id);
1549
- if (lastLoggedAt !== undefined && now - lastLoggedAt < AFFINITY_DISPATCH_SKIP_LOG_COOLDOWN_MS) {
1550
- return;
1551
- }
1552
- lastAffinityDispatchSkipLoggedAt.set(task.id, now);
1553
- try {
1554
- await createLogEntry({
1555
- eventType: "task_dispatch_rejected_affinity",
1556
- agentId: agent.id,
1557
- taskId: task.id,
1558
- metadata: {
1559
- agentRole: agent.role ?? null,
1560
- requiredRole: task.routingAffinity?.role ?? null,
1561
- leadOnly: task.routingAffinity?.leadOnly === true,
1562
- agentIsLead: agent.isLead ?? false,
1563
- routingAffinityInvalid: task.routingAffinityInvalid === true,
1564
- },
1565
- });
1566
- } catch {}
1567
- }
1568
-
1569
- export async function getPendingTaskForAgent(agentId: string): Promise<AgentTask | null> {
1570
- // Get all pending tasks for this agent, ordered by priority (desc) then creation time (asc)
1571
- const rows = await getDbClient().query<AgentTaskRow>(
1572
- "SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'pending' ORDER BY priority DESC, createdAt ASC",
1573
- [agentId],
1574
- );
1575
-
1576
- const agent = await getAgentById(agentId);
1577
- if (!agent) return null;
1578
-
1579
- for (const row of rows) {
1580
- const task = rowToAgentTask(row);
1581
- // `task.agentId` (the WHERE clause above) is a direct-assignment decision
1582
- // already made by the task's creator — `createTaskExtended` enforces a
1583
- // caller-declared requirement at creation time (see
1584
- // `routingAffinityIsInheritedProvenance` there). Re-running the FULL
1585
- // role/capability match here re-litigates that decision using metadata
1586
- // that may be pure inherited PROVENANCE (e.g. a Lead-routed
1587
- // worker-completion follow-up that inherits the finishing worker's
1588
- // role/capabilities as lineage, not a requirement anyone declared) —
1589
- // which permanently stalls dispatch to the agent the task is already
1590
- // pinned to (the #1276-regression this fixes; see PR body). Mirror the
1591
- // convention `acceptTask`/`claimOfferedTask` already use for an
1592
- // established offer: only `leadOnly` (a real authorization boundary) and
1593
- // `routingAffinityInvalid` (quarantined corrupt data) still veto a
1594
- // directly-assigned task. Pool-claim paths (`claimTask`,
1595
- // `assignUnassignedTaskPending`) are untouched and keep the full gate —
1596
- // they are deciding "who gets this" from the pool, not redispatching an
1597
- // assignment that was already authorized (or, for provenance, never a
1598
- // requirement) at creation time.
1599
- if (task.routingAffinityInvalid) {
1600
- await logAffinityDispatchSkip(agent, task);
1601
- continue;
1602
- }
1603
- if (task.routingAffinity?.leadOnly && !isAgentEligibleForTask(agent, task)) {
1604
- await logAffinityDispatchSkip(agent, task);
1605
- continue;
1606
- }
1607
- const { ready } = await checkDependencies(task.id);
1608
- if (ready) return task;
1609
- }
1610
-
1611
- return null;
1612
- }
1613
-
1614
- export async function assignUnassignedTaskPending(
1615
- taskId: string,
1616
- agentId: string,
1617
- ): Promise<AgentTask | null> {
1618
- // This guard is always needed for lead-only tasks; the predicate itself
1619
- // handles the optional role/capability kill-switch.
1620
- {
1621
- const task = await getTaskById(taskId);
1622
- const agent = await getAgentById(agentId);
1623
- if (task && (!agent || !isAgentEligibleForTask(agent, task))) {
1624
- try {
1625
- await createLogEntry({
1626
- eventType: "task_claim_rejected_affinity",
1627
- agentId,
1628
- taskId,
1629
- metadata: {
1630
- agentRole: agent?.role ?? null,
1631
- requiredRole: task.routingAffinity?.role ?? null,
1632
- leadOnly: task.routingAffinity?.leadOnly === true,
1633
- agentIsLead: agent?.isLead ?? false,
1634
- },
1635
- });
1636
- } catch {}
1637
- return null;
1638
- }
1639
- }
1640
-
1641
- const now = new Date().toISOString();
1642
- const row = await getDbClient().get<AgentTaskRow>(
1643
- `UPDATE agent_tasks SET agentId = ?, status = 'pending', lastUpdatedAt = ?
1644
- WHERE id = ? AND status = 'unassigned' RETURNING *`,
1645
- [agentId, now, taskId],
1646
- );
1647
-
1648
- if (row) {
1649
- try {
1650
- await createLogEntry({
1651
- eventType: "task_status_change",
1652
- agentId,
1653
- taskId,
1654
- oldValue: "unassigned",
1655
- newValue: "pending",
1656
- metadata: { pendingDispatch: true },
1657
- });
1658
- } catch {}
1659
- }
1660
-
1661
- return row ? rowToAgentTask(row) : null;
1662
- }
1663
-
1664
- export async function startTask(taskId: string): Promise<AgentTask | null> {
1665
- const oldTask = await getTaskById(taskId);
1666
- if (!oldTask) return null;
1667
-
1668
- // Guard: never revive tasks that are already in a terminal state
1669
- if (isTerminalTaskStatus(oldTask.status)) {
1670
- return null;
1671
- }
1672
-
1673
- const row = await getDbClient().get<AgentTaskRow>(
1674
- `UPDATE agent_tasks SET status = 'in_progress', lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1675
- WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
1676
- [taskId],
1677
- );
1678
- if (row && oldTask) {
1679
- try {
1680
- await createLogEntry({
1681
- eventType: "task_status_change",
1682
- taskId,
1683
- agentId: row.agentId ?? undefined,
1684
- oldValue: oldTask.status,
1685
- newValue: "in_progress",
1686
- });
1687
- } catch {}
1688
- }
1689
- const result = row ? rowToAgentTask(row) : null;
1690
- // Fire-and-forget: notify lifecycle subscribers (e.g. GitHub eyes reaction)
1691
- if (result && oldTask.status !== "in_progress") {
1692
- emitTaskStarted(result);
1693
- }
1694
- return result;
1695
- }
1696
-
1697
- export async function getTaskById(id: string): Promise<AgentTask | null> {
1698
- const row = await getDbClient().get<AgentTaskRow>("SELECT * FROM agent_tasks WHERE id = ?", [id]);
1699
- return row ? rowToAgentTask(row) : null;
1700
- }
1701
-
1702
- export async function getSlackRenderV2ActivatedAt(): Promise<string | null> {
1703
- const row = await getDbClient().get<{ activated_at: string }>(
1704
- `SELECT activated_at FROM slack_render_v2_state WHERE id = 1`,
1705
- );
1706
- return row?.activated_at ?? null;
1707
- }
1708
-
1709
- export async function ensureSlackRenderV2Activation(): Promise<string> {
1710
- const activatedAt = new Date().toISOString();
1711
- await getDbClient().run(
1712
- `INSERT INTO slack_render_v2_state (id, activated_at)
1713
- VALUES (1, ?)
1714
- ON CONFLICT(id) DO NOTHING`,
1715
- [activatedAt],
1716
- );
1717
- const persisted = await getSlackRenderV2ActivatedAt();
1718
- if (!persisted) throw new Error("Failed to persist Slack render v2 activation");
1719
- return persisted;
1720
- }
1721
-
1722
- export async function getSlackDelegationActivatedAt(): Promise<string | null> {
1723
- const row = await getDbClient().get<{ delegation_activated_at: string | null }>(
1724
- `SELECT delegation_activated_at FROM slack_render_v2_state WHERE id = 1`,
1725
- );
1726
- return row?.delegation_activated_at ?? null;
1727
- }
1728
-
1729
- export async function ensureSlackDelegationActivation(): Promise<string> {
1730
- const activatedAt = new Date().toISOString();
1731
- await getDbClient().run(
1732
- `INSERT INTO slack_render_v2_state (id, activated_at, delegation_activated_at)
1733
- VALUES (1, ?, ?)
1734
- ON CONFLICT(id) DO UPDATE SET
1735
- delegation_activated_at = COALESCE(slack_render_v2_state.delegation_activated_at, excluded.delegation_activated_at)`,
1736
- [activatedAt, activatedAt],
1737
- );
1738
- const persisted = await getSlackDelegationActivatedAt();
1739
- if (!persisted) throw new Error("Failed to persist Slack render v2 delegation activation");
1740
- return persisted;
1741
- }
1742
-
1743
- export type SlackMessageKind = "tree" | "outcome" | "agent";
1744
- export type SlackConclusionKind = "complete" | "timeout";
1745
-
1746
- export interface SlackMessageRecord {
1747
- id: string;
1748
- contextKey: string;
1749
- channelId: string;
1750
- threadTs: string;
1751
- ts: string;
1752
- kind: SlackMessageKind;
1753
- taskId?: string;
1754
- permalink?: string;
1755
- finalizedAt?: string;
1756
- streamChunksAppended: number;
1757
- conclusionKind?: SlackConclusionKind;
1758
- createdAt: string;
1759
- updatedAt: string;
1760
- }
1761
-
1762
- const PENDING_SLACK_MESSAGE_TS_PREFIX = "pending:";
1763
-
1764
- export function isPendingSlackMessage(record: SlackMessageRecord): boolean {
1765
- return record.ts.startsWith(PENDING_SLACK_MESSAGE_TS_PREFIX);
1766
- }
1767
-
1768
- type SlackMessageRow = {
1769
- id: string;
1770
- context_key: string;
1771
- channel_id: string;
1772
- thread_ts: string;
1773
- ts: string;
1774
- kind: SlackMessageKind;
1775
- task_id: string | null;
1776
- permalink: string | null;
1777
- finalized_at: string | null;
1778
- stream_chunks_appended: number;
1779
- conclusion_kind: SlackConclusionKind | null;
1780
- created_at: string;
1781
- updated_at: string;
1782
- };
1783
-
1784
- function rowToSlackMessage(row: SlackMessageRow): SlackMessageRecord {
1785
- return {
1786
- id: row.id,
1787
- contextKey: row.context_key,
1788
- channelId: row.channel_id,
1789
- threadTs: row.thread_ts,
1790
- ts: row.ts,
1791
- kind: row.kind,
1792
- taskId: row.task_id ?? undefined,
1793
- permalink: row.permalink ?? undefined,
1794
- finalizedAt: row.finalized_at ?? undefined,
1795
- streamChunksAppended: row.stream_chunks_appended,
1796
- conclusionKind: row.conclusion_kind ?? undefined,
1797
- createdAt: row.created_at,
1798
- updatedAt: row.updated_at,
1799
- };
1800
- }
1801
-
1802
- export async function recordSlackMessage(input: {
1803
- contextKey: string;
1804
- channelId: string;
1805
- threadTs: string;
1806
- ts: string;
1807
- kind: SlackMessageKind;
1808
- taskId?: string;
1809
- permalink?: string;
1810
- finalized?: boolean;
1811
- streamChunksAppended?: number;
1812
- conclusionKind?: SlackConclusionKind;
1813
- actorId?: string;
1814
- }): Promise<SlackMessageRecord> {
1815
- const id = crypto.randomUUID();
1816
- const now = new Date().toISOString();
1817
- const row = await getDbClient().get<SlackMessageRow>(
1818
- `INSERT INTO slack_messages (
1819
- id, context_key, channel_id, thread_ts, ts, kind, task_id, permalink,
1820
- finalized_at, stream_chunks_appended, conclusion_kind, created_at, updated_at, created_by, updated_by
1821
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1822
- ON CONFLICT(channel_id, ts) DO UPDATE SET
1823
- context_key = excluded.context_key,
1824
- kind = excluded.kind,
1825
- task_id = COALESCE(excluded.task_id, slack_messages.task_id),
1826
- permalink = COALESCE(excluded.permalink, slack_messages.permalink),
1827
- finalized_at = COALESCE(excluded.finalized_at, slack_messages.finalized_at),
1828
- stream_chunks_appended = MAX(
1829
- excluded.stream_chunks_appended,
1830
- slack_messages.stream_chunks_appended
1831
- ),
1832
- conclusion_kind = COALESCE(excluded.conclusion_kind, slack_messages.conclusion_kind),
1833
- updated_at = excluded.updated_at,
1834
- updated_by = excluded.updated_by
1835
- RETURNING *`,
1836
- [
1837
- id,
1838
- input.contextKey,
1839
- input.channelId,
1840
- input.threadTs,
1841
- input.ts,
1842
- input.kind,
1843
- input.taskId ?? null,
1844
- input.permalink ?? null,
1845
- input.finalized ? now : null,
1846
- input.streamChunksAppended ?? 0,
1847
- input.conclusionKind ?? null,
1848
- now,
1849
- now,
1850
- input.actorId ?? null,
1851
- input.actorId ?? null,
1852
- ],
1853
- );
1854
- if (!row) throw new Error("Failed to record Slack message");
1855
- return rowToSlackMessage(row);
1856
- }
1857
-
1858
- export async function reserveSlackMessage(input: {
1859
- contextKey: string;
1860
- channelId: string;
1861
- threadTs: string;
1862
- kind: "tree" | "outcome";
1863
- taskId?: string;
1864
- actorId?: string;
1865
- }): Promise<{ record: SlackMessageRecord; created: boolean }> {
1866
- if (input.kind === "outcome" && !input.taskId) {
1867
- throw new Error("Outcome Slack message reservations require a task ID");
1868
- }
1869
-
1870
- const id = crypto.randomUUID();
1871
- const now = new Date().toISOString();
1872
- const pendingTs = `${PENDING_SLACK_MESSAGE_TS_PREFIX}${id}`;
1873
- const inserted = await getDbClient().get<SlackMessageRow>(
1874
- `INSERT INTO slack_messages (
1875
- id, context_key, channel_id, thread_ts, ts, kind, task_id,
1876
- stream_chunks_appended, created_at, updated_at, created_by, updated_by
1877
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
1878
- ON CONFLICT DO NOTHING
1879
- RETURNING *`,
1880
- [
1881
- id,
1882
- input.contextKey,
1883
- input.channelId,
1884
- input.threadTs,
1885
- pendingTs,
1886
- input.kind,
1887
- input.taskId ?? null,
1888
- now,
1889
- now,
1890
- input.actorId ?? null,
1891
- input.actorId ?? null,
1892
- ],
1893
- );
1894
- if (inserted) return { record: rowToSlackMessage(inserted), created: true };
1895
-
1896
- const existing =
1897
- input.kind === "tree"
1898
- ? await getSlackTreeMessageByThread(input.channelId, input.threadTs)
1899
- : await getSlackOutcomeMessage(input.taskId!);
1900
- if (!existing) throw new Error("Failed to reserve Slack message");
1901
- return { record: existing, created: false };
1902
- }
1903
-
1904
- export async function bindSlackMessageTimestamp(
1905
- id: string,
1906
- ts: string,
1907
- options: { streamChunksAppended?: number; renderedThrough?: string } = {},
1908
- ): Promise<SlackMessageRecord | null> {
1909
- const now = new Date().toISOString();
1910
- const row = await getDbClient().get<SlackMessageRow>(
1911
- `UPDATE slack_messages SET
1912
- ts = ?,
1913
- stream_chunks_appended = MAX(stream_chunks_appended, ?),
1914
- updated_at = COALESCE(?, ?)
1915
- WHERE id = ? AND ts LIKE 'pending:%'
1916
- RETURNING *`,
1917
- [ts, options.streamChunksAppended ?? 0, options.renderedThrough ?? null, now, id],
1918
- );
1919
- return row ? rowToSlackMessage(row) : null;
1920
- }
1921
-
1922
- export async function updateSlackMessageRecord(
1923
- id: string,
1924
- updates: {
1925
- permalink?: string;
1926
- finalized?: boolean;
1927
- streamChunksAppended?: number;
1928
- conclusionKind?: SlackConclusionKind;
1929
- actorId?: string;
1930
- touchUpdatedAt?: boolean;
1931
- },
1932
- ): Promise<SlackMessageRecord | null> {
603
+ ): Promise<SlackMessageRecord | null> {
1933
604
  const now = new Date().toISOString();
1934
605
  const row = await getDbClient().get<SlackMessageRow>(
1935
606
  `UPDATE slack_messages SET
@@ -1938,1769 +609,202 @@ export async function updateSlackMessageRecord(
1938
609
  stream_chunks_appended = COALESCE(?, stream_chunks_appended),
1939
610
  conclusion_kind = COALESCE(?, conclusion_kind),
1940
611
  updated_at = CASE WHEN ? = 1 THEN ? ELSE updated_at END,
1941
- updated_by = COALESCE(?, updated_by)
1942
- WHERE id = ?
1943
- RETURNING *`,
1944
- [
1945
- updates.permalink ?? null,
1946
- updates.finalized ? 1 : 0,
1947
- now,
1948
- updates.streamChunksAppended ?? null,
1949
- updates.conclusionKind ?? null,
1950
- updates.touchUpdatedAt === false ? 0 : 1,
1951
- now,
1952
- updates.actorId ?? null,
1953
- id,
1954
- ],
1955
- );
1956
- return row ? rowToSlackMessage(row) : null;
1957
- }
1958
-
1959
- export async function markSlackTreeRendered(
1960
- id: string,
1961
- renderedThrough: string,
1962
- ): Promise<SlackMessageRecord | null> {
1963
- const row = await getDbClient().get<SlackMessageRow>(
1964
- `UPDATE slack_messages SET updated_at = ?
1965
- WHERE id = ? AND kind = 'tree'
1966
- RETURNING *`,
1967
- [renderedThrough, id],
1968
- );
1969
- return row ? rowToSlackMessage(row) : null;
1970
- }
1971
-
1972
- export async function deleteSlackMessageRecord(id: string): Promise<boolean> {
1973
- const result = await getDbClient().run(`DELETE FROM slack_messages WHERE id = ?`, [id]);
1974
- return result.changes > 0;
1975
- }
1976
-
1977
- export async function getSlackTreeMessage(contextKey: string): Promise<SlackMessageRecord | null> {
1978
- const row = await getDbClient().get<SlackMessageRow>(
1979
- `SELECT * FROM slack_messages WHERE context_key = ? AND kind = 'tree' LIMIT 1`,
1980
- [contextKey],
1981
- );
1982
- return row ? rowToSlackMessage(row) : null;
1983
- }
1984
-
1985
- export async function getSlackTreeMessageByThread(
1986
- channelId: string,
1987
- threadTs: string,
1988
- ): Promise<SlackMessageRecord | null> {
1989
- const row = await getDbClient().get<SlackMessageRow>(
1990
- `SELECT * FROM slack_messages
1991
- WHERE channel_id = ? AND thread_ts = ? AND kind = 'tree'
1992
- LIMIT 1`,
1993
- [channelId, threadTs],
1994
- );
1995
- return row ? rowToSlackMessage(row) : null;
1996
- }
1997
-
1998
- export async function getSlackOutcomeMessage(taskId: string): Promise<SlackMessageRecord | null> {
1999
- const row = await getDbClient().get<SlackMessageRow>(
2000
- `SELECT * FROM slack_messages WHERE task_id = ? AND kind = 'outcome' LIMIT 1`,
2001
- [taskId],
2002
- );
2003
- return row ? rowToSlackMessage(row) : null;
2004
- }
2005
-
2006
- export async function getSlackTreeMessages(): Promise<SlackMessageRecord[]> {
2007
- const rows = await getDbClient().query<SlackMessageRow>(
2008
- `SELECT tree.*
2009
- FROM slack_messages tree
2010
- JOIN slack_render_v2_state state ON state.id = 1
2011
- WHERE tree.kind = 'tree'
2012
- AND (
2013
- (tree.ts LIKE 'pending:%' AND tree.created_at >= state.activated_at)
2014
- OR EXISTS (
2015
- SELECT 1 FROM agent_tasks task
2016
- WHERE task.slackChannelId = tree.channel_id
2017
- AND task.slackThreadTs = tree.thread_ts
2018
- AND task.source = 'slack'
2019
- AND task.createdAt >= state.activated_at
2020
- AND task.status IN ('completed', 'failed', 'cancelled')
2021
- AND NOT EXISTS (
2022
- SELECT 1 FROM slack_messages outcome
2023
- WHERE outcome.kind = 'outcome'
2024
- AND outcome.task_id = task.id
2025
- AND outcome.finalized_at IS NOT NULL
2026
- )
2027
- )
2028
- OR
2029
- EXISTS (
2030
- SELECT 1 FROM agent_tasks task
2031
- WHERE task.slackChannelId = tree.channel_id
2032
- AND task.slackThreadTs = tree.thread_ts
2033
- AND task.createdAt >= state.activated_at
2034
- AND task.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2035
- )
2036
- OR EXISTS (
2037
- SELECT 1 FROM agent_tasks task
2038
- WHERE task.slackChannelId = tree.channel_id
2039
- AND task.slackThreadTs = tree.thread_ts
2040
- AND task.createdAt >= state.activated_at
2041
- AND task.lastUpdatedAt > tree.updated_at
2042
- )
2043
- OR EXISTS (
2044
- SELECT 1 FROM slack_messages outcome
2045
- JOIN agent_tasks task ON task.id = outcome.task_id
2046
- WHERE outcome.kind = 'outcome'
2047
- AND outcome.channel_id = tree.channel_id
2048
- AND outcome.thread_ts = tree.thread_ts
2049
- AND task.createdAt >= state.activated_at
2050
- AND (
2051
- outcome.finalized_at IS NULL
2052
- OR outcome.updated_at > tree.updated_at
2053
- )
2054
- )
2055
- )
2056
- ORDER BY tree.created_at ASC`,
2057
- );
2058
- return rows.map(rowToSlackMessage);
2059
- }
2060
-
2061
- export async function getSlackMessageByChannelTs(
2062
- channelId: string,
2063
- ts: string,
2064
- ): Promise<SlackMessageRecord | null> {
2065
- const row = await getDbClient().get<SlackMessageRow>(
2066
- `SELECT * FROM slack_messages WHERE channel_id = ? AND ts = ? LIMIT 1`,
2067
- [channelId, ts],
2068
- );
2069
- return row ? rowToSlackMessage(row) : null;
2070
- }
2071
-
2072
- export async function getSlackTasksInThread(
2073
- channelId: string,
2074
- threadTs: string,
2075
- ): Promise<AgentTask[]> {
2076
- const rows = await getDbClient().query<AgentTaskRow>(
2077
- `SELECT * FROM agent_tasks
2078
- WHERE slackChannelId = ? AND slackThreadTs = ?
2079
- ORDER BY createdAt ASC, rowid ASC`,
2080
- [channelId, threadTs],
2081
- );
2082
- return rows.map(rowToAgentTask);
2083
- }
2084
-
2085
- export async function markTaskSlackReplySent(taskId: string): Promise<void> {
2086
- await getDbClient().run(`UPDATE agent_tasks SET slackReplySent = 1 WHERE id = ?`, [taskId]);
2087
- }
2088
-
2089
- export async function setSlackMessageTracking(
2090
- taskId: string,
2091
- fields: {
2092
- slackProgressMessageTs?: string | null;
2093
- slackTreeRootMessageTs?: string | null;
2094
- },
2095
- ): Promise<void> {
2096
- const sets: string[] = [];
2097
- const args: (string | null)[] = [];
2098
-
2099
- if (Object.hasOwn(fields, "slackProgressMessageTs")) {
2100
- sets.push("slackProgressMessageTs = ?");
2101
- args.push(fields.slackProgressMessageTs ?? null);
2102
- }
2103
- if (Object.hasOwn(fields, "slackTreeRootMessageTs")) {
2104
- sets.push("slackTreeRootMessageTs = ?");
2105
- args.push(fields.slackTreeRootMessageTs ?? null);
2106
- }
2107
- if (sets.length === 0) return;
2108
-
2109
- args.push(taskId);
2110
- await getDbClient().run(`UPDATE agent_tasks SET ${sets.join(", ")} WHERE id = ?`, args);
2111
- }
2112
-
2113
- export async function getChildTasks(parentTaskId: string): Promise<AgentTask[]> {
2114
- const rows = await getDbClient().query<AgentTaskRow>(
2115
- `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC, rowid ASC`,
2116
- [parentTaskId],
2117
- );
2118
- return rows.map(rowToAgentTask);
2119
- }
2120
-
2121
- /**
2122
- * Returns true if `parentId` has at least one non-terminal child task with
2123
- * `taskType = 'resume'`. Used by the heartbeat sweep as an idempotency guard:
2124
- * if a prior sweep tick already created a resume follow-up for this parent,
2125
- * don't create a duplicate.
2126
- *
2127
- * **Filters by taskType = 'resume'** specifically. A parent task can also
2128
- * have ordinary non-terminal delegation children (`send-task` auto-defaults
2129
- * `parentTaskId` to the caller's current task — see src/tools/send-task.ts).
2130
- * Treating those as "already resumed" would incorrectly skip the resume
2131
- * path for a crashed worker that had delegated subtasks (PR #594 review).
2132
- */
2133
- export async function hasNonTerminalResumeChild(parentId: string): Promise<boolean> {
2134
- const row = await getDbClient().get(
2135
- `SELECT 1 FROM agent_tasks
2136
- WHERE parentTaskId = ?
2137
- AND taskType = 'resume'
2138
- AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2139
- LIMIT 1`,
2140
- [parentId],
2141
- );
2142
- return row !== undefined && row !== null;
2143
- }
2144
-
2145
- /**
2146
- * True when a non-terminal `reroute-decision` child exists for `parentId`.
2147
- *
2148
- * Mirrors {@link hasNonTerminalResumeChild} but filters on
2149
- * `taskType = 'reroute-decision'` — the Lead-owned re-delegation decision
2150
- * created when a pinned crash-recovery resume is never reclaimed (DES-523).
2151
- * Makes escalation idempotent: a later heartbeat sweep must not create a second
2152
- * decision for the same original task. We filter on the taskType marker
2153
- * specifically (not any child) so ordinary delegation / completion follow-up
2154
- * children of the original cannot suppress a needed decision, and nothing else
2155
- * is mistaken for one.
2156
- */
2157
- export async function hasNonTerminalRerouteDecisionChild(parentId: string): Promise<boolean> {
2158
- const row = await getDbClient().get<Record<string, number>>(
2159
- `SELECT 1 FROM agent_tasks
2160
- WHERE parentTaskId = ?
2161
- AND taskType = 'reroute-decision'
2162
- AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2163
- LIMIT 1`,
2164
- [parentId],
2165
- );
2166
- return row !== undefined && row !== null;
2167
- }
2168
-
2169
- export async function updateTaskClaudeSessionId(
2170
- taskId: string,
2171
- claudeSessionId: string,
2172
- provider?: ProviderName,
2173
- providerMeta?: Record<string, unknown>,
2174
- model?: string,
2175
- harnessVariant?: string,
2176
- harnessVariantMeta?: Record<string, unknown>,
2177
- ): Promise<AgentTask | null> {
2178
- const setClauses = ["claudeSessionId = ?", "lastUpdatedAt = ?"];
2179
- const params: (string | null)[] = [claudeSessionId, new Date().toISOString()];
2180
-
2181
- if (provider !== undefined) {
2182
- setClauses.push("provider = ?");
2183
- params.push(provider);
2184
- }
2185
- if (providerMeta !== undefined) {
2186
- setClauses.push("providerMeta = ?");
2187
- params.push(JSON.stringify(providerMeta));
2188
- }
2189
- if (model !== undefined) {
2190
- setClauses.push("model = ?");
2191
- params.push(model);
2192
- }
2193
- if (harnessVariant !== undefined) {
2194
- setClauses.push("harnessVariant = ?");
2195
- params.push(harnessVariant);
2196
- }
2197
- if (harnessVariantMeta !== undefined) {
2198
- setClauses.push("harnessVariantMeta = ?");
2199
- params.push(JSON.stringify(harnessVariantMeta));
2200
- }
2201
-
2202
- params.push(taskId);
2203
-
2204
- const row = await getDbClient().get<AgentTaskRow>(
2205
- `UPDATE agent_tasks SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
2206
- params,
2207
- );
2208
- return row ? rowToAgentTask(row) : null;
2209
- }
2210
-
2211
- /**
2212
- * Sets or clears a task's display title (session rename). Trims the input and
2213
- * normalizes an empty string to NULL (clear). Deliberately does NOT touch
2214
- * `lastUpdatedAt` — a rename is not activity, and the sessions sidebar sorts
2215
- * on chain-wide max `lastUpdatedAt`, so bumping it here would reorder the list.
2216
- */
2217
- export async function updateTaskTitle(
2218
- taskId: string,
2219
- title: string | null,
2220
- ): Promise<AgentTask | null> {
2221
- const normalized = title === null ? null : title.trim() || null;
2222
- const row = await getDbClient().get<AgentTaskRow>(
2223
- "UPDATE agent_tasks SET title = ? WHERE id = ? RETURNING *",
2224
- [normalized, taskId],
2225
- );
2226
- return row ? rowToAgentTask(row) : null;
2227
- }
2228
-
2229
- export async function updateTaskVcs(
2230
- taskId: string,
2231
- vcs: {
2232
- vcsProvider: "github" | "gitlab";
2233
- vcsRepo: string;
2234
- vcsNumber: number;
2235
- vcsUrl: string;
2236
- },
2237
- ): Promise<AgentTask | null> {
2238
- const row = await getDbClient().transaction(async () => {
2239
- const updated = await getDbClient().get<AgentTaskRow>(
2240
- `UPDATE agent_tasks
2241
- SET vcsProvider = ?, vcsRepo = ?, vcsNumber = ?, vcsUrl = ?, lastUpdatedAt = ?
2242
- WHERE id = ? RETURNING *`,
2243
- [vcs.vcsProvider, vcs.vcsRepo, vcs.vcsNumber, vcs.vcsUrl, new Date().toISOString(), taskId],
2244
- );
2245
- if (updated) {
2246
- await reconcileTaskPullRequestAttachments(taskId, updated.agentId, [
2247
- updated.output,
2248
- vcs.vcsProvider === "github" ? vcs.vcsUrl : null,
2249
- ]);
2250
- }
2251
- return updated;
2252
- });
2253
- return row ? rowToAgentTask(row) : null;
2254
- }
2255
-
2256
- export async function getTasksByAgentId(agentId: string): Promise<AgentTask[]> {
2257
- const rows = await getDbClient().query<AgentTaskRow>(
2258
- "SELECT * FROM agent_tasks WHERE agentId = ? ORDER BY createdAt DESC",
2259
- [agentId],
2260
- );
2261
- return rows.map(rowToAgentTask);
2262
- }
2263
-
2264
- /**
2265
- * Get the most recently updated in-progress task for an agent.
2266
- * Used as a fallback when X-Source-Task-Id header is missing (e.g. lead agent HITL requests).
2267
- *
2268
- * Note: if agent has multiple in-progress tasks, returns the most recently
2269
- * updated one. This is a best-effort fallback — the X-Source-Task-Id header
2270
- * is the authoritative source when available.
2271
- */
2272
- export async function getAgentCurrentTask(agentId: string): Promise<AgentTask | null> {
2273
- const row = await getDbClient().get<AgentTaskRow>(
2274
- "SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'in_progress' ORDER BY lastUpdatedAt DESC LIMIT 1",
2275
- [agentId],
2276
- );
2277
- return row ? rowToAgentTask(row) : null;
2278
- }
2279
-
2280
- export async function getTasksByStatus(status: AgentTaskStatus): Promise<AgentTask[]> {
2281
- const rows = await getDbClient().query<AgentTaskRow>(
2282
- "SELECT * FROM agent_tasks WHERE status = ? ORDER BY createdAt DESC",
2283
- [status],
2284
- );
2285
- return rows.map(rowToAgentTask);
2286
- }
2287
-
2288
- /**
2289
- * Find a task by VCS repo and issue/PR/MR number.
2290
- * Returns the most recent non-terminal task for this VCS entity.
2291
- *
2292
- * Terminal exclusion MUST stay in lock-step with `TERMINAL_TASK_STATUSES`
2293
- * in `src/types.ts`. SQL strings can't import a TS const — if you add a
2294
- * new terminal status, grep for `NOT IN ('completed'` across this file.
2295
- */
2296
- export async function findTaskByVcs(vcsRepo: string, vcsNumber: number): Promise<AgentTask | null> {
2297
- const row = await getDbClient().get<AgentTaskRow>(
2298
- `SELECT * FROM agent_tasks
2299
- WHERE vcsRepo = ? AND vcsNumber = ?
2300
- AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2301
- ORDER BY createdAt DESC
2302
- LIMIT 1`,
2303
- [vcsRepo, vcsNumber],
2304
- );
2305
- return row ? rowToAgentTask(row) : null;
2306
- }
2307
-
2308
- /** @deprecated Use findTaskByVcs instead */
2309
- export const findTaskByGitHub = findTaskByVcs;
2310
-
2311
- export interface TaskFilters {
2312
- /** Single status (back-compat) OR array of statuses (multi-status filter). */
2313
- status?: AgentTaskStatus | AgentTaskStatus[];
2314
- agentId?: string;
2315
- search?: string;
2316
- // New filters
2317
- unassigned?: boolean;
2318
- offeredTo?: string;
2319
- readyOnly?: boolean;
2320
- taskType?: string;
2321
- tags?: string[];
2322
- scheduleId?: string;
2323
- /** Exact canonical asset namespace. */
2324
- key?: string;
2325
- /** Canonical namespace subtree prefix. */
2326
- keyPrefix?: string;
2327
- /** Filter to tasks whose `source` is in this list. Empty/undefined → no filter. */
2328
- source?: AgentTaskSource[];
2329
- /** ISO 8601 timestamp; only return tasks where createdAt >= this. */
2330
- createdAfter?: string;
2331
- /** ISO 8601 timestamp; only return tasks where createdAt < this. */
2332
- createdBefore?: string;
2333
- /** Only return tasks requested by this canonical user. NULL rows are excluded. */
2334
- requestedByUserId?: string;
2335
- /** When set, restrict to rows where `requestedByUserId` IS NULL. Takes priority over `requestedByUserId`. */
2336
- requestedByUserIdIsNull?: boolean;
2337
- /** Sort list rows for either table freshness or timeline paging. */
2338
- orderBy?: "lastUpdatedAt" | "createdAt";
2339
- limit?: number;
2340
- offset?: number;
2341
- includeHeartbeat?: boolean;
2342
- }
2343
-
2344
- export function getAllTasks(filters?: TaskFilters): Promise<AgentTask[]>;
2345
- export function getAllTasks(
2346
- filters: TaskFilters | undefined,
2347
- opts: { slim: true },
2348
- ): Promise<AgentTaskSummary[]>;
2349
- export async function getAllTasks(
2350
- filters?: TaskFilters,
2351
- opts?: { slim?: boolean },
2352
- ): Promise<AgentTask[] | AgentTaskSummary[]> {
2353
- const conditions: string[] = [];
2354
- const params: (string | AgentTaskStatus)[] = [];
2355
-
2356
- if (filters?.status) {
2357
- if (Array.isArray(filters.status)) {
2358
- if (filters.status.length === 1) {
2359
- conditions.push("status = ?");
2360
- params.push(filters.status[0]!);
2361
- } else if (filters.status.length > 1) {
2362
- const placeholders = filters.status.map(() => "?").join(", ");
2363
- conditions.push(`status IN (${placeholders})`);
2364
- for (const s of filters.status) params.push(s);
2365
- }
2366
- } else {
2367
- conditions.push("status = ?");
2368
- params.push(filters.status);
2369
- }
2370
- }
2371
-
2372
- if (filters?.agentId) {
2373
- conditions.push("agentId = ?");
2374
- params.push(filters.agentId);
2375
- }
2376
-
2377
- if (filters?.search) {
2378
- conditions.push("(task LIKE ? OR id LIKE ?)");
2379
- params.push(`%${filters.search}%`, `%${filters.search}%`);
2380
- }
2381
-
2382
- // New filters
2383
- if (filters?.unassigned) {
2384
- conditions.push("(agentId IS NULL OR status = 'unassigned')");
2385
- }
2386
-
2387
- if (filters?.offeredTo) {
2388
- conditions.push("offeredTo = ?");
2389
- params.push(filters.offeredTo);
2390
- }
2391
-
2392
- if (filters?.taskType) {
2393
- conditions.push("taskType = ?");
2394
- params.push(filters.taskType);
2395
- }
2396
-
2397
- if (filters?.tags && filters.tags.length > 0) {
2398
- // Match any of the tags
2399
- const tagConditions = filters.tags.map(() => "tags LIKE ?");
2400
- conditions.push(`(${tagConditions.join(" OR ")})`);
2401
- for (const tag of filters.tags) {
2402
- params.push(`%"${tag}"%`);
2403
- }
2404
- }
2405
-
2406
- if (filters?.scheduleId) {
2407
- conditions.push("scheduleId = ?");
2408
- params.push(filters.scheduleId);
2409
- }
2410
-
2411
- if (filters?.key) {
2412
- conditions.push('"key" = ?');
2413
- params.push(normalizeAssetKey(filters.key));
2414
- } else if (filters?.keyPrefix) {
2415
- conditions.push(`"key" LIKE ? ESCAPE '\\'`);
2416
- params.push(assetKeyPrefixPattern(filters.keyPrefix));
2417
- }
2418
-
2419
- if (filters?.source && filters.source.length > 0) {
2420
- const placeholders = filters.source.map(() => "?").join(", ");
2421
- conditions.push(`source IN (${placeholders})`);
2422
- for (const s of filters.source) params.push(s);
2423
- }
2424
-
2425
- if (filters?.createdAfter) {
2426
- conditions.push("createdAt >= ?");
2427
- params.push(filters.createdAfter);
2428
- }
2429
-
2430
- if (filters?.createdBefore) {
2431
- conditions.push("createdAt < ?");
2432
- params.push(filters.createdBefore);
2433
- }
2434
-
2435
- if (filters?.requestedByUserIdIsNull) {
2436
- conditions.push("requestedByUserId IS NULL");
2437
- } else if (filters?.requestedByUserId) {
2438
- conditions.push("requestedByUserId = ?");
2439
- params.push(filters.requestedByUserId);
2440
- }
2441
-
2442
- // Exclude system/heartbeat tasks by default. The flag is still called
2443
- // `includeHeartbeat` for backward compat with existing API callers, but we
2444
- // also gate boot-triage + heartbeat-checklist behind it since those are
2445
- // equally noisy in the dashboard task list.
2446
- if (!filters?.includeHeartbeat) {
2447
- conditions.push(
2448
- "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
2449
- );
2450
- }
2451
-
2452
- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2453
- const limit = filters?.limit ?? 25;
2454
- const offset = filters?.offset ?? 0;
2455
- const orderBy =
2456
- filters?.orderBy === "createdAt"
2457
- ? "createdAt DESC, rowid DESC"
2458
- : "lastUpdatedAt DESC, priority DESC";
2459
- const query = `SELECT agent_tasks.*,
2460
- (SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd
2461
- FROM agent_tasks ${whereClause}
2462
- ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`;
2463
-
2464
- const rows = await getDbClient().query<AgentTaskRow>(query, params);
2465
-
2466
- // Filter for ready tasks (dependencies met) if requested. Both the full and
2467
- // the slim row shapes carry `id` + `dependsOn`, so the same predicate works.
2468
- const filterReady = async <T extends { id: string; dependsOn: string[] }>(
2469
- items: T[],
2470
- ): Promise<T[]> => {
2471
- const readyFlags = await Promise.all(
2472
- items.map(async (task) => {
2473
- if (!task.dependsOn || task.dependsOn.length === 0) return true;
2474
- return (await checkDependencies(task.id)).ready;
2475
- }),
2476
- );
2477
- return items.filter((_, i) => readyFlags[i]);
2478
- };
2479
-
2480
- if (opts?.slim) {
2481
- let tasks = rows.map(rowToAgentTaskSummary);
2482
- if (filters?.readyOnly) tasks = await filterReady(tasks);
2483
- return tasks;
2484
- }
2485
-
2486
- let tasks = rows.map(rowToAgentTask);
2487
- if (filters?.readyOnly) tasks = await filterReady(tasks);
2488
- return tasks;
2489
- }
2490
-
2491
- /**
2492
- * Get total count of tasks matching the given filters (ignoring limit).
2493
- * Used alongside getAllTasks to display accurate total counts in UI.
2494
- */
2495
- export async function getTasksCount(
2496
- filters?: Omit<TaskFilters, "limit" | "readyOnly">,
2497
- ): Promise<number> {
2498
- const conditions: string[] = [];
2499
- const params: (string | AgentTaskStatus)[] = [];
2500
-
2501
- if (filters?.status) {
2502
- if (Array.isArray(filters.status)) {
2503
- if (filters.status.length === 1) {
2504
- conditions.push("status = ?");
2505
- params.push(filters.status[0]!);
2506
- } else if (filters.status.length > 1) {
2507
- const placeholders = filters.status.map(() => "?").join(", ");
2508
- conditions.push(`status IN (${placeholders})`);
2509
- for (const s of filters.status) params.push(s);
2510
- }
2511
- } else {
2512
- conditions.push("status = ?");
2513
- params.push(filters.status);
2514
- }
2515
- }
2516
-
2517
- if (filters?.agentId) {
2518
- conditions.push("agentId = ?");
2519
- params.push(filters.agentId);
2520
- }
2521
-
2522
- if (filters?.search) {
2523
- conditions.push("(task LIKE ? OR id LIKE ?)");
2524
- params.push(`%${filters.search}%`, `%${filters.search}%`);
2525
- }
2526
-
2527
- if (filters?.unassigned) {
2528
- conditions.push("(agentId IS NULL OR status = 'unassigned')");
2529
- }
2530
-
2531
- if (filters?.offeredTo) {
2532
- conditions.push("offeredTo = ?");
2533
- params.push(filters.offeredTo);
2534
- }
2535
-
2536
- if (filters?.taskType) {
2537
- conditions.push("taskType = ?");
2538
- params.push(filters.taskType);
2539
- }
2540
-
2541
- if (filters?.tags && filters.tags.length > 0) {
2542
- const tagConditions = filters.tags.map(() => "tags LIKE ?");
2543
- conditions.push(`(${tagConditions.join(" OR ")})`);
2544
- for (const tag of filters.tags) {
2545
- params.push(`%"${tag}"%`);
2546
- }
2547
- }
2548
-
2549
- if (filters?.scheduleId) {
2550
- conditions.push("scheduleId = ?");
2551
- params.push(filters.scheduleId);
2552
- }
2553
-
2554
- if (filters?.key) {
2555
- conditions.push('"key" = ?');
2556
- params.push(normalizeAssetKey(filters.key));
2557
- } else if (filters?.keyPrefix) {
2558
- conditions.push(`"key" LIKE ? ESCAPE '\\'`);
2559
- params.push(assetKeyPrefixPattern(filters.keyPrefix));
2560
- }
2561
-
2562
- if (filters?.source && filters.source.length > 0) {
2563
- const placeholders = filters.source.map(() => "?").join(", ");
2564
- conditions.push(`source IN (${placeholders})`);
2565
- for (const s of filters.source) params.push(s);
2566
- }
2567
-
2568
- if (filters?.createdAfter) {
2569
- conditions.push("createdAt >= ?");
2570
- params.push(filters.createdAfter);
2571
- }
2572
-
2573
- if (filters?.createdBefore) {
2574
- conditions.push("createdAt < ?");
2575
- params.push(filters.createdBefore);
2576
- }
2577
-
2578
- if (filters?.requestedByUserIdIsNull) {
2579
- conditions.push("requestedByUserId IS NULL");
2580
- } else if (filters?.requestedByUserId) {
2581
- conditions.push("requestedByUserId = ?");
2582
- params.push(filters.requestedByUserId);
2583
- }
2584
-
2585
- // Exclude system/heartbeat tasks by default. The flag is still called
2586
- // `includeHeartbeat` for backward compat with existing API callers, but we
2587
- // also gate boot-triage + heartbeat-checklist behind it since those are
2588
- // equally noisy in the dashboard task list.
2589
- if (!filters?.includeHeartbeat) {
2590
- conditions.push(
2591
- "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
2592
- );
2593
- }
2594
-
2595
- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2596
- const query = `SELECT COUNT(*) as count FROM agent_tasks ${whereClause}`;
2597
-
2598
- const result = await getDbClient().get<{ count: number }>(query, params);
2599
-
2600
- return result?.count ?? 0;
2601
- }
2602
-
2603
- /**
2604
- * Get task statistics (counts by status) without any limit.
2605
- * This is more efficient than fetching all tasks for stats purposes.
2606
- */
2607
- export async function getTaskStats(): Promise<{
2608
- total: number;
2609
- unassigned: number;
2610
- offered: number;
2611
- reviewing: number;
2612
- pending: number;
2613
- in_progress: number;
2614
- paused: number;
2615
- completed: number;
2616
- failed: number;
2617
- }> {
2618
- const row = await getDbClient().get<{
2619
- total: number;
2620
- unassigned: number;
2621
- offered: number;
2622
- reviewing: number;
2623
- pending: number;
2624
- in_progress: number;
2625
- paused: number;
2626
- completed: number;
2627
- failed: number;
2628
- }>(
2629
- `SELECT
2630
- COUNT(*) as total,
2631
- SUM(CASE WHEN status = 'unassigned' THEN 1 ELSE 0 END) as unassigned,
2632
- SUM(CASE WHEN status = 'offered' THEN 1 ELSE 0 END) as offered,
2633
- SUM(CASE WHEN status = 'reviewing' THEN 1 ELSE 0 END) as reviewing,
2634
- SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
2635
- SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
2636
- SUM(CASE WHEN status = 'paused' THEN 1 ELSE 0 END) as paused,
2637
- SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
2638
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
2639
- FROM agent_tasks`,
2640
- );
2641
-
2642
- return (
2643
- row ?? {
2644
- total: 0,
2645
- unassigned: 0,
2646
- offered: 0,
2647
- reviewing: 0,
2648
- pending: 0,
2649
- in_progress: 0,
2650
- paused: 0,
2651
- completed: 0,
2652
- failed: 0,
2653
- }
2654
- );
2655
- }
2656
-
2657
- export async function getCompletedSlackTasks(): Promise<AgentTask[]> {
2658
- const rows = await getDbClient().query<AgentTaskRow>(
2659
- `SELECT * FROM agent_tasks
2660
- WHERE slackChannelId IS NOT NULL
2661
- AND status IN ('completed', 'failed')
2662
- ORDER BY lastUpdatedAt DESC
2663
- LIMIT 200`,
2664
- );
2665
- return rows.map(rowToAgentTask);
2666
- }
2667
-
2668
- /**
2669
- * Return terminal Slack-rooted tasks whose durable relay obligation is pending.
2670
- * The obligation is inserted by a DB trigger in the same transaction as the
2671
- * terminal status transition, so a process restart cannot lose the send.
2672
- */
2673
- export async function getPendingSlackRelayTasks(): Promise<AgentTask[]> {
2674
- const rows = await getDbClient().query<AgentTaskRow>(
2675
- `SELECT task.* FROM slack_relay_obligations obligation
2676
- JOIN agent_tasks task ON task.id = obligation.task_id
2677
- WHERE obligation.delivered_at IS NULL
2678
- AND task.status IN ('completed', 'failed', 'cancelled')
2679
- ORDER BY obligation.last_attempt_at IS NOT NULL,
2680
- obligation.last_attempt_at ASC,
2681
- obligation.created_at ASC
2682
- LIMIT 200`,
2683
- );
2684
- return rows.map(rowToAgentTask);
2685
- }
2686
-
2687
- /** Rotate attempted rows behind fresh obligations so poison rows cannot starve the queue. */
2688
- export async function markSlackRelayAttempted(taskId: string): Promise<boolean> {
2689
- const now = new Date().toISOString();
2690
- const result = await getDbClient().run(
2691
- `UPDATE slack_relay_obligations
2692
- SET attempt_count = attempt_count + 1,
2693
- last_attempt_at = ?,
2694
- updated_at = ?
2695
- WHERE task_id = ? AND delivered_at IS NULL`,
2696
- [now, now, taskId],
2697
- );
2698
- return result.changes > 0;
2699
- }
2700
-
2701
- /** Mark a relay obligation delivered only after Slack accepted the final result. */
2702
- export async function markSlackRelayDelivered(taskId: string): Promise<boolean> {
2703
- const now = new Date().toISOString();
2704
- const result = await getDbClient().run(
2705
- `UPDATE slack_relay_obligations
2706
- SET delivered_at = ?, updated_at = ?
2707
- WHERE task_id = ? AND delivered_at IS NULL`,
2708
- [now, now, taskId],
2709
- );
2710
- return result.changes > 0;
2711
- }
2712
-
2713
- /** Discharge obligations already fulfilled by renderer v2's durable outcome record. */
2714
- export async function markFinalizedSlackRelaysDelivered(): Promise<number> {
2715
- const now = new Date().toISOString();
2716
- const result = await getDbClient().run(
2717
- `UPDATE slack_relay_obligations AS obligation
2718
- SET delivered_at = ?, updated_at = ?
2719
- WHERE obligation.delivered_at IS NULL
2720
- AND EXISTS (
2721
- SELECT 1 FROM slack_messages message
2722
- WHERE message.kind = 'outcome'
2723
- AND message.task_id = obligation.task_id
2724
- AND message.finalized_at IS NOT NULL
2725
- )`,
2726
- [now, now],
2727
- );
2728
- return result.changes;
2729
- }
2730
-
2731
- /**
2732
- * Get tasks that were recently finished (completed/failed) by workers (non-lead agents).
2733
- * Used by leads to know when workers complete tasks.
2734
- */
2735
- export async function getRecentlyFinishedWorkerTasks(): Promise<AgentTask[]> {
2736
- // Query for finished tasks that haven't been notified yet
2737
- const rows = await getDbClient().query<AgentTaskRow>(
2738
- `SELECT t.* FROM agent_tasks t
2739
- LEFT JOIN agents a ON t.agentId = a.id
2740
- WHERE t.status IN ('completed', 'failed')
2741
- AND t.finishedAt IS NOT NULL
2742
- AND t.notifiedAt IS NULL
2743
- AND (a.isLead = 0 OR a.isLead IS NULL)
2744
- ORDER BY t.finishedAt DESC LIMIT 50`,
2745
- );
2746
- return rows.map(rowToAgentTask);
2747
- }
2748
-
2749
- /**
2750
- * Atomically mark finished tasks as notified.
2751
- * Sets notifiedAt timestamp to prevent returning them in future polls.
2752
- */
2753
- export async function markTasksNotified(taskIds: string[]): Promise<number> {
2754
- if (taskIds.length === 0) return 0;
2755
-
2756
- const now = new Date().toISOString();
2757
- const placeholders = taskIds.map(() => "?").join(",");
2758
-
2759
- const result = await getDbClient().run(
2760
- `UPDATE agent_tasks SET notifiedAt = ?
2761
- WHERE id IN (${placeholders}) AND notifiedAt IS NULL`,
2762
- [now, ...taskIds],
2763
- );
2764
-
2765
- return result.changes;
2766
- }
2767
-
2768
- /**
2769
- * Reset notifiedAt for tasks, allowing them to be re-delivered on next poll.
2770
- * Used when a trigger was consumed but the session that should process it failed.
2771
- * This prevents permanent notification loss from the mark-before-process race.
2772
- */
2773
- export async function resetTasksNotified(taskIds: string[]): Promise<number> {
2774
- if (taskIds.length === 0) return 0;
2775
-
2776
- const placeholders = taskIds.map(() => "?").join(",");
2777
-
2778
- const result = await getDbClient().run(
2779
- `UPDATE agent_tasks SET notifiedAt = NULL
2780
- WHERE id IN (${placeholders}) AND notifiedAt IS NOT NULL`,
2781
- taskIds,
2782
- );
2783
-
2784
- return result.changes;
2785
- }
2786
-
2787
- export async function getInProgressSlackTasks(): Promise<AgentTask[]> {
2788
- const rows = await getDbClient().query<AgentTaskRow>(
2789
- `SELECT * FROM agent_tasks
2790
- WHERE slackChannelId IS NOT NULL
2791
- AND status = 'in_progress'
2792
- ORDER BY lastUpdatedAt DESC
2793
- LIMIT 200`,
2794
- );
2795
- return rows.map(rowToAgentTask);
2796
- }
2797
-
2798
- export async function getSlackTasksMissingTree(): Promise<AgentTask[]> {
2799
- const rows = await getDbClient().query<AgentTaskRow>(
2800
- `SELECT task.* FROM agent_tasks task
2801
- JOIN slack_render_v2_state state ON state.id = 1
2802
- WHERE task.source = 'slack'
2803
- AND task.slackChannelId IS NOT NULL
2804
- AND task.slackThreadTs IS NOT NULL
2805
- AND task.createdAt >= state.activated_at
2806
- AND task.status NOT IN ('backlog', 'unassigned', 'superseded')
2807
- AND NOT EXISTS (
2808
- SELECT 1 FROM agent_tasks earlier
2809
- WHERE earlier.source = 'slack'
2810
- AND earlier.slackChannelId = task.slackChannelId
2811
- AND earlier.slackThreadTs = task.slackThreadTs
2812
- AND earlier.createdAt < state.activated_at
2813
- )
2814
- AND NOT EXISTS (
2815
- SELECT 1 FROM slack_messages tree
2816
- WHERE tree.kind = 'tree'
2817
- AND tree.channel_id = task.slackChannelId
2818
- AND tree.thread_ts = task.slackThreadTs
2819
- )
2820
- ORDER BY task.lastUpdatedAt DESC
2821
- LIMIT 200`,
2822
- );
2823
- return rows.map(rowToAgentTask);
2824
- }
2825
-
2826
- /**
2827
- * Return sibling tasks for a given cross-ingress context key, optionally
2828
- * filtered by status. The returned shape mirrors getInProgressSlackTasks for
2829
- * consistency; callers can narrow further in TypeScript.
2830
- *
2831
- * See src/tasks/context-key.ts for the key schema.
2832
- */
2833
- export async function getInProgressTasksByContextKey(
2834
- contextKey: string,
2835
- statuses: AgentTaskStatus[] = ["pending", "in_progress", "offered", "paused"],
2836
- ): Promise<AgentTask[]> {
2837
- if (!contextKey || statuses.length === 0) return [];
2838
- const placeholders = statuses.map(() => "?").join(",");
2839
- const rows = await getDbClient().query<AgentTaskRow>(
2840
- `SELECT * FROM agent_tasks
2841
- WHERE contextKey = ?
2842
- AND status IN (${placeholders})
2843
- ORDER BY lastUpdatedAt DESC
2844
- LIMIT 200`,
2845
- [contextKey, ...statuses],
2846
- );
2847
- return rows.map(rowToAgentTask);
2848
- }
2849
-
2850
- export type ExistingTrackerContextWorkReason = "active_task" | "linked_open_pr";
2851
-
2852
- export type ExistingTrackerContextWork = {
2853
- task: AgentTask;
2854
- reason: ExistingTrackerContextWorkReason;
2855
- };
2856
-
2857
- const LINEAR_TRACKER_CONTEXT_KEY_PREFIX = "task:trackers:linear:";
2858
-
2859
- function isLinearTrackerContextKey(contextKey: string | null | undefined): contextKey is string {
2860
- return !!contextKey && contextKey.startsWith(LINEAR_TRACKER_CONTEXT_KEY_PREFIX);
2861
- }
2862
-
2863
- /**
2864
- * Return existing work for a Linear tracker key before creating another task.
2865
- *
2866
- * Active means any non-terminal task. A completed task with persisted VCS PR/MR
2867
- * metadata is also treated as existing work because the task can be complete
2868
- * while the PR is still awaiting review/merge.
2869
- */
2870
- export async function findExistingLinearTrackerContextWork(
2871
- contextKey: string | null | undefined,
2872
- ): Promise<ExistingTrackerContextWork | null> {
2873
- if (!isLinearTrackerContextKey(contextKey)) return null;
2874
-
2875
- const activeRow = await getDbClient().get<AgentTaskRow>(
2876
- `SELECT * FROM agent_tasks
2877
- WHERE contextKey = ?
2878
- AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2879
- ORDER BY lastUpdatedAt DESC
2880
- LIMIT 1`,
2881
- [contextKey],
2882
- );
2883
- if (activeRow) {
2884
- return { task: rowToAgentTask(activeRow), reason: "active_task" };
2885
- }
2886
-
2887
- const linkedPrRow = await getDbClient().get<AgentTaskRow>(
2888
- `SELECT * FROM agent_tasks
2889
- WHERE contextKey = ?
2890
- AND status = 'completed'
2891
- AND vcsProvider IS NOT NULL
2892
- AND vcsRepo IS NOT NULL
2893
- AND vcsNumber IS NOT NULL
2894
- AND vcsUrl IS NOT NULL
2895
- ORDER BY lastUpdatedAt DESC
2896
- LIMIT 1`,
2897
- [contextKey],
2898
- );
2899
- if (linkedPrRow) {
2900
- return { task: rowToAgentTask(linkedPrRow), reason: "linked_open_pr" };
2901
- }
2902
-
2903
- return null;
2904
- }
2905
-
2906
- export async function getLatestTaskByContextKey(contextKey: string): Promise<AgentTask | null> {
2907
- if (!contextKey) return null;
2908
- const row = await getDbClient().get<AgentTaskRow>(
2909
- `SELECT * FROM agent_tasks
2910
- WHERE contextKey = ?
2911
- ORDER BY createdAt DESC
2912
- LIMIT 1`,
2913
- [contextKey],
2914
- );
2915
- return row ? rowToAgentTask(row) : null;
2916
- }
2917
-
2918
- export async function getLatestScriptRunStepTaskByContextKey(
2919
- contextKey: string,
2920
- ): Promise<AgentTask | null> {
2921
- if (!contextKey) return null;
2922
- const row = await getDbClient().get<AgentTaskRow>(
2923
- `SELECT * FROM agent_tasks
2924
- WHERE contextKey = ?
2925
- AND taskType = 'script-run-step'
2926
- ORDER BY createdAt DESC, rowid DESC
2927
- LIMIT 1`,
2928
- [contextKey],
2929
- );
2930
- return row ? rowToAgentTask(row) : null;
2931
- }
2932
-
2933
- /**
2934
- * Find the most recent agent associated with a specific Slack thread.
2935
- * No status filter — returns the last agent that touched this thread regardless of task state.
2936
- * This is intentional: follow-up messages should route to the same agent even after task completion.
2937
- * Callers (e.g. assistant.ts) apply their own status checks (e.g. agent.status !== "offline").
2938
- */
2939
- export async function getAgentWorkingOnThread(
2940
- channelId: string,
2941
- threadTs: string,
2942
- ): Promise<Agent | null> {
2943
- const taskRow = await getDbClient().get<AgentTaskRow>(
2944
- `SELECT * FROM agent_tasks
2945
- WHERE source = 'slack'
2946
- AND slackChannelId = ?
2947
- AND slackThreadTs = ?
2948
- ORDER BY createdAt DESC
2949
- LIMIT 1`,
2950
- [channelId, threadTs],
2951
- );
2952
-
2953
- if (taskRow?.agentId) return getAgentById(taskRow.agentId);
2954
-
2955
- return null;
2956
- }
2957
-
2958
- /**
2959
- * Find the latest active (in_progress or pending) task in a specific Slack thread.
2960
- * Used for dependency chaining in additive Slack buffer.
2961
- */
2962
- export async function getLatestActiveTaskInThread(
2963
- channelId: string,
2964
- threadTs: string,
2965
- ): Promise<AgentTask | null> {
2966
- const row = await getDbClient().get<AgentTaskRow>(
2967
- `SELECT * FROM agent_tasks
2968
- WHERE source = 'slack'
2969
- AND slackChannelId = ?
2970
- AND slackThreadTs = ?
2971
- AND status IN ('in_progress', 'pending')
2972
- ORDER BY createdAt DESC, rowid DESC
2973
- LIMIT 1`,
2974
- [channelId, threadTs],
2975
- );
2976
-
2977
- return row ? rowToAgentTask(row) : null;
2978
- }
2979
-
2980
- /**
2981
- * Find the latest task assigned to a lead agent in a specific Slack thread.
2982
- * Source is deliberately restricted to Slack ingress so inherited worker-task
2983
- * metadata cannot become the thread's steering target.
2984
- */
2985
- export async function getLatestLeadTaskInThread(
2986
- channelId: string,
2987
- threadTs: string,
2988
- ): Promise<AgentTask | null> {
2989
- const row = await getDbClient().get<AgentTaskRow>(
2990
- `SELECT t.*
2991
- FROM agent_tasks t
2992
- JOIN agents a ON a.id = t.agentId
2993
- WHERE t.source = 'slack'
2994
- AND t.slackChannelId = ?
2995
- AND t.slackThreadTs = ?
2996
- AND a.isLead = 1
2997
- ORDER BY t.createdAt DESC, t.rowid DESC
2998
- LIMIT 1`,
2999
- [channelId, threadTs],
3000
- );
3001
-
3002
- return row ? rowToAgentTask(row) : null;
3003
- }
3004
-
3005
- /**
3006
- * Find the most recent task in a Slack thread, regardless of source or status.
3007
- * Unlike getAgentWorkingOnThread (which filters source='slack'), this finds ALL tasks
3008
- * including worker tasks that inherited Slack metadata via parentTaskId.
3009
- */
3010
- export async function getMostRecentTaskInThread(
3011
- channelId: string,
3012
- threadTs: string,
3013
- ): Promise<AgentTask | null> {
3014
- const row = await getDbClient().get<AgentTaskRow>(
3015
- `SELECT * FROM agent_tasks
3016
- WHERE slackChannelId = ?
3017
- AND slackThreadTs = ?
3018
- ORDER BY createdAt DESC
3019
- LIMIT 1`,
3020
- [channelId, threadTs],
3021
- );
3022
- return row ? rowToAgentTask(row) : null;
3023
- }
3024
-
3025
- export async function findCompletedTaskInThread(
3026
- channelId: string,
3027
- threadTs: string,
3028
- windowMinutes: number,
3029
- ): Promise<AgentTask | null> {
3030
- const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
3031
- const row = await getDbClient().get<AgentTaskRow>(
3032
- `SELECT * FROM agent_tasks
3033
- WHERE slackChannelId = ?
3034
- AND slackThreadTs = ?
3035
- AND status = 'completed'
3036
- AND lastUpdatedAt > ?
3037
- ORDER BY lastUpdatedAt DESC
3038
- LIMIT 1`,
3039
- [channelId, threadTs, since],
3040
- );
3041
- return row ? rowToAgentTask(row) : null;
3042
- }
3043
-
3044
- /**
3045
- * Find the most recent CANCELLED task in a Slack thread. Used by the
3046
- * follow-up re-delegation guard so a cancellation (worker SIGTERM,
3047
- * runner-side abort, swarm-events tool-loop abort) doesn't permanently
3048
- * jam re-dispatch when an earlier sibling task in the same thread also
3049
- * completed.
3050
- *
3051
- * Matches both:
3052
- * - `status = 'cancelled'` (the canonical terminal state from cancelTask)
3053
- * - `status = 'failed'` with a failureReason that starts with "cancelled"
3054
- * or "exit 130" or contains "cancelled" (the codex-adapter abort path
3055
- * emits `failureReason: "cancelled"` and exits 130).
3056
- */
3057
- export async function findRecentCancelledTaskInThread(
3058
- channelId: string,
3059
- threadTs: string,
3060
- windowMinutes: number,
3061
- ): Promise<AgentTask | null> {
3062
- const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
3063
- const row = await getDbClient().get<AgentTaskRow>(
3064
- `SELECT * FROM agent_tasks
3065
- WHERE slackChannelId = ?
3066
- AND slackThreadTs = ?
3067
- AND lastUpdatedAt > ?
3068
- AND (
3069
- status = 'cancelled'
3070
- OR (
3071
- status = 'failed'
3072
- AND failureReason IS NOT NULL
3073
- AND (
3074
- failureReason LIKE 'cancelled%'
3075
- OR failureReason LIKE 'exit 130%'
3076
- OR failureReason LIKE '%cancelled%'
3077
- )
3078
- )
3079
- )
3080
- ORDER BY lastUpdatedAt DESC
3081
- LIMIT 1`,
3082
- [channelId, threadTs, since],
3083
- );
3084
- return row ? rowToAgentTask(row) : null;
3085
- }
3086
-
3087
- export async function completeTask(id: string, output?: string): Promise<AgentTask | null> {
3088
- const oldTask = await getTaskById(id);
3089
- if (!oldTask) return null;
3090
-
3091
- // Idempotency guard: don't re-complete a task already in a terminal state.
3092
- // Mirrors cancelTask. Prevents duplicate task.completed events, duplicate
3093
- // log entries, and duplicate follow-up tasks when multiple sessions race.
3094
- if (isTerminalTaskStatus(oldTask.status)) {
3095
- return null;
3096
- }
3097
-
3098
- const row = await getDbClient().transaction(async () => {
3099
- const finishedAt = new Date().toISOString();
3100
- // The status predicate re-checks the idempotency guard atomically: the
3101
- // await between the guard read above and this write lets a racing
3102
- // terminal transition (e.g. heartbeat failTask) land first.
3103
- let completed = await getDbClient().get<AgentTaskRow>(
3104
- `UPDATE agent_tasks SET status = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3105
- WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
3106
- ["completed", finishedAt, id],
3107
- );
3108
- if (!completed) return null;
3109
-
3110
- if (output) {
3111
- completed = await getDbClient().get<AgentTaskRow>(
3112
- "UPDATE agent_tasks SET output = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
3113
- [scrubSecrets(output), id],
3114
- );
3115
- }
3116
- if (completed) {
3117
- await reconcileTaskPullRequestAttachments(id, completed.agentId, [
3118
- completed.output,
3119
- completed.vcsProvider === "github" ? completed.vcsUrl : null,
3120
- ]);
3121
- }
3122
- return completed;
3123
- });
3124
- if (!row) return null;
3125
-
3126
- if (row && oldTask) {
3127
- emitTaskLifecycleTelemetryAfterCommit(
3128
- "completed",
3129
- {
3130
- taskId: id,
3131
- source: oldTask.source,
3132
- ...taskContextForTelemetry(oldTask),
3133
- agentId: row.agentId ?? undefined,
3134
- durationMs: row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : undefined,
3135
- },
3136
- (task) => task?.status === "completed",
3137
- );
3138
-
3139
- try {
3140
- await createLogEntry({
3141
- eventType: "task_status_change",
3142
- taskId: id,
3143
- agentId: row.agentId ?? undefined,
3144
- oldValue: oldTask.status,
3145
- newValue: "completed",
3146
- });
3147
- } catch {}
3148
- getDbClient().afterCommit(() => {
3149
- import("../workflows/event-bus")
3150
- .then(({ workflowEventBus }) => {
3151
- workflowEventBus.emit("task.completed", {
3152
- taskId: id,
3153
- output,
3154
- agentId: row.agentId,
3155
- workflowRunId: row.workflowRunId,
3156
- workflowRunStepId: row.workflowRunStepId,
3157
- });
3158
- })
3159
- .catch((err) =>
3160
- console.error(
3161
- "[db] task.completed event not emitted:",
3162
- scrubSecrets(err instanceof Error ? err.message : String(err)),
3163
- ),
3164
- );
3165
- });
3166
- try {
3167
- await promotePendingSteeringForTask(id, "Task completed before steering was delivered");
3168
- } catch (error) {
3169
- console.error(
3170
- "[completeTask] pending steering promotion error:",
3171
- scrubSecrets(error instanceof Error ? error.message : String(error)),
3172
- );
3173
- }
3174
- }
3175
-
3176
- return row ? rowToAgentTask(row) : null;
3177
- }
3178
-
3179
- export async function failTask(id: string, reason: string): Promise<AgentTask | null> {
3180
- const oldTask = await getTaskById(id);
3181
- if (!oldTask) return null;
3182
-
3183
- // Idempotency guard: don't re-fail a task already in a terminal state.
3184
- // Mirrors cancelTask / completeTask. Prevents duplicate task.failed events
3185
- // and duplicate follow-up tasks when multiple sessions race.
3186
- if (isTerminalTaskStatus(oldTask.status)) {
3187
- return null;
3188
- }
3189
-
3190
- const finishedAt = new Date().toISOString();
3191
- const scrubbedReason = scrubSecrets(reason);
3192
- // Status predicate re-checks the idempotency guard atomically (a racing
3193
- // terminal transition can land during the await above).
3194
- const row = await getDbClient().get<AgentTaskRow>(
3195
- `UPDATE agent_tasks SET status = 'failed', failureReason = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3196
- WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
3197
- [scrubbedReason, finishedAt, id],
3198
- );
3199
- if (row && oldTask) {
3200
- emitTaskLifecycleTelemetryAfterCommit(
3201
- "failed",
3202
- {
3203
- taskId: id,
3204
- source: oldTask.source,
3205
- ...taskContextForTelemetry(oldTask),
3206
- agentId: row.agentId ?? undefined,
3207
- durationMs: row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : undefined,
3208
- },
3209
- (task) => task?.status === "failed",
3210
- );
3211
-
3212
- try {
3213
- await createLogEntry({
3214
- eventType: "task_status_change",
3215
- taskId: id,
3216
- agentId: row.agentId ?? undefined,
3217
- oldValue: oldTask.status,
3218
- newValue: "failed",
3219
- metadata: { reason: scrubbedReason },
3220
- });
3221
- } catch {}
3222
- getDbClient().afterCommit(() => {
3223
- import("../workflows/event-bus")
3224
- .then(({ workflowEventBus }) => {
3225
- workflowEventBus.emit("task.failed", {
3226
- taskId: id,
3227
- failureReason: reason,
3228
- agentId: row.agentId,
3229
- workflowRunId: row.workflowRunId,
3230
- workflowRunStepId: row.workflowRunStepId,
3231
- });
3232
- })
3233
- .catch((err) =>
3234
- console.error(
3235
- "[db] task.failed event not emitted:",
3236
- scrubSecrets(err instanceof Error ? err.message : String(err)),
3237
- ),
3238
- );
3239
- });
3240
- try {
3241
- await promotePendingSteeringForTask(id, "Task failed before steering was delivered");
3242
- } catch (error) {
3243
- console.error(
3244
- "[failTask] pending steering promotion error:",
3245
- scrubSecrets(error instanceof Error ? error.message : String(error)),
3246
- );
3247
- }
3248
-
3249
- // Cascade-fail any non-terminal tasks that depend on this one.
3250
- // The cascade is recursive (transitive closure) and cycle-safe.
3251
- try {
3252
- await cascadeFailDependents(id, "failed");
3253
- } catch (err) {
3254
- console.error("[failTask] cascade-fail dependents error:", err);
3255
- }
3256
- }
3257
- return row ? rowToAgentTask(row) : null;
3258
- }
3259
-
3260
- /**
3261
- * Replace result text on an already-terminal task without replaying terminal
3262
- * side effects or moving any lifecycle timestamps. Callers must opt in to
3263
- * this narrow escape hatch; ordinary completion remains first-call-wins.
3264
- */
3265
- export async function overwriteTerminalTaskResultText(
3266
- id: string,
3267
- patch: { output?: string; failureReason?: string },
3268
- ): Promise<AgentTask | null> {
3269
- const task = await getTaskById(id);
3270
- if (!task || !isTerminalTaskStatus(task.status)) return null;
3271
-
3272
- const output = patch.output !== undefined ? scrubSecrets(patch.output) : (task.output ?? null);
3273
- const failureReason =
3274
- patch.failureReason !== undefined
3275
- ? scrubSecrets(patch.failureReason)
3276
- : (task.failureReason ?? null);
3277
- const row = await getDbClient().transaction(async () => {
3278
- const updated =
3279
- (await getDbClient().get<AgentTaskRow>(
3280
- `UPDATE agent_tasks SET output = ?, failureReason = ?
3281
- WHERE id = ? AND status IN ('completed', 'failed', 'cancelled', 'superseded')
3282
- RETURNING *`,
3283
- [output, failureReason, id],
3284
- )) ?? null;
3285
- if (updated && patch.output !== undefined) {
3286
- await reconcileTaskPullRequestAttachments(id, updated.agentId, [
3287
- updated.output,
3288
- updated.vcsProvider === "github" ? updated.vcsUrl : null,
3289
- ]);
3290
- }
3291
- return updated;
3292
- });
3293
-
3294
- return row ? rowToAgentTask(row) : task;
3295
- }
3296
-
3297
- export async function cancelTask(id: string, reason?: string): Promise<AgentTask | null> {
3298
- const oldTask = await getTaskById(id);
3299
- if (!oldTask) return null;
3300
-
3301
- // Only cancel tasks that are not already in a terminal state
3302
- if (isTerminalTaskStatus(oldTask.status)) {
3303
- return null;
3304
- }
3305
-
3306
- const finishedAt = new Date().toISOString();
3307
- const cancelReason = reason ?? "Cancelled by user";
3308
- // Status predicate re-checks the idempotency guard atomically (a racing
3309
- // terminal transition can land during the await above).
3310
- const row = await getDbClient().get<AgentTaskRow>(
3311
- `UPDATE agent_tasks SET status = 'cancelled', failureReason = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3312
- WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
3313
- [cancelReason, finishedAt, id],
3314
- );
3315
-
3316
- if (row && oldTask) {
3317
- emitTaskLifecycleTelemetryAfterCommit(
3318
- "cancelled",
3319
- {
3320
- taskId: id,
3321
- source: oldTask.source,
3322
- agentId: oldTask.agentId ?? undefined,
3323
- previousStatus: oldTask.status,
3324
- durationMs: oldTask.createdAt
3325
- ? Date.now() - new Date(oldTask.createdAt).getTime()
3326
- : undefined,
3327
- },
3328
- (task) => task?.status === "cancelled",
3329
- );
3330
-
3331
- try {
3332
- await createLogEntry({
3333
- eventType: "task_status_change",
3334
- taskId: id,
3335
- agentId: row.agentId ?? undefined,
3336
- oldValue: oldTask.status,
3337
- newValue: "cancelled",
3338
- metadata: reason ? { reason } : undefined,
3339
- });
3340
- } catch {}
3341
- getDbClient().afterCommit(() => {
3342
- import("../workflows/event-bus")
3343
- .then(({ workflowEventBus }) => {
3344
- workflowEventBus.emit("task.cancelled", {
3345
- taskId: id,
3346
- agentId: row.agentId,
3347
- workflowRunId: row.workflowRunId,
3348
- workflowRunStepId: row.workflowRunStepId,
3349
- });
3350
- })
3351
- .catch((err) =>
3352
- console.error(
3353
- "[db] task.cancelled event not emitted:",
3354
- scrubSecrets(err instanceof Error ? err.message : String(err)),
3355
- ),
3356
- );
3357
- });
3358
- try {
3359
- await promotePendingSteeringForTask(id, "Task was cancelled before steering was delivered");
3360
- } catch (error) {
3361
- console.error(
3362
- "[cancelTask] pending steering promotion error:",
3363
- scrubSecrets(error instanceof Error ? error.message : String(error)),
3364
- );
3365
- }
3366
-
3367
- try {
3368
- await cascadeFailDependents(id, "cancelled");
3369
- } catch (err) {
3370
- console.error("[cancelTask] cascade-fail dependents error:", err);
3371
- }
3372
- }
3373
-
3374
- return row ? rowToAgentTask(row) : null;
3375
- }
3376
-
3377
- /**
3378
- * Supersede a task: mark it as `superseded` (terminal) so a fresh "resume"
3379
- * follow-up task can pick up where it left off. Used by the graceful-shutdown
3380
- * path and the `POST /api/tasks/:id/supersede` route. Returns null if the task
3381
- * is already terminal (mirrors `completeTask` / `cancelTask` idempotency).
3382
- *
3383
- * Writes a `task_superseded` agent_log with `{ reason, resumeTaskId }` payload
3384
- * and emits a `task.superseded` workflow event. The caller is responsible for
3385
- * creating the resume follow-up (via `createResumeFollowUp`) and passing the
3386
- * resulting id as `resumeTaskId`.
3387
- */
3388
- export async function supersedeTask(
3389
- id: string,
3390
- args: { reason: string; resumeTaskId: string | null },
3391
- ): Promise<AgentTask | null> {
3392
- const oldTask = await getTaskById(id);
3393
- if (!oldTask) return null;
3394
-
3395
- // Idempotency guard: don't re-supersede a task already in a terminal state.
3396
- if (isTerminalTaskStatus(oldTask.status)) {
3397
- return null;
3398
- }
3399
-
3400
- const finishedAt = new Date().toISOString();
3401
- const row = await getDbClient().get<AgentTaskRow>(
3402
- `UPDATE agent_tasks
3403
- SET status = 'superseded',
3404
- finishedAt = ?,
3405
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3406
- WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
3407
- RETURNING *`,
3408
- [finishedAt, id],
3409
- );
3410
-
3411
- if (row && oldTask) {
3412
- emitTaskLifecycleTelemetryAfterCommit(
3413
- "superseded",
3414
- {
3415
- taskId: id,
3416
- source: oldTask.source,
3417
- ...taskContextForTelemetry(oldTask),
3418
- agentId: row.agentId ?? undefined,
3419
- reason: args.reason,
3420
- durationMs: oldTask.createdAt
3421
- ? Date.now() - new Date(oldTask.createdAt).getTime()
3422
- : undefined,
3423
- },
3424
- (task) => task?.status === "superseded",
3425
- );
3426
-
3427
- try {
3428
- await createLogEntry({
3429
- eventType: "task_superseded",
3430
- taskId: id,
3431
- agentId: row.agentId ?? undefined,
3432
- oldValue: oldTask.status,
3433
- newValue: "superseded",
3434
- metadata: { reason: args.reason, resumeTaskId: args.resumeTaskId },
3435
- });
3436
- } catch {}
3437
- getDbClient().afterCommit(() => {
3438
- import("../workflows/event-bus")
3439
- .then(({ workflowEventBus }) => {
3440
- workflowEventBus.emit("task.superseded", {
3441
- taskId: id,
3442
- reason: args.reason,
3443
- resumeTaskId: args.resumeTaskId,
3444
- agentId: row.agentId,
3445
- workflowRunId: row.workflowRunId,
3446
- workflowRunStepId: row.workflowRunStepId,
3447
- });
3448
- })
3449
- .catch((err) =>
3450
- console.error(
3451
- "[db] task.superseded event not emitted:",
3452
- scrubSecrets(err instanceof Error ? err.message : String(err)),
3453
- ),
3454
- );
3455
- });
3456
-
3457
- try {
3458
- await cascadeFailDependents(id, "superseded");
3459
- } catch (err) {
3460
- console.error("[supersedeTask] cascade-fail dependents error:", err);
3461
- }
3462
- }
3463
-
3464
- return row ? rowToAgentTask(row) : null;
3465
- }
3466
-
3467
- export async function backfillSupersedeTaskResumeTaskId(
3468
- taskId: string,
3469
- resumeTaskId: string,
3470
- ): Promise<boolean> {
3471
- const row = await getDbClient().get<{ id: string; metadata: string | null }>(
3472
- `SELECT id, metadata
3473
- FROM agent_log
3474
- WHERE taskId = ? AND eventType = 'task_superseded'
3475
- ORDER BY createdAt DESC
3476
- LIMIT 1`,
3477
- [taskId],
3478
- );
3479
- if (!row) return false;
3480
-
3481
- let metadata: Record<string, unknown> = {};
3482
- if (row.metadata) {
3483
- try {
3484
- metadata = JSON.parse(row.metadata) as Record<string, unknown>;
3485
- } catch {
3486
- metadata = {};
3487
- }
3488
- }
3489
- metadata.resumeTaskId = resumeTaskId;
3490
-
3491
- const result = await getDbClient().run("UPDATE agent_log SET metadata = ? WHERE id = ?", [
3492
- JSON.stringify(metadata),
3493
- row.id,
3494
- ]);
3495
- return result.changes > 0;
3496
- }
3497
-
3498
- /**
3499
- * Pause a task that is currently in progress.
3500
- * Used during graceful shutdown to allow tasks to resume after container restart.
3501
- * Unlike failTask, paused tasks retain their agent assignment and can be resumed.
3502
- */
3503
- export async function pauseTask(id: string): Promise<AgentTask | null> {
3504
- const oldTask = await getTaskById(id);
3505
- if (!oldTask) return null;
3506
-
3507
- // Only pause tasks that are in progress
3508
- if (oldTask.status !== "in_progress") {
3509
- return null;
3510
- }
3511
-
3512
- const row = await getDbClient().get<AgentTaskRow>(
3513
- `UPDATE agent_tasks
3514
- SET status = 'paused',
3515
- was_paused = 1,
3516
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3517
- WHERE id = ? AND status = 'in_progress'
612
+ updated_by = COALESCE(?, updated_by)
613
+ WHERE id = ?
3518
614
  RETURNING *`,
3519
- [id],
615
+ [
616
+ updates.permalink ?? null,
617
+ updates.finalized ? 1 : 0,
618
+ now,
619
+ updates.streamChunksAppended ?? null,
620
+ updates.conclusionKind ?? null,
621
+ updates.touchUpdatedAt === false ? 0 : 1,
622
+ now,
623
+ updates.actorId ?? null,
624
+ id,
625
+ ],
3520
626
  );
3521
-
3522
- if (row && oldTask) {
3523
- try {
3524
- await createLogEntry({
3525
- eventType: "task_status_change",
3526
- taskId: id,
3527
- agentId: row.agentId ?? undefined,
3528
- oldValue: oldTask.status,
3529
- newValue: "paused",
3530
- metadata: { pausedForShutdown: true },
3531
- });
3532
- } catch {}
3533
- }
3534
-
3535
- return row ? rowToAgentTask(row) : null;
627
+ return row ? rowToSlackMessage(row) : null;
3536
628
  }
3537
629
 
3538
- /**
3539
- * Resume a paused task - transitions it back to in_progress.
3540
- * Called when worker restarts and picks up paused work.
3541
- */
3542
- export async function resumeTask(taskId: string): Promise<AgentTask | null> {
3543
- const oldTask = await getTaskById(taskId);
3544
- if (!oldTask || oldTask.status !== "paused") return null;
3545
-
3546
- const row = await getDbClient().get<AgentTaskRow>(
3547
- `UPDATE agent_tasks
3548
- SET status = 'in_progress',
3549
- was_paused = 1,
3550
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3551
- WHERE id = ? AND status = 'paused'
630
+ export async function markSlackTreeRendered(
631
+ id: string,
632
+ renderedThrough: string,
633
+ ): Promise<SlackMessageRecord | null> {
634
+ const row = await getDbClient().get<SlackMessageRow>(
635
+ `UPDATE slack_messages SET updated_at = ?
636
+ WHERE id = ? AND kind = 'tree'
3552
637
  RETURNING *`,
3553
- [taskId],
638
+ [renderedThrough, id],
3554
639
  );
640
+ return row ? rowToSlackMessage(row) : null;
641
+ }
3555
642
 
3556
- if (row && oldTask) {
3557
- try {
3558
- await createLogEntry({
3559
- eventType: "task_status_change",
3560
- taskId,
3561
- agentId: row.agentId ?? undefined,
3562
- oldValue: "paused",
3563
- newValue: "in_progress",
3564
- metadata: { resumed: true },
3565
- });
3566
- } catch {}
3567
- }
643
+ export async function deleteSlackMessageRecord(id: string): Promise<boolean> {
644
+ const result = await getDbClient().run(`DELETE FROM slack_messages WHERE id = ?`, [id]);
645
+ return result.changes > 0;
646
+ }
3568
647
 
3569
- return row ? rowToAgentTask(row) : null;
648
+ export async function getSlackTreeMessage(contextKey: string): Promise<SlackMessageRecord | null> {
649
+ const row = await getDbClient().get<SlackMessageRow>(
650
+ `SELECT * FROM slack_messages WHERE context_key = ? AND kind = 'tree' LIMIT 1`,
651
+ [contextKey],
652
+ );
653
+ return row ? rowToSlackMessage(row) : null;
3570
654
  }
3571
655
 
3572
- /**
3573
- * Get paused tasks for a specific agent.
3574
- * Used on startup to resume tasks that were interrupted by deployment.
3575
- * Returns tasks ordered by creation time (oldest first for FIFO).
3576
- */
3577
- export async function getPausedTasksForAgent(agentId: string): Promise<AgentTask[]> {
3578
- const rows = await getDbClient().query<AgentTaskRow>(
3579
- `SELECT * FROM agent_tasks
3580
- WHERE agentId = ? AND status = 'paused'
3581
- ORDER BY createdAt ASC, rowid ASC`,
3582
- [agentId],
656
+ export async function getSlackTreeMessageByThread(
657
+ channelId: string,
658
+ threadTs: string,
659
+ ): Promise<SlackMessageRecord | null> {
660
+ const row = await getDbClient().get<SlackMessageRow>(
661
+ `SELECT * FROM slack_messages
662
+ WHERE channel_id = ? AND thread_ts = ? AND kind = 'tree'
663
+ LIMIT 1`,
664
+ [channelId, threadTs],
3583
665
  );
3584
- return rows.map(rowToAgentTask);
666
+ return row ? rowToSlackMessage(row) : null;
3585
667
  }
3586
668
 
3587
- export async function getOrphanedInProgressTasksForAgent(
3588
- agentId: string,
3589
- minAgeSeconds = 60,
3590
- ): Promise<AgentTask[]> {
3591
- const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
3592
- const rows = await getDbClient().query<AgentTaskRow>(
3593
- `SELECT t.* FROM agent_tasks t
3594
- LEFT JOIN active_sessions s ON s.taskId = t.id
3595
- WHERE t.agentId = ?
3596
- AND t.status = 'in_progress'
3597
- AND t.claudeSessionId IS NULL
3598
- AND t.lastUpdatedAt < ?
3599
- AND s.id IS NULL
3600
- AND t.finishedAt IS NULL
3601
- ORDER BY t.createdAt ASC, t.rowid ASC`,
3602
- [agentId, cutoff],
669
+ export async function getSlackOutcomeMessage(taskId: string): Promise<SlackMessageRecord | null> {
670
+ const row = await getDbClient().get<SlackMessageRow>(
671
+ `SELECT * FROM slack_messages WHERE task_id = ? AND kind = 'outcome' LIMIT 1`,
672
+ [taskId],
3603
673
  );
3604
- return rows.map(rowToAgentTask);
674
+ return row ? rowToSlackMessage(row) : null;
3605
675
  }
3606
676
 
3607
- export async function resetOrphanedInProgressTasksForAgent(
3608
- agentId: string,
3609
- minAgeSeconds = 60,
3610
- ): Promise<AgentTask[]> {
3611
- const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
3612
- const rows = await getDbClient().query<AgentTaskRow>(
3613
- `UPDATE agent_tasks
3614
- SET status = 'pending',
3615
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3616
- WHERE id IN (
3617
- SELECT t.id FROM agent_tasks t
3618
- LEFT JOIN active_sessions s ON s.taskId = t.id
3619
- WHERE t.agentId = ?
3620
- AND t.status = 'in_progress'
3621
- AND t.claudeSessionId IS NULL
3622
- AND t.lastUpdatedAt < ?
3623
- AND s.id IS NULL
3624
- AND t.finishedAt IS NULL
677
+ export async function getSlackTreeMessages(): Promise<SlackMessageRecord[]> {
678
+ const rows = await getDbClient().query<SlackMessageRow>(
679
+ `SELECT tree.*
680
+ FROM slack_messages tree
681
+ JOIN slack_render_v2_state state ON state.id = 1
682
+ WHERE tree.kind = 'tree'
683
+ AND (
684
+ (tree.ts LIKE 'pending:%' AND tree.created_at >= state.activated_at)
685
+ OR EXISTS (
686
+ SELECT 1 FROM agent_tasks task
687
+ WHERE task.slackChannelId = tree.channel_id
688
+ AND task.slackThreadTs = tree.thread_ts
689
+ AND task.source = 'slack'
690
+ AND task.createdAt >= state.activated_at
691
+ AND task.status IN ('completed', 'failed', 'cancelled')
692
+ AND NOT EXISTS (
693
+ SELECT 1 FROM slack_messages outcome
694
+ WHERE outcome.kind = 'outcome'
695
+ AND outcome.task_id = task.id
696
+ AND outcome.finalized_at IS NOT NULL
697
+ )
698
+ )
699
+ OR
700
+ EXISTS (
701
+ SELECT 1 FROM agent_tasks task
702
+ WHERE task.slackChannelId = tree.channel_id
703
+ AND task.slackThreadTs = tree.thread_ts
704
+ AND task.createdAt >= state.activated_at
705
+ AND task.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
706
+ )
707
+ OR EXISTS (
708
+ SELECT 1 FROM agent_tasks task
709
+ WHERE task.slackChannelId = tree.channel_id
710
+ AND task.slackThreadTs = tree.thread_ts
711
+ AND task.createdAt >= state.activated_at
712
+ AND task.lastUpdatedAt > tree.updated_at
713
+ )
714
+ OR EXISTS (
715
+ SELECT 1 FROM slack_messages outcome
716
+ JOIN agent_tasks task ON task.id = outcome.task_id
717
+ WHERE outcome.kind = 'outcome'
718
+ AND outcome.channel_id = tree.channel_id
719
+ AND outcome.thread_ts = tree.thread_ts
720
+ AND task.createdAt >= state.activated_at
721
+ AND (
722
+ outcome.finalized_at IS NULL
723
+ OR outcome.updated_at > tree.updated_at
724
+ )
725
+ )
3625
726
  )
3626
- RETURNING *`,
3627
- [agentId, cutoff],
727
+ ORDER BY tree.created_at ASC`,
3628
728
  );
729
+ return rows.map(rowToSlackMessage);
730
+ }
3629
731
 
3630
- for (const row of rows) {
3631
- try {
3632
- await createLogEntry({
3633
- eventType: "task_status_change",
3634
- taskId: row.id,
3635
- agentId,
3636
- oldValue: "in_progress",
3637
- newValue: "pending",
3638
- metadata: { orphanedInProgressRecovery: true },
3639
- });
3640
- } catch {}
3641
- }
3642
-
3643
- return rows.map(rowToAgentTask);
732
+ export async function getSlackMessageByChannelTs(
733
+ channelId: string,
734
+ ts: string,
735
+ ): Promise<SlackMessageRecord | null> {
736
+ const row = await getDbClient().get<SlackMessageRow>(
737
+ `SELECT * FROM slack_messages WHERE channel_id = ? AND ts = ? LIMIT 1`,
738
+ [channelId, ts],
739
+ );
740
+ return row ? rowToSlackMessage(row) : null;
3644
741
  }
3645
742
 
3646
- /**
3647
- * Get recently cancelled tasks for an agent.
3648
- * Used by hooks to detect task cancellation and stop the worker loop.
3649
- * Returns tasks cancelled within the last 5 minutes.
3650
- */
3651
- export async function getRecentlyCancelledTasksForAgent(agentId: string): Promise<AgentTask[]> {
3652
- const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
743
+ export async function getSlackTasksInThread(
744
+ channelId: string,
745
+ threadTs: string,
746
+ ): Promise<AgentTask[]> {
3653
747
  const rows = await getDbClient().query<AgentTaskRow>(
3654
748
  `SELECT * FROM agent_tasks
3655
- WHERE agentId = ?
3656
- AND status = 'cancelled'
3657
- AND finishedAt > ?
3658
- ORDER BY finishedAt DESC`,
3659
- [agentId, fiveMinutesAgo],
749
+ WHERE slackChannelId = ? AND slackThreadTs = ?
750
+ ORDER BY createdAt ASC, rowid ASC`,
751
+ [channelId, threadTs],
3660
752
  );
3661
753
  return rows.map(rowToAgentTask);
3662
754
  }
3663
755
 
3664
- export async function deleteTask(id: string): Promise<boolean> {
3665
- const result = await getDbClient().run("DELETE FROM agent_tasks WHERE id = ?", [id]);
3666
- return result.changes > 0;
756
+ export async function markTaskSlackReplySent(taskId: string): Promise<void> {
757
+ await getDbClient().run(`UPDATE agent_tasks SET slackReplySent = 1 WHERE id = ?`, [taskId]);
3667
758
  }
3668
759
 
3669
- export async function updateTaskProgress(id: string, progress: string): Promise<AgentTask | null> {
3670
- const scrubbedProgress = scrubSecrets(progress);
3671
- const row = await getDbClient().get<AgentTaskRow>(
3672
- `UPDATE agent_tasks SET progress = ?,
3673
- status = CASE WHEN status IN ('completed', 'failed', 'cancelled', 'superseded') THEN status ELSE 'in_progress' END,
3674
- lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3675
- WHERE id = ? RETURNING *`,
3676
- [scrubbedProgress, id],
3677
- );
3678
- if (row) {
3679
- try {
3680
- await createLogEntry({
3681
- eventType: "task_progress",
3682
- taskId: id,
3683
- agentId: row.agentId ?? undefined,
3684
- newValue: scrubbedProgress,
3685
- });
3686
- } catch {}
3687
- getDbClient().afterCommit(() => {
3688
- import("../workflows/event-bus")
3689
- .then(({ workflowEventBus }) => {
3690
- workflowEventBus.emit("task.progress", {
3691
- taskId: id,
3692
- progress: scrubbedProgress,
3693
- agentId: row.agentId,
3694
- });
3695
- })
3696
- .catch((err) =>
3697
- console.error(
3698
- "[db] task.progress event not emitted:",
3699
- scrubSecrets(err instanceof Error ? err.message : String(err)),
3700
- ),
3701
- );
3702
- });
760
+ export async function setSlackMessageTracking(
761
+ taskId: string,
762
+ fields: {
763
+ slackProgressMessageTs?: string | null;
764
+ slackTreeRootMessageTs?: string | null;
765
+ },
766
+ ): Promise<void> {
767
+ const sets: string[] = [];
768
+ const args: (string | null)[] = [];
769
+
770
+ if (Object.hasOwn(fields, "slackProgressMessageTs")) {
771
+ sets.push("slackProgressMessageTs = ?");
772
+ args.push(fields.slackProgressMessageTs ?? null);
773
+ }
774
+ if (Object.hasOwn(fields, "slackTreeRootMessageTs")) {
775
+ sets.push("slackTreeRootMessageTs = ?");
776
+ args.push(fields.slackTreeRootMessageTs ?? null);
3703
777
  }
778
+ if (sets.length === 0) return;
779
+
780
+ args.push(taskId);
781
+ await getDbClient().run(`UPDATE agent_tasks SET ${sets.join(", ")} WHERE id = ?`, args);
782
+ }
783
+
784
+ export async function updateTaskVcs(
785
+ taskId: string,
786
+ vcs: {
787
+ vcsProvider: "github" | "gitlab";
788
+ vcsRepo: string;
789
+ vcsNumber: number;
790
+ vcsUrl: string;
791
+ },
792
+ ): Promise<AgentTask | null> {
793
+ const row = await getDbClient().transaction(async () => {
794
+ const updated = await getDbClient().get<AgentTaskRow>(
795
+ `UPDATE agent_tasks
796
+ SET vcsProvider = ?, vcsRepo = ?, vcsNumber = ?, vcsUrl = ?, lastUpdatedAt = ?
797
+ WHERE id = ? RETURNING *`,
798
+ [vcs.vcsProvider, vcs.vcsRepo, vcs.vcsNumber, vcs.vcsUrl, new Date().toISOString(), taskId],
799
+ );
800
+ if (updated) {
801
+ await reconcileTaskPullRequestAttachments(taskId, updated.agentId, [
802
+ updated.output,
803
+ vcs.vcsProvider === "github" ? vcs.vcsUrl : null,
804
+ ]);
805
+ }
806
+ return updated;
807
+ });
3704
808
  return row ? rowToAgentTask(row) : null;
3705
809
  }
3706
810
 
@@ -5709,6 +2813,18 @@ export async function promoteDraftTask(taskId: string): Promise<AgentTask | null
5709
2813
  return row ? rowToAgentTask(row) : null;
5710
2814
  }
5711
2815
 
2816
+ /**
2817
+ * Mark a draft as still being worked on, so `promoteAbandonedDraftTasks`
2818
+ * (which goes by `lastUpdatedAt`) doesn't promote it while an upload batch
2819
+ * that outlasts the sweep window is still running. No-op once promoted.
2820
+ */
2821
+ export async function refreshDraftTaskLease(taskId: string): Promise<void> {
2822
+ await getDbClient().run(
2823
+ "UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ? AND status = 'draft'",
2824
+ [new Date().toISOString(), taskId],
2825
+ );
2826
+ }
2827
+
5712
2828
  /**
5713
2829
  * Heartbeat sweep: promote drafts abandoned mid-upload (tab closed, network
5714
2830
  * dropped) instead of leaving them permanently dispatch-ineligible — the
@@ -6040,6 +3156,12 @@ export async function updateAgentProfile(
6040
3156
  avatar?: AgentAvatar | null;
6041
3157
  },
6042
3158
  meta?: VersionMeta,
3159
+ guard?: {
3160
+ /** Per-field compare-and-set token; see {@link ProfileExpectedHashes}. */
3161
+ expectedHashes?: ProfileExpectedHashes;
3162
+ /** Called once per field dropped because its expected hash was stale. */
3163
+ onConflict?: (conflict: ProfileSyncConflict) => void;
3164
+ },
6043
3165
  ): Promise<Agent | null> {
6044
3166
  return await getDbClient().transaction(async (tx) => {
6045
3167
  // Get current agent state for version comparison
@@ -6078,8 +3200,39 @@ export async function updateAgentProfile(
6078
3200
  }
6079
3201
  }
6080
3202
 
3203
+ // Compare-and-set. A writer that edits a copy of a field (a session's
3204
+ // materialized CLAUDE.md) sends the hash that copy was based on. If the DB
3205
+ // moved since — a sibling session's sync, an `update-profile` — the copy is
3206
+ // stale and writing it would revert the newer value, so the field is dropped
3207
+ // (no version, no column write). An edit on top of the current value applies,
3208
+ // including a deliberate revert to an earlier version. Without a token the
3209
+ // write is unconditional, as before.
3210
+ //
3211
+ // Runs BEFORE the budget check: a stale copy of an older, longer value must be
3212
+ // dropped, not rejected — a budget rejection throws and would also roll back
3213
+ // the other fields of the same update.
3214
+ const writable = { ...updates };
3215
+ for (const field of VERSIONABLE_FIELDS) {
3216
+ const expectedHash = guard?.expectedHashes?.[field];
3217
+ if (expectedHash === undefined) continue;
3218
+ if (writable[field] === undefined || writable[field] === null) continue;
3219
+
3220
+ const currentHash = computeContentHash(current[field] ?? "");
3221
+ if (currentHash === expectedHash) continue;
3222
+
3223
+ console.warn(
3224
+ `[profile-sync] agent ${id}: ${field} dropped — based on ${expectedHash.slice(0, 12)}, DB is at ${currentHash.slice(0, 12)}`,
3225
+ );
3226
+ delete writable[field];
3227
+ guard?.onConflict?.({ field, expectedHash, currentHash });
3228
+ }
3229
+
3230
+ if (writable.heartbeatMd !== undefined) {
3231
+ validateHeartbeatExpiry(current.heartbeatMd ?? "", writable.heartbeatMd);
3232
+ }
3233
+
6081
3234
  for (const field of BUDGETED_IDENTITY_FIELDS) {
6082
- const nextValue = updates[field];
3235
+ const nextValue = writable[field];
6083
3236
  if (nextValue === undefined) continue;
6084
3237
 
6085
3238
  const result = checkIdentityFieldBudget({
@@ -6104,9 +3257,9 @@ export async function updateAgentProfile(
6104
3257
  if (existingAgent) throw new Error("Agent name already exists");
6105
3258
  }
6106
3259
 
6107
- // Create context versions for changed fields
3260
+ // Create context versions for changed fields (stale copies already dropped)
6108
3261
  for (const field of VERSIONABLE_FIELDS) {
6109
- const newValue = updates[field];
3262
+ const newValue = writable[field];
6110
3263
  if (newValue === undefined || newValue === null) continue;
6111
3264
 
6112
3265
  const currentValue = current[field] ?? "";
@@ -6163,12 +3316,12 @@ export async function updateAgentProfile(
6163
3316
  updates.description ?? null,
6164
3317
  updates.role ?? null,
6165
3318
  updates.capabilities ? JSON.stringify(updates.capabilities) : null,
6166
- updates.claudeMd ?? null,
6167
- updates.soulMd ?? null,
6168
- updates.identityMd ?? null,
6169
- updates.setupScript ?? null,
6170
- updates.toolsMd ?? null,
6171
- updates.heartbeatMd ?? null,
3319
+ writable.claudeMd ?? null,
3320
+ writable.soulMd ?? null,
3321
+ writable.identityMd ?? null,
3322
+ writable.setupScript ?? null,
3323
+ writable.toolsMd ?? null,
3324
+ writable.heartbeatMd ?? null,
6172
3325
  avatarProvided ? 1 : 0,
6173
3326
  avatarJson,
6174
3327
  now,
@@ -8188,6 +5341,7 @@ type ScheduledTaskRow = {
8188
5341
  lastRunAt: string | null;
8189
5342
  nextRunAt: string | null;
8190
5343
  createdByAgentId: string | null;
5344
+ parentTaskId: string | null;
8191
5345
  timezone: string;
8192
5346
  consecutiveErrors: number | null;
8193
5347
  lastErrorAt: string | null;
@@ -8214,9 +5368,6 @@ type ScheduledTaskRow = {
8214
5368
  // while keeping list payloads small.
8215
5369
  /** Preview length for a schedule's `taskTemplate`. */
8216
5370
  const SCHEDULE_TEMPLATE_PREVIEW_LENGTH = 280;
8217
- /** Preview length for a task's `task` text (pool-triage needs to read it). */
8218
- const TASK_PREVIEW_LENGTH = 300;
8219
-
8220
5371
  /** Truncate text for a list-row preview. Appends an ellipsis when clipped. */
8221
5372
  function previewText(text: string | null | undefined, maxChars: number): string {
8222
5373
  const s = text ?? "";
@@ -8240,6 +5391,7 @@ function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
8240
5391
  lastRunAt: normalizeDate(row.lastRunAt) ?? undefined,
8241
5392
  nextRunAt: normalizeDate(row.nextRunAt) ?? undefined,
8242
5393
  createdByAgentId: row.createdByAgentId ?? undefined,
5394
+ parentTaskId: row.parentTaskId ?? undefined,
8243
5395
  timezone: row.timezone,
8244
5396
  consecutiveErrors: row.consecutiveErrors ?? 0,
8245
5397
  lastErrorAt: normalizeDate(row.lastErrorAt) ?? undefined,
@@ -8389,6 +5541,8 @@ export interface CreateScheduledTaskData {
8389
5541
  enabled?: boolean;
8390
5542
  nextRunAt?: string;
8391
5543
  createdByAgentId?: string;
5544
+ /** Only `defer-task` sets this — the task the schedule's run continues. */
5545
+ parentTaskId?: string;
8392
5546
  timezone?: string;
8393
5547
  model?: string;
8394
5548
  modelTier?: ModelTier;
@@ -8411,11 +5565,11 @@ export async function createScheduledTask(data: CreateScheduledTaskData): Promis
8411
5565
  `INSERT INTO scheduled_tasks (
8412
5566
  id, "key", name, description, cronExpression, intervalMs, taskTemplate,
8413
5567
  taskType, tags, priority, targetAgentId, enabled, nextRunAt,
8414
- createdByAgentId, timezone, model, modelTier, scheduleType, targetType,
8415
- workflowId, scriptName, scriptArgs, params, requiredParams, requires,
5568
+ createdByAgentId, parentTaskId, timezone, model, modelTier, scheduleType,
5569
+ targetType, workflowId, scriptName, scriptArgs, params, requiredParams, requires,
8416
5570
  createdAt, lastUpdatedAt,
8417
5571
  created_by, updated_by
8418
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
5572
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
8419
5573
  [
8420
5574
  id,
8421
5575
  normalizeAssetKey(data.key ?? defaultAssetKey("schedule", id)),
@@ -8431,6 +5585,7 @@ export async function createScheduledTask(data: CreateScheduledTaskData): Promis
8431
5585
  data.enabled !== false ? 1 : 0,
8432
5586
  data.nextRunAt ?? null,
8433
5587
  data.createdByAgentId ?? null,
5588
+ data.parentTaskId ?? null,
8434
5589
  data.timezone ?? "UTC",
8435
5590
  data.model ?? null,
8436
5591
  data.modelTier ?? null,
@@ -8867,7 +6022,7 @@ export async function getInjectableGlobalConfigs(): Promise<SwarmConfig[]> {
8867
6022
  const rows = await getDbClient().query<SwarmConfigRow>(
8868
6023
  `SELECT * FROM swarm_config
8869
6024
  WHERE scope = 'global'
8870
- AND UPPER(key) NOT IN ('API_KEY', 'SECRETS_ENCRYPTION_KEY')
6025
+ AND UPPER(key) NOT IN ('API_KEY', 'SECRETS_ENCRYPTION_KEY', 'CORS_ALLOW_ANY_ORIGIN')
8871
6026
  ORDER BY key ASC`,
8872
6027
  );
8873
6028
  return rows.map(rowToSwarmConfig);