@wardby/cli 0.4.1 → 0.5.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 (221) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/generated/prisma/browser.d.ts +23 -0
  67. package/dist/generated/prisma/client.d.ts +23 -0
  68. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  69. package/dist/generated/prisma/internal/class.d.ts +33 -0
  70. package/dist/generated/prisma/internal/class.js +4 -4
  71. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  72. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  73. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  75. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  76. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  77. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  78. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  79. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  80. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  81. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  83. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  84. package/dist/generated/prisma/models/LocalReview.js +1 -0
  85. package/dist/generated/prisma/models/Run.d.ts +223 -0
  86. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  87. package/dist/generated/prisma/models.d.ts +3 -0
  88. package/dist/help-index.json +476 -37
  89. package/dist/import/neutral-schema.d.ts +2 -2
  90. package/dist/mcp/auth/access.d.ts +3 -1
  91. package/dist/mcp/auth/host-account-cli.js +2 -2
  92. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  93. package/dist/mcp/auth/repo-authorization.js +21 -0
  94. package/dist/mcp/index.js +4 -1
  95. package/dist/mcp/tools/agents.js +46 -4
  96. package/dist/mcp/tools/host-accounts.js +2 -2
  97. package/dist/mcp/tools/repositories.js +71 -12
  98. package/dist/mcp/tools/runs.js +23 -0
  99. package/dist/mcp/tools/trigger.js +153 -24
  100. package/dist/providers/engine/types.d.ts +11 -0
  101. package/dist/providers/executor/composition.js +12 -7
  102. package/dist/providers/executor/container.d.ts +40 -3
  103. package/dist/providers/executor/container.js +140 -9
  104. package/dist/providers/executor/types.d.ts +1 -1
  105. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  106. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  107. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  108. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  109. package/dist/providers/jobs/kubernetes-client.js +3 -0
  110. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  111. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  112. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  113. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  114. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  115. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  116. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  117. package/dist/providers/jobs/kubernetes.js +50 -0
  118. package/dist/providers/jobs/types.d.ts +7 -0
  119. package/dist/providers/review-host/ci.d.ts +7 -0
  120. package/dist/providers/review-host/ci.js +20 -0
  121. package/dist/providers/review-host/github-events.d.ts +7 -0
  122. package/dist/providers/review-host/github-events.js +31 -7
  123. package/dist/providers/review-host/github.d.ts +19 -2
  124. package/dist/providers/review-host/github.js +145 -2
  125. package/dist/providers/review-host/index.d.ts +8 -2
  126. package/dist/providers/review-host/index.js +14 -3
  127. package/dist/providers/review-host/local.d.ts +72 -0
  128. package/dist/providers/review-host/local.js +434 -0
  129. package/dist/providers/review-host/types.d.ts +65 -3
  130. package/dist/providers/review-host/types.js +5 -3
  131. package/dist/providers/vcs/git.d.ts +10 -20
  132. package/dist/providers/vcs/git.js +71 -127
  133. package/dist/providers/vcs/github-remote.d.ts +67 -0
  134. package/dist/providers/vcs/github-remote.js +201 -0
  135. package/dist/providers/vcs/github.d.ts +81 -0
  136. package/dist/providers/vcs/github.js +203 -6
  137. package/dist/providers/vcs/index.d.ts +16 -1
  138. package/dist/providers/vcs/index.js +43 -15
  139. package/dist/providers/vcs/local-remote.d.ts +44 -0
  140. package/dist/providers/vcs/local-remote.js +108 -0
  141. package/dist/providers/vcs/remote.d.ts +58 -0
  142. package/dist/providers/vcs/remote.js +1 -0
  143. package/dist/providers/vcs/routing.d.ts +33 -0
  144. package/dist/providers/vcs/routing.js +52 -0
  145. package/dist/providers/vcs/types.d.ts +12 -1
  146. package/dist/quickstart/coding-db.d.ts +11 -0
  147. package/dist/quickstart/coding-db.js +75 -0
  148. package/dist/quickstart/coding-doctor.d.ts +13 -0
  149. package/dist/quickstart/coding-doctor.js +88 -0
  150. package/dist/quickstart/coding-images.d.ts +15 -0
  151. package/dist/quickstart/coding-images.js +79 -0
  152. package/dist/quickstart/coding-seed.d.ts +63 -0
  153. package/dist/quickstart/coding-seed.js +85 -0
  154. package/dist/quickstart/coding.d.ts +66 -0
  155. package/dist/quickstart/coding.js +430 -0
  156. package/dist/quickstart/images.d.ts +19 -0
  157. package/dist/quickstart/images.js +63 -0
  158. package/dist/quickstart/index.js +61 -12
  159. package/dist/quickstart/starter-services.d.ts +45 -0
  160. package/dist/quickstart/starter-services.js +186 -0
  161. package/dist/quickstart-images.json +1 -0
  162. package/dist/serve.js +14 -1
  163. package/dist/viewer/api-schema.d.ts +176 -0
  164. package/dist/viewer/api-schema.js +27 -2
  165. package/dist/viewer/graph.d.ts +7 -2
  166. package/dist/viewer/graph.js +25 -11
  167. package/dist/viewer/http.d.ts +7 -1
  168. package/dist/viewer/http.js +8 -3
  169. package/dist/viewer/infra.d.ts +2 -0
  170. package/dist/viewer/infra.js +23 -0
  171. package/dist/viewer/run-detail.d.ts +2 -1
  172. package/dist/viewer/run-detail.js +2 -2
  173. package/docs/agent-recipes.md +123 -2
  174. package/docs/code-review-agents.md +293 -38
  175. package/docs/coding-agent-setup.md +141 -2
  176. package/docs/coding-packages.md +21 -0
  177. package/docs/coding-services.md +3 -0
  178. package/docs/coding-worker-isolation.md +56 -20
  179. package/docs/getting-started-gke.md +24 -0
  180. package/docs/getting-started.md +82 -8
  181. package/docs/jira-agents.md +26 -5
  182. package/docs/security-deployment.md +9 -0
  183. package/docs/viewer-api.md +21 -2
  184. package/help/admin-viewer.md +11 -1
  185. package/help/agent-recipes.md +25 -1
  186. package/help/builder-agent.md +4 -0
  187. package/help/code-review-agents.md +82 -2
  188. package/help/coding-packages.md +12 -1
  189. package/help/coding-services.md +4 -0
  190. package/help/cost-attribution.md +3 -1
  191. package/help/creating-agents.md +3 -0
  192. package/help/deploy-gke.md +2 -1
  193. package/help/errors/coding-provider-not-configured.md +60 -0
  194. package/help/errors/coding-turn-limit.md +25 -0
  195. package/help/errors/continuation-closed.md +75 -0
  196. package/help/errors/local-branch-conflict.md +37 -0
  197. package/help/errors/local-path-invalid.md +30 -0
  198. package/help/errors/local-ref-invalid.md +36 -0
  199. package/help/errors/local-ref-not-found.md +41 -0
  200. package/help/errors/local-repo-not-allowed.md +46 -0
  201. package/help/errors/local-repo-not-found.md +39 -0
  202. package/help/errors/model-unavailable.md +7 -6
  203. package/help/errors/vcs-github-not-configured.md +34 -0
  204. package/help/getting-started.md +4 -0
  205. package/help/github.md +22 -5
  206. package/help/jira.md +9 -2
  207. package/help/local-repositories.md +182 -0
  208. package/help/related-pull-requests.md +89 -0
  209. package/help/review-fix-rounds.md +69 -0
  210. package/help/troubleshooting/budgets.md +11 -0
  211. package/help/troubleshooting/coding-workers.md +22 -0
  212. package/help/troubleshooting/repository-access.md +5 -0
  213. package/package.json +3 -2
  214. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  215. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  216. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  217. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  218. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  219. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  220. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  221. package/prisma/schema.prisma +98 -1
