@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,953 @@
1
+ import pkg from "../../../../package.json";
2
+ import { defaultAssetKey } from "../../../assets/key";
3
+ import type { telemetry } from "../../../telemetry";
4
+ import type {
5
+ Agent,
6
+ AgentLog,
7
+ AgentLogEventType,
8
+ AgentTask,
9
+ AgentTaskSource,
10
+ ProviderName,
11
+ TaskAttachment,
12
+ } from "../../../types";
13
+ import { isTerminalTaskStatus } from "../../../types";
14
+ import { scrubSecrets } from "../../../utils/secret-scrubber";
15
+ import { emitTaskStarted } from "../../task-lifecycle-events";
16
+ import { getAgentById, isAgentEligibleForTask } from "../agents";
17
+ import { getDbClient } from "../runtime";
18
+ import { type AgentTaskRow, getTaskById, rowToAgentTask } from "./read";
19
+
20
+ type TaskWriteDependencies = {
21
+ createLogEntry: (entry: {
22
+ eventType: AgentLogEventType;
23
+ agentId?: string;
24
+ taskId?: string;
25
+ oldValue?: string;
26
+ newValue?: string;
27
+ metadata?: Record<string, unknown>;
28
+ }) => Promise<AgentLog>;
29
+ checkDependencies: (taskId: string) => Promise<{ ready: boolean; blockedBy: string[] }>;
30
+ reconcileTaskPullRequestAttachments: (
31
+ taskId: string,
32
+ agentId: string | null,
33
+ sourceTexts: Array<string | null | undefined>,
34
+ ) => Promise<TaskAttachment[]>;
35
+ emitTaskLifecycleTelemetryAfterCommit: (
36
+ event: string,
37
+ props: Parameters<typeof telemetry.taskEvent>[1],
38
+ verify?: (task: AgentTask | null) => boolean,
39
+ ) => void;
40
+ taskContextForTelemetry: (task: AgentTask) => {
41
+ provider?: ProviderName;
42
+ harnessVariant?: string;
43
+ harnessVersion?: string;
44
+ };
45
+ promotePendingSteeringForTask: (taskId: string, reason: string) => Promise<unknown>;
46
+ cascadeFailDependents: (
47
+ parentId: string,
48
+ parentStatus: string,
49
+ ) => Promise<Array<{ taskId: string; taskSubject: string }>>;
50
+ };
51
+
52
+ let dependencies: TaskWriteDependencies;
53
+
54
+ // Registration only stores deferred callbacks; the facade still owns these callees.
55
+ export function configureTaskWriteDependencies(value: TaskWriteDependencies): void {
56
+ dependencies = value;
57
+ }
58
+
59
+ export async function createTask(
60
+ agentId: string,
61
+ task: string,
62
+ options?: {
63
+ source?: AgentTaskSource;
64
+ slackChannelId?: string;
65
+ slackThreadTs?: string;
66
+ slackUserId?: string;
67
+ },
68
+ ): Promise<AgentTask> {
69
+ const id = crypto.randomUUID();
70
+ const source = options?.source ?? "mcp";
71
+ const row = await getDbClient().get<AgentTaskRow>(
72
+ `INSERT INTO agent_tasks (id, "key", agentId, task, status, source, slackChannelId, slackThreadTs, slackUserId, swarmVersion, createdAt, lastUpdatedAt)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *`,
74
+ [
75
+ id,
76
+ defaultAssetKey("task", id),
77
+ agentId,
78
+ task,
79
+ "pending",
80
+ source,
81
+ options?.slackChannelId ?? null,
82
+ options?.slackThreadTs ?? null,
83
+ options?.slackUserId ?? null,
84
+ pkg.version,
85
+ ],
86
+ );
87
+ if (!row) throw new Error("Failed to create task");
88
+ try {
89
+ await dependencies.createLogEntry({
90
+ eventType: "task_created",
91
+ agentId,
92
+ taskId: id,
93
+ newValue: "pending",
94
+ metadata: { source },
95
+ });
96
+ } catch {}
97
+ return rowToAgentTask(row);
98
+ }
99
+
100
+ /**
101
+ * In-process dedup for `task_dispatch_rejected_affinity` logging in
102
+ * `getPendingTaskForAgent` below — that function runs on every poll tick for
103
+ * every agent with a directly-assigned pending task, so an unresolved skip
104
+ * (e.g. a corrupt/misassigned legacy row) would otherwise write one log row
105
+ * per poll forever. Keyed by taskId; per-process, like `alarmActive` in
106
+ * `queue-stall-alarm.ts` — an API restart re-arms it, which is fine since the
107
+ * point is "don't spam," not "log exactly once ever."
108
+ */
109
+ const AFFINITY_DISPATCH_SKIP_LOG_COOLDOWN_MS = 5 * 60 * 1000;
110
+ const lastAffinityDispatchSkipLoggedAt = new Map<string, number>();
111
+
112
+ async function logAffinityDispatchSkip(
113
+ agent: Pick<Agent, "id" | "isLead" | "role">,
114
+ task: Pick<AgentTask, "id" | "routingAffinity" | "routingAffinityInvalid">,
115
+ ): Promise<void> {
116
+ const now = Date.now();
117
+ const lastLoggedAt = lastAffinityDispatchSkipLoggedAt.get(task.id);
118
+ if (lastLoggedAt !== undefined && now - lastLoggedAt < AFFINITY_DISPATCH_SKIP_LOG_COOLDOWN_MS) {
119
+ return;
120
+ }
121
+ lastAffinityDispatchSkipLoggedAt.set(task.id, now);
122
+ try {
123
+ await dependencies.createLogEntry({
124
+ eventType: "task_dispatch_rejected_affinity",
125
+ agentId: agent.id,
126
+ taskId: task.id,
127
+ metadata: {
128
+ agentRole: agent.role ?? null,
129
+ requiredRole: task.routingAffinity?.role ?? null,
130
+ leadOnly: task.routingAffinity?.leadOnly === true,
131
+ agentIsLead: agent.isLead ?? false,
132
+ routingAffinityInvalid: task.routingAffinityInvalid === true,
133
+ },
134
+ });
135
+ } catch {}
136
+ }
137
+
138
+ export async function getPendingTaskForAgent(agentId: string): Promise<AgentTask | null> {
139
+ // Get all pending tasks for this agent, ordered by priority (desc) then creation time (asc)
140
+ const rows = await getDbClient().query<AgentTaskRow>(
141
+ "SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'pending' ORDER BY priority DESC, createdAt ASC",
142
+ [agentId],
143
+ );
144
+
145
+ const agent = await getAgentById(agentId);
146
+ if (!agent) return null;
147
+
148
+ for (const row of rows) {
149
+ const task = rowToAgentTask(row);
150
+ // `task.agentId` (the WHERE clause above) is a direct-assignment decision
151
+ // already made by the task's creator — `createTaskExtended` enforces a
152
+ // caller-declared requirement at creation time (see
153
+ // `routingAffinityIsInheritedProvenance` there). Re-running the FULL
154
+ // role/capability match here re-litigates that decision using metadata
155
+ // that may be pure inherited PROVENANCE (e.g. a Lead-routed
156
+ // worker-completion follow-up that inherits the finishing worker's
157
+ // role/capabilities as lineage, not a requirement anyone declared) —
158
+ // which permanently stalls dispatch to the agent the task is already
159
+ // pinned to (the #1276-regression this fixes; see PR body). Mirror the
160
+ // convention `acceptTask`/`claimOfferedTask` already use for an
161
+ // established offer: only `leadOnly` (a real authorization boundary) and
162
+ // `routingAffinityInvalid` (quarantined corrupt data) still veto a
163
+ // directly-assigned task. Pool-claim paths (`claimTask`,
164
+ // `assignUnassignedTaskPending`) are untouched and keep the full gate —
165
+ // they are deciding "who gets this" from the pool, not redispatching an
166
+ // assignment that was already authorized (or, for provenance, never a
167
+ // requirement) at creation time.
168
+ if (task.routingAffinityInvalid) {
169
+ await logAffinityDispatchSkip(agent, task);
170
+ continue;
171
+ }
172
+ if (task.routingAffinity?.leadOnly && !isAgentEligibleForTask(agent, task)) {
173
+ await logAffinityDispatchSkip(agent, task);
174
+ continue;
175
+ }
176
+ const { ready } = await dependencies.checkDependencies(task.id);
177
+ if (ready) return task;
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ export async function assignUnassignedTaskPending(
184
+ taskId: string,
185
+ agentId: string,
186
+ ): Promise<AgentTask | null> {
187
+ // This guard is always needed for lead-only tasks; the predicate itself
188
+ // handles the optional role/capability kill-switch.
189
+ {
190
+ const task = await getTaskById(taskId);
191
+ const agent = await getAgentById(agentId);
192
+ if (task && (!agent || !isAgentEligibleForTask(agent, task))) {
193
+ try {
194
+ await dependencies.createLogEntry({
195
+ eventType: "task_claim_rejected_affinity",
196
+ agentId,
197
+ taskId,
198
+ metadata: {
199
+ agentRole: agent?.role ?? null,
200
+ requiredRole: task.routingAffinity?.role ?? null,
201
+ leadOnly: task.routingAffinity?.leadOnly === true,
202
+ agentIsLead: agent?.isLead ?? false,
203
+ },
204
+ });
205
+ } catch {}
206
+ return null;
207
+ }
208
+ }
209
+
210
+ const now = new Date().toISOString();
211
+ const row = await getDbClient().get<AgentTaskRow>(
212
+ `UPDATE agent_tasks SET agentId = ?, status = 'pending', lastUpdatedAt = ?
213
+ WHERE id = ? AND status = 'unassigned' RETURNING *`,
214
+ [agentId, now, taskId],
215
+ );
216
+
217
+ if (row) {
218
+ try {
219
+ await dependencies.createLogEntry({
220
+ eventType: "task_status_change",
221
+ agentId,
222
+ taskId,
223
+ oldValue: "unassigned",
224
+ newValue: "pending",
225
+ metadata: { pendingDispatch: true },
226
+ });
227
+ } catch {}
228
+ }
229
+
230
+ return row ? rowToAgentTask(row) : null;
231
+ }
232
+
233
+ export async function startTask(taskId: string): Promise<AgentTask | null> {
234
+ const oldTask = await getTaskById(taskId);
235
+ if (!oldTask) return null;
236
+
237
+ // Guard: never revive tasks that are already in a terminal state
238
+ if (isTerminalTaskStatus(oldTask.status)) {
239
+ return null;
240
+ }
241
+
242
+ const row = await getDbClient().get<AgentTaskRow>(
243
+ `UPDATE agent_tasks SET status = 'in_progress', lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
244
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
245
+ [taskId],
246
+ );
247
+ if (row && oldTask) {
248
+ try {
249
+ await dependencies.createLogEntry({
250
+ eventType: "task_status_change",
251
+ taskId,
252
+ agentId: row.agentId ?? undefined,
253
+ oldValue: oldTask.status,
254
+ newValue: "in_progress",
255
+ });
256
+ } catch {}
257
+ }
258
+ const result = row ? rowToAgentTask(row) : null;
259
+ // Fire-and-forget: notify lifecycle subscribers (e.g. GitHub eyes reaction)
260
+ if (result && oldTask.status !== "in_progress") {
261
+ emitTaskStarted(result);
262
+ }
263
+ return result;
264
+ }
265
+
266
+ export async function updateTaskClaudeSessionId(
267
+ taskId: string,
268
+ claudeSessionId: string,
269
+ provider?: ProviderName,
270
+ providerMeta?: Record<string, unknown>,
271
+ model?: string,
272
+ harnessVariant?: string,
273
+ harnessVariantMeta?: Record<string, unknown>,
274
+ ): Promise<AgentTask | null> {
275
+ const setClauses = ["claudeSessionId = ?", "lastUpdatedAt = ?"];
276
+ const params: (string | null)[] = [claudeSessionId, new Date().toISOString()];
277
+
278
+ if (provider !== undefined) {
279
+ setClauses.push("provider = ?");
280
+ params.push(provider);
281
+ }
282
+ if (providerMeta !== undefined) {
283
+ setClauses.push("providerMeta = ?");
284
+ params.push(JSON.stringify(providerMeta));
285
+ }
286
+ if (model !== undefined) {
287
+ setClauses.push("model = ?");
288
+ params.push(model);
289
+ }
290
+ if (harnessVariant !== undefined) {
291
+ setClauses.push("harnessVariant = ?");
292
+ params.push(harnessVariant);
293
+ }
294
+ if (harnessVariantMeta !== undefined) {
295
+ setClauses.push("harnessVariantMeta = ?");
296
+ params.push(JSON.stringify(harnessVariantMeta));
297
+ }
298
+
299
+ params.push(taskId);
300
+
301
+ const row = await getDbClient().get<AgentTaskRow>(
302
+ `UPDATE agent_tasks SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
303
+ params,
304
+ );
305
+ return row ? rowToAgentTask(row) : null;
306
+ }
307
+
308
+ /**
309
+ * Sets or clears a task's display title (session rename). Trims the input and
310
+ * normalizes an empty string to NULL (clear). Deliberately does NOT touch
311
+ * `lastUpdatedAt` — a rename is not activity, and the sessions sidebar sorts
312
+ * on chain-wide max `lastUpdatedAt`, so bumping it here would reorder the list.
313
+ */
314
+ export async function updateTaskTitle(
315
+ taskId: string,
316
+ title: string | null,
317
+ ): Promise<AgentTask | null> {
318
+ const normalized = title === null ? null : title.trim() || null;
319
+ const row = await getDbClient().get<AgentTaskRow>(
320
+ "UPDATE agent_tasks SET title = ? WHERE id = ? RETURNING *",
321
+ [normalized, taskId],
322
+ );
323
+ return row ? rowToAgentTask(row) : null;
324
+ }
325
+
326
+ export async function completeTask(id: string, output?: string): Promise<AgentTask | null> {
327
+ const oldTask = await getTaskById(id);
328
+ if (!oldTask) return null;
329
+
330
+ // Idempotency guard: don't re-complete a task already in a terminal state.
331
+ // Mirrors cancelTask. Prevents duplicate task.completed events, duplicate
332
+ // log entries, and duplicate follow-up tasks when multiple sessions race.
333
+ if (isTerminalTaskStatus(oldTask.status)) {
334
+ return null;
335
+ }
336
+
337
+ const row = await getDbClient().transaction(async () => {
338
+ const finishedAt = new Date().toISOString();
339
+ // The status predicate re-checks the idempotency guard atomically: the
340
+ // await between the guard read above and this write lets a racing
341
+ // terminal transition (e.g. heartbeat failTask) land first.
342
+ let completed = await getDbClient().get<AgentTaskRow>(
343
+ `UPDATE agent_tasks SET status = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
344
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
345
+ ["completed", finishedAt, id],
346
+ );
347
+ if (!completed) return null;
348
+
349
+ if (output) {
350
+ completed = await getDbClient().get<AgentTaskRow>(
351
+ "UPDATE agent_tasks SET output = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
352
+ [scrubSecrets(output), id],
353
+ );
354
+ }
355
+ if (completed) {
356
+ await dependencies.reconcileTaskPullRequestAttachments(id, completed.agentId, [
357
+ completed.output,
358
+ completed.vcsProvider === "github" ? completed.vcsUrl : null,
359
+ ]);
360
+ }
361
+ return completed;
362
+ });
363
+ if (!row) return null;
364
+
365
+ if (row && oldTask) {
366
+ dependencies.emitTaskLifecycleTelemetryAfterCommit(
367
+ "completed",
368
+ {
369
+ taskId: id,
370
+ source: oldTask.source,
371
+ ...dependencies.taskContextForTelemetry(oldTask),
372
+ agentId: row.agentId ?? undefined,
373
+ durationMs: row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : undefined,
374
+ },
375
+ (task) => task?.status === "completed",
376
+ );
377
+
378
+ try {
379
+ await dependencies.createLogEntry({
380
+ eventType: "task_status_change",
381
+ taskId: id,
382
+ agentId: row.agentId ?? undefined,
383
+ oldValue: oldTask.status,
384
+ newValue: "completed",
385
+ });
386
+ } catch {}
387
+ getDbClient().afterCommit(() => {
388
+ import("../../../workflows/event-bus")
389
+ .then(({ workflowEventBus }) => {
390
+ workflowEventBus.emit("task.completed", {
391
+ taskId: id,
392
+ output,
393
+ agentId: row.agentId,
394
+ workflowRunId: row.workflowRunId,
395
+ workflowRunStepId: row.workflowRunStepId,
396
+ });
397
+ })
398
+ .catch((err) =>
399
+ console.error(
400
+ "[db] task.completed event not emitted:",
401
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
402
+ ),
403
+ );
404
+ });
405
+ try {
406
+ await dependencies.promotePendingSteeringForTask(
407
+ id,
408
+ "Task completed before steering was delivered",
409
+ );
410
+ } catch (error) {
411
+ console.error(
412
+ "[completeTask] pending steering promotion error:",
413
+ scrubSecrets(error instanceof Error ? error.message : String(error)),
414
+ );
415
+ }
416
+ }
417
+
418
+ return row ? rowToAgentTask(row) : null;
419
+ }
420
+
421
+ export async function failTask(id: string, reason: string): Promise<AgentTask | null> {
422
+ const oldTask = await getTaskById(id);
423
+ if (!oldTask) return null;
424
+
425
+ // Idempotency guard: don't re-fail a task already in a terminal state.
426
+ // Mirrors cancelTask / completeTask. Prevents duplicate task.failed events
427
+ // and duplicate follow-up tasks when multiple sessions race.
428
+ if (isTerminalTaskStatus(oldTask.status)) {
429
+ return null;
430
+ }
431
+
432
+ const finishedAt = new Date().toISOString();
433
+ const scrubbedReason = scrubSecrets(reason);
434
+ // Status predicate re-checks the idempotency guard atomically (a racing
435
+ // terminal transition can land during the await above).
436
+ const row = await getDbClient().get<AgentTaskRow>(
437
+ `UPDATE agent_tasks SET status = 'failed', failureReason = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
438
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
439
+ [scrubbedReason, finishedAt, id],
440
+ );
441
+ if (row && oldTask) {
442
+ dependencies.emitTaskLifecycleTelemetryAfterCommit(
443
+ "failed",
444
+ {
445
+ taskId: id,
446
+ source: oldTask.source,
447
+ ...dependencies.taskContextForTelemetry(oldTask),
448
+ agentId: row.agentId ?? undefined,
449
+ durationMs: row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : undefined,
450
+ },
451
+ (task) => task?.status === "failed",
452
+ );
453
+
454
+ try {
455
+ await dependencies.createLogEntry({
456
+ eventType: "task_status_change",
457
+ taskId: id,
458
+ agentId: row.agentId ?? undefined,
459
+ oldValue: oldTask.status,
460
+ newValue: "failed",
461
+ metadata: { reason: scrubbedReason },
462
+ });
463
+ } catch {}
464
+ getDbClient().afterCommit(() => {
465
+ import("../../../workflows/event-bus")
466
+ .then(({ workflowEventBus }) => {
467
+ workflowEventBus.emit("task.failed", {
468
+ taskId: id,
469
+ failureReason: reason,
470
+ agentId: row.agentId,
471
+ workflowRunId: row.workflowRunId,
472
+ workflowRunStepId: row.workflowRunStepId,
473
+ });
474
+ })
475
+ .catch((err) =>
476
+ console.error(
477
+ "[db] task.failed event not emitted:",
478
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
479
+ ),
480
+ );
481
+ });
482
+ try {
483
+ await dependencies.promotePendingSteeringForTask(
484
+ id,
485
+ "Task failed before steering was delivered",
486
+ );
487
+ } catch (error) {
488
+ console.error(
489
+ "[failTask] pending steering promotion error:",
490
+ scrubSecrets(error instanceof Error ? error.message : String(error)),
491
+ );
492
+ }
493
+
494
+ // Cascade-fail any non-terminal tasks that depend on this one.
495
+ // The cascade is recursive (transitive closure) and cycle-safe.
496
+ try {
497
+ await dependencies.cascadeFailDependents(id, "failed");
498
+ } catch (err) {
499
+ console.error("[failTask] cascade-fail dependents error:", err);
500
+ }
501
+ }
502
+ return row ? rowToAgentTask(row) : null;
503
+ }
504
+
505
+ /**
506
+ * Replace result text on an already-terminal task without replaying terminal
507
+ * side effects or moving any lifecycle timestamps. Callers must opt in to
508
+ * this narrow escape hatch; ordinary completion remains first-call-wins.
509
+ */
510
+ export async function overwriteTerminalTaskResultText(
511
+ id: string,
512
+ patch: { output?: string; failureReason?: string },
513
+ ): Promise<AgentTask | null> {
514
+ const task = await getTaskById(id);
515
+ if (!task || !isTerminalTaskStatus(task.status)) return null;
516
+
517
+ const output = patch.output !== undefined ? scrubSecrets(patch.output) : (task.output ?? null);
518
+ const failureReason =
519
+ patch.failureReason !== undefined
520
+ ? scrubSecrets(patch.failureReason)
521
+ : (task.failureReason ?? null);
522
+ const row = await getDbClient().transaction(async () => {
523
+ const updated =
524
+ (await getDbClient().get<AgentTaskRow>(
525
+ `UPDATE agent_tasks SET output = ?, failureReason = ?
526
+ WHERE id = ? AND status IN ('completed', 'failed', 'cancelled', 'superseded')
527
+ RETURNING *`,
528
+ [output, failureReason, id],
529
+ )) ?? null;
530
+ if (updated && patch.output !== undefined) {
531
+ await dependencies.reconcileTaskPullRequestAttachments(id, updated.agentId, [
532
+ updated.output,
533
+ updated.vcsProvider === "github" ? updated.vcsUrl : null,
534
+ ]);
535
+ }
536
+ return updated;
537
+ });
538
+
539
+ return row ? rowToAgentTask(row) : task;
540
+ }
541
+
542
+ export async function cancelTask(id: string, reason?: string): Promise<AgentTask | null> {
543
+ const oldTask = await getTaskById(id);
544
+ if (!oldTask) return null;
545
+
546
+ // Only cancel tasks that are not already in a terminal state
547
+ if (isTerminalTaskStatus(oldTask.status)) {
548
+ return null;
549
+ }
550
+
551
+ const finishedAt = new Date().toISOString();
552
+ const cancelReason = reason ?? "Cancelled by user";
553
+ // Status predicate re-checks the idempotency guard atomically (a racing
554
+ // terminal transition can land during the await above).
555
+ const row = await getDbClient().get<AgentTaskRow>(
556
+ `UPDATE agent_tasks SET status = 'cancelled', failureReason = ?, finishedAt = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
557
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded') RETURNING *`,
558
+ [cancelReason, finishedAt, id],
559
+ );
560
+
561
+ if (row && oldTask) {
562
+ dependencies.emitTaskLifecycleTelemetryAfterCommit(
563
+ "cancelled",
564
+ {
565
+ taskId: id,
566
+ source: oldTask.source,
567
+ agentId: oldTask.agentId ?? undefined,
568
+ previousStatus: oldTask.status,
569
+ durationMs: oldTask.createdAt
570
+ ? Date.now() - new Date(oldTask.createdAt).getTime()
571
+ : undefined,
572
+ },
573
+ (task) => task?.status === "cancelled",
574
+ );
575
+
576
+ try {
577
+ await dependencies.createLogEntry({
578
+ eventType: "task_status_change",
579
+ taskId: id,
580
+ agentId: row.agentId ?? undefined,
581
+ oldValue: oldTask.status,
582
+ newValue: "cancelled",
583
+ metadata: reason ? { reason } : undefined,
584
+ });
585
+ } catch {}
586
+ getDbClient().afterCommit(() => {
587
+ import("../../../workflows/event-bus")
588
+ .then(({ workflowEventBus }) => {
589
+ workflowEventBus.emit("task.cancelled", {
590
+ taskId: id,
591
+ agentId: row.agentId,
592
+ workflowRunId: row.workflowRunId,
593
+ workflowRunStepId: row.workflowRunStepId,
594
+ });
595
+ })
596
+ .catch((err) =>
597
+ console.error(
598
+ "[db] task.cancelled event not emitted:",
599
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
600
+ ),
601
+ );
602
+ });
603
+ try {
604
+ await dependencies.promotePendingSteeringForTask(
605
+ id,
606
+ "Task was cancelled before steering was delivered",
607
+ );
608
+ } catch (error) {
609
+ console.error(
610
+ "[cancelTask] pending steering promotion error:",
611
+ scrubSecrets(error instanceof Error ? error.message : String(error)),
612
+ );
613
+ }
614
+
615
+ try {
616
+ await dependencies.cascadeFailDependents(id, "cancelled");
617
+ } catch (err) {
618
+ console.error("[cancelTask] cascade-fail dependents error:", err);
619
+ }
620
+ }
621
+
622
+ return row ? rowToAgentTask(row) : null;
623
+ }
624
+
625
+ /**
626
+ * Supersede a task: mark it as `superseded` (terminal) so a fresh "resume"
627
+ * follow-up task can pick up where it left off. Used by the graceful-shutdown
628
+ * path and the `POST /api/tasks/:id/supersede` route. Returns null if the task
629
+ * is already terminal (mirrors `completeTask` / `cancelTask` idempotency).
630
+ *
631
+ * Writes a `task_superseded` agent_log with `{ reason, resumeTaskId }` payload
632
+ * and emits a `task.superseded` workflow event. The caller is responsible for
633
+ * creating the resume follow-up (via `createResumeFollowUp`) and passing the
634
+ * resulting id as `resumeTaskId`.
635
+ */
636
+ export async function supersedeTask(
637
+ id: string,
638
+ args: { reason: string; resumeTaskId: string | null },
639
+ ): Promise<AgentTask | null> {
640
+ const oldTask = await getTaskById(id);
641
+ if (!oldTask) return null;
642
+
643
+ // Idempotency guard: don't re-supersede a task already in a terminal state.
644
+ if (isTerminalTaskStatus(oldTask.status)) {
645
+ return null;
646
+ }
647
+
648
+ const finishedAt = new Date().toISOString();
649
+ const row = await getDbClient().get<AgentTaskRow>(
650
+ `UPDATE agent_tasks
651
+ SET status = 'superseded',
652
+ finishedAt = ?,
653
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
654
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
655
+ RETURNING *`,
656
+ [finishedAt, id],
657
+ );
658
+
659
+ if (row && oldTask) {
660
+ dependencies.emitTaskLifecycleTelemetryAfterCommit(
661
+ "superseded",
662
+ {
663
+ taskId: id,
664
+ source: oldTask.source,
665
+ ...dependencies.taskContextForTelemetry(oldTask),
666
+ agentId: row.agentId ?? undefined,
667
+ reason: args.reason,
668
+ durationMs: oldTask.createdAt
669
+ ? Date.now() - new Date(oldTask.createdAt).getTime()
670
+ : undefined,
671
+ },
672
+ (task) => task?.status === "superseded",
673
+ );
674
+
675
+ try {
676
+ await dependencies.createLogEntry({
677
+ eventType: "task_superseded",
678
+ taskId: id,
679
+ agentId: row.agentId ?? undefined,
680
+ oldValue: oldTask.status,
681
+ newValue: "superseded",
682
+ metadata: { reason: args.reason, resumeTaskId: args.resumeTaskId },
683
+ });
684
+ } catch {}
685
+ getDbClient().afterCommit(() => {
686
+ import("../../../workflows/event-bus")
687
+ .then(({ workflowEventBus }) => {
688
+ workflowEventBus.emit("task.superseded", {
689
+ taskId: id,
690
+ reason: args.reason,
691
+ resumeTaskId: args.resumeTaskId,
692
+ agentId: row.agentId,
693
+ workflowRunId: row.workflowRunId,
694
+ workflowRunStepId: row.workflowRunStepId,
695
+ });
696
+ })
697
+ .catch((err) =>
698
+ console.error(
699
+ "[db] task.superseded event not emitted:",
700
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
701
+ ),
702
+ );
703
+ });
704
+
705
+ try {
706
+ await dependencies.cascadeFailDependents(id, "superseded");
707
+ } catch (err) {
708
+ console.error("[supersedeTask] cascade-fail dependents error:", err);
709
+ }
710
+ }
711
+
712
+ return row ? rowToAgentTask(row) : null;
713
+ }
714
+
715
+ export async function backfillSupersedeTaskResumeTaskId(
716
+ taskId: string,
717
+ resumeTaskId: string,
718
+ ): Promise<boolean> {
719
+ const row = await getDbClient().get<{ id: string; metadata: string | null }>(
720
+ `SELECT id, metadata
721
+ FROM agent_log
722
+ WHERE taskId = ? AND eventType = 'task_superseded'
723
+ ORDER BY createdAt DESC
724
+ LIMIT 1`,
725
+ [taskId],
726
+ );
727
+ if (!row) return false;
728
+
729
+ let metadata: Record<string, unknown> = {};
730
+ if (row.metadata) {
731
+ try {
732
+ metadata = JSON.parse(row.metadata) as Record<string, unknown>;
733
+ } catch {
734
+ metadata = {};
735
+ }
736
+ }
737
+ metadata.resumeTaskId = resumeTaskId;
738
+
739
+ const result = await getDbClient().run("UPDATE agent_log SET metadata = ? WHERE id = ?", [
740
+ JSON.stringify(metadata),
741
+ row.id,
742
+ ]);
743
+ return result.changes > 0;
744
+ }
745
+
746
+ /**
747
+ * Pause a task that is currently in progress.
748
+ * Used during graceful shutdown to allow tasks to resume after container restart.
749
+ * Unlike failTask, paused tasks retain their agent assignment and can be resumed.
750
+ */
751
+ export async function pauseTask(id: string): Promise<AgentTask | null> {
752
+ const oldTask = await getTaskById(id);
753
+ if (!oldTask) return null;
754
+
755
+ // Only pause tasks that are in progress
756
+ if (oldTask.status !== "in_progress") {
757
+ return null;
758
+ }
759
+
760
+ const row = await getDbClient().get<AgentTaskRow>(
761
+ `UPDATE agent_tasks
762
+ SET status = 'paused',
763
+ was_paused = 1,
764
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
765
+ WHERE id = ? AND status = 'in_progress'
766
+ RETURNING *`,
767
+ [id],
768
+ );
769
+
770
+ if (row && oldTask) {
771
+ try {
772
+ await dependencies.createLogEntry({
773
+ eventType: "task_status_change",
774
+ taskId: id,
775
+ agentId: row.agentId ?? undefined,
776
+ oldValue: oldTask.status,
777
+ newValue: "paused",
778
+ metadata: { pausedForShutdown: true },
779
+ });
780
+ } catch {}
781
+ }
782
+
783
+ return row ? rowToAgentTask(row) : null;
784
+ }
785
+
786
+ /**
787
+ * Resume a paused task - transitions it back to in_progress.
788
+ * Called when worker restarts and picks up paused work.
789
+ */
790
+ export async function resumeTask(taskId: string): Promise<AgentTask | null> {
791
+ const oldTask = await getTaskById(taskId);
792
+ if (!oldTask || oldTask.status !== "paused") return null;
793
+
794
+ const row = await getDbClient().get<AgentTaskRow>(
795
+ `UPDATE agent_tasks
796
+ SET status = 'in_progress',
797
+ was_paused = 1,
798
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
799
+ WHERE id = ? AND status = 'paused'
800
+ RETURNING *`,
801
+ [taskId],
802
+ );
803
+
804
+ if (row && oldTask) {
805
+ try {
806
+ await dependencies.createLogEntry({
807
+ eventType: "task_status_change",
808
+ taskId,
809
+ agentId: row.agentId ?? undefined,
810
+ oldValue: "paused",
811
+ newValue: "in_progress",
812
+ metadata: { resumed: true },
813
+ });
814
+ } catch {}
815
+ }
816
+
817
+ return row ? rowToAgentTask(row) : null;
818
+ }
819
+
820
+ /**
821
+ * Get paused tasks for a specific agent.
822
+ * Used on startup to resume tasks that were interrupted by deployment.
823
+ * Returns tasks ordered by creation time (oldest first for FIFO).
824
+ */
825
+ export async function getPausedTasksForAgent(agentId: string): Promise<AgentTask[]> {
826
+ const rows = await getDbClient().query<AgentTaskRow>(
827
+ `SELECT * FROM agent_tasks
828
+ WHERE agentId = ? AND status = 'paused'
829
+ ORDER BY createdAt ASC, rowid ASC`,
830
+ [agentId],
831
+ );
832
+ return rows.map(rowToAgentTask);
833
+ }
834
+
835
+ export async function getOrphanedInProgressTasksForAgent(
836
+ agentId: string,
837
+ minAgeSeconds = 60,
838
+ ): Promise<AgentTask[]> {
839
+ const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
840
+ const rows = await getDbClient().query<AgentTaskRow>(
841
+ `SELECT t.* FROM agent_tasks t
842
+ LEFT JOIN active_sessions s ON s.taskId = t.id
843
+ WHERE t.agentId = ?
844
+ AND t.status = 'in_progress'
845
+ AND t.claudeSessionId IS NULL
846
+ AND t.lastUpdatedAt < ?
847
+ AND s.id IS NULL
848
+ AND t.finishedAt IS NULL
849
+ ORDER BY t.createdAt ASC, t.rowid ASC`,
850
+ [agentId, cutoff],
851
+ );
852
+ return rows.map(rowToAgentTask);
853
+ }
854
+
855
+ export async function resetOrphanedInProgressTasksForAgent(
856
+ agentId: string,
857
+ minAgeSeconds = 60,
858
+ ): Promise<AgentTask[]> {
859
+ const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
860
+ const rows = await getDbClient().query<AgentTaskRow>(
861
+ `UPDATE agent_tasks
862
+ SET status = 'pending',
863
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
864
+ WHERE id IN (
865
+ SELECT t.id FROM agent_tasks t
866
+ LEFT JOIN active_sessions s ON s.taskId = t.id
867
+ WHERE t.agentId = ?
868
+ AND t.status = 'in_progress'
869
+ AND t.claudeSessionId IS NULL
870
+ AND t.lastUpdatedAt < ?
871
+ AND s.id IS NULL
872
+ AND t.finishedAt IS NULL
873
+ )
874
+ RETURNING *`,
875
+ [agentId, cutoff],
876
+ );
877
+
878
+ for (const row of rows) {
879
+ try {
880
+ await dependencies.createLogEntry({
881
+ eventType: "task_status_change",
882
+ taskId: row.id,
883
+ agentId,
884
+ oldValue: "in_progress",
885
+ newValue: "pending",
886
+ metadata: { orphanedInProgressRecovery: true },
887
+ });
888
+ } catch {}
889
+ }
890
+
891
+ return rows.map(rowToAgentTask);
892
+ }
893
+
894
+ /**
895
+ * Get recently cancelled tasks for an agent.
896
+ * Used by hooks to detect task cancellation and stop the worker loop.
897
+ * Returns tasks cancelled within the last 5 minutes.
898
+ */
899
+ export async function getRecentlyCancelledTasksForAgent(agentId: string): Promise<AgentTask[]> {
900
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
901
+ const rows = await getDbClient().query<AgentTaskRow>(
902
+ `SELECT * FROM agent_tasks
903
+ WHERE agentId = ?
904
+ AND status = 'cancelled'
905
+ AND finishedAt > ?
906
+ ORDER BY finishedAt DESC`,
907
+ [agentId, fiveMinutesAgo],
908
+ );
909
+ return rows.map(rowToAgentTask);
910
+ }
911
+
912
+ export async function deleteTask(id: string): Promise<boolean> {
913
+ const result = await getDbClient().run("DELETE FROM agent_tasks WHERE id = ?", [id]);
914
+ return result.changes > 0;
915
+ }
916
+
917
+ export async function updateTaskProgress(id: string, progress: string): Promise<AgentTask | null> {
918
+ const scrubbedProgress = scrubSecrets(progress);
919
+ const row = await getDbClient().get<AgentTaskRow>(
920
+ `UPDATE agent_tasks SET progress = ?,
921
+ status = CASE WHEN status IN ('completed', 'failed', 'cancelled', 'superseded') THEN status ELSE 'in_progress' END,
922
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
923
+ WHERE id = ? RETURNING *`,
924
+ [scrubbedProgress, id],
925
+ );
926
+ if (row) {
927
+ try {
928
+ await dependencies.createLogEntry({
929
+ eventType: "task_progress",
930
+ taskId: id,
931
+ agentId: row.agentId ?? undefined,
932
+ newValue: scrubbedProgress,
933
+ });
934
+ } catch {}
935
+ getDbClient().afterCommit(() => {
936
+ import("../../../workflows/event-bus")
937
+ .then(({ workflowEventBus }) => {
938
+ workflowEventBus.emit("task.progress", {
939
+ taskId: id,
940
+ progress: scrubbedProgress,
941
+ agentId: row.agentId,
942
+ });
943
+ })
944
+ .catch((err) =>
945
+ console.error(
946
+ "[db] task.progress event not emitted:",
947
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
948
+ ),
949
+ );
950
+ });
951
+ }
952
+ return row ? rowToAgentTask(row) : null;
953
+ }