@desplega.ai/agent-swarm 2.7.5 → 2.7.6

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 (206) hide show
  1. package/dist/{acp-adapter-g3ads55s.js → acp-adapter-bp91d37f.js} +4 -4
  2. package/dist/{actions-zwfykqqk.js → actions-yj0eeaqh.js} +18 -17
  3. package/dist/{amp-adapter-z3s0ssdt.js → amp-adapter-4pg9h34q.js} +1 -1
  4. package/dist/{app-vmp4fgep.js → app-909e97cr.js} +3 -3
  5. package/dist/{assistant-vwde653f.js → assistant-qdyb5fyy.js} +24 -22
  6. package/dist/{boot-reembed-375t7tbk.js → boot-reembed-fk0s9z18.js} +1 -1
  7. package/dist/{boot-reembed-gq9rcpnj.js → boot-reembed-gs1rzrnh.js} +1 -1
  8. package/dist/{boot-scrub-sweep-nd938kyf.js → boot-scrub-sweep-4qfd4kfx.js} +2 -2
  9. package/dist/{boot-seeds-aykx51v3.js → boot-seeds-yr00k13d.js} +4 -4
  10. package/dist/{boxd-k1j26g3h.js → boxd-vdmfssd9.js} +4 -4
  11. package/dist/{claude-adapter-8j4hm099.js → claude-adapter-qta1f1xn.js} +3 -3
  12. package/dist/{claude-managed-setup-0wyynpn1.js → claude-managed-setup-sc6j4dm0.js} +1 -1
  13. package/dist/{claude-sdk-session-q6wj5hmy.js → claude-sdk-session-zgrz6yvc.js} +9169 -9070
  14. package/dist/{cli-gjr2y2xk.js → cli-0agcv1v5.js} +4 -1
  15. package/dist/{cli-vqypxzh4.js → cli-0at8w34w.js} +7 -7
  16. package/dist/{cli-7a42nfy0.js → cli-0qatpy8q.js} +3 -3
  17. package/dist/{cli-p599f1qm.js → cli-0yv5z2pg.js} +1 -1
  18. package/dist/{cli-0gey5fzy.js → cli-386tyxxg.js} +2 -2
  19. package/dist/{cli-5pag6rmn.js → cli-3ypk9vk1.js} +1 -1
  20. package/dist/{cli-qxnf8z2z.js → cli-436532ck.js} +2 -2
  21. package/dist/{cli-z8eh3ex8.js → cli-4necwtsv.js} +4 -4
  22. package/dist/{cli-zh3fp8v2.js → cli-54t00gt8.js} +1 -1
  23. package/dist/{cli-hf6zt5m6.js → cli-69szn7w7.js} +17 -1
  24. package/dist/{cli-nn4d8dne.js → cli-89knwags.js} +2 -2
  25. package/dist/{cli-spknvxbq.js → cli-8bc1w0tw.js} +10 -10
  26. package/dist/{cli-rqkxrmgw.js → cli-8snvgs5r.js} +38 -13
  27. package/dist/{cli-rhw40jgb.js → cli-bjb3z51z.js} +2 -2
  28. package/dist/{cli-xtjhfmpc.js → cli-c60vetmm.js} +1 -1
  29. package/dist/{cli-0731hvbw.js → cli-decfb6v8.js} +2 -2
  30. package/dist/{cli-bhjt02p8.js → cli-drr5v6av.js} +1 -1
  31. package/dist/{cli-fqzx7gwa.js → cli-dxae2yg7.js} +2 -2
  32. package/dist/{cli-tqgg40ek.js → cli-e3r2neck.js} +8 -7
  33. package/dist/{cli-zppwg9p9.js → cli-ekwks2vs.js} +1 -1
  34. package/dist/{cli-nzxr556s.js → cli-es6va8jk.js} +26 -26
  35. package/dist/{cli-a47xy8eq.js → cli-faxe6thg.js} +1 -1
  36. package/dist/cli-gdhpcbhf.js +55 -0
  37. package/dist/{cli-82jg7fp9.js → cli-he5nxaa8.js} +1 -1
  38. package/dist/{cli-8veytvbh.js → cli-he9wchme.js} +1 -1
  39. package/dist/{cli-pywf3eh5.js → cli-hgaazk69.js} +1 -1
  40. package/dist/{cli-0bxqnvq9.js → cli-hr2e2fft.js} +28 -6
  41. package/dist/{cli-1ayk1eas.js → cli-k2gzwsj2.js} +135 -26
  42. package/dist/{cli-xbkdm710.js → cli-kqdg1bsb.js} +3 -3
  43. package/dist/cli-m2z8jagn.js +12078 -0
  44. package/dist/{cli-g61qxmge.js → cli-m48g6p95.js} +3 -3
  45. package/dist/{cli-7wspm5gm.js → cli-nhrvpaj7.js} +20 -4
  46. package/dist/{cli-afrrppsw.js → cli-nwj0ee2w.js} +2 -2
  47. package/dist/{cli-3h4nhjn8.js → cli-p5gp8pcx.js} +9 -7
  48. package/dist/{cli-rm6hqj3z.js → cli-pd7vagab.js} +33 -139
  49. package/dist/{cli-mjzynrse.js → cli-q0k1w5zd.js} +1 -1
  50. package/dist/{cli-8hs41zy2.js → cli-rmmmrjkd.js} +1 -1
  51. package/dist/{cli-kg8hchyg.js → cli-sa02jsqt.js} +1 -1
  52. package/dist/{cli-1338796d.js → cli-shpbx5ee.js} +142 -98
  53. package/dist/{cli-82ae98pz.js → cli-svj5090z.js} +2 -2
  54. package/dist/{cli-bnz7a7qf.js → cli-vvpx3gsx.js} +1 -1
  55. package/dist/{cli-mmwm7zbf.js → cli-w6dt9ged.js} +1 -1
  56. package/dist/{cli-wvaawacd.js → cli-waqbxg08.js} +5230 -66
  57. package/dist/{cli-j3jvnpe8.js → cli-wp9sp1qb.js} +1 -1
  58. package/dist/{cli-nmbmy64z.js → cli-xbbndvjy.js} +3 -3
  59. package/dist/{cli-vbyfwh92.js → cli-xds5f77d.js} +2 -2
  60. package/dist/{cli-7wcyk2s5.js → cli-xnqgpshy.js} +1 -1
  61. package/dist/{cli-rpm2sgv0.js → cli-xnrwazav.js} +19 -2
  62. package/dist/{cli-gf5hbmdr.js → cli-yjdgea32.js} +3 -3
  63. package/dist/{cli-mry0qzym.js → cli-yn4kxs06.js} +123 -10
  64. package/dist/{cli-hn9hxmz9.js → cli-yx4kfngt.js} +1 -1
  65. package/dist/{cli-y24sc29k.js → cli-yy3rhsdw.js} +30 -8
  66. package/dist/{cli-6s7vdcta.js → cli-zbbwez0r.js} +4 -4
  67. package/dist/{cli-z6jkx23q.js → cli-zm72anhg.js} +9 -9
  68. package/dist/cli.js +22 -21
  69. package/dist/{codex-adapter-q6k3gnyg.js → codex-adapter-ayd1fb6h.js} +2 -2
  70. package/dist/{codex-session-runner-3mwmk3d9.js → codex-session-runner-jesa32xc.js} +2 -2
  71. package/dist/{commands-evrenjn9.js → commands-hanewgf2.js} +9 -3
  72. package/dist/{controller-xjp8vswh.js → controller-dt1n3c1q.js} +8 -3
  73. package/dist/{controllers-connect-pp5jnxvc.js → controllers-connect-xx8x64n5.js} +20 -19
  74. package/dist/{cursor-adapter-yf47w4t0.js → cursor-adapter-9s4dwwdw.js} +1 -1
  75. package/dist/{db-zxpw6aht.js → db-3vttcbv4.js} +3 -3
  76. package/dist/{db-rgfsh91f.js → db-9dv0g1ab.js} +11 -3
  77. package/dist/{devtools-82fzqra8.js → devtools-qxfgbsqz.js} +1 -1
  78. package/dist/{dispatcher-smasgws7.js → dispatcher-tx9hd1sv.js} +17 -16
  79. package/dist/{docker-rrjhaqt9.js → docker-c1a3gqmf.js} +89 -22
  80. package/dist/{dsh-adapter-ftyy8dnp.js → dsh-adapter-007y4ejs.js} +1 -1
  81. package/dist/{e2b-qcaj675y.js → e2b-a19d28rz.js} +2 -2
  82. package/dist/{e2b-w1p0bnpm.js → e2b-we6hs6q5.js} +4 -4
  83. package/dist/{easter-egg-3d-yyts7p16.js → easter-egg-3d-rgbrqb8s.js} +1 -1
  84. package/dist/{embedding-space-1z0mbg95.js → embedding-space-ny1e2940.js} +6 -6
  85. package/dist/{embeddings-ygeg7tm7.js → embeddings-2t386t13.js} +1 -1
  86. package/dist/{execute-14qhqp4d.js → execute-pksh0ceh.js} +1 -1
  87. package/dist/{external-channel-ttgrs3sx.js → external-channel-phgmhmd7.js} +2 -2
  88. package/dist/{fx-adapter-gt6pncj2.js → fx-adapter-fy8rr921.js} +5 -5
  89. package/dist/{google-generative-ai-yvmdxyxa.js → google-generative-ai-8y3m3k4b.js} +1 -1
  90. package/dist/{google-vertex-jrtdh9gs.js → google-vertex-w3v98n7b.js} +1 -1
  91. package/dist/{grok-adapter-sm4m5k4g.js → grok-adapter-q0abvpaj.js} +5 -5
  92. package/dist/handlers-by3sfxyx.js +32 -0
  93. package/dist/{heartbeat-qahz4p62.js → heartbeat-kadyek8p.js} +20 -19
  94. package/dist/{hook-kj3d398m.js → hook-5zs3msf6.js} +2 -2
  95. package/dist/{hook-4fd5wq5w.js → hook-9k551xnj.js} +2 -2
  96. package/dist/{http-1ted0j1k.js → http-mhz9vw1f.js} +213 -80
  97. package/dist/index-4f2pvxae.js +37 -0
  98. package/dist/index-98gz7tmp.js +30 -0
  99. package/dist/{index-5rk6rfzr.js → index-fvzke5kx.js} +1 -1
  100. package/dist/index-k08z2cak.js +33 -0
  101. package/dist/{index-yqhz88yy.js → index-pxn30mse.js} +8 -8
  102. package/dist/{index-6rq3r5zb.js → index-q361cg2f.js} +1 -1
  103. package/dist/{index-7v51awbn.js → index-rvr00sg7.js} +20 -13
  104. package/dist/{index-q4b917zs.js → index-vdygbq10.js} +1 -1
  105. package/dist/{keepalive-6cf311t5.js → keepalive-w56f32cg.js} +3 -3
  106. package/dist/{lead-fjy418cw.js → lead-enq35nqp.js} +31 -30
  107. package/dist/{maintenance-a4bm1bmt.js → maintenance-7mdgxcea.js} +1 -1
  108. package/dist/{oauth-refresh-sweep-3h0dfw1k.js → oauth-refresh-sweep-29txy9vk.js} +2 -2
  109. package/dist/{onboard-myn5hraa.js → onboard-kpsrkarj.js} +4 -4
  110. package/dist/{opencode-adapter-p2nkb5am.js → opencode-adapter-j25vm3rr.js} +3 -3
  111. package/dist/{otel-impl-a1a0ef6b.js → otel-impl-6kkct7a8.js} +1 -1
  112. package/dist/{pi-mono-adapter-rzmpb72a.js → pi-mono-adapter-exhkzhn2.js} +4 -4
  113. package/dist/{pricing-refresh-83drrnxj.js → pricing-refresh-n2x7jh6k.js} +3 -3
  114. package/dist/{rbac-roles-s8y6ecb5.js → rbac-roles-81ypx6es.js} +1 -1
  115. package/dist/{rbac-roles-0fb6e7pj.js → rbac-roles-b5htkjby.js} +2 -2
  116. package/dist/{render-v2-twmbgrz4.js → render-v2-qn6jmpk4.js} +3 -3
  117. package/dist/{resume-zfqc36xj.js → resume-wd30wx23.js} +6 -6
  118. package/dist/{runtime-qwggdpqr.js → runtime-4hsg7h60.js} +1 -1
  119. package/dist/{session-access-sc9pn9da.js → session-access-5qvfevhk.js} +4 -4
  120. package/dist/{session-source-backfill-qh07xr8g.js → session-source-backfill-n1czavbr.js} +2 -2
  121. package/dist/{setup-4fr0m7a1.js → setup-dnb56qy5.js} +3 -3
  122. package/dist/{virtual-modules-fh4ha15b.js → virtual-modules-76xxv9fs.js} +2 -2
  123. package/dist/{work-objects-0wr8zxct.js → work-objects-4n21tfkm.js} +20 -19
  124. package/dist/{worker-follow-up-ktyyazyp.js → worker-follow-up-77hvzxww.js} +17 -16
  125. package/dist/{worker-0c5tyzqe.js → worker-gg1576j3.js} +31 -30
  126. package/openapi.json +93 -1
  127. package/package.json +8 -7
  128. package/src/be/db/script-runs.ts +23 -0
  129. package/src/be/db/tasks/read.ts +122 -3
  130. package/src/be/db-client.ts +8 -5
  131. package/src/be/db.ts +15 -4
  132. package/src/be/memory/providers/sqlite-store.ts +49 -12
  133. package/src/be/migrations/260_runtime_pool_cache.sql +20 -0
  134. package/src/be/migrations/262_session_visibility_indexes.sql +18 -0
  135. package/src/be/migrations/263_task_list_filter_index.sql +11 -0
  136. package/src/be/migrations/264_slack_thread_cover_index.sql +9 -0
  137. package/src/be/migrations/266_script_runs_source_task.sql +11 -0
  138. package/src/be/runtime-pools/cache-cleanup.ts +109 -0
  139. package/src/be/runtime-pools/db.ts +65 -6
  140. package/src/be/runtime-pools/remote-controller.ts +5 -0
  141. package/src/be/runtime-pools/supervisor.ts +33 -2
  142. package/src/be/scripts/typecheck.ts +22 -0
  143. package/src/be/seed-scripts/catalog/task-failure-audit.ts +5 -1
  144. package/src/commands/controller.ts +4 -0
  145. package/src/commands/entrypoint-api-readiness.ts +74 -0
  146. package/src/http/index.ts +1 -1
  147. package/src/http/list-total-cache.ts +14 -7
  148. package/src/http/memory.ts +38 -9
  149. package/src/http/script-runs.ts +174 -5
  150. package/src/http/status.ts +10 -2
  151. package/src/http/tasks.ts +4 -2
  152. package/src/providers/amp-adapter.ts +1 -1
  153. package/src/providers/fx-adapter.ts +1 -1
  154. package/src/runtime-providers/controller-protocol.ts +14 -1
  155. package/src/runtime-providers/controller.ts +5 -0
  156. package/src/runtime-providers/docker.ts +108 -23
  157. package/src/runtime-providers/types.ts +16 -0
  158. package/src/script-workflows/workflow-ctx.ts +8 -0
  159. package/src/scripts-runtime/types/stdlib.d.ts +22 -0
  160. package/src/scripts-runtime/types/swarm-sdk.d.ts +22 -0
  161. package/src/server-user.ts +1 -0
  162. package/src/slack/commands.ts +5 -1
  163. package/src/slack/handlers.ts +37 -2
  164. package/src/slack/left-threads.ts +27 -4
  165. package/src/tasks/follow-up-redelegation-guard.ts +155 -0
  166. package/src/tests/claude-artifacts-read-script.test.ts +209 -0
  167. package/src/tests/db-client.test.ts +21 -0
  168. package/src/tests/db-task-read-contract.test.ts +8 -2
  169. package/src/tests/db-task-write-contract.test.ts +5 -1
  170. package/src/tests/entrypoint-api-readiness.test.ts +416 -210
  171. package/src/tests/explicit-model-validation.test.ts +14 -8
  172. package/src/tests/follow-up-redelivery-guard.test.ts +432 -202
  173. package/src/tests/list-total-cache.test.ts +27 -2
  174. package/src/tests/memory-fts-populate.test.ts +11 -9
  175. package/src/tests/memory-retrieval-purge.test.ts +80 -0
  176. package/src/tests/migration-260-runtime-pool-cache.test.ts +118 -0
  177. package/src/tests/query-audit-plans.test.ts +27 -0
  178. package/src/tests/runtime-cache-cleanup.test.ts +367 -0
  179. package/src/tests/runtime-e2b-pools.test.ts +41 -0
  180. package/src/tests/runtime-pools-db.test.ts +98 -0
  181. package/src/tests/runtime-pools-http.test.ts +29 -0
  182. package/src/tests/runtime-providers-docker.test.ts +253 -0
  183. package/src/tests/runtime-remote-controller.test.ts +66 -2
  184. package/src/tests/runtime-supervisor.test.ts +66 -2
  185. package/src/tests/script-connections.test.ts +11 -0
  186. package/src/tests/script-runs-http.test.ts +684 -2
  187. package/src/tests/seed-scripts.test.ts +26 -0
  188. package/src/tests/slack-leave-thread.test.ts +145 -5
  189. package/src/tests/slack-render-v2-query-plans.test.ts +24 -2
  190. package/src/tests/status.test.ts +11 -0
  191. package/src/tests/tasks-readable-row.test.ts +155 -0
  192. package/src/tools/send-task.ts +35 -47
  193. package/src/tools/slack-leave-thread.ts +1 -1
  194. package/src/tools/task-dedup.ts +45 -21
  195. package/src/types.ts +38 -0
  196. package/templates/skills/composio-google-docs/content.md +10 -0
  197. package/templates/skills/script-workflows/content.md +19 -1
  198. package/templates/skills/swarm-docs/content.md +1 -0
  199. package/templates/skills/swarm-scripts/SKILL.md +2 -0
  200. package/templates/skills/swarm-scripts/content.md +2 -0
  201. package/dist/cli-dznrgaep.js +0 -30
  202. package/dist/cli-w3hcrxht.js +0 -6985
  203. package/dist/handlers-vwmba1m2.js +0 -31
  204. package/dist/index-5r71rf98.js +0 -32
  205. package/dist/index-anmwxs87.js +0 -29
  206. package/dist/index-spp0ra5c.js +0 -36
