@desplega.ai/agent-swarm 2.7.2 → 2.7.3

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 (220) hide show
  1. package/README.md +6 -0
  2. package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
  3. package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
  4. package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
  5. package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
  6. package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
  7. package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
  8. package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
  9. package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
  10. package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
  11. package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
  12. package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
  13. package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
  14. package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
  15. package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
  16. package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
  17. package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
  18. package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
  19. package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
  20. package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
  21. package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
  22. package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
  23. package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
  24. package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
  25. package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
  26. package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
  27. package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
  28. package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
  29. package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
  30. package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
  31. package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
  32. package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
  33. package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
  34. package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
  35. package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
  36. package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
  37. package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
  38. package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
  39. package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
  40. package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
  41. package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
  42. package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
  43. package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
  44. package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
  45. package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
  46. package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
  47. package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
  48. package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
  49. package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
  50. package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
  51. package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
  52. package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
  53. package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
  54. package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
  55. package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
  56. package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
  57. package/dist/cli.js +19 -19
  58. package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
  59. package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
  60. package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
  61. package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
  62. package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
  63. package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
  64. package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
  65. package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
  66. package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
  67. package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
  68. package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
  69. package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
  70. package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
  71. package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
  72. package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
  73. package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
  74. package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
  75. package/dist/handlers-ss5948rq.js +42 -0
  76. package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
  77. package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
  78. package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
  79. package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
  80. package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
  81. package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
  82. package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
  83. package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
  84. package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
  85. package/dist/index-z3b1vfjx.js +44 -0
  86. package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
  87. package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
  88. package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
  89. package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
  90. package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
  91. package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
  92. package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
  93. package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
  94. package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
  95. package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
  96. package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
  97. package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
  98. package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
  99. package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
  100. package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
  101. package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
  102. package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
  103. package/openapi.json +134 -3
  104. package/package.json +5 -5
  105. package/src/be/db/agents.ts +16 -1
  106. package/src/be/db/runtime.ts +25 -15
  107. package/src/be/db/session-summaries.ts +120 -0
  108. package/src/be/db/tasks/read.ts +7 -4
  109. package/src/be/db/tasks/search-index.ts +159 -0
  110. package/src/be/db/tasks/write.ts +3 -0
  111. package/src/be/db-queries/slack-inbound.ts +77 -0
  112. package/src/be/db.ts +27 -8
  113. package/src/be/mcp-overflow-sweep.ts +39 -0
  114. package/src/be/migrations/253_session_summaries.sql +300 -0
  115. package/src/be/migrations/254_query_audit_indexes.sql +25 -0
  116. package/src/be/migrations/256_agent_slack_icon.sql +2 -0
  117. package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
  118. package/src/comb/config.ts +6 -2
  119. package/src/comb/requester.ts +69 -0
  120. package/src/comb/review-batch.ts +24 -2
  121. package/src/commands/onboard/agent-fs-version.ts +1 -1
  122. package/src/commands/onboard/compose-generator.ts +7 -2
  123. package/src/commands/onboard/env-generator.ts +8 -3
  124. package/src/commands/runner.ts +41 -24
  125. package/src/fs/agent-fs-provider.ts +9 -0
  126. package/src/heartbeat/heartbeat.ts +13 -6
  127. package/src/http/agents.ts +10 -2
  128. package/src/http/index.ts +15 -0
  129. package/src/http/integrations.ts +20 -0
  130. package/src/http/list-total-cache.ts +62 -0
  131. package/src/http/sessions.ts +9 -2
  132. package/src/http/slack-inbound.ts +1 -0
  133. package/src/http/status.ts +4 -1
  134. package/src/http/tasks.ts +12 -2
  135. package/src/providers/amp-adapter.ts +1 -1
  136. package/src/providers/cursor-adapter.ts +1 -1
  137. package/src/providers/dsh-adapter.ts +1 -1
  138. package/src/queue-stall-alarm.ts +4 -1
  139. package/src/slack/actions.ts +2 -2
  140. package/src/slack/app-icon.ts +50 -0
  141. package/src/slack/app.ts +13 -1
  142. package/src/slack/assistant.ts +22 -1
  143. package/src/slack/event-dedup.test.ts +11 -0
  144. package/src/slack/event-dedup.ts +8 -4
  145. package/src/slack/inbound-dispatch.ts +58 -0
  146. package/src/slack/render-v2.ts +7 -8
  147. package/src/slack/responses.ts +21 -8
  148. package/src/slack/socket-ack.ts +237 -0
  149. package/src/slack/thread-buffer.ts +3 -5
  150. package/src/slack/watcher.ts +2 -2
  151. package/src/slack/work-objects.ts +5 -1
  152. package/src/tests/agent-fs-release-notes.test.ts +40 -0
  153. package/src/tests/agent-slack-icon.test.ts +85 -0
  154. package/src/tests/approval-cancellation-migration.test.ts +2 -0
  155. package/src/tests/asset-key-migration.test.ts +26 -0
  156. package/src/tests/comb-default.test.ts +159 -0
  157. package/src/tests/comb-review-batch.test.ts +192 -4
  158. package/src/tests/comb-status.test.ts +65 -2
  159. package/src/tests/db-agents.test.ts +13 -0
  160. package/src/tests/db-tasks-read.test.ts +10 -15
  161. package/src/tests/deploy-one-box.test.ts +161 -0
  162. package/src/tests/deploy-stubs.ts +68 -0
  163. package/src/tests/docs-index-footprint.test.js +3 -3
  164. package/src/tests/dsh-adapter.test.ts +1 -1
  165. package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
  166. package/src/tests/fixtures/deploy-stubs/bun +3 -0
  167. package/src/tests/fixtures/deploy-stubs/bunx +27 -0
  168. package/src/tests/fixtures/deploy-stubs/curl +26 -0
  169. package/src/tests/fixtures/deploy-stubs/docker +122 -0
  170. package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
  171. package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
  172. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
  173. package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
  174. package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
  175. package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
  176. package/src/tests/heartbeat-expiry.test.ts +151 -25
  177. package/src/tests/http-api-integration.test.ts +31 -0
  178. package/src/tests/human-free-reclassify.test.ts +6 -0
  179. package/src/tests/install-sh-bind.test.ts +242 -0
  180. package/src/tests/install-sh-tunnel.test.ts +89 -0
  181. package/src/tests/kv-storage.test.ts +28 -0
  182. package/src/tests/list-endpoint-slimming.test.ts +3 -0
  183. package/src/tests/list-total-cache.test.ts +63 -0
  184. package/src/tests/mcp-overflow-sweep.test.ts +84 -0
  185. package/src/tests/onboard-compose.test.ts +17 -3
  186. package/src/tests/onboard-env.test.ts +13 -0
  187. package/src/tests/onboarding.test.ts +1 -1
  188. package/src/tests/preload.ts +1 -0
  189. package/src/tests/query-audit-plans.test.ts +314 -0
  190. package/src/tests/script-connections.test.ts +6 -0
  191. package/src/tests/session-summaries.test.ts +384 -0
  192. package/src/tests/sessions-tree.test.ts +5 -1
  193. package/src/tests/slack-app-icon.test.ts +90 -0
  194. package/src/tests/slack-external-channel-egress.test.ts +1 -1
  195. package/src/tests/slack-inbound-receipts.test.ts +34 -0
  196. package/src/tests/slack-inline-output.test.ts +29 -2
  197. package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
  198. package/src/tests/slack-render-v2.test.ts +35 -0
  199. package/src/tests/slack-socket-ack.test.ts +553 -0
  200. package/src/tests/task-lineage.test.ts +3 -0
  201. package/src/tests/task-search-filter.test.ts +4 -4
  202. package/src/tests/task-search-fts.test.ts +320 -0
  203. package/src/tests/update-profile-auth.test.ts +28 -0
  204. package/src/tools/get-swarm.ts +1 -0
  205. package/src/tools/get-tasks.ts +6 -1
  206. package/src/tools/join-swarm.ts +1 -0
  207. package/src/tools/my-agent-info.ts +1 -0
  208. package/src/tools/slack-post.ts +2 -2
  209. package/src/tools/slack-reply.ts +2 -2
  210. package/src/tools/slack-start-thread.ts +2 -2
  211. package/src/tools/slack-update.ts +1 -1
  212. package/src/tools/update-profile.ts +20 -4
  213. package/src/tools/utils.ts +3 -4
  214. package/src/types.ts +47 -0
  215. package/src/utils/constants.ts +127 -5
  216. package/templates/official/lead/HEARTBEAT.md +4 -2
  217. package/templates/official/lead/config.json +2 -2
  218. package/templates/skills/swarm-docs/content.md +2 -0
  219. package/dist/handlers-7x8sywgr.js +0 -42
  220. package/dist/index-mc30r1hs.js +0 -44
