@desplega.ai/agent-swarm 1.131.0 → 1.133.1

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 (265) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-w53mbns9.js → actions-cskpayd7.js} +11 -9
  3. package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
  4. package/dist/{app-m9gwqsqv.js → app-ang77nbb.js} +8 -6
  5. package/dist/{assistant-bp65m6f3.js → assistant-b4exebdw.js} +16 -13
  6. package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
  7. package/dist/{boot-reembed-62p0at2k.js → boot-reembed-ps2aftpb.js} +9 -7
  8. package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-znyk3nxb.js} +10 -8
  9. package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
  10. package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-3ct8kgxw.js} +13 -9
  11. package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-akx4ntf2.js} +2 -2
  12. package/dist/{cli-t1xze5qh.js → cli-13w2hwkh.js} +1 -1
  13. package/dist/{cli-efxfnq6q.js → cli-1532szb1.js} +8 -6
  14. package/dist/{cli-d2tfkcga.js → cli-23d3rseb.js} +33 -17
  15. package/dist/{cli-nm5gtp1x.js → cli-2a75z9c5.js} +196 -7
  16. package/dist/{cli-fywms69x.js → cli-2t30rj0d.js} +1 -1
  17. package/dist/{cli-cvgjwnh1.js → cli-3c3tdgab.js} +10 -10
  18. package/dist/{cli-pm6xbh84.js → cli-3zzxa9jz.js} +46 -16
  19. package/dist/{cli-cdeg08wx.js → cli-4k6233gc.js} +1 -1
  20. package/dist/{cli-t55yw3a1.js → cli-5p2tz05r.js} +2 -2
  21. package/dist/{cli-0rcyq6d9.js → cli-5z2yhzvd.js} +125 -47
  22. package/dist/{cli-3kf51j15.js → cli-6zfq31nj.js} +9 -3
  23. package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
  24. package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
  25. package/dist/{cli-72q8m8gk.js → cli-9nfc2es8.js} +6 -6
  26. package/dist/{cli-5ze9rv97.js → cli-9ta3pwms.js} +11 -4
  27. package/dist/{cli-78xwvkhj.js → cli-avr0ns7z.js} +2 -2
  28. package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
  29. package/dist/{cli-2q72qz2s.js → cli-dgk20ft7.js} +15 -6
  30. package/dist/cli-et1bbwqf.js +13 -0
  31. package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
  32. package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
  33. package/dist/{cli-e7ckcg4v.js → cli-ftwpv451.js} +170 -387
  34. package/dist/{cli-g6b8x1h8.js → cli-g854s2dw.js} +1 -1
  35. package/dist/{cli-5kx36evn.js → cli-ggmdxeqa.js} +13 -8
  36. package/dist/cli-hkwfe3qj.js +530 -0
  37. package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
  38. package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
  39. package/dist/{cli-5v4rw6yd.js → cli-pvzqhbs9.js} +2 -2
  40. package/dist/cli-pzq5rjym.js +15 -0
  41. package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
  42. package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
  43. package/dist/cli-qys3rhhz.js +27 -0
  44. package/dist/{cli-rzbpmxdr.js → cli-r6tpm37h.js} +2 -2
  45. package/dist/{cli-qv61hp37.js → cli-rch80c76.js} +2 -2
  46. package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
  47. package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
  48. package/dist/{cli-psh3c4va.js → cli-sfd9v7sj.js} +1 -1
  49. package/dist/{cli-70xqj0z3.js → cli-sh3ytsyj.js} +49 -39
  50. package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
  51. package/dist/{cli-d3x3x1c9.js → cli-xdb4mpnp.js} +2 -2
  52. package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
  53. package/dist/{cli-yw8vghx2.js → cli-xv8t8p3c.js} +65 -32
  54. package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
  55. package/dist/{cli-t76vw5db.js → cli-y08jvxzx.js} +601 -140
  56. package/dist/{cli-a4jzdpa3.js → cli-zfbf3srj.js} +650 -245
  57. package/dist/{cli-a40rnf71.js → cli-zmq89k34.js} +6 -6
  58. package/dist/{cli-qmdnhpej.js → cli-zr9q011v.js} +23 -13
  59. package/dist/cli.js +22 -22
  60. package/dist/{codex-adapter-d24q75wk.js → codex-adapter-bef93pq4.js} +9 -7
  61. package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
  62. package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-5pkxys7x.js} +9 -7
  63. package/dist/{commands-c3nrc5hg.js → commands-e23s1bff.js} +9 -7
  64. package/dist/{db-nt7fg3tw.js → db-m3nabatn.js} +17 -5
  65. package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
  66. package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
  67. package/dist/{handlers-fkr6swat.js → handlers-akazedvr.js} +16 -13
  68. package/dist/{hook-77wpvnjc.js → hook-ze4cpf0v.js} +55 -41
  69. package/dist/{hook-g019yehy.js → hook-zfqyyenw.js} +10 -7
  70. package/dist/{http-jewj0syk.js → http-sja0n6kw.js} +730 -150
  71. package/dist/index-0gr8vd6k.js +39 -0
  72. package/dist/{index-7n2xpbd4.js → index-354vkhdv.js} +154 -74
  73. package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
  74. package/dist/{index-hfvggd0z.js → index-95t3cqq5.js} +16 -14
  75. package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
  76. package/dist/{index-6gm5he8h.js → index-vpn36mma.js} +15 -13
  77. package/dist/{keepalive-j0fjvqfd.js → keepalive-3sp3meqj.js} +16 -10
  78. package/dist/{lead-1n8x30xe.js → lead-mefeewkf.js} +39 -36
  79. package/dist/{maintenance-gvev11sa.js → maintenance-8tbrbjfg.js} +10 -8
  80. package/dist/mistral-conversations-00zrp7g4.js +717 -0
  81. package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
  82. package/dist/{onboard-pvm5b38w.js → onboard-mt2tgyjg.js} +7 -7
  83. package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
  84. package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
  85. package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
  86. package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
  87. package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
  88. package/dist/{otel-impl-p6jvgd9c.js → otel-impl-z6pxt3j9.js} +224 -9
  89. package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-btvqmj58.js} +1138 -408
  90. package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-kyq8cezh.js} +9 -7
  91. package/dist/{rbac-roles-cr80g96t.js → rbac-roles-3ryfrfna.js} +8 -6
  92. package/dist/{rbac-roles-30pspvy7.js → rbac-roles-6qfvbee1.js} +9 -7
  93. package/dist/{render-v2-xm457ng1.js → render-v2-p61ppjey.js} +8 -6
  94. package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-j56bbj4y.js} +8 -6
  95. package/dist/{setup-nytnmwwr.js → setup-tpr6c1kn.js} +2 -2
  96. package/dist/{worker-msytr2cw.js → worker-9w1v0mbb.js} +39 -36
  97. package/openapi.json +542 -16
  98. package/package.json +6 -6
  99. package/src/be/budget-refusal-notify.ts +14 -7
  100. package/src/be/db-queries/task-artifacts.ts +182 -0
  101. package/src/be/db.ts +783 -169
  102. package/src/be/events.ts +5 -0
  103. package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
  104. package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
  105. package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
  106. package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
  107. package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
  108. package/src/be/migrations/136_task_requester_provenance.sql +37 -0
  109. package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
  110. package/src/be/multi-runtime.ts +430 -0
  111. package/src/be/scripts/db.ts +43 -1
  112. package/src/be/scripts/extract-schema.ts +3 -1
  113. package/src/be/scripts/retention.ts +164 -0
  114. package/src/be/scripts/run-saved.ts +16 -1
  115. package/src/be/scripts/typecheck.ts +3 -2
  116. package/src/be/seed-skills/index.ts +7 -0
  117. package/src/be/steering.ts +1 -1
  118. package/src/be/swarm-config-guard.ts +18 -0
  119. package/src/commands/credential-wait.ts +54 -0
  120. package/src/commands/onboard/steps/core-credentials.tsx +1 -1
  121. package/src/commands/onboard/steps/custom-templates.tsx +1 -1
  122. package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
  123. package/src/commands/onboard/steps/health-check.tsx +1 -1
  124. package/src/commands/onboard/steps/start.tsx +1 -1
  125. package/src/commands/profile-sync-audit.ts +56 -0
  126. package/src/commands/profile-sync.ts +287 -15
  127. package/src/commands/provider-credentials.ts +46 -19
  128. package/src/commands/runner.ts +196 -33
  129. package/src/commands/templates.ts +22 -0
  130. package/src/github/handlers.ts +55 -9
  131. package/src/heartbeat/heartbeat.ts +71 -12
  132. package/src/hooks/hook.ts +49 -33
  133. package/src/http/active-sessions.ts +11 -1
  134. package/src/http/agents.ts +261 -30
  135. package/src/http/all-routes.ts +1 -0
  136. package/src/http/config.ts +13 -2
  137. package/src/http/core.ts +102 -4
  138. package/src/http/db-query-bounded.ts +313 -0
  139. package/src/http/db-query-shared.ts +249 -0
  140. package/src/http/db-query.ts +54 -42
  141. package/src/http/events.ts +2 -0
  142. package/src/http/index.ts +27 -2
  143. package/src/http/mcp-bridge.ts +37 -3
  144. package/src/http/mcp.ts +12 -0
  145. package/src/http/memory.ts +13 -2
  146. package/src/http/openapi.ts +1 -0
  147. package/src/http/poll.ts +70 -27
  148. package/src/http/route-def.ts +43 -9
  149. package/src/http/schedules.ts +2 -2
  150. package/src/http/scripts.ts +31 -4
  151. package/src/http/session-data.ts +44 -0
  152. package/src/http/tasks.ts +2 -2
  153. package/src/http/users.ts +74 -52
  154. package/src/http/webhooks.ts +5 -2
  155. package/src/http/workflows.ts +1 -4
  156. package/src/http/x.ts +14 -0
  157. package/src/integrations/kapso/inbound.ts +1 -1
  158. package/src/jira/outbound.ts +39 -8
  159. package/src/jira/webhook-lifecycle.ts +13 -2
  160. package/src/linear/outbound.ts +43 -10
  161. package/src/oauth/keepalive.ts +13 -2
  162. package/src/prompts/base-prompt.ts +1 -1
  163. package/src/prompts/session-templates.ts +45 -7
  164. package/src/providers/claude-adapter.ts +6 -0
  165. package/src/providers/claude-managed-models.ts +34 -10
  166. package/src/providers/codex-adapter.ts +6 -0
  167. package/src/providers/opencode-adapter.ts +6 -0
  168. package/src/providers/pi-mono-adapter.ts +8 -0
  169. package/src/providers/pricing-sources.md +24 -0
  170. package/src/queue-stall-alarm.ts +216 -0
  171. package/src/scheduler/scheduler.ts +7 -0
  172. package/src/script-workflows/executor.ts +2 -1
  173. package/src/scripts-runtime/executors/native.ts +2 -1
  174. package/src/scripts-runtime/executors/types.ts +5 -0
  175. package/src/scripts-runtime/loader.ts +5 -0
  176. package/src/scripts-runtime/swarm-config.ts +8 -0
  177. package/src/scripts-runtime/swarm-sdk.ts +7 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/tests/budget-claim-gate.test.ts +20 -0
  181. package/src/tests/claude-adapter.test.ts +32 -0
  182. package/src/tests/claude-managed-adapter.test.ts +38 -7
  183. package/src/tests/codex-adapter.test.ts +30 -0
  184. package/src/tests/create-task-validation.test.ts +128 -0
  185. package/src/tests/credential-wait.test.ts +67 -1
  186. package/src/tests/db-query.test.ts +711 -2
  187. package/src/tests/http-api-integration.test.ts +81 -0
  188. package/src/tests/http-users.test.ts +166 -1
  189. package/src/tests/identity-field-budget.test.ts +176 -0
  190. package/src/tests/mcp-tools-user.test.ts +34 -0
  191. package/src/tests/mcp-user-route.test.ts +17 -0
  192. package/src/tests/metrics-http.test.ts +44 -0
  193. package/src/tests/multi-runtime-registration.test.ts +2703 -0
  194. package/src/tests/openapi-response-contract.test.ts +24 -0
  195. package/src/tests/openapi-route-headers.test.ts +83 -0
  196. package/src/tests/opencode-adapter.test.ts +40 -0
  197. package/src/tests/outbound-listener-rejection.test.ts +138 -0
  198. package/src/tests/pi-mono-adapter.test.ts +98 -0
  199. package/src/tests/profile-sync-warning.test.ts +277 -0
  200. package/src/tests/profile-sync.test.ts +232 -6
  201. package/src/tests/prompt-template-session.test.ts +32 -3
  202. package/src/tests/queue-stall-alarm.test.ts +226 -0
  203. package/src/tests/requester-comms.test.ts +32 -0
  204. package/src/tests/runner-requester-profile.test.ts +38 -0
  205. package/src/tests/sandboxed-process.test.ts +45 -0
  206. package/src/tests/schedule-target-type.test.ts +38 -0
  207. package/src/tests/script-connections.test.ts +16 -3
  208. package/src/tests/scripts-external-api.test.ts +8 -3
  209. package/src/tests/scripts-http.test.ts +12 -0
  210. package/src/tests/scripts-retention.test.ts +384 -0
  211. package/src/tests/scripts-runtime-identity.test.ts +375 -0
  212. package/src/tests/scripts-runtime.test.ts +4 -3
  213. package/src/tests/sdk-allowlist.test.ts +47 -0
  214. package/src/tests/send-task-requested-by.test.ts +9 -0
  215. package/src/tests/session-costs.test.ts +506 -1
  216. package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
  217. package/src/tests/steering-core.test.ts +28 -0
  218. package/src/tests/task-artifacts.test.ts +386 -0
  219. package/src/tests/task-attachments-schema.test.ts +17 -1
  220. package/src/tests/task-completion-idempotency.test.ts +57 -0
  221. package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
  222. package/src/tests/update-profile-api.test.ts +67 -39
  223. package/src/tests/update-profile-auth.test.ts +48 -4
  224. package/src/tests/user-attribution-migration.test.ts +54 -0
  225. package/src/tests/user-identity.test.ts +72 -0
  226. package/src/tests/workflow-async-v2.test.ts +39 -1
  227. package/src/tests/workflow-http-v2.test.ts +3 -2
  228. package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
  229. package/src/tests/workflow-retry-v2.test.ts +45 -1
  230. package/src/tests/workflow-swarm-script.test.ts +14 -5
  231. package/src/tools/db-query.ts +18 -8
  232. package/src/tools/get-task-details.ts +24 -3
  233. package/src/tools/manage-user.ts +36 -3
  234. package/src/tools/poll-task.ts +93 -14
  235. package/src/tools/script-common.ts +5 -0
  236. package/src/tools/send-task.ts +1 -4
  237. package/src/tools/store-progress.ts +13 -2
  238. package/src/tools/swarm-config/delete-config.ts +9 -0
  239. package/src/tools/swarm-config/set-config.ts +3 -2
  240. package/src/tools/task-action.ts +30 -0
  241. package/src/tools/task-tool-ctx.ts +8 -1
  242. package/src/tools/update-profile.ts +16 -21
  243. package/src/tools/utils.ts +5 -0
  244. package/src/tools/workflows/trigger-workflow.ts +7 -1
  245. package/src/types.ts +165 -1
  246. package/src/utils/github-pull-request.ts +76 -0
  247. package/src/utils/identity-field-budget.ts +73 -0
  248. package/src/utils/multi-runtime.ts +20 -0
  249. package/src/utils/requester-comms.ts +24 -0
  250. package/src/utils/sandboxed-process.ts +40 -14
  251. package/src/workflows/engine.ts +22 -2
  252. package/src/workflows/executors/swarm-script.ts +24 -1
  253. package/src/workflows/resume.ts +19 -11
  254. package/src/workflows/retry-poller.ts +1 -0
  255. package/src/workflows/triggers.ts +4 -0
  256. package/templates/schedules/daily-compounding-reflection/content.md +12 -3
  257. package/templates/skills/db-query-guidance/config.json +14 -0
  258. package/templates/skills/db-query-guidance/content.md +57 -0
  259. package/dist/cli-3te4jdkm.js +0 -225
  260. package/dist/cli-bmze986d.js +0 -7
  261. package/dist/cli-kh3ba2zf.js +0 -607
  262. package/dist/index-hp1kdbjj.js +0 -36
  263. package/dist/mistral-conversations-81fwav0j.js +0 -30578
  264. package/dist/otel-nqa12wf3.js +0 -3149
  265. package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Persistence for multi-runtime agents: the runtime-instance lifecycle and the
