@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
@@ -5682,6 +5682,38 @@ export async function checkCompletedProcesses(
5682
5682
 
5683
5683
  const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
5684
5684
 
5685
+ async function saveGeneratedProfile({
5686
+ apiUrl,
5687
+ apiKey,
5688
+ agentId,
5689
+ role,
5690
+ profileUpdate,
5691
+ }: {
5692
+ apiUrl: string;
5693
+ apiKey: string;
5694
+ agentId: string;
5695
+ role: string;
5696
+ profileUpdate: Record<string, string>;
5697
+ }): Promise<void> {
5698
+ try {
5699
+ const response = await fetch(`${apiUrl}/api/agents/${agentId}/profile`, {
5700
+ method: "PUT",
5701
+ headers: {
5702
+ Authorization: `Bearer ${apiKey}`,
5703
+ "X-Agent-ID": agentId,
5704
+ "Content-Type": "application/json",
5705
+ },
5706
+ body: JSON.stringify(profileUpdate),
5707
+ });
5708
+ if (!response.ok) {
5709
+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
5710
+ }
5711
+ console.log(`[${role}] Generated and saved default identity templates`);
5712
+ } catch (err) {
5713
+ console.warn(scrubSecrets(`[${role}] Could not save generated templates to server: ${err}`));
5714
+ }
5715
+ }
5716
+
5685
5717
  async function fetchTemplate(
5686
5718
  templateId: string,
5687
5719
  registryUrl: string,
@@ -6534,30 +6566,15 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
6534
6566
  if (!agentClaudeMd) agentClaudeMd = generateDefaultClaudeMd(agentInfo);
6535
6567
 
6536
6568
  // Push generated templates to server
6537
- try {
6538
- const profileUpdate: Record<string, string> = {};
6539
- if (!profile.soulMd) profileUpdate.soulMd = agentSoulMd;
6540
- if (!profile.identityMd) profileUpdate.identityMd = agentIdentityMd;
6541
- if (!profile.toolsMd) profileUpdate.toolsMd = agentToolsMd;
6542
- if (!profile.claudeMd && agentClaudeMd) profileUpdate.claudeMd = agentClaudeMd;
6543
- if (!profile.setupScript && agentSetupScript)
6544
- profileUpdate.setupScript = agentSetupScript;
6545
- if (!profile.heartbeatMd && agentHeartbeatMd)
6546
- profileUpdate.heartbeatMd = agentHeartbeatMd;
6547
-
6548
- await fetch(`${apiUrl}/api/agents/${agentId}/profile`, {
6549
- method: "PUT",
6550
- headers: {
6551
- Authorization: `Bearer ${apiKey}`,
6552
- "X-Agent-ID": agentId,
6553
- "Content-Type": "application/json",
6554
- },
6555
- body: JSON.stringify(profileUpdate),
6556
- });
6557
- console.log(`[${role}] Generated and saved default identity templates`);
6558
- } catch {
6559
- console.warn(`[${role}] Could not save generated templates to server`);
6560
- }
6569
+ const profileUpdate: Record<string, string> = {};
6570
+ if (!profile.soulMd) profileUpdate.soulMd = agentSoulMd;
6571
+ if (!profile.identityMd) profileUpdate.identityMd = agentIdentityMd;
6572
+ if (!profile.toolsMd) profileUpdate.toolsMd = agentToolsMd;
6573
+ if (!profile.claudeMd && agentClaudeMd) profileUpdate.claudeMd = agentClaudeMd;
6574
+ if (!profile.setupScript && agentSetupScript) profileUpdate.setupScript = agentSetupScript;
6575
+ if (!profile.heartbeatMd && agentHeartbeatMd) profileUpdate.heartbeatMd = agentHeartbeatMd;
6576
+
6577
+ await saveGeneratedProfile({ apiUrl, apiKey, agentId, role, profileUpdate });
6561
6578
  }
6562
6579
 
6563
6580
  // Fetch installed MCP servers for system prompt
@@ -66,6 +66,9 @@ export type AgentFsComment = {
66
66
  /** One entry of a file's `log`. */
67
67
  export type AgentFsFileVersion = { version: number; createdAt: string };
68
68
 
69
+ /** One member of a drive (`drive-members`). */
70
+ export type AgentFsDriveMember = { userId: string; email: string; displayName?: string | null };
71
+
69
72
  /** An agent-fs org and drive. */
70
73
  export type AgentFsDrive = { orgId: string; driveId: string };
71
74
 
@@ -300,6 +303,12 @@ export class AgentFsProvider implements FileStorageProvider {
300
303
  return Array.isArray(result?.versions) ? (result.versions as AgentFsFileVersion[]) : [];
301
304
  }
302
305
 
306
+ /** `drive-members`: the drive's members with their emails. Needs the agent-fs `drive-members` feature. */
307
+ async getDriveMembers(drive: AgentFsDrive): Promise<AgentFsDriveMember[]> {
308
+ const result = asRecord(await this.ops({ op: "drive-members" }, drive));
309
+ return Array.isArray(result?.members) ? (result.members as AgentFsDriveMember[]) : [];
310
+ }
311
+
303
312
  /** Reply to a root comment. The swarm service account is the author. */
304
313
  async replyToComment(
305
314
  drive: AgentFsDrive,
@@ -76,7 +76,12 @@ import {
76
76
  REBOOT_RETRY_PIN_TAG,
77
77
  resolveLeadOnlyRecoveryAssignment,
78
78
  } from "../tasks/worker-follow-up";
79
- import { type AgentTask, SUPERSEDED_WORKFLOW_TASK_REASON, TERMINAL_TASK_STATUSES } from "../types";
79
+ import {
80
+ type AgentTask,
81
+ OPEN_TASK_STATUSES,
82
+ SUPERSEDED_WORKFLOW_TASK_REASON,
83
+ TERMINAL_TASK_STATUSES,
84
+ } from "../types";
80
85
  import { isEnvFlagEnabled } from "../utils/env-flag";
81
86
  import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
82
87
  import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
@@ -1800,14 +1805,16 @@ export async function checkHeartbeatChecklist(): Promise<void> {
1800
1805
 
1801
1806
  if (isEffectivelyEmpty(heartbeatMd)) return;
1802
1807
 
1803
- // Dedup: skip if lead already has an active heartbeat-checklist task
1808
+ // Dedup: skip if lead already has an active heartbeat-checklist task.
1809
+ // Unary + keeps agentId/taskType off their indexes, so the seek runs on
1810
+ // status over the few open tasks instead of every past checklist task.
1804
1811
  const existing = await getDbClient().get<{ id: string }>(
1805
1812
  `SELECT id FROM agent_tasks
1806
- WHERE agentId = ?
1807
- AND taskType = 'heartbeat-checklist'
1808
- AND status NOT IN (${TERMINAL_TASK_STATUSES.map(() => "?").join(", ")})
1813
+ WHERE status IN (${OPEN_TASK_STATUSES.map(() => "?").join(", ")})
1814
+ AND +agentId = ?
1815
+ AND +taskType = 'heartbeat-checklist'
1809
1816
  LIMIT 1`,
1810
- [lead.id, ...TERMINAL_TASK_STATUSES],
1817
+ [...OPEN_TASK_STATUSES, lead.id],
1811
1818
  );
1812
1819
  if (existing) return;
1813
1820
 
@@ -61,6 +61,7 @@ import {
61
61
  AgentCredStatusSchema,
62
62
  AgentLatestModelSchema,
63
63
  AgentSchema,
64
+ AgentSlackIconSchema,
64
65
  AgentStatusSchema,
65
66
  AgentWithTasksSchema,
66
67
  DeliveryModeSchema,
@@ -530,6 +531,11 @@ const updateAgentProfileRoute = route({
530
531
  heartbeatMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
531
532
  /** `null` resets to the deterministic fallback; omit the key to leave untouched. */
532
533
  avatar: AgentAvatarSchema.nullable().optional(),
534
+ // z.lazy keeps `| null` in the OpenAPI body: a named schema's $ref drops it.
535
+ slackIcon: z
536
+ .lazy(() => AgentSlackIconSchema)
537
+ .nullable()
538
+ .optional(),
533
539
  changeSource: z.string().optional(),
534
540
  changedByAgentId: z.string().optional(),
535
541
  changeReason: z.string().optional(),
@@ -976,11 +982,12 @@ export async function handleAgentsRest(
976
982
  body.setupScript === undefined &&
977
983
  body.toolsMd === undefined &&
978
984
  body.heartbeatMd === undefined &&
979
- body.avatar === undefined
985
+ body.avatar === undefined &&
986
+ body.slackIcon === undefined
980
987
  ) {
981
988
  jsonError(
982
989
  res,
983
- "At least one field (role, description, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, heartbeatMd, or avatar) must be provided",
990
+ "At least one field (role, description, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, heartbeatMd, avatar, or slackIcon) must be provided",
984
991
  400,
985
992
  );
986
993
  return true;
@@ -1017,6 +1024,7 @@ export async function handleAgentsRest(
1017
1024
  // Only include the key when the client sent it, so `null` (reset)
1018
1025
  // is distinguishable from "not provided" (leave untouched).
1019
1026
  ...(body.avatar !== undefined ? { avatar: body.avatar } : {}),
1027
+ ...(body.slackIcon !== undefined ? { slackIcon: body.slackIcon } : {}),
1020
1028
  },
1021
1029
  versionMeta,
1022
1030
  {
package/src/http/index.ts CHANGED
@@ -15,8 +15,10 @@ import {
15
15
  getSwarmConfigs,
16
16
  upsertSwarmConfig,
17
17
  } from "../be/db";
18
+ import { startTaskSearchBackfill, stopTaskSearchBackfill } from "../be/db/tasks/search-index";
18
19
  import { startDbRetention, stopDbRetention } from "../be/db-retention";
19
20
  import { startHumanFreeDrain, stopHumanFreeDrain } from "../be/human-free-drain";
21
+ import { startMcpOverflowSweep, stopMcpOverflowSweep } from "../be/mcp-overflow-sweep";
20
22
  import {
21
23
  enqueueAuditRow,
22
24
  flushAuditBuffer,
@@ -528,6 +530,9 @@ async function shutdown() {
528
530
  // Stop the unified OAuth pending-session garbage collector (all flows)
529
531
  stopOAuthPendingGc();
530
532
 
533
+ // Stop the expired MCP overflow sweep
534
+ stopMcpOverflowSweep();
535
+
531
536
  // Stop memory expired-row garbage collector
532
537
  stopMemoryGc();
533
538
 
@@ -537,6 +542,9 @@ async function shutdown() {
537
542
  // Stop the human-free reclassification drain before closing SQLite.
538
543
  stopHumanFreeDrain();
539
544
 
545
+ // Stop the task search index backfill after its current batch.
546
+ await stopTaskSearchBackfill();
547
+
540
548
  // Stop scratch-script retention garbage collector
541
549
  stopScratchScriptGc();
542
550
 
@@ -866,6 +874,9 @@ httpServer
866
874
  // Start the unified OAuth pending-session garbage collector (5-min tick, all flows)
867
875
  startOAuthPendingGc();
868
876
 
877
+ // Delete expired MCP overflow values (10-min tick, off the spill path)
878
+ startMcpOverflowSweep();
879
+
869
880
  // Start expired-memory garbage collector (1-hour tick, immediate first run)
870
881
  await startMemoryGc();
871
882
 
@@ -877,6 +888,10 @@ httpServer
877
888
  // task tree, or a restart mid-drain). Wakes on demand; see human-free-drain.ts.
878
889
  startHumanFreeDrain();
879
890
 
891
+ // Index the task rows migration 256 left out, in batches. Task search
892
+ // stays on LIKE until it finishes. See src/be/db/tasks/search-index.ts.
893
+ void startTaskSearchBackfill();
894
+
880
895
  // (RBAC audit sink is wired pre-listen — see above httpServer.listen.)
881
896
 
882
897
  // Background embedding maintenance: re-embed every memory and script
@@ -7,6 +7,7 @@ import {
7
7
  getChatSdkStatus,
8
8
  registerTelegramWebhook,
9
9
  } from "../integrations/chat-sdk/status";
10
+ import { getSlackAppIconUrl } from "../slack/app-icon";
10
11
  import { buildSlackManifest } from "../slack/manifest";
11
12
  import { selectCredential } from "../utils/credentials";
12
13
  import { ensureHttpPermission } from "./config";
@@ -117,6 +118,20 @@ const slackManifestRoute = route({
117
118
  },
118
119
  });
119
120
 
121
+ const slackAppIconRoute = route({
122
+ method: "get",
123
+ path: "/api/integrations/slack/app-icon",
124
+ pattern: ["api", "integrations", "slack", "app-icon"],
125
+ summary: "The Slack app's own avatar, shown on posts with no icon override",
126
+ tags: ["Integrations"],
127
+ responses: {
128
+ 200: {
129
+ description: "`imageUrl` is null when Slack is off or the lookup fails.",
130
+ schema: z.object({ imageUrl: z.string().nullable() }),
131
+ },
132
+ },
133
+ });
134
+
120
135
  const chatSdkStatusRoute = route({
121
136
  method: "get",
122
137
  path: "/api/integrations/chat/status",
@@ -176,6 +191,11 @@ export function createIntegrationsHandler(deps: TestConnectionDeps = {}) {
176
191
  return true;
177
192
  }
178
193
 
194
+ if (slackAppIconRoute.match(req.method, pathSegments)) {
195
+ slackAppIconRoute.respond(res, 200, { imageUrl: await getSlackAppIconUrl() });
196
+ return true;
197
+ }
198
+
179
199
  if (chatSdkStatusRoute.match(req.method, pathSegments)) {
180
200
  chatSdkStatusRoute.respond(res, 200, await getChatSdkStatus());
181
201
  return true;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A 30-second cache of list `total`s, keyed by filter set.
3
+ *
4
+ * A list total is a COUNT over the same filters as the page, and on the large
5
+ * tables behind the sessions and task lists it costs as much as the page
6
+ * itself. Page 1 (offset 0) always counts, so a write (a new session, a
7
+ * triage move) shows at once where the user looks first. Later pages reuse
8
+ * that count for 30 s. A cold later page (deep link, expired entry) counts
9
+ * too, so `total` is always set.
10
+ */
11
+
12
+ export const LIST_TOTAL_TTL_MS = 30_000;
13
+ const MAX_ENTRIES = 500;
14
+
15
+ interface Entry {
16
+ expiresAt: number;
17
+ total: Promise<number>;
18
+ }
19
+
20
+ export interface ListTotalCache {
21
+ get(
22
+ filters: Record<string, unknown>,
23
+ offset: number | undefined,
24
+ compute: () => Promise<number>,
25
+ ): Promise<number>;
26
+ }
27
+
28
+ /** Stable key: sorted object keys, `undefined` fields dropped. */
29
+ function filterKey(value: unknown): string {
30
+ return JSON.stringify(value, (_key, v) =>
31
+ v && typeof v === "object" && !Array.isArray(v)
32
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
33
+ : v,
34
+ );
35
+ }
36
+
37
+ export function createListTotalCache(ttlMs = LIST_TOTAL_TTL_MS): ListTotalCache {
38
+ const entries = new Map<string, Entry>();
39
+ return {
40
+ get(filters, offset, compute) {
41
+ const key = filterKey(filters);
42
+ const now = Date.now();
43
+ const hit = entries.get(key);
44
+ if (hit && hit.expiresAt > now && (offset ?? 0) > 0) return hit.total;
45
+ if (hit) entries.delete(key);
46
+
47
+ // Cache the promise, so a later page that arrives mid-count shares it.
48
+ const total = compute();
49
+ entries.set(key, { expiresAt: now + ttlMs, total });
50
+ total.catch(() => {
51
+ if (entries.get(key)?.total === total) entries.delete(key);
52
+ });
53
+ // Map iterates in insertion order, so the first key is the oldest.
54
+ while (entries.size > MAX_ENTRIES) {
55
+ const oldest = entries.keys().next().value;
56
+ if (oldest === undefined) break;
57
+ entries.delete(oldest);
58
+ }
59
+ return total;
60
+ },
61
+ };
62
+ }
@@ -75,6 +75,7 @@ import {
75
75
  import { getRequestAuth } from "../utils/request-auth-context";
76
76
  import { isRuntimePoolsEnabled } from "../utils/runtime-pools-flag";
77
77
  import { canMutateTaskNamespace } from "./assets";
78
+ import { createListTotalCache } from "./list-total-cache";
78
79
  import { agentFirstPrincipal, authUserId, requireAuthKind } from "./request-principal";
79
80
  import { route } from "./route-def";
80
81
  import {
@@ -538,6 +539,8 @@ const SessionShowSourceSchema = z
538
539
  "Comma-separated root sources to keep (the sessions v2 source filter), e.g. `ui,slack`. Present and empty keeps none; omit to keep every source.",
539
540
  );
540
541
 
542
+ const sessionTotals = createListTotalCache();
543
+
541
544
  const listSessions = route({
542
545
  method: "get",
543
546
  path: "/api/sessions",
@@ -1017,8 +1020,12 @@ export async function handleSessions(
1017
1020
  ? await listRecentSessions(baseOpts)
1018
1021
  : await listRecentSessions({ ...baseOpts, slim: true });
1019
1022
  // Filter-aware total: same WHERE as the list query, so the UI pager
1020
- // reflects the filtered result set.
1021
- const total = await countSessions(filterOpts);
1023
+ // reflects the filtered result set. Page 1 counts; later pages reuse it
1024
+ // for 30 s (keyed without the request clock).
1025
+ const { now: _now, ...totalKey } = filterOpts;
1026
+ const total = await sessionTotals.get(totalKey, parsed.query.offset, () =>
1027
+ countSessions(filterOpts),
1028
+ );
1022
1029
  listSessions.respond(res, 200, {
1023
1030
  sessions,
1024
1031
  total,
@@ -22,6 +22,7 @@ const OutcomeCountersSchema = z.object({
22
22
  ignored: z.number(),
23
23
  failed: z.number(),
24
24
  uncertain: z.number(),
25
+ ackNotReady: z.number(),
25
26
  lastOutcomeAt: z.string().nullable(),
26
27
  lastFailure: FailureSchema,
27
28
  });
@@ -134,6 +134,8 @@ export type StatusActivity = z.infer<typeof StatusActivitySchema>;
134
134
 
135
135
  export const StatusCombSchema = z.object({
136
136
  enabled: z.boolean(),
137
+ /** What is missing when Comb is off (for example no browser-reachable agent-fs URL). Null when on. */
138
+ reason: z.string().nullable(),
137
139
  api_url: z.string().nullable(),
138
140
  live_url: z.string(),
139
141
  org_id: z.string().nullable(),
@@ -777,6 +779,7 @@ function getCombStatus(serviceUserId: string | null): StatusAgentFs["comb"] {
777
779
  const comb = getCombConfig();
778
780
  return {
779
781
  enabled: comb.enabled,
782
+ reason: comb.reason,
780
783
  api_url: comb.apiUrl,
781
784
  live_url: comb.liveUrl,
782
785
  org_id: comb.orgId,
@@ -811,7 +814,7 @@ const getStatus = route({
811
814
  pattern: ["status"],
812
815
  summary: "Identity + setup readiness + live activity for the swarm dashboard",
813
816
  description:
814
- "Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; setup milestones each emit `unverified | configured | verified`; automations report `running | needs_setup` from the same runtime preflight used at dispatch; activity counts agents alive in the last 5 min, leads that can take work (status idle or busy), and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set, plus the Comb settings (COMB_ENABLED, the browser-facing agent-fs URL, the shared org and drive ids, and the agent-fs user id of the swarm service account); telemetry reports the effective ANONYMIZED_TELEMETRY opt-out state.",
817
+ "Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; setup milestones each emit `unverified | configured | verified`; automations report `running | needs_setup` from the same runtime preflight used at dispatch; activity counts agents alive in the last 5 min, leads that can take work (status idle or busy), and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set, plus the Comb settings (whether Files is on and, when off, what is missing; the browser-facing agent-fs URL, the shared org and drive ids, and the agent-fs user id of the swarm service account); telemetry reports the effective ANONYMIZED_TELEMETRY opt-out state.",
815
818
  tags: ["Status"],
816
819
  responses: {
817
820
  200: { description: "Status payload", schema: StatusResponseSchema },
package/src/http/tasks.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  import { ensure } from "../utils/business-use";
85
85
  import { getRequestAuth } from "../utils/request-auth-context";
86
86
  import { scrubSecrets } from "../utils/secret-scrubber";
87
+ import { createListTotalCache } from "./list-total-cache";
87
88
  import {
88
89
  agentFirstPrincipal,
89
90
  authUserId,
@@ -223,6 +224,8 @@ const SupersedeTaskResponseSchema = z.object({
223
224
 
224
225
  // ─── Route Definitions ───────────────────────────────────────────────────────
225
226
 
227
+ const taskTotals = createListTotalCache();
228
+
226
229
  const listTasks = route({
227
230
  method: "get",
228
231
  path: "/api/tasks",
@@ -240,7 +243,12 @@ const listTasks = route({
240
243
  workflowRunId: z.string().uuid().optional(),
241
244
  key: AssetKeySchema.optional(),
242
245
  keyPrefix: AssetKeySchema.optional(),
243
- search: z.string().optional(),
246
+ search: z
247
+ .string()
248
+ .optional()
249
+ .describe(
250
+ "Search task text and title by word or phrase, with a prefix match on the last word, or a task id by prefix. Not a substring match: '2443' does not find '12443', and '%' and '_' are not wildcards.",
251
+ ),
244
252
  includeHeartbeat: z.enum(["true", "false"]).optional(),
245
253
  /** ISO 8601 — return only tasks created on/after this timestamp. */
246
254
  createdAfter: z.string().datetime().optional(),
@@ -1015,8 +1023,10 @@ export async function handleTasks(
1015
1023
  ? await getAllTasks(filters, { fields: "timeline" })
1016
1024
  : await getAllTasks(filters, { slim: true });
1017
1025
  // The COUNT(*) re-scans every matching row, so only pagers pay for it.
1026
+ // Page 1 counts; later pages reuse that count for 30 s.
1018
1027
  if (parsed.query.includeTotal === "true") {
1019
- const total = await getTasksCount(filters);
1028
+ const { limit: _limit, offset: _offset, orderBy: _orderBy, ...totalKey } = filters;
1029
+ const total = await taskTotals.get(totalKey, filters.offset, () => getTasksCount(filters));
1020
1030
  listTasks.respond(res, 200, { tasks, total });
1021
1031
  } else {
1022
1032
  listTasks.respond(res, 200, { tasks });
@@ -45,7 +45,7 @@ import type {
45
45
  * default; the image installs exactly this version and the per-task settings
46
46
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
47
47
  */
48
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1793104576-gc52692";
48
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1793160055-gbd79f2";
49
49
 
50
50
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
51
51
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -35,7 +35,7 @@ import type {
35
35
  } from "./types";
36
36
 
37
37
  /** Pinned in package.json and Dockerfile.worker; bump all three together. */
38
- export const CURSOR_SDK_VERSION = "1.1.0";
38
+ export const CURSOR_SDK_VERSION = "1.2.0";
39
39
 
40
40
  /** MCP server name; Cursor reports its tools as `mcp` calls with this `providerIdentifier`. */
41
41
  const CURSOR_MCP_SERVER_NAME = "agent-swarm";
@@ -37,7 +37,7 @@ import type {
37
37
  } from "./types";
38
38
 
39
39
  // npm's `latest` is older and lacks the JSON/stdin contract used here.
40
- export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-rc.4";
40
+ export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.2-alpha.2";
41
41
 
42
42
  export function checkDshCredentials(env: Record<string, string | undefined>): CredStatus {
43
43
  return env.DEEPSEEK_API_KEY?.trim() || env.OPENROUTER_API_KEY?.trim()
@@ -86,12 +86,15 @@ export async function getQueueStallSnapshot(now = new Date()): Promise<QueueStal
86
86
  );
87
87
 
88
88
  const pickupCutoff = new Date(now.getTime() - QUEUE_STALL_THRESHOLD_MS).toISOString();
89
+ // Unary + keeps eventType off idx_agent_log_eventType, so the short
90
+ // createdAt window drives through idx_agent_log_createdAt instead of every
91
+ // status-change row ever logged.
89
92
  const recentPickupCount =
90
93
  (
91
94
  await getDbClient().get<{ count: number }>(
92
95
  `SELECT COUNT(*) AS count
93
96
  FROM agent_log
94
- WHERE eventType IN ('task_status_change', 'task_claimed')
97
+ WHERE +eventType IN ('task_status_change', 'task_claimed')
95
98
  AND oldValue IN ('pending', 'unassigned')
96
99
  AND newValue = 'in_progress'
97
100
  AND createdAt >= ?`,
@@ -8,7 +8,7 @@ import { resolveSlackUserId } from "./enrich";
8
8
  import { EXTERNAL_CHANNEL_BLOCKED_REPLY, shouldBlockChannel } from "./external-channel";
9
9
  import { ignoreSlackInbound, noteSlackInboundSideEffect } from "./inbound-dispatch";
10
10
  import { ensureSlackThreadTree, isSlackRenderV2Enabled } from "./render-v2";
11
- import { getAgentDisplayName, getAgentEmoji } from "./responses";
11
+ import { getAgentDisplayName, getAgentSlackIcon } from "./responses";
12
12
 
13
13
  type ChatUpdatePayload = ChatUpdateArguments & {
14
14
  unfurl_links: false;
@@ -153,7 +153,7 @@ export function registerActionHandlers(app: App): void {
153
153
  text: `💬 Follow-up sent to *${agentName}* (${taskLink})`,
154
154
  unfurl_links: false,
155
155
  unfurl_media: false,
156
- ...(lead ? { username: getAgentDisplayName(lead), icon_emoji: getAgentEmoji(lead) } : {}),
156
+ ...(lead ? { username: getAgentDisplayName(lead), ...getAgentSlackIcon(lead) } : {}),
157
157
  });
158
158
  } catch (error) {
159
159
  console.error("[Slack] Failed to post follow-up confirmation:", error);
@@ -0,0 +1,50 @@
1
+ import { WebClient } from "@slack/web-api";
2
+ import { getSlackApp } from "./app";
3
+
4
+ const TTL_MS = 60 * 60 * 1000;
5
+ // Per request, with no retries: the lookup ends within two of these, and an
6
+ // aborted auth.test never goes on to bots.info.
7
+ const REQUEST_TIMEOUT_MS = 2_500;
8
+
9
+ // Keyed by token and API URL, so a reconfigured Slack app is looked up again.
10
+ let cached: { key: string; url: string | null; at: number } | null = null;
11
+ let inFlight: { key: string; promise: Promise<string | null> } | null = null;
12
+
13
+ async function lookUp(client: WebClient): Promise<string | null> {
14
+ const auth = await client.auth.test();
15
+ if (!auth.bot_id) return null;
16
+ const info = await client.bots.info({ bot: auth.bot_id });
17
+ const icons = info.bot?.icons;
18
+ return icons?.image_72 ?? icons?.image_48 ?? icons?.image_36 ?? null;
19
+ }
20
+
21
+ /**
22
+ * The avatar Slack shows for this app's posts when no icon is overridden
23
+ * (`bots.info` for the bot behind the token). Null when Slack is off or the
24
+ * lookup fails. Cached for an hour; failures are not cached.
25
+ */
26
+ export async function getSlackAppIconUrl(): Promise<string | null> {
27
+ // Slack can be turned off at runtime, so check it before the cache.
28
+ const app = getSlackApp()?.client;
29
+ if (!app?.token) return null;
30
+ const key = `${app.slackApiUrl} ${app.token}`;
31
+ if (cached?.key === key && Date.now() - cached.at < TTL_MS) return cached.url;
32
+ if (inFlight?.key === key) return await inFlight.promise;
33
+ const client = new WebClient(app.token, {
34
+ slackApiUrl: app.slackApiUrl,
35
+ retryConfig: { retries: 0 },
36
+ rejectRateLimitedCalls: true,
37
+ timeout: REQUEST_TIMEOUT_MS,
38
+ });
39
+ const promise: Promise<string | null> = lookUp(client)
40
+ .then((url) => {
41
+ cached = { key, url, at: Date.now() };
42
+ return url;
43
+ })
44
+ .catch(() => null)
45
+ .finally(() => {
46
+ if (inFlight?.promise === promise) inFlight = null;
47
+ });
48
+ inFlight = { key, promise };
49
+ return await promise;
50
+ }
package/src/slack/app.ts CHANGED
@@ -5,13 +5,15 @@ import { getSlackConfiguration } from "./config";
5
5
  import { type SlackConnectionState, setSlackConnectionState } from "./connection-state";
6
6
  import { installSlackEgressScrub } from "./egress-scrub";
7
7
  import { installSlackExternalChannelGuard } from "./external-channel-egress";
8
- import { slackInboundOutcomeMiddleware } from "./inbound-dispatch";
8
+ import { recoverSlackInboundAfterRestart, slackInboundOutcomeMiddleware } from "./inbound-dispatch";
9
+ import { installSocketAckRecovery } from "./socket-ack";
9
10
  import { getSlackSocketModeBlockReason, SLACK_DEV_SOCKET_MODE_OPT_IN } from "./socket-mode-guard";
10
11
  import { startTaskWatcher, stopTaskWatcher } from "./watcher";
11
12
 
12
13
  let app: App | null = null;
13
14
  let receiver: SocketModeReceiver | null = null;
14
15
  let initialized = false;
16
+ let inboundDrain: ReturnType<typeof installSocketAckRecovery> | null = null;
15
17
 
16
18
  const SOCKET_EVENT_STATES: Record<string, SlackConnectionState> = {
17
19
  connecting: "connecting",
@@ -105,6 +107,10 @@ export async function initSlackApp(): Promise<App | null> {
105
107
  ...(clientOptions ? { clientOptions } : {}),
106
108
  });
107
109
 
110
+ // Events whose Socket Mode ack failed are stored as receipts; the drain hands
111
+ // them back to Bolt, so a crash or a failing listener leaves recoverable work.
112
+ inboundDrain = installSocketAckRecovery(receiver.client, app);
113
+
108
114
  // Failed validation must remain retryable without requiring stopSlackApp().
109
115
  initialized = true;
110
116
 
@@ -147,8 +153,12 @@ export async function startSlackApp(): Promise<boolean> {
147
153
  // Establish the durable cutoff before Socket Mode can deliver new asks.
148
154
  const { isSlackRenderV2Enabled } = await import("./render-v2");
149
155
  if (isSlackRenderV2Enabled()) await ensureSlackRenderV2Activation();
156
+ // Receipts stored before the last restart: interrupted ones turn uncertain,
157
+ // pending ones are processed once the app can dispatch them.
158
+ await recoverSlackInboundAfterRestart();
150
159
  await app.start();
151
160
  console.log("[Slack] Bot connected via Socket Mode");
161
+ void inboundDrain?.wake();
152
162
  await emitBuiltInIntegrationConnectedOnce("slack");
153
163
 
154
164
  // Start watching for task completions
@@ -161,6 +171,8 @@ export async function startSlackApp(): Promise<boolean> {
161
171
 
162
172
  export async function stopSlackApp(): Promise<void> {
163
173
  stopTaskWatcher();
174
+ inboundDrain?.stop();
175
+ inboundDrain = null;
164
176
 
165
177
  if (app) {
166
178
  await app.stop();
@@ -46,9 +46,24 @@ async function isExternalAssistantThread(
46
46
  return true;
47
47
  }
48
48
 
49
- export function createAssistant(): Assistant {
49
+ type ThreadContextStore = NonNullable<
50
+ ConstructorParameters<typeof Assistant>[0]["threadContextStore"]
51
+ >;
52
+
53
+ /** `threadContextStore` replaces Bolt's metadata-backed default, which calls the Slack API. */
54
+ export function createAssistant(
55
+ options: { threadContextStore?: ThreadContextStore } = {},
56
+ ): Assistant {
50
57
  return new Assistant({
58
+ ...(options.threadContextStore ? { threadContextStore: options.threadContextStore } : {}),
51
59
  threadStarted: async ({ event, body, client, say, setSuggestedPrompts, saveThreadContext }) => {
60
+ // The greeting is not idempotent: a redelivery after a tolerated socket ack
61
+ // failure must not post it twice.
62
+ const eventId = body?.event_id;
63
+ if (!isAdmittedSlackDelivery() && wasEventSeen(eventId)) {
64
+ console.log(`[Slack] dropping Slack retry: event_id=${eventId}`);
65
+ return ignoreSlackInbound("duplicate_event");
66
+ }
52
67
  if (await isExternalAssistantThread(client, event.assistant_thread.channel_id, body)) {
53
68
  return ignoreSlackInbound("external_channel");
54
69
  }
@@ -75,6 +90,12 @@ export function createAssistant(): Assistant {
75
90
  },
76
91
 
77
92
  threadContextChanged: async ({ event, body, client, saveThreadContext }) => {
93
+ // Saves are order-dependent: a stale redelivery would overwrite newer context.
94
+ const eventId = body?.event_id;
95
+ if (!isAdmittedSlackDelivery() && wasEventSeen(eventId)) {
96
+ console.log(`[Slack] dropping Slack retry: event_id=${eventId}`);
97
+ return ignoreSlackInbound("duplicate_event");
98
+ }
78
99
  if (await isExternalAssistantThread(client, event.assistant_thread.channel_id, body)) {
79
100
  return ignoreSlackInbound("external_channel");
80
101
  }