@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
@@ -0,0 +1,367 @@
1
+ import { afterAll, beforeAll, beforeEach, expect, spyOn, test } from "bun:test";
2
+ import { closeDb, createAgent, getDbClient, initDb, upsertSwarmConfig } from "../be/db";
3
+ import { insertProvisioningRuntime, markRuntimeTerminated } from "../be/multi-runtime";
4
+ import { getPoolCache, reconcileCacheReleases } from "../be/runtime-pools/cache-cleanup";
5
+ import {
6
+ createRuntimeController,
7
+ createRuntimePool,
8
+ deleteRuntimeController,
9
+ deleteRuntimePool,
10
+ getRuntimePool,
11
+ updateRuntimeController,
12
+ updateRuntimePool,
13
+ } from "../be/runtime-pools/db";
14
+ import { provisionRuntime, reconcileOnce } from "../be/runtime-pools/supervisor";
15
+ import type { ControllerCommands } from "../runtime-providers/controller";
16
+ import type { ProvisionRequest, RuntimeCacheReference } from "../runtime-providers/types";
17
+ import { registerVolatileSecret } from "../utils/secret-scrubber";
18
+
19
+ const path = `./test-runtime-cache-cleanup-${process.pid}.sqlite`;
20
+ const testGlobals = globalThis as typeof globalThis & { __testMigrationTemplate?: Uint8Array };
21
+ let savedTemplate: Uint8Array | undefined;
22
+ const ownerId = "cache-cleanup-owner";
23
+ let controllerId: string;
24
+ let calls: RuntimeCacheReference[];
25
+ let release: (cache: RuntimeCacheReference) => Promise<void>;
26
+ let provisions: ProvisionRequest[];
27
+ let logSpy: ReturnType<typeof spyOn<typeof console, "error">>;
28
+ const commands: ControllerCommands = {
29
+ traits: { canProvision: true, ephemeralDisk: true, supportsPause: false, persistentCache: true },
30
+ workerApiUrl: "http://api.example.test",
31
+ provision: async (request) => {
32
+ provisions.push(request);
33
+ return { kind: "docker", externalId: request.runtimeId };
34
+ },
35
+ terminate: async () => {},
36
+ status: async () => ({}),
37
+ list: async () => [],
38
+ releaseCache: async (cache) => {
39
+ calls.push(cache);
40
+ await release(cache);
41
+ },
42
+ };
43
+ const drain = () => reconcileCacheReleases(async () => commands, new AbortController().signal);
44
+ const jobs = () =>
45
+ getDbClient().query<{ id: string; state: string; last_error: string | null }>(
46
+ "SELECT id, state, last_error FROM runtime_cache_releases",
47
+ );
48
+ const pool = () =>
49
+ createRuntimePool({
50
+ name: "cached",
51
+ kind: "docker",
52
+ placements: [controllerId],
53
+ image: "worker:test",
54
+ cache: { mountPath: "/cache" },
55
+ });
56
+ async function runtime(poolId: string) {
57
+ const agent = await createAgent({ name: crypto.randomUUID(), status: "idle", isLead: false });
58
+ const id = crypto.randomUUID();
59
+ await insertProvisioningRuntime({
60
+ id,
61
+ agentId: agent.id,
62
+ poolId,
63
+ controllerId,
64
+ reportedSlots: 1,
65
+ });
66
+ return id;
67
+ }
68
+
69
+ beforeAll(() => {
70
+ savedTemplate = testGlobals.__testMigrationTemplate;
71
+ testGlobals.__testMigrationTemplate = undefined;
72
+ closeDb();
73
+ initDb(path);
74
+ logSpy = spyOn(console, "error").mockImplementation(() => {});
75
+ });
76
+ beforeEach(async () => {
77
+ for (const table of [
78
+ "runtime_cache_releases",
79
+ "runtime_instances",
80
+ "session_tokens",
81
+ "agents",
82
+ "runtime_pools",
83
+ "runtime_controllers",
84
+ "swarm_config",
85
+ ])
86
+ await getDbClient().run(`DELETE FROM ${table}`);
87
+ await upsertSwarmConfig({ scope: "global", key: "runtime_owner_id", value: ownerId });
88
+ controllerId = (await createRuntimeController({ name: "cache-controller", kind: "docker" })).id;
89
+ calls = [];
90
+ provisions = [];
91
+ release = async () => {};
92
+ });
93
+ afterAll(async () => {
94
+ logSpy.mockRestore();
95
+ closeDb();
96
+ testGlobals.__testMigrationTemplate = savedTemplate;
97
+ for (const suffix of ["", "-wal", "-shm"])
98
+ await Bun.file(path + suffix)
99
+ .delete()
100
+ .catch(() => {});
101
+ });
102
+
103
+ test("disable defers release for existing runtimes but not new cache-free runtimes", async () => {
104
+ const p = await pool();
105
+ const key = (await getPoolCache(p.id))!.key;
106
+ const old = await runtime(p.id);
107
+ await updateRuntimePool(p.id, { cache: null });
108
+ const fresh = await runtime(p.id);
109
+ await drain();
110
+ expect(calls).toEqual([]);
111
+ expect((await jobs())[0]?.state).toBe("pending");
112
+ await markRuntimeTerminated(old, "stopped");
113
+ await drain();
114
+ expect(calls).toEqual([{ key, poolId: p.id, ownerId }]);
115
+ expect(await jobs()).toEqual([]);
116
+ expect(
117
+ await getDbClient().get(
118
+ "SELECT id FROM runtime_instances WHERE id = ? AND status != 'offline'",
119
+ [fresh],
120
+ ),
121
+ ).not.toBeNull();
122
+ });
123
+
124
+ test("pending release survives pool deletion and keeps its controller available", async () => {
125
+ const p = await pool();
126
+ await deleteRuntimePool(p.id);
127
+ expect(await getRuntimePool(p.id)).toBeNull();
128
+ await expect(
129
+ getDbClient().run("DELETE FROM runtime_controllers WHERE id = ?", [controllerId]),
130
+ ).rejects.toThrow();
131
+ await expect(deleteRuntimeController(controllerId)).rejects.toThrow("pending cache cleanup");
132
+ await expect(
133
+ updateRuntimeController(controllerId, { config: { DOCKER_HOST: "unix:///another.sock" } }),
134
+ ).rejects.toThrow("wait for cleanup");
135
+ await updateRuntimeController(controllerId, { name: "renamed" });
136
+ await drain();
137
+ expect(calls[0]?.poolId).toBe(p.id);
138
+ await deleteRuntimeController(controllerId);
139
+ });
140
+
141
+ test("a failed release is scrubbed, persisted over restart, and retried", async () => {
142
+ const p = await pool();
143
+ const secret = "cache-cleanup-private-value";
144
+ registerVolatileSecret(secret, "cleanup-test");
145
+ release = async () => {
146
+ throw new Error(`in use ${secret}`);
147
+ };
148
+ await updateRuntimePool(p.id, { cache: null });
149
+ await drain();
150
+ expect((await jobs())[0]).toMatchObject({
151
+ state: "pending",
152
+ last_error: expect.stringContaining("in use"),
153
+ });
154
+ expect((await jobs())[0]?.last_error).not.toContain(secret);
155
+ const id = (await jobs())[0]!.id;
156
+ closeDb();
157
+ initDb(path);
158
+ expect((await jobs())[0]?.id).toBe(id);
159
+ release = async () => {};
160
+ await drain();
161
+ expect(calls).toHaveLength(2);
162
+ expect(await jobs()).toEqual([]);
163
+ });
164
+
165
+ test("concurrent cache reconciles release a cache only once", async () => {
166
+ const p = await pool();
167
+ await updateRuntimePool(p.id, { cache: null });
168
+ const entered = Promise.withResolvers<void>();
169
+ const duplicate = Promise.withResolvers<void>();
170
+ const finish = Promise.withResolvers<void>();
171
+ release = async () => {
172
+ entered.resolve();
173
+ if (calls.length > 1) duplicate.resolve();
174
+ await finish.promise;
175
+ };
176
+ const first = drain();
177
+ await entered.promise;
178
+ const second = drain();
179
+ try {
180
+ await Promise.race([second, duplicate.promise]);
181
+ expect(calls).toHaveLength(1);
182
+ } finally {
183
+ finish.resolve();
184
+ await Promise.all([first, second]);
185
+ }
186
+ expect(await jobs()).toEqual([]);
187
+ });
188
+
189
+ test("re-enabling uses a new generation; edits retain the current generation", async () => {
190
+ const p = await pool();
191
+ const oldKey = (await getPoolCache(p.id))!.key;
192
+ await updateRuntimePool(p.id, { name: "renamed", cache: { mountPath: "/cache/changed" } });
193
+ closeDb();
194
+ initDb(path);
195
+ expect((await getPoolCache(p.id))!.key).toBe(oldKey);
196
+ await updateRuntimePool(p.id, { cache: null });
197
+ await updateRuntimePool(p.id, { cache: { mountPath: "/cache" } });
198
+ const newKey = (await getPoolCache(p.id))!.key;
199
+ expect(newKey).not.toBe(oldKey);
200
+ expect((await getRuntimePool(p.id))!.cache).toEqual({ mountPath: "/cache" });
201
+ await drain();
202
+ expect(calls.map((c) => c.key)).toEqual([oldKey]);
203
+ expect(await jobs()).toEqual([]);
204
+ expect((await getPoolCache(p.id))!.key).toBe(newKey);
205
+ });
206
+
207
+ test("re-enabling during a release leaves the retired generation isolated", async () => {
208
+ const p = await pool();
209
+ const oldKey = (await getPoolCache(p.id))!.key;
210
+ await updateRuntimePool(p.id, { cache: null });
211
+ const entered = Promise.withResolvers<void>();
212
+ const finish = Promise.withResolvers<void>();
213
+ release = async () => {
214
+ entered.resolve();
215
+ await finish.promise;
216
+ };
217
+ const draining = drain();
218
+ await entered.promise;
219
+ try {
220
+ await updateRuntimePool(p.id, { cache: { mountPath: "/cache" } });
221
+ expect((await getPoolCache(p.id))!.key).not.toBe(oldKey);
222
+ expect((await jobs())[0]?.state).toBe("deleting");
223
+ } finally {
224
+ finish.resolve();
225
+ await draining;
226
+ }
227
+ expect(calls[0]?.key).toBe(oldKey);
228
+ });
229
+
230
+ test("legacy cache names remain readable until re-enabling rotates the key", async () => {
231
+ const p = await pool();
232
+ await getDbClient().run("UPDATE runtime_pools SET cache = ? WHERE id = ?", [
233
+ JSON.stringify({ mountPath: "/cache" }),
234
+ p.id,
235
+ ]);
236
+ expect((await getPoolCache(p.id))!.key).toBe(`swarm-cache-${p.id}`);
237
+ await updateRuntimePool(p.id, { cache: null });
238
+ await updateRuntimePool(p.id, { cache: { mountPath: "/cache" } });
239
+ expect((await getPoolCache(p.id))!.key).not.toBe(`swarm-cache-${p.id}`);
240
+ await drain();
241
+ expect(calls[0]?.key).toBe(`swarm-cache-${p.id}`);
242
+ });
243
+
244
+ test("an abandoned deleting lease is recovered after restart", async () => {
245
+ const p = await pool();
246
+ await updateRuntimePool(p.id, { cache: null });
247
+ await getDbClient().run("UPDATE runtime_cache_releases SET state = 'deleting', updated_at = ?", [
248
+ new Date(Date.now() - 6 * 60_000).toISOString(),
249
+ ]);
250
+ closeDb();
251
+ initDb(path);
252
+ await drain();
253
+ expect(calls).toHaveLength(1);
254
+ expect(await jobs()).toEqual([]);
255
+ });
256
+
257
+ for (const outcome of ["success", "failure"] as const) {
258
+ test(`an expired worker's ${outcome} cannot overwrite a newer cleanup claim`, async () => {
259
+ const p = await pool();
260
+ await updateRuntimePool(p.id, { cache: null });
261
+ const firstEntered = Promise.withResolvers<void>();
262
+ const secondEntered = Promise.withResolvers<void>();
263
+ const firstFinish = Promise.withResolvers<void>();
264
+ const secondFinish = Promise.withResolvers<void>();
265
+ release = async () => {
266
+ const first = calls.length === 1;
267
+ (first ? firstEntered : secondEntered).resolve();
268
+ await (first ? firstFinish : secondFinish).promise;
269
+ if (first && outcome === "failure") throw new Error("late failure");
270
+ };
271
+ const first = drain();
272
+ await firstEntered.promise;
273
+ const second = reconcileCacheReleases(
274
+ async () => commands,
275
+ new AbortController().signal,
276
+ new Date(Date.now() + 6 * 60_000),
277
+ );
278
+ await secondEntered.promise;
279
+ try {
280
+ firstFinish.resolve();
281
+ await first;
282
+ expect((await jobs())[0]).toMatchObject({ state: "deleting", last_error: null });
283
+ } finally {
284
+ firstFinish.resolve();
285
+ secondFinish.resolve();
286
+ await Promise.all([first, second]);
287
+ }
288
+ expect(await jobs()).toEqual([]);
289
+ });
290
+ }
291
+
292
+ test("an unsupported or offline controller retains cleanup for a later retry", async () => {
293
+ const p = await pool();
294
+ await updateRuntimePool(p.id, { cache: null });
295
+ await reconcileCacheReleases(async () => {
296
+ throw new Error("controller offline");
297
+ }, new AbortController().signal);
298
+ expect((await jobs())[0]?.last_error).toContain("offline");
299
+ const { releaseCache: _, ...legacy } = commands;
300
+ await reconcileCacheReleases(async () => legacy, new AbortController().signal);
301
+ expect((await jobs())[0]?.last_error).toContain("upgrade");
302
+ await drain();
303
+ expect(await jobs()).toEqual([]);
304
+ });
305
+
306
+ test("caches still configured on a disabled pool are retained for future use", async () => {
307
+ const p = await pool();
308
+ await updateRuntimePool(p.id, { enabled: false });
309
+ await drain();
310
+ expect(await jobs()).toEqual([]);
311
+ expect(calls).toEqual([]);
312
+ });
313
+
314
+ test("the controller target cannot change while a cache exists", async () => {
315
+ const p = await pool();
316
+ await expect(updateRuntimeController(controllerId, { placement: "builtin" })).rejects.toThrow(
317
+ "Disable pool caches",
318
+ );
319
+ await updateRuntimeController(controllerId, { config: {}, secretConfig: {} });
320
+ await expect(
321
+ updateRuntimeController(controllerId, { secretConfig: { KEY: "changed" } }),
322
+ ).rejects.toThrow("Disable pool caches");
323
+ await updateRuntimePool(p.id, { cache: null });
324
+ await drain();
325
+ await updateRuntimeController(controllerId, { config: { DOCKER_HOST: "unix:///new.sock" } });
326
+ });
327
+
328
+ test("a stale pool snapshot does not mount a disabled cache during provision", async () => {
329
+ const p = await pool();
330
+ await updateRuntimePool(p.id, { cache: null });
331
+ const agent = await createAgent({ name: "fresh", status: "idle", isLead: false });
332
+ expect(await provisionRuntime(p, agent, commands, ownerId)).toBe(true);
333
+ expect(provisions).toHaveLength(1);
334
+ expect(provisions[0]?.cache).toBeUndefined();
335
+ await drain();
336
+ expect(calls[0]?.poolId).toBe(p.id);
337
+ });
338
+
339
+ test("the supervisor tick releases a deleted pool cache", async () => {
340
+ const p = await pool();
341
+ await deleteRuntimePool(p.id);
342
+ const previousPools = process.env.RUNTIME_POOLS_ENABLED;
343
+ const previousMulti = process.env.MULTI_RUNTIME_ENABLED;
344
+ process.env.RUNTIME_POOLS_ENABLED = "true";
345
+ process.env.MULTI_RUNTIME_ENABLED = "true";
346
+ try {
347
+ await reconcileOnce({ getControllerCommands: async () => commands });
348
+ expect(calls[0]?.poolId).toBe(p.id);
349
+ expect(await jobs()).toEqual([]);
350
+ } finally {
351
+ if (previousPools === undefined) delete process.env.RUNTIME_POOLS_ENABLED;
352
+ else process.env.RUNTIME_POOLS_ENABLED = previousPools;
353
+ if (previousMulti === undefined) delete process.env.MULTI_RUNTIME_ENABLED;
354
+ else process.env.MULTI_RUNTIME_ENABLED = previousMulti;
355
+ }
356
+ });
357
+
358
+ test("a stale provision cannot recreate the cache on a previous placement", async () => {
359
+ const p = await pool();
360
+ const next = await createRuntimeController({ name: "next", kind: "docker" });
361
+ await updateRuntimePool(p.id, { placements: [next.id] });
362
+ const agent = await createAgent({ name: "moved", status: "idle", isLead: false });
363
+ expect(await provisionRuntime(p, agent, commands, ownerId)).toBe(true);
364
+ expect(provisions[0]?.cache).toBeUndefined();
365
+ await drain();
366
+ expect(calls[0]?.poolId).toBe(p.id);
367
+ });
@@ -226,6 +226,47 @@ async function busy(options: Partial<Parameters<typeof createRuntimePool>[0]> =
226
226
  return state;
227
227
  }
