@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,556 @@
1
+ import type {
2
+ Agent,
3
+ AgentAvatar,
4
+ AgentCredStatus,
5
+ AgentLog,
6
+ AgentLogEventType,
7
+ AgentSkill,
8
+ AgentStatus,
9
+ AgentTask,
10
+ ProviderName,
11
+ RoutingAffinity,
12
+ } from "../../types";
13
+ import { AgentAvatarSchema } from "../../types";
14
+ import { isEnvFlagEnabled } from "../../utils/env-flag";
15
+ import { getDbClient } from "./runtime";
16
+
17
+ type AgentDependencies = {
18
+ createLogEntry: (entry: {
19
+ eventType: AgentLogEventType;
20
+ agentId?: string;
21
+ oldValue?: string;
22
+ newValue?: string;
23
+ }) => Promise<AgentLog>;
24
+ installSystemDefaultSkillsForAgent: (agentId: string) => Promise<AgentSkill[]>;
25
+ };
26
+
27
+ let dependencies: AgentDependencies;
28
+
29
+ // The facade registers deferred calls while it still owns logging and skills.
30
+ export function configureAgentDependencies(value: AgentDependencies): void {
31
+ dependencies = value;
32
+ }
33
+
34
+ // ============================================================================
35
+ // Agent Queries
36
+ // ============================================================================
37
+
38
+ export type AgentRow = {
39
+ id: string;
40
+ name: string;
41
+ isLead: number;
42
+ status: AgentStatus;
43
+ description: string | null;
44
+ role: string | null;
45
+ capabilities: string | null;
46
+ maxTasks: number | null;
47
+ emptyPollCount: number | null;
48
+ claudeMd: string | null;
49
+ soulMd: string | null;
50
+ identityMd: string | null;
51
+ setupScript: string | null;
52
+ toolsMd: string | null;
53
+ heartbeatMd: string | null;
54
+ lastActivityAt: string | null;
55
+ provider: string | null;
56
+ createdAt: string;
57
+ lastUpdatedAt: string;
58
+ /** JSON array of env-var names; populated only when status is `waiting_for_credentials`. */
59
+ credentialMissing: string | null;
60
+ /** Phase 1.5: per-agent harness provider pushed on worker registration. */
61
+ harness_provider: string | null;
62
+ /** Migration 055: worker-self-reported credential snapshot (JSON of AgentCredStatus). NULL = unreported. */
63
+ cred_status: string | null;
64
+ /** Migration 119: custom avatar (JSON of AgentAvatar). NULL = deterministic hash-derived fallback. */
65
+ avatar: string | null;
66
+ };
67
+
68
+ /** Safe-parse the `avatar` JSON column. Malformed/invalid content (e.g. a
69
+ * hand-edited row, or a future downgrade) falls back to `null` so rendering
70
+ * always has a deterministic path — never throws. */
71
+ function parseAgentAvatar(raw: string | null): AgentAvatar | null {
72
+ if (!raw) return null;
73
+ try {
74
+ const parsed = AgentAvatarSchema.safeParse(JSON.parse(raw));
75
+ return parsed.success ? parsed.data : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Map an agent row to the `Agent` shape. When `slim` is true the six identity
83
+ * markdown blobs (`claudeMd`/`soulMd`/`identityMd`/`toolsMd`/`heartbeatMd`/
84
+ * `setupScript`) are omitted — they bloat list responses by ~16 KB/agent and
85
+ * are never needed at the swarm-overview level. Fetch them via
86
+ * `GET /api/agents/{id}` when required.
87
+ */
88
+ export function rowToAgent(row: AgentRow, slim = false): Agent {
89
+ const base: Agent = {
90
+ id: row.id,
91
+ name: row.name,
92
+ isLead: row.isLead === 1,
93
+ status: row.status,
94
+ description: row.description ?? undefined,
95
+ role: row.role ?? undefined,
96
+ capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
97
+ maxTasks: row.maxTasks ?? 1,
98
+ emptyPollCount: row.emptyPollCount ?? 0,
99
+ lastActivityAt: row.lastActivityAt ?? undefined,
100
+ provider: (row.provider as ProviderName | null) ?? undefined,
101
+ harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
102
+ createdAt: row.createdAt,
103
+ lastUpdatedAt: row.lastUpdatedAt,
104
+ credentialMissing: row.credentialMissing
105
+ ? (JSON.parse(row.credentialMissing) as string[])
106
+ : null,
107
+ credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
108
+ avatar: parseAgentAvatar(row.avatar),
109
+ };
110
+ if (slim) return base;
111
+ return {
112
+ ...base,
113
+ claudeMd: row.claudeMd ?? undefined,
114
+ soulMd: row.soulMd ?? undefined,
115
+ identityMd: row.identityMd ?? undefined,
116
+ setupScript: row.setupScript ?? undefined,
117
+ toolsMd: row.toolsMd ?? undefined,
118
+ heartbeatMd: row.heartbeatMd ?? undefined,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Phase 3 of the worker credential safe-loop plan.
124
+ *
125
+ * `ready=true` clears the waiting state — the agent transitions to `idle`
126
+ * and the dispatcher will start handing it tasks again.
127
+ *
128
+ * `ready=false` parks the agent on `waiting_for_credentials` with the env-var
129
+ * names it's blocked on. The capacity dispatch query already filters
130
+ * `status === 'idle'` so the new value is implicitly excluded with no other
131
+ * code change.
132
+ */
133
+ export async function updateAgentCredentialState(
134
+ agentId: string,
135
+ ready: boolean,
136
+ missing: string[] | null,
137
+ ): Promise<Agent | null> {
138
+ const prev = await getAgentById(agentId);
139
+ const status: AgentStatus = ready ? "idle" : "waiting_for_credentials";
140
+ const missingJson = ready ? null : missing && missing.length > 0 ? JSON.stringify(missing) : null;
141
+ const row = await getDbClient().get<AgentRow>(
142
+ "UPDATE agents SET status = ?, credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
143
+ [status, missingJson, agentId],
144
+ );
145
+ // Only clear the accumulated empty-poll count on a genuine recovery
146
+ // (waiting_for_credentials -> ready), so routine post-task `ready:true`
147
+ // reports don't clobber a legitimately accumulated count and defeat the
148
+ // MAX_EMPTY_POLLS polling gate.
149
+ if (ready && prev?.status === "waiting_for_credentials") await resetEmptyPollCount(agentId);
150
+ return row ? rowToAgent(row) : null;
151
+ }
152
+
153
+ /**
154
+ * Record which env vars a worker is missing without touching status — the
155
+ * logical status is derived from runtime readiness in multi-runtime mode.
156
+ */
157
+ export async function updateAgentCredentialMissing(
158
+ agentId: string,
159
+ missing: string[] | null,
160
+ ): Promise<void> {
161
+ const json = missing && missing.length > 0 ? JSON.stringify(missing) : null;
162
+ await getDbClient().run(
163
+ "UPDATE agents SET credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
164
+ [json, agentId],
165
+ );
166
+ }
167
+
168
+ export async function createAgent(
169
+ agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
170
+ ): Promise<Agent> {
171
+ const id = agent.id ?? crypto.randomUUID();
172
+ const maxTasks = agent.maxTasks ?? 1;
173
+ const row = await getDbClient().get<AgentRow>(
174
+ "INSERT INTO agents (id, name, isLead, status, maxTasks, provider, harness_provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
175
+ [
176
+ id,
177
+ agent.name,
178
+ agent.isLead ? 1 : 0,
179
+ agent.status,
180
+ maxTasks,
181
+ agent.provider ?? null,
182
+ agent.harnessProvider ?? null,
183
+ ],
184
+ );
185
+ if (!row) throw new Error("Failed to create agent");
186
+ try {
187
+ await dependencies.installSystemDefaultSkillsForAgent(id);
188
+ } catch (err) {
189
+ console.warn(
190
+ "[db] Failed to install system-default skills for new agent:",
191
+ (err as Error).message,
192
+ );
193
+ }
194
+ try {
195
+ await dependencies.createLogEntry({
196
+ eventType: "agent_joined",
197
+ agentId: id,
198
+ newValue: agent.status,
199
+ });
200
+ } catch {}
201
+ return rowToAgent(row);
202
+ }
203
+
204
+ export async function getAgentById(id: string): Promise<Agent | null> {
205
+ const row = await getDbClient().get<AgentRow>("SELECT * FROM agents WHERE id = ?", [id]);
206
+ return row ? rowToAgent(row) : null;
207
+ }
208
+
209
+ export async function getAllAgents(opts?: { slim?: boolean }): Promise<Agent[]> {
210
+ const rows = await getDbClient().query<AgentRow>("SELECT * FROM agents ORDER BY name");
211
+ return rows.map((row) => rowToAgent(row, opts?.slim ?? false));
212
+ }
213
+
214
+ export async function getLeadAgent(): Promise<Agent | null> {
215
+ const leads = (await getAllAgents()).filter((a) => a.isLead);
216
+ // Prefer a usable (non-offline) lead so callers route to one that can actually
217
+ // poll — e.g. an old offline lead must not shadow a live replacement. Falls
218
+ // back to any lead (incl. offline) so existing "is there a lead at all?"
219
+ // semantics are preserved; callers that require a live lead must check
220
+ // `status` themselves (see escalateUnreclaimedResumes).
221
+ return leads.find((a) => a.status !== "offline") ?? leads[0] ?? null;
222
+ }
223
+
224
+ export async function updateAgentStatus(id: string, status: AgentStatus): Promise<Agent | null> {
225
+ const oldAgent = await getAgentById(id);
226
+ const row = await getDbClient().get<AgentRow>(
227
+ "UPDATE agents SET status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
228
+ [status, id],
229
+ );
230
+ if (row && oldAgent) {
231
+ try {
232
+ await dependencies.createLogEntry({
233
+ eventType: "agent_status_change",
234
+ agentId: id,
235
+ oldValue: oldAgent.status,
236
+ newValue: status,
237
+ });
238
+ } catch {}
239
+ }
240
+ return row ? rowToAgent(row) : null;
241
+ }
242
+
243
+ export async function updateAgentMaxTasks(id: string, maxTasks: number): Promise<Agent | null> {
244
+ const row = await getDbClient().get<AgentRow>(
245
+ `UPDATE agents SET maxTasks = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
246
+ WHERE id = ? RETURNING *`,
247
+ [maxTasks, id],
248
+ );
249
+ return row ? rowToAgent(row) : null;
250
+ }
251
+
252
+ export async function updateAgentProvider(
253
+ id: string,
254
+ provider: ProviderName,
255
+ ): Promise<Agent | null> {
256
+ const row = await getDbClient().get<AgentRow>(
257
+ `UPDATE agents SET provider = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
258
+ WHERE id = ? RETURNING *`,
259
+ [provider, id],
260
+ );
261
+ return row ? rowToAgent(row) : null;
262
+ }
263
+
264
+ /**
265
+ * Phase 1.5 (cloud-personalization): set the per-agent `harness_provider`
266
+ * column. Pass `null` to clear. Validation against the canonical provider
267
+ * list happens at the API layer via `ProviderNameSchema`.
268
+ *
269
+ * Returns the updated row, or null if the agent does not exist.
270
+ */
271
+ export async function setAgentHarnessProvider(
272
+ id: string,
273
+ provider: ProviderName | null,
274
+ ): Promise<Agent | null> {
275
+ const row = await getDbClient().get<AgentRow>(
276
+ `UPDATE agents SET harness_provider = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
277
+ WHERE id = ? RETURNING *`,
278
+ [provider, id],
279
+ );
280
+ return row ? rowToAgent(row) : null;
281
+ }
282
+
283
+ /**
284
+ * Migration 055 — write the worker-self-reported credential snapshot.
285
+ * Pass `null` to clear (e.g. on agent re-registration). Validation against
286
+ * the JSON shape happens at the API layer via `AgentCredStatusSchema`.
287
+ *
288
+ * Worker reports this alongside the existing `updateAgentCredentialState`
289
+ * call; we keep the writes in two functions so the dispatch pattern stays
290
+ * one-row-one-fact, and the PATCH handler can choose which to call based
291
+ * on which fields the request body carried.
292
+ */
293
+ export async function updateAgentCredStatus(
294
+ id: string,
295
+ credStatus: AgentCredStatus | null,
296
+ ): Promise<Agent | null> {
297
+ const json = credStatus ? JSON.stringify(credStatus) : null;
298
+ const row = await getDbClient().get<AgentRow>(
299
+ `UPDATE agents SET cred_status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
300
+ WHERE id = ? RETURNING *`,
301
+ [json, id],
302
+ );
303
+ return row ? rowToAgent(row) : null;
304
+ }
305
+
306
+ /**
307
+ * Migration 055 — read all agents whose `harness_provider` matches a given
308
+ * provider, with their reported `cred_status`. Used by the credential-status
309
+ * API endpoint to roll up "is this provider working across the fleet?".
310
+ *
311
+ * Agents with NULL `cred_status` (never reported, or CRED_CHECK_DISABLE=1)
312
+ * are still returned — the caller surfaces them as "unreported".
313
+ */
314
+ export async function listAgentsWithCredStatusByProvider(provider: string): Promise<Agent[]> {
315
+ const rows = await getDbClient().query<AgentRow>(
316
+ `SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`,
317
+ [provider],
318
+ );
319
+ return rows.map((row) => rowToAgent(row));
320
+ }
321
+
322
+ /**
323
+ * Phase 1.5 (cloud-personalization): aggregate count of registered agents
324
+ * by `harness_provider`. NULL rows (agents that registered before the
325
+ * migration or never pushed a value) are excluded — they show up in the
326
+ * total agent count but not here.
327
+ *
328
+ * Used by future fleet displays. Not consumed in this phase.
329
+ */
330
+ export async function getAgentHarnessProviders(): Promise<
331
+ Array<{ provider: string; count: number }>
332
+ > {
333
+ const rows = await getDbClient().query<{ provider: string; count: number }>(
334
+ `SELECT harness_provider AS provider, COUNT(*) AS count
335
+ FROM agents
336
+ WHERE harness_provider IS NOT NULL
337
+ GROUP BY harness_provider
338
+ ORDER BY harness_provider`,
339
+ );
340
+ return rows.map((r) => ({ provider: r.provider, count: r.count }));
341
+ }
342
+
343
+ export async function updateAgentActivity(id: string): Promise<void> {
344
+ await getDbClient().run(
345
+ `UPDATE agents SET lastActivityAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
346
+ [id],
347
+ );
348
+ }
349
+
350
+ // ============================================================================
351
+ // Agent Poll Tracking Functions
352
+ // ============================================================================
353
+
354
+ /** Maximum consecutive empty polls before agent should stop polling */
355
+ export const MAX_EMPTY_POLLS = 2;
356
+
357
+ /**
358
+ * Increment the empty poll count for an agent.
359
+ * Returns the new count after incrementing.
360
+ */
361
+ export async function incrementEmptyPollCount(agentId: string): Promise<number> {
362
+ const row = await getDbClient().get<{ emptyPollCount: number }>(
363
+ `UPDATE agents
364
+ SET emptyPollCount = emptyPollCount + 1,
365
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
366
+ WHERE id = ?
367
+ RETURNING emptyPollCount`,
368
+ [agentId],
369
+ );
370
+ return row?.emptyPollCount ?? 0;
371
+ }
372
+
373
+ /**
374
+ * Reset the empty poll count for an agent to zero.
375
+ * Called when a task is assigned or agent re-registers.
376
+ */
377
+ export async function resetEmptyPollCount(agentId: string): Promise<void> {
378
+ await getDbClient().run(
379
+ `UPDATE agents
380
+ SET emptyPollCount = 0,
381
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
382
+ WHERE id = ?`,
383
+ [agentId],
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Check if an agent has exceeded the maximum empty poll count.
389
+ */
390
+ export async function shouldBlockPolling(agentId: string): Promise<boolean> {
391
+ const agent = await getAgentById(agentId);
392
+ return (agent?.emptyPollCount ?? 0) >= MAX_EMPTY_POLLS;
393
+ }
394
+
395
+ export async function deleteAgent(id: string): Promise<boolean> {
396
+ const agent = await getAgentById(id);
397
+ if (agent) {
398
+ try {
399
+ await dependencies.createLogEntry({
400
+ eventType: "agent_left",
401
+ agentId: id,
402
+ oldValue: agent.status,
403
+ });
404
+ } catch {}
405
+ }
406
+ const result = await getDbClient().run("DELETE FROM agents WHERE id = ?", [id]);
407
+ return result.changes > 0;
408
+ }
409
+
410
+ // ============================================================================
411
+ // Agent Capacity Functions
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Get the count of active (in_progress) tasks for an agent.
416
+ * Used to determine current capacity usage.
417
+ */
418
+ /**
419
+ * Tasks occupying one of the agent's concurrency slots.
420
+ *
421
+ * A claimed offer counts too — omitting it let a second concurrent poll take
422
+ * another task past the limit. It is counted only through `offeredTo`, the
423
+ * agent actually reviewing it: `agentId` on an offer may still be the lead
424
+ * that created it, which would otherwise consume the lead's own capacity.
425
+ */
426
+ export async function getActiveTaskCount(agentId: string): Promise<number> {
427
+ const result = await getDbClient().get<{ count: number }>(
428
+ `SELECT COUNT(*) as count FROM agent_tasks
429
+ WHERE (agentId = ? AND status = 'in_progress')
430
+ OR (offeredTo = ? AND status = 'reviewing')`,
431
+ [agentId, agentId],
432
+ );
433
+ return result?.count ?? 0;
434
+ }
435
+
436
+ /**
437
+ * Check if an agent has capacity to accept more tasks.
438
+ */
439
+ export async function hasCapacity(agentId: string): Promise<boolean> {
440
+ const agent = await getAgentById(agentId);
441
+ if (!agent) return false;
442
+ const activeCount = await getActiveTaskCount(agentId);
443
+ return activeCount < (agent.maxTasks ?? 1);
444
+ }
445
+
446
+ /**
447
+ * Get remaining capacity (available task slots) for an agent.
448
+ */
449
+ export async function getRemainingCapacity(agentId: string): Promise<number> {
450
+ const agent = await getAgentById(agentId);
451
+ if (!agent) return 0;
452
+ const activeCount = await getActiveTaskCount(agentId);
453
+ return Math.max(0, (agent.maxTasks ?? 1) - activeCount);
454
+ }
455
+
456
+ /**
457
+ * Update agent status based on current capacity.
458
+ * Agent is 'busy' when any tasks are in progress, 'idle' when none.
459
+ * Does not modify 'offline' status.
460
+ */
461
+ export async function updateAgentStatusFromCapacity(agentId: string): Promise<void> {
462
+ const agent = await getAgentById(agentId);
463
+ if (!agent || agent.status === "offline") return;
464
+ // `waiting_for_credentials` is owned by the worker's credential-wait
465
+ // tick — task-completion shouldn't accidentally promote a blocked agent
466
+ // back to idle.
467
+ if (agent.status === "waiting_for_credentials") return;
468
+
469
+ const activeCount = await getActiveTaskCount(agentId);
470
+ const newStatus = activeCount > 0 ? "busy" : "idle";
471
+
472
+ if (agent.status !== newStatus) {
473
+ await updateAgentStatus(agentId, newStatus);
474
+ }
475
+ }
476
+
477
+ // ============================================================================
478
+ // Routing Affinity (interrupted/pooled task role & capability gating)
479
+ // ============================================================================
480
+
481
+ /**
482
+ * Kill-switch for the pool eligibility gate (`isAgentEligibleForTask` and its
483
+ * callers: `claimTask`, `assignUnassignedTaskPending`,
484
+ * `getUnassignedTaskIdsForAgent`). ON by default. Set to `0` to restore
485
+ * pre-affinity behavior verbatim — mirrors the `HEARTBEAT_PIN_*_RESUME`
486
+ * rollback convention. A function (read dynamically), not a module-load-time
487
+ * const, so it can be toggled mid-test (see `isGracefulResumePinEnabled` in
488
+ * src/tasks/worker-follow-up.ts for the same pattern).
489
+ */
490
+ export function isPoolAffinityEnforcementEnabled(): boolean {
491
+ return isEnvFlagEnabled("POOL_AFFINITY_ENFORCEMENT", true);
492
+ }
493
+
494
+ /**
495
+ * Snapshot an agent's role/harness/capabilities into a `RoutingAffinity`
496
+ * blob, for stamping onto a continuation task (resume, retry) at the moment
497
+ * of interruption. Returns `null` when the agent row is already gone —
498
+ * callers fall back to the parent's own (inherited) `routingAffinity` via
499
+ * `createTaskExtended`'s parentTaskId inheritance block.
500
+ */
501
+ export async function buildRoutingAffinityFromAgent(
502
+ agentId: string,
503
+ ): Promise<RoutingAffinity | null> {
504
+ const agent = await getAgentById(agentId);
505
+ if (!agent) return null;
506
+ return {
507
+ sourceAgentId: agent.id,
508
+ role: agent.role,
509
+ harnessProvider: agent.harnessProvider ?? agent.provider ?? undefined,
510
+ capabilities: agent.capabilities ?? [],
511
+ };
512
+ }
513
+
514
+ /**
515
+ * The single eligibility gate every pool consumer (poll auto-claim,
516
+ * `task-action claim`, `autoAssignPoolTasks`) MUST use before handing a task
517
+ * to an agent. Exact-match on the snapshotted role string (no keyword
518
+ * taxonomy in v1); `harnessProvider` is informational only and never
519
+ * enforced (native session resume is deprecated). Missing role data on
520
+ * either side is treated as INELIGIBLE — never fail-open to "anyone" — so a
521
+ * capability-only requirement (no `role` set) can only ever be claimed by
522
+ * its `sourceAgentId`, and otherwise queues until the starvation escalation
523
+ * hands it to the Lead. Lead-only work is different: any Lead may claim it
524
+ * (subject to explicitly required capabilities), because its source/role is
525
+ * only recovery provenance and must not turn a worker's old role into a
526
+ * constraint on the Lead pool.
527
+ */
528
+ export function isAgentEligibleForTask(
529
+ agent: Pick<Agent, "id" | "isLead" | "role" | "capabilities">,
530
+ task: Pick<AgentTask, "routingAffinity" | "routingAffinityInvalid">,
531
+ ): boolean {
532
+ const affinity = task.routingAffinity;
533
+ // A malformed persisted blob is a security boundary failure, not an
534
+ // untagged task. Quarantine it from every assignment/claim path.
535
+ if (task.routingAffinityInvalid) return false;
536
+ if (!affinity) return true; // Untagged task — unchanged behavior.
537
+
538
+ const requiredCapabilities = affinity.capabilities ?? [];
539
+ const hasRequiredCapabilities = () => {
540
+ const agentCapabilities = new Set(agent.capabilities ?? []);
541
+ return requiredCapabilities.every((cap) => agentCapabilities.has(cap));
542
+ };
543
+ // Lead-only is an authorization boundary, never a best-effort pool hint or
544
+ // a source-agent exception. Its explicit capability requirements remain
545
+ // enforced even if the role/capability affinity kill-switch is enabled.
546
+ // Do not require an unrelated worker role/source to match a Lead-only task.
547
+ if (affinity.leadOnly) return agent.isLead && hasRequiredCapabilities();
548
+ if (!isPoolAffinityEnforcementEnabled()) return true;
549
+
550
+ if (affinity.sourceAgentId && affinity.sourceAgentId === agent.id) return true; // Own work.
551
+
552
+ if (!agent.role || !affinity.role) return false; // Missing role data — no fail-open.
553
+ if (agent.role !== affinity.role) return false;
554
+
555
+ return hasRequiredCapabilities();
556
+ }
@@ -0,0 +1,117 @@
1
+ import type { ChangeSource, ContextVersion, VersionableField } from "../../types";
2
+ import { getDbClient } from "./runtime";
3
+
4
+ export function computeContentHash(content: string): string {
5
+ const hasher = new Bun.CryptoHasher("sha256");
6
+ hasher.update(content);
7
+ return hasher.digest("hex");
8
+ }
9
+
10
+ type ContextVersionRow = {
11
+ id: string;
12
+ agentId: string;
13
+ field: string;
14
+ content: string;
15
+ version: number;
16
+ changeSource: string;
17
+ changedByAgentId: string | null;
18
+ changeReason: string | null;
19
+ contentHash: string;
20
+ previousVersionId: string | null;
21
+ createdAt: string;
22
+ };
23
+
24
+ function rowToContextVersion(row: ContextVersionRow): ContextVersion {
25
+ return {
26
+ id: row.id,
27
+ agentId: row.agentId,
28
+ field: row.field as VersionableField,
29
+ content: row.content,
30
+ version: row.version,
31
+ changeSource: row.changeSource as ChangeSource,
32
+ changedByAgentId: row.changedByAgentId,
33
+ changeReason: row.changeReason,
34
+ contentHash: row.contentHash,
35
+ previousVersionId: row.previousVersionId,
36
+ createdAt: row.createdAt,
37
+ };
38
+ }
39
+
40
+ export async function createContextVersion(params: {
41
+ agentId: string;
42
+ field: VersionableField;
43
+ content: string;
44
+ version: number;
45
+ changeSource: ChangeSource;
46
+ changedByAgentId?: string | null;
47
+ changeReason?: string | null;
48
+ contentHash: string;
49
+ previousVersionId?: string | null;
50
+ }): Promise<ContextVersion> {
51
+ const id = crypto.randomUUID();
52
+ const now = new Date().toISOString();
53
+
54
+ const row = await getDbClient().get<ContextVersionRow>(
55
+ `INSERT INTO context_versions (id, agentId, field, content, version, changeSource, changedByAgentId, changeReason, contentHash, previousVersionId, createdAt)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
57
+ [
58
+ id,
59
+ params.agentId,
60
+ params.field,
61
+ params.content,
62
+ params.version,
63
+ params.changeSource,
64
+ params.changedByAgentId ?? null,
65
+ params.changeReason ?? null,
66
+ params.contentHash,
67
+ params.previousVersionId ?? null,
68
+ now,
69
+ ],
70
+ );
71
+
72
+ if (!row) throw new Error("Failed to create context version");
73
+ return rowToContextVersion(row);
74
+ }
75
+
76
+ export async function getLatestContextVersion(
77
+ agentId: string,
78
+ field: VersionableField,
79
+ ): Promise<ContextVersion | null> {
80
+ const row = await getDbClient().get<ContextVersionRow>(
81
+ `SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT 1`,
82
+ [agentId, field],
83
+ );
84
+
85
+ return row ? rowToContextVersion(row) : null;
86
+ }
87
+
88
+ export async function getContextVersion(id: string): Promise<ContextVersion | null> {
89
+ const row = await getDbClient().get<ContextVersionRow>(
90
+ `SELECT * FROM context_versions WHERE id = ?`,
91
+ [id],
92
+ );
93
+
94
+ return row ? rowToContextVersion(row) : null;
95
+ }
96
+
97
+ export async function getContextVersionHistory(params: {
98
+ agentId: string;
99
+ field?: VersionableField;
100
+ limit?: number;
101
+ }): Promise<ContextVersion[]> {
102
+ const limit = params.limit ?? 10;
103
+
104
+ if (params.field) {
105
+ const rows = await getDbClient().query<ContextVersionRow>(
106
+ `SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT ?`,
107
+ [params.agentId, params.field, limit],
108
+ );
109
+ return rows.map(rowToContextVersion);
110
+ }
111
+
112
+ const rows = await getDbClient().query<ContextVersionRow>(
113
+ `SELECT * FROM context_versions WHERE agentId = ? ORDER BY createdAt DESC LIMIT ?`,
114
+ [params.agentId, limit],
115
+ );
116
+ return rows.map(rowToContextVersion);
117
+ }