@wardby/cli 0.5.3 → 0.6.0

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 (370) hide show
  1. package/.env.example +33 -0
  2. package/README.md +25 -14
  3. package/dist/claude-coding-worker/context.d.ts +22 -0
  4. package/dist/claude-coding-worker/context.js +92 -0
  5. package/dist/claude-coding-worker/driver.d.ts +7 -1
  6. package/dist/claude-coding-worker/driver.js +38 -5
  7. package/dist/claude-coding-worker/sdk.js +13 -4
  8. package/dist/cli-help.d.ts +1 -1
  9. package/dist/cli-help.js +5 -2
  10. package/dist/cli-native-mode.d.ts +9 -0
  11. package/dist/cli-native-mode.js +20 -0
  12. package/dist/cli.d.ts +3 -1
  13. package/dist/cli.js +141 -5
  14. package/dist/coding/claude-context.d.ts +45 -0
  15. package/dist/coding/claude-context.js +434 -0
  16. package/dist/coding/profile.d.ts +12 -0
  17. package/dist/coding/profile.js +6 -0
  18. package/dist/coding/protocol.d.ts +104 -0
  19. package/dist/coding/protocol.js +87 -3
  20. package/dist/coding-worker/artifact.d.ts +8 -0
  21. package/dist/coding-worker/artifact.js +2 -2
  22. package/dist/coding-worker/driver.js +2 -0
  23. package/dist/coding-worker/sdk.d.ts +6 -0
  24. package/dist/coding-worker/sdk.js +1 -0
  25. package/dist/coding-worker/skills.d.ts +23 -0
  26. package/dist/coding-worker/skills.js +117 -0
  27. package/dist/coding-worker/types.d.ts +2 -0
  28. package/dist/config/github-allowed-accounts.d.ts +15 -0
  29. package/dist/config/github-allowed-accounts.js +36 -0
  30. package/dist/config/low-power.d.ts +12 -0
  31. package/dist/config/low-power.js +18 -0
  32. package/dist/config/mobile.d.ts +12 -0
  33. package/dist/config/mobile.js +18 -0
  34. package/dist/config/providers.d.ts +53 -0
  35. package/dist/config/providers.js +92 -6
  36. package/dist/core/db.d.ts +16 -6
  37. package/dist/core/db.js +12 -10
  38. package/dist/core/dispatch.d.ts +11 -0
  39. package/dist/core/dispatch.js +36 -1
  40. package/dist/core/engine-native.js +9 -5
  41. package/dist/core/host-events.d.ts +49 -3
  42. package/dist/core/host-events.js +289 -15
  43. package/dist/core/issue-bridge.js +42 -2
  44. package/dist/core/issue-events.js +12 -1
  45. package/dist/core/issue-status.js +14 -3
  46. package/dist/core/merge-order-check.d.ts +129 -0
  47. package/dist/core/merge-order-check.js +336 -0
  48. package/dist/core/notification-dispatcher.d.ts +70 -0
  49. package/dist/core/notification-dispatcher.js +368 -0
  50. package/dist/core/notification-templates.d.ts +25 -0
  51. package/dist/core/notification-templates.js +105 -0
  52. package/dist/core/notifications.d.ts +10 -0
  53. package/dist/core/notifications.js +38 -0
  54. package/dist/core/pg-auth.d.ts +22 -0
  55. package/dist/core/pg-auth.js +152 -0
  56. package/dist/core/power-composition.d.ts +16 -0
  57. package/dist/core/power-composition.js +37 -0
  58. package/dist/core/power-idle.d.ts +14 -0
  59. package/dist/core/power-idle.js +30 -0
  60. package/dist/core/power.d.ts +71 -0
  61. package/dist/core/power.js +202 -0
  62. package/dist/core/reconciler.d.ts +4 -2
  63. package/dist/core/reconciler.js +29 -5
  64. package/dist/core/related-pull-requests.d.ts +48 -4
  65. package/dist/core/related-pull-requests.js +113 -11
  66. package/dist/core/repo-access.d.ts +15 -0
  67. package/dist/core/repo-access.js +66 -0
  68. package/dist/core/review-fix.d.ts +37 -0
  69. package/dist/core/review-fix.js +207 -6
  70. package/dist/core/review-host-tools.d.ts +20 -0
  71. package/dist/core/review-host-tools.js +58 -2
  72. package/dist/core/runner.d.ts +336 -3
  73. package/dist/core/runner.js +894 -317
  74. package/dist/core/secret-broker-config.d.ts +103 -0
  75. package/dist/core/secret-broker-config.js +112 -0
  76. package/dist/core/secrets.d.ts +39 -4
  77. package/dist/core/secrets.js +148 -40
  78. package/dist/core/workflow-event-recorder.d.ts +14 -0
  79. package/dist/core/workflow-event-recorder.js +120 -0
  80. package/dist/core/workflow-events.d.ts +82 -0
  81. package/dist/core/workflow-events.js +44 -0
  82. package/dist/core/workflow-run-events.d.ts +16 -0
  83. package/dist/core/workflow-run-events.js +40 -0
  84. package/dist/generated/prisma/browser.d.ts +58 -0
  85. package/dist/generated/prisma/client.d.ts +58 -0
  86. package/dist/generated/prisma/commonInputTypes.d.ts +150 -0
  87. package/dist/generated/prisma/enums.d.ts +27 -0
  88. package/dist/generated/prisma/enums.js +23 -0
  89. package/dist/generated/prisma/internal/class.d.ts +88 -0
  90. package/dist/generated/prisma/internal/class.js +4 -4
  91. package/dist/generated/prisma/internal/prismaNamespace.d.ts +772 -1
  92. package/dist/generated/prisma/internal/prismaNamespace.js +135 -4
  93. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +139 -0
  94. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -4
  95. package/dist/generated/prisma/models/Agent.d.ts +360 -1
  96. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +73 -1
  97. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  98. package/dist/generated/prisma/models/DeferredReview.d.ts +78 -1
  99. package/dist/generated/prisma/models/NativeGatewayCall.d.ts +1675 -0
  100. package/dist/generated/prisma/models/NativeGatewayCall.js +1 -0
  101. package/dist/generated/prisma/models/NativeGatewaySession.d.ts +1517 -0
  102. package/dist/generated/prisma/models/NativeGatewaySession.js +1 -0
  103. package/dist/generated/prisma/models/NativeWarmWorker.d.ts +1296 -0
  104. package/dist/generated/prisma/models/NativeWarmWorker.js +1 -0
  105. package/dist/generated/prisma/models/NotificationChannel.d.ts +1543 -0
  106. package/dist/generated/prisma/models/NotificationChannel.js +1 -0
  107. package/dist/generated/prisma/models/NotificationDelivery.d.ts +1502 -0
  108. package/dist/generated/prisma/models/NotificationDelivery.js +1 -0
  109. package/dist/generated/prisma/models/NotificationThread.d.ts +1090 -0
  110. package/dist/generated/prisma/models/NotificationThread.js +1 -0
  111. package/dist/generated/prisma/models/Run.d.ts +826 -1
  112. package/dist/generated/prisma/models/RunHostCheck.d.ts +36 -1
  113. package/dist/generated/prisma/models/Secret.d.ts +37 -1
  114. package/dist/generated/prisma/models/SecretBrokerChange.d.ts +1101 -0
  115. package/dist/generated/prisma/models/SecretBrokerChange.js +1 -0
  116. package/dist/generated/prisma/models/WorkflowEvent.d.ts +1422 -0
  117. package/dist/generated/prisma/models/WorkflowEvent.js +1 -0
  118. package/dist/generated/prisma/models.d.ts +8 -0
  119. package/dist/help/build.js +4 -2
  120. package/dist/help/catalog.d.ts +1 -1
  121. package/dist/help/catalog.js +31 -6
  122. package/dist/help-index.json +1363 -176
  123. package/dist/import/create.js +17 -0
  124. package/dist/import/neutral-schema.d.ts +6 -2
  125. package/dist/import/neutral-schema.js +2 -0
  126. package/dist/mcp/auth/access.d.ts +1 -0
  127. package/dist/mcp/auth/repo-authorization.js +14 -0
  128. package/dist/mcp/auth/resource-server.d.ts +2 -1
  129. package/dist/mcp/auth/resource-server.js +7 -2
  130. package/dist/mcp/auth/self-hosted/browser.d.ts +1 -1
  131. package/dist/mcp/auth/self-hosted/browser.js +19 -6
  132. package/dist/mcp/context.d.ts +6 -0
  133. package/dist/mcp/host-events/github-ingress.d.ts +23 -1
  134. package/dist/mcp/host-events/github-ingress.js +68 -0
  135. package/dist/mcp/index.d.ts +13 -0
  136. package/dist/mcp/index.js +59 -10
  137. package/dist/mcp/tools/agents.js +82 -11
  138. package/dist/mcp/tools/notification-channels.d.ts +2 -0
  139. package/dist/mcp/tools/notification-channels.js +293 -0
  140. package/dist/mcp/tools/power.d.ts +2 -0
  141. package/dist/mcp/tools/power.js +77 -0
  142. package/dist/mcp/tools/runs.d.ts +0 -6
  143. package/dist/mcp/tools/runs.js +7 -2
  144. package/dist/mcp/tools/secret-broker-compat.d.ts +12 -0
  145. package/dist/mcp/tools/secret-broker-compat.js +23 -0
  146. package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -2
  147. package/dist/mcp/tools/secret-elicitation-form.js +200 -10
  148. package/dist/mcp/tools/secret-elicitation-server.js +8 -1
  149. package/dist/mcp/tools/secret-elicitation.d.ts +38 -3
  150. package/dist/mcp/tools/secret-elicitation.js +110 -12
  151. package/dist/mcp/tools/secrets.js +184 -7
  152. package/dist/mcp/tools/tools.d.ts +1 -1
  153. package/dist/mcp/tools/tools.js +22 -2
  154. package/dist/native-worker/composition.d.ts +23 -0
  155. package/dist/native-worker/composition.js +77 -0
  156. package/dist/native-worker/docker-isolation.d.ts +65 -0
  157. package/dist/native-worker/docker-isolation.js +183 -0
  158. package/dist/native-worker/docker-launcher.d.ts +74 -0
  159. package/dist/native-worker/docker-launcher.js +237 -0
  160. package/dist/native-worker/gateway-server.d.ts +39 -0
  161. package/dist/native-worker/gateway-server.js +315 -0
  162. package/dist/native-worker/gateway.d.ts +44 -0
  163. package/dist/native-worker/gateway.js +197 -0
  164. package/dist/native-worker/http-server.d.ts +22 -0
  165. package/dist/native-worker/http-server.js +109 -0
  166. package/dist/native-worker/http-transport.d.ts +17 -0
  167. package/dist/native-worker/http-transport.js +88 -0
  168. package/dist/native-worker/kubernetes-isolation.d.ts +90 -0
  169. package/dist/native-worker/kubernetes-isolation.js +218 -0
  170. package/dist/native-worker/kubernetes-launcher.d.ts +102 -0
  171. package/dist/native-worker/kubernetes-launcher.js +335 -0
  172. package/dist/native-worker/launch.d.ts +45 -0
  173. package/dist/native-worker/launch.js +44 -0
  174. package/dist/native-worker/ledger.d.ts +109 -0
  175. package/dist/native-worker/ledger.js +192 -0
  176. package/dist/native-worker/loopback.d.ts +12 -0
  177. package/dist/native-worker/loopback.js +49 -0
  178. package/dist/native-worker/main.d.ts +7 -0
  179. package/dist/native-worker/main.js +74 -0
  180. package/dist/native-worker/protocol.d.ts +744 -0
  181. package/dist/native-worker/protocol.js +200 -0
  182. package/dist/native-worker/sandbox-executor.d.ts +68 -0
  183. package/dist/native-worker/sandbox-executor.js +210 -0
  184. package/dist/native-worker/stdio.d.ts +29 -0
  185. package/dist/native-worker/stdio.js +188 -0
  186. package/dist/native-worker/warm-delivery.d.ts +16 -0
  187. package/dist/native-worker/warm-delivery.js +33 -0
  188. package/dist/native-worker/warm-pool-ledger.d.ts +39 -0
  189. package/dist/native-worker/warm-pool-ledger.js +73 -0
  190. package/dist/native-worker/warm-pool.d.ts +107 -0
  191. package/dist/native-worker/warm-pool.js +283 -0
  192. package/dist/native-worker/worker.d.ts +17 -0
  193. package/dist/native-worker/worker.js +115 -0
  194. package/dist/providers/auth/authorization-server.d.ts +3 -0
  195. package/dist/providers/auth/index.js +2 -0
  196. package/dist/providers/auth/self-hosted.d.ts +13 -1
  197. package/dist/providers/auth/self-hosted.js +54 -4
  198. package/dist/providers/chat/fake.d.ts +46 -0
  199. package/dist/providers/chat/fake.js +39 -0
  200. package/dist/providers/chat/index.d.ts +4 -0
  201. package/dist/providers/chat/index.js +8 -0
  202. package/dist/providers/chat/slack.d.ts +37 -0
  203. package/dist/providers/chat/slack.js +105 -0
  204. package/dist/providers/chat/types.d.ts +42 -0
  205. package/dist/providers/chat/types.js +19 -0
  206. package/dist/providers/engine/types.d.ts +19 -0
  207. package/dist/providers/engine/types.js +22 -0
  208. package/dist/providers/executor/composition.d.ts +24 -1
  209. package/dist/providers/executor/composition.js +39 -6
  210. package/dist/providers/executor/container.d.ts +24 -1
  211. package/dist/providers/executor/container.js +101 -11
  212. package/dist/providers/executor/dbos.d.ts +42 -0
  213. package/dist/providers/executor/dbos.js +118 -12
  214. package/dist/providers/executor/deferred.d.ts +56 -0
  215. package/dist/providers/executor/deferred.js +119 -0
  216. package/dist/providers/executor/routing.d.ts +37 -10
  217. package/dist/providers/executor/routing.js +73 -32
  218. package/dist/providers/executor/types.d.ts +12 -0
  219. package/dist/providers/jobs/docker.js +3 -3
  220. package/dist/providers/jobs/fake-kubernetes-api.d.ts +21 -2
  221. package/dist/providers/jobs/fake-kubernetes-api.js +42 -1
  222. package/dist/providers/jobs/kubernetes-api.d.ts +17 -1
  223. package/dist/providers/jobs/kubernetes-api.js +7 -0
  224. package/dist/providers/jobs/kubernetes-client.d.ts +10 -1
  225. package/dist/providers/jobs/kubernetes-client.js +52 -3
  226. package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +6 -1
  227. package/dist/providers/jobs/kubernetes-dry-run-fixture.js +6 -3
  228. package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
  229. package/dist/providers/jobs/kubernetes-isolation.js +6 -0
  230. package/dist/providers/jobs/kubernetes-platform.d.ts +4 -1
  231. package/dist/providers/jobs/kubernetes-platform.js +38 -1
  232. package/dist/providers/jobs/kubernetes-preflight.d.ts +2 -1
  233. package/dist/providers/jobs/kubernetes-preflight.js +4 -2
  234. package/dist/providers/jobs/kubernetes.d.ts +36 -1
  235. package/dist/providers/jobs/kubernetes.js +74 -4
  236. package/dist/providers/jobs/metadata-probes.d.ts +11 -0
  237. package/dist/providers/jobs/metadata-probes.js +13 -0
  238. package/dist/providers/jobs/types.d.ts +9 -0
  239. package/dist/providers/review-host/github.d.ts +11 -1
  240. package/dist/providers/review-host/github.js +50 -0
  241. package/dist/providers/review-host/types.d.ts +23 -0
  242. package/dist/providers/vcs/github.d.ts +32 -6
  243. package/dist/providers/vcs/github.js +74 -16
  244. package/dist/quickstart/coding-seed.d.ts +10 -3
  245. package/dist/quickstart/coding-seed.js +74 -3
  246. package/dist/quickstart/images.d.ts +2 -0
  247. package/dist/quickstart/images.js +7 -1
  248. package/dist/quickstart/reviewer-prompt.d.ts +1 -1
  249. package/dist/quickstart/reviewer-prompt.js +1 -1
  250. package/dist/quickstart-images.json +1 -1
  251. package/dist/sandbox/brokered-fetch.d.ts +23 -0
  252. package/dist/sandbox/brokered-fetch.js +37 -0
  253. package/dist/sandbox/host-functions.d.ts +34 -0
  254. package/dist/sandbox/host-functions.js +144 -80
  255. package/dist/sandbox/prelude.js +7 -1
  256. package/dist/sandbox/run-in-sandbox.d.ts +19 -0
  257. package/dist/sandbox/run-in-sandbox.js +28 -10
  258. package/dist/sandbox/safe-fetch.d.ts +2 -0
  259. package/dist/sandbox/safe-fetch.js +3 -1
  260. package/dist/sandbox/secret-broker-sigv4.d.ts +2 -0
  261. package/dist/sandbox/secret-broker-sigv4.js +49 -0
  262. package/dist/sandbox/secret-broker.d.ts +34 -0
  263. package/dist/sandbox/secret-broker.js +162 -0
  264. package/dist/sandbox/user-tool.d.ts +14 -0
  265. package/dist/sandbox/user-tool.js +32 -0
  266. package/dist/serve.js +18 -0
  267. package/dist/viewer/api-schema.d.ts +210 -14
  268. package/dist/viewer/api-schema.js +22 -0
  269. package/dist/viewer/build-schemas.js +4 -1
  270. package/dist/viewer/event-bus.d.ts +6 -0
  271. package/dist/viewer/event-bus.js +16 -7
  272. package/dist/viewer/graph.d.ts +5 -2
  273. package/dist/viewer/graph.js +7 -2
  274. package/dist/viewer/http.d.ts +0 -1
  275. package/dist/viewer/http.js +2 -2
  276. package/dist/viewer/infra.js +25 -2
  277. package/dist/viewer/limits.d.ts +4 -0
  278. package/dist/viewer/limits.js +4 -0
  279. package/dist/viewer/openapi.d.ts +15 -0
  280. package/dist/viewer/openapi.js +172 -0
  281. package/docs/README.md +7 -0
  282. package/docs/agent-recipes.md +57 -8
  283. package/docs/architecture-runtime.md +16 -1
  284. package/docs/code-review-agents.md +139 -5
  285. package/docs/coding-agent-setup.md +113 -5
  286. package/docs/coding-packages.md +4 -2
  287. package/docs/coding-services.md +3 -1
  288. package/docs/coding-worker-byo-images.md +198 -6
  289. package/docs/coding-worker-isolation.md +68 -14
  290. package/docs/getting-started-gke.md +225 -9
  291. package/docs/getting-started-identity-provider.md +21 -10
  292. package/docs/getting-started.md +5 -4
  293. package/docs/jira-agents.md +5 -1
  294. package/docs/native-sandbox.md +635 -0
  295. package/docs/release-verification.md +9 -0
  296. package/docs/security-deployment.md +113 -12
  297. package/docs/slack-notifications.md +252 -0
  298. package/docs/tool-secrets.md +252 -0
  299. package/docs/viewer-api.md +46 -5
  300. package/docs/viewer-api.openapi.yml +1404 -0
  301. package/help/admin-viewer.md +6 -2
  302. package/help/agent-recipes.md +27 -0
  303. package/help/architecture-agent.md +1 -1
  304. package/help/brokered-secrets.md +79 -0
  305. package/help/build-claude-code-image.md +131 -0
  306. package/help/build-worker-image.md +3 -6
  307. package/help/code-review-agents.md +68 -6
  308. package/help/deploy-gke.md +8 -1
  309. package/help/deployment-targets.md +25 -14
  310. package/help/errors/coding-provider-not-configured.md +2 -2
  311. package/help/errors/database-auth-invalid.md +28 -0
  312. package/help/errors/github-account-not-allowed.md +25 -0
  313. package/help/errors/low-power-disabled.md +29 -0
  314. package/help/errors/low-power-scale-forbidden.md +35 -0
  315. package/help/errors/low-power-wake-timeout.md +30 -0
  316. package/help/errors/mobile-disabled.md +31 -0
  317. package/help/errors/native-sandbox-capacity.md +22 -0
  318. package/help/errors/native-sandbox-deadline-exceeded.md +19 -0
  319. package/help/errors/native-sandbox-docker-failed.md +20 -0
  320. package/help/errors/native-sandbox-gateway-unavailable.md +21 -0
  321. package/help/errors/native-sandbox-image-not-pinned.md +19 -0
  322. package/help/errors/native-sandbox-isolation-mismatch.md +20 -0
  323. package/help/errors/native-sandbox-network-unenforced.md +22 -0
  324. package/help/errors/native-sandbox-requires-catalog.md +31 -0
  325. package/help/errors/native-sandbox-unavailable.md +19 -0
  326. package/help/errors/native-sandbox-warm-delivery-failed.md +21 -0
  327. package/help/errors/native-sandbox-worker-exited.md +20 -0
  328. package/help/errors/native-sandbox-worker-lost.md +20 -0
  329. package/help/errors/native-sandbox-worker-unready.md +22 -0
  330. package/help/errors/secret-broker-body-invalid.md +26 -0
  331. package/help/errors/secret-broker-changed.md +20 -0
  332. package/help/errors/secret-broker-config-invalid.md +30 -0
  333. package/help/errors/secret-broker-conflict.md +27 -0
  334. package/help/errors/secret-broker-destination-denied.md +28 -0
  335. package/help/errors/secret-broker-value-invalid.md +28 -0
  336. package/help/errors/secret-brokered.md +24 -0
  337. package/help/errors/secret-not-brokered.md +25 -0
  338. package/help/errors/slack-auth-failed.md +48 -0
  339. package/help/errors/slack-channel-unreachable.md +39 -0
  340. package/help/errors/slack-not-configured.md +33 -0
  341. package/help/getting-started.md +4 -5
  342. package/help/github.md +18 -1
  343. package/help/identity-and-access.md +5 -1
  344. package/help/local-repositories.md +2 -3
  345. package/help/low-power-mode.md +125 -0
  346. package/help/merge-order-check.md +117 -0
  347. package/help/mobile-app.md +54 -0
  348. package/help/native-sandbox.md +136 -0
  349. package/help/observability.md +8 -0
  350. package/help/related-pull-requests.md +70 -9
  351. package/help/repo-instructions-and-skills.md +91 -0
  352. package/help/review-fix-rounds.md +10 -0
  353. package/help/slack-notifications.md +62 -0
  354. package/help/troubleshooting/coding-workers.md +21 -0
  355. package/package.json +11 -4
  356. package/prisma/migrations/20261009000003_native_execution_mode/migration.sql +12 -0
  357. package/prisma/migrations/20261009000004_native_gateway_ledger/migration.sql +65 -0
  358. package/prisma/migrations/20261009000005_native_gateway_call_child_agent/migration.sql +5 -0
  359. package/prisma/migrations/20261009000006_run_deferred_start_stop/migration.sql +14 -0
  360. package/prisma/migrations/20261009000007_native_gateway_network_ready/migration.sql +5 -0
  361. package/prisma/migrations/20261010000001_workflow_notifications/migration.sql +99 -0
  362. package/prisma/migrations/20261011000001_native_gateway_session_status_index/migration.sql +2 -0
  363. package/prisma/migrations/20261012000001_native_warm_worker/migration.sql +32 -0
  364. package/prisma/migrations/20261013000001_coding_run_merge_order/migration.sql +2 -0
  365. package/prisma/migrations/20261013000001_secret_broker/migration.sql +17 -0
  366. package/prisma/migrations/20261013000002_deferred_review_request/migration.sql +4 -0
  367. package/prisma/migrations/20261014000000_repo_skills/migration.sql +15 -0
  368. package/prisma/migrations/20261015000001_coding_run_merge_order_index/migration.sql +2 -0
  369. package/prisma/migrations/20261015000002_no_change_rereview/migration.sql +2 -0
  370. package/prisma/schema.prisma +362 -91