@@ -26,6 +26,8 @@ import { scopeDatastore } from "../providers/datastore/scoped.js";
26
26
  import { buildSecretsAccessor, scopeSecretsAccessor } from "./secrets.js";
27
27
  import { buildSharedDatastoreAccessor, scopeSharedDatastoreAccessor } from "./datastores.js";
28
28
  import { effectiveBudgetForRun } from "./budget-groups.js";
29
+ import { createDelegationSiblings } from "./delegation-siblings.js";
30
+ import { createSerialGate } from "./serial-gate.js";
29
31
  import { withRunHeartbeat } from "./run-heartbeat.js";
30
32
  import { ContinuationRefusedError, dispatchRun } from "./dispatch.js";
31
33
  import { canDelegate } from "./grants.js";
@@ -38,12 +40,15 @@ import { loadCodingConcurrencyConfig } from "../config/providers.js";
38
40
  import { REVIEW_HOST_TOOL_DEFS, REVIEW_HOST_TOOL_NAMES, handleReviewHostTool, } from "./review-host-tools.js";
39
41
  import { closeOpenHostCheck } from "./review-host-checks.js";
40
42
  import { serviceRefusalSentence } from "../coding/services/wording.js";
43
+ import { CONTINUATION_CLOSED_SENTENCE, isContinuationClosedError } from "../coding/continuation-wording.js";
41
44
  import { RUN_TASK_TAG, splitTaskOverride, wrapUntrusted } from "./untrusted-content.js";
42
- import { completeHostStatus } from "./host-status.js";
45
+ import { completeHostStatus, pullRequestOutcome } from "./host-status.js";
43
46
  import { ISSUE_TRACKER_TOOL_DEFS, ISSUE_TRACKER_TOOL_NAMES, handleIssueTrackerTool, } from "./issue-tracker-tools.js";
44
47
  import { completeIssueStatus } from "./issue-status.js";
45
48
  import { fileIssue } from "./issue-dedupe.js";
46
49
  import { fileSelfDefect } from "./self-defects.js";
50
+ import { startReviewFixAfterReview } from "./review-fix.js";
51
+ import { updateRelatedPullRequests } from "./related-pull-requests.js";
47
52
  import { recordNativeModelUsage } from "./model-usage.js";