228
228
 
229
+ test("boxd env defaults apply only to builtin boxd targets and stored config wins", async () => {
230
+ const defaults = {
231
+ BOXD_ORG: "env-org",
232
+ BOXD_BASE_URL: "https://boxd-env.test",
233
+ BOXD_SNAPSHOT: "env-snapshot",
234
+ BOXD_DELETE_AFTER_SEC: "3600",
235
+ BOXD_START_TIMEOUT_SEC: "120",
236
+ BOXD_STATUS_TIMEOUT_SEC: "30",
237
+ BOXD_TEARDOWN_TIMEOUT_SEC: "60",
238
+ };
239
+ const storedKey = `stored-${crypto.randomUUID()}`;
240
+ const env = { ...defaults, BOXD_API_KEY: `env-${crypto.randomUUID()}` };
241
+ const builtin = await createRuntimeController({
242
+ name: "builtin-boxd",
243
+ kind: "boxd",
244
+ placement: "builtin",
245
+ secretConfig: { BOXD_API_KEY: storedKey },
246
+ });
247
+ expect(await getRuntimeControllerTarget(builtin, env)).toMatchObject({
248
+ ...defaults,
249
+ BOXD_API_KEY: storedKey,
250
+ });
251
+ const configured = await updateRuntimeController(builtin.id, {
252
+ config: { BOXD_BASE_URL: "https://boxd-config.test" },
253
+ });
254
+ expect((await getRuntimeControllerTarget(configured, env)).BOXD_BASE_URL).toBe(
255
+ "https://boxd-config.test",
256
+ );
257
+ const remote = await createRuntimeController({
258
+ name: "remote-boxd",
259
+ kind: "boxd",
260
+ placement: "remote",
261
+ });
262
+ for (const controller of [remote, (await getRuntimeController(controllerId))!]) {
263
+ const target = await getRuntimeControllerTarget(controller, env);
264
+ for (const key of [...Object.keys(defaults), "BOXD_API_KEY"]) {
265
+ expect(target[key]).toBeUndefined();
266
+ }
267
+ }
268
+ });
269
+
229
270
  test("pending work provisions an E2B runtime with a short lease", async () => {
230
271
  const { pool, agent } = await setup();
231
272
  await tick();
@@ -52,6 +52,104 @@ beforeEach(async () => {
52
52
  });
53
53
 
54
54
  describe("runtime pools persistence", () => {
55
+ test("pool caches default off, persist across edits, and can be disabled explicitly", async () => {
56
+ const controller = await createRuntimeController({ name: "cache-controller", kind: "docker" });
57
+ const base = {
58
+ name: "cached-pool",
59
+ kind: "docker" as const,
60
+ placements: [controller.id],
61
+ image: "worker:test",
62
+ };
63
+ const pool = await createRuntimePool(base);
64
+ expect(pool.cache).toBeNull();
65
+ expect((await updateRuntimePool(pool.id, { cache: { mountPath: "/cache" } })).cache).toEqual({
66
+ mountPath: "/cache",
67
+ });
68
+ expect((await updateRuntimePool(pool.id, { name: "renamed-pool" })).cache).toEqual({
69
+ mountPath: "/cache",
70
+ });
71
+ expect((await getRuntimePool(pool.id))?.cache).toEqual({ mountPath: "/cache" });
72
+ expect((await updateRuntimePool(pool.id, { cache: null })).cache).toBeNull();
73
+ });
74
+
75
+ test("pool cache validation rejects unsafe mount paths and unsupported providers", async () => {
76
+ const docker = await createRuntimeController({
77
+ name: "docker-cache-validation",
78
+ kind: "docker",
79
+ });
80
+ for (const mountPath of [
81
+ "/",
82
+ "//",
83
+ "relative",
84
+ "/cache/..",
85
+ "/./cache",
86
+ "/cache\0bad",
87
+ "/home",
88
+ "/home/worker",
89
+ "/home/worker/",
90
+ "//home//worker//",
91
+ "/workspace",
92
+ "/workspace/personal",
93
+ "/workspace/shared",
94
+ "/workspace/personal/cache",
95
+ "//workspace//personal//cache/",
96
+ "/workspace/shared/.cache",
97
+ "/workspace/shared/repo/.git",
98
+ "/root",
99
+ "/home/worker/.codex",
100
+ "/home/worker/.local/share/opencode",
101
+ "/home/worker/.claude.json",
102
+ "/home/worker/.ssh/keys",
103
+ "/home/worker/.config/cache",
104
+ "/root/.claude",
105
+ "/root/.local/share/opencode/cache",
106
+ ]) {
107
+ await expect(
108
+ createRuntimePool({
109
+ name: "invalid-path",
110
+ kind: "docker",
111
+ placements: [docker.id],
112
+ image: "worker:test",
113
+ cache: { mountPath },
114
+ }),
115
+ ).rejects.toThrow("mountPath");
116
+ }
117
+ for (const kind of ["static", "e2b", "boxd"] as const) {
118
+ const controller = await createRuntimeController({ name: `${kind}-cache-validation`, kind });
119
+ await expect(
120
+ createRuntimePool({
121
+ name: "unsupported-cache",
122
+ kind,
123
+ placements: [controller.id],
124
+ image: "worker:test",
125
+ cache: { mountPath: "/cache" },
126
+ }),
127
+ ).rejects.toThrow("does not support persistent pool caches");
128
+ }
129
+ expect(await getDbClient().query("SELECT id FROM runtime_pools")).toEqual([]);
130
+ });
131
+
132
+ test("cache mount validation accepts isolated cache subdirectories", async () => {
133
+ const controller = await createRuntimeController({ name: "safe-cache-paths", kind: "docker" });
134
+ for (const [index, mountPath] of [
135
+ "/cache",
136
+ "/home/worker/.cache",
137
+ "/home/worker/.cache/bun",
138
+ "/workspace/cache",
139
+ "/root/.cache",
140
+ "/workspace/personal-cache",
141
+ ].entries()) {
142
+ const pool = await createRuntimePool({
143
+ name: `safe-cache-${index}`,
144
+ kind: "docker",
145
+ placements: [controller.id],
146
+ image: "worker:test",
147
+ cache: { mountPath },
148
+ });
149
+ expect(pool.cache?.mountPath).toBe(mountPath);
150
+ }
151
+ });
152
+
55
153
  test("maps persisted task wake and agent provisioning state without changing routing", async () => {
56
154
  const agent = await createAgent({ name: "worker", status: "offline", isLead: false });
57
155
  expect(agent).toMatchObject({ runtimeProvisionAttempts: 0, runtimeProvisionRetryAt: null });
@@ -70,6 +70,35 @@ async function dispatch(
70
70
  ).toBe(true);
71
71
  return { status, text, data: JSON.parse(text) };
72
72
  }
73
+ test("operator can configure a pool cache through HTTP without selecting provider volume names", async () => {
74
+ const controller = await dispatch("/api/runtime-controllers", "POST", {
75
+ name: "cache-controller",
76
+ kind: "docker",
77
+ });
78
+ const input = {
79
+ name: "cache-pool",
80
+ kind: "docker",
81
+ placements: [controller.data.controller.id],
82
+ image: "worker:test",
83
+ cache: { mountPath: "/cache" },
84
+ };
85
+ const created = await dispatch("/api/runtime-pools", "POST", input);
86
+ expect(created.status).toBe(200);
87
+ expect(created.data.pool.cache).toEqual({ mountPath: "/cache" });
88
+ const id = created.data.pool.id;
89
+ const renamed = await dispatch(`/api/runtime-pools/${id}`, "PATCH", {
90
+ name: "renamed-cache-pool",
91
+ });
92
+ expect(renamed.data.pool.cache).toEqual({ mountPath: "/cache" });
93
+ const rejected = await dispatch(`/api/runtime-pools/${id}`, "PATCH", {
94
+ cache: { mountPath: "/cache", key: "foreign-volume" },
95
+ });
96
+ expect(rejected.status).toBe(400);
97
+ const disabled = await dispatch(`/api/runtime-pools/${id}`, "PATCH", { cache: null });
98
+ expect(disabled.status).toBe(200);
99
+ expect(disabled.data.pool.cache).toBeNull();
100
+ });
101
+
73
102
  beforeAll(async () => {
74
103
  await cleanup();
75
104
  initDb(path);