@@ -16,11 +16,15 @@
16
16
  * down) lives inside the engine now — there is exactly one place that
17
17
  * reasons about cost, not one here plus one in the engine.
18
18
  */
19
+ import { createHash, randomBytes } from "node:crypto";
19
20
  import { z } from "zod";
20
- import { runStepInline } from "../providers/engine/types.js";
21
+ import { RunOwnershipLostError, runStepInline } from "../providers/engine/types.js";
21
22
  import { isLlmEffort } from "../providers/llm/types.js";
22
- import { validateParams } from "../sandbox/zod-params.js";
23
- import { runInSandbox } from "../sandbox/run-in-sandbox.js";
23
+ import { createPrivilegedHost } from "../sandbox/host-functions.js";
24
+ import { consoleRedactionValues } from "../sandbox/secret-broker.js";
25
+ import { runUserToolCall } from "../sandbox/user-tool.js";
26
+ import { runSandboxedEngine, SandboxRunCancelledError, } from "../native-worker/gateway.js";
27
+ import { NATIVE_WORKER_PROTOCOL_VERSION } from "../native-worker/protocol.js";
24
28
  import { asStringArray, asPrefixMap } from "../sandbox/tool-capabilities.js";
25
29
  import { scopeDatastore } from "../providers/datastore/scoped.js";