@@ -24,6 +24,7 @@ import { parseProviderMeta } from "../../../utils/provider-metadata";
24
24
  import { scrubSecrets } from "../../../utils/secret-scrubber";
25
25
  import { getAgentById } from "../agents";
26
26
  import { getDbClient } from "../runtime";
27
+ import { taskSearchCondition } from "./search-index";
27
28
 
28
29
  type TaskReadDependencies = {
29
30
  checkDependencies: (taskId: string) => Promise<{ ready: boolean; blockedBy: string[] }>;
@@ -906,8 +907,9 @@ export async function getAllTasks(
906
907
  }
907
908
 
908
909
  if (filters?.search) {
909
- conditions.push("(task LIKE ? OR title LIKE ? OR id LIKE ?)");
910
- params.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`);
910
+ const search = await taskSearchCondition(filters.search, "page");
911
+ conditions.push(search.sql);
912
+ params.push(...search.params);
911
913
  }
912
914
 
913
915
  // New filters
@@ -1079,8 +1081,9 @@ export async function getTasksCount(
1079
1081
  }
1080
1082
 
1081
1083
  if (filters?.search) {
1082
- conditions.push("(task LIKE ? OR title LIKE ? OR id LIKE ?)");
1083
- params.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`);
1084
+ const search = await taskSearchCondition(filters.search, "count");
1085
+ conditions.push(search.sql);
1086
+ params.push(...search.params);
1084
1087
  }