@@ -217,6 +217,32 @@ describe("seed-scripts catalog", () => {
217
217
  ).toHaveLength(200);
218
218
  });
219
219
 
220
+ test.each([
221
+ ["Incomplete response returned, reason: max_output_tokens", "output-limit"],
222
+ ["Stopped: max_tokens", "output-limit"],
223
+ ["Maximum output length exceeded", "output-limit"],
224
+ ["Output token limit reached", "output-limit"],
225
+ ["401 Unauthorized", "auth/credentials"],
226
+ ["invalid token", "auth/credentials"],
227
+ ["Resource not accessible by personal access token", "auth/credentials"],
228
+ ["Processed 100 tokens", "other"],
229
+ ["max_input_tokens exceeded", "other"],
230
+ ])("task-failure-audit classifies %s as %s", async (failureReason, expectedKey) => {
231
+ const result = await taskFailureAudit(
232
+ { publishPage: false },
233
+ {
234
+ swarm: {
235
+ async task_list() {
236
+ return { data: { tasks: [{ id: "failed-task", failureReason }] } };
237
+ },
238
+ },
239
+ },
240
+ );
241
+
242
+ expect(result.groups).toHaveLength(1);
243
+ expect(result.groups[0].key).toBe(expectedKey);
244
+ });
245
+
220
246
  test("task-failure-audit projects failure reasons into schedule-group samples", async () => {
221
247
  let projectionArgs: Record<string, unknown> | undefined;
222
248
  const result = await taskFailureAudit(
@@ -9,6 +9,7 @@ import {
9
9
  createTaskExtended,
10
10
  createWorkflow,
11
11
  deleteSwarmConfig,
12
+ getKv,
12
13
  getSlackTasksInThread,
13
14
  initDb,
14
15
  updateWorkflow,
@@ -19,9 +20,14 @@ import { invokeToolInProcess, resetBridgeServerForTesting } from "../http/mcp-br
19
20
  import { handleTasks } from "../http/tasks";
20
21
  import { reservedNamespaceError } from "../kv-reserved-namespaces";
21
22
  import { createServer as createMcpServer } from "../server";
23
+ import { registerCommandHandler } from "../slack/commands";
22
24
  import { registerMessageHandler, resetSlackHandlerCachesForTesting } from "../slack/handlers";
23
- import { isSlackThreadLeft, SLACK_LEFT_THREAD_KV_NAMESPACE } from "../slack/left-threads";
24
- import { bufferThreadMessage, instantFlush } from "../slack/thread-buffer";
25
+ import {
26
+ isSlackThreadLeft,
27
+ SLACK_LEFT_THREAD_CONFIRMATION,
28
+ SLACK_LEFT_THREAD_KV_NAMESPACE,
29
+ } from "../slack/left-threads";
30
+ import { bufferThreadMessage, getBufferMessageCount, instantFlush } from "../slack/thread-buffer";
25
31
  import { slackContextKey } from "../tasks/context-key";
26
32
  import { registerSlackLeaveThreadTool } from "../tools/slack-leave-thread";
27
33
  import type { Agent, AgentTask } from "../types";
@@ -45,6 +51,8 @@ const previousEnv = {
45
51
 
46
52
  type Handler = (args: Record<string, unknown>) => Promise<void>;
47
53
  const handlers = new Map<string, Handler>();
54
+ const commands = new Map<string, Handler>();
55
+ const ephemerals: Array<Record<string, unknown>> = [];
48
56
 
49
57
  const client = {
50
58
  auth: { test: async () => ({ user_id: BOT_USER_ID, bot_id: "B_LEAVE_BOT" }) },
@@ -52,7 +60,13 @@ const client = {
52
60
  info: async () => ({ ok: true, channel: {} }),
53
61
  replies: async () => ({ ok: true, messages: [] }),
54
62
  },
55
- chat: { postMessage: async () => ({ ok: true, ts: "1.1" }) },
63
+ chat: {
64
+ postMessage: async () => ({ ok: true, ts: "1.1" }),
65
+ postEphemeral: async (args: Record<string, unknown>) => {
66
+ ephemerals.push(args);
67
+ return { ok: true };
68
+ },
69
+ },
56
70
  reactions: { add: async () => ({ ok: true }) },
57
71
  users: { info: async () => ({ ok: true, user: { profile: {} } }) },
58
72
  };
@@ -99,10 +113,10 @@ let lead: Agent;
99
113
  let worker: Agent;
100
114
  let otherWorker: Agent;
101
115
 
102
- async function seedThread(channel: string, threadTs: string) {
116
+ async function seedThread(channel: string, threadTs: string, opts: { unassigned?: boolean } = {}) {
103
117
  // As the Slack handlers create it: only Slack ingress writes slackTriggerMessageTs.
104
118
  return createTaskExtended("original ask", {
105
- agentId: worker.id,
119
+ agentId: opts.unassigned ? undefined : worker.id,
106
120
  source: "slack",
107
121
  slackChannelId: channel,
108
122
  slackThreadTs: threadTs,
@@ -158,6 +172,11 @@ beforeAll(async () => {
158
172
  handlers.set(type, handler);
159
173
  },
160
174
  } as never);
175
+ registerCommandHandler({
176
+ command: (name: string, handler: Handler) => {
177
+ commands.set(name, handler);
178
+ },
179
+ } as never);
161
180
 
162
181
  routeServer = createServer(async (req, res) => {
163
182
  setRequestAuth(req, { kind: "operator", fingerprint: "test-key" });
@@ -192,6 +211,7 @@ afterAll(async () => {
192
211
 
193
212
  beforeEach(() => {
194
213
  resetSlackHandlerCachesForTesting();
214
+ ephemerals.length = 0;
195
215
  });
196
216
 
197
217
  describe("slack-leave-thread", () => {
@@ -429,3 +449,123 @@ describe("SLACK_LEAVE_THREAD_DISABLE", () => {
429
449
  }
430
450
  });
431
451
  });
452
+
453
+ describe("!leave", () => {
454
+ test("leaves the thread in code: no task, no workflow event, an ephemeral confirmation", async () => {
455
+ const emitted: unknown[] = [];
456
+ const listener = (payload: unknown) => emitted.push(payload);
457
+ workflowEventBus.on("slack.message", listener);
458
+ try {
459
+ await seedThread("C_BANG_A", "3000.0001");
460
+ expect(await sendThreadReply("C_BANG_A", "3000.0001", "!leave")).toHaveLength(1);
461
+ expect(emitted).toHaveLength(0);
462
+ } finally {
463
+ workflowEventBus.off("slack.message", listener);
464
+ }
465
+
466
+ expect(await isSlackThreadLeft("C_BANG_A", "3000.0001")).toBe(true);
467
+ const entry = await getKv(SLACK_LEFT_THREAD_KV_NAMESPACE, "C_BANG_A:3000.0001");
468
+ expect(entry?.value).toMatchObject({ slackUserId: HUMAN });
469
+ expect(entry?.value).not.toHaveProperty("agentId");
470
+ expect(ephemerals).toEqual([
471
+ {
472
+ channel: "C_BANG_A",
473
+ user: HUMAN,
474
+ thread_ts: "3000.0001",
475
+ text: SLACK_LEFT_THREAD_CONFIRMATION,
476
+ },
477
+ ]);
478
+
479
+ expect(await sendThreadReply("C_BANG_A", "3000.0001", "chatting among ourselves")).toHaveLength(
480
+ 1,
481
+ );
482
+ expect(
483
+ await sendThreadReply("C_BANG_A", "3000.0001", `<@${BOT_USER_ID}> back please`),
484
+ ).toHaveLength(2);
485
+ expect(await isSlackThreadLeft("C_BANG_A", "3000.0001")).toBe(false);
486
+ });
487
+
488
+ test("an @mention before !leave leaves instead of rejoining, also in a left thread", async () => {
489
+ await seedThread("C_BANG_B", "3100.0001");
490
+ await sendThreadReply("C_BANG_B", "3100.0001", "!leave");
491
+ expect(await sendThreadReply("C_BANG_B", "3100.0001", `<@${BOT_USER_ID}> !leave`)).toHaveLength(
492
+ 1,
493
+ );
494
+ expect(await isSlackThreadLeft("C_BANG_B", "3100.0001")).toBe(true);
495
+ expect(ephemerals).toHaveLength(2);
496
+ });
497
+
498
+ test("with ADDITIVE_SLACK the !leave message is never buffered", async () => {
499
+ process.env.ADDITIVE_SLACK = "true";
500
+ try {
501
+ await seedThread("C_BANG_C", "3200.0001");
502
+ await sendThreadReply("C_BANG_C", "3200.0001", "!leave");
503
+ expect(getBufferMessageCount("C_BANG_C:3200.0001")).toBe(0);
504
+ expect(await isSlackThreadLeft("C_BANG_C", "3200.0001")).toBe(true);
505
+ } finally {
506
+ process.env.ADDITIVE_SLACK = "false";
507
+ }
508
+ });
509
+
510
+ test.each([
511
+ ["!leave", "3600.0001"],
512
+ [`<@${BOT_USER_ID}> !leave`, "3600.0002"],
513
+ ])("leaves a human-rooted thread whose only task is unassigned: %s", async (text, threadTs) => {
514
+ const emitted: unknown[] = [];
515
+ const listener = (payload: unknown) => emitted.push(payload);
516
+ workflowEventBus.on("slack.message", listener);
517
+ try {
518
+ const seeded = await seedThread("C_BANG_G", threadTs, { unassigned: true });
519
+ expect(seeded.agentId).toBeNull();
520
+ expect(await sendThreadReply("C_BANG_G", threadTs, text)).toHaveLength(1);
521
+ expect(emitted).toHaveLength(0);
522
+ } finally {
523
+ workflowEventBus.off("slack.message", listener);
524
+ }
525
+ expect(await isSlackThreadLeft("C_BANG_G", threadTs)).toBe(true);
526
+ expect(ephemerals).toHaveLength(1);
527
+ });
528
+
529
+ test("a task in another thread of the channel does not make !leave a command", async () => {
530
+ await seedThread("C_BANG_H", "3700.0001", { unassigned: true });
531
+ await sendThreadReply("C_BANG_H", "3700.0002", "!leave");
532
+ expect(await isSlackThreadLeft("C_BANG_H", "3700.0002")).toBe(false);
533
+ expect(ephemerals).toHaveLength(0);
534
+ });
535
+
536
+ test("is not a command outside a swarm thread, when addressed elsewhere, or with extra text", async () => {
537
+ await sendThreadReply("C_BANG_D", "3300.0001", "!leave");
538
+ expect(await isSlackThreadLeft("C_BANG_D", "3300.0001")).toBe(false);
539
+
540
+ await seedThread("C_BANG_E", "3400.0001");
541
+ await sendThreadReply("C_BANG_E", "3400.0001", "<@U_SOMEONE_ELSE> !leave");
542
+ await sendThreadReply("C_BANG_E", "3400.0001", "!leave it to me");
543
+ expect(await isSlackThreadLeft("C_BANG_E", "3400.0001")).toBe(false);
544
+ expect(ephemerals).toHaveLength(0);
545
+ });
546
+
547
+ test("SLACK_LEAVE_THREAD_DISABLE makes !leave an ordinary message and hides it from help", async () => {
548
+ async function helpText(): Promise<string> {
549
+ let text = "";
550
+ await commands.get("/agent-swarm-help")!({
551
+ ack: async () => {},
552
+ respond: async (reply: { blocks: Array<{ text: { text: string } }> }) => {
553
+ text = reply.blocks.map((b) => b.text.text).join("\n");
554
+ },
555
+ });
556
+ return text;
557
+ }
558
+ expect(await helpText()).toContain("`!leave` in a thread");
559
+
560
+ process.env.SLACK_LEAVE_THREAD_DISABLE = "true";
561
+ try {
562
+ await seedThread("C_BANG_F", "3500.0001");
563
+ expect(await sendThreadReply("C_BANG_F", "3500.0001", "!leave")).toHaveLength(2);
564
+ expect(await isSlackThreadLeft("C_BANG_F", "3500.0001")).toBe(false);
565
+ expect(ephemerals).toHaveLength(0);
566
+ expect(await helpText()).not.toContain("!leave");
567
+ } finally {
568
+ delete process.env.SLACK_LEAVE_THREAD_DISABLE;
569
+ }
570
+ });
571
+ });
@@ -1,3 +1,4 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
3
  import {
3
4
  closeDb,
@@ -6,6 +7,7 @@ import {
6
7
  getDb,
7
8
  getDbClient,
8
9
  getResolvableDeferralOutcomes,
10
+ getSlackTreeMessages,
9
11
  initDb,
10
12
  recordSlackMessage,
11
13
  } from "../be/db";
@@ -93,12 +95,32 @@ describe("slack render-v2 watcher queries", () => {
93
95
  closeDb();
94
96
  });
95
97
 
98
+ test("the tree sweep tests thread tasks in the covering index, not task rows", async () => {
99
+ const sql = await capturedSql(() => getSlackTreeMessages());
100
+ const detail = plan(sql);
101
+ const uses = "SEARCH task USING COVERING INDEX idx_agent_tasks_slack_thread_cover";
102
+ expect(detail.split(uses).length - 1).toBe(3);
103
+ // Control: without migration 264 the same tests read the task rows.
104
+ const copy = Database.deserialize(getDb().serialize());
105
+ try {
106
+ copy.run("DROP INDEX idx_agent_tasks_slack_thread_cover");
107
+ const reverted = copy
108
+ .query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`)
109
+ .all()
110
+ .map((row) => row.detail)
111
+ .join("\n");
112
+ expect(reverted).not.toContain(uses);
113
+ } finally {
114
+ copy.close();
115
+ }
116
+ });
117
+
96
118
  test("deferral resolution drives from the chain, not from every task", async () => {
97
119
  const sql = await capturedSql(() => getResolvableDeferralOutcomes());
98
120
  const detail = plan(sql, [50]);
99
- // Either parentTaskId-led index is a seek on the chain's children.
121
+ // Any parentTaskId-led index is a seek on the chain's children.
100
122
  expect(detail).toMatch(
101
- /SEARCH wake USING INDEX idx_agent_tasks_parentTaskId(_source)? \(parentTaskId=\?\)/,
123
+ /SEARCH wake USING INDEX idx_agent_tasks_parentTaskId(_source|_id)? \(parentTaskId=\?\)/,
102
124
  );
103
125
  expect(detail).not.toContain("idx_agent_tasks_taskType");
104
126
  expect(detail).not.toContain("idx_agent_tasks_status_last_updated");
@@ -87,6 +87,8 @@ const ENV_KEYS_TO_RESET = [
87
87
  "SWARM_BRAND_COLOR",
88
88
  "SWARM_MARKETING_URL",
89
89
  "SWARM_HIDE_CLOUD_PROMO",
90
+ "APP_URL",
91
+ "DASHBOARD_URL",
90
92
  "HARNESS_PROVIDER",
91
93
  "ANTHROPIC_API_KEY",
92
94
  "CLAUDE_CODE_OAUTH_TOKEN",
@@ -191,6 +193,7 @@ describe("buildStatusPayload — identity", () => {
191
193
  marketing_url: null,
192
194
  hide_cloud_promo: false,
193
195
  org_id: null,
196
+ dashboard_url: null,
194
197
  });
195
198
  });
196
199
 
@@ -212,9 +215,17 @@ describe("buildStatusPayload — identity", () => {
212
215
  marketing_url: "https://swarm.acme.example",
213
216
  hide_cloud_promo: true,
214
217
  org_id: "org_acme_123",
218
+ dashboard_url: null,
215
219
  });
216
220
  });
217
221
 
222
+ test("reports the configured dashboard URL: the first APP_URL entry, else DASHBOARD_URL", async () => {
223
+ process.env.DASHBOARD_URL = "https://old.example.com";
224
+ expect((await buildStatusPayload()).identity.dashboard_url).toBe("https://old.example.com");
225
+ process.env.APP_URL = "https://swarm.example.com/, https://other.example.com";
226
+ expect((await buildStatusPayload()).identity.dashboard_url).toBe("https://swarm.example.com");
227
+ });
228
+
218
229
  test("treats SWARM_CLOUD=1 the same as 'true'", async () => {
219
230
  process.env.SWARM_CLOUD = "1";
220
231
  const payload = await buildStatusPayload();
@@ -0,0 +1,155 @@
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getAllTasks, getDb, getDbClient, initDb } from "../be/db";
4
+ import { readableSessionCondition } from "../be/db/tasks/read";
5
+
6
+ const TEST_DB_PATH = "./test-tasks-readable-row.sqlite";
7
+ const AT = "2026-10-01T00:00:00.000Z";
8
+
9
+ async function removeDbFiles(): Promise<void> {
10
+ for (const suffix of ["", "-wal", "-shm"])
11
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
12
+ }
13
+
14
+ async function task(
15
+ id: string,
16
+ opts: { parent?: string; owner?: string; scheduleId?: string } = {},
17
+ ): Promise<void> {
18
+ await getDbClient().run(
19
+ `INSERT INTO agent_tasks (id, task, status, source, parentTaskId, requestedByUserId, scheduleId,
20
+ createdAt, lastUpdatedAt)
21
+ VALUES (?, ?, 'completed', 'api', ?, ?, ?, ?, ?)`,
22
+ [id, `task ${id}`, opts.parent ?? null, opts.owner ?? null, opts.scheduleId ?? null, AT, AT],
23
+ );
24
+ }
25
+
26
+ describe("readableSessionRowCondition", () => {
27
+ beforeAll(async () => {
28
+ await removeDbFiles();
29
+ initDb(TEST_DB_PATH);
30
+ for (const id of ["u1", "u2", "u3"]) {
31
+ await getDbClient().run(
32
+ "INSERT INTO users (id, name, role, status, createdAt, lastUpdatedAt) VALUES (?, ?, 'member', 'active', ?, ?)",
33
+ [id, id, AT, AT],
34
+ );
35
+ }
36
+ // u1's session, three deep.
37
+ await task("a", { owner: "u1" });
38
+ await task("a1", { parent: "a", owner: "u1" });
39
+ await task("a2", { parent: "a1" });
40
+ // u2's session, with a child that names u1.
41
+ await task("b", { owner: "u2" });
42
+ await task("b1", { parent: "b", owner: "u1" });
43
+ // u2's schedule run: everyone reads it.
44
+ await task("c", { owner: "u2", scheduleId: "schedule-1" });
45
+ await task("c1", { parent: "c" });
46
+ // u2's session shared with u1, and one shared with everyone.
47
+ await task("d", { owner: "u2" });
48
+ await task("d1", { parent: "d" });
49
+ await task("e", { owner: "u2" });
50
+ await getDbClient().run(
51
+ `INSERT INTO session_shares (id, rootTaskId, userId, access, createdAt)
52
+ VALUES ('share-d', 'd', 'u1', 'read', ?), ('share-e', 'e', NULL, 'read', ?)`,
53
+ [AT, AT],
54
+ );
55
+ // A revoked share hides again.
56
+ await task("f", { owner: "u2" });
57
+ await getDbClient().run(
58
+ `INSERT INTO session_shares (id, rootTaskId, userId, access, createdAt, revokedAt)
59
+ VALUES ('share-f', 'f', 'u1', 'read', ?, ?)`,
60
+ [AT, AT],
61
+ );
62
+ // A session nobody owns.
63
+ await task("g");
64
+ await task("g1", { parent: "g", owner: "u2" });
65
+ // Orphan subtrees: the top's parent is gone, so the top is the root.
66
+ await task("h", { owner: "u2" });
67
+ await task("h1", { parent: "h" });
68
+ await task("h2", { parent: "h1", owner: "u1" });
69
+ await task("i", { owner: "u1" });
70
+ await task("i1", { parent: "i", owner: "u2" });
71
+ await task("i2", { parent: "i1" });
72
+ // Chains deeper than the walk's hop limit fall back to the set form.
73
+ for (const [root, owner] of [
74
+ ["deep-u1", "u1"],
75
+ ["deep-u2", "u2"],
76
+ ] as const) {
77
+ await task(root, { owner });
78
+ let parent: string = root;
79
+ for (let i = 1; i <= 70; i++) {
80
+ await task(`${root}-${i}`, { parent });
81
+ parent = `${root}-${i}`;
82
+ }
83
+ }
84
+ getDb().run("PRAGMA foreign_keys = OFF");
85
+ getDb().run("DELETE FROM agent_tasks WHERE id IN ('h', 'i')");
86
+ getDb().run("PRAGMA foreign_keys = ON");
87
+ });
88
+
89
+ afterAll(async () => {
90
+ closeDb();
91
+ await removeDbFiles();
92
+ });
93
+
94
+ for (const userId of ["u1", "u2", "u3", null]) {
95
+ test(`the page shows the rows the set form shows, for ${userId ?? "a guest"}`, async () => {
96
+ const set = readableSessionCondition(userId);
97
+ const expected = (
98
+ await getDbClient().query<{ id: string }>(
99
+ `SELECT id FROM agent_tasks WHERE ${set.sql} ORDER BY id`,
100
+ set.params,
101
+ )
102
+ ).map((row) => row.id);
103
+ const page = await getAllTasks({ readableBy: { userId }, limit: 1000 });
104
+ expect(page.map((t) => t.id).sort()).toEqual(expected);
105
+ });
106
+ }
107
+
108
+ test("u1 sees exactly the expected sessions", async () => {
109
+ const page = await getAllTasks({ readableBy: { userId: "u1" }, limit: 1000 });
110
+ const deep = Array.from({ length: 70 }, (_, i) => `deep-u1-${i + 1}`);
111
+ expect(page.map((t) => t.id).sort()).toEqual(
112
+ [
113
+ "a",
114
+ "a1",
115
+ "a2",
116
+ "c",
117
+ "c1",
118
+ "d",
119
+ "d1",
120
+ "e",
121
+ "g",
122
+ "g1",
123
+ "h1",
124
+ "h2",
125
+ "deep-u1",
126
+ ...deep,
127
+ ].sort(),
128
+ );
129
+ });
130
+
131
+ test("the page walks up from each row it reads", async () => {
132
+ const query = spyOn(getDbClient(), "query");
133
+ let sql = "";
134
+ let params: unknown[] = [];
135
+ try {
136
+ await getAllTasks({ status: "completed", readableBy: { userId: "u1" } });
137
+ [sql, params = []] = query.mock.calls.find(([s]) => s.includes("WITH page AS"))! as [
138
+ string,
139
+ unknown[]?,
140
+ ];
141
+ } finally {
142
+ query.mockRestore();
143
+ }
144
+ const plan = getDb()
145
+ .query<{ detail: string }, any[]>(`EXPLAIN QUERY PLAN ${sql}`)
146
+ .all(...(params as any[]))
147
+ .map((row) => row.detail)
148
+ .join("\n");
149
+ // The walk is a correlated lookup per row. The set form appears only as
150
+ // the fallback branch, which SQLite builds when a walk finds no root.
151
+ expect(plan).toContain("CORRELATED SCALAR SUBQUERY");
152
+ expect(plan).toContain("SEARCH u USING INDEX sqlite_autoindex_agent_tasks_1 (id=?)");
153
+ expect(plan).toContain("SEARCH p USING INDEX sqlite_autoindex_agent_tasks_1 (id=?)");
154
+ });
155
+ });
@@ -5,9 +5,7 @@ import { resolveTaskAuditUserId } from "@/be/audit-user";
5
5
  import {
6
6
  createTaskExtended,
7
7
  extensionAgentAssignmentError,
8
- findCompletedTaskInThread,
9
8
  findExistingLinearTrackerContextWork,
10
- findRecentCancelledTaskInThread,
11
9
  getActiveTaskCount,
12
10
  getAgentById,
13
11
  getDbClient,
@@ -23,6 +21,10 @@ import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
23
21
  import { explicitModelErrorForAgent } from "@/be/model-validation";
24
22
  import { applyPreTaskCreate } from "@/extensions/apply-task-create";
25
23
  import { can } from "@/rbac";
24
+ import {
25
+ evaluateFollowUpRedelegation,
26
+ followUpThreadForSourceTask,
27
+ } from "@/tasks/follow-up-redelegation-guard";
26
28
  import { checkSlackRoutingCoherence } from "@/tasks/slack-routing";
27
29
  import { findDuplicateTask } from "@/tools/task-dedup";
28
30
  import { ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
@@ -163,7 +165,13 @@ export const sendTaskInputSchema = z
163
165
  .boolean()
164
166
  .default(false)
165
167
  .describe(
166
- "If true, skip duplicate detection and create the task even if a similar one exists.",
168
+ "If true, skip duplicate detection and create the task even if a similar one exists. Does not bypass the follow-up re-delegation guard; see allowRedelegation.",
169
+ ),
170
+ allowRedelegation: z
171
+ .boolean()
172
+ .default(false)
173
+ .describe(
174
+ "Only matters when you call send-task from a follow-up task in a Slack thread. Such a dispatch is blocked when it repeats a task already completed in that thread (same-work rule, unless a task there was cancelled or failed after that completion), or when you already dispatched 10 tasks into the thread in the last 6 hours (loop-cap rule). Set true to dispatch anyway; the override is logged. Use it only for deliberate re-runs or long chains, never to retry a block you have not read.",
167
175
  ),
168
176
  slackChannelId: z
169
177
  .string()
@@ -292,6 +300,7 @@ export async function sendTaskHandler(
292
300
  effort,
293
301
  profile,
294
302
  allowDuplicate,
303
+ allowRedelegation,
295
304
  slackChannelId,
296
305
  slackThreadTs,
297
306
  slackUserId,
@@ -316,6 +325,7 @@ export async function sendTaskHandler(
316
325
  const requestedLeadOnly = ctx.kind === "user" ? false : leadOnly;
317
326
  const requestedCapabilities = ctx.kind === "user" ? undefined : requiredCapabilities;
318
327
  const requestedAllowDuplicate = ctx.kind === "user" ? false : allowDuplicate;
328
+ const requestedAllowRedelegation = ctx.kind === "user" ? false : allowRedelegation;
319
329
  const requestedOverrideSlackContext = ctx.kind === "user" ? false : overrideSlackContext;
320
330
 
321
331
  // Defense in depth for direct TypeScript callers that bypass MCP schema parsing.
@@ -572,7 +582,9 @@ export async function sendTaskHandler(
572
582
  // Every read in this handler releases the FIFO lock, so two concurrent
573
583
  // send-task calls with the same creator and text otherwise both pass the
574
584
  // guards and both create a task (no UNIQUE index arbitrates them).
575
- const evaluateDedupGuards = async (): Promise<{
585
+ const evaluateDedupGuards = async (
586
+ logOverride: boolean,
587
+ ): Promise<{
576
588
  ok: boolean;
577
589
  message: string;
578
590
  task?: AgentTask;
@@ -602,59 +614,35 @@ export async function sendTaskHandler(
602
614
  }
603
615
  }
604
616
 
605
- // Guard: prevent re-delegation from follow-up tasks
606
- // When the source task is a "follow-up" (worker completed/failed notification),
607
- // check if there are completed tasks in the same Slack thread recently.
608
- // This prevents the cycle: worker completes → follow-up → Lead re-delegates → repeat.
609
- //
610
- // Exception: if a MORE RECENT task in the same thread was cancelled (exit 130,
611
- // status='cancelled', or status='failed' with failureReason containing
612
- // "cancelled"), bypass the guard. A cancellation means the work was
613
- // interrupted — re-dispatch is the correct response, not a deduped no-op.
614
- // Without this bypass, a cancelled worker permanently jams the thread
615
- // against re-delegation when an earlier completed sibling exists.
617
+ // Guard: prevent re-delegation from follow-up tasks (same-work and
618
+ // loop-cap rules, see src/tasks/follow-up-redelegation-guard.ts).
619
+ // allowRedelegation bypasses both and is logged.
616
620
  //
617
621
  // NOTE: `taskType === "resume"` (created by createResumeFollowUp on
618
622
  // supersede) is intentionally NOT in this guard — a resume IS the legitimate
619
623
  // re-dispatch and bypassing the check is correct. Do not add "resume" here.
620
- if (sourceTaskId) {
621
- const sourceTask = await getTaskById(sourceTaskId);
622
- if (
623
- sourceTask?.taskType === "follow-up" &&
624
- sourceTask.slackThreadTs &&
625
- sourceTask.slackChannelId
626
- ) {
627
- const recentCompleted = await findCompletedTaskInThread(
628
- sourceTask.slackChannelId,
629
- sourceTask.slackThreadTs,
630
- 2880, // 48 hours in minutes
624
+ const followUpThread = creatorAgentId ? await followUpThreadForSourceTask(sourceTaskId) : null;
625
+ if (followUpThread && creatorAgentId) {
626
+ const blocked = await evaluateFollowUpRedelegation({
627
+ ...followUpThread,
628
+ creatorAgentId,
629
+ taskDescription,
630
+ targetAgentId: taskOptions.agentId ?? taskOptions.offeredTo,
631
+ });
632
+ if (blocked && !requestedAllowRedelegation) {
633
+ return { ok: false, message: blocked.message };
634
+ }
635
+ if (blocked && logOverride) {
636
+ console.log(
637
+ `[send-task] follow-up re-delegation override: rule=${blocked.rule} creatorAgentId=${creatorAgentId} sourceTaskId=${sourceTaskId} thread=${followUpThread.channelId}/${followUpThread.threadTs}`,
631
638
  );
632
- if (recentCompleted) {
633
- const recentCancelled = await findRecentCancelledTaskInThread(
634
- sourceTask.slackChannelId,
635
- sourceTask.slackThreadTs,
636
- 2880,
637
- );
638
- const cancelledMoreRecent =
639
- recentCancelled &&
640
- new Date(recentCancelled.lastUpdatedAt).getTime() >
641
- new Date(recentCompleted.lastUpdatedAt).getTime();
642
- if (!cancelledMoreRecent) {
643
- return {
644
- ok: false,
645
- message: `Blocked: re-delegation from follow-up task in a thread that already has completed work (task ${recentCompleted.id.slice(0, 8)}). The original request was already handled.`,
646
- };
647
- }
648
- // else: fall through — the cancellation is more recent than the
649
- // completion, so re-delegation is legitimate.
650
- }
651
639
  }
652
640
  }
653
641
 
654
642
  return null;
655
643
  };
656
644
 
657
- const guard = await evaluateDedupGuards();
645
+ const guard = await evaluateDedupGuards(true);
658
646
  if (guard) {
659
647
  const guardData = {
660
648
  yourAgentId: creatorAgentId,
@@ -669,7 +657,7 @@ export async function sendTaskHandler(
669
657
  // Authoritative re-check: the reads above are separated from the INSERT by
670
658
  // the whole handler, so only a guard inside this transaction can see a
671
659
  // concurrent send-task's committed task.
672
- const raced = await evaluateDedupGuards();
660
+ const raced = await evaluateDedupGuards(false);
673
661
  if (raced) return { success: raced.ok, message: raced.message, task: raced.task };
674
662
 
675
663
  // This transaction already checked the tracker key, excluding the caller's
@@ -94,7 +94,7 @@ export const registerSlackLeaveThreadTool = (server: McpServer) => {
94
94
  return toolErr("No Slack thread context available.");
95
95
  }
96
96
 
97
- await leaveSlackThread(slackChannelId, slackThreadTs, agent.id);
97
+ await leaveSlackThread(slackChannelId, slackThreadTs, { agentId: agent.id });
98
98
  return toolOk(
99
99
  `Left Slack thread ${slackChannelId}/${slackThreadTs}. Messages there are ignored until someone @mentions the bot.`,
100
100
  { data: { channelId: slackChannelId, threadTs: slackThreadTs } },