@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
@@ -0,0 +1,1167 @@
1
+ import { normalizeAssetKey } from "../../../assets/key";
2
+ import type {
3
+ Agent,
4
+ AgentTask,
5
+ AgentTaskSource,
6
+ AgentTaskStatus,
7
+ AgentTaskSummary,
8
+ FollowUpConfig,
9
+ ProviderName,
10
+ ReasoningEffort,
11
+ RoutingAffinity,
12
+ } from "../../../types";
13
+ import {
14
+ FollowUpConfigSchema,
15
+ parseModelTier,
16
+ ReasoningEffortSchema,
17
+ RoutingAffinitySchema,
18
+ } from "../../../types";
19
+ import { parseProviderMeta } from "../../../utils/provider-metadata";
20
+ import { getAgentById } from "../agents";
21
+ import { getDbClient } from "../runtime";
22
+
23
+ type TaskReadDependencies = {
24
+ checkDependencies: (taskId: string) => Promise<{ ready: boolean; blockedBy: string[] }>;
25
+ assetKeyPrefixPattern: (input: string) => string;
26
+ previewText: (text: string | null | undefined, maxChars: number) => string;
27
+ };
28
+
29
+ let dependencies: TaskReadDependencies;
30
+
31
+ // The facade registers deferred calls to helpers it still owns.
32
+ export function configureTaskReadDependencies(value: TaskReadDependencies): void {
33
+ dependencies = value;
34
+ }
35
+
36
+ /** Preview length for a task's `task` text (pool-triage needs to read it). */
37
+ const TASK_PREVIEW_LENGTH = 300;
38
+
39
+ export type AgentTaskRow = {
40
+ id: string;
41
+ key: string;
42
+ agentId: string | null;
43
+ creatorAgentId: string | null;
44
+ task: string;
45
+ title: string | null;
46
+ status: AgentTaskStatus;
47
+ source: AgentTaskSource;
48
+ taskType: string | null;
49
+ tags: string | null;
50
+ priority: number;
51
+ dependsOn: string | null;
52
+ offeredTo: string | null;
53
+ offeredAt: string | null;
54
+ acceptedAt: string | null;
55
+ rejectionReason: string | null;
56
+ slackChannelId: string | null;
57
+ slackThreadTs: string | null;
58
+ slackTriggerMessageTs: string | null;
59
+ slackUserId: string | null;
60
+ slackReplySent: number;
61
+ slackProgressMessageTs: string | null;
62
+ slackTreeRootMessageTs: string | null;
63
+ vcsProvider: string | null;
64
+ vcsRepo: string | null;
65
+ vcsEventType: string | null;
66
+ vcsNumber: number | null;
67
+ vcsCommentId: number | null;
68
+ vcsAuthor: string | null;
69
+ vcsUrl: string | null;
70
+ vcsInstallationId: number | null;
71
+ vcsNodeId: string | null;
72
+ agentmailInboxId: string | null;
73
+ agentmailMessageId: string | null;
74
+ agentmailThreadId: string | null;
75
+ mentionMessageId: string | null;
76
+ mentionChannelId: string | null;
77
+ dir: string | null;
78
+ parentTaskId: string | null;
79
+ claudeSessionId: string | null;
80
+ model: string | null;
81
+ modelTier: string | null;
82
+ effort: string | null;
83
+ scheduleId: string | null;
84
+ workflowRunId: string | null;
85
+ workflowRunStepId: string | null;
86
+ outputSchema: string | null;
87
+ followUpConfig: string | null;
88
+ contextKey: string | null;
89
+ createdAt: string;
90
+ lastUpdatedAt: string;
91
+ finishedAt: string | null;
92
+ notifiedAt: string | null;
93
+ failureReason: string | null;
94
+ output: string | null;
95
+ progress: string | null;
96
+ compactionCount: number | null;
97
+ peakContextPercent: number | null;
98
+ peakContextTokens: number | null;
99
+ contextWindowSize: number | null;
100
+ was_paused: number;
101
+ credentialKeySuffix: string | null;
102
+ credentialKeyType: string | null;
103
+ requestedByUserId: string | null;
104
+ requestedByUserIdInherited: number;
105
+ swarmVersion: string | null;
106
+ provider: string | null;
107
+ providerMeta: string | null;
108
+ harnessVariant: string | null;
109
+ harnessVariantMeta: string | null;
110
+ totalCostUsd?: number | null;
111
+ routingAffinity: string | null;
112
+ };
113
+
114
+ export function rowToAgentTask(row: AgentTaskRow): AgentTask {
115
+ let followUpConfig: FollowUpConfig | undefined;
116
+ if (row.followUpConfig) {
117
+ try {
118
+ const parsed = FollowUpConfigSchema.safeParse(JSON.parse(row.followUpConfig));
119
+ if (parsed.success) {
120
+ followUpConfig = parsed.data;
121
+ } else {
122
+ console.warn(
123
+ `[db] Ignoring invalid agent_tasks.followUpConfig for task ${row.id}:`,
124
+ parsed.error.message,
125
+ );
126
+ }
127
+ } catch (error) {
128
+ console.warn(
129
+ `[db] Ignoring malformed agent_tasks.followUpConfig for task ${row.id}:`,
130
+ error instanceof Error ? error.message : String(error),
131
+ );
132
+ followUpConfig = undefined;
133
+ }
134
+ }
135
+
136
+ let routingAffinity: RoutingAffinity | undefined;
137
+ let routingAffinityInvalid = false;
138
+ if (row.routingAffinity) {
139
+ try {
140
+ const parsed = RoutingAffinitySchema.safeParse(JSON.parse(row.routingAffinity));
141
+ if (parsed.success) {
142
+ routingAffinity = parsed.data;
143
+ } else {
144
+ routingAffinityInvalid = true;
145
+ console.warn(
146
+ `[db] Quarantining invalid agent_tasks.routingAffinity for task ${row.id}:`,
147
+ parsed.error.message,
148
+ );
149
+ }
150
+ } catch (error) {
151
+ routingAffinityInvalid = true;
152
+ console.warn(
153
+ `[db] Quarantining malformed agent_tasks.routingAffinity for task ${row.id}:`,
154
+ error instanceof Error ? error.message : String(error),
155
+ );
156
+ }
157
+ }
158
+
159
+ return {
160
+ id: row.id,
161
+ key: row.key,
162
+ agentId: row.agentId,
163
+ creatorAgentId: row.creatorAgentId ?? undefined,
164
+ task: row.task,
165
+ title: row.title ?? undefined,
166
+ status: row.status,
167
+ source: row.source,
168
+ taskType: row.taskType ?? undefined,
169
+ tags: row.tags ? JSON.parse(row.tags) : [],
170
+ priority: row.priority ?? 50,
171
+ dependsOn: row.dependsOn ? JSON.parse(row.dependsOn) : [],
172
+ offeredTo: row.offeredTo ?? undefined,
173
+ offeredAt: row.offeredAt ?? undefined,
174
+ acceptedAt: row.acceptedAt ?? undefined,
175
+ rejectionReason: row.rejectionReason ?? undefined,
176
+ slackChannelId: row.slackChannelId ?? undefined,
177
+ slackThreadTs: row.slackThreadTs ?? undefined,
178
+ slackTriggerMessageTs: row.slackTriggerMessageTs ?? undefined,
179
+ slackUserId: row.slackUserId ?? undefined,
180
+ slackReplySent: !!row.slackReplySent,
181
+ slackProgressMessageTs: row.slackProgressMessageTs ?? undefined,
182
+ slackTreeRootMessageTs: row.slackTreeRootMessageTs ?? undefined,
183
+ vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
184
+ vcsRepo: row.vcsRepo ?? undefined,
185
+ vcsEventType: row.vcsEventType ?? undefined,
186
+ vcsNumber: row.vcsNumber ?? undefined,
187
+ vcsCommentId: row.vcsCommentId ?? undefined,
188
+ vcsAuthor: row.vcsAuthor ?? undefined,
189
+ vcsUrl: row.vcsUrl ?? undefined,
190
+ vcsInstallationId: row.vcsInstallationId ?? undefined,
191
+ vcsNodeId: row.vcsNodeId ?? undefined,
192
+ agentmailInboxId: row.agentmailInboxId ?? undefined,
193
+ agentmailMessageId: row.agentmailMessageId ?? undefined,
194
+ agentmailThreadId: row.agentmailThreadId ?? undefined,
195
+ mentionMessageId: row.mentionMessageId ?? undefined,
196
+ mentionChannelId: row.mentionChannelId ?? undefined,
197
+ dir: row.dir ?? undefined,
198
+ parentTaskId: row.parentTaskId ?? undefined,
199
+ claudeSessionId: row.claudeSessionId ?? undefined,
200
+ model: row.model ?? undefined,
201
+ modelTier: parseModelTier(row.modelTier) ?? undefined,
202
+ effort: ReasoningEffortSchema.safeParse(row.effort).success
203
+ ? (row.effort as ReasoningEffort)
204
+ : undefined,
205
+ scheduleId: row.scheduleId ?? undefined,
206
+ workflowRunId: row.workflowRunId ?? undefined,
207
+ workflowRunStepId: row.workflowRunStepId ?? undefined,
208
+ outputSchema: row.outputSchema ? JSON.parse(row.outputSchema) : undefined,
209
+ followUpConfig,
210
+ contextKey: row.contextKey ?? undefined,
211
+ compactionCount: row.compactionCount ?? undefined,
212
+ peakContextPercent: row.peakContextPercent ?? undefined,
213
+ peakContextTokens: row.peakContextTokens ?? undefined,
214
+ contextWindowSize: row.contextWindowSize ?? undefined,
215
+ createdAt: row.createdAt,
216
+ lastUpdatedAt: row.lastUpdatedAt,
217
+ finishedAt: row.finishedAt ?? undefined,
218
+ notifiedAt: row.notifiedAt ?? undefined,
219
+ failureReason: row.failureReason ?? undefined,
220
+ output: row.output ?? undefined,
221
+ progress: row.progress ?? undefined,
222
+ wasPaused: !!row.was_paused,
223
+ credentialKeySuffix: row.credentialKeySuffix ?? undefined,
224
+ credentialKeyType: row.credentialKeyType ?? undefined,
225
+ requestedByUserId: row.requestedByUserId ?? undefined,
226
+ swarmVersion: row.swarmVersion ?? undefined,
227
+ provider: (row.provider as ProviderName | null) ?? undefined,
228
+ providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
229
+ harnessVariant: row.harnessVariant ?? undefined,
230
+ harnessVariantMeta: row.harnessVariantMeta ? JSON.parse(row.harnessVariantMeta) : undefined,
231
+ totalCostUsd: row.totalCostUsd ?? undefined,
232
+ routingAffinity,
233
+ routingAffinityInvalid: routingAffinityInvalid || undefined,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Slim list-row mapper — truncates the `task` text to a bounded preview and
239
+ * drops completion/integration/context blobs (`output`, `failureReason`,
240
+ * `providerMeta`, all `vcs*`/`slack*`/`agentmail*`/`credential*`/`mention*` and
241
+ * context-window fields). The preview is long enough for pool-triage; the full
242
+ * brief is on `get-task-details` / `GET /api/tasks/{id}`.
243
+ */
244
+ export function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
245
+ const t = rowToAgentTask(row);
246
+ return {
247
+ id: t.id,
248
+ key: t.key,
249
+ agentId: t.agentId,
250
+ creatorAgentId: t.creatorAgentId,
251
+ task: dependencies.previewText(t.task, TASK_PREVIEW_LENGTH),
252
+ title: t.title,
253
+ status: t.status,
254
+ source: t.source,
255
+ taskType: t.taskType,
256
+ tags: t.tags,
257
+ priority: t.priority,
258
+ dependsOn: t.dependsOn,
259
+ offeredTo: t.offeredTo,
260
+ acceptedAt: t.acceptedAt,
261
+ parentTaskId: t.parentTaskId,
262
+ scheduleId: t.scheduleId,
263
+ model: t.model,
264
+ modelTier: t.modelTier,
265
+ effort: t.effort,
266
+ provider: t.provider,
267
+ requestedByUserId: t.requestedByUserId,
268
+ progress: t.progress,
269
+ createdAt: t.createdAt,
270
+ lastUpdatedAt: t.lastUpdatedAt,
271
+ finishedAt: t.finishedAt,
272
+ peakContextPercent: t.peakContextPercent,
273
+ totalCostUsd: t.totalCostUsd,
274
+ };
275
+ }
276
+
277
+ export async function getTaskById(id: string): Promise<AgentTask | null> {
278
+ const row = await getDbClient().get<AgentTaskRow>("SELECT * FROM agent_tasks WHERE id = ?", [id]);
279
+ return row ? rowToAgentTask(row) : null;
280
+ }
281
+
282
+ export async function getChildTasks(parentTaskId: string): Promise<AgentTask[]> {
283
+ const rows = await getDbClient().query<AgentTaskRow>(
284
+ `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC, rowid ASC`,
285
+ [parentTaskId],
286
+ );
287
+ return rows.map(rowToAgentTask);
288
+ }
289
+
290
+ /**
291
+ * Returns true if `parentId` has at least one non-terminal child task with
292
+ * `taskType = 'resume'`. Used by the heartbeat sweep as an idempotency guard:
293
+ * if a prior sweep tick already created a resume follow-up for this parent,
294
+ * don't create a duplicate.
295
+ *
296
+ * **Filters by taskType = 'resume'** specifically. A parent task can also
297
+ * have ordinary non-terminal delegation children (`send-task` auto-defaults
298
+ * `parentTaskId` to the caller's current task — see src/tools/send-task.ts).
299
+ * Treating those as "already resumed" would incorrectly skip the resume
300
+ * path for a crashed worker that had delegated subtasks (PR #594 review).
301
+ */
302
+ export async function hasNonTerminalResumeChild(parentId: string): Promise<boolean> {
303
+ const row = await getDbClient().get(
304
+ `SELECT 1 FROM agent_tasks
305
+ WHERE parentTaskId = ?
306
+ AND taskType = 'resume'
307
+ AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
308
+ LIMIT 1`,
309
+ [parentId],
310
+ );
311
+ return row !== undefined && row !== null;
312
+ }
313
+
314
+ /**
315
+ * True when a non-terminal `reroute-decision` child exists for `parentId`.
316
+ *
317
+ * Mirrors {@link hasNonTerminalResumeChild} but filters on
318
+ * `taskType = 'reroute-decision'` — the Lead-owned re-delegation decision
319
+ * created when a pinned crash-recovery resume is never reclaimed (DES-523).
320
+ * Makes escalation idempotent: a later heartbeat sweep must not create a second
321
+ * decision for the same original task. We filter on the taskType marker
322
+ * specifically (not any child) so ordinary delegation / completion follow-up
323
+ * children of the original cannot suppress a needed decision, and nothing else
324
+ * is mistaken for one.
325
+ */
326
+ export async function hasNonTerminalRerouteDecisionChild(parentId: string): Promise<boolean> {
327
+ const row = await getDbClient().get<Record<string, number>>(
328
+ `SELECT 1 FROM agent_tasks
329
+ WHERE parentTaskId = ?
330
+ AND taskType = 'reroute-decision'
331
+ AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
332
+ LIMIT 1`,
333
+ [parentId],
334
+ );
335
+ return row !== undefined && row !== null;
336
+ }
337
+
338
+ export async function getTasksByAgentId(agentId: string): Promise<AgentTask[]> {
339
+ const rows = await getDbClient().query<AgentTaskRow>(
340
+ "SELECT * FROM agent_tasks WHERE agentId = ? ORDER BY createdAt DESC",
341
+ [agentId],
342
+ );
343
+ return rows.map(rowToAgentTask);
344
+ }
345
+
346
+ /**
347
+ * Get the most recently updated in-progress task for an agent.
348
+ * Used as a fallback when X-Source-Task-Id header is missing (e.g. lead agent HITL requests).
349
+ *
350
+ * Note: if agent has multiple in-progress tasks, returns the most recently
351
+ * updated one. This is a best-effort fallback — the X-Source-Task-Id header
352
+ * is the authoritative source when available.
353
+ */
354
+ export async function getAgentCurrentTask(agentId: string): Promise<AgentTask | null> {
355
+ const row = await getDbClient().get<AgentTaskRow>(
356
+ "SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'in_progress' ORDER BY lastUpdatedAt DESC LIMIT 1",
357
+ [agentId],
358
+ );
359
+ return row ? rowToAgentTask(row) : null;
360
+ }
361
+
362
+ export async function getTasksByStatus(status: AgentTaskStatus): Promise<AgentTask[]> {
363
+ const rows = await getDbClient().query<AgentTaskRow>(
364
+ "SELECT * FROM agent_tasks WHERE status = ? ORDER BY createdAt DESC",
365
+ [status],
366
+ );
367
+ return rows.map(rowToAgentTask);
368
+ }
369
+
370
+ /**
371
+ * Find a task by VCS repo and issue/PR/MR number.
372
+ * Returns the most recent non-terminal task for this VCS entity.
373
+ *
374
+ * Terminal exclusion MUST stay in lock-step with `TERMINAL_TASK_STATUSES`
375
+ * in `src/types.ts`. SQL strings can't import a TS const — if you add a
376
+ * new terminal status, grep for `NOT IN ('completed'` across this file.
377
+ */
378
+ export async function findTaskByVcs(vcsRepo: string, vcsNumber: number): Promise<AgentTask | null> {
379
+ const row = await getDbClient().get<AgentTaskRow>(
380
+ `SELECT * FROM agent_tasks
381
+ WHERE vcsRepo = ? AND vcsNumber = ?
382
+ AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
383
+ ORDER BY createdAt DESC
384
+ LIMIT 1`,
385
+ [vcsRepo, vcsNumber],
386
+ );
387
+ return row ? rowToAgentTask(row) : null;
388
+ }
389
+
390
+ /** @deprecated Use findTaskByVcs instead */
391
+ export const findTaskByGitHub = findTaskByVcs;
392
+
393
+ export interface TaskFilters {
394
+ /** Single status (back-compat) OR array of statuses (multi-status filter). */
395
+ status?: AgentTaskStatus | AgentTaskStatus[];
396
+ agentId?: string;
397
+ search?: string;
398
+ // New filters
399
+ unassigned?: boolean;
400
+ offeredTo?: string;
401
+ readyOnly?: boolean;
402
+ taskType?: string;
403
+ tags?: string[];
404
+ scheduleId?: string;
405
+ /** Exact canonical asset namespace. */
406
+ key?: string;
407
+ /** Canonical namespace subtree prefix. */
408
+ keyPrefix?: string;
409
+ /** Filter to tasks whose `source` is in this list. Empty/undefined → no filter. */
410
+ source?: AgentTaskSource[];
411
+ /** ISO 8601 timestamp; only return tasks where createdAt >= this. */
412
+ createdAfter?: string;
413
+ /** ISO 8601 timestamp; only return tasks where createdAt < this. */
414
+ createdBefore?: string;
415
+ /** Only return tasks requested by this canonical user. NULL rows are excluded. */
416
+ requestedByUserId?: string;
417
+ /** When set, restrict to rows where `requestedByUserId` IS NULL. Takes priority over `requestedByUserId`. */
418
+ requestedByUserIdIsNull?: boolean;
419
+ /** Sort list rows for either table freshness or timeline paging. */
420
+ orderBy?: "lastUpdatedAt" | "createdAt";
421
+ limit?: number;
422
+ offset?: number;
423
+ includeHeartbeat?: boolean;
424
+ }
425
+
426
+ export function getAllTasks(filters?: TaskFilters): Promise<AgentTask[]>;
427
+ export function getAllTasks(
428
+ filters: TaskFilters | undefined,
429
+ opts: { slim: true },
430
+ ): Promise<AgentTaskSummary[]>;
431
+ export async function getAllTasks(
432
+ filters?: TaskFilters,
433
+ opts?: { slim?: boolean },
434
+ ): Promise<AgentTask[] | AgentTaskSummary[]> {
435
+ const conditions: string[] = [];
436
+ const params: (string | AgentTaskStatus)[] = [];
437
+
438
+ if (filters?.status) {
439
+ if (Array.isArray(filters.status)) {
440
+ if (filters.status.length === 1) {
441
+ conditions.push("status = ?");
442
+ params.push(filters.status[0]!);
443
+ } else if (filters.status.length > 1) {
444
+ const placeholders = filters.status.map(() => "?").join(", ");
445
+ conditions.push(`status IN (${placeholders})`);
446
+ for (const s of filters.status) params.push(s);
447
+ }
448
+ } else {
449
+ conditions.push("status = ?");
450
+ params.push(filters.status);
451
+ }
452
+ }
453
+
454
+ if (filters?.agentId) {
455
+ conditions.push("agentId = ?");
456
+ params.push(filters.agentId);
457
+ }
458
+
459
+ if (filters?.search) {
460
+ conditions.push("(task LIKE ? OR id LIKE ?)");
461
+ params.push(`%${filters.search}%`, `%${filters.search}%`);
462
+ }
463
+
464
+ // New filters
465
+ if (filters?.unassigned) {
466
+ conditions.push("(agentId IS NULL OR status = 'unassigned')");
467
+ }
468
+
469
+ if (filters?.offeredTo) {
470
+ conditions.push("offeredTo = ?");
471
+ params.push(filters.offeredTo);
472
+ }
473
+
474
+ if (filters?.taskType) {
475
+ conditions.push("taskType = ?");
476
+ params.push(filters.taskType);
477
+ }
478
+
479
+ if (filters?.tags && filters.tags.length > 0) {
480
+ // Match any of the tags
481
+ const tagConditions = filters.tags.map(() => "tags LIKE ?");
482
+ conditions.push(`(${tagConditions.join(" OR ")})`);
483
+ for (const tag of filters.tags) {
484
+ params.push(`%"${tag}"%`);
485
+ }
486
+ }
487
+
488
+ if (filters?.scheduleId) {
489
+ conditions.push("scheduleId = ?");
490
+ params.push(filters.scheduleId);
491
+ }
492
+
493
+ if (filters?.key) {
494
+ conditions.push('"key" = ?');
495
+ params.push(normalizeAssetKey(filters.key));
496
+ } else if (filters?.keyPrefix) {
497
+ conditions.push(`"key" LIKE ? ESCAPE '\\'`);
498
+ params.push(dependencies.assetKeyPrefixPattern(filters.keyPrefix));
499
+ }
500
+
501
+ if (filters?.source && filters.source.length > 0) {
502
+ const placeholders = filters.source.map(() => "?").join(", ");
503
+ conditions.push(`source IN (${placeholders})`);
504
+ for (const s of filters.source) params.push(s);
505
+ }
506
+
507
+ if (filters?.createdAfter) {
508
+ conditions.push("createdAt >= ?");
509
+ params.push(filters.createdAfter);
510
+ }
511
+
512
+ if (filters?.createdBefore) {
513
+ conditions.push("createdAt < ?");
514
+ params.push(filters.createdBefore);
515
+ }
516
+
517
+ if (filters?.requestedByUserIdIsNull) {
518
+ conditions.push("requestedByUserId IS NULL");
519
+ } else if (filters?.requestedByUserId) {
520
+ conditions.push("requestedByUserId = ?");
521
+ params.push(filters.requestedByUserId);
522
+ }
523
+
524
+ // Exclude system/heartbeat tasks by default. The flag is still called
525
+ // `includeHeartbeat` for backward compat with existing API callers, but we
526
+ // also gate boot-triage + heartbeat-checklist behind it since those are
527
+ // equally noisy in the dashboard task list.
528
+ if (!filters?.includeHeartbeat) {
529
+ conditions.push(
530
+ "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
531
+ );
532
+ }
533
+
534
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
535
+ const limit = filters?.limit ?? 25;
536
+ const offset = filters?.offset ?? 0;
537
+ const orderBy =
538
+ filters?.orderBy === "createdAt"
539
+ ? "createdAt DESC, rowid DESC"
540
+ : "lastUpdatedAt DESC, priority DESC";
541
+ const query = `SELECT agent_tasks.*,
542
+ (SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd
543
+ FROM agent_tasks ${whereClause}
544
+ ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`;
545
+
546
+ const rows = await getDbClient().query<AgentTaskRow>(query, params);
547
+
548
+ // Filter for ready tasks (dependencies met) if requested. Both the full and
549
+ // the slim row shapes carry `id` + `dependsOn`, so the same predicate works.
550
+ const filterReady = async <T extends { id: string; dependsOn: string[] }>(
551
+ items: T[],
552
+ ): Promise<T[]> => {
553
+ const readyFlags = await Promise.all(
554
+ items.map(async (task) => {
555
+ if (!task.dependsOn || task.dependsOn.length === 0) return true;
556
+ return (await dependencies.checkDependencies(task.id)).ready;
557
+ }),
558
+ );
559
+ return items.filter((_, i) => readyFlags[i]);
560
+ };
561
+
562
+ if (opts?.slim) {
563
+ let tasks = rows.map(rowToAgentTaskSummary);
564
+ if (filters?.readyOnly) tasks = await filterReady(tasks);
565
+ return tasks;
566
+ }
567
+
568
+ let tasks = rows.map(rowToAgentTask);
569
+ if (filters?.readyOnly) tasks = await filterReady(tasks);
570
+ return tasks;
571
+ }
572
+
573
+ /**
574
+ * Get total count of tasks matching the given filters (ignoring limit).
575
+ * Used alongside getAllTasks to display accurate total counts in UI.
576
+ */
577
+ export async function getTasksCount(
578
+ filters?: Omit<TaskFilters, "limit" | "readyOnly">,
579
+ ): Promise<number> {
580
+ const conditions: string[] = [];
581
+ const params: (string | AgentTaskStatus)[] = [];
582
+
583
+ if (filters?.status) {
584
+ if (Array.isArray(filters.status)) {
585
+ if (filters.status.length === 1) {
586
+ conditions.push("status = ?");
587
+ params.push(filters.status[0]!);
588
+ } else if (filters.status.length > 1) {
589
+ const placeholders = filters.status.map(() => "?").join(", ");
590
+ conditions.push(`status IN (${placeholders})`);
591
+ for (const s of filters.status) params.push(s);
592
+ }
593
+ } else {
594
+ conditions.push("status = ?");
595
+ params.push(filters.status);
596
+ }
597
+ }
598
+
599
+ if (filters?.agentId) {
600
+ conditions.push("agentId = ?");
601
+ params.push(filters.agentId);
602
+ }
603
+
604
+ if (filters?.search) {
605
+ conditions.push("(task LIKE ? OR id LIKE ?)");
606
+ params.push(`%${filters.search}%`, `%${filters.search}%`);
607
+ }
608
+
609
+ if (filters?.unassigned) {
610
+ conditions.push("(agentId IS NULL OR status = 'unassigned')");
611
+ }
612
+
613
+ if (filters?.offeredTo) {
614
+ conditions.push("offeredTo = ?");
615
+ params.push(filters.offeredTo);
616
+ }
617
+
618
+ if (filters?.taskType) {
619
+ conditions.push("taskType = ?");
620
+ params.push(filters.taskType);
621
+ }
622
+
623
+ if (filters?.tags && filters.tags.length > 0) {
624
+ const tagConditions = filters.tags.map(() => "tags LIKE ?");
625
+ conditions.push(`(${tagConditions.join(" OR ")})`);
626
+ for (const tag of filters.tags) {
627
+ params.push(`%"${tag}"%`);
628
+ }
629
+ }
630
+
631
+ if (filters?.scheduleId) {
632
+ conditions.push("scheduleId = ?");
633
+ params.push(filters.scheduleId);
634
+ }
635
+
636
+ if (filters?.key) {
637
+ conditions.push('"key" = ?');
638
+ params.push(normalizeAssetKey(filters.key));
639
+ } else if (filters?.keyPrefix) {
640
+ conditions.push(`"key" LIKE ? ESCAPE '\\'`);
641
+ params.push(dependencies.assetKeyPrefixPattern(filters.keyPrefix));
642
+ }
643
+
644
+ if (filters?.source && filters.source.length > 0) {
645
+ const placeholders = filters.source.map(() => "?").join(", ");
646
+ conditions.push(`source IN (${placeholders})`);
647
+ for (const s of filters.source) params.push(s);
648
+ }
649
+
650
+ if (filters?.createdAfter) {
651
+ conditions.push("createdAt >= ?");
652
+ params.push(filters.createdAfter);
653
+ }
654
+
655
+ if (filters?.createdBefore) {
656
+ conditions.push("createdAt < ?");
657
+ params.push(filters.createdBefore);
658
+ }
659
+
660
+ if (filters?.requestedByUserIdIsNull) {
661
+ conditions.push("requestedByUserId IS NULL");
662
+ } else if (filters?.requestedByUserId) {
663
+ conditions.push("requestedByUserId = ?");
664
+ params.push(filters.requestedByUserId);
665
+ }
666
+
667
+ // Exclude system/heartbeat tasks by default. The flag is still called
668
+ // `includeHeartbeat` for backward compat with existing API callers, but we
669
+ // also gate boot-triage + heartbeat-checklist behind it since those are
670
+ // equally noisy in the dashboard task list.
671
+ if (!filters?.includeHeartbeat) {
672
+ conditions.push(
673
+ "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
674
+ );
675
+ }
676
+
677
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
678
+ const query = `SELECT COUNT(*) as count FROM agent_tasks ${whereClause}`;
679
+
680
+ const result = await getDbClient().get<{ count: number }>(query, params);
681
+
682
+ return result?.count ?? 0;
683
+ }
684
+
685
+ /**
686
+ * Get task statistics (counts by status) without any limit.
687
+ * This is more efficient than fetching all tasks for stats purposes.
688
+ */
689
+ export async function getTaskStats(): Promise<{
690
+ total: number;
691
+ unassigned: number;
692
+ offered: number;
693
+ reviewing: number;
694
+ pending: number;
695
+ in_progress: number;
696
+ paused: number;
697
+ completed: number;
698
+ failed: number;
699
+ }> {
700
+ const row = await getDbClient().get<{
701
+ total: number;
702
+ unassigned: number;
703
+ offered: number;
704
+ reviewing: number;
705
+ pending: number;
706
+ in_progress: number;
707
+ paused: number;
708
+ completed: number;
709
+ failed: number;
710
+ }>(
711
+ `SELECT
712
+ COUNT(*) as total,
713
+ SUM(CASE WHEN status = 'unassigned' THEN 1 ELSE 0 END) as unassigned,
714
+ SUM(CASE WHEN status = 'offered' THEN 1 ELSE 0 END) as offered,
715
+ SUM(CASE WHEN status = 'reviewing' THEN 1 ELSE 0 END) as reviewing,
716
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
717
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
718
+ SUM(CASE WHEN status = 'paused' THEN 1 ELSE 0 END) as paused,
719
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
720
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
721
+ FROM agent_tasks`,
722
+ );
723
+
724
+ return (
725
+ row ?? {
726
+ total: 0,
727
+ unassigned: 0,
728
+ offered: 0,
729
+ reviewing: 0,
730
+ pending: 0,
731
+ in_progress: 0,
732
+ paused: 0,
733
+ completed: 0,
734
+ failed: 0,
735
+ }
736
+ );
737
+ }
738
+
739
+ export async function getCompletedSlackTasks(): Promise<AgentTask[]> {
740
+ const rows = await getDbClient().query<AgentTaskRow>(
741
+ `SELECT * FROM agent_tasks
742
+ WHERE slackChannelId IS NOT NULL
743
+ AND status IN ('completed', 'failed')
744
+ ORDER BY lastUpdatedAt DESC
745
+ LIMIT 200`,
746
+ );
747
+ return rows.map(rowToAgentTask);
748
+ }
749
+
750
+ /**
751
+ * Return terminal Slack-rooted tasks whose durable relay obligation is pending.
752
+ * The obligation is inserted by a DB trigger in the same transaction as the
753
+ * terminal status transition, so a process restart cannot lose the send.
754
+ */
755
+ export async function getPendingSlackRelayTasks(): Promise<AgentTask[]> {
756
+ const rows = await getDbClient().query<AgentTaskRow>(
757
+ `SELECT task.* FROM slack_relay_obligations obligation
758
+ JOIN agent_tasks task ON task.id = obligation.task_id
759
+ WHERE obligation.delivered_at IS NULL
760
+ AND task.status IN ('completed', 'failed', 'cancelled')
761
+ ORDER BY obligation.last_attempt_at IS NOT NULL,
762
+ obligation.last_attempt_at ASC,
763
+ obligation.created_at ASC
764
+ LIMIT 200`,
765
+ );
766
+ return rows.map(rowToAgentTask);
767
+ }
768
+
769
+ /** Rotate attempted rows behind fresh obligations so poison rows cannot starve the queue. */
770
+ export async function markSlackRelayAttempted(taskId: string): Promise<boolean> {
771
+ const now = new Date().toISOString();
772
+ const result = await getDbClient().run(
773
+ `UPDATE slack_relay_obligations
774
+ SET attempt_count = attempt_count + 1,
775
+ last_attempt_at = ?,
776
+ updated_at = ?
777
+ WHERE task_id = ? AND delivered_at IS NULL`,
778
+ [now, now, taskId],
779
+ );
780
+ return result.changes > 0;
781
+ }
782
+
783
+ /** Mark a relay obligation delivered only after Slack accepted the final result. */
784
+ export async function markSlackRelayDelivered(taskId: string): Promise<boolean> {
785
+ const now = new Date().toISOString();
786
+ const result = await getDbClient().run(
787
+ `UPDATE slack_relay_obligations
788
+ SET delivered_at = ?, updated_at = ?
789
+ WHERE task_id = ? AND delivered_at IS NULL`,
790
+ [now, now, taskId],
791
+ );
792
+ return result.changes > 0;
793
+ }
794
+
795
+ /** Discharge obligations already fulfilled by renderer v2's durable outcome record. */
796
+ export async function markFinalizedSlackRelaysDelivered(): Promise<number> {
797
+ const now = new Date().toISOString();
798
+ const result = await getDbClient().run(
799
+ `UPDATE slack_relay_obligations AS obligation
800
+ SET delivered_at = ?, updated_at = ?
801
+ WHERE obligation.delivered_at IS NULL
802
+ AND EXISTS (
803
+ SELECT 1 FROM slack_messages message
804
+ WHERE message.kind = 'outcome'
805
+ AND message.task_id = obligation.task_id
806
+ AND message.finalized_at IS NOT NULL
807
+ )`,
808
+ [now, now],
809
+ );
810
+ return result.changes;
811
+ }
812
+
813
+ /**
814
+ * Get tasks that were recently finished (completed/failed) by workers (non-lead agents).
815
+ * Used by leads to know when workers complete tasks.
816
+ */
817
+ export async function getRecentlyFinishedWorkerTasks(): Promise<AgentTask[]> {
818
+ // Query for finished tasks that haven't been notified yet
819
+ const rows = await getDbClient().query<AgentTaskRow>(
820
+ `SELECT t.* FROM agent_tasks t
821
+ LEFT JOIN agents a ON t.agentId = a.id
822
+ WHERE t.status IN ('completed', 'failed')
823
+ AND t.finishedAt IS NOT NULL
824
+ AND t.notifiedAt IS NULL
825
+ AND (a.isLead = 0 OR a.isLead IS NULL)
826
+ ORDER BY t.finishedAt DESC LIMIT 50`,
827
+ );
828
+ return rows.map(rowToAgentTask);
829
+ }
830
+
831
+ /**
832
+ * Atomically mark finished tasks as notified.
833
+ * Sets notifiedAt timestamp to prevent returning them in future polls.
834
+ */
835
+ export async function markTasksNotified(taskIds: string[]): Promise<number> {
836
+ if (taskIds.length === 0) return 0;
837
+
838
+ const now = new Date().toISOString();
839
+ const placeholders = taskIds.map(() => "?").join(",");
840
+
841
+ const result = await getDbClient().run(
842
+ `UPDATE agent_tasks SET notifiedAt = ?
843
+ WHERE id IN (${placeholders}) AND notifiedAt IS NULL`,
844
+ [now, ...taskIds],
845
+ );
846
+
847
+ return result.changes;
848
+ }
849
+
850
+ /**
851
+ * Reset notifiedAt for tasks, allowing them to be re-delivered on next poll.
852
+ * Used when a trigger was consumed but the session that should process it failed.
853
+ * This prevents permanent notification loss from the mark-before-process race.
854
+ */
855
+ export async function resetTasksNotified(taskIds: string[]): Promise<number> {
856
+ if (taskIds.length === 0) return 0;
857
+
858
+ const placeholders = taskIds.map(() => "?").join(",");
859
+
860
+ const result = await getDbClient().run(
861
+ `UPDATE agent_tasks SET notifiedAt = NULL
862
+ WHERE id IN (${placeholders}) AND notifiedAt IS NOT NULL`,
863
+ taskIds,
864
+ );
865
+
866
+ return result.changes;
867
+ }
868
+
869
+ export async function getInProgressSlackTasks(): Promise<AgentTask[]> {
870
+ const rows = await getDbClient().query<AgentTaskRow>(
871
+ `SELECT * FROM agent_tasks
872
+ WHERE slackChannelId IS NOT NULL
873
+ AND status = 'in_progress'
874
+ ORDER BY lastUpdatedAt DESC
875
+ LIMIT 200`,
876
+ );
877
+ return rows.map(rowToAgentTask);
878
+ }
879
+
880
+ export async function getSlackTasksMissingTree(): Promise<AgentTask[]> {
881
+ const rows = await getDbClient().query<AgentTaskRow>(
882
+ `SELECT task.* FROM agent_tasks task
883
+ JOIN slack_render_v2_state state ON state.id = 1
884
+ WHERE task.source = 'slack'
885
+ AND task.slackChannelId IS NOT NULL
886
+ AND task.slackThreadTs IS NOT NULL
887
+ AND task.createdAt >= state.activated_at
888
+ AND task.status NOT IN ('backlog', 'unassigned', 'superseded')
889
+ AND NOT EXISTS (
890
+ SELECT 1 FROM agent_tasks earlier
891
+ WHERE earlier.source = 'slack'
892
+ AND earlier.slackChannelId = task.slackChannelId
893
+ AND earlier.slackThreadTs = task.slackThreadTs
894
+ AND earlier.createdAt < state.activated_at
895
+ )
896
+ AND NOT EXISTS (
897
+ SELECT 1 FROM slack_messages tree
898
+ WHERE tree.kind = 'tree'
899
+ AND tree.channel_id = task.slackChannelId
900
+ AND tree.thread_ts = task.slackThreadTs
901
+ )
902
+ ORDER BY task.lastUpdatedAt DESC
903
+ LIMIT 200`,
904
+ );
905
+ return rows.map(rowToAgentTask);
906
+ }
907
+
908
+ /**
909
+ * Return sibling tasks for a given cross-ingress context key, optionally
910
+ * filtered by status. The returned shape mirrors getInProgressSlackTasks for
911
+ * consistency; callers can narrow further in TypeScript.
912
+ *
913
+ * See src/tasks/context-key.ts for the key schema.
914
+ */
915
+ export async function getInProgressTasksByContextKey(
916
+ contextKey: string,
917
+ statuses: AgentTaskStatus[] = ["pending", "in_progress", "offered", "paused"],
918
+ ): Promise<AgentTask[]> {
919
+ if (!contextKey || statuses.length === 0) return [];
920
+ const placeholders = statuses.map(() => "?").join(",");
921
+ const rows = await getDbClient().query<AgentTaskRow>(
922
+ `SELECT * FROM agent_tasks
923
+ WHERE contextKey = ?
924
+ AND status IN (${placeholders})
925
+ ORDER BY lastUpdatedAt DESC
926
+ LIMIT 200`,
927
+ [contextKey, ...statuses],
928
+ );
929
+ return rows.map(rowToAgentTask);
930
+ }
931
+
932
+ export type ExistingTrackerContextWorkReason = "active_task" | "linked_open_pr";
933
+
934
+ export type ExistingTrackerContextWork = {
935
+ task: AgentTask;
936
+ reason: ExistingTrackerContextWorkReason;
937
+ };
938
+
939
+ const LINEAR_TRACKER_CONTEXT_KEY_PREFIX = "task:trackers:linear:";
940
+
941
+ function isLinearTrackerContextKey(contextKey: string | null | undefined): contextKey is string {
942
+ return !!contextKey && contextKey.startsWith(LINEAR_TRACKER_CONTEXT_KEY_PREFIX);
943
+ }
944
+
945
+ /**
946
+ * Return existing work for a Linear tracker key before creating another task.
947
+ *
948
+ * Active means any non-terminal task. A completed task with persisted VCS PR/MR
949
+ * metadata is also treated as existing work because the task can be complete
950
+ * while the PR is still awaiting review/merge.
951
+ */
952
+ export async function findExistingLinearTrackerContextWork(
953
+ contextKey: string | null | undefined,
954
+ ): Promise<ExistingTrackerContextWork | null> {
955
+ if (!isLinearTrackerContextKey(contextKey)) return null;
956
+
957
+ const activeRow = await getDbClient().get<AgentTaskRow>(
958
+ `SELECT * FROM agent_tasks
959
+ WHERE contextKey = ?
960
+ AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
961
+ ORDER BY lastUpdatedAt DESC
962
+ LIMIT 1`,
963
+ [contextKey],
964
+ );
965
+ if (activeRow) {
966
+ return { task: rowToAgentTask(activeRow), reason: "active_task" };
967
+ }
968
+
969
+ const linkedPrRow = await getDbClient().get<AgentTaskRow>(
970
+ `SELECT * FROM agent_tasks
971
+ WHERE contextKey = ?
972
+ AND status = 'completed'
973
+ AND vcsProvider IS NOT NULL
974
+ AND vcsRepo IS NOT NULL
975
+ AND vcsNumber IS NOT NULL
976
+ AND vcsUrl IS NOT NULL
977
+ ORDER BY lastUpdatedAt DESC
978
+ LIMIT 1`,
979
+ [contextKey],
980
+ );
981
+ if (linkedPrRow) {
982
+ return { task: rowToAgentTask(linkedPrRow), reason: "linked_open_pr" };
983
+ }
984
+
985
+ return null;
986
+ }
987
+
988
+ export async function getLatestTaskByContextKey(contextKey: string): Promise<AgentTask | null> {
989
+ if (!contextKey) return null;
990
+ const row = await getDbClient().get<AgentTaskRow>(
991
+ `SELECT * FROM agent_tasks
992
+ WHERE contextKey = ?
993
+ ORDER BY createdAt DESC
994
+ LIMIT 1`,
995
+ [contextKey],
996
+ );
997
+ return row ? rowToAgentTask(row) : null;
998
+ }
999
+
1000
+ export async function getLatestScriptRunStepTaskByContextKey(
1001
+ contextKey: string,
1002
+ ): Promise<AgentTask | null> {
1003
+ if (!contextKey) return null;
1004
+ const row = await getDbClient().get<AgentTaskRow>(
1005
+ `SELECT * FROM agent_tasks
1006
+ WHERE contextKey = ?
1007
+ AND taskType = 'script-run-step'
1008
+ ORDER BY createdAt DESC, rowid DESC
1009
+ LIMIT 1`,
1010
+ [contextKey],
1011
+ );
1012
+ return row ? rowToAgentTask(row) : null;
1013
+ }
1014
+
1015
+ /**
1016
+ * Find the most recent agent associated with a specific Slack thread.
1017
+ * No status filter — returns the last agent that touched this thread regardless of task state.
1018
+ * This is intentional: follow-up messages should route to the same agent even after task completion.
1019
+ * Callers (e.g. assistant.ts) apply their own status checks (e.g. agent.status !== "offline").
1020
+ */
1021
+ export async function getAgentWorkingOnThread(
1022
+ channelId: string,
1023
+ threadTs: string,
1024
+ ): Promise<Agent | null> {
1025
+ const taskRow = await getDbClient().get<AgentTaskRow>(
1026
+ `SELECT * FROM agent_tasks
1027
+ WHERE source = 'slack'
1028
+ AND slackChannelId = ?
1029
+ AND slackThreadTs = ?
1030
+ ORDER BY createdAt DESC
1031
+ LIMIT 1`,
1032
+ [channelId, threadTs],
1033
+ );
1034
+
1035
+ if (taskRow?.agentId) return getAgentById(taskRow.agentId);
1036
+
1037
+ return null;
1038
+ }
1039
+
1040
+ /**
1041
+ * Find the latest active (in_progress or pending) task in a specific Slack thread.
1042
+ * Used for dependency chaining in additive Slack buffer.
1043
+ */
1044
+ export async function getLatestActiveTaskInThread(
1045
+ channelId: string,
1046
+ threadTs: string,
1047
+ ): Promise<AgentTask | null> {
1048
+ const row = await getDbClient().get<AgentTaskRow>(
1049
+ `SELECT * FROM agent_tasks
1050
+ WHERE source = 'slack'
1051
+ AND slackChannelId = ?
1052
+ AND slackThreadTs = ?
1053
+ AND status IN ('in_progress', 'pending')
1054
+ ORDER BY createdAt DESC, rowid DESC
1055
+ LIMIT 1`,
1056
+ [channelId, threadTs],
1057
+ );
1058
+
1059
+ return row ? rowToAgentTask(row) : null;
1060
+ }
1061
+
1062
+ /**
1063
+ * Find the latest task assigned to a lead agent in a specific Slack thread.
1064
+ * Source is deliberately restricted to Slack ingress so inherited worker-task
1065
+ * metadata cannot become the thread's steering target.
1066
+ */
1067
+ export async function getLatestLeadTaskInThread(
1068
+ channelId: string,
1069
+ threadTs: string,
1070
+ ): Promise<AgentTask | null> {
1071
+ const row = await getDbClient().get<AgentTaskRow>(
1072
+ `SELECT t.*
1073
+ FROM agent_tasks t
1074
+ JOIN agents a ON a.id = t.agentId
1075
+ WHERE t.source = 'slack'
1076
+ AND t.slackChannelId = ?
1077
+ AND t.slackThreadTs = ?
1078
+ AND a.isLead = 1
1079
+ ORDER BY t.createdAt DESC, t.rowid DESC
1080
+ LIMIT 1`,
1081
+ [channelId, threadTs],
1082
+ );
1083
+
1084
+ return row ? rowToAgentTask(row) : null;
1085
+ }
1086
+
1087
+ /**
1088
+ * Find the most recent task in a Slack thread, regardless of source or status.
1089
+ * Unlike getAgentWorkingOnThread (which filters source='slack'), this finds ALL tasks
1090
+ * including worker tasks that inherited Slack metadata via parentTaskId.
1091
+ */
1092
+ export async function getMostRecentTaskInThread(
1093
+ channelId: string,
1094
+ threadTs: string,
1095
+ ): Promise<AgentTask | null> {
1096
+ const row = await getDbClient().get<AgentTaskRow>(
1097
+ `SELECT * FROM agent_tasks
1098
+ WHERE slackChannelId = ?
1099
+ AND slackThreadTs = ?
1100
+ ORDER BY createdAt DESC
1101
+ LIMIT 1`,
1102
+ [channelId, threadTs],
1103
+ );
1104
+ return row ? rowToAgentTask(row) : null;
1105
+ }
1106
+
1107
+ export async function findCompletedTaskInThread(
1108
+ channelId: string,
1109
+ threadTs: string,
1110
+ windowMinutes: number,
1111
+ ): Promise<AgentTask | null> {
1112
+ const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
1113
+ const row = await getDbClient().get<AgentTaskRow>(
1114
+ `SELECT * FROM agent_tasks
1115
+ WHERE slackChannelId = ?
1116
+ AND slackThreadTs = ?
1117
+ AND status = 'completed'
1118
+ AND lastUpdatedAt > ?
1119
+ ORDER BY lastUpdatedAt DESC
1120
+ LIMIT 1`,
1121
+ [channelId, threadTs, since],
1122
+ );
1123
+ return row ? rowToAgentTask(row) : null;
1124
+ }
1125
+
1126
+ /**
1127
+ * Find the most recent CANCELLED task in a Slack thread. Used by the
1128
+ * follow-up re-delegation guard so a cancellation (worker SIGTERM,
1129
+ * runner-side abort, swarm-events tool-loop abort) doesn't permanently
1130
+ * jam re-dispatch when an earlier sibling task in the same thread also
1131
+ * completed.
1132
+ *
1133
+ * Matches both:
1134
+ * - `status = 'cancelled'` (the canonical terminal state from cancelTask)
1135
+ * - `status = 'failed'` with a failureReason that starts with "cancelled"
1136
+ * or "exit 130" or contains "cancelled" (the codex-adapter abort path
1137
+ * emits `failureReason: "cancelled"` and exits 130).
1138
+ */
1139
+ export async function findRecentCancelledTaskInThread(
1140
+ channelId: string,
1141
+ threadTs: string,
1142
+ windowMinutes: number,
1143
+ ): Promise<AgentTask | null> {
1144
+ const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
1145
+ const row = await getDbClient().get<AgentTaskRow>(
1146
+ `SELECT * FROM agent_tasks
1147
+ WHERE slackChannelId = ?
1148
+ AND slackThreadTs = ?
1149
+ AND lastUpdatedAt > ?
1150
+ AND (
1151
+ status = 'cancelled'
1152
+ OR (
1153
+ status = 'failed'
1154
+ AND failureReason IS NOT NULL
1155
+ AND (
1156
+ failureReason LIKE 'cancelled%'
1157
+ OR failureReason LIKE 'exit 130%'
1158
+ OR failureReason LIKE '%cancelled%'
1159
+ )
1160
+ )
1161
+ )
1162
+ ORDER BY lastUpdatedAt DESC
1163
+ LIMIT 1`,
1164
+ [channelId, threadTs, since],
1165
+ );
1166
+ return row ? rowToAgentTask(row) : null;
1167
+ }