1085
1088
 
1086
1089
  if (filters?.unassigned) {
@@ -0,0 +1,159 @@
1
+ import { getDbClient } from "../runtime";
2
+
3
+ /** `agent_tasks_fts_state.indexedThrough` once every row is indexed (migration 256). */
4
+ export const TASK_FTS_COMPLETE = Number.MAX_SAFE_INTEGER;
5
+
6
+ // At or below this many candidate rows, a page reads them by rowid and sorts
7
+ // them. Above it, the page walks the lastUpdatedAt index and tests each row
8
+ // against the index instead: sorting thousands of hits means reading each
9
+ // row's record, and a long task body spills over many overflow pages.
10
+ const ROWID_SET_MAX = 500;
11
+
12
+ // Rows per backfill transaction. A task averages about 16 KB of text, so a
13
+ // batch holds the write lock and the event loop for tens of milliseconds.
14
+ const BACKFILL_BATCH_SIZE = 100;
15
+
16
+ /**
17
+ * The agent_tasks_fts MATCH expression for free text: the whole input as one
18
+ * FTS5 phrase, so user text cannot inject operators (NEAR, AND, -, ^, column
19
+ * filters), with a prefix match on its last token. Returns null when the input
20
+ * has no indexable token (the phrase would match nothing).
21
+ */
22
+ export function buildTaskFtsMatch(search: string): string | null {
23
+ if (!/[\p{L}\p{N}]/u.test(search)) return null;
24
+ return `"${search.replaceAll('"', '""')}"*`;
25
+ }
26
+
27
+ /**
28
+ * The WHERE term for a task `search`: a token and phrase match on the task
29
+ * text and title through agent_tasks_fts, or a prefix of the task id. Until
30
+ * the post-boot backfill has indexed every row it is the old substring LIKE,
31
+ * so results never miss rows the index has not reached yet.
32
+ *
33
+ * `purpose` picks the plan. A page with few candidates reads them by rowid; a
34
+ * page with many walks its ORDER BY index and tests membership (`+rowid`
35
+ * keeps the planner off the rowid set), stopping at the page size. A count
36
+ * has no LIMIT to stop at, so it always drives from the candidate set.
37
+ */
38
+ export async function taskSearchCondition(
39
+ search: string,
40
+ purpose: "page" | "count",
41
+ ): Promise<{ sql: string; params: string[] }> {
42
+ const client = getDbClient();
43
+ const state = await client.get<{ indexedThrough: number }>(
44
+ "SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1",
45
+ );
46
+ if (!state || state.indexedThrough < TASK_FTS_COMPLETE) {
47
+ const pattern = `%${search}%`;
48
+ return {
49
+ sql: "(task LIKE ? OR title LIKE ? OR id LIKE ?)",
50
+ params: [pattern, pattern, pattern],
51
+ };
52
+ }
53
+
54
+ const match = buildTaskFtsMatch(search);
55
+ // Task ids are lowercase UUIDs; a prefix is a range on the primary key.
56
+ const idLow = search.trim().toLowerCase();
57
+ const idRange = idLow ? [idLow, `${idLow}\uffff`] : null;
58
+ if (!match && !idRange) return { sql: "0", params: [] };
59
+
60
+ const arms: string[] = [];
61
+ const armParams: string[] = [];
62
+ if (match) {
63
+ arms.push("SELECT rowid AS rid FROM agent_tasks_fts WHERE agent_tasks_fts MATCH ?");
64
+ armParams.push(match);
65
+ }
66
+ if (idRange) {
67
+ arms.push("SELECT rowid AS rid FROM agent_tasks WHERE id >= ? AND id < ?");
68
+ armParams.push(...idRange);
69
+ }
70
+ const candidates = await client.query<{ rid: number }>(
71
+ `${arms.join(" UNION ")} LIMIT ${ROWID_SET_MAX + 1}`,
72
+ armParams,
73
+ );
74
+ if (candidates.length === 0) return { sql: "0", params: [] };
75
+ if (candidates.length <= ROWID_SET_MAX) {
76
+ return {
77
+ sql: `agent_tasks.rowid IN (${candidates.map((row) => row.rid).join(", ")})`,
78
+ params: [],
79
+ };
80
+ }
81
+
82
+ const rowid = purpose === "page" ? "+agent_tasks.rowid" : "agent_tasks.rowid";
83
+ const terms: string[] = [];
84
+ const params: string[] = [];
85
+ if (match) {
86
+ terms.push(`${rowid} IN (SELECT rowid FROM agent_tasks_fts WHERE agent_tasks_fts MATCH ?)`);
87
+ params.push(match);
88
+ }
89
+ if (idRange) {
90
+ terms.push("(agent_tasks.id >= ? AND agent_tasks.id < ?)");
91
+ params.push(...idRange);
92
+ }
93
+ return { sql: `(${terms.join(" OR ")})`, params };
94
+ }
95
+
96
+ /**
97
+ * Index the rows migration 256 left out, in rowid batches. Each batch and the
98
+ * `indexedThrough` mark that admits it to the triggers commit together, so a
99
+ * concurrent write is indexed exactly once: by its trigger when its row is
100
+ * already covered, else by a later batch. The last batch sets the mark to
101
+ * TASK_FTS_COMPLETE, which switches search to the index. Resumable after a
102
+ * restart; a no-op once complete.
103
+ */
104
+ export async function backfillTaskSearchIndex(
105
+ batchSize = BACKFILL_BATCH_SIZE,
106
+ ): Promise<{ indexed: number; ms: number }> {
107
+ const client = getDbClient();
108
+ const startedAt = performance.now();
109
+ let indexed = 0;
110
+ while (!stopRequested) {
111
+ const done = await client.transaction(async (tx) => {
112
+ const state = await tx.get<{ indexedThrough: number }>(
113
+ "SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1",
114
+ );
115
+ if (!state || state.indexedThrough >= TASK_FTS_COMPLETE) return true;
116
+ const last = await tx.get<{ rid: number }>(
117
+ "SELECT rowid AS rid FROM agent_tasks WHERE rowid > ? ORDER BY rowid LIMIT 1 OFFSET ?",
118
+ [state.indexedThrough, batchSize - 1],
119
+ );
120
+ const through = last?.rid ?? TASK_FTS_COMPLETE;
121
+ const result = await tx.run(
122
+ `INSERT INTO agent_tasks_fts(rowid, task, title)
123
+ SELECT rowid, task, title FROM agent_tasks WHERE rowid > ? AND rowid <= ?`,
124
+ [state.indexedThrough, through],
125
+ );
126
+ indexed += result.changes;
127
+ await tx.run("UPDATE agent_tasks_fts_state SET indexedThrough = ? WHERE id = 1", [through]);
128
+ return through >= TASK_FTS_COMPLETE;
129
+ });
130
+ if (done) break;
131
+ await new Promise<void>((resolve) => setImmediate(resolve));
132
+ }
133
+ return { indexed, ms: Math.round(performance.now() - startedAt) };
134
+ }
135
+
136
+ let backfill: Promise<void> | null = null;
137
+ let stopRequested = false;
138
+
139
+ /** Start the backfill once per process, off the boot path. */
140
+ export function startTaskSearchBackfill(): Promise<void> {
141
+ stopRequested = false;
142
+ backfill ??= backfillTaskSearchIndex()
143
+ .then(({ indexed, ms }) => {
144
+ if (indexed > 0) console.log(`[task-fts] backfill indexed=${indexed} ms=${ms}`);
145
+ })
146
+ .catch((err) => {
147
+ console.error("[task-fts] backfill failed:", (err as Error).message);
148
+ })
149
+ .finally(() => {
150
+ backfill = null;
151
+ });
152
+ return backfill;
153
+ }
154
+
155
+ /** Stop after the current batch, before SQLite closes. The next start resumes. */
156
+ export async function stopTaskSearchBackfill(): Promise<void> {
157
+ stopRequested = true;
158
+ await backfill;
159
+ }
@@ -19,6 +19,7 @@ import { emitTaskStarted } from "../../task-lifecycle-events";
19
19
  import { publishWorkerNudge } from "../../worker-nudges";
20
20
  import { getAgentById, isAgentEligibleForTask } from "../agents";
21
21
  import { getDbClient } from "../runtime";
22
+ import { dropSessionDescendants } from "../session-summaries";
22
23
  import {
23
24
  classifyTaskHumanFree,
24
25
  reclassifyTaskHumanFree,
@@ -1138,6 +1139,8 @@ export async function deleteTask(id: string): Promise<boolean> {
1138
1139
  const childIds = (
1139
1140
  await tx.query<{ id: string }>("SELECT id FROM agent_tasks WHERE parentTaskId = ?", [id])
1140
1141
  ).map((row) => row.id);
1142
+ // Its descendants leave the session too; the delete trigger handles the rest.
1143
+ await dropSessionDescendants(id);
1141
1144
  const result = await tx.run("DELETE FROM agent_tasks WHERE id = ?", [id]);
1142
1145
  await reclassifyTaskHumanFree(childIds);
1143
1146
  return result.changes > 0;
@@ -236,6 +236,83 @@ export async function admitSlackInboundReceipt(
236
236
  });
237
237
  }
238
238
 
239
+ export interface RecordSlackInboundInlineInput {
240
+ dedupKey: string;
241
+ transport: SlackInboundTransport;
242
+ kind: SlackInboundKind;
243
+ payloadType: string;
244
+ apiAppId?: string | null;
245
+ eventId?: string | null;
246
+ retryNum?: number | null;
247
+ retryReason?: string | null;
248
+ }
249
+
250
+ /**
251
+ * Remember the dedup key of a delivery that is about to run outside the drain.
252
+ *
253
+ * The row is a tombstone: terminal (`processed`), no payload, never claimable.
254
+ * Retention drops it with the other completed keys, after the window in which
255
+ * Slack still redelivers. It exists so a redelivery is dropped after a restart,
256
+ * when the in-memory `event_id` cache is gone. The key is written before the
257
+ * work runs, so a crash mid-run is never replayed, as for an `uncertain`
258
+ * receipt. Returns `duplicate` when the key was already stored, counting the
259
+ * sighting.
260
+ */
261
+ export async function recordSlackInboundInline(
262
+ input: RecordSlackInboundInlineInput,
263
+ now: Date = new Date(),
264
+ ): Promise<"recorded" | "duplicate"> {
265
+ const nowIso = now.toISOString();
266
+ const inserted = await getDbClient().get<{ id: string }>(
267
+ `INSERT INTO slack_inbound_receipts (
268
+ id, dedup_key, transport, kind, payload_type, api_app_id, event_id,
269
+ payload_bytes, retry_num, retry_reason, state, attempts, outcome_code,
270
+ received_at, available_at, completed_at, payload_erased_at, created_at, updated_at
271
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'processed', 1, 'ran_inline', ?, ?, ?, ?, ?, ?)
272
+ ON CONFLICT(dedup_key) DO NOTHING
273
+ RETURNING id`,
274
+ [
275
+ crypto.randomUUID(),
276
+ input.dedupKey,
277
+ input.transport,
278
+ input.kind,
279
+ input.payloadType,
280
+ input.apiAppId ?? null,
281
+ input.eventId ?? null,
282
+ input.retryNum ?? null,
283
+ input.retryReason ?? null,
284
+ nowIso,
285
+ nowIso,
286
+ nowIso,
287
+ nowIso,
288
+ nowIso,
289
+ nowIso,
290
+ ],
291
+ );
292
+ if (inserted) return "recorded";
293
+ await touchSlackInboundDuplicate(input.dedupKey, now);
294
+ return "duplicate";
295
+ }
296
+
297
+ /**
298
+ * Is this key stored? Counts the sighting as a duplicate delivery when it is.
299
+ * One atomic UPDATE, no read-then-write.
300
+ */
301
+ export async function touchSlackInboundDuplicate(
302
+ dedupKey: string,
303
+ now: Date = new Date(),
304
+ ): Promise<boolean> {
305
+ const nowIso = now.toISOString();
306
+ const row = await getDbClient().get<{ id: string }>(
307
+ `UPDATE slack_inbound_receipts
308
+ SET duplicate_count = duplicate_count + 1, last_duplicate_at = ?, updated_at = ?
309
+ WHERE dedup_key = ?
310
+ RETURNING id`,
311
+ [nowIso, nowIso, dedupKey],
312
+ );
313
+ return row !== null && row !== undefined;
314
+ }
315
+
239
316
  /**
240
317
  * Atomically move one claimable pending receipt to `processing`. Without an id
241
318
  * the oldest available receipt is claimed. Returns null when nothing is
package/src/be/db.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
25
  AgentLogEventType,
26
26
  AgentMcpServer,
27
27
  AgentSkill,
28
+ AgentSlackIcon,
28
29
  AgentStatus,
29
30
  AgentTask,
30
31
  AgentTaskStatus,
@@ -127,6 +128,7 @@ import {
127
128
  CreateTaskOptionsSchema,
128
129
  isTerminalTaskStatus,
129
130
  type ModelTier,
131
+ OPEN_TASK_STATUSES,
130
132
  parseModelTier,
131
133
  SERVER_GENERATED_ATTACHMENT_CAPABILITY,
132
134
  SessionCostModelBreakdownSchema,
@@ -2435,17 +2437,23 @@ async function findLatestRoutingCandidate(
2435
2437
  async function captureRoutingDecisionSnapshot(
2436
2438
  options: CreateTaskOptions,
2437
2439
  ): Promise<RoutingDecisionSnapshot> {
2440
+ // MATERIALIZED pins the plan: a status seek over the few open tasks. Without
2441
+ // it (and with no sqlite_stat1) the planner flattens the CTE back into an OR
2442
+ // over agentId/offeredTo that reads each agent's whole task history.
2438
2443
  const workerStatuses = await getDbClient().query<RoutingDecisionWorkerStatus>(
2439
- `SELECT agent.id AS agentId, agent.status,
2444
+ `WITH open_task AS MATERIALIZED (
2445
+ SELECT id, agentId, offeredTo, status FROM agent_tasks
2446
+ WHERE status IN (${OPEN_TASK_STATUSES.map(() => "?").join(", ")})
2447
+ )
2448
+ SELECT agent.id AS agentId, agent.status,
2440
2449
  COUNT(task.id) AS openTaskCount,
2441
2450
  SUM(CASE WHEN task.status IN ('in_progress', 'reviewing') THEN 1 ELSE 0 END) AS activeTaskCount
2442
2451
  FROM agents agent
2443
- LEFT JOIN agent_tasks task ON
2444
- (task.agentId = agent.id OR task.offeredTo = agent.id)
2445
- AND task.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
2452
+ LEFT JOIN open_task task ON task.agentId = agent.id OR task.offeredTo = agent.id
2446
2453
  WHERE agent.isLead = 0
2447
2454
  GROUP BY agent.id, agent.status
2448
2455
  ORDER BY agent.id`,
2456
+ [...OPEN_TASK_STATUSES],
2449
2457
  );
2450
2458
 
2451
2459
  const samePr =
@@ -3744,6 +3752,7 @@ export async function updateAgentProfile(
3744
3752
  heartbeatMd?: string;
3745
3753
  /** `null` resets to the deterministic fallback; omit the key to leave untouched. */
3746
3754
  avatar?: AgentAvatar | null;
3755
+ slackIcon?: AgentSlackIcon | null;
3747
3756
  },
