@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
@@ -4,6 +4,7 @@ import {
4
4
  type ConfigStatus,
5
5
  type ProvisionRequest,
6
6
  RUNTIME_LABELS,
7
+ type RuntimeCacheReference,
7
8
  type RuntimeHandle,
8
9
  type RuntimeProvider,
9
10
  type RuntimeStatus,
@@ -63,6 +64,22 @@ class DockerApiError extends Error {
63
64
  }
64
65
  }
65
66
 
67
+ function isCacheMountError(error: unknown): boolean {
68
+ return error instanceof DockerApiError && /\b(?:mount\w*|volume\w*)\b/i.test(error.message);
69
+ }
70
+
71
+ type CacheVolume = { Name: string; Labels?: Record<string, string> };
72
+
73
+ function assertCacheOwner(volume: CacheVolume, cache: RuntimeCacheReference): void {
74
+ if (
75
+ volume.Name !== cache.key ||
76
+ volume.Labels?.[RUNTIME_LABELS.owner] !== cache.ownerId ||
77
+ volume.Labels?.[RUNTIME_LABELS.poolId] !== cache.poolId ||
78
+ volume.Labels?.["swarm.cache"] !== "true"
79
+ )
80
+ throw new Error(`Cache volume ${cache.key} is not owned by this swarm and pool`);
81
+ }
82
+
66
83
  async function dockerApi<T>(
67
84
  target: RuntimeTargetConfig,
68
85
  method: string,
@@ -119,7 +136,12 @@ async function dockerApi<T>(
119
136
 
120
137
  export class DockerRuntimeProvider implements RuntimeProvider {
121
138
  readonly kind = "docker" as const;
122
- readonly traits = { canProvision: true, ephemeralDisk: true, supportsPause: false };
139
+ readonly traits = {
140
+ canProvision: true,
141
+ ephemeralDisk: true,
142
+ supportsPause: false,
143
+ persistentCache: true,
144
+ };
123
145
 
124
146
  checkConfig(target: RuntimeTargetConfig): ConfigStatus {
125
147
  try {
@@ -132,6 +154,29 @@ export class DockerRuntimeProvider implements RuntimeProvider {
132
154
 
133
155
  async provision(target: RuntimeTargetConfig, req: ProvisionRequest): Promise<RuntimeHandle> {
134
156
  try {
157
+ let cacheWarning: string | undefined;
158
+ let cacheMount: { Type: "volume"; Source: string; Target: string } | undefined;
159
+ if (req.cache) {
160
+ try {
161
+ const volume = await dockerApi<CacheVolume>(target, "POST", "/volumes/create", {
162
+ Name: req.cache.key,
163
+ Labels: {
164
+ [RUNTIME_LABELS.owner]: req.ownerId,
165
+ [RUNTIME_LABELS.poolId]: req.poolId,
166
+ "swarm.cache": "true",
167
+ },
168
+ });
169
+ // Docker reuses an existing name without replacing its labels.
170
+ assertCacheOwner(volume, {
171
+ key: req.cache.key,
172
+ poolId: req.poolId,
173
+ ownerId: req.ownerId,
174
+ });
175
+ cacheMount = { Type: "volume", Source: req.cache.key, Target: req.cache.mountPath };
176
+ } catch (error) {
177
+ cacheWarning = redactWithEnv(scrubSecrets(String(error)), req.env);
178
+ }
179
+ }
135
180
  const path = `/containers/create?name=${encodeURIComponent(`swarm-rt-${req.runtimeId}`)}`;
136
181
  const body = {
137
182
  Image: req.image,
@@ -154,40 +199,80 @@ export class DockerRuntimeProvider implements RuntimeProvider {
154
199
  NetworkMode: target.DOCKER_NETWORK,
155
200
  Memory: req.resources?.memoryMb ? req.resources.memoryMb * 1024 * 1024 : undefined,
156
201
  NanoCpus: req.resources?.cpu ? Math.round(req.resources.cpu * 1e9) : undefined,
202
+ ...(cacheMount ? { Mounts: [cacheMount] } : {}),
157
203
  },
158
204
  };
159
- let created: { Id: string };
160
- try {
161
- created = await dockerApi(target, "POST", path, body);
162
- } catch (err) {
163
- if (
164
- !(err instanceof DockerApiError) ||
165
- err.statusCode !== 404 ||
166
- !err.message.includes("No such image")
167
- ) {
168
- throw err;
205
+ const createAndStart = async (): Promise<string> => {
206
+ let created: { Id: string };
207
+ try {
208
+ created = await dockerApi(target, "POST", path, body);
209
+ } catch (err) {
210
+ if (
211
+ !(err instanceof DockerApiError) ||
212
+ err.statusCode !== 404 ||
213
+ !err.message.includes("No such image")
214
+ )
215
+ throw err;
216
+ const pullParams = new URLSearchParams({ fromImage: req.image });
217
+ if (
218
+ !req.image.includes("@") &&
219
+ req.image.lastIndexOf(":") <= req.image.lastIndexOf("/")
220
+ ) {
221
+ pullParams.set("tag", "latest");
222
+ }
223
+ await dockerApi(target, "POST", `/images/create?${pullParams}`, undefined, true);
224
+ created = await dockerApi(target, "POST", path, body);
169
225
  }
170
- const pullParams = new URLSearchParams({ fromImage: req.image });
171
- if (!req.image.includes("@") && req.image.lastIndexOf(":") <= req.image.lastIndexOf("/")) {
172
- pullParams.set("tag", "latest");
226
+ try {
227
+ await dockerApi(target, "POST", `/containers/${created.Id}/start`);
228
+ } catch (err) {
229
+ try {
230
+ await dockerApi(target, "DELETE", `/containers/${created.Id}?force=true&v=true`);
231
+ } catch (cleanupError) {
232
+ if (
233
+ cacheMount &&
234
+ isCacheMountError(err) &&
235
+ !(cleanupError instanceof DockerApiError && cleanupError.statusCode === 404)
236
+ )
237
+ throw new Error(
238
+ `${String(err)}; failed-start cleanup failed: ${String(cleanupError)}`,
239
+ );
240
+ }
241
+ throw err;
173
242
  }
174
- await dockerApi(target, "POST", `/images/create?${pullParams}`, undefined, true);
175
- created = await dockerApi(target, "POST", path, body);
176
- }
243
+ return created.Id;
244
+ };
245
+ let externalId: string;
177
246
  try {
178
- await dockerApi(target, "POST", `/containers/${created.Id}/start`);
247
+ externalId = await createAndStart();
179
248
  } catch (err) {
180
- await dockerApi(target, "DELETE", `/containers/${created.Id}?force=true&v=true`).catch(
181
- () => {},
182
- );
183
- throw err;
249
+ if (!cacheMount || !isCacheMountError(err)) throw err;
250
+ cacheWarning = redactWithEnv(scrubSecrets(String(err)), req.env);
251
+ delete body.HostConfig.Mounts;
252
+ externalId = await createAndStart();
184
253
  }
185
- return { kind: "docker", externalId: created.Id };
254
+ return {
255
+ kind: "docker",
256
+ externalId,
257
+ ...(cacheWarning ? { meta: { cacheWarning } } : {}),
258
+ };
186
259
  } catch (err) {
187
260
  throw new Error(redactWithEnv(String(err), req.env));
188
261
  }
189
262
  }
190
263
 
264
+ async releaseCache(target: RuntimeTargetConfig, cache: RuntimeCacheReference): Promise<void> {
265
+ const path = `/volumes/${encodeURIComponent(cache.key)}`;
266
+ try {
267
+ const volume = await dockerApi<CacheVolume>(target, "GET", path);
268
+ assertCacheOwner(volume, cache);
269
+ // A volume still attached to any container must remain intact (Docker returns 409).
270
+ await dockerApi(target, "DELETE", path);
271
+ } catch (error) {
272
+ if (!(error instanceof DockerApiError && error.statusCode === 404)) throw error;
273
+ }
274
+ }
275
+
191
276
  async terminate(target: RuntimeTargetConfig, handle: RuntimeHandle): Promise<void> {
192
277
  try {
193
278
  // v=true also removes the container's anonymous volumes (the worker image declares
@@ -19,6 +19,8 @@ export interface RuntimeProviderTraits {
19
19
  ephemeralDisk: boolean;
20
20
  /** Provider can pause and resume a runtime. */
21
21
  supportsPause: boolean;
22
+ /** Can mount a persistent cache shared by all runtimes of one pool. */
23
+ persistentCache?: boolean;
22
24
  /** Provider hard cap on runtime lifetime, if any. */
23
25
  maxTtlSec?: number;
24
26
  /** Continuous runtime limit for the configured infrastructure tier. */
@@ -45,11 +47,23 @@ export interface ProvisionRequest {
45
47
  /** Worker env: MCP_BASE_URL, AGENT_ID, API key or scoped token, pool env. */
46
48
  env: Record<string, string>;
47
49
  resources?: { cpu?: number; memoryMb?: number };
50
+ /** Provider-neutral cache identity, allocated by the API rather than the operator. */
51
+ cache?: { key: string; mountPath: string };
48
52
  ttlSec?: number;
49
53
  /** Ownership labels, used by list() to find runtimes this swarm started. */
50
54
  labels: Record<string, string>;
51
55
  }
52
56
 
57
+ export interface RuntimeCacheReference {
58
+ key: string;
59
+ poolId: string;
60
+ ownerId: string;
61
+ }
62
+
63
+ export function poolCacheKey(poolId: string, generation?: string): string {
64
+ return `swarm-cache-${poolId}${generation ? `-${generation}` : ""}`;
65
+ }
66
+
53
67
  export interface RuntimeHandle {
54
68
  kind: RuntimeProviderKind;
55
69
  /** Container id, sandbox id, or pod name. */
@@ -74,6 +88,8 @@ export interface RuntimeProvider {
74
88
  status(target: RuntimeTargetConfig, handle: RuntimeHandle): Promise<RuntimeStatus>;
75
89
  /** Every runtime this swarm owns on the target, found by labels. Used to reap orphans. */
76
90
  list(target: RuntimeTargetConfig, ownerId: string): Promise<RuntimeHandle[]>;
91
+ /** Remove an owned pool cache after its runtimes stop; never force detach. */
92
+ releaseCache?(target: RuntimeTargetConfig, cache: RuntimeCacheReference): Promise<void>;
77
93
  renew?(
78
94
  target: RuntimeTargetConfig,
79
95
  handle: RuntimeHandle,
@@ -44,6 +44,14 @@ type AgentTaskConfig = {
44
44
  parentTaskId?: string;
45
45
  requestedByUserId?: string;
46
46
  outputSchema?: Record<string, unknown>;
47
+ /**
48
+ * Dispatch even when the follow-up re-delegation guard blocks this step.
49
+ * The guard applies only to a run launched from a follow-up task in a Slack
50
+ * thread, and decides once for the whole run at its first new step: it
51
+ * blocks a repeat of work already completed in the thread, or a run past the
52
+ * thread's dispatch cap. A blocked run's later new steps are blocked too.
53
+ */
54
+ allowRedelegation?: boolean;
47
55
  /**
48
56
  * Block until the dispatched task reaches a terminal status and journal
49
57
  * its real output. Defaults to `true` — this is the natural semantic for
@@ -63,6 +63,8 @@ declare module "swarm-sdk" {
63
63
  parentTaskId?: string;
64
64
  requestedByUserId?: string;
65
65
  outputSchema?: Record<string, unknown>;
66
+ /** Dispatch even when the follow-up re-delegation guard blocks this step (a run launched from a follow-up task in a Slack thread, checked at its first new step). */
67
+ allowRedelegation?: boolean;
66
68
  /** Wait for the dispatched task to reach a terminal status before resolving. Default: true. */
67
69
  waitForCompletion?: boolean;
68
70
  /** Max ms to wait for a terminal status before throwing. Default: 2h. Only used when waitForCompletion is true. */
@@ -760,6 +762,26 @@ declare module "swarm-sdk" {
760
762
  label: string,
761
763
  config: { prompt: string; model?: string; schema?: Record<string, unknown> },
762
764
  ): Promise<unknown>;
765
+ /**
766
+ * Dispatch a task and journal its result under `label`.
767
+ *
768
+ * Resolves to the task's OUTPUT on success: the stored output string (JSON
769
+ * text under `outputSchema`, so parse it yourself), or `null` when the task
770
+ * stored none. It is never a task record: on a string, `result.taskId` and
771
+ * `result.status` are undefined; on `null`, reading them throws a TypeError.
772
+ * Other shapes:
773
+ * - `failOnTaskFailure: false` and the task ends failed/cancelled/superseded:
774
+ * `{ taskId, status: "failed", error }`.
775
+ * - `waitForCompletion: false`: the first call still waits up to ~30s while
776
+ * the server polls the task. Done in that window: the output, as on
777
+ * success. Failed or cancelled in that window: throws, or
778
+ * `{ taskId, status: "failed", error }` with `failOnTaskFailure: false`.
779
+ * Still running: `{ taskId, status }`.
780
+ *
781
+ * To get a step's task id, query by its context key:
782
+ * `SELECT id, status, output FROM agent_tasks WHERE contextKey = 'script-run:<runId>:<label>'
783
+ * AND taskType = 'script-run-step' ORDER BY createdAt DESC, rowid DESC LIMIT 1`.
784
+ */
763
785
  agentTask(label: string, config: AgentTaskStepConfig): Promise<unknown>;
764
786
  swarmScript(
765
787
  label: string,
@@ -45,6 +45,8 @@ declare module "swarm-sdk" {
45
45
  parentTaskId?: string;
46
46
  requestedByUserId?: string;
47
47
  outputSchema?: Record<string, unknown>;
48
+ /** Dispatch even when the follow-up re-delegation guard blocks this step (a run launched from a follow-up task in a Slack thread, checked at its first new step). */
49
+ allowRedelegation?: boolean;
48
50
  /** Wait for the dispatched task to reach a terminal status before resolving. Default: true. */
49
51
  waitForCompletion?: boolean;
50
52
  /** Max ms to wait for a terminal status before throwing. Default: 2h. Only used when waitForCompletion is true. */
@@ -742,6 +744,26 @@ declare module "swarm-sdk" {
742
744
  label: string,
743
745
  config: { prompt: string; model?: string; schema?: Record<string, unknown> },
744
746
  ): Promise<unknown>;
747
+ /**
748
+ * Dispatch a task and journal its result under `label`.
749
+ *
750
+ * Resolves to the task's OUTPUT on success: the stored output string (JSON
751
+ * text under `outputSchema`, so parse it yourself), or `null` when the task
752
+ * stored none. It is never a task record: on a string, `result.taskId` and
753
+ * `result.status` are undefined; on `null`, reading them throws a TypeError.
754
+ * Other shapes:
755
+ * - `failOnTaskFailure: false` and the task ends failed/cancelled/superseded:
756
+ * `{ taskId, status: "failed", error }`.
757
+ * - `waitForCompletion: false`: the first call still waits up to ~30s while
758
+ * the server polls the task. Done in that window: the output, as on
759
+ * success. Failed or cancelled in that window: throws, or
760
+ * `{ taskId, status: "failed", error }` with `failOnTaskFailure: false`.
761
+ * Still running: `{ taskId, status }`.
762
+ *
763
+ * To get a step's task id, query by its context key:
764
+ * `SELECT id, status, output FROM agent_tasks WHERE contextKey = 'script-run:<runId>:<label>'
765
+ * AND taskType = 'script-run-step' ORDER BY createdAt DESC, rowid DESC LIMIT 1`.
766
+ */
745
767
  agentTask(label: string, config: AgentTaskStepConfig): Promise<unknown>;
746
768
  swarmScript(
747
769
  label: string,
@@ -130,6 +130,7 @@ export function createUserServer(user: User): McpServer {
130
130
  offerMode: false,
131
131
  leadOnly: false,
132
132
  allowDuplicate: false,
133
+ allowRedelegation: false,
133
134
  overrideSlackContext: false,
134
135
  });
135
136
  });
@@ -2,6 +2,7 @@ import type { App } from "@slack/bolt";
2
2
  import { getAllAgents, getAllTasks } from "../be/db";
3
3
  import { isEnvFlagEnabled } from "../utils/env-flag";
4
4
  import { EXTERNAL_CHANNEL_BLOCKED_REPLY, shouldBlockChannel } from "./external-channel";
5
+ import { isSlackLeaveThreadDisabled } from "./left-threads";
5
6
 
6
7
  export function registerCommandHandler(app: App): void {
7
8
  app.command("/agent-swarm-status", async ({ ack, respond, command, client }) => {
@@ -88,9 +89,12 @@ export function registerCommandHandler(app: App): void {
88
89
  • Follow-up tasks auto-depend on the active task in the thread for natural sequencing`);
89
90
  }
90
91
 
92
+ const leaveLine = isSlackLeaveThreadDisabled()
93
+ ? ""
94
+ : "\n• `!leave` in a thread - Make the swarm ignore that thread until someone @mentions it";
91
95
  sections.push(`*Commands:*
92
96
  • \`/agent-swarm-status\` - Show all agents and their current status
93
- • \`/agent-swarm-help\` - Show this help message`);
97
+ • \`/agent-swarm-help\` - Show this help message${leaveLine}`);
94
98
 
95
99
  const blocks = [
96
100
  {
@@ -52,7 +52,13 @@ import {
52
52
  leaveExternalChannel,
53
53
  shouldBlockChannel,
54
54
  } from "./external-channel";
55
- import { shouldSkipLeftSlackThread } from "./left-threads";
55
+ import {
56
+ isSlackLeaveThreadCommand,
57
+ isSlackLeaveThreadDisabled,
58
+ leaveSlackThread,
59
+ SLACK_LEFT_THREAD_CONFIRMATION,
60
+ shouldSkipLeftSlackThread,
61
+ } from "./left-threads";
56
62
  import {
57
63
  collectSlackMessageFiles,
58
64
  extractSlackMessageText,
@@ -491,7 +497,36 @@ export function registerMessageHandler(app: App): void {
491
497
  // Check if bot was mentioned (in original text only)
492
498
  const botMentioned = !!cachedBotUserId && !!msg.text?.includes(`<@${cachedBotUserId}>`);
493
499
 
494
- // A thread the swarm left (slack-leave-thread) stays silent until someone
500
+ // `!leave` in a thread the swarm is in: a human makes the swarm leave it,
501
+ // in code, with no workflow event, buffering, task, or model call.
502
+ // Any task bound to the thread counts, assigned or not, as does a thread
503
+ // the swarm started. Checked before the left-thread gate so `@bot !leave`
504
+ // leaves rather than rejoining. The confirmation is ephemeral, to the
505
+ // sender only.
506
+ if (
507
+ msg.thread_ts &&
508
+ !isSlackLeaveThreadDisabled() &&
509
+ isSlackLeaveThreadCommand(msg.text, cachedBotUserId) &&
510
+ ((await getMostRecentTaskInThread(msg.channel, msg.thread_ts)) !== null ||
511
+ (await wasThreadStartedBySwarm(client, msg.channel, msg.thread_ts)))
512
+ ) {
513
+ noteSlackInboundSideEffect("thread_leave");
514
+ await leaveSlackThread(msg.channel, msg.thread_ts, { slackUserId: msg.user });
515
+ console.log(`[Slack] Left thread ${msg.channel}/${msg.thread_ts}: !leave from ${msg.user}`);
516
+ try {
517
+ await client.chat.postEphemeral({
518
+ channel: msg.channel,
519
+ user: msg.user,
520
+ thread_ts: msg.thread_ts,
521
+ text: SLACK_LEFT_THREAD_CONFIRMATION,
522
+ });
523
+ } catch (error) {
524
+ console.error("[Slack] Failed to confirm !leave:", error);
525
+ }
526
+ return;
527
+ }
528
+
529
+ // A thread the swarm left (slack-leave-thread or `!leave`) stays silent until someone
495
530
  // @mentions the bot. Checked before the workflow event, buffering, routing,
496
531
  // or any AI call.
497
532
  if (await shouldSkipLeftSlackThread(msg.channel, msg.thread_ts, botMentioned)) {
@@ -2,7 +2,8 @@ import { deleteKv, getKv, upsertKv } from "../be/db";
2
2
  import { isEnvFlagEnabled } from "../utils/env-flag";
3
3
 
4
4
  /**
5
- * Slack threads the swarm left via `slack-leave-thread`.
5
+ * Slack threads the swarm left, via the `slack-leave-thread` tool or a
6
+ * human's `!leave` in the thread.
6
7
  *
7
8
  * A left thread drops every inbound message that does not @mention the bot,
8
9
  * in code, before buffering, routing, or any AI call. A mention clears the
@@ -11,7 +12,7 @@ import { isEnvFlagEnabled } from "../utils/env-flag";
11
12
  */
12
13
  export const SLACK_LEFT_THREAD_KV_NAMESPACE = "slack-left-thread";
13
14
 
14
- /** Operator flag that turns the `slack-leave-thread` tool off. */
15
+ /** Operator flag that turns leaving threads off: the tool and `!leave`. */
15
16
  export const SLACK_LEAVE_THREAD_DISABLE = "SLACK_LEAVE_THREAD_DISABLE";
16
17
 
17
18
  /**
@@ -22,19 +23,41 @@ export function isSlackLeaveThreadDisabled(): boolean {
22
23
  return isEnvFlagEnabled(SLACK_LEAVE_THREAD_DISABLE, false);
23
24
  }
24
25
 
26
+ /** Ephemeral reply to the human who sent `!leave`. */
27
+ export const SLACK_LEFT_THREAD_CONFIRMATION =
28
+ "Left this thread. @mention the swarm to bring it back.";
29
+
30
+ /**
31
+ * True when a thread message is the `!leave` command: exactly `!leave`,
32
+ * optionally after @mentions of the bot. A message that mentions anyone else
33
+ * is not addressed to the swarm and does not match.
34
+ */
35
+ export function isSlackLeaveThreadCommand(
36
+ text: string | undefined,
37
+ botUserId: string | null,
38
+ ): boolean {
39
+ const withoutBotMentions = botUserId
40
+ ? (text ?? "").replaceAll(`<@${botUserId}>`, "")
41
+ : (text ?? "");
42
+ return withoutBotMentions.trim() === "!leave";
43
+ }
44
+
25
45
  function threadKey(channelId: string, threadTs: string): string {
26
46
  return `${channelId}:${threadTs}`;
27
47
  }
28
48
 
49
+ /** Who left the thread: an agent via the tool, or a Slack user via `!leave`. */
50
+ export type SlackThreadLeftBy = { agentId: string } | { slackUserId: string };
51
+
29
52
  export async function leaveSlackThread(
30
53
  channelId: string,
31
54
  threadTs: string,
32
- agentId: string,
55
+ leftBy: SlackThreadLeftBy,
33
56
  ): Promise<void> {
34
57
  await upsertKv({
35
58
  namespace: SLACK_LEFT_THREAD_KV_NAMESPACE,
36
59
  key: threadKey(channelId, threadTs),
37
- value: { agentId, leftAt: new Date().toISOString() },
60
+ value: { ...leftBy, leftAt: new Date().toISOString() },
38
61
  valueType: "json",
39
62
  });
40
63
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Follow-up re-delegation guard, shared by every agent dispatch path that can
3
+ * run inside a follow-up session: MCP `send-task` and durable-run
4
+ * `ctx.step.agentTask` steps (`src/http/script-runs.ts`).
5
+ *
6
+ * When the dispatching session's source task is a `follow-up` in a Slack
7
+ * thread, two rules stop the cycle
8
+ * worker completes → follow-up → Lead re-delegates → repeat,
9
+ * while a distinct next step (step 2 of a chain, a fix round, a retry) passes:
10
+ *
11
+ * 1. same-work: the new task matches a completed task in the thread under the
12
+ * dedup similarity rules. Bypassed when a task in the thread was cancelled
13
+ * or failed after that completion: the work was interrupted or went wrong,
14
+ * so re-dispatch is the correct response, not a deduped no-op.
15
+ * 2. loop-cap: the caller already made FOLLOW_UP_DISPATCH_CAP dispatches into
16
+ * the thread within FOLLOW_UP_DISPATCH_WINDOW_MINUTES. A durable script run
17
+ * counts once however many steps it dispatched. Independent of similarity,
18
+ * so a loop that rewords each re-delegation still stops.
19
+ *
20
+ * Callers bypass both with `allowRedelegation` and log the override. Each
21
+ * caller evaluates the rules inside the write transaction that inserts the
22
+ * task, so two concurrent dispatches into one thread (send-task or a run's
23
+ * first step, in any mix) cannot both read the count below the cap.
24
+ */
25
+ import {
26
+ countAgentDispatchesInThread,
27
+ findCompletedTasksInThread,
28
+ findRecentCancelledTaskInThread,
29
+ findRecentFailedTaskInThread,
30
+ getTaskById,
31
+ } from "../be/db";
32
+ import { type DedupMatch, matchSimilarTask } from "../tools/task-dedup";
33
+
34
+ /** How far back the same-work rule looks for a completed task in the thread. */
35
+ export const FOLLOW_UP_SAME_WORK_WINDOW_MINUTES = 2880; // 48 hours
36
+ /**
37
+ * Loop cap: dispatches one agent may make into one Slack thread per window
38
+ * before a follow-up session needs allowRedelegation. A long legitimate chain
39
+ * (plan, implement, review, fix, re-review, fix, CI fix, retry) is about 8
40
+ * dispatches; a runaway loop makes one per worker run (minutes), so it hits
41
+ * 10 within a few hours and burns at most 10 worker sessions.
42
+ */
43
+ export const FOLLOW_UP_DISPATCH_CAP = 10;
44
+ export const FOLLOW_UP_DISPATCH_WINDOW_MINUTES = 360; // 6 hours
45
+
46
+ /** Rows per page when the same-work rule scans a thread's completions. */
47
+ export const FOLLOW_UP_SAME_WORK_PAGE_SIZE = 200;
48
+
49
+ export type FollowUpThread = { channelId: string; threadTs: string; userId?: string };
50
+
51
+ export type FollowUpRedelegationBlock = {
52
+ rule: "same-work" | "loop-cap";
53
+ message: string;
54
+ };
55
+
56
+ /**
57
+ * The Slack thread the guard applies to, or null when the source task is not
58
+ * a `follow-up` in a Slack thread. A `resume` source is intentionally exempt:
59
+ * a resume IS the legitimate re-dispatch.
60
+ */
61
+ export async function followUpThreadForSourceTask(
62
+ sourceTaskId: string | null | undefined,
63
+ ): Promise<FollowUpThread | null> {
64
+ if (!sourceTaskId) return null;
65
+ const sourceTask = await getTaskById(sourceTaskId);
66
+ if (
67
+ sourceTask?.taskType !== "follow-up" ||
68
+ !sourceTask.slackChannelId ||
69
+ !sourceTask.slackThreadTs
70
+ ) {
71
+ return null;
72
+ }
73
+ return {
74
+ channelId: sourceTask.slackChannelId,
75
+ threadTs: sourceTask.slackThreadTs,
76
+ userId: sourceTask.slackUserId,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The most recent completed task in the thread's same-work window whose
82
+ * description matches this dispatch. Pages through every completion in the
83
+ * window (an older match can sit behind any number of newer unrelated ones),
84
+ * one bounded query per page, and stops at the first match.
85
+ */
86
+ async function findMatchingCompletedWork(args: {
87
+ channelId: string;
88
+ threadTs: string;
89
+ taskDescription: string;
90
+ targetAgentId?: string;
91
+ }): Promise<DedupMatch | null> {
92
+ let before: { lastUpdatedAt: string; id: string } | undefined;
93
+ for (;;) {
94
+ const page = await findCompletedTasksInThread(
95
+ args.channelId,
96
+ args.threadTs,
97
+ FOLLOW_UP_SAME_WORK_WINDOW_MINUTES,
98
+ FOLLOW_UP_SAME_WORK_PAGE_SIZE,
99
+ before,
100
+ );
101
+ for (const existing of page) {
102
+ const match = matchSimilarTask(existing, args);
103
+ if (match) return match;
104
+ }
105
+ const last = page.at(-1);
106
+ if (!last || page.length < FOLLOW_UP_SAME_WORK_PAGE_SIZE) return null;
107
+ before = { lastUpdatedAt: last.lastUpdatedAt, id: last.id };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The follow-up re-delegation rules. Returns the rule that blocks this
113
+ * dispatch, or null when it may proceed.
114
+ */
115
+ export async function evaluateFollowUpRedelegation(args: {
116
+ channelId: string;
117
+ threadTs: string;
118
+ creatorAgentId: string;
119
+ taskDescription: string;
120
+ targetAgentId?: string;
121
+ }): Promise<FollowUpRedelegationBlock | null> {
122
+ const { channelId, threadTs } = args;
123
+ const match = await findMatchingCompletedWork(args);
124
+ if (match) {
125
+ const existing = match.task;
126
+ const [cancelled, failed] = await Promise.all([
127
+ findRecentCancelledTaskInThread(channelId, threadTs, FOLLOW_UP_SAME_WORK_WINDOW_MINUTES),
128
+ findRecentFailedTaskInThread(channelId, threadTs, FOLLOW_UP_SAME_WORK_WINDOW_MINUTES),
129
+ ]);
130
+ const completedAt = new Date(existing.lastUpdatedAt).getTime();
131
+ const interruptedLater = [cancelled, failed].some(
132
+ (t) => t && new Date(t.lastUpdatedAt).getTime() > completedAt,
133
+ );
134
+ if (!interruptedLater) {
135
+ return {
136
+ rule: "same-work",
137
+ message: `Blocked by the follow-up re-delegation guard (same-work rule): this task matches completed task ${existing.id.slice(0, 8)} in this Slack thread (${match.reason}), so the work was already done. If it must run again, pass allowRedelegation: true.`,
138
+ };
139
+ }
140
+ }
141
+
142
+ const dispatched = await countAgentDispatchesInThread(
143
+ channelId,
144
+ threadTs,
145
+ args.creatorAgentId,
146
+ FOLLOW_UP_DISPATCH_WINDOW_MINUTES,
147
+ );
148
+ if (dispatched >= FOLLOW_UP_DISPATCH_CAP) {
149
+ return {
150
+ rule: "loop-cap",
151
+ message: `Blocked by the follow-up re-delegation guard (loop-cap rule): you already dispatched ${dispatched} times into this Slack thread in the last ${FOLLOW_UP_DISPATCH_WINDOW_MINUTES / 60} hours (cap ${FOLLOW_UP_DISPATCH_CAP}; a durable script run counts once). If this is a deliberate next step and not a re-delegation loop, pass allowRedelegation: true.`,
152
+ };
153
+ }
154
+ return null;
155
+ }