@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
package/src/be/db.ts CHANGED
@@ -313,7 +313,11 @@ export {
313
313
  getContextVersionHistory,
314
314
  getLatestContextVersion,
315
315
  } from "./db/context-versions";
316
- export { getScriptRunExecutionArgs } from "./db/script-runs";
316
+ export {
317
+ getScriptRunExecutionArgs,
318
+ getScriptRunFollowUpGuard,
319
+ setScriptRunFollowUpGuard,
320
+ } from "./db/script-runs";
317
321
  export {
318
322
  AssetKeyMovesBlockedError,
319
323
  assertAssetKeyMovesAllowed,
@@ -336,12 +340,14 @@ export {
336
340
  } from "./db/runtime";
337
341
 
338
342
  export {
343
+ countAgentDispatchesInThread,
339
344
  type ExistingTrackerContextWork,
340
345
  type ExistingTrackerContextWorkReason,
341
346
  findActiveTasksByVcs,
342
- findCompletedTaskInThread,
347
+ findCompletedTasksInThread,
343
348
  findExistingLinearTrackerContextWork,
344
349
  findRecentCancelledTaskInThread,
350
+ findRecentFailedTaskInThread,
345
351
  findTaskByGitHub,
346
352
  findTaskByVcs,
347
353
  getAgentCurrentTask,
@@ -14116,6 +14122,7 @@ type ScriptRunRow = {
14116
14122
  last_heartbeat_at: string | null;
14117
14123
  idempotencyKey: string | null;
14118
14124
  requestedByUserId: string | null;
14125
+ sourceTaskId: string | null;
14119
14126
  created_by: string | null;
14120
14127
  updated_by: string | null;
14121
14128
  };
@@ -14160,6 +14167,7 @@ function rowToScriptRun(row: ScriptRunRow): ScriptRun {
14160
14167
  lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
14161
14168
  idempotencyKey: row.idempotencyKey ?? undefined,
14162
14169
  requestedByUserId: row.requestedByUserId ?? undefined,
14170
+ sourceTaskId: row.sourceTaskId ?? undefined,
14163
14171
  };
14164
14172
  }
14165
14173
 
@@ -14188,6 +14196,7 @@ export async function createScriptRun(data: {
14188
14196
  scriptName?: string;
14189
14197
  idempotencyKey?: string;
14190
14198
  requestedByUserId?: string;
14199
+ sourceTaskId?: string;
14191
14200
  createdBy?: string;
14192
14201
  updatedBy?: string;
14193
14202
  }): Promise<{ run: ScriptRun; existing: boolean }> {
@@ -14202,8 +14211,9 @@ export async function createScriptRun(data: {
14202
14211
 
14203
14212
  const row = await client.get<ScriptRunRow>(
14204
14213
  `INSERT INTO script_runs
14205
- (id, agentId, scriptName, source, args, idempotencyKey, requestedByUserId, created_by, updated_by)
14206
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
14214
+ (id, agentId, scriptName, source, args, idempotencyKey, requestedByUserId, sourceTaskId,
14215
+ created_by, updated_by)
14216
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
14207
14217
  RETURNING *`,
14208
14218
  [
14209
14219
  data.id,
@@ -14216,6 +14226,7 @@ export async function createScriptRun(data: {
14216
14226
  sealJson(data.args ?? null),
14217
14227
  data.idempotencyKey ?? null,
14218
14228
  data.requestedByUserId ?? null,
14229
+ data.sourceTaskId ?? null,
14219
14230
  data.createdBy ?? null,
14220
14231
  data.updatedBy ?? data.createdBy ?? null,
14221
14232
  ],
@@ -45,14 +45,18 @@ function armFetchLimit(limit: number): number {
45
45
  // lookup against it is a full table scan per outer row. The old backfill used
46
46
  // `WHERE NOT EXISTS (SELECT 1 FROM memory_fts f WHERE f.memory_id = m.id)`,
47
47
  // which is N x N: at ~18k memories it held the main connection for minutes at
48
- // boot. Both statements below use a non-correlated `NOT IN (subquery)`, which
49
- // SQLite evaluates once into an ephemeral index, so each is one pass over each
50
- // table. `memory_id IS NOT NULL` keeps a stray NULL row from turning every
51
- // NOT IN into NULL (which would insert nothing).
52
- export const FTS_DELETE_EXTRA_SQL = `DELETE FROM memory_fts
53
- WHERE memory_id NOT IN (SELECT id FROM agent_memory)`;
54
- export const FTS_MISSING_IDS_SQL = `SELECT m.id FROM agent_memory m
55
- WHERE m.id NOT IN (SELECT memory_id FROM memory_fts WHERE memory_id IS NOT NULL)`;
48
+ // boot. The diff below reads each table once, in rowid batches (FTS5 seeks a
49
+ // rowid range), and yields the event loop between batches: one whole-table
50
+ // statement held the loop for seconds on a large DB. A memory_fts row is an
51
+ // orphan when its memory is gone; that check is a lookup on the agent_memory
52
+ // primary key. A NULL memory_id is never an orphan, as with the old NOT IN.
53
+ export const FTS_SCAN_BATCH_SQL = `SELECT rowid AS rowid, memory_id AS memoryId,
54
+ memory_id IS NOT NULL
55
+ AND NOT EXISTS (SELECT 1 FROM agent_memory m WHERE m.id = memory_fts.memory_id) AS orphan
56
+ FROM memory_fts WHERE rowid > ? ORDER BY rowid LIMIT ?`;
57
+ export const MEMORY_ID_BATCH_SQL = `SELECT rowid AS rowid, id FROM agent_memory
58
+ WHERE rowid > ? ORDER BY rowid LIMIT ?`;
59
+ const FTS_SCAN_BATCH_SIZE = 1000;
56
60
  const FTS_POPULATE_BATCH_SIZE = 500;
57
61
 
58
62
  export type AgentMemoryRow = {
@@ -251,12 +255,45 @@ export class SqliteMemoryStore implements MemoryStore {
251
255
  private async populateFtsTable(): Promise<void> {
252
256
  const client = getDbClient();
253
257
  const startedAt = performance.now();
254
- const deletedExtra = await client.run(FTS_DELETE_EXTRA_SQL);
255
- if (deletedExtra.changes > 0) {
256
- console.warn(`[memory-fts] removed_extra_rows count=${deletedExtra.changes}`);
258
+ const yieldLoop = () => new Promise<void>((resolve) => setImmediate(resolve));
259
+
260
+ // Drop orphan rows and collect the memory ids the index holds.
261
+ const indexed = new Set<string>();
262
+ let removed = 0;
263
+ for (let after = 0; ; ) {
264
+ const rows = await client.query<{ rowid: number; memoryId: string | null; orphan: number }>(
265
+ FTS_SCAN_BATCH_SQL,
266
+ [after, FTS_SCAN_BATCH_SIZE],
267
+ );
268
+ if (rows.length === 0) break;
269
+ after = rows[rows.length - 1]!.rowid;
270
+ const orphans = rows.filter((row) => row.orphan).map((row) => row.rowid);
271
+ if (orphans.length > 0) {
272
+ await client.run(
273
+ `DELETE FROM memory_fts WHERE rowid IN (${orphans.map(() => "?").join(",")})`,
274
+ orphans,
275
+ );
276
+ removed += orphans.length;
277
+ }
278
+ for (const row of rows) if (!row.orphan && row.memoryId) indexed.add(row.memoryId);
279
+ await yieldLoop();
280
+ }
281
+ if (removed > 0) {
282
+ console.warn(`[memory-fts] removed_extra_rows count=${removed}`);
283
+ }
284
+
285
+ const missing: { id: string }[] = [];
286
+ for (let after = 0; ; ) {
287
+ const rows = await client.query<{ rowid: number; id: string }>(MEMORY_ID_BATCH_SQL, [
288
+ after,
289
+ FTS_SCAN_BATCH_SIZE,
290
+ ]);
291
+ if (rows.length === 0) break;
292
+ after = rows[rows.length - 1]!.rowid;
293
+ for (const row of rows) if (!indexed.has(row.id)) missing.push(row);
294
+ await yieldLoop();
257
295
  }
258
296
 
259
- const missing = await client.query<{ id: string }>(FTS_MISSING_IDS_SQL);
260
297
  for (let i = 0; i < missing.length; i += FTS_POPULATE_BATCH_SIZE) {
261
298
  const ids = missing.slice(i, i + FTS_POPULATE_BATCH_SIZE).map((row) => row.id);
262
299
  const placeholders = ids.map(() => "?").join(",");
@@ -0,0 +1,20 @@
1
+ -- NULL preserves the ephemeral-only behavior of existing pools.
2
+ ALTER TABLE runtime_pools ADD COLUMN cache TEXT
3
+ CHECK (cache IS NULL OR json_valid(cache));
4
+
5
+ -- Retain release intent across API restarts and pool deletion.
6
+ CREATE TABLE runtime_cache_releases (
7
+ id TEXT PRIMARY KEY,
8
+ pool_id TEXT NOT NULL,
9
+ controller_id TEXT NOT NULL REFERENCES runtime_controllers(id) ON DELETE RESTRICT,
10
+ owner_id TEXT NOT NULL,
11
+ cache_key TEXT NOT NULL,
12
+ runtime_ids TEXT NOT NULL CHECK (json_valid(runtime_ids) AND json_type(runtime_ids) = 'array'),
13
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'deleting')),
14
+ last_error TEXT,
15
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
16
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
17
+ created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
18
+ updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
19
+ UNIQUE(controller_id, owner_id, cache_key)
20
+ );
@@ -0,0 +1,18 @@
1
+ -- Covering indexes for the session visibility walk (readableSessionCondition
2
+ -- in src/be/db/tasks/read.ts). Every task list and count a dashboard user
3
+ -- loads computes the set of tasks in sessions the user cannot read: roots
4
+ -- owned by someone else, plus all their descendants.
5
+ --
6
+ -- agent_tasks rows carry large `task` text, and parentTaskId, scheduleId and
7
+ -- requestedByUserId sit after it, in the overflow pages. Without these
8
+ -- indexes the root step read every unscheduled task row and the recursive
9
+ -- step read the row of every descendant. With them, both steps stay in the
10
+ -- index. Production has no sqlite_stat1; the planner picks both without it.
11
+
12
+ -- Root step: `scheduleId IS NULL` plus the root, owner and id columns.
13
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_session_owner
14
+ ON agent_tasks(scheduleId, parentTaskId, requestedByUserId, id);
15
+
16
+ -- Recursive step: the children of a hidden task, by id.
17
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId_id
18
+ ON agent_tasks(parentTaskId, id);
@@ -0,0 +1,11 @@
1
+ -- Covering index for the default task list count (getTasksCount in
2
+ -- src/be/db/tasks/read.ts). The task list hides heartbeat tasks by default:
3
+ --
4
+ -- IFNULL(taskType, '') NOT IN (...) AND tags NOT LIKE '%"heartbeat"%'
5
+ --
6
+ -- A NOT IN and a NOT LIKE cannot seek, so the count scans. taskType and tags
7
+ -- sit after the large `task` text, in the overflow pages, so the scan read
8
+ -- every task body. This index holds the two filter columns and `id` (the
9
+ -- session visibility filter), so the count scans the index only.
10
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_list_filter
11
+ ON agent_tasks(taskType, tags, id);
@@ -0,0 +1,9 @@
1
+ -- Covering index for the Slack render-v2 watcher sweep (getSlackTreeMessages
2
+ -- in src/be/db.ts), which runs on every watcher tick, even when idle. For
3
+ -- each stored tree it tests the thread's tasks by createdAt, status, source
4
+ -- and lastUpdatedAt. Those columns sit after the large `task` text, in the
5
+ -- overflow pages, so each test read whole task rows. This index holds them,
6
+ -- so the tests stay in the index. Partial: only Slack tasks carry a channel.
7
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_slack_thread_cover
8
+ ON agent_tasks(slackChannelId, slackThreadTs, createdAt, status, source, lastUpdatedAt, id)
9
+ WHERE slackChannelId IS NOT NULL;
@@ -0,0 +1,11 @@
1
+ -- The task whose session launched a durable script run (the caller's
2
+ -- X-Source-Task-Id, kept only when that task belongs to the launching agent).
3
+ -- The follow-up re-delegation guard reads it when the run's ctx.step.agentTask
4
+ -- dispatches, the same way send-task reads the caller's source task.
5
+ ALTER TABLE script_runs ADD COLUMN sourceTaskId TEXT;
6
+
7
+ -- The run's follow-up re-delegation guard decision, taken once for the whole
8
+ -- run at its first new step: NULL (not decided yet), 'passed', or the rule that
9
+ -- blocked it ('same-work' or 'loop-cap'). Every later step follows it, so
10
+ -- parallel steps of one run are dispatched all or none.
11
+ ALTER TABLE script_runs ADD COLUMN followUpGuard TEXT;
@@ -0,0 +1,109 @@
1
+ import type { ControllerCommands } from "../../runtime-providers/controller";
2
+ import { poolCacheKey, type RuntimeCacheReference } from "../../runtime-providers/types";
3
+ import type { RuntimePool } from "../../types";
4
+ import { scrubSecrets } from "../../utils/secret-scrubber";
5
+ import { getDbClient, getSwarmConfigs } from "../db";
6
+
7
+ /** The stored key is private API metadata; legacy caches retain their original name. */
8
+ export async function getPoolCache(poolId: string): Promise<{
9
+ key: string;
10
+ mountPath: string;
11
+ controllerId: string;
12
+ } | null> {
13
+ const row = await getDbClient().get<{ cache: string | null; controllerId: string }>(
14
+ "SELECT cache, json_extract(placements, '$[0]') AS controllerId FROM runtime_pools WHERE id = ?",
15
+ [poolId],
16
+ );
17
+ if (!row?.cache) return null;
18
+ const cache = JSON.parse(row.cache);
19
+ return {
20
+ key: cache.key ?? poolCacheKey(poolId),
21
+ mountPath: cache.mountPath,
22
+ controllerId: row.controllerId,
23
+ };
24
+ }
25
+
26
+ /** Call in the pool write transaction; no infrastructure calls occur here. */
27
+ export async function queuePoolCacheRelease(
28
+ pool: RuntimePool,
29
+ actor: string | null,
30
+ ): Promise<void> {
31
+ if (!pool.cache) return;
32
+ const owner = (await getSwarmConfigs({ scope: "global", key: "runtime_owner_id" }))[0];
33
+ // No runtime could have created an owned cache before the owner was allocated.
34
+ if (!owner) return;
35
+ const cache = (await getPoolCache(pool.id))!;
36
+ const runtimes = await getDbClient().query<{ id: string }>(
37
+ `SELECT id FROM runtime_instances WHERE pool_id = ?
38
+ AND (controller_id = ? OR controller_id IS NULL) AND status != 'offline'`,
39
+ [pool.id, pool.placements[0]!],
40
+ );
41
+ await getDbClient().run(
42
+ `INSERT INTO runtime_cache_releases
43
+ (id, pool_id, controller_id, owner_id, cache_key, runtime_ids, created_by, updated_by)
44
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(controller_id, owner_id, cache_key) DO NOTHING`,
45
+ [
46
+ crypto.randomUUID(),
47
+ pool.id,
48
+ pool.placements[0]!,
49
+ owner.value,
50
+ cache.key,
51
+ JSON.stringify(runtimes.map((r) => r.id)),
52
+ actor,
53
+ actor,
54
+ ],
55
+ );
56
+ }
57
+
58
+ interface CacheRelease extends RuntimeCacheReference {
59
+ id: string;
60
+ controllerId: string;
61
+ }
62
+
63
+ const RELEASE_LEASE_MS = 5 * 60_000;
64
+
65
+ /** Claims are atomic across reconcilers; all network calls are outside DB transactions. */
66
+ export async function reconcileCacheReleases(
67
+ commands: (id: string) => Promise<ControllerCommands>,
68
+ signal: AbortSignal,
69
+ now?: Date,
70
+ ): Promise<void> {
71
+ const jobs = await getDbClient().query<CacheRelease>(
72
+ `SELECT id, pool_id AS poolId, controller_id AS controllerId, owner_id AS ownerId,
73
+ cache_key AS key FROM runtime_cache_releases ORDER BY created_at`,
74
+ );
75
+ for (const job of jobs) {
76
+ if (signal.aborted) return;
77
+ const claimed = now ?? new Date();
78
+ const claimedAt = claimed.toISOString();
79
+ // Reclaim abandoned work after five minutes; generation keys fence late provider calls.
80
+ const reserved = await getDbClient().run(
81
+ `UPDATE runtime_cache_releases SET state = 'deleting', updated_at = ?
82
+ WHERE id = ? AND (state = 'pending' OR updated_at < ?) AND NOT EXISTS (
83
+ SELECT 1 FROM runtime_instances WHERE status != 'offline'
84
+ AND id IN (SELECT value FROM json_each(runtime_cache_releases.runtime_ids))
85
+ )`,
86
+ [claimedAt, job.id, new Date(claimed.getTime() - RELEASE_LEASE_MS).toISOString()],
87
+ );
88
+ if (!reserved.changes) continue;
89
+ try {
90
+ signal.throwIfAborted();
91
+ const controller = await commands(job.controllerId);
92
+ if (!controller.releaseCache)
93
+ throw new Error("Controller cannot release caches; upgrade it before cleanup");
94
+ await controller.releaseCache({ key: job.key, poolId: job.poolId, ownerId: job.ownerId });
95
+ await getDbClient().run(
96
+ "DELETE FROM runtime_cache_releases WHERE id = ? AND state = 'deleting' AND updated_at = ?",
97
+ [job.id, claimedAt],
98
+ );
99
+ } catch (error) {
100
+ const message = scrubSecrets(String(error));
101
+ await getDbClient().run(
102
+ `UPDATE runtime_cache_releases SET state = 'pending', last_error = ?, updated_at = ?
103
+ WHERE id = ? AND state = 'deleting' AND updated_at = ?`,
104
+ [message, new Date().toISOString(), job.id, claimedAt],
105
+ );
106
+ if (!signal.aborted) console.error("[runtime-pools] Cache cleanup deferred:", message);
107
+ }
108
+ }
109
+ }
@@ -4,6 +4,7 @@ import {
4
4
  createRuntimeProvider,
5
5
  type RuntimeTargetConfig,
6
6
  } from "../../runtime-providers";
7
+ import { poolCacheKey } from "../../runtime-providers/types";
7
8
  import type { Agent, DeliveryMode, RuntimeController, RuntimePool } from "../../types";
8
9
  import {
9
10
  RuntimeControllerCreateSchema,
@@ -17,6 +18,7 @@ import { decryptSecret, encryptSecret, getEncryptionKey } from "../crypto";
17
18
  import { createAgent, getAgentById, getAllAgents, getDbClient, upsertSwarmConfig } from "../db";
18
19
  import { type AgentRow, rowToAgent } from "../db/agents";
19
20
  import { registerStoredSecret } from "../secret-registry";
21
+ import { getPoolCache, queuePoolCacheRelease } from "./cache-cleanup";
20
22
  import { RuntimePoolError } from "./errors";
21
23
  import { revokeControllerTokens } from "./tokens";
22
24
 
@@ -29,10 +31,11 @@ type ControllerRow = Omit<
29
31
  config: string;
30
32
  secretConfig: string | null;
31
33
  };
32
- type PoolRow = Omit<RuntimePool, "placements" | "env" | "resources" | "enabled"> & {
34
+ type PoolRow = Omit<RuntimePool, "placements" | "env" | "resources" | "cache" | "enabled"> & {
33
35
  placements: string;
34
36
  env: string;
35
37
  resources: string | null;
38
+ cache: string | null;
36
39
  enabled: number;
37
40
  };
38
41
  const controllerColumns = `id, name, kind, placement, config, secret_config AS secretConfig,
@@ -40,7 +43,7 @@ const controllerColumns = `id, name, kind, placement, config, secret_config AS s
40
43
  created_by AS createdBy, updated_by AS updatedBy, created_at AS createdAt, updated_at AS updatedAt`;
41
44
  const poolColumns = `id, name, kind, placements, image, lifecycle, idle_minutes AS idleMinutes,
42
45
  slots_per_runtime AS slotsPerRuntime, max_runtimes AS maxRuntimes, min_runtimes AS minRuntimes,
43
- delivery, env, resources, ttl_sec AS ttlSec, resolved_image AS resolvedImage, enabled,
46
+ delivery, env, resources, cache, ttl_sec AS ttlSec, resolved_image AS resolvedImage, enabled,
44
47
  consecutive_failures AS consecutiveFailures, circuit_open_until AS circuitOpenUntil,
45
48
  last_error AS lastError, last_error_at AS lastErrorAt, created_by AS createdBy,
46
49
  updated_by AS updatedBy, created_at AS createdAt, updated_at AS updatedAt`;
@@ -65,11 +68,13 @@ function rowToController(row: ControllerRow): RuntimeController {
65
68
  };
66
69
  }
67
70
  function rowToPool(row: PoolRow): RuntimePool {
71
+ const cache = row.cache ? JSON.parse(row.cache) : null;
68
72
  return {
69
73
  ...row,
70
74
  placements: JSON.parse(row.placements),
71
75
  env: JSON.parse(row.env),
72
76
  resources: row.resources ? JSON.parse(row.resources) : null,
77
+ cache: cache ? { mountPath: cache.mountPath } : null,
73
78
  enabled: row.enabled === 1,
74
79
  };
75
80
  }
@@ -121,6 +126,17 @@ export async function getRuntimeControllerTarget(
121
126
  E2B_MAX_CONTINUOUS_SEC: env.E2B_MAX_CONTINUOUS_SEC,
122
127
  RUNTIME_WORKER_API_URL: env.RUNTIME_WORKER_API_URL,
123
128
  PUBLIC_MCP_BASE_URL: env.PUBLIC_MCP_BASE_URL,
129
+ ...(controller.kind === "boxd" && controller.placement === "builtin"
130
+ ? {
131
+ BOXD_ORG: env.BOXD_ORG,
132
+ BOXD_BASE_URL: env.BOXD_BASE_URL,
133
+ BOXD_SNAPSHOT: env.BOXD_SNAPSHOT,
134
+ BOXD_DELETE_AFTER_SEC: env.BOXD_DELETE_AFTER_SEC,
135
+ BOXD_START_TIMEOUT_SEC: env.BOXD_START_TIMEOUT_SEC,
136
+ BOXD_STATUS_TIMEOUT_SEC: env.BOXD_STATUS_TIMEOUT_SEC,
137
+ BOXD_TEARDOWN_TIMEOUT_SEC: env.BOXD_TEARDOWN_TIMEOUT_SEC,
138
+ }
139
+ : {}),
124
140
  ...controller.config,
125
141
  ...secrets,
126
142
  };
@@ -234,11 +250,32 @@ export async function updateRuntimeController(
234
250
  )
235
251
  throw new RuntimePoolError("Controller name already exists", 409);
236
252
  const pools = await listRuntimePools();
253
+ const oldConfig: Record<string, string> = JSON.parse(row.config);
254
+ const configChanged =
255
+ Object.keys(data.config).length !== Object.keys(oldConfig).length ||
256
+ Object.entries(data.config).some(([key, value]) => oldConfig[key] !== value);
257
+ const secrets = patch.secretConfig !== undefined ? controllerSecrets(row) : {};
258
+ const secretsChanged = Object.entries(patch.secretConfig ?? {}).some(([key, value]) =>
259
+ value === null ? key in secrets : secrets[key] !== value,
260
+ );
261
+ if (
262
+ (data.kind !== row.kind ||
263
+ data.placement !== row.placement ||
264
+ configChanged ||
265
+ secretsChanged) &&
266
+ (pools.some((pool) => pool.cache && pool.placements.includes(id)) ||
267
+ (await tx.get("SELECT id FROM runtime_cache_releases WHERE controller_id = ? LIMIT 1", [
268
+ id,
269
+ ])))
270
+ )
271
+ throw new RuntimePoolError(
272
+ "Disable pool caches and wait for cleanup before changing controller configuration or placement",
273
+ 409,
274
+ );
237
275
  if (pools.some((pool) => pool.placements.includes(id) && pool.kind !== data.kind))
238
276
  throw new RuntimePoolError("Pool kind must equal controller kind");
239
277
  let encrypted = row.secretConfig;
240
278
  if (patch.secretConfig !== undefined) {
241
- const secrets = controllerSecrets(row);
242
279
  for (const [key, value] of Object.entries(patch.secretConfig)) {
243
280
  if (value === null) delete secrets[key];
244
281
  else secrets[key] = value;
@@ -272,6 +309,8 @@ export async function deleteRuntimeController(id: string): Promise<void> {
272
309
  if (!(await getRuntimeController(id))) throw new RuntimePoolError("Controller not found", 404);
273
310
  if ((await listRuntimePools()).some((pool) => pool.placements.includes(id)))
274
311
  throw new RuntimePoolError("Controller has runtime pools", 409);
312
+ if (await tx.get("SELECT id FROM runtime_cache_releases WHERE controller_id = ? LIMIT 1", [id]))
313
+ throw new RuntimePoolError("Controller has pending cache cleanup", 409);
275
314
  await revokeControllerTokens(id);
276
315
  await tx.run("DELETE FROM runtime_controllers WHERE id = ?", [id]);
277
316
  });
@@ -291,6 +330,9 @@ export async function getRuntimePool(id: string): Promise<RuntimePool | null> {
291
330
  }
292
331
  async function validatePool(data: z.output<typeof RuntimePoolCreateSchema>): Promise<void> {
293
332
  supportedKind(data.kind);
333
+ if (data.cache && !(await createRuntimeProvider(data.kind)).traits.persistentCache) {
334
+ throw new RuntimePoolError(`${data.kind} does not support persistent pool caches`);
335
+ }
294
336
  const controller = await getRuntimeController(data.placements[0]!);
295
337
  if (!controller) throw new RuntimePoolError("Controller not found");
296
338
  if (data.kind !== controller.kind)
@@ -316,7 +358,7 @@ export async function createRuntimePool(
316
358
  if (await tx.get("SELECT id FROM runtime_pools WHERE name = ? OR id = ?", [data.name, id]))
317
359
  throw new RuntimePoolError("Pool already exists", 409);
318
360
  await tx.run(
319
- `INSERT INTO runtime_pools (id, name, kind, placements, image, lifecycle, idle_minutes, slots_per_runtime, max_runtimes, min_runtimes, delivery, env, resources, ttl_sec, enabled, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
361
+ `INSERT INTO runtime_pools (id, name, kind, placements, image, lifecycle, idle_minutes, slots_per_runtime, max_runtimes, min_runtimes, delivery, env, resources, cache, ttl_sec, enabled, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
320
362
  [
321
363
  id,
322
364
  data.name,
@@ -331,6 +373,9 @@ export async function createRuntimePool(
331
373
  data.delivery,
332
374
  JSON.stringify(data.env),
333
375
  data.resources ? JSON.stringify(data.resources) : null,
376
+ data.cache
377
+ ? JSON.stringify({ ...data.cache, key: poolCacheKey(id, crypto.randomUUID()) })
378
+ : null,
334
379
  data.ttlSec,
335
380
  Number(data.enabled),
336
381
  actor,
@@ -364,8 +409,19 @@ export async function updateRuntimePool(
364
409
  await validatePool(data);
365
410
  if (await tx.get("SELECT id FROM runtime_pools WHERE name = ? AND id != ?", [data.name, id]))
366
411
  throw new RuntimePoolError("Pool name already exists", 409);
412
+ const cache = data.cache
413
+ ? {
414
+ ...data.cache,
415
+ key:
416
+ existing.cache && data.placements[0] === existing.placements[0]
417
+ ? (await getPoolCache(id))!.key
418
+ : poolCacheKey(id, crypto.randomUUID()),
419
+ }
420
+ : null;
421
+ if (existing.cache && (!data.cache || data.placements[0] !== existing.placements[0]))
422
+ await queuePoolCacheRelease(existing, actor);
367
423
  await tx.run(
368
- `UPDATE runtime_pools SET name = ?, kind = ?, placements = ?, image = ?, resolved_image = ?, lifecycle = ?, idle_minutes = ?, slots_per_runtime = ?, max_runtimes = ?, min_runtimes = ?, delivery = ?, env = ?, resources = ?, ttl_sec = ?, enabled = ?, updated_by = COALESCE(?, updated_by), updated_at = ? WHERE id = ?`,
424
+ `UPDATE runtime_pools SET name = ?, kind = ?, placements = ?, image = ?, resolved_image = ?, lifecycle = ?, idle_minutes = ?, slots_per_runtime = ?, max_runtimes = ?, min_runtimes = ?, delivery = ?, env = ?, resources = ?, cache = ?, ttl_sec = ?, enabled = ?, updated_by = COALESCE(?, updated_by), updated_at = ? WHERE id = ?`,
369
425
  [
370
426
  data.name,
371
427
  data.kind,
@@ -384,6 +440,7 @@ export async function updateRuntimePool(
384
440
  data.delivery,
385
441
  JSON.stringify(data.env),
386
442
  data.resources ? JSON.stringify(data.resources) : null,
443
+ cache ? JSON.stringify(cache) : null,
387
444
  data.ttlSec,
388
445
  Number(data.enabled),
389
446
  actor,
@@ -400,7 +457,8 @@ export async function deleteRuntimePool(
400
457
  actor: string | null = null,
401
458
  ): Promise<void> {
402
459
  await getDbClient().transaction(async (tx) => {
403
- if (!(await getRuntimePool(id))) throw new RuntimePoolError("Pool not found", 404);
460
+ const pool = await getRuntimePool(id);
461
+ if (!pool) throw new RuntimePoolError("Pool not found", 404);
404
462
  if (
405
463
  await tx.get(
406
464
  "SELECT id FROM runtime_instances WHERE pool_id = ? AND status != 'offline' LIMIT 1",
@@ -419,6 +477,7 @@ export async function deleteRuntimePool(
419
477
  [new Date().toISOString(), actor, id],
420
478
  );
421
479
  }
480
+ await queuePoolCacheRelease(pool, actor);
422
481
  await tx.run("DELETE FROM runtime_pools WHERE id = ?", [id]);
423
482
  });
424
483
  }
@@ -53,6 +53,7 @@ export class RemoteController implements ControllerCommands {
53
53
  readonly renew?: ControllerCommands["renew"];
54
54
  readonly pause?: ControllerCommands["pause"];
55
55
  readonly resume?: ControllerCommands["resume"];
56
+ readonly releaseCache?: ControllerCommands["releaseCache"];
56
57
 
57
58
  constructor(
58
59
  readonly id: string,
@@ -61,6 +62,10 @@ export class RemoteController implements ControllerCommands {
61
62
  readonly workerApiUrl?: string,
62
63
  private readonly timeouts: { commandTimeoutMs?: number; provisionTimeoutMs?: number } = {},
63
64
  ) {
65
+ if (traits.persistentCache)
66
+ this.releaseCache = async (cache) => {
67
+ await this.call({ op: "release-cache", payload: cache });
68
+ };
64
69
  if (traits.maxTtlSec)
65
70
  this.renew = async (handle, ttlSec) =>
66
71
  z
@@ -29,6 +29,7 @@ import {
29
29
  runtimeStaleThresholdMinutes,
30
30
  } from "../multi-runtime";
31
31
  import { mintRuntimeToken } from "../users";
32
+ import { getPoolCache, reconcileCacheReleases } from "./cache-cleanup";
32
33
  import {
33
34
  clearRuntimeOwnerIdCache,
34
35
  ensureRuntimeOwnerId,
@@ -245,6 +246,13 @@ export async function provisionRuntime(
245
246
  if (pool.lifecycle === "per-task") env.SWARM_EXIT_AFTER_TASKS = "1";
246
247
  if (templateId) env.TEMPLATE_ID = templateId;
247
248
  const ttlSec = pool.kind === "e2b" ? poolLeaseTtl(pool, controller) : undefined;
249
+ const currentPoolCache = await getPoolCache(pool.id);
250
+ const currentCache =
251
+ currentPoolCache?.controllerId === pool.placements[0] ? currentPoolCache : null;
252
+ const cache =
253
+ currentCache && controller.traits.persistentCache
254
+ ? { key: currentCache.key, mountPath: currentCache.mountPath }
255
+ : undefined;
248
256
  signal?.throwIfAborted();
249
257
  const handle = await controller.provision({
250
258
  runtimeId,
@@ -255,6 +263,7 @@ export async function provisionRuntime(
255
263
  ...(ttlSec === undefined ? {} : { ttlSec }),
256
264
  env,
257
265
  resources: pool.resources ?? undefined,
266
+ ...(cache ? { cache } : {}),
258
267
  labels: {
259
268
  [RUNTIME_LABELS.runtimeId]: runtimeId,
260
269
  [RUNTIME_LABELS.poolId]: pool.id,
@@ -267,6 +276,25 @@ export async function provisionRuntime(
267
276
  await markRuntimeTerminated(runtimeId, "Supervisor stopped during provision");
268
277
  return false;
269
278
  }
279
+ const cacheWarning = currentCache
280
+ ? !controller.traits.persistentCache
281
+ ? "Controller does not support persistent caches; runtime started without cache"
282
+ : handle.meta?.cacheWarning
283
+ : undefined;
284
+ // A cache is optional: surface degradation without counting a provision failure.
285
+ const cacheErrorPrefix = "Persistent cache: ";
286
+ const cacheError = cacheWarning ? cacheErrorPrefix + scrubSecrets(cacheWarning) : null;
287
+ await getDbClient().run(
288
+ `UPDATE runtime_pools SET last_error = ?, last_error_at = ?
289
+ WHERE id = ? AND (last_error IS NULL OR substr(last_error, 1, ?) = ?)`,
290
+ [
291
+ cacheError,
292
+ cacheError ? new Date().toISOString() : null,
293
+ pool.id,
294
+ cacheErrorPrefix.length,
295
+ cacheErrorPrefix,
296
+ ],
297
+ );
270
298
  await getDbClient().run(
271
299
  "UPDATE runtime_instances SET external_handle = ?, lease_expires_at = ?, created_at = COALESCE(?, created_at), updated_at = ? WHERE id = ?",
272
300
  [
@@ -886,11 +914,14 @@ export async function reconcileOnce(
886
914
  opts: { now?: Date; getControllerCommands?: typeof getControllerCommands } = {},
887
915
  ): Promise<void> {
888
916
  if (!isRuntimePoolsEnabled() || !isMultiRuntimeEnabled() || inFlight) return;
889
- tickAbort = new AbortController();
917
+ const tick = new AbortController();
918
+ tickAbort = tick;
890
919
  inFlight = reconcilePools(
891
920
  opts.now ?? new Date(),
892
921
  opts.getControllerCommands ?? getControllerCommands,
893
- tickAbort.signal,
922
+ tick.signal,
923
+ ).then(() =>
924
+ reconcileCacheReleases(opts.getControllerCommands ?? getControllerCommands, tick.signal),
894
925
  );
895
926
  try {
896
927
  await inFlight;
@@ -65,6 +65,8 @@ export type AgentTaskStepConfig = {
65
65
  parentTaskId?: string;
66
66
  requestedByUserId?: string;
67
67
  outputSchema?: Record<string, unknown>;
68
+ /** 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). */
69
+ allowRedelegation?: boolean;
68
70
  /** Wait for the dispatched task to reach a terminal status before resolving. Default: true. */
69
71
  waitForCompletion?: boolean;
70
72
  /** Max ms to wait for a terminal status before throwing. Default: 2h. Only used when waitForCompletion is true. */
@@ -497,6 +499,26 @@ export interface ScriptWorkflowSteps {
497
499
  label: string,
498
500
  config: { prompt: string; model?: string; schema?: Record<string, unknown> },
499
501
  ): Promise<unknown>;
502
+ /**
503
+ * Dispatch a task and journal its result under \`label\`.
504
+ *
505
+ * Resolves to the task's OUTPUT on success: the stored output string (JSON
506
+ * text under \`outputSchema\`, so parse it yourself), or \`null\` when the task
507
+ * stored none. It is never a task record: on a string, \`result.taskId\` and
508
+ * \`result.status\` are undefined; on \`null\`, reading them throws a TypeError.
509
+ * Other shapes:
510
+ * - \`failOnTaskFailure: false\` and the task ends failed/cancelled/superseded:
511
+ * \`{ taskId, status: "failed", error }\`.
512
+ * - \`waitForCompletion: false\`: the first call still waits up to ~30s while
513
+ * the server polls the task. Done in that window: the output, as on
514
+ * success. Failed or cancelled in that window: throws, or
515
+ * \`{ taskId, status: "failed", error }\` with \`failOnTaskFailure: false\`.
516
+ * Still running: \`{ taskId, status }\`.
517
+ *
518
+ * To get a step's task id, query by its context key:
519
+ * \`SELECT id, status, output FROM agent_tasks WHERE contextKey = 'script-run:<runId>:<label>'
520
+ * AND taskType = 'script-run-step' ORDER BY createdAt DESC, rowid DESC LIMIT 1\`.
521
+ */
500
522
  agentTask(
501
523
  label: string,
502
524
  config: AgentTaskStepConfig,
@@ -30,7 +30,11 @@ const REASON_PATTERNS: any[] = [
30
30
  { key: "context-window", re: /context (window|limit|saturat)|peakcontext|compact/i },
31
31
  { key: "reboot-sweep", re: /reboot sweep/i },
32
32
  { key: "not-found", re: /not found|404|missing|no such/i },
33
- { key: "auth/credentials", re: /unauthorized|401|403|credential|token|forbidden/i },
33
+ {
34
+ key: "output-limit",
35
+ re: /\bmax_(?:output_)?tokens\b|\b(?:maximum|max) output (?:length|tokens)\b|\boutput (?:token|length) limit\b/i,
36
+ },
37
+ { key: "auth/credentials", re: /unauthorized|401|403|credential|\btoken\b|forbidden/i },
34
38
  { key: "ci/checks-failed", re: /\bci\b|check.?s? fail|lint|tsc|test.?s? fail/i },
35
39
  { key: "network", re: /network|econn|fetch failed|socket|dns|502|503|504/i },
36
40
  { key: "cancelled", re: /cancel|aborted/i },