@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
@@ -45,6 +45,17 @@ describe("wasEventSeen (production cache)", () => {
45
45
  });
46
46
 
47
47
  describe("isolated test cache (TTL behavior)", () => {
48
+ test("default TTL still drops a redelivery after Slack's 24h delayed-retry window", () => {
49
+ const cache = _createTestCache();
50
+ try {
51
+ expect(cache.wasEventSeen("Ev_late")).toBe(false);
52
+ cache.advance(24 * 60 * 60 * 1000); // last delayed-events retry
53
+ expect(cache.wasEventSeen("Ev_late")).toBe(true);
54
+ } finally {
55
+ cache.destroy();
56
+ }
57
+ });
58
+
48
59
  test("entry expires after TTL elapses", () => {
49
60
  const cache = _createTestCache(1000); // 1s TTL
50
61
  try {
@@ -13,15 +13,19 @@
13
13
  * This module exposes a single in-memory check-and-insert that returns `false`
14
14
  * the first time we see an event_id (caller should proceed) and `true` on
15
15
  * subsequent retries within the TTL window — i.e. it answers "was this event
16
- * already seen?" (default TTL 5 min). Slack's max retry window is 1h with 3
17
- * retries, but the second retry typically lands within 60s, so 5 min is a
18
- * safe-but-tight bound.
16
+ * already seen?" (default TTL 25h). The TTL must outlive the last possible
17
+ * redelivery: Slack's standard schedule ends after ~5 min, but the optional
18
+ * Delayed Events feature retries hourly for 24h. The socket-ack wrapper
19
+ * (`socket-ack.ts`) runs listeners for an event whose ack failed, so this cache
20
+ * is what drops the later redelivery. Entries are an id and a number, so the
21
+ * long TTL costs little memory.
19
22
  *
20
23
  * Single-pod-only. The API server (which owns the Slack socket) runs as a
21
24
  * single API process; if that ever changes, swap this for a DB-backed table.
22
25
  */
23
26
 
24
- const DEFAULT_TTL_MS = 300_000; // 5 minutes
27
+ export const EVENT_DEDUP_TTL_MS = 25 * 60 * 60 * 1000; // 24h delayed-retry window + 1h margin
28
+ const DEFAULT_TTL_MS = EVENT_DEDUP_TTL_MS;
25
29
  const CLEANUP_INTERVAL_MS = 60_000; // 1 minute
26
30
 
27
31
  interface DedupCache {
@@ -9,6 +9,7 @@ import {
9
9
  getNextSlackInboundAvailableAt,
10
10
  getSlackInboundReceiptStats,
11
11
  markInterruptedSlackInboundReceiptsUncertain,
12
+ recordSlackInboundInline,
12
13
  releaseSlackInboundReceipt,
13
14
  SLACK_INBOUND_LIMITS,
14
15
  type SlackInboundAdmission,
@@ -16,6 +17,7 @@ import {
16
17
  type SlackInboundReceipt,
17
18
  type SlackInboundReceiptStats,
18
19
  type SlackInboundTransport,
20
+ touchSlackInboundDuplicate,
19
21
  } from "../be/db-queries/slack-inbound";
20
22
  import { scrubSecrets } from "../utils/secret-scrubber";
21
23
  import { getSlackConfiguration, type SlackMode } from "./config";
@@ -128,6 +130,8 @@ export function resolveSlackInboundOutcome(
128
130
  // ─── Outcome counters (Socket Mode is observed, not stored) ─────────────────
129
131
 
130
132
  type OutcomeCounters = Record<SlackInboundOutcomeState, number> & {
133
+ /** Socket acks that failed because the client was not ready; the event still ran. */
134
+ ackNotReady: number;
131
135
  lastOutcomeAt: string | null;
132
136
  lastFailure: { code: string; at: string } | null;
133
137
  };
@@ -138,6 +142,7 @@ function emptyCounters(): OutcomeCounters {
138
142
  ignored: 0,
139
143
  failed: 0,
140
144
  uncertain: 0,
145
+ ackNotReady: 0,
141
146
  lastOutcomeAt: null,
142
147
  lastFailure: null,
143
148
  };
@@ -159,6 +164,11 @@ function recordOutcome(transport: SlackInboundTransport, outcome: SlackInboundOu
159
164
  }
160
165
  }
161
166
 
167
+ /** Count a Socket Mode ack that failed because the client was not ready. */
168
+ export function recordSlackSocketAckNotReady(): void {
169
+ counters.socket.ackNotReady += 1;
170
+ }
171
+
162
172
  /** Test-only. */
163
173
  export function resetSlackInboundCountersForTests(): void {
164
174
  counters.socket = emptyCounters();
@@ -290,6 +300,54 @@ export function computeSlackInboundKey(
290
300
  };
291
301
  }
292
302
 
303
+ /** Dedup key for a Socket Mode `event_callback` body, or null for any other envelope. */
304
+ function socketEventKey(body: unknown): Extract<SlackInboundKeyResult, { ok: true }> | null {
305
+ if ((body as { type?: unknown } | null)?.type !== "event_callback") return null;
306
+ const key = computeSlackInboundKey("event", "", body);
307
+ return key.ok ? key : null;
308
+ }
309
+
310
+ /**
311
+ * Is this Socket Mode event already stored as a receipt (admitted after a
312
+ * failed ack)? Counts the sighting. A stored receipt is owned by the drain, so
313
+ * a redelivery needs no second run.
314
+ */
315
+ export async function isSlackSocketEventStored(
316
+ body: unknown,
317
+ now: Date = new Date(),
318
+ ): Promise<boolean> {
319
+ const key = socketEventKey(body);
320
+ return key ? touchSlackInboundDuplicate(key.dedupKey, now) : false;
321
+ }
322
+
323
+ /**
324
+ * Remember a Socket Mode event that runs inline after a failed ack, so its
325
+ * redelivery is dropped by `isSlackSocketEventStored` even after a restart.
326
+ * Stores the dedup key only, never the payload. `invalid` when the envelope has
327
+ * no usable key (nothing to remember, the caller still runs it).
328
+ */
329
+ export async function recordSlackSocketEventRanInline(
330
+ body: unknown,
331
+ retry: { retryNum?: number | null; retryReason?: string | null } = {},
332
+ now: Date = new Date(),
333
+ ): Promise<"recorded" | "duplicate" | "invalid"> {
334
+ const key = socketEventKey(body);
335
+ if (!key) return "invalid";
336
+ return recordSlackInboundInline(
337
+ {
338
+ dedupKey: key.dedupKey,
339
+ transport: "socket",
340
+ kind: "event",
341
+ payloadType: key.payloadType,
342
+ apiAppId: key.apiAppId,
343
+ eventId: key.eventId,
344
+ retryNum: retry.retryNum,
345
+ retryReason: retry.retryReason,
346
+ },
347
+ now,
348
+ );
349
+ }
350
+
293
351
  export interface AdmitSlackInboundInput {
294
352
  transport: SlackInboundTransport;
295
353
  kind: SlackInboundKind;
@@ -52,7 +52,7 @@ import {
52
52
  } from "./blocks";
53
53
  import { buildAskClosure, type ClosureState, closureState } from "./closure";
54
54
  import { completedStatusIcon, reactionName, type SlackReactionEvent } from "./reaction-shortcode";
55
- import { getAgentDisplayName, getAgentEmoji } from "./responses";
55
+ import { getAgentDisplayName, getAgentSlackIcon } from "./responses";
56
56
  import {
57
57
  _resetSlackSessionStatusForTests,
58
58
  beginSlackStatusTick,
@@ -675,7 +675,7 @@ async function createThreadTree(task: AgentTask): Promise<SlackMessageRecord | n
675
675
  blocks: treeBlocks(text),
676
676
  unfurl_links: false,
677
677
  unfurl_media: false,
678
- ...(agent ? { username: getAgentDisplayName(agent), icon_emoji: getAgentEmoji(agent) } : {}),
678
+ ...(agent ? { username: getAgentDisplayName(agent), ...getAgentSlackIcon(agent) } : {}),
679
679
  metadata: {
680
680
  event_type: SLACK_RENDER_METADATA_EVENT,
681
681
  event_payload: { message_id: reservation.id, kind: "tree" },
@@ -1312,10 +1312,10 @@ export async function streamOutcomeCard(
1312
1312
  markdown_text: presentation,
1313
1313
  };
1314
1314
  const agent = task.agentId ? await getAgentById(task.agentId) : undefined;
1315
- if (agent) {
1316
- startPayload.username = getAgentDisplayName(agent);
1317
- startPayload.icon_emoji = getAgentEmoji(agent);
1318
- }
1315
+ const persona = agent
1316
+ ? { username: getAgentDisplayName(agent), ...getAgentSlackIcon(agent) }
1317
+ : {};
1318
+ Object.assign(startPayload, persona);
1319
1319
  if (!task.slackChannelId.startsWith("D") && task.slackUserId) {
1320
1320
  const teamId = await slackTeamId(app.client);
1321
1321
  if (teamId) {
@@ -1379,8 +1379,7 @@ export async function streamOutcomeCard(
1379
1379
  thread_ts: task.slackThreadTs,
1380
1380
  text: presentation,
1381
1381
  blocks: [{ type: "markdown", text: presentation }, ...captions],
1382
- ...(startPayload.username ? { username: startPayload.username } : {}),
1383
- ...(startPayload.icon_emoji ? { icon_emoji: startPayload.icon_emoji } : {}),
1382
+ ...persona,
1384
1383
  });
1385
1384
  deliveredViaFallback = true;
1386
1385
  }
@@ -99,7 +99,7 @@ export async function sendInlineTaskOutput(task: AgentTask): Promise<boolean> {
99
99
  thread_ts: task.slackThreadTs,
100
100
  text: batch.join("\n\n"),
101
101
  username: getAgentDisplayName(agent),
102
- icon_emoji: getAgentEmoji(agent),
102
+ icon: getAgentSlackIcon(agent),
103
103
  blocks: batch.map((chunk) => ({
104
104
  type: "section",
105
105
  text: { type: "mrkdwn", text: chunk },
@@ -173,7 +173,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
173
173
  ? `${completedStatusIcon()} ${agentName} completed${i > 0 ? ` (continued ${i + 1}/${blockBatches.length})` : ""}`
174
174
  : body,
175
175
  username: getAgentDisplayName(agent),
176
- icon_emoji: getAgentEmoji(agent),
176
+ icon: getAgentSlackIcon(agent),
177
177
  blocks: blockBatches[i],
178
178
  });
179
179
  }
@@ -185,7 +185,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
185
185
  thread_ts: task.slackThreadTs,
186
186
  text: `Task failed: ${reason}`,
187
187
  username: getAgentDisplayName(agent),
188
- icon_emoji: getAgentEmoji(agent),
188
+ icon: getAgentSlackIcon(agent),
189
189
  blocks,
190
190
  });
191
191
  } else if (task.status === "cancelled") {
@@ -195,7 +195,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
195
195
  thread_ts: task.slackThreadTs,
196
196
  text: "Task cancelled",
197
197
  username: getAgentDisplayName(agent),
198
- icon_emoji: getAgentEmoji(agent),
198
+ icon: getAgentSlackIcon(agent),
199
199
  blocks,
200
200
  });
201
201
  }
@@ -232,7 +232,7 @@ export async function sendProgressUpdate(
232
232
  thread_ts: task.slackThreadTs,
233
233
  text: progress,
234
234
  username: getAgentDisplayName(agent),
235
- icon_emoji: getAgentEmoji(agent),
235
+ icon: getAgentSlackIcon(agent),
236
236
  blocks,
237
237
  });
238
238
  } catch (error) {
@@ -354,7 +354,7 @@ export async function updateToFinal(
354
354
  thread_ts: task.slackThreadTs ?? messageTs,
355
355
  text: `${completedStatusIcon()} ${agentName} completed (continued ${i + 1}/${completionBlockBatches.length})`,
356
356
  username: getAgentDisplayName(agent),
357
- icon_emoji: getAgentEmoji(agent),
357
+ icon: getAgentSlackIcon(agent),
358
358
  blocks: completionBlockBatches[i],
359
359
  });
360
360
  }
@@ -418,7 +418,7 @@ export async function sendWithPersona(
418
418
  thread_ts: string;
419
419
  text: string;
420
420
  username: string;
421
- icon_emoji: string;
421
+ icon: SlackPersonaIcon;
422
422
  blocks?: unknown[];
423
423
  },
424
424
  ): Promise<string | undefined> {
@@ -433,7 +433,7 @@ export async function sendWithPersona(
433
433
  unfurl_links: false,
434
434
  unfurl_media: false,
435
435
  username: options.username,
436
- icon_emoji: options.icon_emoji,
436
+ ...options.icon,
437
437
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects are typed as plain JSON
438
438
  blocks: blocks as any,
439
439
  });
@@ -441,6 +441,19 @@ export async function sendWithPersona(
441
441
  return result.ts;
442
442
  }
443
443
 
444
+ /** Empty = no override, so Slack shows the app's own avatar. */
445
+ export type SlackPersonaIcon =
446
+ | { icon_emoji: string }
447
+ | { icon_url: string }
448
+ | Record<string, never>;
449
+
450
+ export function getAgentSlackIcon(agent: Agent): SlackPersonaIcon {
451
+ if (agent.slackIcon?.type === "url") return { icon_url: agent.slackIcon.url };
452
+ if (agent.slackIcon?.type === "emoji") return { icon_emoji: agent.slackIcon.emoji };
453
+ if (agent.slackIcon?.type === "app") return {};
454
+ return { icon_emoji: getAgentEmoji(agent) };
455
+ }
456
+
444
457
  export function getAgentEmoji(agent: Agent): string {
445
458
  if (agent.isLead) return ":crown:";
446
459
 
@@ -0,0 +1,237 @@
1
+ import type { App, SocketModeReceiver } from "@slack/bolt";
2
+ import {
3
+ admitSlackInbound,
4
+ createSlackInboundDrain,
5
+ isSlackSocketEventStored,
6
+ recordSlackSocketAckNotReady,
7
+ recordSlackSocketEventRanInline,
8
+ } from "./inbound-dispatch";
9
+
10
+ /** @slack/socket-mode `sendWhileNotReadyError()`: the socket is down or reconnecting. */
11
+ const NOT_READY_MESSAGE = "Failed to send a WebSocket message as the client is not ready";
12
+
13
+ export function isSocketNotReadyError(error: unknown): boolean {
14
+ return error instanceof Error && error.message === NOT_READY_MESSAGE;
15
+ }
16
+
17
+ /**
18
+ * Events whose effect is "last write wins". Run late from the drain they would
19
+ * overwrite a newer event that already ran live, so they never wait in the queue.
20
+ * They run inline and leave only a dedup-key tombstone behind.
21
+ */
22
+ const ORDER_DEPENDENT_EVENTS = new Set(["assistant_thread_context_changed"]);
23
+
24
+ function isOrderDependentEvent(body: unknown): boolean {
25
+ const type = (body as { event?: { type?: unknown } } | undefined)?.event?.type;
26
+ return typeof type === "string" && ORDER_DEPENDENT_EVENTS.has(type);
27
+ }
28
+
29
+ type SocketAck = (response?: Record<string, unknown>) => Promise<void>;
30
+
31
+ interface SocketEnvelope {
32
+ ack?: SocketAck;
33
+ envelope_id?: string;
34
+ type?: string;
35
+ body?: unknown;
36
+ retry_num?: number;
37
+ retry_reason?: string;
38
+ }
39
+
40
+ interface SocketAckOptions {
41
+ /** Called after a not-ready event was stored as a pending receipt: start the drain. */
42
+ onAdmitted: () => void;
43
+ }
44
+
45
+ /**
46
+ * Bolt acks an Events API delivery before it runs any middleware or listener,
47
+ * and the receiver drops the event when that ack throws. A stalled event loop
48
+ * can kill the socket between receive and ack, so the event is lost.
49
+ *
50
+ * Wrap the ack Bolt receives on each `slack_event`. When it fails only because
51
+ * the socket is not ready, persist the event as a `pending` receipt (encrypted
52
+ * payload plus dedup key, in one transaction) and wake the durable drain, which
53
+ * hands it back to Bolt (`app.processEvent`) and records its outcome. Bolt's own
54
+ * copy still drops, because the original error is rethrown. Work is persisted
55
+ * before anything runs, so a crash or a failing middleware leaves a recoverable
56
+ * receipt rather than a "processed" marker: a `pending` receipt is retried after
57
+ * a restart, and a delivery that failed before any side effect is retried with
58
+ * backoff. Only a crash mid-listener leaves it `uncertain`, which the drain
59
+ * never replays, as for every durable delivery.
60
+ *
61
+ * If the receipt cannot be stored (store down, backlog full, no event id) the
62
+ * ack error is swallowed instead and Bolt runs the listeners inline, deduped
63
+ * only by the in-memory `event_id` cache: losing the event is the worse failure.
64
+ * Order-dependent events (`assistant_thread_context_changed`, last write wins)
65
+ * take that inline path by design: queued, an older one could run after a newer
66
+ * one that ran live and overwrite it. Their dedup key is stored alongside, off
67
+ * the ack path (Bolt runs the listener once the ack settles, so awaiting the
68
+ * write would let a newer live event run first), with no payload and no way for
69
+ * the drain to claim it. The in-memory cache is then not the only thing standing
70
+ * between a redelivery and a restart: after one, an older context would
71
+ * otherwise overwrite a newer one. A crash before that write lands loses only
72
+ * this protection, and a redelivery in that window can run twice.
73
+ * Any other ack error still throws.
74
+ *
75
+ * Slack redelivers the unacked event with the same `event_id` and a
76
+ * `retry_num`. The redelivery is dropped before it reaches Bolt when its key is
77
+ * stored, so the receipt stays the single owner of the work, also across a
78
+ * restart. Only retries pay the lookup; first deliveries emit synchronously. If
79
+ * the lookup fails the retry runs.
80
+ *
81
+ * Only `events_api` envelopes are wrapped. Interactive payloads (modal
82
+ * submissions, button actions) and slash commands ack from inside their
83
+ * handler, which does the work after acking: swallowing a failed ack there
84
+ * would run the handler while Slack shows the user an error, and the retry
85
+ * would run it again.
86
+ *
87
+ * Wrapping `emit` (public) is the narrowest hook: the receiver builds its own
88
+ * client and reads `args.ack` when its `slack_event` listener runs.
89
+ */
90
+ function tolerateSocketNotReadyAcks(
91
+ client: SocketModeReceiver["client"],
92
+ options: SocketAckOptions,
93
+ ): void {
94
+ const emit = client.emit.bind(client);
95
+ const deliver = (event: string | symbol, args: unknown[]) => emit(event, ...args);
96
+
97
+ client.emit = (event: string | symbol, ...args: unknown[]): boolean => {
98
+ const payload = args[0] as SocketEnvelope | undefined;
99
+ if (
100
+ event !== "slack_event" ||
101
+ payload?.type !== "events_api" ||
102
+ typeof payload.ack !== "function"
103
+ ) {
104
+ return deliver(event, args);
105
+ }
106
+
107
+ const ack = payload.ack;
108
+ const envelope = payload.envelope_id ?? "unknown";
109
+ payload.ack = async (response) => {
110
+ try {
111
+ await ack(response);
112
+ } catch (error) {
113
+ if (!isSocketNotReadyError(error)) throw error;
114
+ recordSlackSocketAckNotReady();
115
+ const disposition = await dispose(payload, envelope);
116
+ if (disposition === "drain") {
117
+ options.onAdmitted();
118
+ throw error; // Bolt drops its copy; the receipt carries the event.
119
+ }
120
+ if (disposition === "drop") throw error; // An earlier delivery already ran it.
121
+ console.warn(
122
+ `[Slack] socket ack failed (client not ready), processing inline: envelope_id=${envelope}`,
123
+ );
124
+ }
125
+ };
126
+
127
+ if (typeof payload.retry_num !== "number" || payload.retry_num < 1) {
128
+ return deliver(event, args);
129
+ }
130
+
131
+ // A redelivery: drop it when a receipt already owns the event.
132
+ void (async () => {
133
+ let stored = false;
134
+ try {
135
+ stored = await isSlackSocketEventStored(payload.body);
136
+ } catch (storeError) {
137
+ console.warn("[Slack] could not check stored event key, processing:", storeError);
138
+ }
139
+ if (!stored) {
140
+ deliver(event, args);
141
+ return;
142
+ }
143
+ console.log(`[Slack] dropping redelivery of a stored event: envelope_id=${envelope}`);
144
+ try {
145
+ await ack();
146
+ } catch (error) {
147
+ console.warn("[Slack] ack of a dropped redelivery failed:", error);
148
+ }
149
+ })();
150
+ return client.listenerCount(event) > 0;
151
+ };
152
+ }
153
+
154
+ type Disposition =
155
+ /** Stored as a pending receipt: the drain runs it. */
156
+ | "drain"
157
+ /** Its key was already stored: an earlier delivery owns it. */
158
+ | "drop"
159
+ /** Not stored for the drain (store failure, or order-dependent): Bolt runs it inline. */
160
+ | "inline";
161
+
162
+ async function dispose(payload: SocketEnvelope, envelope: string): Promise<Disposition> {
163
+ if (isOrderDependentEvent(payload.body)) {
164
+ // Not awaited: Bolt runs the listener once the ack settles, so a slow or
165
+ // blocked write would hold this event behind a newer one that ran live.
166
+ void rememberInline(payload, envelope);
167
+ return "inline";
168
+ }
169
+ try {
170
+ const result = await admitSlackInbound({
171
+ transport: "socket",
172
+ kind: "event",
173
+ rawBody: JSON.stringify(payload.body),
174
+ body: payload.body,
175
+ retryNum: payload.retry_num,
176
+ retryReason: payload.retry_reason,
177
+ });
178
+ if (result.status === "admitted" || result.status === "duplicate") {
179
+ console.warn(
180
+ `[Slack] socket ack failed (client not ready), event stored for the drain: envelope_id=${envelope}`,
181
+ );
182
+ return "drain";
183
+ }
184
+ console.warn(
185
+ `[Slack] could not store unacked event (${result.reason}): envelope_id=${envelope}`,
186
+ );
187
+ } catch (storeError) {
188
+ console.warn("[Slack] could not persist unacked event:", storeError);
189
+ }
190
+ return "inline";
191
+ }
192
+
193
+ /**
194
+ * An order-dependent event runs inline. Its key is stored alongside (no payload,
195
+ * nothing for the drain to claim) so a redelivery after a restart is dropped
196
+ * instead of overwriting a newer event that ran in between. The write is
197
+ * best-effort and off the ack path: it never delays the event, and a store
198
+ * failure only costs the restart protection, never the event.
199
+ */
200
+ async function rememberInline(payload: SocketEnvelope, envelope: string): Promise<void> {
201
+ try {
202
+ const result = await recordSlackSocketEventRanInline(payload.body, {
203
+ retryNum: payload.retry_num,
204
+ retryReason: payload.retry_reason,
205
+ });
206
+ if (result === "duplicate") {
207
+ console.warn(`[Slack] inline event key was already stored: envelope_id=${envelope}`);
208
+ }
209
+ } catch (storeError) {
210
+ console.warn("[Slack] could not remember unacked inline event:", storeError);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Install the not-ready ack tolerance on the receiver's client and the durable
216
+ * drain that runs the receipts it stores. The caller owns the drain: `wake()` it
217
+ * after boot recovery to pick up receipts stored before a restart, `stop()` it
218
+ * on shutdown.
219
+ */
220
+ export function installSocketAckRecovery(
221
+ client: SocketModeReceiver["client"],
222
+ app: Pick<App, "processEvent">,
223
+ ): ReturnType<typeof createSlackInboundDrain> {
224
+ const drain = createSlackInboundDrain(
225
+ async ({ receipt, rawBody }) => {
226
+ await app.processEvent({
227
+ body: JSON.parse(rawBody),
228
+ ack: async () => {},
229
+ retryNum: receipt.retryNum ?? undefined,
230
+ retryReason: receipt.retryReason ?? undefined,
231
+ });
232
+ },
233
+ { requireBoltDispatch: true },
234
+ );
235
+ tolerateSocketNotReadyAcks(client, { onAdmitted: () => void drain.wake() });
236
+ return drain;
237
+ }
@@ -23,7 +23,7 @@ import {
23
23
  } from "./inbound-files";
24
24
  import { extractSlackMessageText } from "./message-text";
25
25
  import { ensureSlackThreadTree, isSlackRenderV2Enabled } from "./render-v2";
26
- import { getAgentDisplayName, getAgentEmoji } from "./responses";
26
+ import { getAgentDisplayName, getAgentSlackIcon } from "./responses";
27
27
  import { formatSlackSteeringAck, requestSlackThreadSteering } from "./steering";
28
28
  import { registerTreeMessage } from "./watcher";
29
29
 
@@ -257,9 +257,7 @@ async function slackFlush(
257
257
  text: formatSlackSteeringAck(steering.result),
258
258
  unfurl_links: false,
259
259
  unfurl_media: false,
260
- ...(agent
261
- ? { username: getAgentDisplayName(agent), icon_emoji: getAgentEmoji(agent) }
262
- : {}),
260
+ ...(agent ? { username: getAgentDisplayName(agent), ...getAgentSlackIcon(agent) } : {}),
263
261
  });
264
262
  } catch (error) {
265
263
  console.error("[Slack] Failed to post steering feedback:", error);
@@ -363,7 +361,7 @@ async function slackFlush(
363
361
  text: fallbackText,
364
362
  unfurl_links: false,
365
363
  unfurl_media: false,
366
- ...(lead ? { username: getAgentDisplayName(lead), icon_emoji: getAgentEmoji(lead) } : {}),
364
+ ...(lead ? { username: getAgentDisplayName(lead), ...getAgentSlackIcon(lead) } : {}),
367
365
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
368
366
  blocks: blocks as any,
369
367
  });
@@ -18,7 +18,7 @@ import { buildTreeBlocks, formatDuration } from "./blocks";
18
18
  import { isSlackRenderV2Enabled, processSlackRenderV2 } from "./render-v2";
19
19
  import {
20
20
  getAgentDisplayName,
21
- getAgentEmoji,
21
+ getAgentSlackIcon,
22
22
  sendInlineTaskOutput,
23
23
  sendProgressUpdate,
24
24
  sendTaskResponse,
@@ -491,7 +491,7 @@ async function postInitialDMTreeMessage(task: AgentTask): Promise<string | undef
491
491
  unfurl_links: false,
492
492
  unfurl_media: false,
493
493
  username: getAgentDisplayName(agent),
494
- icon_emoji: getAgentEmoji(agent),
494
+ ...getAgentSlackIcon(agent),
495
495
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
496
496
  blocks: blocks as any,
497
497
  });
@@ -3,8 +3,10 @@ import type { EntityPresentDetailsArguments, WebClient } from "@slack/web-api";
3
3
  import { getAgentById, getTaskById } from "../be/db";
4
4
  import { isEnvFlagEnabled } from "../utils/env-flag";
5
5
  import { getTaskUrl } from "./blocks";
6
+ import { wasEventSeen } from "./event-dedup";
6
7
  import { shouldBlockChannel } from "./external-channel";
7
8
  import { isUserAllowed } from "./handlers";
9
+ import { isAdmittedSlackDelivery } from "./inbound-dispatch";
8
10
  import { slackTaskOutput } from "./task-output";
9
11
 
10
12
  type DetailsEvent = SlackEventMiddlewareArgs<"entity_details_requested">["event"];
@@ -93,8 +95,10 @@ async function getDetails(event: DetailsEvent, client: WebClient): Promise<Detai
93
95
 
94
96
  export function registerWorkObjectHandlers(app: App): void {
95
97
  // Bolt acknowledges Events API deliveries; the flexpane needs a separate API response.
96
- app.event("entity_details_requested", async ({ event, client, logger }) => {
98
+ app.event("entity_details_requested", async ({ event, body, client, logger }) => {
97
99
  if (!isEnvFlagEnabled("SLACK_WORK_OBJECTS_ENABLED", false)) return;
100
+ // A redelivery after a tolerated socket ack failure must not present twice.
101
+ if (!isAdmittedSlackDelivery() && wasEventSeen(body?.event_id)) return;
98
102
 
99
103
  let details: Details;
100
104
  try {
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderAgentFsReleaseNotes } from "../../scripts/agent-fs-release-notes";
3
+
4
+ const currentYaml = 'agentFs:\n image:\n tag: "0.18.11"\n';
5
+ const unchangedSection = `## agent-fs
6
+
7
+ Ships [agent-fs 0.18.11](https://github.com/desplega-ai/agent-fs/releases/tag/v0.18.11).
8
+
9
+ agent-swarm pins agent-fs via the chart image tag and npm packages \`@desplega.ai/agent-fs\` and \`@desplega.ai/agent-fs-just-bash\`; each agent-fs release needs a \`chore(deps): bump agent-fs pins\` PR here.`;
10
+
11
+ describe("agent-fs release notes", () => {
12
+ test("renders the bump when the version changed", () => {
13
+ expect(renderAgentFsReleaseNotes(currentYaml, 'agentFs:\n image:\n tag: "0.18.9"\n')).toBe(
14
+ unchangedSection.replace(").\n", ") (bumped from 0.18.9).\n"),
15
+ );
16
+ });
17
+
18
+ test("omits the bump when the version is unchanged", () => {
19
+ expect(renderAgentFsReleaseNotes(currentYaml, currentYaml)).toBe(unchangedSection);
20
+ });
21
+
22
+ test("omits the bump when there is no previous release tag", () => {
23
+ expect(renderAgentFsReleaseNotes(currentYaml)).toBe(unchangedSection);
24
+ });
25
+
26
+ test("omits the bump when the previous chart has no agentFs.image.tag", () => {
27
+ expect(renderAgentFsReleaseNotes(currentYaml, "agentFs:\n image: {}\n")).toBe(
28
+ unchangedSection,
29
+ );
30
+ });
31
+
32
+ test.each([
33
+ "agentFs:\n image: {}\n",
34
+ 'agentFs:\n image:\n tag: ""\n',
35
+ ])("fails loudly when the current chart tag is missing or empty: %s", (yaml) => {
36
+ expect(() => renderAgentFsReleaseNotes(yaml)).toThrow(
37
+ "Current chart is missing agentFs.image.tag",
38
+ );
39
+ });
40
+ });