48
53
  import { pinNativeRunPricing } from "./run-pricing.js";
49
54
  import { RoutingLlmProvider } from "../providers/llm/routing.js";
@@ -54,11 +59,15 @@ const runnerLog = logger.child({ module: "runner" });
54
59
  /**
55
60
  * Sub-agent dispatch uses one synthetic tool per declared child, named by the
56
61
  * boundName it's attached
57
- * under. Synchronous only for v1: the parent's turn blocks until the child
58
- * run reaches a terminal state, and its result comes back as this tool
59
- * call's result. At most one dispatch is honored per run (see the
60
- * "already dispatched" check below) — a haiku-tier classifier deciding
61
- * plan-vs-implement should commit to exactly one child, not spray both.
62
+ * under. The parent's tool call blocks until the child run reaches a
63
+ * terminal state, and its result comes back as this tool call's result.
64
+ * A run delegates at most maxDelegationsPerRun times (default one: a
65
+ * classifier deciding plan-vs-implement commits to exactly one child) and to
66
+ * each child at most once. With Agent.parallelDelegations, the delegate_to_*
67
+ * calls of one turn run at the same time (NativeEngine.runToolCalls); their
68
+ * admission (the checks below plus the child row) is still one at a time,
69
+ * through the run's delegation gate, and a child refused for budget while a
70
+ * sibling runs waits for that sibling to finish (delegation-siblings.ts).
62
71
  *
63
72
  * Two execution paths depending on the child's `kind`:
64
73
  * - `native`: calls `executeRun` directly, bypassing any `Executor` — the
@@ -82,10 +91,15 @@ const runnerLog = logger.child({ module: "runner" });
82
91
  * The `delegate_to_` prefix lives in core/tool-names.ts, which reserves it
83
92
  * so no user tool can be created under a name this dispatch would shadow.
84
93
  */