3
+ * logical task-concurrency policy that sits above it.
4
+ *
5
+ * Server-side by design — these helpers own SQLite statements, so they live
6
+ * under `src/be` rather than beside the DB-free flag reader in
7
+ * `src/utils/multi-runtime.ts`, keeping the worker/API database boundary
8
+ * intact (`scripts/check-db-boundary.sh`).
9
+ */
10
+
11
+ import type { Agent, RuntimeInstance, RuntimeInstanceStatus, SwarmConfig } from "../types";
12
+ import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
13
+ import {
14
+ getActiveTaskCount,
15
+ getAgentById,
16
+ getDb,
17
+ getSwarmConfigs,
18
+ updateAgentMaxTasks,
19
+ updateAgentStatus,
20
+ upsertSwarmConfig,
21
+ } from "./db";
22
+
23
+ // ─── Logical task policy ─────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Agent-scoped swarm_config key holding the logical maxTasks policy in
27
+ * multi-runtime mode. Operators edit this row; `agents.maxTasks` stays the
28
+ * enforcement mirror that hasCapacity()/getRemainingCapacity() read. Same
29
+ * column-vs-config split as HARNESS_PROVIDER.
30
+ */
31
+ export const AGENT_MAX_TASKS_CONFIG_KEY = "AGENT_MAX_TASKS";
32
+
33
+ /** Bounds match AgentSchema.maxTasks, so a mirrored write stays schema-valid. */
34
+ function parseAgentMaxTasksValue(raw: string): number | null {
35
+ const str = raw.trim();
36
+ if (!/^\d+$/.test(str)) return null;
37
+ const value = Number(str);
38
+ return value >= 1 && value <= 100 ? value : null;
39
+ }
40
+
41
+ /**
42
+ * Concurrency an agent falls back to with no explicit policy. Mirrors the
43
+ * role defaults the runner resolves for itself (`resolveMaxConcurrent`), so a
44
+ * lead keeps room for the follow-up work it waits on.
45
+ */
46
+ export function defaultMaxTasksForAgent(agent: Pick<Agent, "isLead">): number {
47
+ return agent.isLead ? 2 : 1;
48
+ }
49
+
50
+ export function getAgentMaxTasksConfig(agentId: string): SwarmConfig | null {
51
+ return (
52
+ getSwarmConfigs({ scope: "agent", scopeId: agentId, key: AGENT_MAX_TASKS_CONFIG_KEY })[0] ??
53
+ null
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Reconcile the AGENT_MAX_TASKS policy row with its agents.maxTasks mirror,
59
+ * never consuming the runtime-reported concurrency.
60
+ *
61
+ * With no row yet, seed from the persisted agents.maxTasks — the value the
62
+ * swarm is already enforcing — so enabling multi-runtime mode cannot change
63
+ * behaviour. An existing row is authoritative and repairs the mirror instead.
64
+ *
65
+ * Runs inside the registration transaction so concurrent runtimes serialize:
66
+ * the first seeds, the rest observe.
67
+ */
68
+ export function reconcileAgentMaxTasksPolicy(agentId: string): void {
69
+ const agent = getAgentById(agentId);
70
+ if (!agent) return;
71
+ const existing = getAgentMaxTasksConfig(agentId);
72
+ if (existing) {
73
+ const authoritative = parseAgentMaxTasksValue(existing.value);
74
+ if (authoritative !== null && authoritative !== (agent.maxTasks ?? 1)) {
75
+ updateAgentMaxTasks(agentId, authoritative);
76
+ }
77
+ return;
78
+ }
79
+ upsertSwarmConfig({
80
+ scope: "agent",
81
+ scopeId: agentId,
82
+ key: AGENT_MAX_TASKS_CONFIG_KEY,
83
+ value: String(agent.maxTasks ?? defaultMaxTasksForAgent(agent)),
84
+ description: "Logical agent max-tasks policy (seeded from agents.maxTasks)",
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Deleting the policy returns the agent to its role default rather than to a
90
+ * fixed single slot — otherwise resetting a lead would leave it unable to
91
+ * start the follow-up its own task waits on.
92
+ */
93
+ export function resetAgentMaxTasksMirror(agentId: string): void {
94
+ const agent = getAgentById(agentId);
95
+ if (!agent) return;
96
+ updateAgentMaxTasks(agentId, defaultMaxTasksForAgent(agent));
97
+ }
98
+
99
+ /**
100
+ * Config upsert that keeps the agents.maxTasks enforcement mirror in step
101
+ * with an agent-scoped AGENT_MAX_TASKS write, in one transaction, so policy
102
+ * and enforcement cannot diverge — including with no runtime connected.
103
+ * Every other key passes straight through.
104
+ */
105
+ export function upsertSwarmConfigWithPolicyMirror(
106
+ data: Parameters<typeof upsertSwarmConfig>[0],
107
+ ): SwarmConfig {
108
+ const scopeId = data.scope === "agent" ? data.scopeId : null;
109
+ if (!scopeId || data.key !== AGENT_MAX_TASKS_CONFIG_KEY) {
110
+ return upsertSwarmConfig(data);
111
+ }
112
+ return getDb().transaction(() => {
113
+ const row = upsertSwarmConfig(data);
114
+ // Guard, not validation — both write paths already ran validateConfigValue.
115
+ const value = parseAgentMaxTasksValue(data.value);
116
+ if (value !== null) updateAgentMaxTasks(scopeId, value);
117
+ return row;
118
+ })();
119
+ }
120
+
121
+ // ─── Runtime instances ───────────────────────────────────────────────────────
122
+
123
+ interface RuntimeInstanceRow {
124
+ id: string;
125
+ agent_id: string;
126
+ status: string;
127
+ reported_slots: number;
128
+ credential_ready: number | null;
129
+ metadata: string | null;
130
+ last_seen_at: string;
131
+ created_at: string;
132
+ updated_at: string;
133
+ created_by: string | null;
134
+ updated_by: string | null;
135
+ }
136
+
137
+ function rowToRuntimeInstance(row: RuntimeInstanceRow): RuntimeInstance {
138
+ let metadata: Record<string, unknown> | null = null;
139
+ if (row.metadata) {
140
+ try {
141
+ metadata = JSON.parse(row.metadata) as Record<string, unknown>;
142
+ } catch {
143
+ metadata = null;
144
+ }
145
+ }
146
+ return {
147
+ id: row.id,
148
+ agentId: row.agent_id,
149
+ status: row.status as RuntimeInstanceStatus,
150
+ reportedSlots: row.reported_slots,
151
+ credentialReady: row.credential_ready === null ? null : row.credential_ready === 1,
152
+ metadata,
153
+ lastSeenAt: row.last_seen_at,
154
+ createdAt: row.created_at,
155
+ updatedAt: row.updated_at,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Minutes without a ping before a runtime stops counting as live. The runner
161
+ * pings every poll iteration (~2s), so this is a wide margin that will not
162
+ * expire a healthy idle worker; it matches the "worker clearly dead" window
163
+ * the stalled-task classifier already uses.
164
+ */
165
+ export function runtimeStaleThresholdMinutes(): number {
166
+ return Number(process.env.RUNTIME_STALE_THRESHOLD_MIN) || 5;
167
+ }
168
+
169
+ function runtimeLivenessCutoff(): string {
170
+ return new Date(Date.now() - runtimeStaleThresholdMinutes() * 60 * 1000).toISOString();
171
+ }
172
+
173
+ /**
174
+ * Register (or refresh) a runtime instance serving a logical agent. A
175
+ * re-registration from the same process refreshes its row in place rather
176
+ * than accumulating rows.
177
+ *
178
+ * The `DO UPDATE ... WHERE` guard makes runtime ownership permanent: an id
179
+ * already held by one agent can never be reassigned to another, so a
180
+ * duplicate id cannot move a live runtime between agents. Returns null in
181
+ * that case (no row written).
182
+ */
183
+ export function upsertRuntimeInstance(instance: {
184
+ id: string;
185
+ agentId: string;
186
+ reportedSlots: number;
187
+ metadata?: Record<string, unknown> | null;
188
+ }): RuntimeInstance | null {
189
+ const metadataJson = instance.metadata ? JSON.stringify(instance.metadata) : null;
190
+ const row = getDb()
191
+ .prepare<RuntimeInstanceRow, [string, string, number, string | null]>(
192
+ `INSERT INTO runtime_instances (id, agent_id, status, reported_slots, metadata)
193
+ VALUES (?, ?, 'active', ?, ?)
194
+ ON CONFLICT(id) DO UPDATE SET
195
+ status = 'active',
196
+ reported_slots = excluded.reported_slots,
197
+ metadata = COALESCE(excluded.metadata, runtime_instances.metadata),
198
+ last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
199
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
200
+ WHERE runtime_instances.agent_id = excluded.agent_id
201
+ RETURNING *`,
202
+ )
203
+ .get(instance.id, instance.agentId, instance.reportedSlots, metadataJson);
204
+ return row ? rowToRuntimeInstance(row) : null;
205
+ }
206
+
207
+ export function getRuntimeInstanceById(id: string): RuntimeInstance | null {
208
+ const row = getDb()
209
+ .prepare<RuntimeInstanceRow, [string]>("SELECT * FROM runtime_instances WHERE id = ?")
210
+ .get(id);
211
+ return row ? rowToRuntimeInstance(row) : null;
212
+ }
213
+
214
+ /**
215
+ * Refresh a live runtime instance's liveness from the worker's ping cadence.
216
+ *
217
+ * Matches only a row that is already live and owned by the caller, so worker
218
+ * traffic can refresh liveness but never resurrect a closed or expired
219
+ * runtime — registration stays the only path back to `active`. Returns false
220
+ * when nothing matched, which callers use as "this identity is not live".
221
+ */
222
+ export function touchRuntimeInstance(id: string, agentId: string): boolean {
223
+ const result = getDb()
224
+ .prepare(
225
+ `UPDATE runtime_instances
226
+ SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
227
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
228
+ WHERE id = ? AND agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
229
+ )
230
+ .run(id, agentId, runtimeLivenessCutoff());
231
+ return result.changes > 0;
232
+ }
233
+
234
+ /** Mark one runtime instance offline (scoped to its agent, like touch). */
235
+ export function markRuntimeInstanceOffline(id: string, agentId: string): RuntimeInstance | null {
236
+ const row = getDb()
237
+ .prepare<RuntimeInstanceRow, [string, string]>(
238
+ `UPDATE runtime_instances
239
+ SET status = 'offline', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
240
+ WHERE id = ? AND agent_id = ? RETURNING *`,
241
+ )
242
+ .get(id, agentId);
243
+ return row ? rowToRuntimeInstance(row) : null;
244
+ }
245
+
246
+ /**
247
+ * Runtimes currently serving an agent. A crashed process never reaches
248
+ * `/close`, so `active` alone would keep a dead runtime counted forever;
249
+ * liveness is therefore the conjunction of status and a fresh `last_seen_at`.
250
+ */
251
+ export function countActiveRuntimeInstancesForAgent(agentId: string): number {
252
+ const result = getDb()
253
+ .prepare<{ count: number }, [string, string]>(
254
+ `SELECT COUNT(*) as count FROM runtime_instances
255
+ WHERE agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
256
+ )
257
+ .get(agentId, runtimeLivenessCutoff());
258
+ return result?.count ?? 0;
259
+ }
260
+
261
+ /**
262
+ * Read-only listing for operator observability. `isLive` applies the same
263
+ * status+freshness conjunction as `isRuntimeInstanceLive`, so a stale row
264
+ * that the sweep has not pruned yet is never reported as live.
265
+ */
266
+ export function listRuntimeInstancesForAgent(
267
+ agentId: string,
268
+ ): Array<RuntimeInstance & { isLive: boolean }> {
269
+ const rows = getDb()
270
+ .prepare<RuntimeInstanceRow & { is_live: number }, [string, string]>(
271
+ `SELECT *, (status = 'active' AND last_seen_at >= ?) AS is_live
272
+ FROM runtime_instances
273
+ WHERE agent_id = ?
274
+ ORDER BY created_at ASC, id ASC`,
275
+ )
276
+ .all(runtimeLivenessCutoff(), agentId);
277
+ return rows.map((row) => ({ ...rowToRuntimeInstance(row), isLive: row.is_live === 1 }));
278
+ }
279
+
280
+ /**
281
+ * Whether this identity names a runtime of `agentId` that is still reporting.
282
+ * Dispatch uses it so a retired process cannot be handed work alongside its
283
+ * replacement; an absent identity is never live.
284
+ */
285
+ export function isRuntimeInstanceLive(id: string | undefined, agentId: string): boolean {
286
+ if (!id) return false;
287
+ const row = getDb()
288
+ .prepare<{ c: number }, [string, string, string]>(
289
+ `SELECT COUNT(*) c FROM runtime_instances
290
+ WHERE id = ? AND agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
291
+ )
292
+ .get(id, agentId, runtimeLivenessCutoff());
293
+ return (row?.c ?? 0) > 0;
294
+ }
295
+
296
+ /**
297
+ * Record this runtime's credential readiness. Process-local by design: a
298
+ * sibling's state is never touched. Only a live runtime may report.
299
+ */
300
+ export function setRuntimeCredentialReady(id: string, agentId: string, ready: boolean): boolean {
301
+ const result = getDb()
302
+ .prepare(
303
+ `UPDATE runtime_instances
304
+ SET credential_ready = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
305
+ WHERE id = ? AND agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
306
+ )
307
+ .run(ready ? 1 : 0, id, agentId, runtimeLivenessCutoff());
308
+ return result.changes > 0;
309
+ }
310
+
311
+ /**
312
+ * Whether any live runtime can currently execute work. A runtime that never
313
+ * reported (NULL) counts as ready, so workers opting out of credential checks
314
+ * stay dispatchable.
315
+ */
316
+ export function hasReadyLiveRuntime(agentId: string): boolean {
317
+ const row = getDb()
318
+ .prepare<{ c: number }, [string, string]>(
319
+ `SELECT COUNT(*) c FROM runtime_instances
320
+ WHERE agent_id = ? AND status = 'active' AND last_seen_at >= ?
321
+ AND (credential_ready IS NULL OR credential_ready = 1)`,
322
+ )
323
+ .get(agentId, runtimeLivenessCutoff());
324
+ return (row?.c ?? 0) > 0;
325
+ }
326
+
327
+ /**
328
+ * Recompute the logical agent's state from its live runtimes.
329
+ *
330
+ * Precedence: no live runtime wins (offline), then active work (busy), then
331
+ * credential availability, then idle. Aggregating here is what stops one
332
+ * process's report from overwriting a sibling's — a waiting runtime cannot
333
+ * park an agent a ready sibling is serving, and a ready one cannot pull a
334
+ * busy agent back to idle.
335
+ */
336
+ export function reconcileAgentStatusFromRuntimes(agentId: string): void {
337
+ const agent = getAgentById(agentId);
338
+ if (!agent) return;
339
+ if (countActiveRuntimeInstancesForAgent(agentId) === 0) {
340
+ if (agent.status !== "offline") updateAgentStatus(agentId, "offline");
341
+ return;
342
+ }
343
+ if (!hasReadyLiveRuntime(agentId)) {
344
+ if (agent.status !== "waiting_for_credentials") {
345
+ updateAgentStatus(agentId, "waiting_for_credentials");
346
+ }
347
+ return;
348
+ }
349
+ const next = getActiveTaskCount(agentId) > 0 ? "busy" : "idle";
350
+ if (agent.status !== next) updateAgentStatus(agentId, next);
351
+ }
352
+
353
+ /**
354
+ * Agents with at least one runtime still reporting. While multi-runtime mode
355
+ * is on this is the eligibility set for dispatch: an agent with no live
356
+ * runtime — whether its runtimes died or it has not re-registered since the
357
+ * flag was enabled — cannot poll for work, so assigning to it would strand
358
+ * the task.
359
+ */
360
+ export function agentsWithLiveRuntime(): Set<string> {
361
+ const rows = getDb()
362
+ .prepare<{ agent_id: string }, [string]>(
363
+ `SELECT DISTINCT agent_id FROM runtime_instances
364
+ WHERE status = 'active' AND last_seen_at >= ?`,
365
+ )
366
+ .all(runtimeLivenessCutoff());
367
+ return new Set(rows.map((r) => r.agent_id));
368
+ }
369
+
370
+ /**
371
+ * Retire runtimes that stopped pinging and take their agents offline when
372
+ * nothing live remains.
373
+ *
374
+ * Inert unless multi-runtime mode is on: after a rollback, workers stop
375
+ * refreshing their rows, and expiring them would offline agents that are
376
+ * running perfectly well under legacy semantics.
377
+ *
378
+ * Deleting the retired rows rather than keeping them is deliberate. Runtime
379
+ * identity is per boot, so retaining them would grow the table once per boot
380
+ * per agent forever; nothing reads a runtime after it stops being live.
381
+ *
382
+ * Expiry deliberately does NOT touch active_sessions. Runtime liveness and
383
+ * in-flight work liveness answer different questions: a stale runtime row is
384
+ * enough to stop NEW dispatch, but sessions are heartbeated by tool activity
385
+ * only — a healthy worker inside a long model call or shell command can be
386
+ * quiet past this cutoff — so it is not evidence the CURRENT work is dead.
387
+ * Crash classification stays owned by the heartbeat's stalled-task
388
+ * remediation, which requires both a stale session heartbeat and a stale
389
+ * task before reclaiming (and cleans the session when it does); the sweep's
390
+ * 30-minute stale-session cleanup backstops any leftover row.
391
+ */
392
+ export function expireStaleRuntimeInstances(): {
393
+ expired: number;
394
+ agentsOffline: number;
395
+ pruned: number;
396
+ } {
397
+ if (!isMultiRuntimeEnabled()) {
398
+ return { expired: 0, agentsOffline: 0, pruned: 0 };
399
+ }
400
+
401
+ return getDb().transaction(() => {
402
+ const cutoff = runtimeLivenessCutoff();
403
+ // Still-active rows past the window never reached /close; retiring them
404
+ // removes the process from new-work eligibility. Whether its in-flight
405
+ // work is dead is decided separately (see doc comment above).
406
+ const stale = getDb()
407
+ .prepare<{ id: string; agent_id: string }, [string]>(
408
+ `SELECT id, agent_id FROM runtime_instances
409
+ WHERE status = 'active' AND last_seen_at < ?`,
410
+ )
411
+ .all(cutoff);
412
+
413
+ // Prune every row past the window, closed ones included. Runtime identity
414
+ // is per boot, so keeping them would add a row per boot per agent forever
415
+ // and nothing reads a runtime once it stops being live.
416
+ const pruned = getDb()
417
+ .prepare("DELETE FROM runtime_instances WHERE last_seen_at < ?")
418
+ .run(cutoff).changes;
419
+
420
+ // Recompute every affected agent from what survived: losing the only
421
+ // ready runtime has to park the agent even though a waiting sibling is
422
+ // still live, or the same sweep would assign work it cannot execute.
423
+ let agentsOffline = 0;
424
+ for (const agentId of new Set(stale.map((r) => r.agent_id))) {
425
+ reconcileAgentStatusFromRuntimes(agentId);
426
+ if (getAgentById(agentId)?.status === "offline") agentsOffline++;
427
+ }
428
+ return { expired: stale.length, agentsOffline, pruned };
429
+ })();
430
+ }
@@ -226,11 +226,17 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
226
226
  args.signatureJson !== existing.signatureJson ||
227
227
  argsJsonSchema !== existing.argsJsonSchema;
228
228
  const promotedFromScratch = existing.isScratch && !isScratch;
229
+ const refreshScratchLastUsed =
230
+ existing.scope === "agent" &&
231
+ existing.name.startsWith("scratch-") &&
232
+ existing.isScratch &&
233
+ isScratch;
229
234
  if (
230
235
  fsMode !== existing.fsMode ||
231
236
  isScratch !== existing.isScratch ||
232
237
  typeChecked !== existing.typeChecked ||
233
- trackedMetadataChanged
238
+ trackedMetadataChanged ||
239
+ refreshScratchLastUsed
234
240
  ) {
235
241
  const row = getDb()
236
242
  .prepare<
@@ -466,6 +472,42 @@ export function deleteScript(args: ScriptIdentity): boolean {
466
472
  return result.changes > 0;
467
473
  }
468
474
 
475
+ /** Bumps updatedAt and returns the timestamp actually written, or null if the row didn't match. */
476
+ export function touchScratchScriptLastUsed(
477
+ id: string,
478
+ at: string = new Date().toISOString(),
479
+ ): string | null {
480
+ const result = getDb()
481
+ .prepare(
482
+ `UPDATE scripts SET updatedAt = ?
483
+ WHERE id = ? AND scope = 'agent' AND isScratch = 1 AND name GLOB 'scratch-*'`,
484
+ )
485
+ .run(at, id);
486
+ return result.changes > 0 ? at : null;
487
+ }
488
+
489
+ /**
490
+ * Rolls back an in-flight run-start touch after the run turns out to have
491
+ * failed, so a failed invocation doesn't extend the retention window the way
492
+ * a successful one does. Only applies if nothing has touched the row since
493
+ * (CAS on updatedAt) — a concurrent run's touch, in-flight or successful,
494
+ * always wins over this rollback.
495
+ */
496
+ export function restoreScratchScriptLastUsedIfUnchanged(
497
+ id: string,
498
+ priorUpdatedAt: string,
499
+ expectedUpdatedAt: string,
500
+ ): boolean {
501
+ return (
502
+ getDb()
503
+ .prepare(
504
+ `UPDATE scripts SET updatedAt = ?
505
+ WHERE id = ? AND scope = 'agent' AND isScratch = 1 AND name GLOB 'scratch-*' AND updatedAt = ?`,
506
+ )
507
+ .run(priorUpdatedAt, id, expectedUpdatedAt).changes > 0
508
+ );
509
+ }
510
+
469
511
  // ─── External script APIs (script_apis) ──────────────────────────────────────
470
512
 
471
513
  type ScriptApiRow = {
@@ -7,10 +7,12 @@
7
7
  * Extraction is best-effort and non-blocking — failures return null.
8
8
  */
9
9
 
10
+ import { fileURLToPath } from "node:url";
11
+
10
12
  const TIMEOUT_MS = 5_000;
11
13
 
12
14
  function extractorPath(): string {
13
- return new URL("../../scripts-runtime/extract-args-schema.ts", import.meta.url).pathname;
15
+ return fileURLToPath(new URL("../../scripts-runtime/extract-args-schema.ts", import.meta.url));
14
16
  }
15
17
 
16
18
  export async function extractArgsJsonSchema(source: string): Promise<string | null> {
@@ -0,0 +1,164 @@
1
+ import { collectScriptReferences } from "../../apps/definition";
2
+ import { listAppRecords } from "../../apps/store";
3
+ import { getDb, listWorkflows } from "../db";
4
+
5
+ const SCRATCH_RETENTION_DAYS = 14;
6
+ const SCRATCH_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+
8
+ let scratchGcTimer: ReturnType<typeof setInterval> | null = null;
9
+
10
+ /**
11
+ * Every script id wired into an app definition, as an action or model source.
12
+ *
13
+ * `candidateIds` are the GC-candidate scratch scripts this call cares about.
14
+ * A broken app (invalid stored JSON) decodes with `definition` set to the raw
15
+ * string itself, not a merge-patch object, so `collectScriptReferences` below
16
+ * finds nothing for it. Same substring-probe fallback the interactive delete
17
+ * guard applies via `appScriptReferenceIssues` in src/http/scripts.ts — a
18
+ * broken app is not consent to break it further.
19
+ */
20
+ function appReferencedScriptIds(candidateIds: readonly string[]): Set<string> {
21
+ const ids = new Set<string>();
22
+ for (const app of listAppRecords()) {
23
+ for (const scriptId of collectScriptReferences(app.definition).keys()) {
24
+ ids.add(scriptId);
25
+ }
26
+ if (app.definitionError === undefined) continue;
27
+ const serialized = JSON.stringify(app.definition ?? null);
28
+ for (const id of candidateIds) {
29
+ if (!ids.has(id) && serialized.includes(id)) ids.add(id);
30
+ }
31
+ }
32
+ return ids;
33
+ }
34
+
35
+ /**
36
+ * Every script id bound to a durable public API endpoint. `script_apis.scriptId`
37
+ * cascade-deletes with the script (`ON DELETE CASCADE`, src/be/migrations/102_script_apis.sql),
38
+ * so an unreferenced-looking scratch script backing a live `/api/x/script` endpoint
39
+ * would otherwise be silently deleted out from under it.
40
+ */
41
+ function scriptApiReferencedScriptIds(): Set<string> {
42
+ const rows = getDb()
43
+ .prepare<{ scriptId: string }, []>("SELECT DISTINCT scriptId FROM script_apis")
44
+ .all();
45
+ return new Set(rows.map((row) => row.scriptId));
46
+ }
47
+
48
+ /**
49
+ * `(name, agentId)` keys an enabled workflow's `swarm-script` node could resolve at
50
+ * agent scope. A node's `config.scriptName` resolves by NAME, not id
51
+ * (src/workflows/executors/swarm-script.ts `resolveScriptSource`), against the
52
+ * workflow's `createdByAgentId` for `scope: "agent"` or omitted scope — so a
53
+ * name/scope match against a workflow's owner is the only static signal available;
54
+ * `scope: "global"` nodes can never resolve an agent-scoped scratch candidate and
55
+ * are skipped.
56
+ */
57
+ function workflowReferencedAgentScriptKeys(): Set<string> {
58
+ const keys = new Set<string>();
59
+ for (const workflow of listWorkflows()) {
60
+ if (!workflow.createdByAgentId) continue;
61
+ for (const node of workflow.definition.nodes) {
62
+ if (node.type !== "swarm-script") continue;
63
+ const scriptName = (node.config as { scriptName?: unknown }).scriptName;
64
+ const scope = (node.config as { scope?: unknown }).scope;
65
+ if (typeof scriptName !== "string" || scope === "global") continue;
66
+ keys.add(`${scriptName}::${workflow.createdByAgentId}`);
67
+ }
68
+ }
69
+ return keys;
70
+ }
71
+
72
+ /**
73
+ * Script names an enabled `swarm-script` node could resolve at agent scope for a
74
+ * workflow with no static owner (`createdByAgentId` null — no `X-Agent-ID` at
75
+ * creation time, or a pre-migration legacy row). `SwarmScriptExecutor` falls back to
76
+ * `trigger.agentId` at run time for these (src/workflows/executors/swarm-script.ts
77
+ * `agentIdFromContext`), so the resolving agent isn't knowable statically — this
78
+ * excludes by NAME alone across every agent scope, which is over-broad (it also
79
+ * protects a same-named scratch script some *other*, unrelated agent owns) but is
80
+ * the only sound static signal available; `scope: "global"` nodes are skipped for
81
+ * the same reason as the owner-keyed variant above.
82
+ */
83
+ function workflowReferencedScratchNamesForOwnerlessWorkflows(): Set<string> {
84
+ const names = new Set<string>();
85
+ for (const workflow of listWorkflows()) {
86
+ if (workflow.createdByAgentId) continue;
87
+ for (const node of workflow.definition.nodes) {
88
+ if (node.type !== "swarm-script") continue;
89
+ const scriptName = (node.config as { scriptName?: unknown }).scriptName;
90
+ const scope = (node.config as { scope?: unknown }).scope;
91
+ if (typeof scriptName !== "string" || scope === "global") continue;
92
+ names.add(scriptName);
93
+ }
94
+ }
95
+ return names;
96
+ }
97
+
98
+ /** Delete auto-saved scratch scripts that have not run within the retention window. */
99
+ export function purgeExpiredScratchScripts(now = new Date()): number {
100
+ const cutoff = new Date(
101
+ now.getTime() - SCRATCH_RETENTION_DAYS * 24 * 60 * 60 * 1000,
102
+ ).toISOString();
103
+ const candidates = getDb()
104
+ .prepare<{ id: string; name: string; scopeId: string | null }, [string]>(
105
+ `SELECT id, name, scopeId FROM scripts
106
+ WHERE scope = 'agent'
107
+ AND isScratch = 1
108
+ AND name GLOB 'scratch-*'
109
+ AND updatedAt < ?`,
110
+ )
111
+ .all(cutoff);
112
+ if (candidates.length === 0) return 0;
113
+
114
+ // A scratch script still wired into a durable reference — an app action/model
115
+ // source, a public API endpoint, or a workflow's swarm-script node — is no
116
+ // longer "scratch" in effect; deleting it leaves that reference dangling. Same
117
+ // guard the interactive scripts-API delete route enforces for app references via
118
+ // appScriptReferenceIssues, applied here to the whole sweep at once, plus the two
119
+ // durable reference kinds that guard doesn't cover either.
120
+ const referenced = appReferencedScriptIds(candidates.map((row) => row.id));
121
+ const apiReferenced = scriptApiReferencedScriptIds();
122
+ const workflowReferenced = workflowReferencedAgentScriptKeys();
123
+ const ownerlessWorkflowReferencedNames = workflowReferencedScratchNamesForOwnerlessWorkflows();
124
+ const idsToDelete = candidates
125
+ .filter((row) => !referenced.has(row.id))
126
+ .filter((row) => !apiReferenced.has(row.id))
127
+ .filter((row) => !(row.scopeId && workflowReferenced.has(`${row.name}::${row.scopeId}`)))
128
+ .filter((row) => !ownerlessWorkflowReferencedNames.has(row.name))
129
+ .map((row) => row.id);
130
+ if (idsToDelete.length === 0) return 0;
131
+
132
+ const placeholders = idsToDelete.map(() => "?").join(",");
133
+ return (
134
+ getDb()
135
+ .prepare<{ id: string }, string[]>(
136
+ `DELETE FROM scripts WHERE id IN (${placeholders}) RETURNING id`,
137
+ )
138
+ // RETURNING counts scripts only; SQLite's change count includes cascaded rows.
139
+ .all(...idsToDelete).length
140
+ );
141
+ }
142
+
143
+ function runScratchScriptGc(label: "Initial" | "Periodic"): void {
144
+ try {
145
+ const purged = purgeExpiredScratchScripts();
146
+ console.log(`[scratch-script-gc] ${label} purge removed ${purged} scratch script row(s)`);
147
+ } catch (err) {
148
+ console.error(`[scratch-script-gc] ${label} purge failed:`, (err as Error).message);
149
+ }
150
+ }
151
+
152
+ /** Start the scratch-script retention GC (daily tick, immediate first run). */
153
+ export function startScratchScriptGc(intervalMs = SCRATCH_GC_INTERVAL_MS): void {
154
+ if (scratchGcTimer) return;
155
+ runScratchScriptGc("Initial");
156
+ scratchGcTimer = setInterval(() => runScratchScriptGc("Periodic"), intervalMs);
157
+ if (typeof scratchGcTimer.unref === "function") scratchGcTimer.unref();
158
+ }
159
+
160
+ export function stopScratchScriptGc(): void {
161
+ if (!scratchGcTimer) return;
162
+ clearInterval(scratchGcTimer);
163
+ scratchGcTimer = null;
164
+ }