26
30
  import { buildSecretsAccessor, scopeSecretsAccessor } from "./secrets.js";
@@ -47,8 +51,11 @@ import { ISSUE_TRACKER_TOOL_DEFS, ISSUE_TRACKER_TOOL_NAMES, handleIssueTrackerTo
47
51
  import { completeIssueStatus } from "./issue-status.js";
48
52
  import { fileIssue } from "./issue-dedupe.js";
49
53
  import { fileSelfDefect } from "./self-defects.js";
54
+ import { emitRunFinishedEvents } from "./workflow-run-events.js";
50
55
  import { startReviewFixAfterReview } from "./review-fix.js";
51
56
  import { updateRelatedPullRequests } from "./related-pull-requests.js";
57
+ import { syncMergeOrderChecksAfterRun } from "./merge-order-check.js";
58
+ import { startDeferredForRequest } from "./host-events.js";
52
59
  import { recordNativeModelUsage } from "./model-usage.js";
53
60
  import { pinNativeRunPricing } from "./run-pricing.js";
54
61
  import { RoutingLlmProvider } from "../providers/llm/routing.js";
@@ -115,20 +122,177 @@ function delegateToolDef(boundName, parallel) {
115
122
  type: "string",
116
123
  description: "Coding sub-agents only. The run id of a prior coding run whose branch/PR this dispatch should push a new commit onto, instead of opening a fresh one — use when the task is a revision to an existing PR (a plan/spec update, or a code-review follow-up), even across a different sub-agent than the one that opened it.",
117
124
  },
125
+ mergeOrder: {
126
+ type: "integer",
127
+ minimum: 1,
128
+ maximum: 99,
129
+ description: "Coding sub-agents only. This repository's step in the merge order of the whole request (1 = merge first). " +
130
+ "Give dependencies lower numbers than the changes that use them (e.g. a service 1, the API that calls it 2, the client 3); " +
131
+ "equal numbers have no order between them. Set it whenever one change spans several repositories.",
132
+ },
118
133
  },
119
134
  required: ["task"],
120
135
  additionalProperties: false,
121
136
  },
122
137
  };
123
138
  }
139
+ /** Every mergeOrder validation failure (wrong type, non-integer, or out of range) names the same bound. */
140
+ const MERGE_ORDER_RANGE_MESSAGE = "mergeOrder must be an integer from 1 to 99.";
124
141
  const DelegateArgs = z
125
142
  .object({
126
143
  task: z.string(),
127
144
  datastoreRef: z.object({ name: z.string(), key: z.string() }).strict().optional(),
128
145
  grantParentMemoryKeys: z.array(z.string()).optional(),
129
146
  continuePriorRun: z.string().optional(),
147
+ mergeOrder: z
148
+ .number({ invalid_type_error: MERGE_ORDER_RANGE_MESSAGE })
149
+ .int(MERGE_ORDER_RANGE_MESSAGE)
150
+ .min(1, MERGE_ORDER_RANGE_MESSAGE)
151
+ .max(99, MERGE_ORDER_RANGE_MESSAGE)
152
+ .optional(),
130
153
  })
131
154
  .strict();
155
+ /** The tool result note for a native child's delegation when the caller passed mergeOrder: it does not apply. */
156
+ const MERGE_ORDER_IGNORED_NOTE = "mergeOrder applies only to coding sub-agents; ignored";
157
+ /** A delegate_to_* call's arguments, or the tool result refusing them. */
158
+ export function parseDelegateArgs(argsJson) {
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(argsJson || "{}");
162
+ }
163
+ catch (err) {
164
+ return {
165
+ refusal: JSON.stringify({
166
+ error: "invalid_arguments_json",
167
+ message: err instanceof Error ? err.message : String(err),
168
+ }),
169
+ };
170
+ }
171
+ try {
172
+ return { args: DelegateArgs.parse(parsed) };
173
+ }
174
+ catch (err) {
175
+ if (err instanceof z.ZodError) {
176
+ return {
177
+ refusal: JSON.stringify({
178
+ error: "validation_failed",
179
+ message: err.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "),
180
+ }),
181
+ };
182
+ }
183
+ throw err;
184
+ }
185
+ }
186
+ /**
187
+ * At most `limit` dispatches per run (default one: a classifier deciding plan-vs-implement
188
+ * commits to exactly one child), and never the same child twice, so a retry can't leave two
189
+ * children racing on the same task. A delegation waiting for budget holds its place.
190
+ */
191
+ export function delegationLimitRefusal(input) {
192
+ const { priorChildAgentIds, waitingChildAgentIds, childAgentId, boundName, limit } = input;
193
+ if (priorChildAgentIds.includes(childAgentId) || waitingChildAgentIds.includes(childAgentId)) {
194
+ return JSON.stringify({
195
+ error: "already_dispatched",
196
+ message: `This run already delegated to "${boundName}"; a run delegates to each sub-agent at most once.`,
197
+ });
198
+ }
199
+ if (priorChildAgentIds.length < limit && priorChildAgentIds.length + waitingChildAgentIds.length >= limit) {
200
+ // Only a waiting delegation (parallelDelegations) holds the remaining place.
201
+ return JSON.stringify({
202
+ error: "already_dispatched",
203
+ message: `This run reached its limit of ${limit} delegations: ${priorChildAgentIds.length} made and ${waitingChildAgentIds.length} waiting for budget.`,
204
+ });
205
+ }
206
+ if (priorChildAgentIds.length >= limit) {
207
+ return JSON.stringify({
208
+ error: "already_dispatched",
209
+ message: limit === 1
210
+ ? "This run already delegated to a sub-agent; only one delegation is allowed per run."
211
+ : `This run already made ${limit} delegations, the most this agent allows per run.`,
212
+ });
213
+ }
214
+ return null;
215
+ }
216
+ const DELEGATION_CHILD_SELECT = {
217
+ id: true,
218
+ kind: true,
219
+ budgetGroupId: true,
220
+ budgetUsd: true,
221
+ ownerId: true,
222
+ nativeExecutionMode: true,
223
+ codingProfile: { select: { allowWebhookTaskOverride: true, timeoutSec: true } },
224
+ };
225
+ /**
226
+ * The live authorization of one delegation, and the child agent as it is now. Owners are re-read
227
+ * on every delegation (resource-sharing grants spec §3.4.4, N1): the child must have the parent's
228
+ * current owner, or that owner must still hold execute on the child. A revoked grant or a
229
+ * make_owner transfer stops the very next call.
230
+ */
231
+ export async function authorizeDelegation(db, parentAgentId, edge, boundName, args) {
232
+ const [parentNow, childAgent] = await Promise.all([
233
+ db.agent.findUnique({ where: { id: parentAgentId }, select: { ownerId: true } }),
234
+ db.agent.findUniqueOrThrow({ where: { id: edge.childAgentId }, select: DELEGATION_CHILD_SELECT }),
235
+ ]);
236
+ const parentOwnerId = parentNow?.ownerId ?? null;
237
+ const childOwnerId = childAgent.ownerId ?? null;
238
+ if (!parentNow ||
239
+ !(await canDelegate(db, { ownerId: parentOwnerId }, { id: childAgent.id, ownerId: childOwnerId }))) {
240
+ return {
241
+ refusal: JSON.stringify({
242
+ error: "subagent_not_authorized",
243
+ message: `The "${boundName}" sub-agent belongs to another owner who has not given this agent's owner execute access to it.`,
244
+ }),
245
+ };
246
+ }
247
+ if (parentOwnerId !== childOwnerId) {
248
+ // Across owners the edge carries execute and nothing more: no
249
+ // model-chosen memory grant, no continuation of another run's PR,
250
+ // and no task text the caller couldn't give the child directly --
251
+ // the trigger_agent rule (review I3): a coding child only with its
252
+ // owner's allowWebhookTaskOverride opt-in, a native child never
253
+ // (it runs its owner's fixed prompt; pass an empty task).
254
+ const refusal = (args.grantParentMemoryKeys?.length ?? 0) > 0
255
+ ? "grantParentMemoryKeys is only allowed when the sub-agent has the same owner."
256
+ : args.continuePriorRun !== undefined
257
+ ? "continuePriorRun is only allowed when the sub-agent has the same owner."
258
+ : childAgent.kind === "coding" && !childAgent.codingProfile?.allowWebhookTaskOverride
259
+ ? "This coding sub-agent belongs to another owner and does not accept task text from others (allowWebhookTaskOverride)."
260
+ : childAgent.kind !== "coding" && (args.task.trim() !== "" || args.datastoreRef !== undefined)
261
+ ? 'This sub-agent belongs to another owner and runs only its own instructions: delegate with task "" and no datastoreRef.'
262
+ : null;
263
+ if (refusal)
264
+ return { refusal: JSON.stringify({ error: "cross_owner_not_allowed", message: refusal }) };
265
+ }
266
+ if (args.continuePriorRun !== undefined && childAgent.kind !== "coding") {
267
+ return {
268
+ refusal: JSON.stringify({
269
+ error: "continuation_requires_coding_agent",
270
+ message: "continuePriorRun is only supported when the sub-agent is coding-kind.",
271
+ }),
272
+ };
273
+ }
274
+ return { childAgent };
275
+ }
276
+ /** A native child's task text: the delegated task, with any datastore reference as a note. */
277
+ export function delegationTaskOverride(args) {
278
+ return args.datastoreRef
279
+ ? `${args.task}\n\n[Referenced datastore: name="${args.datastoreRef.name}", key="${args.datastoreRef.key}" — use your datastore tools to read it.]`
280
+ : args.task.trim() !== ""
281
+ ? args.task
282
+ : undefined;
283
+ }
284
+ /** The tool result a native child's terminal run reports to its parent. `note` surfaces e.g. an ignored mergeOrder. */
285
+ export function nativeChildResult(run, note) {
286
+ return JSON.stringify({
287
+ status: run.status,
288
+ finalText: run.finalText,
289
+ costUsd: Number(run.costUsd),
290
+ tokensIn: run.tokensIn,
291
+ tokensOut: run.tokensOut,
292
+ ...(run.error ? { error: run.error } : {}),
293
+ ...(note ? { note } : {}),
294
+ });
295
+ }
132
296
  /**
133
297
  * The composed review hosts, or undefined when there are none (no GitHub App
134
298
  * configured yields an empty registry). Undefined means the run never touches
@@ -169,7 +333,8 @@ function toIssueProjectLink(row) {
169
333
  * reaches a terminal state first must win. A conditional `updateMany` makes
170
334
  * that a database-level CAS rather than a race — the loser's WHERE matches
171
335
  * zero rows. It also stops a *later* attempt resurrecting a run the
172
- * reconciler already reaped: a `lost` row stays `lost`.
336
+ * reconciler already reaped: a `lost` row stays `lost`. A step-level
337
+ * ownership loss writes nothing at all (RunOwnershipLostError).
173
338
  */
174
339
  const DRIVABLE = ["pending", "running"];
175
340
  /**
@@ -183,6 +348,15 @@ export class RunCancelledError extends Error {
183
348
  this.name = "RunCancelledError";
184
349
  }
185
350
  }
351
+ /** Re-exported beside RunCancelledError: thrown by a step runner when another attempt owns the run. */
352
+ export { RunOwnershipLostError };
353
+ /**
354
+ * The one log line for an attempt that lost ownership of its run. Warn, not info: each one also
355
+ * means a discarded step's spend and an owner that stalled long enough to be adopted.
356
+ */
357
+ function logRunOwnershipLost(runId, err) {
358
+ runnerLog.warn({ runId, step: err.step, event: "run_ownership_lost", discardedUsage: err.discardedUsage }, "run_ownership_lost: another execution owns this run; stopping without a terminal write (this attempt's last step is discarded)");
359
+ }
186
360
  /**
187
361
  * Terminal write + read-back. The write is conditional (see DRIVABLE), so
188
362
  * the returned row is the run's real state — this attempt's result if it won
@@ -196,6 +370,15 @@ async function finishRunClaimed(db, runId, data) {
196
370
  const updated = await db.run.updateMany({ where: { id: runId, status: { in: [...DRIVABLE] } }, data });
197
371
  return { run: await db.run.findUniqueOrThrow({ where: { id: runId } }), claimed: updated.count > 0 };
198
372
  }
373
+ /**
374
+ * Why a sandbox-mode native run is not executed here. The runner only ever
375
+ * executes native runs in the control plane; a run whose snapshot says
376
+ * `sandbox` reaches it only when no native sandbox executor is composed in
377
+ * (RoutingExecutor sends it to the native executor), and must fail closed
378
+ * rather than run unisolated.
379
+ */
380
+ export const NATIVE_SANDBOX_NOT_CONFIGURED = "native_sandbox_unavailable: This agent runs with nativeExecutionMode=sandbox, but this deployment has no native " +
381
+ "sandbox executor configured, so the run was not started. Switch the agent back to control-plane mode to run it here.";
199
382
  /**
200
383
  * Why a coding run cannot start in this process: runs reach the runner only
201
384
  * when no container executor is composed in (JOB_LAUNCHER=local, e.g. every
@@ -210,6 +393,12 @@ const CODING_EXECUTOR_NOT_CONFIGURED = "Coding agents need a container executor,
210
393
  * write the terminal row, and the queue timeout only fires on a drain tick.
211
394
  */
