@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
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { ensure } from "@desplega.ai/business-use";
3
3
  import { z } from "zod";
4
4
  import {
5
+ computeContentHash,
5
6
  createAgent,
6
7
  deleteSwarmConfigByKey,
7
8
  getAgentById,
@@ -13,6 +14,7 @@ import {
13
14
  resetEmptyPollCount,
14
15
  setAgentHarnessProvider,
15
16
  updateAgentActivity,
17
+ updateAgentCredentialMissing,
16
18
  updateAgentCredentialState,
17
19
  updateAgentCredStatus,
18
20
  updateAgentMaxTasks,
@@ -22,10 +24,21 @@ import {
22
24
  updateAgentStatus,
23
25
  upsertSwarmConfig,
24
26
  } from "../be/db";
27
+ import { createEvent } from "../be/events";
28
+ import {
29
+ getRuntimeInstanceById,
30
+ listRuntimeInstancesForAgent,
31
+ reconcileAgentMaxTasksPolicy,
32
+ reconcileAgentStatusFromRuntimes,
33
+ runtimeStaleThresholdMinutes,
34
+ setRuntimeCredentialReady,
35
+ upsertRuntimeInstance,
36
+ } from "../be/multi-runtime";
25
37
  import { reasoningCapability } from "../providers/reasoning-effort";
26
38
  import { ALL_CAPABILITIES, getEnabledCapabilities } from "../server";
27
39
  import { telemetry } from "../telemetry";
28
40
  import {
41
+ type Agent,
29
42
  AgentAvatarSchema,
30
43
  AgentCredStatusSchema,
31
44
  AgentLatestModelSchema,
@@ -35,11 +48,24 @@ import {
35
48
  type ProviderName,
36
49
  ProviderNameSchema,
37
50
  ReasoningEffortSchema,
51
+ RuntimeInstanceSchema,
38
52
  } from "../types";
39
53
  import { MAX_PROFILE_FILE_LENGTH } from "../utils/constants";
40
- import { route } from "./route-def";
54
+ import {
55
+ type BudgetedIdentityField,
56
+ IDENTITY_FIELD_BUDGETS,
57
+ IdentityFieldBudgetError,
58
+ } from "../utils/identity-field-budget";
59
+ import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
60
+ import { scrubSecrets } from "../utils/secret-scrubber";
61
+ import { route, runtimeInstanceHeader } from "./route-def";
41
62
  import { agentWithCapacity, json, jsonError } from "./utils";
42
63
 
64
+ function singleHeaderValue(req: IncomingMessage, name: string): string | undefined {
65
+ const raw = req.headers[name];
66
+ return Array.isArray(raw) ? raw[0] : raw;
67
+ }
68
+
43
69
  // ─── Route Definitions ───────────────────────────────────────────────────────
44
70
 
45
71
  /** Mirrors `CAPABILITIES_T` (src/server.ts) — the server's registered MCP tool-group flags. */
@@ -103,6 +129,13 @@ const registerAgent = route({
103
129
  * the canonical list — unknown values reject the request with 400.
104
130
  */
105
131
  harness_provider: ProviderNameSchema.optional(),
132
+ /**
133
+ * Per-process runtime identity; `X-Agent-ID` remains the logical agent.
134
+ * Ignored when MULTI_RUNTIME_ENABLED is off, required when it is on —
135
+ * schema-optional so older workers still parse, with the handler
136
+ * enforcing the mode-dependent requirement.
137
+ */
138
+ runtimeInstanceId: z.string().min(1).optional(),
106
139
  }),
107
140
  responses: {
108
141
  200: {
@@ -216,6 +249,41 @@ const getAgentSetupScript = route({
216
249
  },
217
250
  });
218
251
 
252
+ /** Operator view of a runtime instance: internal `metadata` omitted, server-derived `isLive` added. */
253
+ const AgentRuntimeInstanceSchema = RuntimeInstanceSchema.omit({ metadata: true }).extend({
254
+ isLive: z.boolean(),
255
+ });
256
+
257
+ const listAgentRuntimeInstances = route({
258
+ method: "get",
259
+ path: "/api/agents/{id}/runtime-instances",
260
+ pattern: ["api", "agents", null, "runtime-instances"],
261
+ summary: "List runtime instances serving an agent",
262
+ description:
263
+ "Read-only view of the worker processes currently registered for a logical agent. Rows exist only for multi-runtime registrations (MULTI_RUNTIME_ENABLED), so the list is empty in the default configuration. `isLive` combines `status` with `lastSeenAt` freshness against the server's staleness cutoff (`staleThresholdMinutes`); `reportedSlots` is each process's self-reported capacity, distinct from the agent's logical `maxTasks` policy.",
264
+ tags: ["Agents"],
265
+ params: z.object({ id: z.string() }),
266
+ responses: {
267
+ 200: {
268
+ description: "Runtime instances for the agent (empty when none are registered)",
269
+ schema: z.object({
270
+ runtimeInstances: z.array(AgentRuntimeInstanceSchema),
271
+ staleThresholdMinutes: z.number().int(),
272
+ }),
273
+ },
274
+ 404: { description: "Agent not found" },
275
+ },
276
+ });
277
+
278
+ const ProfileSyncRejectionSchema = z.object({
279
+ field: z.enum(["soulMd", "identityMd", "claudeMd", "toolsMd"]),
280
+ diskSize: z.number().int(),
281
+ dbSize: z.number().int(),
282
+ budget: z.number().int(),
283
+ delta: z.number().int(),
284
+ reason: z.string(),
285
+ });
286
+
219
287
  const updateAgentProfileRoute = route({
220
288
  method: "put",
221
289
  path: "/api/agents/{id}/profile",
@@ -227,11 +295,11 @@ const updateAgentProfileRoute = route({
227
295
  role: z.string().max(100).optional(),
228
296
  description: z.string().optional(),
229
297
  capabilities: z.array(z.string()).optional(),
230
- claudeMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
231
- soulMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
232
- identityMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
298
+ claudeMd: z.string().optional(),
299
+ soulMd: z.string().optional(),
300
+ identityMd: z.string().optional(),
233
301
  setupScript: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
234
- toolsMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
302
+ toolsMd: z.string().optional(),
235
303
  heartbeatMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
236
304
  /** `null` resets to the deterministic fallback; omit the key to leave untouched. */
237
305
  avatar: AgentAvatarSchema.nullable().optional(),
@@ -241,7 +309,13 @@ const updateAgentProfileRoute = route({
241
309
  }),
242
310
  responses: {
243
311
  200: { description: "Profile updated", schema: AgentWithCapacitySchema },
244
- 400: { description: "Validation error" },
312
+ 400: {
313
+ description: "Validation or identity-field budget error",
314
+ schema: z.object({
315
+ error: z.string(),
316
+ profileSyncRejection: ProfileSyncRejectionSchema.optional(),
317
+ }),
318
+ },
245
319
  404: { description: "Agent not found" },
246
320
  },
247
321
  });
@@ -301,12 +375,14 @@ const updateAgentCredentialStatusRoute = route({
301
375
  summary: "Worker self-report of credential readiness (Phase 3 boot loop)",
302
376
  tags: ["Agents"],
303
377
  params: z.object({ id: z.string() }),
378
+ headers: runtimeInstanceHeader("report credential readiness"),
304
379
  body: credentialStatusBody,
305
380
  responses: {
306
381
  200: {
307
382
  description: "State updated; returns the agent row.",
308
383
  schema: AgentWithCapacitySchema,
309
384
  },
385
+ 400: { description: "Missing X-Runtime-Instance-ID in multi-runtime mode" },
310
386
  404: { description: "Agent not found" },
311
387
  },
312
388
  });
@@ -354,6 +430,25 @@ export async function handleAgentRegister(
354
430
  if (!parsed) return true;
355
431
 
356
432
  const agentId = myAgentId || crypto.randomUUID();
433
+ // Read once so one mode applies consistently even if a config reload
434
+ // flips the flag mid-request.
435
+ const multiRuntime = isMultiRuntimeEnabled();
436
+
437
+ if (multiRuntime && parsed.body.runtimeInstanceId) {
438
+ // Runtime ownership is permanent: a runtime id already serving another
439
+ // agent must not be moved. `upsertRuntimeInstance` enforces this in SQL
440
+ // too; rejecting here keeps the failure legible and pre-mutation.
441
+ const existingRuntime = getRuntimeInstanceById(parsed.body.runtimeInstanceId);
442
+ if (existingRuntime && existingRuntime.agentId !== agentId) {
443
+ jsonError(res, "runtimeInstanceId is already registered to another agent", 400);
444
+ return true;
445
+ }
446
+ } else if (multiRuntime) {
447
+ // Enforced here rather than in the schema so the field stays optional
448
+ // for workers running against a server with the flag off.
449
+ jsonError(res, "runtimeInstanceId is required when multi-runtime mode is enabled", 400);
450
+ return true;
451
+ }
357
452
 
358
453
  const result = getDb().transaction(() => {
359
454
  const existingAgent = getAgentById(agentId);
@@ -361,7 +456,15 @@ export async function handleAgentRegister(
361
456
  if (existingAgent.status === "offline") {
362
457
  updateAgentStatus(existingAgent.id, "idle");
363
458
  }
364
- if (parsed.body.maxTasks !== undefined && parsed.body.maxTasks !== existingAgent.maxTasks) {
459
+ if (multiRuntime) {
460
+ // body.maxTasks is this runtime's own capacity, recorded below; it
461
+ // must not redefine the logical policy, or two runtimes serving one
462
+ // agent would race to overwrite it.
463
+ reconcileAgentMaxTasksPolicy(existingAgent.id);
464
+ } else if (
465
+ parsed.body.maxTasks !== undefined &&
466
+ parsed.body.maxTasks !== existingAgent.maxTasks
467
+ ) {
365
468
  updateAgentMaxTasks(existingAgent.id, parsed.body.maxTasks);
366
469
  }
367
470
  if (parsed.body.provider && parsed.body.provider !== existingAgent.provider) {
@@ -379,6 +482,16 @@ export async function handleAgentRegister(
379
482
  setAgentHarnessProvider(existingAgent.id, parsed.body.harness_provider);
380
483
  }
381
484
  resetEmptyPollCount(existingAgent.id);
485
+ if (multiRuntime && parsed.body.runtimeInstanceId) {
486
+ upsertRuntimeInstance({
487
+ id: parsed.body.runtimeInstanceId,
488
+ agentId: existingAgent.id,
489
+ reportedSlots: parsed.body.maxTasks ?? 1,
490
+ });
491
+ // A new live runtime can lift the agent out of waiting without
492
+ // forcing idle over work another runtime is already doing.
493
+ reconcileAgentStatusFromRuntimes(existingAgent.id);
494
+ }
382
495
  return { agent: getAgentById(agentId), created: false };
383
496
  }
384
497
 
@@ -390,11 +503,29 @@ export async function handleAgentRegister(
390
503
  description: parsed.body.description,
391
504
  role: parsed.body.role,
392
505
  capabilities: parsed.body.capabilities ?? [],
506
+ // The first registration establishes the logical policy — including
507
+ // role defaults such as a lead's two concurrent tasks. Later
508
+ // registrations cannot change it; reconcile below only seeds.
393
509
  maxTasks: parsed.body.maxTasks ?? 1,
394
510
  provider: parsed.body.provider,
395
511
  harnessProvider: parsed.body.harness_provider ?? null,
396
512
  });
397
513
 
514
+ if (multiRuntime) {
515
+ // Re-fetch below: this may adopt a policy row an operator wrote
516
+ // before the agent existed.
517
+ reconcileAgentMaxTasksPolicy(agent.id);
518
+ if (parsed.body.runtimeInstanceId) {
519
+ upsertRuntimeInstance({
520
+ id: parsed.body.runtimeInstanceId,
521
+ agentId: agent.id,
522
+ reportedSlots: parsed.body.maxTasks ?? 1,
523
+ });
524
+ reconcileAgentStatusFromRuntimes(agent.id);
525
+ }
526
+ return { agent: getAgentById(agent.id), created: true };
527
+ }
528
+
398
529
  return { agent, created: true };
399
530
  })();
400
531
 
@@ -506,6 +637,22 @@ export async function handleAgentsRest(
506
637
  return true;
507
638
  }
508
639
 
640
+ if (listAgentRuntimeInstances.match(req.method, pathSegments)) {
641
+ const parsed = await listAgentRuntimeInstances.parse(req, res, pathSegments, queryParams);
642
+ if (!parsed) return true;
643
+ if (!getAgentById(parsed.params.id)) {
644
+ jsonError(res, "Agent not found", 404);
645
+ return true;
646
+ }
647
+ listAgentRuntimeInstances.respond(res, 200, {
648
+ runtimeInstances: listRuntimeInstancesForAgent(parsed.params.id).map(
649
+ ({ metadata: _metadata, ...instance }) => instance,
650
+ ),
651
+ staleThresholdMinutes: runtimeStaleThresholdMinutes(),
652
+ });
653
+ return true;
654
+ }
655
+
509
656
  if (updateAgentProfileRoute.match(req.method, pathSegments)) {
510
657
  const parsed = await updateAgentProfileRoute.parse(req, res, pathSegments, queryParams);
511
658
  if (!parsed) return true;
@@ -545,30 +692,92 @@ export async function handleAgentsRest(
545
692
  }
546
693
  : undefined;
547
694
 
548
- const agent = updateAgentProfile(
549
- parsed.params.id,
550
- {
551
- role: body.role,
552
- description: body.description,
553
- capabilities: body.capabilities,
554
- claudeMd: body.claudeMd,
555
- soulMd: body.soulMd,
556
- identityMd: body.identityMd,
557
- setupScript: body.setupScript,
558
- toolsMd: body.toolsMd,
559
- heartbeatMd: body.heartbeatMd,
560
- // Only include the key when the client sent it, so `null` (reset)
561
- // is distinguishable from "not provided" (leave untouched).
562
- ...(body.avatar !== undefined ? { avatar: body.avatar } : {}),
563
- },
564
- versionMeta,
565
- );
695
+ let agent: Agent | null;
696
+ try {
697
+ agent = updateAgentProfile(
698
+ parsed.params.id,
699
+ {
700
+ role: body.role,
701
+ description: body.description,
702
+ capabilities: body.capabilities,
703
+ claudeMd: body.claudeMd,
704
+ soulMd: body.soulMd,
705
+ identityMd: body.identityMd,
706
+ setupScript: body.setupScript,
707
+ toolsMd: body.toolsMd,
708
+ heartbeatMd: body.heartbeatMd,
709
+ // Only include the key when the client sent it, so `null` (reset)
710
+ // is distinguishable from "not provided" (leave untouched).
711
+ ...(body.avatar !== undefined ? { avatar: body.avatar } : {}),
712
+ },
713
+ versionMeta,
714
+ );
715
+ } catch (error) {
716
+ if (error instanceof IdentityFieldBudgetError) {
717
+ if (
718
+ versionMeta?.changeSource === "self_edit" ||
719
+ versionMeta?.changeSource === "session_sync"
720
+ ) {
721
+ try {
722
+ createEvent({
723
+ category: "system",
724
+ event: "system.profile_sync_rejected",
725
+ status: "error",
726
+ source: "api",
727
+ agentId: parsed.params.id,
728
+ data: {
729
+ ...error.rejection,
730
+ dbHash: error.dbHash,
731
+ diskHash: error.diskHash,
732
+ changeSource: versionMeta.changeSource,
733
+ },
734
+ });
735
+ } catch (eventError) {
736
+ const message = eventError instanceof Error ? eventError.message : String(eventError);
737
+ console.error(
738
+ scrubSecrets(`[profile-sync] Failed to persist budget rejection event: ${message}`),
739
+ );
740
+ }
741
+ }
742
+ updateAgentProfileRoute.respond(res, 400, {
743
+ error: error.message,
744
+ profileSyncRejection: error.rejection,
745
+ });
746
+ return true;
747
+ }
748
+ throw error;
749
+ }
566
750
 
567
751
  if (!agent) {
568
752
  jsonError(res, "Agent not found", 404);
569
753
  return true;
570
754
  }
571
755
 
756
+ if (versionMeta?.changeSource === "self_edit" || versionMeta?.changeSource === "session_sync") {
757
+ try {
758
+ for (const field of Object.keys(IDENTITY_FIELD_BUDGETS) as BudgetedIdentityField[]) {
759
+ if (body[field] === undefined) continue;
760
+ createEvent({
761
+ category: "system",
762
+ event: "system.profile_sync_reconciled",
763
+ status: "ok",
764
+ source: "api",
765
+ agentId: parsed.params.id,
766
+ data: {
767
+ field,
768
+ dbHash: computeContentHash(agent[field] ?? ""),
769
+ changeSource: versionMeta.changeSource,
770
+ },
771
+ });
772
+ }
773
+ } catch (eventError) {
774
+ const message = eventError instanceof Error ? eventError.message : String(eventError);
775
+ console.error(
776
+ scrubSecrets(`[profile-sync] Failed to persist reconciliation event: ${message}`),
777
+ );
778
+ }
779
+ }
780
+
572
781
  updateAgentProfileRoute.respond(res, 200, agentWithCapacity(agent));
573
782
  return true;
574
783
  }
@@ -730,14 +939,36 @@ export async function handleAgentsRest(
730
939
  jsonError(res, "Agent not found", 404);
731
940
  return true;
732
941
  }
733
- const agent =
734
- parsed.body.ready !== undefined
735
- ? (updateAgentCredentialState(
942
+ // Credential readiness is process-local. With several runtimes serving one
943
+ // agent, writing it straight onto the shared row lets the last reporter win
944
+ // — so the report is stored on its own runtime and the logical state is
945
+ // recomputed from all live runtimes.
946
+ const runtimeInstanceId = singleHeaderValue(req, "x-runtime-instance-id");
947
+ const multiRuntime = isMultiRuntimeEnabled();
948
+ // Only the readiness write is process-scoped; model/status metadata on this
949
+ // same endpoint stays agent-level and needs no runtime identity.
950
+ if (multiRuntime && parsed.body.ready !== undefined && !runtimeInstanceId) {
951
+ jsonError(res, "X-Runtime-Instance-ID is required when multi-runtime mode is enabled", 400);
952
+ return true;
953
+ }
954
+
955
+ let agent = existing;
956
+ if (parsed.body.ready !== undefined) {
957
+ if (multiRuntime && runtimeInstanceId) {
958
+ if (setRuntimeCredentialReady(runtimeInstanceId, parsed.params.id, parsed.body.ready)) {
959
+ updateAgentCredentialMissing(parsed.params.id, parsed.body.missing ?? null);
960
+ reconcileAgentStatusFromRuntimes(parsed.params.id);
961
+ }
962
+ agent = getAgentById(parsed.params.id) ?? existing;
963
+ } else {
964
+ agent =
965
+ updateAgentCredentialState(
736
966
  parsed.params.id,
737
967
  parsed.body.ready,
738
968
  parsed.body.missing ?? null,
739
- ) ?? existing)
740
- : existing;
969
+ ) ?? existing;
970
+ }
971
+ }
741
972
  if (!agent) {
742
973
  jsonError(res, "Agent not found", 404);
743
974
  return true;
@@ -17,6 +17,7 @@ import "./budgets";
17
17
  import "./codex-oauth-keep-warm";
18
18
  import "./config";
19
19
  import "./context";
20
+ import "./core";
20
21
  import "./db-query";
21
22
  import "./ecosystem";
22
23
 
@@ -8,8 +8,12 @@ import {
8
8
  getSwarmConfigLookupById,
9
9
  getSwarmConfigs,
10
10
  maskSecrets,
11
- upsertSwarmConfig,
12
11
  } from "../be/db";
12
+ import {
13
+ AGENT_MAX_TASKS_CONFIG_KEY,
14
+ resetAgentMaxTasksMirror,
15
+ upsertSwarmConfigWithPolicyMirror,
16
+ } from "../be/multi-runtime";
13
17
  import { getUserGrant } from "../be/rbac-roles";
14
18
  import {
15
19
  isReservedConfigKey,
@@ -394,7 +398,7 @@ export async function handleConfig(
394
398
 
395
399
  try {
396
400
  const includeSecrets = queryParams.get("includeSecrets") === "true";
397
- const config = upsertSwarmConfig({
401
+ const config = upsertSwarmConfigWithPolicyMirror({
398
402
  scope,
399
403
  scopeId: scopeId || null,
400
404
  key,
@@ -435,6 +439,13 @@ export async function handleConfig(
435
439
  if (existing.scope === "global") {
436
440
  scheduleIntegrationsReload();
437
441
  }
442
+ if (
443
+ existing.scope === "agent" &&
444
+ existing.scopeId &&
445
+ existing.key === AGENT_MAX_TASKS_CONFIG_KEY
446
+ ) {
447
+ resetAgentMaxTasksMirror(existing.scopeId);
448
+ }
438
449
  deleteConfig.respond(res, 200, { success: true });
439
450
  return true;
440
451
  }
package/src/http/core.ts CHANGED
@@ -10,6 +10,11 @@ import {
10
10
  shouldBlockPolling,
11
11
  updateAgentStatus,
12
12
  } from "../be/db";
13
+ import {
14
+ markRuntimeInstanceOffline,
15
+ reconcileAgentStatusFromRuntimes,
16
+ touchRuntimeInstance,
17
+ } from "../be/multi-runtime";
13
18
  import { enqueueAdmissionRow } from "../be/rbac-audit";
14
19
  import { getUserGrant } from "../be/rbac-roles";
15
20
  import { initGitHub, resetGitHub } from "../github";
@@ -19,11 +24,12 @@ import { initLinear, resetLinear } from "../linear";
19
24
  import { decideAdmission, isRbacEnabled } from "../rbac";
20
25
  import { startSlackApp, stopSlackApp } from "../slack";
21
26
  import type { AgentStatus } from "../types";
27
+ import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
22
28
  import { setRequestAuth } from "../utils/request-auth-context";
23
29
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
24
30
  import { resolveHttpRequestAuth } from "./auth";
25
31
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
26
- import { findRoute, isPublicRoute } from "./route-def";
32
+ import { findRoute, isPublicRoute, route, runtimeInstanceHeader } from "./route-def";
27
33
  import { agentWithCapacity, getPathSegments, jsonError, parseQueryParams } from "./utils";
28
34
 
29
35
  /**
@@ -273,6 +279,58 @@ export function _resetAutoReloadForTests(): void {
273
279
  autoReloadInvocations = 0;
274
280
  }
275
281
 
282
+ function singleHeader(req: IncomingMessage, name: string): string | undefined {
283
+ const raw = req.headers[name];
284
+ return Array.isArray(raw) ? raw[0] : raw;
285
+ }
286
+
287
+ const RUNTIME_HEADER_DOC =
288
+ "Workers may send `X-Runtime-Instance-ID`, the per-boot identifier of the calling process. " +
289
+ "It is ignored unless MULTI_RUNTIME_ENABLED is set.";
290
+
291
+ const pingRoute = route({
292
+ method: "post",
293
+ path: "/ping",
294
+ pattern: ["ping"],
295
+ summary: "Report agent liveness",
296
+ description:
297
+ `Refreshes the calling agent's status. ${RUNTIME_HEADER_DOC} With multi-runtime mode on, ` +
298
+ "the header must identify a live runtime of this agent; an absent, unknown, offline, or " +
299
+ "foreign identifier makes the call a no-op instead of an error, so workers predating the " +
300
+ "flag keep running.",
301
+ tags: ["Core"],
302
+ auth: { apiKey: true, agentId: true },
303
+ headers: runtimeInstanceHeader("refresh a runtime's liveness"),
304
+ rbac: { ungated: "self-scoped: an agent reports its own liveness" },
305
+ responses: {
306
+ 204: { description: "Liveness recorded (or accepted as a no-op)" },
307
+ 400: { description: "Missing X-Agent-ID header" },
308
+ 404: { description: "Agent not found" },
309
+ },
310
+ });
311
+
312
+ const closeRoute = route({
313
+ method: "post",
314
+ path: "/close",
315
+ pattern: ["close"],
316
+ summary: "Mark an agent or runtime offline on shutdown",
317
+ description:
318
+ `Retires the calling process. ${RUNTIME_HEADER_DOC} With multi-runtime mode on, the header ` +
319
+ "is required and only that runtime is retired; the agent goes offline once no live runtime " +
320
+ "remains. With the flag off, the agent is marked offline as before.",
321
+ tags: ["Core"],
322
+ auth: { apiKey: true, agentId: true },
323
+ headers: runtimeInstanceHeader("retire a runtime"),
324
+ rbac: { ungated: "self-scoped: an agent retires its own runtime" },
325
+ responses: {
326
+ 204: { description: "Runtime (and agent, when last) marked offline" },
327
+ 400: {
328
+ description: "Missing X-Agent-ID, or missing X-Runtime-Instance-ID in multi-runtime mode",
329
+ },
330
+ 404: { description: "Agent not found" },
331
+ },
332
+ });
333
+
276
334
  export async function handleCore(
277
335
  req: IncomingMessage,
278
336
  res: ServerResponse,
@@ -473,13 +531,16 @@ export async function handleCore(
473
531
  return true;
474
532
  }
475
533
 
476
- if (req.method === "POST" && req.url === "/ping") {
534
+ if (pingRoute.match(req.method, getPathSegments(req.url || ""))) {
477
535
  if (!myAgentId) {
478
536
  res.writeHead(400, { "Content-Type": "application/json" });
479
537
  res.end(JSON.stringify({ error: "Missing X-Agent-ID header" }));
480
538
  return true;
481
539
  }
482
540
 
541
+ const runtimeInstanceId = singleHeader(req, "x-runtime-instance-id");
542
+ const multiRuntime = isMultiRuntimeEnabled();
543
+
483
544
  const tx = getDb().transaction(() => {
484
545
  const agent = getAgentById(myAgentId);
485
546
 
@@ -489,6 +550,18 @@ export async function handleCore(
489
550
  return false;
490
551
  }
491
552
 
553
+ if (multiRuntime) {
554
+ // Prove the identity before touching the agent: the status ladder
555
+ // below resolves `offline` to `idle`, so an anonymous, unknown, or
556
+ // already-closed runtime could otherwise revive an agent whose
557
+ // runtimes have all exited. Failing that check is a no-op rather
558
+ // than an error — ping is non-destructive, and rejecting it would
559
+ // break workers that predate the flag.
560
+ if (!runtimeInstanceId || !touchRuntimeInstance(runtimeInstanceId, agent.id)) {
561
+ return true;
562
+ }
563
+ }
564
+
492
565
  let status: AgentStatus = "idle";
493
566
 
494
567
  if (agent.status === "busy") {
@@ -514,13 +587,29 @@ export async function handleCore(
514
587
  return true;
515
588
  }
516
589
 
517
- if (req.method === "POST" && req.url === "/close") {
590
+ if (closeRoute.match(req.method, getPathSegments(req.url || ""))) {
518
591
  if (!myAgentId) {
519
592
  res.writeHead(400, { "Content-Type": "application/json" });
520
593
  res.end(JSON.stringify({ error: "Missing X-Agent-ID header" }));
521
594
  return true;
522
595
  }
523
596
 
597
+ const runtimeInstanceId = singleHeader(req, "x-runtime-instance-id");
598
+ const multiRuntime = isMultiRuntimeEnabled();
599
+
600
+ // Unlike ping, close is destructive, so it fails closed: an anonymous
601
+ // close would fall through to the legacy agent-wide close and take down
602
+ // an agent a sibling runtime is still serving.
603
+ if (multiRuntime && !runtimeInstanceId) {
604
+ res.writeHead(400, { "Content-Type": "application/json" });
605
+ res.end(
606
+ JSON.stringify({
607
+ error: "X-Runtime-Instance-ID is required when multi-runtime mode is enabled",
608
+ }),
609
+ );
610
+ return true;
611
+ }
612
+
524
613
  const tx = getDb().transaction(() => {
525
614
  const agent = getAgentById(myAgentId);
526
615
 
@@ -530,7 +619,16 @@ export async function handleCore(
530
619
  return false;
531
620
  }
532
621
 
533
- updateAgentStatus(agent.id, "offline");
622
+ if (multiRuntime && runtimeInstanceId) {
623
+ // One process exiting retires only its own runtime; the logical state
624
+ // is then recomputed from whatever is left — offline when nothing is,
625
+ // otherwise reflecting the surviving runtimes' readiness and work.
626
+ markRuntimeInstanceOffline(runtimeInstanceId, agent.id);
627
+ reconcileAgentStatusFromRuntimes(agent.id);
628
+ } else {
629
+ // Legacy semantics, with or without a runtime header.
630
+ updateAgentStatus(agent.id, "offline");
631
+ }
534
632
 
535
633
  return true;
536
634
  });