3748
3757
  meta?: VersionMeta,
3749
3758
  guard?: {
@@ -3891,6 +3900,8 @@ export async function updateAgentProfile(
3891
3900
  // untouched) from "key present with value null" (reset).
3892
3901
  const avatarProvided = Object.hasOwn(updates, "avatar");
3893
3902
  const avatarJson = updates.avatar ? JSON.stringify(updates.avatar) : null;
3903
+ const slackIconProvided = Object.hasOwn(updates, "slackIcon");
3904
+ const slackIconJson = updates.slackIcon ? JSON.stringify(updates.slackIcon) : null;
3894
3905
 
3895
3906
  const now = new Date().toISOString();
3896
3907
  const row = await tx.get<AgentRow>(
@@ -3906,6 +3917,7 @@ export async function updateAgentProfile(
3906
3917
  toolsMd = COALESCE(?, toolsMd),
3907
3918
  heartbeatMd = COALESCE(?, heartbeatMd),
3908
3919
  avatar = CASE WHEN ? = 1 THEN ? ELSE avatar END,
3920
+ slack_icon = CASE WHEN ? = 1 THEN ? ELSE slack_icon END,
3909
3921
  lastUpdatedAt = ?
3910
3922
  WHERE id = ? RETURNING *`,
3911
3923
  [
@@ -3921,6 +3933,8 @@ export async function updateAgentProfile(
3921
3933
  writable.heartbeatMd ?? null,
3922
3934
  avatarProvided ? 1 : 0,
3923
3935
  avatarJson,
3936
+ slackIconProvided ? 1 : 0,
3937
+ slackIconJson,
3924
3938
  now,
3925
3939
  id,
3926
3940
  ],
@@ -8276,6 +8290,10 @@ export interface StuckWorkflowRun {
8276
8290
  }
8277
8291
 
8278
8292
  export async function getStuckWorkflowRuns(): Promise<StuckWorkflowRun[]> {
8293
+ // CROSS JOIN fixes the join order: the few waiting steps drive, and each
8294
+ // one seeks its task through idx_agent_tasks_workflowRunStepId, so `output`
8295
+ // is read only for matched tasks. Driving from agent_tasks by status reads
8296
+ // nearly every finished task.
8279
8297
  return getDbClient().query<StuckWorkflowRun>(
8280
8298
  `SELECT
8281
8299
  wr.id as runId,
@@ -8285,10 +8303,11 @@ export async function getStuckWorkflowRuns(): Promise<StuckWorkflowRun[]> {
8285
8303
  at.status as taskStatus,
8286
8304
  at.output as taskOutput,
8287
8305
  wr.workflowId
8288
- FROM workflow_runs wr
8289
- JOIN workflow_run_steps wrs ON wrs.runId = wr.id AND wrs.status = 'waiting'
8290
- JOIN agent_tasks at ON at.workflowRunStepId = wrs.id
8291
- WHERE wr.status = 'waiting'
8306
+ FROM workflow_run_steps wrs
8307
+ CROSS JOIN workflow_runs wr ON wr.id = wrs.runId
8308
+ CROSS JOIN agent_tasks at ON at.workflowRunStepId = wrs.id
8309
+ WHERE wrs.status = 'waiting'
8310
+ AND wr.status = 'waiting'
8292
8311
  AND at.status IN ('completed', 'failed', 'cancelled')
8293
8312
  ORDER BY at.createdAt ASC, at.rowid ASC`,
8294
8313
  );
@@ -0,0 +1,39 @@
1
+ import { MCP_OVERFLOW_NAMESPACE } from "../kv-overflow";
2
+ import { sweepExpiredKvPrefix } from "./db";
3
+
4
+ export const MCP_OVERFLOW_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
5
+
6
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
7
+
8
+ /**
9
+ * Delete expired MCP overflow values across every agent's namespace. Runs on
10
+ * a timer, not on the spill path: the DELETE of multi-MB values and its write
11
+ * lock would otherwise land on a read tool's request.
12
+ */
13
+ export async function runMcpOverflowSweep(now = Date.now()): Promise<number> {
14
+ try {
15
+ const removed = await sweepExpiredKvPrefix(MCP_OVERFLOW_NAMESPACE, now);
16
+ if (removed > 0) {
17
+ console.debug(`[mcp-overflow] Sweep removed ${removed} expired value(s)`);
18
+ }
19
+ return removed;
20
+ } catch (err) {
21
+ console.error("[mcp-overflow] Sweep failed:", err);
22
+ return 0;
23
+ }
24
+ }
25
+
26
+ export function startMcpOverflowSweep(intervalMs = MCP_OVERFLOW_SWEEP_INTERVAL_MS): void {
27
+ if (sweepTimer) return;
28
+ sweepTimer = setInterval(() => {
29
+ void runMcpOverflowSweep();
30
+ }, intervalMs);
31
+ if (typeof sweepTimer?.unref === "function") sweepTimer.unref();
32
+ }
33
+
34
+ export function stopMcpOverflowSweep(): void {
35
+ if (sweepTimer) {
36
+ clearInterval(sweepTimer);
37
+ sweepTimer = null;
38
+ }
39
+ }