85
- function delegateToolDef(boundName) {
94
+ function delegateToolDef(boundName, parallel) {
95
+ // With the flag off the description stays byte-identical to before, so
96
+ // existing leads keep their cached tool definitions.
97
+ const timing = parallel
98
+ ? "Blocks until it finishes. Several delegate_to_* calls made in the same turn run at the same time, so make them together when their tasks don't need each other's results. Its spend"
99
+ : "Runs synchronously and blocks until it finishes; its spend";
86
100
  return {
87
101
  name: `${DELEGATE_TOOL_PREFIX}${boundName}`,
88
- description: `Delegates a task to your "${boundName}" sub-agent. Runs synchronously and blocks until it finishes; its spend counts against your own run's shared budget scope. If the sub-agent belongs to another owner it runs only its own instructions: pass an empty task.`,
102
+ description: `Delegates a task to your "${boundName}" sub-agent. ${timing} counts against your own run's shared budget scope. If the sub-agent belongs to another owner it runs only its own instructions: pass an empty task.`,
89
103
  jsonSchema: {
90
104
  type: "object",
91
105
  properties: {
@@ -189,7 +203,7 @@ async function finishRunClaimed(db, runId, data) {
189
203
  * ("requires the Phase 5 container executor") read as if Phase 5 were unbuilt.
190
204
  */
191
205
  const CODING_EXECUTOR_NOT_CONFIGURED = "Coding agents need a container executor, but this deployment runs with JOB_LAUNCHER=local. " +
192
- "Set JOB_LAUNCHER=docker on a host with Docker to run them (see docs/coding-worker-isolation.md).";
206
+ "Set JOB_LAUNCHER=docker (or kubernetes) to run them (see docs/coding-worker-isolation.md).";
193
207
  /**
194
208
  * Slack past the child's own queue wait + container deadline before the
195
209
  * parent gives up: the executor needs a moment after a deadline kill to
@@ -201,8 +215,17 @@ export const CODING_CHILD_WAIT_GRACE_SEC = 60;
201
215
  * over its services carries the host sentence (never the raw Run.error), so
202
216
  * the parent can pass it on — for a mention, that is the requester.
203
217
  */
204
- export function codingChildResult(run) {
205
- const refusal = run.status === "refused" ? serviceRefusalSentence(run.error) : null;
218
+ export function codingChildResult(run, codingResult) {
219
+ // Checked before the "refused" branch: a continuation whose PR closed
220
+ // before the run ever spent anything is stored as "refused" (a preflight
221
+ // refusal, container.ts), not "failed" -- but it is the same
222
+ // continuation_closed category either way, and gets the same sentence
223
+ // regardless of which status it landed on.
224
+ const refusal = isContinuationClosedError(run.error)
225
+ ? CONTINUATION_CLOSED_SENTENCE
226
+ : run.status === "refused"
227
+ ? serviceRefusalSentence(run.error)
228
+ : null;
206
229
  return JSON.stringify({
207
230
  status: run.status,
208
231
  finalText: run.finalText,
@@ -210,8 +233,26 @@ export function codingChildResult(run) {
210
233
  tokensIn: run.tokensIn,
211
234
  tokensOut: run.tokensOut,
212
235
  ...(refusal ? { refusal } : {}),
236
+ ...pullRequestOf(codingResult),
213
237
  });
214
238
  }
239
+ /**
240
+ * The pull request the coding sub-run opened or pushed to, from wardby's own
241
+ * stored result (never the sub-agent's text), so the parent can cite it.
242
+ */
243
+ function pullRequestOf(codingResult) {
244
+ const pr = pullRequestOutcome(codingResult);
245
+ if (!pr)
246
+ return {};
247
+ return {
248
+ pullRequest: {
249
+ outcome: pr.outcome === "pull_request_opened" ? "opened" : "updated",
250
+ repository: pr.repository,
251
+ number: pr.pullRequestNumber,
252
+ ...(pr.pullRequestUrl ? { url: pr.pullRequestUrl } : {}),
253
+ },
254
+ };
255
+ }
215
256
  /**
216
257
  * Polls a dispatched coding child until it reaches a terminal status. Needed
217
258
  * because a queued child (every concurrency slot taken) makes Executor.start
@@ -334,14 +375,16 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
334
375
  where: { parentAgentId: agent.id },
335
376
  select: { boundName: true, childAgentId: true },
336
377
  });
337
- // Only queried when a review host is configured, so deployments without
338
- // a GitHub App (and tests) never touch the table.
378
+ // Only queried when the runner was given review hosts. buildReviewHosts
379
+ // always includes the local host, so a configured server always queries;
380
+ // only a runner built without hosts (as in many tests) skips the table.
339
381
  const repositoryLinks = reviewHosts
340
382
  ? (await db.agentRepository.findMany({ where: { agentId: agent.id } })).map((l) => ({
341
383
  provider: l.provider,
342
384
  repository: l.repository,
343
385
  access: l.access === "write" ? "write" : "read",
344
386
  checkName: l.checkName,
387
+ waitForCi: l.waitForCi,
345
388
  }))
346
389
  : [];
347
390
  // Likewise only when a Jira site is configured.
@@ -375,6 +418,10 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
375
418
  pricing,
376
419
  memoryEnabled: agent.memoryEnabled,
377
420
  subAgentEdges,
421
+ // Only when above the default of one, so a default agent's pinned step result is unchanged.
422
+ ...(agent.maxDelegationsPerRun > 1 ? { maxDelegationsPerRun: agent.maxDelegationsPerRun } : {}),
423
+ // Only when on, so a default agent's pinned step result is unchanged.
424
+ ...(agent.parallelDelegations ? { parallelDelegations: true } : {}),
378
425
  repositoryLinks,
379
426
  issueProjectLinks,
380
427
  agent: {
@@ -407,7 +454,7 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
407
454
  ...(agent.memoryEnabled ? MEMORY_TOOL_DEFS : []),
408
455
  ...(subAgentEdges.length > 0 ? [SUBAGENT_MEMORY_GET_TOOL] : []),
409
456
  ...(isDispatchedChild ? [PARENT_MEMORY_GET_TOOL] : []),
410
- ...subAgentEdges.map((edge) => delegateToolDef(edge.boundName)),
457
+ ...subAgentEdges.map((edge) => delegateToolDef(edge.boundName, agent.parallelDelegations)),
411
458
  ...(repositoryLinks.length > 0 ? REVIEW_HOST_TOOL_DEFS : []),
412
459
  ...(issueProjectLinks.length > 0 ? ISSUE_TRACKER_TOOL_DEFS : []),
413
460
  ],
@@ -469,6 +516,13 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
469
516
  // jira_create_issue's per-run cap counter: shared by every tool call of this attempt (a resumed attempt
470
517
  // starts a fresh one, floored by the run's recorded fingerprint creates).
471
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();
472
526
  const runSandboxTool = async (name, argsJson) => {
473
527
  if (loaded.memoryEnabled && MEMORY_TOOL_NAMES.has(name)) {
474
528
  return handleMemoryTool(name, argsJson, loaded.agentId, providers.memory);
@@ -488,8 +542,15 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
488
542
  prNumber: check.prNumber,
489
543
  }
490
544
  : null,
491
- markRunCheckCompleted: async () => {
492
- await db.runHostCheck.update({ where: { runId }, data: { completedAt: new Date() } });
545
+ markRunCheckCompleted: async (review) => {
546
+ await db.runHostCheck.update({
547
+ where: { runId },
548
+ data: {
549
+ completedAt: new Date(),
550
+ ...(review ? { verdict: review.verdict, reviewBody: review.body } : {}),
551
+ ...(review?.ciPending !== undefined ? { ciPendingAtReview: review.ciPending } : {}),
552
+ },
553
+ });
493
554
  },
494
555
  // Live, not from the pinned load: the link's current stamp and
495
556
  // access, and the agent's CURRENT owner (a make_owner, unlink, or
@@ -560,195 +621,301 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
560
621
  message: `No sub-agent is bound to name "${boundName}".`,
561
622
  });
562
623
  }
563
- // At most one dispatch per run: a classifier deciding plan-vs-implement
564
- // should commit to exactly one child, never both and never a retry
565
- // that leaves two children racing on the same task.
566
- const priorDispatches = await db.run.findMany({ where: { parentRunId: { in: [runId] } } });
567
- if (priorDispatches.length > 0) {
568
- return JSON.stringify({
569
- error: "already_dispatched",
570
- message: "This run already delegated to a sub-agent; only one delegation is allowed per run.",
571
- });
572
- }
573
- let parsed;
624
+ let release = await delegationGate.acquire();
625
+ let finishSibling = () => { };
574
626
  try {
575
- parsed = JSON.parse(argsJson || "{}");
576
- }
577
- catch (err) {
578
- return JSON.stringify({
579
- error: "invalid_arguments_json",
580
- message: err instanceof Error ? err.message : String(err),
581
- });
582
- }
583
- let args;
584
- try {
585
- args = DelegateArgs.parse(parsed);
586
- }
587
- catch (err) {
588
- if (err instanceof z.ZodError) {
627
+ // At most maxDelegationsPerRun dispatches per run (default one: a
628
+ // classifier deciding plan-vs-implement commits to exactly one child),
629
+ // and never the same child twice, so a retry can't leave two children
630
+ // racing on the same task. Checked and the child row written under the
631
+ // run's delegation gate: delegations started together in one turn
632
+ // (parallelDelegations) are admitted one at a time.
633
+ const limit = loaded.maxDelegationsPerRun ?? 1;
634
+ const priorDispatches = await db.run.findMany({ where: { parentRunId: { in: [runId] } } });
635
+ if (priorDispatches.some((prior) => prior.agentId === edge.childAgentId) ||
636
+ waitingDelegations.has(edge.childAgentId)) {
589
637
  return JSON.stringify({
590
- error: "validation_failed",
591
- message: err.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "),
638
+ error: "already_dispatched",
639
+ message: `This run already delegated to "${boundName}"; a run delegates to each sub-agent at most once.`,
592
640
  });
593
641
  }
594
- throw err;
595
- }
596
- // Owners are re-read live on every delegation (resource-sharing
597
- // grants spec §3.4.4, N1): the child must have the parent's current
598
- // owner, or that owner must still hold execute on the child. A
599
- // revoked grant or a make_owner transfer stops the very next call.
600
- const [parentNow, childAgent] = await Promise.all([
601
- db.agent.findUnique({ where: { id: loaded.agentId }, select: { ownerId: true } }),
602
- db.agent.findUniqueOrThrow({
603
- where: { id: edge.childAgentId },
604
- select: {
605
- id: true,
606
- kind: true,
607
- budgetGroupId: true,
608
- budgetUsd: true,
609
- ownerId: true,
610
- codingProfile: { select: { allowWebhookTaskOverride: true } },
611
- },
612
- }),
613
- ]);
614
- const parentOwnerId = parentNow?.ownerId ?? null;
615
- const childOwnerId = childAgent.ownerId ?? null;
616
- if (!parentNow ||
617
- !(await canDelegate(db, { ownerId: parentOwnerId }, { id: childAgent.id, ownerId: childOwnerId }))) {
618
- return JSON.stringify({
619
- error: "subagent_not_authorized",
620
- message: `The "${boundName}" sub-agent belongs to another owner who has not given this agent's owner execute access to it.`,
621
- });
622
- }
623
- if (parentOwnerId !== childOwnerId) {
624
- // Across owners the edge carries execute and nothing more: no
625
- // model-chosen memory grant, no continuation of another run's PR,
626
- // and no task text the caller couldn't give the child directly --
627
- // the trigger_agent rule (review I3): a coding child only with its
628
- // owner's allowWebhookTaskOverride opt-in, a native child never
629
- // (it runs its owner's fixed prompt; pass an empty task).
630
- const refusal = (args.grantParentMemoryKeys?.length ?? 0) > 0
631
- ? "grantParentMemoryKeys is only allowed when the sub-agent has the same owner."
632
- : args.continuePriorRun !== undefined
633
- ? "continuePriorRun is only allowed when the sub-agent has the same owner."
634
- : childAgent.kind === "coding" && !childAgent.codingProfile?.allowWebhookTaskOverride
635
- ? "This coding sub-agent belongs to another owner and does not accept task text from others (allowWebhookTaskOverride)."
636
- : childAgent.kind !== "coding" && (args.task.trim() !== "" || args.datastoreRef !== undefined)
637
- ? 'This sub-agent belongs to another owner and runs only its own instructions: delegate with task "" and no datastoreRef.'
638
- : null;
639
- if (refusal)
640
- return JSON.stringify({ error: "cross_owner_not_allowed", message: refusal });
641
- }
642
- if (args.continuePriorRun !== undefined && childAgent.kind !== "coding") {
643
- return JSON.stringify({
644
- error: "continuation_requires_coding_agent",
645
- message: "continuePriorRun is only supported when the sub-agent is coding-kind.",
646
- });
647
- }
648
- if (childAgent.kind === "coding") {
649
- // Coding-kind children need a real Docker container (Codex/Claude
650
- // Code), which executeRun explicitly refuses to drive — go through
651
- // the same dispatchRun path webhooks/trigger_agent use instead.
652
- // datastoreRef/grantParentMemoryKeys have no equivalent inside a
653
- // coding container's own tool surface (subagent_memory_get/
654
- // parent_memory_get are native-engine built-ins only), so they're
655
- // deliberately dropped here rather than silently implying a
656
- // capability that doesn't exist for this path.
657
- if (!providers.executor) {
642
+ if (priorDispatches.length < limit && priorDispatches.length + waitingDelegations.size >= limit) {
643
+ // Only a waiting delegation (parallelDelegations) holds the remaining place.
658
644
  return JSON.stringify({
659
- error: "coding_dispatch_unavailable",
660
- message: "This execution context has no Executor wired in, so a coding-kind sub-agent cannot be dispatched.",
645
+ error: "already_dispatched",
646
+ message: `This run reached its limit of ${limit} delegations: ${priorDispatches.length} made and ${waitingDelegations.size} waiting for budget.`,
661
647
  });
662
648
  }
663
- // dispatchRun reserves the child's budget itself, inside its persist
664
- // transaction: the agent's budgetUsd tightened by its budget group and
665
- // by this run tree (parentRunId), or refused when either is spent.
666
- let dispatched;
667
- try {
668
- dispatched = await dispatchRun({
669
- db,
670
- executor: providers.executor,
671
- selfDefects: { db, issueTrackers },
672
- agentId: edge.childAgentId,
673
- trigger: "subagent",
674
- codingTask: args.task,
675
- continuesCodingRunId: args.continuePriorRun,
676
- parentRunId: runId,
677
- // The child's result flows back into this run, which the
678
- // triggerer sees, so the child is visible to them too.
679
- triggeredById: existingRun.triggeredById,
680
- awaitExecution: true,
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.`,
681
655
  });
682
656
  }
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(argsJson || "{}");
660
+ }
683
661
  catch (err) {
684
- // A continuation this deployment cannot make (the run id came from
685
- // a pull request another deployment opened, say) is the model's to
686
- // report, not a reason to fail the whole run.
687
- if (!(err instanceof ContinuationRefusedError))
688
- throw err;
689
662
  return JSON.stringify({
690
- error: "continuation_refused",
691
- message: `${err.message} No sub-agent run was started. Do not open a new pull request in its place: ` +
692
- "tell the requester that this wardby deployment cannot continue that pull request's branch.",
663
+ error: "invalid_arguments_json",
664
+ message: err instanceof Error ? err.message : String(err),
693
665
  });
694
666
  }
695
- if (!dispatched) {
696
- return JSON.stringify({ error: "dispatch_failed", message: "The sub-agent run could not be created." });
667
+ let args;
668
+ try {
669
+ args = DelegateArgs.parse(parsed);
697
670
  }
698
- const childRunId = dispatched.run.id;
699
- const codingRun = await db.codingRun.findUnique({
700
- where: { runId: childRunId },
701
- select: { timeoutSec: true },
702
- });
703
- const { queueTimeoutSec } = loadCodingConcurrencyConfig();
704
- const waited = await waitForCodingChild({
705
- db,
706
- executor: providers.executor,
707
- childRunId,
708
- parentRunId: runId,
709
- boundMs: (queueTimeoutSec + (codingRun?.timeoutSec ?? 0) + CODING_CHILD_WAIT_GRACE_SEC) * 1000,
710
- });
711
- if (waited.kind === "timed_out") {
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 }))) {
712
702
  return JSON.stringify({
713
- error: "subagent_wait_timed_out",
714
- runId: childRunId,
715
- message: "The coding sub-agent did not finish within its queue timeout plus run timeout; it was stopped and produced no result.",
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.`,
716
705
  });
717
706
  }
718
- if (waited.kind === "parent_cancelled") {
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") {
719
727
  return JSON.stringify({
720
- error: "parent_cancelled",
721
- runId: childRunId,
722
- message: "This run was cancelled while waiting for the coding sub-agent; the sub-agent was stopped.",
728
+ error: "continuation_requires_coding_agent",
729
+ message: "continuePriorRun is only supported when the sub-agent is coding-kind.",
723
730
  });
724
731
  }
725
- return codingChildResult(waited.run);
732
+ // parallelDelegations: a child the budget would refuse (run_tree_exhausted or
733
+ // budget_group_exhausted:*) while a sibling still runs waits for a sibling to finish and
734
+ // free its hold, then tries again while siblings remain, up to the child's own wait bound
735
+ // (as for waitForCodingChild below). With no sibling running, it is refused as before.
736
+ if (loaded.parallelDelegations && delegationSiblings.inFlight > 0) {
737
+ const { queueTimeoutSec } = loadCodingConcurrencyConfig();
738
+ const runTimeoutSec = childAgent.kind === "coding" ? (childAgent.codingProfile?.timeoutSec ?? 0) : 0;
739
+ const deadline = Date.now() + (queueTimeoutSec + runTimeoutSec + CODING_CHILD_WAIT_GRACE_SEC) * 1000;
740
+ let waited = false;
741
+ while (delegationSiblings.inFlight > 0) {
742
+ // Taken before the budget read, so a sibling that finishes during it still wakes this wait.
743
+ const finishes = delegationSiblings.finishes;
744
+ const { exhaustedBy } = await effectiveBudgetForRun(db, childAgent, new Date(), runId);
745
+ const remainingMs = deadline - Date.now();
746
+ if (!exhaustedBy || remainingMs <= 0)
747
+ break;
748
+ runnerLog.info({ runId, boundName, exhaustedBy, siblingsInFlight: delegationSiblings.inFlight }, "delegation waiting for a sibling to free budget");
749
+ waitingDelegations.add(edge.childAgentId);
750
+ waited = true;
751
+ release();
752
+ try {
753
+ await delegationSiblings.nextFinish(remainingMs, finishes);
754
+ }
755
+ finally {
756
+ release = await delegationGate.acquire();
757
+ waitingDelegations.delete(edge.childAgentId);
758
+ }
759
+ }
760
+ // The parent may have been stopped while this delegation waited: start nothing for it.
761
+ if (waited) {
762
+ const parent = await db.run.findUnique({ where: { id: runId }, select: { status: true } });
763
+ const parentEnded = !parent || !DRIVABLE.includes(parent.status);
764
+ if (parentEnded ||
765
+ (await db.task.findFirst({ where: { runId, status: "cancelled" }, select: { id: true } })) !== null) {
766
+ return JSON.stringify({
767
+ error: "parent_cancelled",
768
+ message: "This run was cancelled while the sub-agent waited for budget; no sub-agent run was started.",
769
+ });
770
+ }
771
+ }
772
+ }
773
+ if (childAgent.kind === "coding") {
774
+ // Coding-kind children need a real Docker container (Codex/Claude
775
+ // Code), which executeRun explicitly refuses to drive — go through
776
+ // the same dispatchRun path webhooks/trigger_agent use instead.
777
+ // datastoreRef/grantParentMemoryKeys have no equivalent inside a
778
+ // coding container's own tool surface (subagent_memory_get/
779
+ // parent_memory_get are native-engine built-ins only), so they're
780
+ // deliberately dropped here rather than silently implying a
781
+ // capability that doesn't exist for this path.
782
+ if (!providers.executor) {
783
+ return JSON.stringify({
784
+ error: "coding_dispatch_unavailable",
785
+ message: "This execution context has no Executor wired in, so a coding-kind sub-agent cannot be dispatched.",
786
+ });
787
+ }
788
+ // dispatchRun reserves the child's budget itself, inside its persist
789
+ // transaction: the agent's budgetUsd tightened by its budget group and
790
+ // by this run tree (parentRunId), or refused when either is spent.
791
+ let dispatched;
792
+ try {
793
+ dispatched = await dispatchRun({
794
+ db,
795
+ executor: providers.executor,
796
+ selfDefects: { db, issueTrackers },
797
+ agentId: edge.childAgentId,
798
+ trigger: "subagent",
799
+ codingTask: args.task,
800
+ continuesCodingRunId: args.continuePriorRun,
801
+ parentRunId: runId,
802
+ // The child's result flows back into this run, which the
803
+ // triggerer sees, so the child is visible to them too.
804
+ triggeredById: existingRun.triggeredById,
805
+ awaitExecution: true,
806
+ // The child row is committed: count it as running (unless refused at dispatch) and
807
+ // admit the next delegation while this one's run starts or queues.
808
+ onPersisted: (persistedRun) => {
809
+ if (persistedRun.status !== "refused" && persistedRun.status !== "failed") {
810
+ finishSibling = delegationSiblings.start();
811
+ }
812
+ release();
813
+ },
814
+ });
815
+ }
816
+ catch (err) {
817
+ // A continuation this deployment cannot make (the run id came from
818
+ // a pull request another deployment opened, say) is the model's to
819
+ // report, not a reason to fail the whole run.
820
+ if (!(err instanceof ContinuationRefusedError))
821
+ throw err;
822
+ return JSON.stringify({
823
+ error: "continuation_refused",
824
+ message: `${err.message} No sub-agent run was started. Do not open a new pull request in its place: ` +
825
+ "tell the requester that this wardby deployment cannot continue that pull request's branch.",
826
+ });
827
+ }
828
+ if (!dispatched) {
829
+ return JSON.stringify({ error: "dispatch_failed", message: "The sub-agent run could not be created." });
830
+ }
831
+ const childRunId = dispatched.run.id;
832
+ const startedAt = Date.now();
833
+ runnerLog.info({ runId, childRunId, boundName, kind: "coding" }, "delegation started");
834
+ const codingRun = await db.codingRun.findUnique({
835
+ where: { runId: childRunId },
836
+ select: { timeoutSec: true },
837
+ });
838
+ const { queueTimeoutSec } = loadCodingConcurrencyConfig();
839
+ const waited = await waitForCodingChild({
840
+ db,
841
+ executor: providers.executor,
842
+ childRunId,
843
+ parentRunId: runId,
844
+ boundMs: (queueTimeoutSec + (codingRun?.timeoutSec ?? 0) + CODING_CHILD_WAIT_GRACE_SEC) * 1000,
845
+ });
846
+ finishSibling();
847
+ runnerLog.info({
848
+ runId,
849
+ childRunId,
850
+ boundName,
851
+ outcome: waited.kind === "terminal" ? waited.run.status : waited.kind,
852
+ durationMs: Date.now() - startedAt,
853
+ }, "delegation finished");
854
+ if (waited.kind === "timed_out") {
855
+ return JSON.stringify({
856
+ error: "subagent_wait_timed_out",
857
+ runId: childRunId,
858
+ message: "The coding sub-agent did not finish within its queue timeout plus run timeout; it was stopped and produced no result.",
859
+ });
860
+ }
861
+ if (waited.kind === "parent_cancelled") {
862
+ return JSON.stringify({
863
+ error: "parent_cancelled",
864
+ runId: childRunId,
865
+ message: "This run was cancelled while waiting for the coding sub-agent; the sub-agent was stopped.",
866
+ });
867
+ }
868
+ const stored = await db.codingRun
869
+ .findUnique({ where: { runId: childRunId }, select: { result: true } })
870
+ .catch((err) => {
871
+ runnerLog.warn({ err, childRunId }, "could not read the coding sub-run's result");
872
+ return null;
873
+ });
874
+ return codingChildResult(waited.run, stored?.result);
875
+ }
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;
881
+ const childRun = await db.run.create({
882
+ data: {
883
+ agentId: edge.childAgentId,
884
+ trigger: "subagent",
885
+ parentRunId: runId,
886
+ grantedParentMemoryKeys: args.grantParentMemoryKeys ?? [],
887
+ taskOverride,
888
+ triggeredById: existingRun.triggeredById,
889
+ },
890
+ });
891
+ finishSibling = delegationSiblings.start();
892
+ // The child row exists: admit the next delegation while this child runs.
893
+ release();
894
+ const startedAt = Date.now();
895
+ runnerLog.info({ runId, childRunId: childRun.id, boundName, kind: "native" }, "delegation started");
896
+ // Runs inline, so no executor beats for it: beat here to keep its
897
+ // budget-group hold live while it runs (and let it lapse if we die).
898
+ const childResult = await withRunHeartbeat(db, childRun.id, () => executeRun(childRun.id, providers, db));
899
+ finishSibling();
900
+ runnerLog.info({
901
+ runId,
902
+ childRunId: childRun.id,
903
+ boundName,
904
+ outcome: childResult.status,
905
+ durationMs: Date.now() - startedAt,
906
+ }, "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
+ });
914
+ }
915
+ finally {
916
+ finishSibling();
917
+ release();
726
918
  }
727
- const taskOverride = args.datastoreRef
728
- ? `${args.task}\n\n[Referenced datastore: name="${args.datastoreRef.name}", key="${args.datastoreRef.key}" — use your datastore tools to read it.]`
729
- : args.task.trim() !== ""
730
- ? args.task
731
- : undefined;
732
- const childRun = await db.run.create({
733
- data: {
734
- agentId: edge.childAgentId,
735
- trigger: "subagent",
736
- parentRunId: runId,
737
- grantedParentMemoryKeys: args.grantParentMemoryKeys ?? [],
738
- taskOverride,
739
- triggeredById: existingRun.triggeredById,
740
- },
741
- });
742
- // Runs inline, so no executor beats for it: beat here to keep its
743
- // budget-group hold live while it runs (and let it lapse if we die).
744
- const childResult = await withRunHeartbeat(db, childRun.id, () => executeRun(childRun.id, providers, db));
745
- return JSON.stringify({
746
- status: childResult.status,
747
- finalText: childResult.finalText,
748
- costUsd: Number(childResult.costUsd),
749
- tokensIn: childResult.tokensIn,
750
- tokensOut: childResult.tokensOut,
751
- });
752
919
  }
753
920
  const tool = toolsByName.get(name);
754
921
  if (!tool) {
@@ -819,6 +986,10 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
819
986
  : providers.llm,
820
987
  },
821
988
  runSandboxTool,
989
+ // A replay recorded before this flag existed has none: its delegations stay sequential.
990
+ ...(loaded.parallelDelegations
991
+ ? { runsConcurrently: (toolName) => toolName.startsWith(DELEGATE_TOOL_PREFIX) }
992
+ : {}),
822
993
  onText,
823
994
  onProgress,
824
995
  step,
@@ -838,6 +1009,18 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
838
1009
  await closeOpenHostCheck(db, finished, reviewHosts);
839
1010
  await completeHostStatus(db, finished, reviewHosts);
840
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
+ }
841
1024
  // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
842
1025
  // filed twice. Bounded and never throws.
843
1026
  if (claimed)
@@ -858,6 +1041,18 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
858
1041
  await closeOpenHostCheck(db, finished, reviewHosts);
859
1042
  await completeHostStatus(db, finished, reviewHosts);
860
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
+ });
1055
+ }
861
1056
  // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
862
1057
  // filed twice. Bounded and never throws.
863
1058
  if (claimed)