212
395
  export const CODING_CHILD_WAIT_GRACE_SEC = 60;
396
+ /**
397
+ * How long a parent waits for a sandbox-mode native child before stopping it. Native runs have
398
+ * no run timeout of their own; until the sandbox executor brings a per-run deadline, this bounds
399
+ * a parent left waiting on a child that never finishes.
400
+ */
401
+ export const SANDBOX_CHILD_WAIT_SEC = 60 * 60;
213
402
  /**
214
403
  * What a coding sub-agent's run tells the parent run's model. A run refused
215
404
  * over its services carries the host sentence (never the raw Run.error), so
@@ -306,40 +495,27 @@ export async function createRun(db, agentName, trigger = "manual") {
306
495
  if (agent.kind === "coding") {
307
496
  throw new Error(CODING_EXECUTOR_NOT_CONFIGURED);
308
497
  }
309
- return db.run.create({ data: { agentId: agent.id, trigger } });
310
- }
311
- /** Drives an existing Run (created by `createRun` or the scheduler) to a terminal state. */
312
- export async function executeRun(runId, providers, db = defaultDb, onText, step = runStepInline) {
313
- // Counted so a shutdown waits for this run instead of abandoning it (core/in-flight-runs.ts).
314
- return trackRun(runId, () => executeTrackedRun(runId, providers, db, onText, step));
315
- }
316
- async function executeTrackedRun(runId, providers, db, onText, step) {
317
- const existingRun = await db.run.findUnique({ where: { id: runId } });
318
- if (!existingRun) {
319
- throw new Error(`Unknown run "${runId}".`);
498
+ // Refused here, before any row: a foreground run executes inline, never in a sandbox.
499
+ if (agent.nativeExecutionMode === "sandbox") {
500
+ throw new Error(NATIVE_SANDBOX_NOT_CONFIGURED);
320
501
  }
321
- // Pre-flight guard. A run that already reached a terminal state must never
322
- // be re-driven: a durable workflow re-dispatched after the reconciler
323
- // reaped its row (rollback to EXECUTOR=in-process, then roll forward) would
324
- // otherwise re-spend the whole run against a `lost` row, and a duplicate
325
- // attempt of a finished run would spend a second time for a result no write
326
- // can land. Cheaper and clearer than letting it run and discarding the
327
- // result at the conditional write.
328
- if (!DRIVABLE.includes(existingRun.status)) {
329
- runnerLog.info({ runId, status: existingRun.status }, "skipping execution of an already-terminal run");
330
- return existingRun;
331
- }
332
- const reviewHosts = configuredReviewHosts(providers.reviewHosts);
333
- const repoAccess = reviewHosts
334
- ? (providers.repoAccess ?? createRepoAccessGate({ db, hosts: reviewHosts }))
335
- : undefined;
336
- const issueTrackers = configuredIssueTrackers(providers.issueTrackers);
337
- // Pinned in one checkpointed step: on replay after a crash, the agent row
338
- // or its budget group may have changed since first execution. The
339
- // engine's control flow depends on budgetUsd and maxTurns, so they must
340
- // be pinned to the values seen on first execution or the replay's step
341
- // order diverges from the record.
342
- const loadedOrUnavailable = await step("load", async () => {
502
+ return db.run.create({ data: { agentId: agent.id, trigger, nativeExecutionMode: agent.nativeExecutionMode } });
503
+ }
504
+ /**
505
+ * The trusted load of a native run: the agent, its attached tools and their
506
+ * consented capabilities, the pinned catalog entry, the effective budget,
507
+ * sub-agent edges, and repository/issue links. Shared by in-process execution
508
+ * and the native sandbox gateway, which hands the worker this result.
509
+ *
510
+ * Pinned in one checkpointed step: on replay after a crash, the agent row
511
+ * or its budget group may have changed since first execution. The
512
+ * engine's control flow depends on budgetUsd and maxTurns, so they must
513
+ * be pinned to the values seen on first execution or the replay's step
514
+ * order diverges from the record.
515
+ */
516
+ export async function loadNativeRun(options) {
517
+ const { runId, existingRun, providers, db, step, reviewHosts, issueTrackers } = options;
518
+ return step("load", async () => {
343
519
  const agent = await db.agent.findUnique({ where: { id: existingRun.agentId } });
344
520
  if (!agent) {
345
521
  throw new Error(`Run "${runId}" references missing agent "${existingRun.agentId}".`);
@@ -486,53 +662,48 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
486
662
  (err instanceof Error && err.message.startsWith("model_unavailable:"))) {
487
663
  return { unavailable: err.message };
488
664
  }
665
+ // Thrown out of executeRun ahead of its backstop, so logged here instead.
666
+ if (err instanceof RunOwnershipLostError)
667
+ logRunOwnershipLost(runId, err);
489
668
  throw err;
490
669
  });
491
- // Fails a run that ends before its engine starts, closing what its trigger opened (the review
492
- // check, the host and issue status comments) exactly as the normal and catch paths below do.
493
- // Deliberately no self-defect: both callers are configuration states, not this run's defect (an
494
- // admin disabled or removed the model, or this deployment has no coding executor), and filing
495
- // would open one defect per affected agent rather than describe a failure of that agent.
496
- const finishEarly = async (error) => {
497
- const finished = await finishRun(db, runId, { status: "failed", error, finishedAt: new Date() });
498
- await closeOpenHostCheck(db, finished, reviewHosts);
499
- await completeHostStatus(db, finished, reviewHosts);
500
- await completeIssueStatus(db, finished, issueTrackers);
501
- return finished;
502
- };
503
- if ("unavailable" in loadedOrUnavailable)
504
- return finishEarly(loadedOrUnavailable.unavailable);
505
- const loaded = loadedOrUnavailable;
506
- if (loaded.kind === "coding")
507
- return finishEarly(CODING_EXECUTOR_NOT_CONFIGURED);
508
- // Conditional on DRIVABLE rather than on `pending`: an adopted attempt
509
- // legitimately finds the row already `running`, but a terminal row must
510
- // never be flipped back to `running`.
511
- await db.run.updateMany({ where: { id: runId, status: { in: [...DRIVABLE] } }, data: { status: "running" } });
512
- try {
513
- const toolsByName = new Map(Object.entries(loaded.toolsByName));
514
- const secretsAccessor = buildSecretsAccessor(loaded.agentId, providers.secrets, db);
515
- const sharedDatastoreAccessor = buildSharedDatastoreAccessor(loaded.agentId, providers.datastore, db);
516
- // jira_create_issue's per-run cap counter: shared by every tool call of this attempt (a resumed attempt
517
- // starts a fresh one, floored by the run's recorded fingerprint creates).
518
- const issueCreationCounters = new Map();
519
- // One per attempt: admits this run's delegations one at a time (see the delegate branch).
520
- const delegationGate = createSerialGate();
521
- // The children this attempt's delegations have running, and the sub-agents whose delegation is
522
- // waiting for one of them to free budget (parallelDelegations): a waiting delegation keeps its
523
- // place in the same-child and limit checks while the gate is open to the others.
524
- const delegationSiblings = createDelegationSiblings();
525
- const waitingDelegations = new Set();
526
- const runSandboxTool = async (name, argsJson) => {
527
- if (loaded.memoryEnabled && MEMORY_TOOL_NAMES.has(name)) {
670
+ }
671
+ /**
672
+ * One attempt's tool surface for a native run. `builtinHandler(name)` is the
673
+ * single source of truth for which names are trusted built-ins (memory,
674
+ * repo_*, jira_*, sub-agent/parent memory, delegate_to_*) and returns undefined
675
+ * for everything else, which `runUserTool` runs in the WASM sandbox. Per-attempt
676
+ * state (the delegation gate, siblings, issue creation counters) lives here.
677
+ */
678
+ export function createNativeRunTools(options) {
679
+ const { runId, existingRun, loaded, providers, db, reviewHosts, repoAccess, issueTrackers } = options;
680
+ const toolsByName = new Map(Object.entries(loaded.toolsByName));
681
+ const secretsAccessor = buildSecretsAccessor(loaded.agentId, providers.secrets, db);
682
+ const sharedDatastoreAccessor = buildSharedDatastoreAccessor(loaded.agentId, providers.datastore, db);
683
+ // jira_create_issue's per-run cap counter: shared by every tool call of this attempt (a resumed attempt
684
+ // starts a fresh one, floored by the run's recorded fingerprint creates).
685
+ const issueCreationCounters = new Map();
686
+ // One per attempt: admits this run's delegations one at a time (see the delegate branch).
687
+ const delegationGate = createSerialGate();
688
+ // The children this attempt's delegations have running, and the sub-agents whose delegation is
689
+ // waiting for one of them to free budget (parallelDelegations): a waiting delegation keeps its
690
+ // place in the same-child and limit checks while the gate is open to the others.
691
+ const delegationSiblings = createDelegationSiblings();
692
+ const waitingDelegations = new Set();
693
+ const builtinHandler = (name) => {
694
+ if (loaded.memoryEnabled && MEMORY_TOOL_NAMES.has(name)) {
695
+ return async (argsJson) => {
528
696
  return handleMemoryTool(name, argsJson, loaded.agentId, providers.memory);
529
- }
530
- if (REVIEW_HOST_TOOL_NAMES.has(name) && loaded.repositoryLinks.length > 0 && reviewHosts && repoAccess) {
697
+ };
698
+ }
699
+ if (REVIEW_HOST_TOOL_NAMES.has(name) && loaded.repositoryLinks.length > 0 && reviewHosts && repoAccess) {
700
+ return async (argsJson) => {
531
701
  const check = await db.runHostCheck.findUnique({ where: { runId } });
532
702
  return handleReviewHostTool(name, argsJson, {
533
703
  agentId: loaded.agentId,
534
704
  links: loaded.repositoryLinks,
535
705
  hosts: reviewHosts,
706
+ db,
536
707
  runCheck: check && !check.completedAt
537
708
  ? {
538
709
  provider: check.provider,
@@ -581,11 +752,13 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
581
752
  });
582
753
  },
583
754
  });
584
- }
585
- // `?? []`: a load step replayed from before these links were pinned has none.
586
- // Each link is re-normalised too: one pinned before the allowlists existed lacks them.
587
- const issueProjectLinks = (loaded.issueProjectLinks ?? []).map(toIssueProjectLink);
588
- if (ISSUE_TRACKER_TOOL_NAMES.has(name) && issueProjectLinks.length > 0 && issueTrackers) {
755
+ };
756
+ }
757
+ // `?? []`: a load step replayed from before these links were pinned has none.
758
+ // Each link is re-normalised too: one pinned before the allowlists existed lacks them.
759
+ const issueProjectLinks = (loaded.issueProjectLinks ?? []).map(toIssueProjectLink);
760
+ if (ISSUE_TRACKER_TOOL_NAMES.has(name) && issueProjectLinks.length > 0 && issueTrackers) {
761
+ return async (argsJson) => {
589
762
  return handleIssueTrackerTool(name, argsJson, {
590
763
  agentId: loaded.agentId,
591
764
  links: issueProjectLinks,
@@ -605,14 +778,20 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
605
778
  recordedCreates: (projectKey) => db.issueFingerprint.count({ where: { createdByRunId: runId, issueProvider: "jira", projectKey } }),
606
779
  },
607
780
  });
608
- }
609
- if (name === "subagent_memory_get") {
781
+ };
782
+ }
783
+ if (name === "subagent_memory_get") {
784
+ return async (argsJson) => {
610
785
  return handleSubAgentMemoryGet(argsJson, loaded.agentId, db, providers.memory);
611
- }
612
- if (name === "parent_memory_get") {
786
+ };
787
+ }
788
+ if (name === "parent_memory_get") {
789
+ return async (argsJson) => {
613
790
  return handleParentMemoryGet(argsJson, runId, db, providers.memory);
614
- }
615
- if (name.startsWith(DELEGATE_TOOL_PREFIX)) {
791
+ };
792
+ }
793
+ if (name.startsWith(DELEGATE_TOOL_PREFIX)) {
794
+ return async (argsJson) => {
616
795
  const boundName = name.slice(DELEGATE_TOOL_PREFIX.length);
617
796
  const edge = loaded.subAgentEdges.find((e) => e.boundName === boundName);
618
797
  if (!edge) {
@@ -630,105 +809,24 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
630
809
  // racing on the same task. Checked and the child row written under the
631
810
  // run's delegation gate: delegations started together in one turn
632
811
  // (parallelDelegations) are admitted one at a time.
633
- const limit = loaded.maxDelegationsPerRun ?? 1;
634
812
  const priorDispatches = await db.run.findMany({ where: { parentRunId: { in: [runId] } } });
635
- if (priorDispatches.some((prior) => prior.agentId === edge.childAgentId) ||
636
- waitingDelegations.has(edge.childAgentId)) {
637
- return JSON.stringify({
638
- error: "already_dispatched",
639
- message: `This run already delegated to "${boundName}"; a run delegates to each sub-agent at most once.`,
640
- });
641
- }
642
- if (priorDispatches.length < limit && priorDispatches.length + waitingDelegations.size >= limit) {
643
- // Only a waiting delegation (parallelDelegations) holds the remaining place.
644
- return JSON.stringify({
645
- error: "already_dispatched",
646
- message: `This run reached its limit of ${limit} delegations: ${priorDispatches.length} made and ${waitingDelegations.size} waiting for budget.`,
647
- });
648
- }
649
- if (priorDispatches.length >= limit) {
650
- return JSON.stringify({
651
- error: "already_dispatched",
652
- message: limit === 1
653
- ? "This run already delegated to a sub-agent; only one delegation is allowed per run."
654
- : `This run already made ${limit} delegations, the most this agent allows per run.`,
655
- });
656
- }
657
- let parsed;
658
- try {
659
- parsed = JSON.parse(argsJson || "{}");
660
- }
661
- catch (err) {
662
- return JSON.stringify({
663
- error: "invalid_arguments_json",
664
- message: err instanceof Error ? err.message : String(err),
665
- });
666
- }
667
- let args;
668
- try {
669
- args = DelegateArgs.parse(parsed);
670
- }
671
- catch (err) {
672
- if (err instanceof z.ZodError) {
673
- return JSON.stringify({
674
- error: "validation_failed",
675
- message: err.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "),
676
- });
677
- }
678
- throw err;
679
- }
680
- // Owners are re-read live on every delegation (resource-sharing
681
- // grants spec §3.4.4, N1): the child must have the parent's current
682
- // owner, or that owner must still hold execute on the child. A
683
- // revoked grant or a make_owner transfer stops the very next call.
684
- const [parentNow, childAgent] = await Promise.all([
685
- db.agent.findUnique({ where: { id: loaded.agentId }, select: { ownerId: true } }),
686
- db.agent.findUniqueOrThrow({
687
- where: { id: edge.childAgentId },
688
- select: {
689
- id: true,
690
- kind: true,
691
- budgetGroupId: true,
692
- budgetUsd: true,
693
- ownerId: true,
694
- codingProfile: { select: { allowWebhookTaskOverride: true, timeoutSec: true } },
695
- },
696
- }),
697
- ]);
698
- const parentOwnerId = parentNow?.ownerId ?? null;
699
- const childOwnerId = childAgent.ownerId ?? null;
700
- if (!parentNow ||
701
- !(await canDelegate(db, { ownerId: parentOwnerId }, { id: childAgent.id, ownerId: childOwnerId }))) {
702
- return JSON.stringify({
703
- error: "subagent_not_authorized",
704
- message: `The "${boundName}" sub-agent belongs to another owner who has not given this agent's owner execute access to it.`,
705
- });
706
- }
707
- if (parentOwnerId !== childOwnerId) {
708
- // Across owners the edge carries execute and nothing more: no
709
- // model-chosen memory grant, no continuation of another run's PR,
710
- // and no task text the caller couldn't give the child directly --
711
- // the trigger_agent rule (review I3): a coding child only with its
712
- // owner's allowWebhookTaskOverride opt-in, a native child never
713
- // (it runs its owner's fixed prompt; pass an empty task).
714
- const refusal = (args.grantParentMemoryKeys?.length ?? 0) > 0
715
- ? "grantParentMemoryKeys is only allowed when the sub-agent has the same owner."
716
- : args.continuePriorRun !== undefined
717
- ? "continuePriorRun is only allowed when the sub-agent has the same owner."
718
- : childAgent.kind === "coding" && !childAgent.codingProfile?.allowWebhookTaskOverride
719
- ? "This coding sub-agent belongs to another owner and does not accept task text from others (allowWebhookTaskOverride)."
720
- : childAgent.kind !== "coding" && (args.task.trim() !== "" || args.datastoreRef !== undefined)
721
- ? 'This sub-agent belongs to another owner and runs only its own instructions: delegate with task "" and no datastoreRef.'
722
- : null;
723
- if (refusal)
724
- return JSON.stringify({ error: "cross_owner_not_allowed", message: refusal });
725
- }
726
- if (args.continuePriorRun !== undefined && childAgent.kind !== "coding") {
727
- return JSON.stringify({
728
- error: "continuation_requires_coding_agent",
729
- message: "continuePriorRun is only supported when the sub-agent is coding-kind.",
730
- });
731
- }
813
+ const limitRefusal = delegationLimitRefusal({
814
+ priorChildAgentIds: priorDispatches.map((prior) => prior.agentId),
815
+ waitingChildAgentIds: [...waitingDelegations],
816
+ childAgentId: edge.childAgentId,
817
+ boundName,
818
+ limit: loaded.maxDelegationsPerRun ?? 1,
819
+ });
820
+ if (limitRefusal)
821
+ return limitRefusal;
822
+ const parsedArgs = parseDelegateArgs(argsJson);
823
+ if ("refusal" in parsedArgs)
824
+ return parsedArgs.refusal;
825
+ const { args } = parsedArgs;
826
+ const authorized = await authorizeDelegation(db, loaded.agentId, edge, boundName, args);
827
+ if ("refusal" in authorized)
828
+ return authorized.refusal;
829
+ const { childAgent } = authorized;
732
830
  // parallelDelegations: a child the budget would refuse (run_tree_exhausted or
733
831
  // budget_group_exhausted:*) while a sibling still runs waits for a sibling to finish and
734
832
  // free its hold, then tries again while siblings remain, up to the child's own wait bound
@@ -798,6 +896,7 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
798
896
  trigger: "subagent",
799
897
  codingTask: args.task,
800
898
  continuesCodingRunId: args.continuePriorRun,
899
+ mergeOrder: args.mergeOrder,
801
900
  parentRunId: runId,
802
901
  // The child's result flows back into this run, which the
803
902
  // triggerer sees, so the child is visible to them too.
@@ -873,11 +972,78 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
873
972
  });
874
973
  return codingChildResult(waited.run, stored?.result);
875
974
  }
876
- const taskOverride = args.datastoreRef
877
- ? `${args.task}\n\n[Referenced datastore: name="${args.datastoreRef.name}", key="${args.datastoreRef.key}" — use your datastore tools to read it.]`
878
- : args.task.trim() !== ""
879
- ? args.task
880
- : undefined;
975
+ const taskOverride = delegationTaskOverride(args);
976
+ // mergeOrder is a coding-only concept (it orders CodingRuns within a merge); a native
977
+ // child has no CodingRun to store it on, so it's never passed to dispatchRun/executeRun
978
+ // below -- only surfaced back to the model as a note, so a lead that set it on the wrong
979
+ // kind of sub-agent notices rather than silently losing it.
980
+ const mergeOrderNote = args.mergeOrder !== undefined ? MERGE_ORDER_IGNORED_NOTE : undefined;
981
+ if (childAgent.nativeExecutionMode === "sandbox") {
982
+ // A sandbox-mode child must run where its snapshot says, so it goes through
983
+ // dispatchRun and the Executor (RoutingExecutor sends it to the sandbox executor),
984
+ // exactly like a coding child — never inline in this process.
985
+ if (!providers.executor) {
986
+ return JSON.stringify({
987
+ error: "sandbox_dispatch_unavailable",
988
+ message: "This execution context has no Executor wired in, so a sandbox-mode sub-agent cannot be dispatched.",
989
+ });
990
+ }
991
+ const dispatched = await dispatchRun({
992
+ db,
993
+ executor: providers.executor,
994
+ selfDefects: { db, issueTrackers },
995
+ agentId: edge.childAgentId,
996
+ trigger: "subagent",
997
+ taskOverride,
998
+ grantedParentMemoryKeys: args.grantParentMemoryKeys ?? [],
999
+ parentRunId: runId,
1000
+ triggeredById: existingRun.triggeredById,
1001
+ awaitExecution: true,
1002
+ onPersisted: (persistedRun) => {
1003
+ if (persistedRun.status !== "refused" && persistedRun.status !== "failed") {
1004
+ finishSibling = delegationSiblings.start();
1005
+ }
1006
+ release();
1007
+ },
1008
+ });
1009
+ if (!dispatched) {
1010
+ return JSON.stringify({ error: "dispatch_failed", message: "The sub-agent run could not be created." });
1011
+ }
1012
+ const childRunId = dispatched.run.id;
1013
+ const startedAt = Date.now();
1014
+ runnerLog.info({ runId, childRunId, boundName, kind: "native-sandbox" }, "delegation started");
1015
+ // Executor.start may resolve before the child is terminal, so wait on the row like a coding child.
1016
+ const waited = await waitForCodingChild({
1017
+ db,
1018
+ executor: providers.executor,
1019
+ childRunId,
1020
+ parentRunId: runId,
1021
+ boundMs: SANDBOX_CHILD_WAIT_SEC * 1000,
1022
+ });
1023
+ finishSibling();
1024
+ runnerLog.info({
1025
+ runId,
1026
+ childRunId,
1027
+ boundName,
1028
+ outcome: waited.kind === "terminal" ? waited.run.status : waited.kind,
1029
+ durationMs: Date.now() - startedAt,
1030
+ }, "delegation finished");
1031
+ if (waited.kind === "timed_out") {
1032
+ return JSON.stringify({
1033
+ error: "subagent_wait_timed_out",
1034
+ runId: childRunId,
1035
+ message: "The sandbox sub-agent did not finish in time; it was stopped and produced no result.",
1036
+ });
1037
+ }
1038
+ if (waited.kind === "parent_cancelled") {
1039
+ return JSON.stringify({
1040
+ error: "parent_cancelled",
1041
+ runId: childRunId,
1042
+ message: "This run was cancelled while waiting for the sandbox sub-agent; the sub-agent was stopped.",
1043
+ });
1044
+ }
1045
+ return nativeChildResult(waited.run, mergeOrderNote);
1046
+ }
881
1047
  const childRun = await db.run.create({
882
1048
  data: {
883
1049
  agentId: edge.childAgentId,
@@ -886,6 +1052,7 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
886
1052
  grantedParentMemoryKeys: args.grantParentMemoryKeys ?? [],
887
1053
  taskOverride,
888
1054
  triggeredById: existingRun.triggeredById,
1055
+ nativeExecutionMode: childAgent.nativeExecutionMode,
889
1056
  },
890
1057
  });
891
1058
  finishSibling = delegationSiblings.start();
@@ -904,87 +1071,545 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
904
1071
  outcome: childResult.status,
905
1072
  durationMs: Date.now() - startedAt,
906
1073
  }, "delegation finished");
907
- return JSON.stringify({
908
- status: childResult.status,
909
- finalText: childResult.finalText,
910
- costUsd: Number(childResult.costUsd),
911
- tokensIn: childResult.tokensIn,
912
- tokensOut: childResult.tokensOut,
913
- });
1074
+ return nativeChildResult(childResult, mergeOrderNote);
914
1075
  }
915
1076
  finally {
916
1077
  finishSibling();
917
1078
  release();
918
1079
  }
919
- }
920
- const tool = toolsByName.get(name);
921
- if (!tool) {
922
- return JSON.stringify({
923
- error: "unknown_tool",
924
- message: `No tool named "${name}" is attached to this agent.`,
925
- });
926
- }
927
- let parsedArgs;
928
- try {
929
- // Some providers stream no JSON delta at all for a zero-parameter
930
- // tool call, yielding an empty argsJson rather than "{}".
931
- parsedArgs = JSON.parse(argsJson || "{}");
932
- }
933
- catch (err) {
934
- return JSON.stringify({
935
- error: "invalid_arguments_json",
936
- message: err instanceof Error ? err.message : String(err),
937
- });
938
- }
939
- const validation = await validateParams(tool.paramsZod, parsedArgs);
940
- if (!validation.ok) {
941
- return JSON.stringify({ error: "validation_failed", message: validation.errorMessage });
942
- }
943
- const result = await runInSandbox({
944
- code: tool.code,
945
- params: validation.value,
946
- agentId: loaded.agentId,
947
- datastore: scopeDatastore(providers.datastore, tool.allowedDatastorePrefixes),
948
- sharedDatastore: scopeSharedDatastoreAccessor(sharedDatastoreAccessor, tool.allowedSharedDatastorePrefixes),
949
- secrets: scopeSecretsAccessor(secretsAccessor, tool.allowedSecrets),
950
- allowedFetchHosts: tool.allowedHosts,
951
- toolName: name,
1080
+ };
1081
+ }
1082
+ return undefined;
1083
+ };
1084
+ /**
1085
+ * The privileged host for one call of the user tool `name`, scoped to that attachment's
1086
+ * consented capabilities. Undefined for a name that is not an attached user tool. In-process it
1087
+ * serves the sandbox directly; the native sandbox gateway serves a worker's bridge calls with it.
1088
+ */
1089
+ const scopedHost = (name, tool, signal, redactSecretValues) => createPrivilegedHost({
1090
+ redactSecretValues,
1091
+ agentId: loaded.agentId,
1092
+ datastore: scopeDatastore(providers.datastore, tool.allowedDatastorePrefixes),
1093
+ sharedDatastore: scopeSharedDatastoreAccessor(sharedDatastoreAccessor, tool.allowedSharedDatastorePrefixes),
1094
+ secrets: scopeSecretsAccessor(secretsAccessor, tool.allowedSecrets),
1095
+ allowedFetchHosts: tool.allowedHosts,
1096
+ fetchImpl: providers.sandboxFetch,
1097
+ logTag: name,
1098
+ signal,
1099
+ });
1100
+ const privilegedHostFor = (name, signal, redactSecretValues) => {
1101
+ const tool = toolsByName.get(name);
1102
+ return tool ? scopedHost(name, tool, signal, redactSecretValues) : undefined;
1103
+ };
1104
+ /** Every secret value the user tool `name` may use (readable or brokered), for console redaction by a stateless gateway. */
1105
+ const readableSecretValues = async (name) => {
1106
+ const tool = toolsByName.get(name);
1107
+ if (!tool)
1108
+ return [];
1109
+ const scoped = scopeSecretsAccessor(secretsAccessor, tool.allowedSecrets);
1110
+ const values = await Promise.all(tool.allowedSecrets.map((secret) => (scoped.resolve
1111
+ ? scoped.resolve(secret).then((e) => (e ? consoleRedactionValues(e) : []))
1112
+ : scoped.get(secret).then((v) => (v === undefined ? [] : [v]))).catch(() => [])));
1113
+ // A SigV4 secret's key and token alone, too: the same set the in-process host redacts.
1114
+ return values.flat().filter((value) => typeof value === "string");
1115
+ };
1116
+ const runUserTool = async (name, argsJson) => {
1117
+ const tool = toolsByName.get(name);
1118
+ if (!tool) {
1119
+ return JSON.stringify({
1120
+ error: "unknown_tool",
1121
+ message: `No tool named "${name}" is attached to this agent.`,
1122
+ });
1123
+ }
1124
+ return runUserToolCall(tool, argsJson, (signal) => scopedHost(name, tool, signal));
1125
+ };
1126
+ const runSandboxTool = async (name, argsJson) => {
1127
+ const builtin = builtinHandler(name);
1128
+ return builtin ? builtin(argsJson) : runUserTool(name, argsJson);
1129
+ };
1130
+ return { toolsByName, builtinHandler, privilegedHostFor, readableSecretValues, runUserTool, runSandboxTool };
1131
+ }
1132
+ /**
1133
+ * Live progress for observers (MCP get_run, the viewer). Absolute totals, so a DBOS replay
1134
+ * re-writing them is harmless. Written into the run's own cost columns on purpose: the run
1135
+ * tree's shared budget (computeRunTreeSpend) then counts a running parent's spend so far, as it
1136
+ * already does for coding runs, whose proxy ledger writes their totals live. Best-effort: a
1137
+ * failed write only delays what observers see, and finishRun writes the final totals anyway.
1138
+ */
1139
+ export async function recordNativeRunProgress(db, runId, progress) {
1140
+ try {
1141
+ await db.run.updateMany({
1142
+ where: { id: runId, status: "running" },
1143
+ data: {
1144
+ turns: progress.turns,
1145
+ tokensIn: progress.usage.tokensIn,
1146
+ tokensOut: progress.usage.tokensOut,
1147
+ costUsd: progress.usage.costUsd,
1148
+ heartbeatAt: new Date(),
1149
+ },
1150
+ });
1151
+ }
1152
+ catch (err) {
1153
+ runnerLog.warn({ err, runId }, "failed to record run progress");
1154
+ }
1155
+ }
1156
+ /** Everything after the terminal write, shared by the normal and backstop paths. */
1157
+ async function settleFinishedNativeRun(ctx, finished, claimed) {
1158
+ const { runId, db, providers, reviewHosts, repoAccess, issueTrackers } = ctx;
1159
+ await closeOpenHostCheck(db, finished, reviewHosts);
1160
+ await completeHostStatus(db, finished, reviewHosts);
1161
+ await completeIssueStatus(db, finished, issueTrackers);
1162
+ // After the issue status, so IssuePullRequest rows for this run exist. Bounded and never throws.
1163
+ if (claimed)
1164
+ await updateRelatedPullRequests(db, finished, reviewHosts, issueTrackers);
1165
+ // A tree root is the lead of a request: once it is terminal every coding run it delegated is
1166
+ // too, so the reviews of their pull requests that waited for it start now, after the related
1167
+ // sections above are current (#259). Not gated on `claimed`: release is claimed per row, and a
1168
+ // lead another finalizer ended (a cancellation) should not leave them to the sweep. A run that
1169
+ // delegated nothing has no rows: one indexed lookup. Never throws.
1170
+ if (finished.parentRunId === null && providers.executor && reviewHosts && repoAccess) {
1171
+ await startDeferredForRequest({ db, executor: providers.executor, hosts: reviewHosts, repoAccess, issueTrackers }, runId).catch((err) => runnerLog.warn({ err, runId }, "could not start the reviews waiting for this run"));
1172
+ }
1173
+ if (claimed && providers.executor && reviewHosts && repoAccess) {
1174
+ await startReviewFixAfterReview(runId, {
1175
+ db,
1176
+ executor: providers.executor,
1177
+ hosts: reviewHosts,
1178
+ repoAccess,
1179
+ issueTrackers,
1180
+ });
1181
+ }
1182
+ // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
1183
+ // filed twice. Bounded and never throws.
1184
+ if (claimed)
1185
+ await fileSelfDefect(db, issueTrackers, finished);
1186
+ // Only the finalizer that made the row terminal notifies. Never throws.
1187
+ if (claimed)
1188
+ await emitRunFinishedEvents(db, finished);
1189
+ // A lead's end posts the `wardby merge order` check on each open pull request of its set,
1190
+ // once every delegated coding run has opened its PR (#261); last, after the release above (so
1191
+ // no review waits on it) and after the follow-ups and notifications (so none is delayed by its
1192
+ // host reads). Only for a tree root that delegated. Not gated on `claimed`: the sync is
1193
+ // idempotent (an unchanged check writes nothing). Never throws.
1194
+ await syncMergeOrderChecksAfterRun({ db, hosts: reviewHosts }, finished);
1195
+ return finished;
1196
+ }
1197
+ /** The terminal write for an engine result, then its usage, host/issue status, and follow-ups. */
1198
+ export async function finishNativeRun(ctx, model, result) {
1199
+ const { run: finished, claimed } = await finishRunClaimed(ctx.db, ctx.runId, {
1200
+ status: result.status,
1201
+ tokensIn: result.usage.tokensIn,
1202
+ tokensOut: result.usage.tokensOut,
1203
+ costUsd: result.usage.costUsd,
1204
+ error: result.error ?? null,
1205
+ finalText: result.finalText || null,
1206
+ turns: result.turns,
1207
+ finishedAt: new Date(),
1208
+ });
1209
+ // Per-model usage is written on the same path as Run.costUsd: any future mid-run cost write must write usage too.
1210
+ await recordNativeModelUsage(ctx.db, ctx.runId, model, result.usage);
1211
+ return settleFinishedNativeRun(ctx, finished, claimed);
1212
+ }
1213
+ /** The backstop terminal write for a run that threw. A cancellation is not a failure: it carries the operator's own reason. */
1214
+ export async function failNativeRun(ctx, err) {
1215
+ const { run: finished, claimed } = await finishRunClaimed(ctx.db, ctx.runId, {
1216
+ status: err instanceof RunCancelledError || err instanceof SandboxRunCancelledError ? "cancelled" : "failed",
1217
+ error: err instanceof Error ? err.message : String(err),
1218
+ finishedAt: new Date(),
1219
+ });
1220
+ return settleFinishedNativeRun(ctx, finished, claimed);
1221
+ }
1222
+ /** Where a run stands for the native gateway: still drivable, cancelled (its task), or otherwise ended. */
1223
+ export async function runDrivability(db, runId) {
1224
+ const run = await db.run.findUnique({ where: { id: runId }, select: { status: true } });
1225
+ if (!run || !DRIVABLE.includes(run.status)) {
1226
+ return run?.status === "cancelled" ? "cancelled" : "ended";
1227
+ }
1228
+ const cancelledTask = await db.task.findFirst({ where: { runId, status: "cancelled" }, select: { id: true } });
1229
+ return cancelledTask ? "cancelled" : "drivable";
1230
+ }
1231
+ /**
1232
+ * What a sandbox worker is given: the pinned agent and tool definitions, which names are built-ins
1233
+ * (served by the gateway), and each user tool's code and schema only — never its capabilities,
1234
+ * secret names, or values, which stay with the gateway's privileged host.
1235
+ */
1236
+ export function sandboxWorkerInput(runId, loaded, tools) {
1237
+ if (!loaded.pricing) {
1238
+ throw new Error("native_sandbox_requires_catalog: a sandbox run needs its model's catalog entry, and none was recorded.");
1239
+ }
1240
+ return {
1241
+ v: NATIVE_WORKER_PROTOCOL_VERSION,
1242
+ runId,
1243
+ agent: loaded.agent,
1244
+ tools: loaded.tools,
1245
+ builtinTools: loaded.tools.map((tool) => tool.name).filter((name) => tools.builtinHandler(name) !== undefined),
1246
+ userTools: Object.fromEntries([...tools.toolsByName].map(([name, tool]) => [name, { code: tool.code, paramsZod: tool.paramsZod }])),
1247
+ runsConcurrently: loaded.parallelDelegations
1248
+ ? loaded.tools.map((tool) => tool.name).filter((name) => name.startsWith(DELEGATE_TOOL_PREFIX))
1249
+ : [],
1250
+ pricing: { ...loaded.pricing.entry, efforts: [...loaded.pricing.entry.efforts] },
1251
+ };
1252
+ }
1253
+ /** Long-poll window: well under common proxy and load-balancer idle timeouts. */
1254
+ export const DELEGATION_POLL_WINDOW_MS = 20_000;
1255
+ /**
1256
+ * A delegate_to_* call from a sandboxed run, served by the native gateway. The in-process branch
1257
+ * holds its admission, budget wait, and child wait in one process's memory for the child's whole
1258
+ * life; this one keeps them on the call's ledger row, so the worker can call again with the same
1259
+ * callId — on any control-plane replica, before or after a restart — and continue where it was:
1260
+ *
1261
+ * - every child, native or coding, is a managed run dispatched through dispatchRun and the
1262
+ * Executor (never inline: an inline child dies with the replica, and the reconciler does not
1263
+ * reap unmanaged runs);
1264
+ * - admission re-checks the per-run delegation limit under a run-scoped advisory lock inside the
1265
+ * child's own transaction, so parallel delegations on different replicas cannot exceed it;
1266
+ * - a delegation the budget would refuse while sibling children still run is recorded as waiting
1267
+ * and re-evaluated on each call (parallelDelegations), up to the same bound as in process;
1268
+ * - each call waits on the child row up to `pollWindowMs`, then answers `pending`.
1269
+ *
1270
+ * Every refusal and result uses the same helpers, and so the same messages, as in process.
1271
+ */
1272
+ export async function durableDelegate(ctx, callId, name, argsJson) {
1273
+ const { runId, existingRun, loaded, providers, db, ledger, sessionId } = ctx;
1274
+ const now = ctx.now ?? Date.now;
1275
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1276
+ const finish = async (result) => {
1277
+ await ledger.recordResult(sessionId, callId, result);
1278
+ return { result };
1279
+ };
1280
+ const claimed = await ledger.claim(sessionId, callId, "builtin.call");
1281
+ if (claimed.outcome === "done")
1282
+ return { result: String(claimed.result) };
1283
+ const state = await ledger.delegation(sessionId, callId);
1284
+ const startedAt = state?.createdAt.getTime() ?? now();
1285
+ const boundName = name.slice(DELEGATE_TOOL_PREFIX.length);
1286
+ const edge = loaded.subAgentEdges.find((e) => e.boundName === boundName);
1287
+ if (!edge) {
1288
+ return finish(JSON.stringify({ error: "no_such_subagent", message: `No sub-agent is bound to name "${boundName}".` }));
1289
+ }
1290
+ const executor = providers.executor;
1291
+ if (!executor) {
1292
+ return finish(JSON.stringify({
1293
+ error: "delegation_unavailable",
1294
+ message: "This execution context has no Executor wired in, so the sub-agent cannot be dispatched.",
1295
+ }));
1296
+ }
1297
+ const parsedArgs = parseDelegateArgs(argsJson);
1298
+ if ("refusal" in parsedArgs)
1299
+ return finish(parsedArgs.refusal);
1300
+ const { args } = parsedArgs;
1301
+ const authorized = await authorizeDelegation(db, loaded.agentId, edge, boundName, args);
1302
+ if ("refusal" in authorized)
1303
+ return finish(authorized.refusal);
1304
+ const { childAgent } = authorized;
1305
+ const coding = childAgent.kind === "coding";
1306
+ const { queueTimeoutSec } = loadCodingConcurrencyConfig();
1307
+ let childRunId = state?.childRunId ?? null;
1308
+ if (!childRunId && state?.childAgentId === edge.childAgentId) {
1309
+ // A replica that died between dispatching the child and recording it left the child row:
1310
+ // adopt it rather than refusing this very delegation as a duplicate.
1311
+ const orphan = await db.run.findFirst({
1312
+ where: { parentRunId: runId, agentId: edge.childAgentId },
1313
+ select: { id: true },
1314
+ });
1315
+ childRunId = orphan?.id ?? null;
1316
+ }
1317
+ if (!childRunId) {
1318
+ const limit = loaded.maxDelegationsPerRun ?? 1;
1319
+ const refusalNow = async (reader) => {
1320
+ const prior = await reader.run.findMany({ where: { parentRunId: runId }, select: { agentId: true } });
1321
+ return delegationLimitRefusal({
1322
+ priorChildAgentIds: prior.map((p) => p.agentId),
1323
+ waitingChildAgentIds: await ledger.waitingChildAgentIds(sessionId, callId),
1324
+ childAgentId: edge.childAgentId,
1325
+ boundName,
1326
+ limit,
952
1327
  });
953
- if (!result.ok) {
954
- return JSON.stringify({ error: result.errorKind, message: result.errorMessage });
955
- }
956
- return JSON.stringify(result.value);
957
1328
  };
958
- // Live progress for observers (MCP get_run, the viewer). Absolute totals, so a DBOS replay
959
- // re-writing them is harmless. Written into the run's own cost columns on purpose: the run
960
- // tree's shared budget (computeRunTreeSpend) then counts a running parent's spend so far, as it
961
- // already does for coding runs, whose proxy ledger writes their totals live. Best-effort: a
962
- // failed write only delays what observers see, and finishRun writes the final totals anyway.
963
- const onProgress = async (progress) => {
964
- try {
965
- await db.run.updateMany({
966
- where: { id: runId, status: "running" },
967
- data: {
968
- turns: progress.turns,
969
- tokensIn: progress.usage.tokensIn,
970
- tokensOut: progress.usage.tokensOut,
971
- costUsd: progress.usage.costUsd,
972
- heartbeatAt: new Date(),
973
- },
974
- });
1329
+ const limitRefusal = await refusalNow(db);
1330
+ if (limitRefusal)
1331
+ return finish(limitRefusal);
1332
+ // parallelDelegations: a child the budget would refuse while a sibling still runs waits for a
1333
+ // sibling to finish and free its hold, up to the child's own wait bound. With no sibling
1334
+ // running it goes ahead and is refused at dispatch or at its first turn, as in process.
1335
+ if (loaded.parallelDelegations) {
1336
+ const siblings = await db.run.count({ where: { parentRunId: runId, status: { in: [...DRIVABLE] } } });
1337
+ if (siblings > 0) {
1338
+ const runTimeoutSec = coding ? (childAgent.codingProfile?.timeoutSec ?? 0) : 0;
1339
+ const boundMs = (queueTimeoutSec + runTimeoutSec + CODING_CHILD_WAIT_GRACE_SEC) * 1000;
1340
+ const { exhaustedBy } = await effectiveBudgetForRun(db, childAgent, new Date(), runId);
1341
+ if (exhaustedBy && now() - startedAt < boundMs) {
1342
+ await ledger.setDelegation(sessionId, callId, { status: "waiting_budget", childAgentId: edge.childAgentId });
1343
+ return { pending: true };
1344
+ }
975
1345
  }
976
- catch (err) {
977
- runnerLog.warn({ err, runId }, "failed to record run progress");
1346
+ }
1347
+ // Recorded before dispatch, so a replica dying in between leaves a row the next call adopts.
1348
+ await ledger.setDelegation(sessionId, callId, { status: "pending", childAgentId: edge.childAgentId });
1349
+ let dispatched;
1350
+ let refusedUnderLock = null;
1351
+ try {
1352
+ dispatched = await dispatchRun({
1353
+ db,
1354
+ executor,
1355
+ selfDefects: { db, issueTrackers: ctx.issueTrackers },
1356
+ agentId: edge.childAgentId,
1357
+ trigger: "subagent",
1358
+ ...(coding
1359
+ ? { codingTask: args.task, continuesCodingRunId: args.continuePriorRun, mergeOrder: args.mergeOrder }
1360
+ : { taskOverride: delegationTaskOverride(args), grantedParentMemoryKeys: args.grantParentMemoryKeys ?? [] }),
1361
+ parentRunId: runId,
1362
+ // The child's result flows back into this run, which the triggerer sees, so the child is visible to them too.
1363
+ triggeredById: existingRun.triggeredById,
1364
+ beforePersist: async (tx) => {
1365
+ // One admission per parent at a time, across every replica.
1366
+ await tx.$queryRaw `SELECT 1 AS locked FROM (SELECT pg_advisory_xact_lock(hashtext(${runId}))) AS l`;
1367
+ refusedUnderLock = await refusalNow(tx);
1368
+ return refusedUnderLock === null;
1369
+ },
1370
+ });
1371
+ }
1372
+ catch (err) {
1373
+ if (!(err instanceof ContinuationRefusedError))
1374
+ throw err;
1375
+ return finish(JSON.stringify({
1376
+ error: "continuation_refused",
1377
+ message: `${err.message} No sub-agent run was started. Do not open a new pull request in its place: ` +
1378
+ "tell the requester that this wardby deployment cannot continue that pull request's branch.",
1379
+ }));
1380
+ }
1381
+ if (!dispatched) {
1382
+ return finish(refusedUnderLock ??
1383
+ JSON.stringify({ error: "dispatch_failed", message: "The sub-agent run could not be created." }));
1384
+ }
1385
+ childRunId = dispatched.run.id;
1386
+ await ledger.setDelegation(sessionId, callId, { status: "pending", childAgentId: edge.childAgentId, childRunId });
1387
+ runnerLog.info({ runId, childRunId, boundName, kind: childAgent.kind, durable: true }, "delegation started");
1388
+ }
1389
+ const codingRun = coding
1390
+ ? await db.codingRun.findUnique({ where: { runId: childRunId }, select: { timeoutSec: true } })
1391
+ : null;
1392
+ const boundMs = coding
1393
+ ? (queueTimeoutSec + (codingRun?.timeoutSec ?? 0) + CODING_CHILD_WAIT_GRACE_SEC) * 1000
1394
+ : SANDBOX_CHILD_WAIT_SEC * 1000;
1395
+ const windowEnd = now() + (ctx.pollWindowMs ?? DELEGATION_POLL_WINDOW_MS);
1396
+ for (;;) {
1397
+ const child = await db.run.findUniqueOrThrow({ where: { id: childRunId } });
1398
+ if (!DRIVABLE.includes(child.status)) {
1399
+ runnerLog.info({ runId, childRunId, boundName, outcome: child.status, durable: true }, "delegation finished");
1400
+ if (!coding) {
1401
+ return finish(nativeChildResult(child, args.mergeOrder !== undefined ? MERGE_ORDER_IGNORED_NOTE : undefined));
978
1402
  }
979
- };
1403
+ const stored = await db.codingRun
1404
+ .findUnique({ where: { runId: childRunId }, select: { result: true } })
1405
+ .catch(() => null);
1406
+ return finish(codingChildResult(child, stored?.result));
1407
+ }
1408
+ if (now() - startedAt >= boundMs) {
1409
+ await executor.stop(childRunId, "sub-agent wait timed out").catch((err) => {
1410
+ runnerLog.warn({ err, childRunId, runId }, "failed to stop a timed-out sub-agent run");
1411
+ });
1412
+ return finish(JSON.stringify({
1413
+ error: "subagent_wait_timed_out",
1414
+ runId: childRunId,
1415
+ message: coding
1416
+ ? "The coding sub-agent did not finish within its queue timeout plus run timeout; it was stopped and produced no result."
1417
+ : "The sub-agent did not finish in time; it was stopped and produced no result.",
1418
+ }));
1419
+ }
1420
+ const remaining = windowEnd - now();
1421
+ if (remaining <= 0)
1422
+ return { pending: true };
1423
+ await sleep(Math.min(ctx.pollIntervalMs ?? 1_000, remaining));
1424
+ }
1425
+ }
1426
+ /** The integrations a native run uses, as executeRun derives them from its providers. */
1427
+ export function nativeRunIntegrations(providers, db) {
1428
+ const reviewHosts = configuredReviewHosts(providers.reviewHosts);
1429
+ const repoAccess = reviewHosts
1430
+ ? (providers.repoAccess ?? createRepoAccessGate({ db, hosts: reviewHosts }))
1431
+ : undefined;
1432
+ return { reviewHosts, repoAccess, issueTrackers: configuredIssueTrackers(providers.issueTrackers) };
1433
+ }
1434
+ /** How long a sandbox run's gateway session (and so its capability) stays valid. */
1435
+ export const SANDBOX_RUN_MAX_SEC = SANDBOX_CHILD_WAIT_SEC;
1436
+ /**
1437
+ * Starts a sandbox-mode run's trusted half: loads and pins the run, marks it running, records its
1438
+ * gateway session (capability hash, deadline, budget, snapshot), and records `native-sandbox` as
1439
+ * its execution backend — all before any worker exists. The caller launches the worker with the
1440
+ * returned input, which carries the gateway URL and the one-time capability.
1441
+ */
1442
+ export async function createSandboxSession(options) {
1443
+ const { runId, providers, db, ledger } = options;
1444
+ const now = options.now ?? new Date();
1445
+ const existingRun = await db.run.findUnique({ where: { id: runId } });
1446
+ if (!existingRun)
1447
+ throw new Error(`Unknown run "${runId}".`);
1448
+ if (!DRIVABLE.includes(existingRun.status))
1449
+ return { kind: "ended", run: existingRun };
1450
+ if (existingRun.nativeExecutionMode !== "sandbox")
1451
+ throw new Error(`Run "${runId}" is not a sandbox-mode run.`);
1452
+ const { reviewHosts, repoAccess, issueTrackers } = nativeRunIntegrations(providers, db);
1453
+ const finishContext = { runId, db, providers, reviewHosts, repoAccess, issueTrackers };
1454
+ const loadedOrUnavailable = await loadNativeRun({
1455
+ runId,
1456
+ existingRun,
1457
+ providers,
1458
+ db,
1459
+ step: runStepInline,
1460
+ reviewHosts,
1461
+ issueTrackers,
1462
+ });
1463
+ if ("unavailable" in loadedOrUnavailable) {
1464
+ return { kind: "ended", run: await failNativeRun(finishContext, new Error(loadedOrUnavailable.unavailable)) };
1465
+ }
1466
+ const loaded = loadedOrUnavailable;
1467
+ if (loaded.kind === "coding") {
1468
+ return { kind: "ended", run: await failNativeRun(finishContext, new Error(CODING_EXECUTOR_NOT_CONFIGURED)) };
1469
+ }
1470
+ const tools = createNativeRunTools({
1471
+ runId,
1472
+ existingRun,
1473
+ loaded,
1474
+ providers,
1475
+ db,
1476
+ reviewHosts,
1477
+ repoAccess,
1478
+ issueTrackers,
1479
+ });
1480
+ let input;
1481
+ try {
1482
+ input = sandboxWorkerInput(runId, loaded, tools);
1483
+ }
1484
+ catch (err) {
1485
+ return { kind: "ended", run: await failNativeRun(finishContext, err) };
1486
+ }
1487
+ await db.run.updateMany({ where: { id: runId, status: { in: [...DRIVABLE] } }, data: { status: "running" } });
1488
+ const capability = randomBytes(32).toString("base64url");
1489
+ const snapshot = { input, loaded };
1490
+ const session = await ledger.createSession({
1491
+ runId,
1492
+ capabilityHash: sandboxCapabilityHash(capability),
1493
+ deadlineAt: new Date(now.getTime() + SANDBOX_RUN_MAX_SEC * 1000),
1494
+ budgetUsd: loaded.agent.budgetUsd,
1495
+ snapshot: snapshot,
1496
+ networkReadyAt: options.networkReady === false ? null : now,
1497
+ });
1498
+ // The handle exists before any worker does, so the reconciler routes a stale run to the sandbox executor.
1499
+ await db.run.updateMany({
1500
+ where: { id: runId, executionBackend: null },
1501
+ data: { executionBackend: NATIVE_SANDBOX_BACKEND },
1502
+ });
1503
+ return {
1504
+ kind: "started",
1505
+ sessionId: session.id,
1506
+ capability,
1507
+ input: { ...input, gateway: { url: options.gatewayUrl, capability } },
1508
+ };
1509
+ }
1510
+ /** Run.executionBackend for a sandbox-mode run whose worker is (or was) launched. */
1511
+ export const NATIVE_SANDBOX_BACKEND = "native-sandbox";
1512
+ /** The stored form of a sandbox capability: never the token itself. */
1513
+ export function sandboxCapabilityHash(capability) {
1514
+ return createHash("sha256").update(capability).digest("hex");
1515
+ }
1516
+ /** Drives an existing Run (created by `createRun` or the scheduler) to a terminal state. */
1517
+ export async function executeRun(runId, providers, db = defaultDb, onText, step = runStepInline) {
1518
+ // Counted so a shutdown waits for this run instead of abandoning it (core/in-flight-runs.ts).
1519
+ return trackRun(runId, () => executeTrackedRun(runId, providers, db, onText, step));
1520
+ }
1521
+ async function executeTrackedRun(runId, providers, db, onText, step) {
1522
+ const existingRun = await db.run.findUnique({ where: { id: runId } });
1523
+ if (!existingRun) {
1524
+ throw new Error(`Unknown run "${runId}".`);
1525
+ }
1526
+ // Pre-flight guard. A run that already reached a terminal state must never
1527
+ // be re-driven: a durable workflow re-dispatched after the reconciler
1528
+ // reaped its row (rollback to EXECUTOR=in-process, then roll forward) would
1529
+ // otherwise re-spend the whole run against a `lost` row, and a duplicate
1530
+ // attempt of a finished run would spend a second time for a result no write
1531
+ // can land. Cheaper and clearer than letting it run and discarding the
1532
+ // result at the conditional write.
1533
+ if (!DRIVABLE.includes(existingRun.status)) {
1534
+ runnerLog.info({ runId, status: existingRun.status }, "skipping execution of an already-terminal run");
1535
+ return existingRun;
1536
+ }
1537
+ const reviewHosts = configuredReviewHosts(providers.reviewHosts);
1538
+ const repoAccess = reviewHosts
1539
+ ? (providers.repoAccess ?? createRepoAccessGate({ db, hosts: reviewHosts }))
1540
+ : undefined;
1541
+ const issueTrackers = configuredIssueTrackers(providers.issueTrackers);
1542
+ // Fails a run that ends before its engine starts, closing what its trigger opened (the review
1543
+ // check, the host and issue status comments) exactly as the normal and catch paths below do.
1544
+ // Deliberately no self-defect: every caller is a configuration state, not this run's defect (an
1545
+ // admin disabled or removed the model, or this deployment has no coding or native sandbox
1546
+ // executor), and filing would open one defect per affected agent rather than describe a failure
1547
+ // of that agent.
1548
+ const finishEarly = async (error) => {
1549
+ const finished = await finishRun(db, runId, { status: "failed", error, finishedAt: new Date() });
1550
+ await closeOpenHostCheck(db, finished, reviewHosts);
1551
+ await completeHostStatus(db, finished, reviewHosts);
1552
+ await completeIssueStatus(db, finished, issueTrackers);
1553
+ await emitRunFinishedEvents(db, finished);
1554
+ return finished;
1555
+ };
1556
+ // Decided by the run's own snapshot, before any work or spend (no pricing pin, no engine): a
1557
+ // sandbox-mode run never executes in the control plane.
1558
+ const sandboxed = existingRun.nativeExecutionMode === "sandbox";
1559
+ if (sandboxed && !providers.nativeSandbox)
1560
+ return finishEarly(NATIVE_SANDBOX_NOT_CONFIGURED);
1561
+ const loadedOrUnavailable = await loadNativeRun({
1562
+ runId,
1563
+ existingRun,
1564
+ providers,
1565
+ db,
1566
+ step,
1567
+ reviewHosts,
1568
+ issueTrackers,
1569
+ });
1570
+ if ("unavailable" in loadedOrUnavailable)
1571
+ return finishEarly(loadedOrUnavailable.unavailable);
1572
+ const loaded = loadedOrUnavailable;
1573
+ if (loaded.kind === "coding")
1574
+ return finishEarly(CODING_EXECUTOR_NOT_CONFIGURED);
1575
+ // Conditional on DRIVABLE rather than on `pending`: an adopted attempt
1576
+ // legitimately finds the row already `running`, but a terminal row must
1577
+ // never be flipped back to `running`.
1578
+ await db.run.updateMany({ where: { id: runId, status: { in: [...DRIVABLE] } }, data: { status: "running" } });
1579
+ const finishContext = { runId, db, providers, reviewHosts, repoAccess, issueTrackers };
1580
+ try {
1581
+ const tools = createNativeRunTools({
1582
+ runId,
1583
+ existingRun,
1584
+ loaded,
1585
+ providers,
1586
+ db,
1587
+ reviewHosts,
1588
+ repoAccess,
1589
+ issueTrackers,
1590
+ });
1591
+ const { runSandboxTool } = tools;
1592
+ const onProgress = (progress) => recordNativeRunProgress(db, runId, progress);
1593
+ const llm = loaded.pricing && providers.llm instanceof RoutingLlmProvider
1594
+ ? providers.llm.forRun(loaded.pricing.entry)
1595
+ : providers.llm;
1596
+ if (sandboxed && providers.nativeSandbox) {
1597
+ // The turn loop runs in a worker; this process serves its calls from the same tools and LLM.
1598
+ const engineResult = await runSandboxedEngine({
1599
+ runId,
1600
+ input: sandboxWorkerInput(runId, loaded, tools),
1601
+ ctx: { providers: { llm }, onText, onProgress },
1602
+ builtinHandler: tools.builtinHandler,
1603
+ privilegedHostFor: tools.privilegedHostFor,
1604
+ drivability: () => runDrivability(db, runId),
1605
+ launcher: providers.nativeSandbox,
1606
+ });
1607
+ return await finishNativeRun(finishContext, loaded.agent.model, engineResult);
1608
+ }
980
1609
  const engineResult = await providers.engine.run({
981
1610
  agent: loaded.agent,
982
1611
  tools: loaded.tools,
983
- providers: {
984
- llm: loaded.pricing && providers.llm instanceof RoutingLlmProvider
985
- ? providers.llm.forRun(loaded.pricing.entry)
986
- : providers.llm,
987
- },
1612
+ providers: { llm },
988
1613
  runSandboxTool,
989
1614
  // A replay recorded before this flag existed has none: its delegations stay sequential.
990
1615
  ...(loaded.parallelDelegations
@@ -994,70 +1619,22 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
994
1619
  onProgress,
995
1620
  step,
996
1621
  });
997
- const { run: finished, claimed } = await finishRunClaimed(db, runId, {
998
- status: engineResult.status,
999
- tokensIn: engineResult.usage.tokensIn,
1000
- tokensOut: engineResult.usage.tokensOut,
1001
- costUsd: engineResult.usage.costUsd,
1002
- error: engineResult.error ?? null,
1003
- finalText: engineResult.finalText || null,
1004
- turns: engineResult.turns,
1005
- finishedAt: new Date(),
1006
- });
1007
- // Per-model usage is written on the same path as Run.costUsd: any future mid-run cost write must write usage too.
1008
- await recordNativeModelUsage(db, runId, loaded.agent.model, engineResult.usage);
1009
- await closeOpenHostCheck(db, finished, reviewHosts);
1010
- await completeHostStatus(db, finished, reviewHosts);
1011
- await completeIssueStatus(db, finished, issueTrackers);
1012
- // After the issue status, so IssuePullRequest rows for this run exist. Bounded and never throws.
1013
- if (claimed)
1014
- await updateRelatedPullRequests(db, finished, reviewHosts, issueTrackers);
1015
- if (claimed && providers.executor && reviewHosts && repoAccess) {
1016
- await startReviewFixAfterReview(runId, {
1017
- db,
1018
- executor: providers.executor,
1019
- hosts: reviewHosts,
1020
- repoAccess,
1021
- issueTrackers,
1022
- });
1023
- }
1024
- // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
1025
- // filed twice. Bounded and never throws.
1026
- if (claimed)
1027
- await fileSelfDefect(db, issueTrackers, finished);
1028
- return finished;
1622
+ return await finishNativeRun(finishContext, loaded.agent.model, engineResult);
1029
1623
  }
1030
1624
  catch (err) {
1031
1625
  // Defensive backstop: the engine is expected to catch its own errors
1032
1626
  // and return a "failed" EngineResult, but an unexpected throw here
1033
1627
  // (a real bug, or tool-loading failing outside the per-tool try above)
1034
- // must still never leave the run dangling in "running". A cancellation
1035
- // is not a failure: it carries the operator's own reason.
1036
- const { run: finished, claimed } = await finishRunClaimed(db, runId, {
1037
- status: err instanceof RunCancelledError ? "cancelled" : "failed",
1038
- error: err instanceof Error ? err.message : String(err),
1039
- finishedAt: new Date(),
1040
- });
1041
- await closeOpenHostCheck(db, finished, reviewHosts);
1042
- await completeHostStatus(db, finished, reviewHosts);
1043
- await completeIssueStatus(db, finished, issueTrackers);
1044
- // After the issue status, so IssuePullRequest rows for this run exist. Bounded and never throws.
1045
- if (claimed)
1046
- await updateRelatedPullRequests(db, finished, reviewHosts, issueTrackers);
1047
- if (claimed && providers.executor && reviewHosts && repoAccess) {
1048
- await startReviewFixAfterReview(runId, {
1049
- db,
1050
- executor: providers.executor,
1051
- hosts: reviewHosts,
1052
- repoAccess,
1053
- issueTrackers,
1054
- });
1628
+ // must still never leave the run dangling in "running".
1629
+ if (err instanceof RunOwnershipLostError) {
1630
+ // Another execution of this run recorded a step first and owns the run now. Write nothing
1631
+ // and fire no settle side effects: the winner makes the terminal write. Rethrown (not
1632
+ // returned) so the durable executor hands the conflict back to its SDK, which parks this
1633
+ // attempt instead of recording a workflow outcome under the winner.
1634
+ logRunOwnershipLost(runId, err);
1635
+ throw err;
1055
1636
  }
1056
- // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
1057
- // filed twice. Bounded and never throws.
1058
- if (claimed)
1059
- await fileSelfDefect(db, issueTrackers, finished);
1060
- return finished;
1637
+ return failNativeRun(finishContext, err);
1061
1638
  }
1062
1639
  }
1063
1640
  /**