@yemi33/minions 0.1.2447 → 0.1.2449

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 (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +66 -7
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +186 -45
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +52 -6
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
package/engine.js CHANGED
@@ -24,13 +24,13 @@
24
24
  // Install ISO timestamp prefixes on console.{log,info,warn,error} so
25
25
  // engine-stdio.log is diagnosable to the second. Must run BEFORE any other
26
26
  // require that might log during module init.
27
- require('./engine/stdio-timestamps').installIfNotInstalled();
27
+ require('./engine/core/stdio-timestamps').installIfNotInstalled();
28
28
 
29
29
  const fs = require('fs');
30
30
  const path = require('path');
31
31
  const os = require('os');
32
32
  const crypto = require('crypto');
33
- const shared = require('./engine/shared');
33
+ const shared = require('./engine/core/shared');
34
34
  const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
35
35
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
36
36
  FAILURE_CLASS, resolvePollFlag } = shared;
@@ -40,18 +40,24 @@ const {
40
40
  buildSpawnFlags,
41
41
  prepareWorkspace,
42
42
  } = require('./engine/runtimes');
43
- const { assertStaleHeadOk } = require('./engine/spawn-agent');
43
+ // Cloud dispatch provider registry (Agency / Copilot cloud / ADO assignment).
44
+ // A DIFFERENT registry from ./engine/runtimes: runtimes describe a local CLI
45
+ // process, cloud providers describe a remote session that authors its own PR.
46
+ // Engine code resolves through this facade and never imports a provider.
47
+ const cloudProviders = require('./engine/cloud');
48
+ const { assertStaleHeadOk } = require('./engine/agents/spawn-agent');
44
49
  // P-1d8f0b93 — fleet ACP worker pool + its child_process-shaped facade.
45
- // NOTE: engine/agent-worker-pool.js is a DIFFERENT module from the
50
+ // NOTE: engine/agents/agent-worker-pool.js is a DIFFERENT module from the
46
51
  // dashboard's single-tab CC pool (engine.js must never import that one —
47
52
  // see the cc-tab-pool wiring regression guard test).
48
- const agentWorkerPool = require('./engine/agent-worker-pool');
49
- const { PooledAgentProcess } = require('./engine/pooled-agent-process');
50
- const { normalizeExecutionModel } = require('./engine/execution-model');
51
- const adoGitAuth = require('./engine/ado-git-auth');
52
- const queries = require('./engine/queries');
53
- const dispatchEvents = require('./engine/dispatch-events');
54
- const prdStore = require('./engine/prd-store');
53
+ const agentWorkerPool = require('./engine/agents/agent-worker-pool');
54
+ const { PooledAgentProcess } = require('./engine/agents/pooled-agent-process');
55
+ const { normalizeExecutionModel } = require('./engine/core/execution-model');
56
+ const adoGitAuth = require('./engine/ado/git-auth');
57
+ const queries = require('./engine/core/queries');
58
+ const dispatchEvents = require('./engine/orchestration/dispatch-events');
59
+ const prdStore = require('./engine/planning/prd-store');
60
+ const prdResultSidecar = require('./engine/planning/prd-result-sidecar');
55
61
 
56
62
  // ─── Paths ──────────────────────────────────────────────────────────────────
57
63
 
@@ -132,66 +138,67 @@ const CHECKPOINT_CAP_FAIL_REASON = 'Exceeded 3 checkpoint-resumes; manual interv
132
138
  // re-aliased here for the existing call sites in this file.
133
139
  const READ_ONLY_ROOT_TASK_TYPES = shared.READ_ONLY_ROOT_TASK_TYPES;
134
140
 
135
- // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
141
+ // ─── Dispatch Management (extracted to engine/orchestration/dispatch.js) ───────────────────
136
142
 
137
143
  const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason,
138
- deferDispatchForWorktreeProtection, completeDispatch,
139
- writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync } = require('./engine/dispatch');
144
+ deferDispatchForWorktreeProtection, completeDispatch, deferDispatchToPending,
145
+ writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync } = require('./engine/orchestration/dispatch');
140
146
 
141
- // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
147
+ // ─── Timeout / Steering / Idle (extracted to engine/orchestration/timeout.js) ──────────────
142
148
 
143
- const { checkTimeouts, checkSteering, checkIdleThreshold, dropSteeringForPurgedSession } = require('./engine/timeout');
144
- const steering = require('./engine/steering');
149
+ const { checkTimeouts, checkSteering, checkIdleThreshold, dropSteeringForPurgedSession } = require('./engine/orchestration/timeout');
150
+ const steering = require('./engine/agents/steering');
145
151
 
146
- // ─── Cleanup (extracted to engine/cleanup.js) ────────────────────────────────
152
+ // ─── Cleanup (extracted to engine/orchestration/cleanup.js) ────────────────────────────────
147
153
 
148
- const { runCleanup } = require('./engine/cleanup');
154
+ const { runCleanup } = require('./engine/orchestration/cleanup');
149
155
 
150
156
  // ─── Worktree pool (W-mp73ya3e000me6c5 — opt-in cross-branch warm reuse) ────
151
157
 
152
- const worktreePool = require('./engine/worktree-pool');
153
- const worktreePreflight = require('./engine/worktree-preflight');
158
+ const worktreePool = require('./engine/worktrees/pool');
159
+ const worktreePreflight = require('./engine/worktrees/preflight');
154
160
 
155
- // ─── State Readers (delegated to engine/queries.js) ─────────────────────────
161
+ // ─── State Readers (delegated to engine/core/queries.js) ─────────────────────────
156
162
 
157
163
  const { getConfig, getControl, getDispatch, getNotes,
158
164
  getAgentStatus, getInboxFiles,
159
165
  collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
160
166
  getPrs, SKILLS_DIR } = queries;
161
167
 
162
- // ─── Routing (extracted to engine/routing.js) ───────────────────────────────
168
+ // ─── Routing (extracted to engine/orchestration/routing.js) ───────────────────────────────
163
169
 
164
- const routing = require('./engine/routing');
165
- const reviewLearning = require('./engine/review-learning');
170
+ const routing = require('./engine/orchestration/routing');
171
+ const reviewLearning = require('./engine/memory/review-learning');
166
172
  const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
167
173
  getAgentErrorRate, isAgentIdle, resolveAgent, resetClaimedAgents,
168
174
  resolveAgentReservation, setTempBudget, tempAgents } = routing;
169
175
 
170
- // ─── Playbook, system prompt, agent context (extracted to engine/playbook.js) ─
176
+ // ─── Playbook, system prompt, agent context (extracted to engine/agents/playbook.js) ─
171
177
 
172
178
  const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
173
179
  buildSystemPrompt, buildAgentContext, selectPlaybook,
174
180
  buildBaseVars, buildPrDispatch, resolveTaskContext,
175
- getRepoHost, getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
181
+ getRepoHost, getRepoHostLabel, getRepoHostToolRule } = require('./engine/agents/playbook');
176
182
 
177
183
  // Per-slug GitHub PAT resolution — mirrors getAdoToken/MINIONS_ADO_TOKEN.
178
184
  // Used at agent spawn time to inject GH_TOKEN for GitHub projects so child
179
185
  // `gh`/`git push` calls authenticate as the right account without falling
180
186
  // through to an interactive `gh auth login` device-code flow.
181
- const ghToken = require('./engine/gh-token');
187
+ const ghToken = require('./engine/providers/gh-token');
182
188
 
183
- // ─── Lifecycle (extracted to engine/lifecycle.js) ────────────────────────────
189
+ // ─── Lifecycle (extracted to engine/orchestration/lifecycle.js) ────────────────────────────
184
190
 
185
191
  const { runPostCompletionHooks, handleQaSessionSetupCompletion, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
186
192
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
187
193
  updateAgentHistory, updateMetrics, parseAgentOutput, syncPrdFromPrs, persistVerifyPrsToPrd,
188
194
  isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemScope,
189
195
  mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs, collapseAllDuplicatePrRecords,
190
- recordWorktreeHeldPause, clearWorktreeHeldPause } = require('./engine/lifecycle');
196
+ parseCompletionReportFile,
197
+ recordWorktreeHeldPause, clearWorktreeHeldPause } = require('./engine/orchestration/lifecycle');
191
198
 
192
199
  // ─── Diagnostics: memory + event-loop + GC sampler (P-a1b2c3d4 / P-b2c3d4e5) ─
193
200
 
194
- const diagnosticsMemory = require('./engine/diagnostics-memory');
201
+ const diagnosticsMemory = require('./engine/observability/diagnostics-memory');
195
202
  const DIAGNOSTICS_MEMORY_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
196
203
 
197
204
  // P-e5f6a7b8 — sentinel consumed each tick; dashboard.js writes it after
@@ -208,7 +215,7 @@ const realActivityMap = new Map(); // dispatchId → timestamp of last agent std
208
215
  const SPAWN_ATTEMPT_ABORTED = Symbol('spawn-attempt-aborted');
209
216
  const FOREIGN_SPAWN_LEASE_MAX_AGE_MS = 30 * 60 * 1000;
210
217
  const _spawnAttemptLeases = new Map(); // dispatchId → in-flight pre-runtime lease
211
- // tempAgents imported from engine/routing.js
218
+ // tempAgents imported from engine/orchestration/routing.js
212
219
  let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
213
220
  const engineRestartGraceExempt = new Set(); // dispatch IDs with confirmed-dead PIDs at restart — bypass grace period
214
221
 
@@ -367,10 +374,19 @@ async function _checkWorktreeBranchHoldForDispatch(project, type, branch, config
367
374
  if (!project?.localPath) return null;
368
375
  let spawnPaths;
369
376
  try {
370
- spawnPaths = shared.resolveSpawnPaths(project, type, MINIONS_DIR);
377
+ spawnPaths = shared.resolveSpawnPaths(project, type, MINIONS_DIR, {
378
+ executionSurface: _deps.executionSurface,
379
+ });
371
380
  } catch {
372
381
  return null; // spawnAgent owns structural path diagnostics
373
382
  }
383
+ // W-ms5tb6ha016fda10: a control-plane dispatch allocates no worktree, so no
384
+ // operator checkout can hold "its" branch. A cloud dispatch is covered by the
385
+ // `!spawnPaths.worktreeRootDir` check on the next line — reachable only
386
+ // because the caller resolves and forwards the CLOUD surface (see the
387
+ // `_itemSkipsCheckoutAllocation` block in dispatchPending); a caller that
388
+ // forwarded `repo` for a cloud item would silently get repo-shaped gating.
389
+ if (spawnPaths.controlPlane) return notApplicable;
374
390
  if (spawnPaths.liveMode || !spawnPaths.worktreeRootDir) return notApplicable;
375
391
 
376
392
  const listWorktrees = _deps.listWorktrees || (repoDir =>
@@ -477,14 +493,24 @@ function _clearWorktreeBranchHeld(dispatchItem) {
477
493
  }
478
494
  }
479
495
 
480
- function _checkWorktreeDiskSpaceForDispatch(project, type, config) {
496
+ function _checkWorktreeDiskSpaceForDispatch(project, type, config, options = {}) {
481
497
  if (!project?.localPath) return null;
482
498
  let spawnPaths;
483
499
  try {
484
- spawnPaths = shared.resolveSpawnPaths(project, type, MINIONS_DIR);
500
+ spawnPaths = shared.resolveSpawnPaths(project, type, MINIONS_DIR, {
501
+ executionSurface: options.executionSurface,
502
+ });
485
503
  } catch {
486
504
  return null; // spawnAgent owns structural path diagnostics
487
505
  }
506
+ // W-ms5tb6ha016fda10: a control-plane dispatch never runs `git worktree add`,
507
+ // so worktree free-space capacity is irrelevant to whether it can run. A
508
+ // cloud dispatch is covered by the `!spawnPaths.worktreeRootDir` check on the
509
+ // next line — reachable only because the caller resolves and forwards the
510
+ // CLOUD surface (see the `_itemSkipsCheckoutAllocation` block in
511
+ // dispatchPending); a caller that forwarded `repo` for a cloud item would
512
+ // silently subject it to a disk preflight it can never legitimately fail.
513
+ if (spawnPaths.controlPlane) return null;
488
514
  if (spawnPaths.liveMode || !spawnPaths.worktreeRootDir) return null;
489
515
  const worktreeRoot = path.resolve(
490
516
  spawnPaths.worktreeRootDir,
@@ -738,11 +764,133 @@ function buildDepConflictFixItem({
738
764
  featureBranch: depConflictBranch,
739
765
  _branch: depConflictBranch,
740
766
  _blockedItem: blockedItem ? blockedItem.id : null,
767
+ // W-mrzqfozm00035365 — track EVERY downstream item blocked by this dep
768
+ // conflict (not just the first), so completing the repair can wake all of
769
+ // them and restart/reconciliation can rebuild the links idempotently.
770
+ _blockedItems: blockedItem ? [blockedItem.id] : [],
741
771
  _isInterDepConflict: !!isInterDepConflict,
742
772
  project: projectName || null,
743
773
  };
744
774
  }
745
775
 
776
+ // ─── W-mrzqfozm00035365: recoverable dependency merge-conflict gate ──────────
777
+ // A dependency-branch merge conflict is detected in spawnAgent BEFORE the agent
778
+ // process starts. It is NOT an execution failure of the downstream item and must
779
+ // not burn its retry budget or drive it to terminal `failed` while the
780
+ // deterministic conflict-repair WI is still being worked. Instead the downstream
781
+ // item is stamped with a `_dependencyConflict` marker + `_pendingReason` and
782
+ // held pending; the helpers below make the orchestration decisions pure and
783
+ // unit-testable.
784
+ const DEP_CONFLICT_PENDING_REASON = 'dependency_conflict'; // repair in progress — auto-clears
785
+ const DEP_CONFLICT_UNRESOLVED_PENDING_REASON = 'dependency_conflict_unresolved'; // repair failed/cancelled or still conflicting — actionable block
786
+ const DEP_CONFLICT_FIX_CREATED_BY = 'engine:dep-conflict-fix';
787
+
788
+ // Build the marker stamped on a downstream WI when its dependency branches
789
+ // conflict. `files` is capped so the persisted record stays small.
790
+ function buildDependencyConflictMarker({
791
+ repairWiId,
792
+ conflictBranch,
793
+ conflictsWith,
794
+ isInterDep = false,
795
+ files = [],
796
+ detectedAt = null,
797
+ }) {
798
+ return {
799
+ repairWiId: repairWiId || null,
800
+ conflictBranch: conflictBranch || null,
801
+ conflictsWith: conflictsWith || null,
802
+ isInterDep: !!isInterDep,
803
+ files: Array.isArray(files) ? files.slice(0, 10) : [],
804
+ detectedAt: detectedAt || ts(),
805
+ };
806
+ }
807
+
808
+ // Locate the deterministic conflict-repair WI for a blocked downstream item.
809
+ // Prefers the id recorded on the marker; falls back to the persisted
810
+ // back-reference (`_blockedItems` / legacy `_blockedItem`) so links survive
811
+ // even when the marker was never written (legacy terminal-failed items).
812
+ function findConflictRepairForBlockedItem(itemId, allItems, markerRepairId = null) {
813
+ if (!Array.isArray(allItems)) return null;
814
+ if (markerRepairId) {
815
+ const byId = allItems.find(w => w && w.id === markerRepairId);
816
+ if (byId) return byId;
817
+ }
818
+ return allItems.find(w =>
819
+ w && w.createdBy === DEP_CONFLICT_FIX_CREATED_BY
820
+ && ((Array.isArray(w._blockedItems) && w._blockedItems.includes(itemId)) || w._blockedItem === itemId)
821
+ ) || null;
822
+ }
823
+
824
+ // Decide what to do with a downstream item held on a dependency merge conflict,
825
+ // given the current state of its conflict-repair WI. Pure + deterministic.
826
+ // downstream: the blocked WI (carries `_dependencyConflict`)
827
+ // repair: the conflict-fix WI (or null when not yet materialized / pruned)
828
+ // nowMs: Date.now() (injectable for tests; unused today but kept for parity)
829
+ // Returns { action, pendingReason, reason }:
830
+ // 'wait' → keep pending, repair still in progress (never redispatch into the
831
+ // same conflict)
832
+ // 'wake' → clear the marker so the item re-runs dependency integration on
833
+ // freshly-fetched refs
834
+ // 'block' → keep pending with an actionable unresolved reason and DO NOT loop
835
+ function evaluateDependencyConflictGate(downstream, repair, nowMs = Date.now()) {
836
+ const marker = downstream && downstream._dependencyConflict;
837
+ if (!marker) {
838
+ return { action: 'wake', pendingReason: null, reason: 'no active dependency-conflict marker' };
839
+ }
840
+ if (!repair) {
841
+ // Repair not found yet (creation race, or store not visible this tick).
842
+ // Hold — the detection path (re)creates it; never redispatch blindly.
843
+ return { action: 'wait', pendingReason: DEP_CONFLICT_PENDING_REASON, reason: 'conflict-repair work item not found yet' };
844
+ }
845
+ const st = repair.status;
846
+ if (st === WI_STATUS.FAILED || st === WI_STATUS.CANCELLED) {
847
+ return {
848
+ action: 'block',
849
+ pendingReason: DEP_CONFLICT_UNRESOLVED_PENDING_REASON,
850
+ reason: `conflict-repair ${repair.id} is ${st}`,
851
+ };
852
+ }
853
+ if (DONE_STATUSES.has(st)) {
854
+ // Wake only if the repair completed AFTER we detected THIS conflict. A repair
855
+ // that completed BEFORE (or exactly at) detection means the freshly-fetched
856
+ // merge still conflicts despite the repair — block instead of looping.
857
+ const detectedAtMs = marker.detectedAt ? Date.parse(marker.detectedAt) : NaN;
858
+ const completedAtMs = repair.completedAt ? Date.parse(repair.completedAt) : NaN;
859
+ const repairAfterDetection = (Number.isFinite(completedAtMs) && Number.isFinite(detectedAtMs))
860
+ ? completedAtMs >= detectedAtMs
861
+ : true; // missing timestamps → wake once (repair is done)
862
+ if (repairAfterDetection) {
863
+ return { action: 'wake', pendingReason: null, reason: `conflict-repair ${repair.id} done` };
864
+ }
865
+ return {
866
+ action: 'block',
867
+ pendingReason: DEP_CONFLICT_UNRESOLVED_PENDING_REASON,
868
+ reason: `conflict-repair ${repair.id} completed before the conflict was re-detected — dependency branches still conflict`,
869
+ };
870
+ }
871
+ // pending / dispatched / decomposed / paused → still being repaired.
872
+ return { action: 'wait', pendingReason: DEP_CONFLICT_PENDING_REASON, reason: `conflict-repair ${repair.id} in progress (${st})` };
873
+ }
874
+
875
+ // Reconciliation (requirement 7): should a terminally-FAILED item be revived to
876
+ // pending because it failed on a dependency merge conflict AND its deterministic
877
+ // repair WI is live (pending/dispatched) or done? Guards against reviving
878
+ // unrelated failures: only fires for MERGE_CONFLICT-class failures linked to a
879
+ // repair WI that is not itself terminal-dead. Pure + deterministic.
880
+ function shouldReviveFailedDependencyConflict(failedItem, repair) {
881
+ if (!failedItem || failedItem.status !== WI_STATUS.FAILED) return false;
882
+ const fc = failedItem._failureClass || '';
883
+ const fr = String(failedItem.failReason || '');
884
+ const looksLikeDepConflict = fc === FAILURE_CLASS.MERGE_CONFLICT
885
+ || /\bMERGE_CONFLICT\b/i.test(fr)
886
+ || /Dependency merge failed/i.test(fr);
887
+ if (!looksLikeDepConflict) return false;
888
+ if (!repair) return false;
889
+ // The repair must be actively repairing or already done — not itself dead.
890
+ if (repair.status === WI_STATUS.FAILED || repair.status === WI_STATUS.CANCELLED) return false;
891
+ return true;
892
+ }
893
+
746
894
  // Prune dep branches that are ancestors of other dep branches (#958)
747
895
  // When B already contains A's commits, merging both A and B causes conflicts.
748
896
  async function pruneAncestorDeps(deps, gitOpts, cwd) {
@@ -821,6 +969,10 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
821
969
  function _syncAgentWorkerPoolConfig(engineConfig = {}) {
822
970
  const enabled = (engineConfig.agentUseWorkerPool ?? ENGINE_DEFAULTS.agentUseWorkerPool) === true;
823
971
  agentWorkerPool.setEnabled(enabled);
972
+ // Issue #1105 — cold ACP handshake budget (`initialize` + `session/new` /
973
+ // `session/load`). Pushed unconditionally so a pool enabled later in the same
974
+ // process already carries the operator-configured budget.
975
+ agentWorkerPool.setHandshakeTimeoutMs(shared.resolveAcpHandshakeTimeoutMs(engineConfig));
824
976
  if (enabled) agentWorkerPool.setPoolSize(shared.resolveAgentAcpPoolSize(engineConfig));
825
977
  return enabled;
826
978
  }
@@ -956,7 +1108,18 @@ function _classifyAgentFailure(runtime, code, stdout, stderr) {
956
1108
  function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Date.now()) {
957
1109
  if (!procInfo?._pendingSteeringFiles?.length || !rawOutput) return;
958
1110
  if (procInfo._steeringMessage || procInfo._steeringNoSession) return;
959
- const acked = steering.ackProcessedSteeringMessages(agentId, procInfo._pendingSteeringFiles, rawOutput, { observedAtMs });
1111
+ let acked;
1112
+ try {
1113
+ acked = steering.ackProcessedSteeringMessages(
1114
+ agentId,
1115
+ procInfo._pendingSteeringFiles,
1116
+ rawOutput,
1117
+ { observedAtMs },
1118
+ );
1119
+ } catch (err) {
1120
+ log('warn', `Steering ACK persistence failed for ${agentId}: ${err.message}`);
1121
+ return;
1122
+ }
960
1123
  if (acked.length === 0) return;
961
1124
 
962
1125
  const ackedPaths = new Set(acked.map(entry => entry.path));
@@ -977,17 +1140,21 @@ function captureSessionIdFromStdoutChunk(agentId, dispatchId, branchName, runtim
977
1140
  if (!sessionId) continue;
978
1141
  procInfo.sessionId = sessionId;
979
1142
  if (runtime && typeof runtime.saveSession === 'function') {
980
- runtime.saveSession({
981
- agentId,
982
- dispatchId,
983
- branch: branchName,
984
- sessionId,
985
- agentsDir: AGENTS_DIR,
986
- // W-ms0vk9nv000z985b — the directory this session is anchored to. A
987
- // later dispatch refuses to --resume it once that directory is gone.
988
- cwd: sessionCwd || null,
989
- logger: _runtimeLogger(),
990
- });
1143
+ try {
1144
+ runtime.saveSession({
1145
+ agentId,
1146
+ dispatchId,
1147
+ branch: branchName,
1148
+ sessionId,
1149
+ agentsDir: AGENTS_DIR,
1150
+ // W-ms0vk9nv000z985b — the directory this session is anchored to. A
1151
+ // later dispatch refuses to --resume it once that directory is gone.
1152
+ cwd: sessionCwd || null,
1153
+ logger: _runtimeLogger(),
1154
+ });
1155
+ } catch (err) {
1156
+ log('warn', `Could not persist runtime session for ${dispatchId}: ${err.message}`);
1157
+ }
991
1158
  }
992
1159
 
993
1160
  return;
@@ -1097,7 +1264,7 @@ function promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutpu
1097
1264
  // 're_spawning' — captures that the engine has committed to deliver
1098
1265
  // these messages via session resume at the natural checkpoint.
1099
1266
  try {
1100
- const store = require('./engine/steering-store');
1267
+ const store = require('./engine/persistence/steering-store');
1101
1268
  for (const entry of checkpointEntries) {
1102
1269
  if (entry?.steerId) {
1103
1270
  store.updateStatus(entry.steerId, 're_spawning', { runtime: runtime?.name || null });
@@ -1107,6 +1274,85 @@ function promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutpu
1107
1274
  return { status: 'promoted', entries: checkpointEntries };
1108
1275
  }
1109
1276
 
1277
+ // W-ms9tcs3o01y03383 — the prompt completion boundary, as one decision.
1278
+ //
1279
+ // A dispatch is provably finished when BOTH halves hold:
1280
+ // 1. the tracked child exited terminally and successfully (`code === 0`, which
1281
+ // the caller only has once the `close` event fired), and
1282
+ // 2. a structurally valid completion report exists whose nonce proves it was
1283
+ // written by THIS dispatch (the nonce is minted per spawn and carried
1284
+ // across steering resumes).
1285
+ //
1286
+ // Fails closed on every other shape: non-zero exit, missing report, malformed
1287
+ // report, missing/forged nonce. Those keep the existing failure/timeout paths
1288
+ // untouched — nothing here weakens success validation.
1289
+ function resolveCloseCompletionBoundary(dispatchItem, code, expectedNonce, readReport = parseCompletionReportFile) {
1290
+ if (code !== 0) return null;
1291
+ try {
1292
+ return shared.acceptCompletionReportForNonce(readReport(dispatchItem), expectedNonce);
1293
+ } catch (e) {
1294
+ log('warn', `close completion-report probe failed for ${dispatchItem?.id}: ${e.message}`);
1295
+ return null;
1296
+ }
1297
+ }
1298
+
1299
+ // W-ms9tcs3o01y03383 — after a confirmed terminal exit whose completion report
1300
+ // is structurally valid AND owned by this dispatch's nonce, the prompt is
1301
+ // finished. A steering resume from that point is pure bookkeeping: it cannot
1302
+ // change the completed turn, but it CAN hold the dispatch open for a full
1303
+ // `engine.resumeHeartbeatTimeout` when the resumed runtime never emits a first
1304
+ // heartbeat (observed 310s on lambert-plan-to-prd-ms9skh2q01a91a35 — the whole
1305
+ // point of this work item).
1306
+ //
1307
+ // So we release the resume intent instead of spawning it, and deliberately do
1308
+ // NOT ack/consume the steering messages: they stay UNREAD on disk, so the
1309
+ // agent's next dispatch picks them up through the normal
1310
+ // steering.buildPendingSteeringPrompt path. That is the same durability
1311
+ // contract the existing no-sessionId `[steering-pending]` branch relies on, and
1312
+ // it matches what the human was already told when the message was deferred
1313
+ // ("queued until the agent reaches a resumable checkpoint or the next dispatch").
1314
+ //
1315
+ // The same rule applies to every other steering disposition that would override
1316
+ // durable completion evidence, including the `_steeringNoSession` re-queue.
1317
+ function releasePendingSteeringResumeForCompletedTurn(agentId, procInfo, liveOutputPath) {
1318
+ if (!procInfo) return { released: false, deferred: 0, entries: [] };
1319
+ const entries = Array.isArray(procInfo._steeringEntry)
1320
+ ? procInfo._steeringEntry
1321
+ : (procInfo._steeringEntry ? [procInfo._steeringEntry] : []);
1322
+ const released = !!procInfo._steeringMessage || !!procInfo._steeringNoSession;
1323
+ delete procInfo._steeringMessage;
1324
+ delete procInfo._steeringSessionId;
1325
+ delete procInfo._steeringEntry;
1326
+ delete procInfo._steeringDeferredCheckpoint;
1327
+ // The no-session steering kill (#1014) re-queues the dispatch back to pending
1328
+ // so the message rides along on the next attempt. That is right for an agent
1329
+ // that was interrupted mid-work — and wrong for one that provably finished,
1330
+ // because it discards a real success and re-runs completed work. Clearing the
1331
+ // flag lets the report finalize the dispatch; the message is unread either
1332
+ // way, so the next dispatch still delivers it.
1333
+ delete procInfo._steeringNoSession;
1334
+
1335
+ let unread = [];
1336
+ try { unread = steering.listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim()); }
1337
+ catch { /* inbox read is best-effort — the release itself must still happen */ }
1338
+ if (!released && unread.length === 0) return { released: false, deferred: 0, entries: [] };
1339
+
1340
+ // Roll any row we had already moved to `re_spawning` back to `queued` so the
1341
+ // dashboard does not advertise a resume that intentionally never ran.
1342
+ try {
1343
+ const store = require('./engine/persistence/steering-store');
1344
+ for (const entry of entries) {
1345
+ if (entry?.steerId) store.updateStatus(entry.steerId, 'queued');
1346
+ }
1347
+ } catch { /* best-effort */ }
1348
+
1349
+ log('info', `Steering: ${agentId} finished its turn with a valid completion report — ${unread.length} message(s) stay unread for the next dispatch instead of holding the dispatch open for a resume`);
1350
+ try {
1351
+ fs.appendFileSync(liveOutputPath, `\n[steering-pending] Agent completed this task and wrote a valid completion report, so no resume was started. Your message remains unread and will be delivered on the next dispatch.\n`);
1352
+ } catch { /* optional */ }
1353
+ return { released, deferred: unread.length, entries };
1354
+ }
1355
+
1110
1356
  // Resolve dependency plan item IDs to their PR branches
1111
1357
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
1112
1358
  // P-faea3206: each entry now carries projectName + projectRoot + isCrossRepo
@@ -1221,7 +1467,13 @@ async function syncReusedWorktree(rootDir, worktreePath, branchName, gitOpts = {
1221
1467
  // auto-pick-up master mid-flight, so the carve-out is explicit.
1222
1468
  const { mainRef, isSharedBranch } = opts;
1223
1469
  if (mainRef && !isSharedBranch) {
1224
- try { await shared.shellSafeGit(['fetch', 'origin', mainRef], { ...gitOpts, cwd: rootDir }); }
1470
+ // W-ms5tb6ha016fda10: serialize the shared-ref refresh (see
1471
+ // _fetchWithTransientRetry). Only the fetch is inside the chain — the
1472
+ // merge below is per-worktree work and must NOT be serialized with it.
1473
+ try {
1474
+ await shared.withSerializedRemoteRefRefresh(rootDir, () =>
1475
+ shared.shellSafeGit(['fetch', 'origin', mainRef], { ...gitOpts, cwd: rootDir }));
1476
+ }
1225
1477
  catch (e) { log('warn', `git: failed to fetch origin/${mainRef} during reuse-sync: ${e.message}`); }
1226
1478
  try { await shared.shellSafeGit(['merge', `origin/${mainRef}`, '--no-edit', '--no-ff'], { ...gitOpts, cwd: worktreePath }); }
1227
1479
  catch (e) {
@@ -1336,13 +1588,29 @@ function _isTransientGitNetworkError(err) {
1336
1588
  //
1337
1589
  // `args` is the array passed to git AFTER 'fetch' (e.g. ['origin', branch]).
1338
1590
  // `label` is a short string used in the log line for triage.
1591
+ //
1592
+ // W-ms5tb6ha016fda10 — every attempt runs inside
1593
+ // `shared.withSerializedRemoteRefRefresh(<repo>)`. Concurrent fresh-worktree
1594
+ // preparation in the SAME clone used to race on `refs/remotes/origin/<main>`
1595
+ // and the loser died with `cannot lock ref ... but expected <sha>`, which then
1596
+ // classified as a bogus NETWORK_ERROR. Chaining per repo removes that race at
1597
+ // its source; the chain is keyed on `opts.cwd`, so unrelated projects still
1598
+ // fetch in parallel. The lock is in-process and covers ONLY the fetch — no
1599
+ // worktree add / checkout / merge work is serialized with it. A residual
1600
+ // cross-process ref-lock loss (an operator's own git, the dashboard, the CLI)
1601
+ // is treated as a transient worth one retry, and is classified as
1602
+ // GIT_REF_LOCK rather than a connectivity failure.
1339
1603
  async function _fetchWithTransientRetry(args, opts, label, { throwOnFailure = false } = {}) {
1340
- const _attempt = async () => shared.shellSafeGit(['fetch', ...args], opts);
1604
+ const _attempt = async () => shared.withSerializedRemoteRefRefresh(
1605
+ opts?.cwd,
1606
+ () => shared.shellSafeGit(['fetch', ...args], opts),
1607
+ );
1341
1608
  try {
1342
1609
  await _attempt();
1343
1610
  return true;
1344
1611
  } catch (e) {
1345
- if (!_isTransientGitNetworkError(e)) {
1612
+ const refLock = shared.isGitRefLockError(e);
1613
+ if (!refLock && !_isTransientGitNetworkError(e)) {
1346
1614
  // #3045 — log the FULL exec error message (redacted), not just the first
1347
1615
  // line. child_process.exec rejections format as "Command failed: <cmd>"
1348
1616
  // on line 1 and the real stderr ("TF400813 unknown user", "HTTP 401",
@@ -1356,7 +1624,7 @@ async function _fetchWithTransientRetry(args, opts, label, { throwOnFailure = fa
1356
1624
  return false; // swallow non-transient — caller falls back to local ref
1357
1625
  }
1358
1626
  const transientMsg = adoGitAuth.redactBearer(String(e.message || e));
1359
- log('warn', `git fetch ${label}: transient (${transientMsg}) — retrying once after 1.5s`);
1627
+ log('warn', `git fetch ${label}: ${refLock ? 'ref-lock contention' : 'transient'} (${transientMsg}) — retrying once after 1.5s`);
1360
1628
  await new Promise(r => setTimeout(r, 1500));
1361
1629
  try {
1362
1630
  await _attempt();
@@ -1372,6 +1640,11 @@ async function _fetchWithTransientRetry(args, opts, label, { throwOnFailure = fa
1372
1640
  }
1373
1641
 
1374
1642
  function _classifyFreshBaseFetchFailure(err) {
1643
+ // Order matters: a lost ref-lock race is a LOCAL serialization defect, not
1644
+ // connectivity. Classifying it as NETWORK_ERROR (the historical behavior)
1645
+ // hid the real cause behind a "flaky network" story and burned retries on
1646
+ // maintenance jobs that never needed a checkout at all.
1647
+ if (shared.isGitRefLockError(err)) return FAILURE_CLASS.GIT_REF_LOCK;
1375
1648
  return adoGitAuth.isAdoAuthFailure(err)
1376
1649
  ? FAILURE_CLASS.AUTH
1377
1650
  : FAILURE_CLASS.NETWORK_ERROR;
@@ -1841,32 +2114,34 @@ function _reapProbeTimeoutMs() {
1841
2114
  return ENGINE_DEFAULTS.statusProbeKillTimeoutMs || 12000;
1842
2115
  }
1843
2116
 
1844
- // W-mq5n1zx5 — Layer 2a: on Windows, kill any live `git.exe` descendants
1845
- // whose command line points at the quarantine target path. We never
1846
- // tracked the PID of the `git status --porcelain` child that the probe
1847
- // timed out on, so we can't kill by PID — instead we shell out to
1848
- // PowerShell's CIM cmdlets to find matching processes and Stop-Process
1849
- // them. POSIX is a no-op (the EBUSY race is Windows-specific). Best-effort;
1850
- // failure is logged but never blocks the quarantine.
1851
- function _killGitDescendantsForWorktree(worktreePath) {
2117
+ // W-mq5n1zx5 — Layer 2a: on Windows, terminate live `git.exe` processes whose
2118
+ // command line proves they target the quarantine path. POSIX is a no-op (the
2119
+ // EBUSY race is Windows-specific). Best-effort; failure never blocks quarantine.
2120
+ function _killGitDescendantsForWorktree(worktreePath, opts = {}) {
1852
2121
  if (process.platform !== 'win32') return { killed: 0, skipped: true };
1853
2122
  if (!ENGINE_DEFAULTS.statusProbeKillDescendantsWin32) return { killed: 0, skipped: true };
1854
2123
  if (!worktreePath) return { killed: 0, skipped: true };
1855
- // PowerShell expects single-quoted literals; escape any embedded single
1856
- // quote by doubling it (PowerShell's standard single-quote escape).
1857
- const safePath = String(worktreePath).replace(/'/g, "''");
1858
- const ps = [
1859
- "$ErrorActionPreference='SilentlyContinue';",
1860
- `$matches = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'git.exe' -and $_.CommandLine -like '*${safePath}*' };`,
1861
- "if ($matches) { $matches | ForEach-Object { Stop-Process -Id $_.ProcessId -Force; $_.ProcessId }; }",
1862
- ].join(' ');
1863
2124
  try {
1864
- const out = shared.execSilent(`powershell -NoProfile -Command "${ps.replace(/"/g, '\\"')}"`, { timeout: _reapProbeTimeoutMs(), encoding: 'utf8' });
1865
- const killed = String(out || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean).length;
2125
+ const target = path.resolve(worktreePath).replace(/\//g, '\\').toLowerCase();
2126
+ const processes = Array.isArray(opts.processes) ? opts.processes : shared.listAllProcesses();
2127
+ const matches = processes
2128
+ .filter(proc => String(proc?.name || '').toLowerCase() === 'git.exe')
2129
+ .filter(proc => String(proc?.cmd || '').replace(/\//g, '\\').toLowerCase().includes(target));
2130
+ const pids = matches.map(proc => proc.pid);
2131
+ const expectedIdentities = new Map(
2132
+ matches.map(proc => [Number(proc.pid), Number(proc.startedAt)]),
2133
+ );
2134
+ let killed = 0;
2135
+ if (pids.length > 0) {
2136
+ const ownership = { expectedIdentities };
2137
+ killed = opts.terminateProcess
2138
+ ? opts.terminateProcess('engine.git-status-probe-cleanup', pids, ownership)
2139
+ : shared.terminateProcess('engine.git-status-probe-cleanup', pids, ownership);
2140
+ }
1866
2141
  if (killed > 0) log('info', `_killGitDescendantsForWorktree: killed ${killed} git.exe descendant(s) holding ${worktreePath}`);
1867
2142
  return { killed };
1868
2143
  } catch (e) {
1869
- log('warn', `_killGitDescendantsForWorktree: powershell probe failed: ${e.message}`);
2144
+ log('warn', `_killGitDescendantsForWorktree: process probe failed: ${e.message}`);
1870
2145
  return { killed: 0, error: e.message };
1871
2146
  }
1872
2147
  }
@@ -1888,11 +2163,11 @@ function _findTerminalWorktreeOwners(worktreePath, _deps = {}) {
1888
2163
  try {
1889
2164
  const reader = typeof _deps.readDispatchSectioned === 'function'
1890
2165
  ? _deps.readDispatchSectioned
1891
- : require('./engine/dispatch-store').readDispatchSectioned;
2166
+ : require('./engine/persistence/dispatch-store').readDispatchSectioned;
1892
2167
  sectioned = reader();
1893
2168
  } catch {
1894
2169
  try {
1895
- sectioned = require(path.join(__dirname, 'engine', 'dispatch-store')).readDispatchSectioned();
2170
+ sectioned = require(path.join(__dirname, 'engine', 'persistence', 'dispatch-store')).readDispatchSectioned();
1896
2171
  } catch { return []; }
1897
2172
  }
1898
2173
  if (!sectioned || typeof sectioned !== 'object') return [];
@@ -1979,7 +2254,7 @@ function _reapWorktreeHolders(worktreePath, opts = {}) {
1979
2254
 
1980
2255
  // Layer 0 — legacy git.exe-by-cmdline sweep. NOT redundant with Layer 2
1981
2256
  // (P-e2a6c9d4, evaluated): Layer 2 (findProcessCwdHolders) matches purely on a
1982
- // process's CWD resolving at/under THIS worktree (engine/shared.js
2257
+ // process's CWD resolving at/under THIS worktree (engine/core/shared.js
1983
2258
  // _windowsCwdProbeScript + _parseCwdHolderLines filter on cwd only). Layer 0
1984
2259
  // matches `git.exe` by `CommandLine -like '*<worktreePath>*'` regardless of CWD.
1985
2260
  // The case Layer 2 cannot see: a git.exe the engine itself spawns FROM the repo
@@ -3433,7 +3708,17 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3433
3708
 
3434
3709
  const qaRunId = meta?.qaRunId || meta?.item?.meta?.qaRunId;
3435
3710
  if (qaRunId) {
3436
- require('./engine/qa-runs').prepareResultSidecar(agentId, qaRunId);
3711
+ require('./engine/qa/runs').prepareResultSidecar(agentId, qaRunId);
3712
+ }
3713
+
3714
+ // W-msb9kgs402813a97-b — same contract for the DRAFT visual-journey
3715
+ // manifest. Load-bearing on the re-draft path (`editDraft` re-queues DRAFT
3716
+ // for the SAME session id): a leftover manifest would pass the session-id
3717
+ // check and be consumed as if it described the new draft.
3718
+ const qaSessionIdForSpawn = meta?.sessionId || meta?.item?.meta?.sessionId;
3719
+ const qaSessionPhaseForSpawn = meta?.sessionPhase || meta?.item?.meta?.sessionPhase;
3720
+ if (qaSessionIdForSpawn && qaSessionPhaseForSpawn === 'draft') {
3721
+ require('./engine/qa/visual-journey').prepareDraftResultSidecar(agentId, qaSessionIdForSpawn);
3437
3722
  }
3438
3723
 
3439
3724
  // W-mp73x32w000l143d: decouple agent cwd from worktree placement.
@@ -3492,10 +3777,100 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3492
3777
  }
3493
3778
  const validatedWorkdir = _wdValidation.value; // null when unset / empty
3494
3779
 
3495
- let cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false;
3780
+ // ── Control-plane execution surface (W-ms5tb6ha016fda10) ──────────────
3781
+ // Explicit, structured opt-out of repository checkout for tasks whose whole
3782
+ // contract is Minions/dashboard REST calls plus external CLIs (`gh`, `az`).
3783
+ // Never inferred from prose. Validation is fail-closed at the ALLOWLIST in
3784
+ // shared.CONTROL_PLANE_ELIGIBLE_TYPES, so a repo-mutating type that declares
3785
+ // `control-plane` is REJECTED here rather than quietly losing its worktree.
3786
+ // The dashboard/scheduler validate on create; this is the defense-in-depth
3787
+ // gate for ad-hoc dispatches and pre-validator work items.
3788
+ const _rawSurface = meta?.item?.meta?.executionSurface ?? meta?.executionSurface;
3789
+ const _surfaceValidation = shared.validateExecutionSurface(_rawSurface, type);
3790
+ if (!_surfaceValidation.valid) {
3791
+ const _wiId = meta?.item?.id || meta?.workItemId || id;
3792
+ log('warn', `spawnAgent: executionSurface validation rejected dispatch ${id} (WI ${_wiId}): ${_surfaceValidation.error}`);
3793
+ try {
3794
+ writeInboxAlert(`invalid-execution-surface-${_wiId}`, [
3795
+ `# Invalid meta.executionSurface on ${_wiId}`,
3796
+ ``,
3797
+ `Dispatch \`${id}\` for agent \`${agentId}\` (type \`${type}\`) was rejected before spawn.`,
3798
+ ``,
3799
+ `**Reason:** ${_surfaceValidation.error}`,
3800
+ ``,
3801
+ `**Submitted value:** \`${typeof _rawSurface === 'string' ? _rawSurface : JSON.stringify(_rawSurface)}\``,
3802
+ ``,
3803
+ `\`meta.executionSurface\` is an explicit declaration, never inferred. Valid values are \`repo\` (default — normal worktree/live checkout), \`control-plane\` (no repository checkout; the task only calls the Minions API / external CLIs), and \`cloud\` (handed to a remote coding agent that authors its own PR). Only these work types may declare \`control-plane\`: ${[...shared.CONTROL_PLANE_ELIGIBLE_TYPES].sort().join(', ')}. Only these may declare \`cloud\`: ${[...shared.CLOUD_ELIGIBLE_TYPES].sort().join(', ')}.`,
3804
+ ``,
3805
+ `Fix the WI's \`meta.executionSurface\` (or remove it) and re-dispatch.`,
3806
+ ].join('\n'));
3807
+ } catch (e) { log('warn', `invalid-execution-surface inbox alert write failed: ${e.message}`); }
3808
+ completeDispatch(
3809
+ id,
3810
+ DISPATCH_RESULT.ERROR,
3811
+ _surfaceValidation.error.slice(0, 800),
3812
+ 'meta.executionSurface validation rejected this dispatch — fix or remove the field before re-dispatch.',
3813
+ { failureClass: FAILURE_CLASS.INVALID_EXECUTION_SURFACE, agentRetryable: false },
3814
+ );
3815
+ cleanupTempAgent(agentId);
3816
+ return null;
3817
+ }
3818
+ const executionSurface = _surfaceValidation.value;
3819
+ const isControlPlane = executionSurface === shared.EXECUTION_SURFACE.CONTROL_PLANE;
3820
+
3821
+ // ── Cloud execution surface — fail-closed provider gate ────────────────
3822
+ // A validly-declared `cloud` item is handed to a REMOTE coding agent; it
3823
+ // must never quietly fall through to a local dispatch, because the two
3824
+ // produce different work in different places under different identities.
3825
+ // So the gate refuses the dispatch unless a configured provider resolves,
3826
+ // and (until the hand-off itself lands) refuses it even when one does.
3827
+ //
3828
+ // Every refusal here is STRUCTURAL — an unregistered/undeclared provider or
3829
+ // one that reports itself unconfigured reproduces identically on retry — so
3830
+ // it is non-retryable and must not consume the item's retry budget.
3831
+ if (executionSurface === shared.EXECUTION_SURFACE.CLOUD) {
3832
+ const _wiId = meta?.item?.id || meta?.workItemId || id;
3833
+ const _cloudTarget = cloudProviders.resolveCloudDispatchTarget({
3834
+ item: meta?.item || meta,
3835
+ project,
3836
+ engine: config?.engine,
3837
+ config,
3838
+ });
3839
+ const _cloudReason = _cloudTarget.ok
3840
+ ? `cloud provider "${_cloudTarget.id}" is registered and configured, but the cloud dispatch `
3841
+ + `hand-off is not implemented yet — the engine refuses to run cloud-declared work locally`
3842
+ : _cloudTarget.reason;
3843
+ log('warn', `spawnAgent: cloud dispatch ${id} (WI ${_wiId}) refused: ${_cloudReason}`);
3844
+ try {
3845
+ writeInboxAlert(`cloud-provider-unavailable-${_wiId}`, [
3846
+ `# Cloud dispatch refused for ${_wiId}`,
3847
+ ``,
3848
+ `Dispatch \`${id}\` for agent \`${agentId}\` (type \`${type}\`) declared \`meta.executionSurface: "cloud"\` but was not sent anywhere.`,
3849
+ ``,
3850
+ `**Reason:** ${_cloudReason}`,
3851
+ ``,
3852
+ `**Registered providers:** ${cloudProviders.listCloudProviders().join(', ') || '(none)'}`,
3853
+ ``,
3854
+ `The engine never downgrades a cloud declaration to a local dispatch — a remote agent works in its own checkout, under its own identity, and opens its own PR, so silently running it here would produce different work than was asked for.`,
3855
+ ``,
3856
+ `Either configure a cloud provider (\`meta.cloudProvider\` on the WI, \`cloudProvider\` on the project, or \`engine.cloudProvider\`) or remove \`meta.executionSurface\` to dispatch locally.`,
3857
+ ].join('\n'));
3858
+ } catch (e) { log('warn', `cloud-provider-unavailable inbox alert write failed: ${e.message}`); }
3859
+ completeDispatch(
3860
+ id,
3861
+ DISPATCH_RESULT.ERROR,
3862
+ _cloudReason.slice(0, 800),
3863
+ 'Cloud dispatch refused — no usable cloud provider for this work item.',
3864
+ { failureClass: FAILURE_CLASS.CLOUD_PROVIDER_UNAVAILABLE, agentRetryable: false },
3865
+ );
3866
+ cleanupTempAgent(agentId);
3867
+ return null;
3868
+ }
3869
+
3870
+ let cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false, controlPlane = false;
3496
3871
  try {
3497
- ({ cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false } =
3498
- shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir }));
3872
+ ({ cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false, controlPlane = false } =
3873
+ shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir, executionSurface }));
3499
3874
  } catch (rootErr) {
3500
3875
  if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
3501
3876
  log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
@@ -3551,6 +3926,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3551
3926
  ? { name: project.name, localPath: project.localPath }
3552
3927
  : null;
3553
3928
  dispatchItem.checkoutMode = checkoutModeAtDispatch;
3929
+ dispatchItem.executionSurface = executionSurface;
3554
3930
  if (resolvedProjectMeta && dispatchItem.meta) {
3555
3931
  dispatchItem.meta.project = resolvedProjectMeta;
3556
3932
  }
@@ -3560,6 +3936,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3560
3936
  const found = (dispatch?.[queue] || []).find(d => d && d.id === id);
3561
3937
  if (!found) continue;
3562
3938
  found.checkoutMode = checkoutModeAtDispatch;
3939
+ found.executionSurface = executionSurface;
3563
3940
  if (resolvedProjectMeta && found.meta) found.meta.project = resolvedProjectMeta;
3564
3941
  }
3565
3942
  return dispatch;
@@ -3567,6 +3944,13 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3567
3944
  } catch (e) {
3568
3945
  log('warn', `spawnAgent: failed to persist checkout context for ${id}: ${e.message}`);
3569
3946
  }
3947
+ if (controlPlane) {
3948
+ // Observability: the dispatch record itself carries `executionSurface`, so
3949
+ // "how many checkouts did the control-plane surface avoid?" is derived
3950
+ // from dispatch history by GET /api/worktree-inventory — no extra counter
3951
+ // state to migrate or drift.
3952
+ log('info', `${type}: control-plane dispatch ${id} — no repository checkout allocated; cwd ${cwd}`);
3953
+ }
3570
3954
  // Legacy local alias: downstream git ops (worktree add, prune, fetch) and
3571
3955
  // the `cwd === rootDir` safety warn at line ~1387 reference `rootDir`. For
3572
3956
  // read-only rootless tasks (no worktree, no branch) this is null — the
@@ -3578,7 +3962,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3578
3962
  // allocation probe and checkout. This runs before prompt/worktree creation
3579
3963
  // and leaves the existing dispatch pending rather than completing it as an
3580
3964
  // agent failure.
3581
- const _spawnDiskPreflight = _checkWorktreeDiskSpaceForDispatch(project, type, config);
3965
+ const _spawnDiskPreflight = _checkWorktreeDiskSpaceForDispatch(project, type, config, { executionSurface });
3582
3966
  if (_spawnDiskPreflight && !_spawnDiskPreflight.ok) {
3583
3967
  const pendingReason = _persistWorktreeDiskPreflight(dispatchItem, _spawnDiskPreflight);
3584
3968
  _writeWorktreeDiskPreflightAlert(dispatchItem, project, _spawnDiskPreflight);
@@ -3597,6 +3981,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3597
3981
  type,
3598
3982
  _preBranchName,
3599
3983
  config,
3984
+ { executionSurface },
3600
3985
  );
3601
3986
  if (spawnAttemptLease && !_isSpawnAttemptCurrent(spawnAttemptLease)) {
3602
3987
  return SPAWN_ATTEMPT_ABORTED;
@@ -3651,7 +4036,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3651
4036
  // engine compares report.nonce against the in-memory value below; on
3652
4037
  // mismatch the report is treated as forged (e.g. a prompt-injected agent
3653
4038
  // writing into a sibling agent's completion path) and discarded. See
3654
- // engine/lifecycle.js:runPostCompletionHooks and docs/completion-reports.md.
4039
+ // engine/orchestration/lifecycle.js:runPostCompletionHooks and docs/completion-reports.md.
3655
4040
  const completionNonce = crypto.randomBytes(16).toString('hex');
3656
4041
  const completionReportInstruction = completionReportPath ? [
3657
4042
  '## Completion Report',
@@ -3890,11 +4275,18 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3890
4275
  };
3891
4276
  _phaseT.afterPrompt = Date.now();
3892
4277
 
3893
- if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type)) {
4278
+ if (branchName && (READ_ONLY_ROOT_TASK_TYPES.has(type) || isControlPlane)) {
3894
4279
  // Read-only dispatches never own a branch. Project-bound worktree-mode
3895
4280
  // dispatches still get a detached worktree below; project-less/live tasks
3896
4281
  // keep their resolved cwd.
3897
- log('info', `${type}: ignoring branch label ${branchName} for read-only dispatch`);
4282
+ //
4283
+ // W-ms5tb6ha016fda10: a validly-declared control-plane dispatch owns no
4284
+ // branch either — it has no checkout to put one in. Dropping the label
4285
+ // here (rather than only at derivation) also covers explicitly-set
4286
+ // `item.branch` and schedule-supplied labels, and keeps the dispatch out
4287
+ // of the branch mutex so independent maintenance jobs don't serialize.
4288
+ const reason = isControlPlane ? 'control-plane' : 'read-only';
4289
+ log('info', `${type}: ignoring branch label ${branchName} for ${reason} dispatch`);
3898
4290
  branchName = null;
3899
4291
  }
3900
4292
 
@@ -3906,7 +4298,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3906
4298
  // target branch. W-mrcifup2000c218a: the dirty/stale-base conditions below
3907
4299
  // are now caught EARLIER, at allocation time, by the pre-dispatch checks in
3908
4300
  // the dispatch-existing-pending loop (`checkLiveCheckoutDirty` /
3909
- // `checkLiveCheckoutStaleBase` in engine/live-checkout.js) — a positive hit
4301
+ // `checkLiveCheckoutStaleBase` in engine/worktrees/live-checkout.js) — a positive hit
3910
4302
  // there leaves the WI pending with `_pendingReason` stamped and never reaches
3911
4303
  // spawnAgent at all, so this block is now the EXCEPTION path (defense in
3912
4304
  // depth for the race window between that probe and the git operations
@@ -3959,7 +4351,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
3959
4351
  // fork off baseline" signal.
3960
4352
  const _liveAllowNonMainBase = !!(meta?.useExistingBranch || meta?.branchStrategy === 'shared-branch');
3961
4353
  const _wiIdForAlert = meta?.item?.id || id;
3962
- const _liveCheckout = require('./engine/live-checkout');
4354
+ const _liveCheckout = require('./engine/worktrees/live-checkout');
3963
4355
  // W-mrawgw4q000a6bff: stash-before-reset ordering. Resolve BOTH recovery
3964
4356
  // policies here (before the first prepareLiveCheckout call) so we can
3965
4357
  // decide precedence up front instead of letting prepareLiveCheckout's
@@ -4825,7 +5217,11 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
4825
5217
  }
4826
5218
  try {
4827
5219
  const _mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
4828
- await shared.shellSafeGit(['fetch', 'origin', _mainRef], { ..._gitOpts, cwd: rootDir, timeout: 30000 });
5220
+ // W-ms5tb6ha016fda10: shared remote-tracking ref — serialize the
5221
+ // refresh so a concurrent pool borrow / fresh create in the same
5222
+ // clone cannot lose a `refs/remotes/origin/<main>` lock race.
5223
+ await shared.withSerializedRemoteRefRefresh(rootDir, () =>
5224
+ shared.shellSafeGit(['fetch', 'origin', _mainRef], { ..._gitOpts, cwd: rootDir, timeout: 30000 }));
4829
5225
  // -B force-creates/resets the branch so a stale local ref from a
4830
5226
  // prior occupant does not block the checkout.
4831
5227
  await shared.shellSafeGit(['checkout', '-B', branchName, `origin/${_mainRef}`], { ..._gitOpts, cwd: borrowed.path, timeout: 30000 });
@@ -5374,7 +5770,8 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
5374
5770
  const _depIsSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
5375
5771
  if (!_failedRefCache.has(_depMainRef)) {
5376
5772
  try {
5377
- await adoGitAuth.runAdoGit(project, ['fetch', 'origin', _depMainRef], { ..._gitOpts, cwd: rootDir });
5773
+ await shared.withSerializedRemoteRefRefresh(rootDir, () =>
5774
+ adoGitAuth.runAdoGit(project, ['fetch', 'origin', _depMainRef], { ..._gitOpts, cwd: rootDir }));
5378
5775
  } catch (mainFetchErr) {
5379
5776
  log('warn', `Failed to fetch origin/${_depMainRef} before dep merge: ${mainFetchErr.message} — proceeding with stale base`);
5380
5777
  if (adoGitAuth.isAdoAuthFailure(mainFetchErr)) _depAuthFailed = true;
@@ -5761,6 +6158,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
5761
6158
  }
5762
6159
  // Build actionable failReason identifying the conflicting branch and files (#958)
5763
6160
  const mainBranch = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
6161
+ const _conflictsWith = (_isInterDepConflict && _preflightConflictPrev) ? _preflightConflictPrev : mainBranch;
5764
6162
  let failReason = 'Dependency merge failed';
5765
6163
  if (depConflictBranch) {
5766
6164
  if (_isInterDepConflict && _preflightConflictPrev) {
@@ -5771,15 +6169,24 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
5771
6169
  if (depConflictFiles.length > 0) failReason += ` in ${depConflictFiles.slice(0, 5).join(', ')}`;
5772
6170
  failReason += ' — dep branch needs updating';
5773
6171
  }
5774
- completeDispatch(id, DISPATCH_RESULT.ERROR, failReason, '', { failureClass: FAILURE_CLASS.MERGE_CONFLICT });
5775
6172
 
5776
- // Auto-queue conflict-fix work item when a specific dep branch is identified.
5777
- // Routes through the shared-branch dispatch path (see buildDepConflictFixItem)
5778
- // so commits land on the dep's existing PR branch (W-mpcwojgr000a0244).
5779
- if (depConflictBranch && meta?.item?.id && project) {
6173
+ // W-mrzqfozm00035365 — Recoverable dependency merge-conflict gate.
6174
+ // A specific dependency-branch conflict detected BEFORE the agent
6175
+ // process starts is NOT an execution failure of the downstream item.
6176
+ // When we can identify the blocked downstream WI, create/reuse the
6177
+ // deterministic conflict-repair WI, link the blocked item, hold the
6178
+ // downstream PENDING behind a `_dependencyConflict` marker, and defer
6179
+ // the dispatch (no retry consumed). The discovery gate then wakes the
6180
+ // downstream once the repair is done — instead of burning the
6181
+ // downstream's retry budget to terminal `failed` while the repair is
6182
+ // still being worked. Routes the repair through the shared-branch
6183
+ // dispatch path (see buildDepConflictFixItem) so commits land on the
6184
+ // dep's existing PR branch (W-mpcwojgr000a0244).
6185
+ const _canRecoverDepConflict = !!(depConflictBranch && meta?.item?.id && project);
6186
+ if (_canRecoverDepConflict) {
5780
6187
  try {
5781
6188
  const wiScope = project.name ? project : 'central';
5782
- const newItem = buildDepConflictFixItem({
6189
+ const repairItem = buildDepConflictFixItem({
5783
6190
  depConflictBranch,
5784
6191
  depConflictFiles,
5785
6192
  isInterDepConflict: _isInterDepConflict,
@@ -5788,15 +6195,61 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
5788
6195
  blockedItem: meta.item,
5789
6196
  projectName: project.name || null,
5790
6197
  });
6198
+ let repairId = repairItem.id;
5791
6199
  mutateWorkItems(wiScope, items => {
5792
- // Don't create duplicate conflict-fix items
5793
- const existing = items.find(i => i.id === newItem.id && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED && i.status !== WI_STATUS.CANCELLED);
5794
- if (existing) return;
5795
- items.push(newItem);
5796
- log('info', `Auto-queued conflict-fix work item ${newItem.id} for ${depConflictBranch} (blocked: ${meta.item.id})`);
6200
+ // Create-or-reuse the deterministic conflict-repair WI. Link
6201
+ // THIS downstream item into `_blockedItems` idempotently so
6202
+ // multiple downstream items can share one repair and completing
6203
+ // it wakes all of them.
6204
+ const existing = items.find(i => i.id === repairItem.id);
6205
+ if (!existing) {
6206
+ items.push(repairItem);
6207
+ log('info', `Auto-queued conflict-fix work item ${repairItem.id} for ${depConflictBranch} (blocked: ${meta.item.id})`);
6208
+ } else {
6209
+ repairId = existing.id;
6210
+ if (!Array.isArray(existing._blockedItems)) {
6211
+ existing._blockedItems = existing._blockedItem ? [existing._blockedItem] : [];
6212
+ }
6213
+ if (!existing._blockedItems.includes(meta.item.id)) {
6214
+ existing._blockedItems.push(meta.item.id);
6215
+ }
6216
+ }
6217
+ // Stamp the blocked downstream WI: hold PENDING with the
6218
+ // recoverable marker + actionable pending reason. No retry
6219
+ // charged, no terminal failure.
6220
+ const downstream = items.find(i => i.id === meta.item.id);
6221
+ if (downstream) {
6222
+ downstream.status = WI_STATUS.PENDING;
6223
+ downstream._dependencyConflict = buildDependencyConflictMarker({
6224
+ repairWiId: repairId,
6225
+ conflictBranch: depConflictBranch,
6226
+ conflictsWith: _conflictsWith,
6227
+ isInterDep: _isInterDepConflict,
6228
+ files: depConflictFiles,
6229
+ detectedAt: ts(),
6230
+ });
6231
+ downstream._pendingReason = DEP_CONFLICT_PENDING_REASON;
6232
+ delete downstream.dispatched_at;
6233
+ delete downstream.dispatched_to;
6234
+ }
6235
+ return items;
5797
6236
  });
5798
- } catch (e) { log('warn', `Failed to auto-queue conflict-fix: ${e.message}`); }
6237
+ // Tear the dispatch down as a recoverable deferral — NOT a
6238
+ // completion — so no retry is consumed and the downstream WI is
6239
+ // free to re-dispatch once the gate clears.
6240
+ deferDispatchToPending(id, `dependency conflict: ${depConflictBranch} conflicts with ${_conflictsWith} (repair ${repairId})`);
6241
+ log('info', `Dependency conflict on ${depConflictBranch} blocks ${meta.item.id} — held pending behind repair ${repairId} (no retry consumed)`);
6242
+ } catch (e) {
6243
+ log('warn', `Recoverable dep-conflict gate failed for ${meta?.item?.id}: ${e.message} — falling back to failure`);
6244
+ completeDispatch(id, DISPATCH_RESULT.ERROR, failReason, '', { failureClass: FAILURE_CLASS.MERGE_CONFLICT });
6245
+ }
6246
+ cleanupTempAgent(agentId);
6247
+ return;
5799
6248
  }
6249
+
6250
+ // No identifiable downstream item (central / no meta.item) — preserve
6251
+ // the original terminal failure behavior.
6252
+ completeDispatch(id, DISPATCH_RESULT.ERROR, failReason, '', { failureClass: FAILURE_CLASS.MERGE_CONFLICT });
5800
6253
  cleanupTempAgent(agentId);
5801
6254
  return;
5802
6255
  }
@@ -6101,6 +6554,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6101
6554
 
6102
6555
  // Spawn the selected harness runtime.
6103
6556
  const childEnv = shared.cleanChildEnv();
6557
+ shared.applyAgentTestLoadSafetyEnv(childEnv);
6104
6558
  if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
6105
6559
  if (completionNonce) childEnv.MINIONS_COMPLETION_NONCE = completionNonce;
6106
6560
  childEnv.MINIONS_REPO_HOST = getRepoHost(project);
@@ -6151,7 +6605,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6151
6605
  // W-mpg54mi2000n7b7e — inject a per-slug GitHub PAT so child `gh`/`git`
6152
6606
  // calls authenticate as the right account without any `gh auth login`
6153
6607
  // interactive flow. Resolution honors config.engine.ghAccounts via
6154
- // engine/gh-token.js (exact owner → owner-glob → fleet default → null).
6608
+ // engine/providers/gh-token.js (exact owner → owner-glob → fleet default → null).
6155
6609
  // Mirrors the MINIONS_ADO_TOKEN injection above for ADO projects.
6156
6610
  try {
6157
6611
  const slug = shared.getProjectOrg(project) && project?.repoName
@@ -6165,12 +6619,12 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6165
6619
  if (_abortStaleSpawn()) return SPAWN_ATTEMPT_ABORTED;
6166
6620
  // Spawn via wrapper script — node directly (no bash intermediary)
6167
6621
  // spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
6168
- const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
6622
+ const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'agents', 'spawn-agent.js');
6169
6623
 
6170
6624
  const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
6171
6625
  // P-1d8f0b93 — the pooled-dispatch branch further down (agentWorkerPool
6172
6626
  // acquireWorker) accepts an optional `hermeticDirs` (workspace-manifest
6173
- // `allowed_repos` beyond the primary cwd — see engine/agent-worker-pool.js
6627
+ // `allowed_repos` beyond the primary cwd — see engine/agents/agent-worker-pool.js
6174
6628
  // header doc). Repository harness discovery (skills/commands/--add-dir) is
6175
6629
  // now delegated entirely to the native runtime CLI (#817), so there is no
6176
6630
  // longer an engine-computed add-dir set to reuse here; leave it empty and
@@ -6178,6 +6632,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6178
6632
  const addDirs = [];
6179
6633
 
6180
6634
  let dispatchActivatedForLaunch = false;
6635
+ let runtimePidObservedAt = 0;
6181
6636
  const _activateDispatchForRuntime = (phase) => {
6182
6637
  let activated = false;
6183
6638
  mutateDispatch((dispatch) => {
@@ -6223,6 +6678,8 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6223
6678
  item._spawnPhase = 'running';
6224
6679
  if (Number.isInteger(proc?.pid) && proc.pid > 0) {
6225
6680
  item.runtimePid = proc.pid;
6681
+ runtimePidObservedAt = Date.now();
6682
+ item.runtimePidObservedAt = runtimePidObservedAt;
6226
6683
  }
6227
6684
  persisted = true;
6228
6685
  return dispatch;
@@ -6246,6 +6703,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6246
6703
  delete item.pooled;
6247
6704
  delete item.executionCwd;
6248
6705
  delete item.runtimePid;
6706
+ delete item.runtimePidObservedAt;
6249
6707
  delete item._spawnPhase;
6250
6708
  if (!dispatch.pending.some(entry => entry.id === id)) dispatch.pending.push(item);
6251
6709
  return dispatch;
@@ -6304,7 +6762,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6304
6762
  }
6305
6763
  let pidFilePath, logFd, registeredInActivityMap, registeredInActiveProcesses, proc;
6306
6764
  // The PID file at this path is written asynchronously by the spawned
6307
- // child (engine/spawn-agent.js:423) once it starts. We compute the path
6765
+ // child (engine/agents/spawn-agent.js:423) once it starts. We compute the path
6308
6766
  // upfront so the catch block can unlink it whether spawn fails before
6309
6767
  // the child wrote it (no-op) or after (real cleanup).
6310
6768
  pidFilePath = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
@@ -6396,7 +6854,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6396
6854
  promptText: fs.readFileSync(promptPath, 'utf8'),
6397
6855
  });
6398
6856
  // The child_process PID file is normally written asynchronously by
6399
- // spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
6857
+ // spawn-agent.js itself (engine/agents/spawn-agent.js:606) once it starts.
6400
6858
  // There is no such child process here, so the engine writes the
6401
6859
  // equivalent file itself as soon as the pool hands back a real OS PID
6402
6860
  // (mirrors that write: async, best-effort, same `String(pid)` shape).
@@ -6426,13 +6884,14 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6426
6884
  startedAt,
6427
6885
  runtimeName,
6428
6886
  sessionId: cachedSessionId,
6887
+ runtimePidObservedAt,
6429
6888
  _completionNonce: completionNonce,
6430
6889
  ...(cachedSessionId ? {
6431
6890
  _runtimeResumeAt: Date.now(),
6432
6891
  _runtimeResumeAwaitingFirstOutput: true,
6433
6892
  } : {}),
6434
6893
  _pendingSteeringFiles: pendingSteering.entries,
6435
- // P-1d8f0b93 — marks a pooled dispatch so engine/timeout.js's
6894
+ // P-1d8f0b93 — marks a pooled dispatch so engine/orchestration/timeout.js's
6436
6895
  // cancel-vs-kill wiring (P-6e2a4c15, killTrackedProcess /
6437
6896
  // _escalatePlatformKill) can route `.kill()` to cancel+release the
6438
6897
  // ACP worker instead of SIGKILL/taskkill on the shared worker's OS pid.
@@ -6450,7 +6909,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6450
6909
  // banner on tab switch / agent change).
6451
6910
  //
6452
6911
  // ONLY emitted on fresh spawns — re-attach after engine restart goes
6453
- // through engine/cli.js and does NOT pass through here, so a re-attached
6912
+ // through engine/operations/cli.js and does NOT pass through here, so a re-attached
6454
6913
  // dispatch (procInfo.reattached === true) won't synthesise a spurious
6455
6914
  // started event that could clobber a still-live previous-dispatch
6456
6915
  // banner that the user is mid-read on.
@@ -6476,9 +6935,9 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6476
6935
  // kill the orphan child so it doesn't run unmonitored. shared.killImmediate
6477
6936
  // recurses into the process tree (footgun #4) — plain proc.kill('SIGKILL')
6478
6937
  // doesn't on Windows.
6479
- try { shared.killImmediate(proc); } catch { /* already exited */ }
6938
+ try { shared.terminateProcess('engine.spawn-partial-failure', proc); } catch { /* already exited */ }
6480
6939
  } else if (proc && typeof proc.kill === 'function') {
6481
- try { proc.kill(); } catch { /* already exited */ }
6940
+ try { shared.terminateProcess('engine.spawn-partial-pooled', proc); } catch { /* already exited */ }
6482
6941
  }
6483
6942
  if (registeredInActiveProcesses) {
6484
6943
  activeProcesses.delete(id);
@@ -6618,7 +7077,35 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6618
7077
 
6619
7078
  const procInfo = activeProcesses.get(id);
6620
7079
  ackPendingSteeringFiles(agentId, procInfo, steeringAckStdout);
6621
- promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutputPath);
7080
+
7081
+ // W-ms9tcs3o01y03383 — process exit + a valid, dispatch-owned completion
7082
+ // report IS the completion boundary for this prompt.
7083
+ //
7084
+ // `code` here comes from the child's `close` event, so terminal exit is
7085
+ // confirmed by construction, and the report is only accepted when it echoes
7086
+ // the per-spawn nonce (carried across steering resumes). Both halves are
7087
+ // required: a missing/forged report, or a still-live process, keeps every
7088
+ // existing failure/timeout path exactly as it was. This never kills a live
7089
+ // agent for silence — it only short-circuits AFTER the process is gone.
7090
+ //
7091
+ // When both hold, any queued resume bookkeeping is released rather than
7092
+ // spawned, so the dispatch finalizes on this same async continuation
7093
+ // instead of waiting out `engine.resumeHeartbeatTimeout` for a first
7094
+ // heartbeat that a completed turn will never produce.
7095
+ const terminalCompletionReport = resolveCloseCompletionBoundary(
7096
+ dispatchItem,
7097
+ code,
7098
+ procInfo?._completionNonce || completionNonce,
7099
+ );
7100
+ let closeCompletionPath = shared.DISPATCH_COMPLETION_PATH.PROCESS_CLOSE;
7101
+ if (terminalCompletionReport) {
7102
+ const releasedResume = releasePendingSteeringResumeForCompletedTurn(agentId, procInfo, liveOutputPath);
7103
+ if (releasedResume.released || releasedResume.deferred > 0) {
7104
+ closeCompletionPath = shared.DISPATCH_COMPLETION_PATH.REPORT_RECOVERY_IMMEDIATE;
7105
+ }
7106
+ } else {
7107
+ promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutputPath);
7108
+ }
6622
7109
 
6623
7110
  // #853 — the child is gone, so its clean committed state is stable. Push
6624
7111
  // any strictly-ahead dispatch branch before steering resume, pool return,
@@ -6670,7 +7157,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6670
7157
  // Live-kill flow first lands here; deferred-checkpoint flow
6671
7158
  // also lands here from the natural-close branch above.
6672
7159
  try {
6673
- const store = require('./engine/steering-store');
7160
+ const store = require('./engine/persistence/steering-store');
6674
7161
  const steerEntries = Array.isArray(steerEntry) ? steerEntry : (steerEntry ? [steerEntry] : []);
6675
7162
  for (const entry of steerEntries) {
6676
7163
  if (entry?.steerId) {
@@ -6726,8 +7213,9 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6726
7213
  return;
6727
7214
  }
6728
7215
 
6729
- const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
7216
+ const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'agents', 'spawn-agent.js');
6730
7217
  const childEnv = shared.cleanChildEnv();
7218
+ shared.applyAgentTestLoadSafetyEnv(childEnv);
6731
7219
  if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
6732
7220
  // P-d2a8f6c1: preserve the per-dispatch nonce across steering resume so
6733
7221
  // the agent's completion JSON still validates after the resumed turn.
@@ -6809,18 +7297,23 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6809
7297
  cleanupTempAgent(agentId);
6810
7298
  return;
6811
7299
  }
7300
+ let resumePidObservedAt = 0;
6812
7301
  const writeResumePid = () => {
6813
7302
  if (!resumeProc.pid) return;
7303
+ resumePidObservedAt = Date.now();
6814
7304
  try { fs.writeFileSync(pidFilePath, String(resumeProc.pid)); } catch { /* best-effort */ }
6815
7305
  try {
6816
7306
  mutateDispatch((dp) => {
6817
7307
  const active = dp.active.find((entry) => entry.id === id);
6818
7308
  if (active) {
6819
7309
  active.runtimePid = resumeProc.pid;
7310
+ active.runtimePidObservedAt = resumePidObservedAt;
6820
7311
  active._spawnPhase = 'running';
6821
7312
  }
6822
7313
  return dp;
6823
7314
  });
7315
+ const tracked = activeProcesses.get(id);
7316
+ if (tracked?.proc === resumeProc) tracked.runtimePidObservedAt = resumePidObservedAt;
6824
7317
  } catch (e) {
6825
7318
  log('warn', `Steering: failed to persist resumed PID for ${agentId}: ${e.message}`);
6826
7319
  }
@@ -6839,6 +7332,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6839
7332
  startedAt: procInfo.startedAt,
6840
7333
  runtimeName,
6841
7334
  sessionId: steerSessionId,
7335
+ runtimePidObservedAt: resumePidObservedAt,
6842
7336
  // P-d2a8f6c1: keep the per-dispatch nonce alive across the steering resume.
6843
7337
  _completionNonce: procInfo._completionNonce || completionNonce,
6844
7338
  _runtimeResumeAt: Date.now(),
@@ -6877,7 +7371,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6877
7371
  if (resumeInfo && !resumeInfo._steeringDeliveredAt) {
6878
7372
  resumeInfo._steeringDeliveredAt = Date.now();
6879
7373
  try {
6880
- const store = require('./engine/steering-store');
7374
+ const store = require('./engine/persistence/steering-store');
6881
7375
  const pending = Array.isArray(resumeInfo._pendingSteeringFiles) ? resumeInfo._pendingSteeringFiles : [];
6882
7376
  for (const pendingEntry of pending) {
6883
7377
  if (pendingEntry?.steerId) {
@@ -6910,17 +7404,24 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
6910
7404
  try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Resume exited with code ${resumeCode}. Your message was received but the agent could not continue the session.\n`); } catch {}
6911
7405
  }
6912
7406
  // Don't assume original work completed — run normal close handler to parse output and determine actual result
6913
- onAgentClose(resumeCode);
7407
+ finalizeAgentClose(resumeCode);
6914
7408
  return;
6915
7409
  }
6916
7410
  // Successful resume — run normal close handler
6917
- onAgentClose(resumeCode);
7411
+ finalizeAgentClose(resumeCode);
6918
7412
  });
6919
7413
  resumeProc.on('error', (err) => {
6920
- log('warn', `Steering re-spawn error for ${agentId}: ${err.message}`);
6921
- try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Spawn error: ${err.message}. Your message was received but the agent could not resume.\n`); } catch {}
6922
- activeProcesses.delete(id);
6923
- completeDispatch(id, DISPATCH_RESULT.ERROR, `Steering re-spawn failed: ${err.message}`);
7414
+ try {
7415
+ log('warn', `Steering re-spawn error for ${agentId}: ${err.message}`);
7416
+ try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Spawn error: ${err.message}. Your message was received but the agent could not resume.\n`); } catch {}
7417
+ activeProcesses.delete(id);
7418
+ completeDispatch(id, DISPATCH_RESULT.ERROR, `Steering re-spawn failed: ${err.message}`);
7419
+ } catch (handlerErr) {
7420
+ console.error(
7421
+ `[engine] Failed to contain steering re-spawn error for ${id}: ` +
7422
+ `${handlerErr?.stack || handlerErr}`,
7423
+ );
7424
+ }
6924
7425
  });
6925
7426
 
6926
7427
  // Don't run completion hooks — agent is still working
@@ -7065,7 +7566,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7065
7566
  || !!dispatchItem.meta?.keep_processes_skip_workdir_check;
7066
7567
  if (_kpEnabled) {
7067
7568
  try {
7068
- const keepProcessSweep = require('./engine/keep-process-sweep');
7569
+ const keepProcessSweep = require('./engine/processes/keep-process-sweep');
7069
7570
  // First evaluate with workdir validation OFF so we always catch
7070
7571
  // schema failures (pids-missing, ttl-too-long, etc.) regardless
7071
7572
  // of whether requireGitWorkdir is enabled or skipped per-WI.
@@ -7168,7 +7669,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7168
7669
  parsedSnippet ? '## What you wrote\n\n```json\n' + parsedSnippet + '\n```\n' : '',
7169
7670
  '## Canonical shape',
7170
7671
  '',
7171
- canonicalHint || '(see `engine/keep-process-sweep.js` `buildKeepProcessesHint` for the canonical shape.)',
7672
+ canonicalHint || '(see `engine/processes/keep-process-sweep.js` `buildKeepProcessesHint` for the canonical shape.)',
7172
7673
  '',
7173
7674
  ].filter(Boolean).join('\n');
7174
7675
  }
@@ -7215,7 +7716,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7215
7716
  {
7216
7717
  if (managedSpawnRequested && lifecycleCompletionAccepted) {
7217
7718
  try {
7218
- const managedSpawn = require('./engine/managed-spawn');
7719
+ const managedSpawn = require('./engine/processes/managed-spawn');
7219
7720
  const allowedCwdRoot = keepProcessesAllowedCwdRoot;
7220
7721
  const wiId = dispatchItem.meta?.item?.id || '';
7221
7722
  const evalResult = managedSpawn.evaluateManagedSpawnAcceptance(agentId, {
@@ -7265,7 +7766,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7265
7766
  parsedSnippet ? '## What you wrote\n\n```json\n' + parsedSnippet + '\n```\n' : '',
7266
7767
  '## Canonical shape',
7267
7768
  '',
7268
- canonicalHint || '(see `engine/managed-spawn.js` `buildManagedSpawnHint` for the canonical shape.)',
7769
+ canonicalHint || '(see `engine/processes/managed-spawn.js` `buildManagedSpawnHint` for the canonical shape.)',
7269
7770
  '',
7270
7771
  ].filter(Boolean).join('\n');
7271
7772
  writeInboxAlert(`managed-spawn-${agentId}`, alertBody);
@@ -7299,7 +7800,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7299
7800
  const qaSessionMeta = dispatchItem.meta?.item?.meta;
7300
7801
  if (!spawnFailureReason
7301
7802
  && qaSessionMeta?.sessionId) {
7302
- const qaSessions = require('./engine/qa-sessions');
7803
+ const qaSessions = require('./engine/qa/sessions');
7303
7804
  if (qaSessionMeta.sessionPhase === qaSessions.SESSION_PHASE.SETUP) {
7304
7805
  const qaSession = qaSessions.getSession(qaSessionMeta.sessionId);
7305
7806
  if (!qaSession || qaSessions.TERMINAL_STATES.has(qaSession.state)) {
@@ -7410,7 +7911,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7410
7911
  };
7411
7912
  }
7412
7913
  } else if (managedSpawnRequested) {
7413
- const managedSpawn = require('./engine/managed-spawn');
7914
+ const managedSpawn = require('./engine/processes/managed-spawn');
7414
7915
  const staleSidecar = path.join(AGENTS_DIR, agentId, managedSpawn.MANAGED_SPAWN_FILENAME);
7415
7916
  try { fs.unlinkSync(staleSidecar); } catch (e) {
7416
7917
  if (e.code !== 'ENOENT') log('warn', `managed-spawn: failed to discard untrusted sidecar for ${agentId}: ${e.message}`);
@@ -7431,7 +7932,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7431
7932
  let managedSpawnHealthcheckFailure = null;
7432
7933
  if (managedSpawnSpawned.length > 0) {
7433
7934
  try {
7434
- const managedSpawn = require('./engine/managed-spawn');
7935
+ const managedSpawn = require('./engine/processes/managed-spawn');
7435
7936
  const items = managedSpawnSpawned;
7436
7937
  // Re-read the specs from the state file (recordManagedBatch normalised
7437
7938
  // them — healthcheck shape lives there now).
@@ -7595,6 +8096,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7595
8096
  });
7596
8097
  const finalCompletionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
7597
8098
  const completionOpts = {
8099
+ completionPath: closeCompletionPath,
7598
8100
  ...(finalCompletionReportPath ? { completionReportPath: finalCompletionReportPath } : {}),
7599
8101
  ...(structuredCompletion ? { structuredCompletion } : {}),
7600
8102
  };
@@ -7696,7 +8198,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7696
8198
  let processAnchorReason = null;
7697
8199
  if (worktreePath) {
7698
8200
  try {
7699
- const anchorDecision = require('./engine/worktree-gc').shouldGcDispatchWorktree({
8201
+ const anchorDecision = require('./engine/worktrees/gc').shouldGcDispatchWorktree({
7700
8202
  worktreePath,
7701
8203
  agentId,
7702
8204
  managedSpawnSpawnedCount: managedSpawnAliveCount,
@@ -7792,7 +8294,8 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7792
8294
  await shared.shellSafeGit(['reset', '--hard', 'HEAD'], { ..._gitOpts, cwd: worktreePath, timeout: 30000 });
7793
8295
  // -fd preserves gitignored files (node_modules, .vite, .next caches) — that's the whole point.
7794
8296
  await shared.shellSafeGit(['clean', '-fd'], { ..._gitOpts, cwd: worktreePath, timeout: 30000 });
7795
- await shared.shellSafeGit(['fetch', 'origin', _mainRefRet], { ..._gitOpts, cwd: rootDir, timeout: 30000 });
8297
+ await shared.withSerializedRemoteRefRefresh(rootDir, () =>
8298
+ shared.shellSafeGit(['fetch', 'origin', _mainRefRet], { ..._gitOpts, cwd: rootDir, timeout: 30000 }));
7796
8299
  // Detach at origin/<main> — local main is typically checked out in
7797
8300
  // the project root and git refuses two checkouts of the same branch.
7798
8301
  await shared.shellSafeGit(['checkout', '--detach', `origin/${_mainRefRet}`], { ..._gitOpts, cwd: worktreePath, timeout: 30000 });
@@ -7842,7 +8345,7 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7842
8345
  // at …` until the 2-hour age sweep in cleanup.js eventually catches up.
7843
8346
  if (worktreePath && rootDir && failedWorktreeProtection.allowRemoval !== false) {
7844
8347
  try {
7845
- const _wgc = require('./engine/worktree-gc');
8348
+ const _wgc = require('./engine/worktrees/gc');
7846
8349
  const _wtRoot = path.resolve(rootDir, engineConfig.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
7847
8350
  const _gcResult = _wgc.gcDispatchWorktreeIfOrphan({
7848
8351
  worktreePath,
@@ -7877,19 +8380,19 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
7877
8380
  // ── P-d9e6b2c4 — live-mode dispatch-end auto-restore + terminal-failure
7878
8381
  // notify. Runs on EVERY terminal result for a live-mode dispatch: the
7879
8382
  // agent ran in-place in the operator's checkout, so switch the tree back
7880
- // to the ref it was on before (see engine/live-checkout.js#
8383
+ // to the ref it was on before (see engine/worktrees/live-checkout.js#
7881
8384
  // restoreLiveCheckoutAtDispatchEnd). The worktree-GC block above no-ops
7882
8385
  // in live mode (worktreePath===null); this is its live-mode counterpart.
7883
8386
  // originalRef was captured by prepareLiveCheckout and persisted on the
7884
8387
  // dispatch record at branch-resolution time (P-c5a1f3b8); the in-memory
7885
8388
  // closure copy is authoritative here. The engine-restart reattach path
7886
- // fires the same helper from the persisted record in engine/cli.js. The
8389
+ // fires the same helper from the persisted record in engine/operations/cli.js. The
7887
8390
  // restore is a plain `git checkout <originalRef>` — never --force/reset/
7888
8391
  // clean/stash — and is best-effort: a thrown restore never alters the
7889
8392
  // dispatch result.
7890
8393
  if (liveMode && branchName && shared.isLiveCheckoutDispatchRecord(dispatchItem, getConfig())) {
7891
8394
  try {
7892
- const _liveRestore = require('./engine/live-checkout');
8395
+ const _liveRestore = require('./engine/worktrees/live-checkout');
7893
8396
  await _liveRestore.restoreLiveCheckoutAtDispatchEnd({
7894
8397
  localPath: cwd,
7895
8398
  branchName,
@@ -8003,14 +8506,52 @@ async function spawnAgent(dispatchItem, config, spawnAttemptLease = null) {
8003
8506
  }
8004
8507
  }
8005
8508
 
8006
- proc.on('close', onAgentClose);
8509
+ function finalizeAgentClose(code) {
8510
+ onAgentClose(code).catch(err => {
8511
+ const detail = err instanceof Error ? err.stack || err.message : String(err);
8512
+ const reason = err instanceof Error ? err.message : String(err);
8513
+ try {
8514
+ log('error', `Agent ${agentId} (${id}) close finalization failed: ${detail}`);
8515
+ activeProcesses.delete(id);
8516
+ realActivityMap.delete(id);
8517
+ const dispatchNow = getDispatch();
8518
+ const stillQueued = [...(dispatchNow.active || []), ...(dispatchNow.pending || [])]
8519
+ .some(item => item.id === id);
8520
+ if (stillQueued) {
8521
+ completeDispatch(
8522
+ id,
8523
+ DISPATCH_RESULT.ERROR,
8524
+ `Post-completion lifecycle error: ${reason}`,
8525
+ '',
8526
+ { failureClass: FAILURE_CLASS.UNKNOWN, agentRetryable: true },
8527
+ );
8528
+ }
8529
+ } catch (recoveryErr) {
8530
+ console.error(
8531
+ `[engine] Failed to contain close finalization error for ${id}: ` +
8532
+ `${recoveryErr?.stack || recoveryErr}`,
8533
+ );
8534
+ } finally {
8535
+ try { cleanupTempAgent(agentId); } catch { /* best-effort recovery */ }
8536
+ }
8537
+ });
8538
+ }
8539
+
8540
+ proc.on('close', finalizeAgentClose);
8007
8541
 
8008
8542
  proc.on('error', (err) => {
8009
- log('error', `Failed to spawn agent ${agentId}: ${err.message}`);
8010
- activeProcesses.delete(id);
8011
- realActivityMap.delete(id);
8012
- completeDispatch(id, DISPATCH_RESULT.ERROR, `Spawn error: ${err.message}`);
8013
- cleanupTempAgent(agentId);
8543
+ try {
8544
+ log('error', `Failed to spawn agent ${agentId}: ${err.message}`);
8545
+ activeProcesses.delete(id);
8546
+ realActivityMap.delete(id);
8547
+ completeDispatch(id, DISPATCH_RESULT.ERROR, `Spawn error: ${err.message}`);
8548
+ } catch (handlerErr) {
8549
+ console.error(
8550
+ `[engine] Failed to contain spawn error for ${id}: ${handlerErr?.stack || handlerErr}`,
8551
+ );
8552
+ } finally {
8553
+ try { cleanupTempAgent(agentId); } catch { /* best-effort recovery */ }
8554
+ }
8014
8555
  });
8015
8556
 
8016
8557
  // Safety: if process exits immediately (within 3s), log it
@@ -8097,8 +8638,6 @@ function areDependenciesMet(item, config) {
8097
8638
  const deps = item.depends_on;
8098
8639
  if (!deps || deps.length === 0) return true;
8099
8640
  const sourcePlan = item.sourcePlan;
8100
- if (!sourcePlan) return true;
8101
- const projects = getProjects(config);
8102
8641
 
8103
8642
  // Collect work items from ALL projects (dependencies can be cross-project)
8104
8643
  const allWorkItems = queries.getWorkItems(config);
@@ -8109,12 +8648,14 @@ function areDependenciesMet(item, config) {
8109
8648
  const depItem = allWorkItems.find(w => w.id === depId);
8110
8649
  if (!depItem) {
8111
8650
  // Fallback: check the PRD store — plan-to-prd agents may pre-set items to done.
8112
- try {
8113
- const plan = prdStore.readPrd(sourcePlan);
8114
- const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
8115
- if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
8116
- } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
8117
- log('warn', `Dependency ${depId} not found for ${item.id} (plan: ${sourcePlan}) — treating as unmet`);
8651
+ if (sourcePlan) {
8652
+ try {
8653
+ const plan = prdStore.readPrd(sourcePlan);
8654
+ const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
8655
+ if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
8656
+ } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
8657
+ }
8658
+ log('warn', `Dependency ${depId} not found for ${item.id} (plan: ${sourcePlan || 'none'}) — treating as unmet`);
8118
8659
  return false;
8119
8660
  }
8120
8661
  if (depItem.status === WI_STATUS.FAILED) return 'failed';
@@ -8227,12 +8768,12 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
8227
8768
  return reconciled;
8228
8769
  }
8229
8770
 
8230
- // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
8771
+ // ─── Inbox Consolidation (extracted to engine/memory/consolidation.js) ──────────────
8231
8772
 
8232
- const { consolidateInbox } = require('./engine/consolidation');
8773
+ const { consolidateInbox } = require('./engine/memory/consolidation');
8233
8774
  const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled, getAdoThrottleState } = require('./engine/ado');
8234
- const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, checkLiveBuildAndConflict: ghCheckLiveBuildAndConflict, isGhThrottled } = require('./engine/github');
8235
- const { reconcileSharedBranchPrs } = require('./engine/shared-branch-pr-reconcile');
8775
+ const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, checkLiveBuildAndConflict: ghCheckLiveBuildAndConflict, isGhThrottled } = require('./engine/providers/github');
8776
+ const { reconcileSharedBranchPrs } = require('./engine/providers/shared-branch-pr-reconcile');
8236
8777
 
8237
8778
  // ─── State Snapshot ─────────────────────────────────────────────────────────
8238
8779
 
@@ -8273,12 +8814,12 @@ function updateSnapshot(config) {
8273
8814
  safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
8274
8815
  }
8275
8816
 
8276
- // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
8817
+ // ─── Cooldowns (extracted to engine/orchestration/cooldown.js) ─────────────────────────────
8277
8818
 
8278
8819
  const { dispatchCooldowns, loadCooldowns, saveCooldowns,
8279
8820
  isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
8280
8821
  setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
8281
- isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
8822
+ isAlreadyDispatched, isBranchActive } = require('./engine/orchestration/cooldown');
8282
8823
 
8283
8824
  // Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
8284
8825
  function autoCleanPrdWorkItems(prdFile, config) {
@@ -8845,7 +9386,7 @@ function materializePlansAsWorkItems(config) {
8845
9386
  }
8846
9387
  }
8847
9388
 
8848
- // buildBaseVars, selectPlaybook, buildPrDispatch extracted to engine/playbook.js
9389
+ // buildBaseVars, selectPlaybook, buildPrDispatch extracted to engine/agents/playbook.js
8849
9390
 
8850
9391
  function clearPendingHumanFeedbackFlag(projectMeta, prId) {
8851
9392
  if (!prId) return;
@@ -8865,7 +9406,7 @@ function isPrNoOpFixCauseSuppressed(pr, cause) {
8865
9406
  }
8866
9407
 
8867
9408
  // #639 — distinct suppression check for the build-fix-ineffective pause
8868
- // (see shared.isBuildFixIneffectivePaused / engine/lifecycle.js
9409
+ // (see shared.isBuildFixIneffectivePaused / engine/orchestration/lifecycle.js
8869
9410
  // recordBuildFixIneffective). Kept separate from isPrNoOpFixCauseSuppressed
8870
9411
  // so the log line is unambiguous about WHY build-fix automation stopped:
8871
9412
  // repeated attempts genuinely found nothing to change vs. repeated attempts
@@ -9341,8 +9882,8 @@ async function discoverFromPrs(config, project) {
9341
9882
  if (pr.status !== PR_STATUS.ACTIVE) continue;
9342
9883
  // Issue #384: skip tombstoned PRs (user explicitly deleted via dashboard)
9343
9884
  if (pr.userDeleted === true) continue;
9344
- if (shared.isContextOnlyPrRecord(pr)) {
9345
- _logPrDispatchSkipOnce(pr, 'context-only');
9885
+ if (!shared.isAutoManagedPrRecord(pr)) {
9886
+ _logPrDispatchSkipOnce(pr, 'not-auto-managed');
9346
9887
  continue;
9347
9888
  }
9348
9889
  if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
@@ -9368,13 +9909,6 @@ async function discoverFromPrs(config, project) {
9368
9909
  log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
9369
9910
  continue;
9370
9911
  }
9371
- // Auto-managed gate: single source of truth in engine/shared.js. Canonical
9372
- // `contextOnly` wins over stale legacy flags; legacy `_contextOnly` is only
9373
- // a fallback for raw pre-migration records.
9374
- if (!shared.isAutoManagedPrRecord(pr)) {
9375
- _logPrDispatchSkipOnce(pr, 'not-auto-managed');
9376
- continue;
9377
- }
9378
9912
  // #764 — PR-level circuit breaker: a prior dispatch against this PR's
9379
9913
  // branch hit WORKTREE_PREFLIGHT because the branch is held externally
9380
9914
  // (project root or an unreachable sibling worktree). Every dispatch path
@@ -9389,6 +9923,8 @@ async function discoverFromPrs(config, project) {
9389
9923
  // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
9390
9924
  // minionsReview tracks metadata (reviewer, note) but not the authoritative status
9391
9925
  const reviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
9926
+ const prAutoFixEnabled = shared.isPrAutoFixEnabled(pr);
9927
+ const prAutoReviewEnabled = shared.isPrAutoReviewEnabled(pr);
9392
9928
 
9393
9929
  // Skip fix dispatch if a fix was recently submitted and awaiting re-review.
9394
9930
  // The poller holds reviewStatus at 'waiting' until the reviewer acts on the new code.
@@ -9402,8 +9938,8 @@ async function discoverFromPrs(config, project) {
9402
9938
  let fixDispatched = false;
9403
9939
 
9404
9940
  // PRs needing review: evalLoop gates the entire review+fix cycle; pollEnabled ensures reviewStatus is fresh
9405
- const reviewEnabled = evalLoopEnabled && pollEnabled && autoReviewPrs;
9406
- const reReviewEnabled = evalLoopEnabled && pollEnabled && autoReReviewPrs;
9941
+ const reviewEnabled = prAutoReviewEnabled && evalLoopEnabled && pollEnabled && autoReviewPrs;
9942
+ const reReviewEnabled = prAutoReviewEnabled && evalLoopEnabled && pollEnabled && autoReReviewPrs;
9407
9943
  const alreadyReviewed = isPrReviewCurrent(pr);
9408
9944
  const needsReview = reviewEnabled && reviewStatus === REVIEW_STATUS.PENDING && !alreadyReviewed;
9409
9945
  if (needsReview) {
@@ -9473,7 +10009,7 @@ async function discoverFromPrs(config, project) {
9473
10009
  const humanCauseKey = getPrAutomationCauseKey('human-comment', pr);
9474
10010
  const humanFixKey = getPrAutomationDispatchKey(humanFixBaseKey, humanCauseKey);
9475
10011
  const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
9476
- if (pollEnabled && autoFixHumanComments && (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched
10012
+ if (prAutoFixEnabled && pollEnabled && autoFixHumanComments && (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched
9477
10013
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.HUMAN_FEEDBACK)) {
9478
10014
  // W-mp3bp0ha000997ab-d: skip when the most recent HUMAN-FEEDBACK dispatch
9479
10015
  // already noop'd against the same head SHA AND the same triggering
@@ -9703,7 +10239,7 @@ async function discoverFromPrs(config, project) {
9703
10239
 
9704
10240
  // PRs with changes requested → route back to author for fix.
9705
10241
  // Gate on evalLoopEnabled and provider polling — the review→fix cycle depends on fresh vote state.
9706
- if (evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED && !awaitingReReview && !fixDispatched
10242
+ if (prAutoFixEnabled && evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED && !awaitingReReview && !fixDispatched
9707
10243
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.REVIEW_FEEDBACK)) {
9708
10244
  const reviewCauseKey = getPrAutomationCauseKey('review-feedback', pr);
9709
10245
  const key = getPrAutomationDispatchKey(`fix-${project?.name || 'default'}-${prDisplayId}`, reviewCauseKey);
@@ -9759,7 +10295,7 @@ async function discoverFromPrs(config, project) {
9759
10295
  const gracePeriodMs = config.engine?.buildFixGracePeriod ?? ENGINE_DEFAULTS.buildFixGracePeriod;
9760
10296
  if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
9761
10297
  }
9762
- if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
10298
+ if (prAutoFixEnabled && pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
9763
10299
  && !fixDispatched
9764
10300
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)
9765
10301
  && !isBuildFixIneffectiveSuppressed(pr)) {
@@ -9923,7 +10459,7 @@ async function discoverFromPrs(config, project) {
9923
10459
  }
9924
10460
 
9925
10461
  // PRs with merge conflicts — dispatch fix to resolve (gated by provider polling + autoFixConflicts)
9926
- if (pollEnabled && autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched
10462
+ if (prAutoFixEnabled && pollEnabled && autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched
9927
10463
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.MERGE_CONFLICT)) {
9928
10464
  // W-mpritzcr0004afc5 (#2955): "don't fan out a parallel conflict-fix
9929
10465
  // while the review-feedback path owns the PR" is already handled by
@@ -10022,12 +10558,188 @@ async function discoverFromPrs(config, project) {
10022
10558
  return newWork;
10023
10559
  }
10024
10560
 
10561
+ /**
10562
+ * Hard cap on the co-service rows a QA-session prompt can carry. Sessions are
10563
+ * already bounded at `qaSessions.LIMITS.projectsMax` (5); this is the
10564
+ * independent render-side bound so a hand-edited session record can never
10565
+ * inflate a dispatch prompt.
10566
+ */
10567
+ const QA_SESSION_SERVICES_MAX = 8;
10568
+
10569
+ /**
10570
+ * W-msb9kgs402813a97-a — the live co-service inventory for a QA Session.
10571
+ *
10572
+ * A multi-project session owns ONE managed-spawn per project
10573
+ * (`qaSessions.managedSpawnNamesForSession`), but DRAFT/EXECUTE only ever saw
10574
+ * the PRIMARY spawn — so a co-service origin was unaddressable from the test
10575
+ * the agent drafted. This builds a bounded
10576
+ * `{ name, project, primary, health, baseUrl, ports }` row per spawn, which is
10577
+ * surfaced to the playbooks as `{{session_services_json}}` and to runner
10578
+ * adapters as `briefOpts.services`.
10579
+ *
10580
+ * `health` is one of `healthy` | `unhealthy` (alive but failing its
10581
+ * healthcheck) | `down` (registered but not alive) | `missing` (no spec
10582
+ * registered yet — the normal SETUP-phase state). Never throws: an unreadable
10583
+ * managed-process store degrades to `missing` rows, because a render must not
10584
+ * fail over service discovery.
10585
+ *
10586
+ * Exported for testing.
10587
+ */
10588
+ function buildQaSessionServices(session) {
10589
+ if (!session || typeof session !== 'object') return [];
10590
+ let names = [];
10591
+ try {
10592
+ names = require('./engine/qa/sessions').managedSpawnNamesForSession(session);
10593
+ } catch (err) {
10594
+ log('warn', `qa-session render: managedSpawnNamesForSession failed: ${err.message}`);
10595
+ return [];
10596
+ }
10597
+ if (!Array.isArray(names) || names.length === 0) return [];
10598
+
10599
+ const specProjects = Array.isArray(session.spec && session.spec.projects)
10600
+ ? session.spec.projects.filter(p => typeof p === 'string' && p)
10601
+ : [];
10602
+ const primaryProject = session.primaryProject
10603
+ || specProjects[0]
10604
+ || (session.spec && session.spec.project)
10605
+ || null;
10606
+
10607
+ const byName = new Map();
10608
+ try {
10609
+ for (const spec of require('./engine/processes/managed-spawn').listManagedSpecs()) {
10610
+ if (spec && typeof spec.name === 'string') byName.set(spec.name, spec);
10611
+ }
10612
+ } catch (err) {
10613
+ log('warn', `qa-session render: managed-spawn inventory unavailable: ${err.message}`);
10614
+ }
10615
+
10616
+ return names.slice(0, QA_SESSION_SERVICES_MAX).map((name, index) => {
10617
+ // managedSpawnNamesForSession maps spec.projects positionally, so index
10618
+ // alignment is its contract; single/central sessions collapse to the one
10619
+ // primary row.
10620
+ const project = specProjects.length > 1
10621
+ ? (specProjects[index] || null)
10622
+ : (primaryProject || null);
10623
+ const record = byName.get(name) || null;
10624
+ let health = 'missing';
10625
+ if (record) {
10626
+ if (record.healthy === true) health = 'healthy';
10627
+ else if (record.alive === true) health = 'unhealthy';
10628
+ else health = 'down';
10629
+ }
10630
+ return {
10631
+ name,
10632
+ project,
10633
+ primary: !project || project === primaryProject,
10634
+ health,
10635
+ baseUrl: (record && record.attrs && typeof record.attrs.base_url === 'string')
10636
+ ? record.attrs.base_url
10637
+ : '',
10638
+ ports: (record && Array.isArray(record.ports)) ? record.ports.slice() : [],
10639
+ };
10640
+ });
10641
+ }
10642
+
10643
+ /**
10644
+ * Render-time wrapper: resolve the session behind a QA work item and encode its
10645
+ * service inventory. Returns '' for non-QA items and for any lookup failure, so
10646
+ * `{{session_services_json}}` degrades to the PLAYBOOK_OPTIONAL_VARS empty
10647
+ * string rather than breaking the render.
10648
+ */
10649
+ function _qaSessionServicesJson(item) {
10650
+ const meta = item && item.meta;
10651
+ if (!meta || !meta.sessionId) return '';
10652
+ try {
10653
+ const session = require('./engine/qa/sessions').getSession(meta.sessionId);
10654
+ if (!session) return '';
10655
+ const services = buildQaSessionServices(session);
10656
+ return services.length ? JSON.stringify(services) : '';
10657
+ } catch (err) {
10658
+ log('warn', `qa-session render: session services lookup failed for ${meta.sessionId}: ${err.message}`);
10659
+ return '';
10660
+ }
10661
+ }
10662
+
10663
+ /**
10664
+ * Independent render-side cap on the manifest journey rows an EXECUTE prompt
10665
+ * can carry. The DRAFT gate already bounds an accepted manifest at
10666
+ * `visualJourney.LIMITS.journeysMax`, but a hand-edited session record must
10667
+ * never be able to inflate a dispatch prompt.
10668
+ */
10669
+ const QA_SESSION_JOURNEYS_MAX = 20;
10670
+
10671
+ /**
10672
+ * W-msb9kgs402813a97-c — the DRAFT-accepted visual-journey manifest, projected
10673
+ * into the exact shape EXECUTE has to report back.
10674
+ *
10675
+ * `visualJourney.validateEvidenceCoverage` check (c) requires every journey the
10676
+ * manifest declared to come back as a PASSED `journeyCoverage` entry keyed by
10677
+ * the journey id VERBATIM. Those ids are free-form strings the DRAFT agent
10678
+ * invents (`playbooks/qa-session-draft.md` calls them "short-stable-id"), the
10679
+ * DRAFT sidecar that carried them is read-then-unlinked, and the accepted
10680
+ * manifest is persisted on the session record only. So without this projection
10681
+ * the EXECUTE agent is graded against a value it was never shown, and every
10682
+ * session that requested screenshots or video fails
10683
+ * `qa-session-evidence-incomplete` on a flawless run.
10684
+ *
10685
+ * Field names deliberately mirror the sidecar's coverage entry (`journeyId`,
10686
+ * `projects`, `services`) rather than the manifest's own (`id`, …), so the
10687
+ * contract the agent is handed is copy-shaped for the contract it must emit.
10688
+ *
10689
+ * Pure and never throws — exported for testing.
10690
+ */
10691
+ function buildQaExecuteJourneyContract(session) {
10692
+ if (!session || typeof session !== 'object') return [];
10693
+ const manifest = session.visualJourneyManifest;
10694
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return [];
10695
+ if (!Array.isArray(manifest.journeys)) return [];
10696
+ const boundedList = (value) => (Array.isArray(value)
10697
+ ? value.filter(v => typeof v === 'string' && v).slice(0, QA_SESSION_SERVICES_MAX)
10698
+ : []);
10699
+ return manifest.journeys
10700
+ .filter(j => j && typeof j === 'object' && typeof j.id === 'string' && j.id)
10701
+ .slice(0, QA_SESSION_JOURNEYS_MAX)
10702
+ .map(j => ({
10703
+ journeyId: j.id,
10704
+ name: typeof j.name === 'string' ? j.name : '',
10705
+ kind: typeof j.kind === 'string' ? j.kind : '',
10706
+ projects: boundedList(j.projects),
10707
+ services: boundedList(j.services),
10708
+ }));
10709
+ }
10710
+
10711
+ /**
10712
+ * Render-time wrapper for `{{visual_journey_manifest_json}}`.
10713
+ *
10714
+ * EXECUTE-only on purpose: the manifest does not exist until DRAFT produces it,
10715
+ * and on the re-draft path (`editDraft` re-queues DRAFT for the same session)
10716
+ * showing the PREVIOUS attempt's manifest to the DRAFT agent would invite it to
10717
+ * copy ids it is supposed to be re-deciding.
10718
+ *
10719
+ * Returns '' for non-QA items, non-EXECUTE phases, logs-only/legacy sessions
10720
+ * with no persisted manifest, and any lookup failure, so the var degrades to
10721
+ * the PLAYBOOK_OPTIONAL_VARS empty string rather than breaking the render.
10722
+ */
10723
+ function _qaVisualJourneyManifestJson(item) {
10724
+ const meta = item && item.meta;
10725
+ if (!meta || !meta.sessionId || meta.sessionPhase !== 'execute') return '';
10726
+ try {
10727
+ const session = require('./engine/qa/sessions').getSession(meta.sessionId);
10728
+ if (!session) return '';
10729
+ const journeys = buildQaExecuteJourneyContract(session);
10730
+ return journeys.length ? JSON.stringify(journeys, null, 2) : '';
10731
+ } catch (err) {
10732
+ log('warn', `qa-session render: visual-journey manifest lookup failed for ${meta.sessionId}: ${err.message}`);
10733
+ return '';
10734
+ }
10735
+ }
10736
+
10025
10737
  /**
10026
10738
  * P-f9a2e1b4 — Compute runner_brief / runner_execute_brief / test_file for
10027
10739
  * QA Session DRAFT and EXECUTE dispatches.
10028
10740
  *
10029
- * Lazy-requires `./engine/qa-sessions`, `./engine/qa-runners`, and
10030
- * `./engine/managed-spawn` so non-QA dispatches don't pay the load cost
10741
+ * Lazy-requires `./engine/qa/sessions`, `./engine/qa-runners`, and
10742
+ * `./engine/processes/managed-spawn` so non-QA dispatches don't pay the load cost
10031
10743
  * and so test isolation (createTestMinionsDir → ISOLATED_MODULES) gets a
10032
10744
  * fresh module instance per test.
10033
10745
  *
@@ -10049,9 +10761,9 @@ function _buildRunnerBriefVars(item, project, qaRunPaths = {}) {
10049
10761
  const phase = meta.sessionPhase;
10050
10762
  if (phase !== 'draft' && phase !== 'execute') return empty;
10051
10763
  try {
10052
- const qaSessions = require('./engine/qa-sessions');
10764
+ const qaSessions = require('./engine/qa/sessions');
10053
10765
  const qaRunners = require('./engine/qa-runners');
10054
- const managedSpawn = require('./engine/managed-spawn');
10766
+ const managedSpawn = require('./engine/processes/managed-spawn');
10055
10767
  const session = qaSessions.getSession(meta.sessionId);
10056
10768
  if (!session) {
10057
10769
  log('warn', `qa-session render: session ${meta.sessionId} not found — runner brief empty`);
@@ -10084,6 +10796,10 @@ function _buildRunnerBriefVars(item, project, qaRunPaths = {}) {
10084
10796
  session,
10085
10797
  sessionId: session.id,
10086
10798
  spawnInfo,
10799
+ // W-msb9kgs402813a97-a — every co-service origin the session owns, not
10800
+ // just the primary `spawnInfo`. Runner adapters address a co-service by
10801
+ // its own baseUrl/ports instead of assuming one origin per session.
10802
+ services: buildQaSessionServices(session),
10087
10803
  flowsRaw: (meta.qaSession && meta.qaSession.flowsRaw) || session.spec.flowsRaw || '',
10088
10804
  capture: (meta.qaSession && meta.qaSession.capture) || session.spec.capture || {},
10089
10805
  testFile,
@@ -10166,13 +10882,24 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
10166
10882
  managedSpawnLookupUrl =
10167
10883
  `http://localhost:${dashboardPort}/api/managed-processes/by-name/${encodeURIComponent(managedSpawnName)}`;
10168
10884
  }
10169
- const qaRuns = qaRunId ? require('./engine/qa-runs') : null;
10885
+ const qaRuns = qaRunId ? require('./engine/qa/runs') : null;
10170
10886
  const qaRunPaths = qaRunId
10171
10887
  ? {
10172
10888
  artifactsDir: qaRuns.qaArtifactsDirForRun(qaRunId),
10173
10889
  resultSidecar: qaRuns.resultSidecarPath(String(agentId)),
10174
10890
  }
10175
10891
  : { artifactsDir: '', resultSidecar: '' };
10892
+ // W-msb9kgs402813a97-b — absolute path of the DRAFT visual-journey manifest
10893
+ // sidecar. Only the DRAFT phase writes one; every other dispatch renders ''
10894
+ // (filtered via PLAYBOOK_OPTIONAL_VARS).
10895
+ let qaDraftResultSidecar = '';
10896
+ if (item.meta && item.meta.sessionId && item.meta.sessionPhase === 'draft') {
10897
+ try {
10898
+ qaDraftResultSidecar = require('./engine/qa/visual-journey').draftResultSidecarPath(String(agentId));
10899
+ } catch (err) {
10900
+ log('warn', `qa-session render: draft result sidecar path for ${item.meta.sessionId}: ${err.message}`);
10901
+ }
10902
+ }
10176
10903
  const vars = {
10177
10904
  ...buildBaseVars(agentId, config, project),
10178
10905
  item_id: item.id,
@@ -10199,7 +10926,7 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
10199
10926
  // suppress their own premature PR-creation step the same way via
10200
10927
  // {{#shared_branch}} / {{^shared_branch}} conditional blocks. Truthy only
10201
10928
  // when the item is genuinely on a shared branch (mirrors selectPlaybook's
10202
- // implement→implement-shared gate at engine/playbook.js). Default '' for
10929
+ // implement→implement-shared gate at engine/agents/playbook.js). Default '' for
10203
10930
  // parallel/PR-targeted dispatches keeps existing per-type PR behavior.
10204
10931
  shared_branch: (item.branchStrategy === 'shared-branch' && item.featureBranch) ? '1' : '',
10205
10932
  // W-mrtdmtwq000kf391 — resolved PR contract flag. Truthy when the work item
@@ -10236,6 +10963,12 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
10236
10963
  managed_spawn_ttl_minutes: item.meta && Number.isFinite(Number(item.meta.managed_spawn_ttl_minutes))
10237
10964
  ? Math.floor(Number(item.meta.managed_spawn_ttl_minutes))
10238
10965
  : '',
10966
+ // W-ms5tb6ha016fda10 — declared execution surface. Resolved fail-closed so
10967
+ // only a valid `control-plane` opt-in on an eligible type reaches the
10968
+ // playbook; everything else renders as a normal repo dispatch.
10969
+ execution_surface: shared.isControlPlaneExecution(item, workType)
10970
+ ? shared.EXECUTION_SURFACE.CONTROL_PLANE
10971
+ : shared.EXECUTION_SURFACE.REPO,
10239
10972
  // W-mq5l3f2u000i9045 — opt-out flag for the inlined PR description audit
10240
10973
  // section in fix.md / implement.md. When the dispatcher sets
10241
10974
  // item.meta.skipDescriptionAudit (skill-meta updates, doc-only fixes,
@@ -10254,8 +10987,12 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
10254
10987
  qa_target: (item.meta && item.meta.qaTarget) || null,
10255
10988
  qa_artifacts_dir: qaRunPaths.artifactsDir,
10256
10989
  qa_result_sidecar: qaRunPaths.resultSidecar,
10990
+ // W-msb9kgs402813a97-b — where the DRAFT agent writes its visual-journey
10991
+ // manifest. The engine consumes it exactly once in
10992
+ // engine/orchestration/lifecycle.js and gates EXECUTE on it.
10993
+ qa_draft_result_sidecar: qaDraftResultSidecar,
10257
10994
  // P-e6b3c2d8 — QA Session template vars. The qa-sessions chain helpers
10258
- // (engine/qa-sessions.js#_baseWorkItem) stamp meta.sessionId,
10995
+ // (engine/qa/sessions.js#_baseWorkItem) stamp meta.sessionId,
10259
10996
  // meta.sessionPhase, and meta.qaSession.{target,flowsRaw,mode,capture,runner}
10260
10997
  // on each SETUP/DRAFT/EXECUTE WI; renderProjectWorkItemPromptForAgent
10261
10998
  // surfaces them as named template vars so the qa-session-* playbooks
@@ -10269,6 +11006,18 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
10269
11006
  ? 'qa-session-' + String(item.meta.sessionId)
10270
11007
  : '',
10271
11008
  managed_spawn_lookup_url: managedSpawnLookupUrl,
11009
+ // W-msb9kgs402813a97-a — every managed-spawn the session owns, as
11010
+ // `[{name, project, primary, health, baseUrl, ports}]`. `managed_spawn_name`
11011
+ // stays the PRIMARY (back-compat); this var is what lets DRAFT/EXECUTE
11012
+ // address a co-service origin. Empty string when the item is not a QA
11013
+ // session (filtered via PLAYBOOK_OPTIONAL_VARS).
11014
+ session_services_json: _qaSessionServicesJson(item),
11015
+ // W-msb9kgs402813a97-c — the DRAFT-accepted visual-journey manifest,
11016
+ // projected into the sidecar's coverage-entry shape. EXECUTE's evidence
11017
+ // gate grades the run against these journey ids verbatim, so the agent has
11018
+ // to be shown them; the DRAFT sidecar that carried them is already
11019
+ // consumed. Empty for every non-EXECUTE and non-visual dispatch.
11020
+ visual_journey_manifest_json: _qaVisualJourneyManifestJson(item),
10272
11021
  target_kind: (item.meta && item.meta.qaSession && item.meta.qaSession.target && item.meta.qaSession.target.kind) || '',
10273
11022
  target_pr_id: (item.meta && item.meta.qaSession && item.meta.qaSession.target && item.meta.qaSession.target.kind === 'pr'
10274
11023
  ? String(item.meta.qaSession.target.prId || '')
@@ -10543,12 +11292,56 @@ function discoverFromWorkItems(config, project) {
10543
11292
  }
10544
11293
  }
10545
11294
 
11295
+ // W-mrzqfozm00035365 (requirement 7) — safe reconciliation: revive items
11296
+ // that terminally FAILED solely because a pre-execution dependency merge
11297
+ // conflict exhausted their retries, when the deterministic conflict-repair
11298
+ // WI exists and is still live (pending/dispatched) or done. Guarded by
11299
+ // shouldReviveFailedDependencyConflict so unrelated failures are never
11300
+ // revived. After revival the recoverable gate below governs redispatch, so
11301
+ // there is no immediate retry loop and no retry charge.
11302
+ if (item.status === WI_STATUS.FAILED && !isItemCompleted(item)) {
11303
+ const repair = findConflictRepairForBlockedItem(item.id, items, item._dependencyConflict?.repairWiId);
11304
+ if (shouldReviveFailedDependencyConflict(item, repair)) {
11305
+ // Ensure the repair links back to this item so the gate can find it on
11306
+ // later ticks (legacy repairs only tracked the first blocked item).
11307
+ if (!Array.isArray(repair._blockedItems)) {
11308
+ repair._blockedItems = repair._blockedItem ? [repair._blockedItem] : [];
11309
+ }
11310
+ if (!repair._blockedItems.includes(item.id)) repair._blockedItems.push(item.id);
11311
+ // Re-stamp / preserve the recoverable marker so the discovery gate
11312
+ // governs redispatch instead of an immediate dispatch attempt.
11313
+ if (!item._dependencyConflict) {
11314
+ item._dependencyConflict = buildDependencyConflictMarker({
11315
+ repairWiId: repair.id,
11316
+ conflictBranch: repair.featureBranch || repair._branch || null,
11317
+ conflictsWith: null,
11318
+ isInterDep: !!repair._isInterDepConflict,
11319
+ files: [],
11320
+ detectedAt: item.failedAt || ts(),
11321
+ });
11322
+ } else if (!item._dependencyConflict.repairWiId) {
11323
+ item._dependencyConflict.repairWiId = repair.id;
11324
+ }
11325
+ const prevReason = item.failReason;
11326
+ item.status = WI_STATUS.PENDING;
11327
+ delete item.failReason;
11328
+ delete item.failedAt;
11329
+ delete item._failureClass;
11330
+ delete item._lastDispatchResult;
11331
+ delete item.dispatched_at;
11332
+ delete item.dispatched_to;
11333
+ item._pendingReason = DEP_CONFLICT_PENDING_REASON;
11334
+ needsWrite = true;
11335
+ log('info', `Dependency-conflict reconciliation: revived ${item.id} from failed → pending behind repair ${repair.id} (was: ${prevReason})`);
11336
+ }
11337
+ }
11338
+
10546
11339
  // #2996 follow-up: auto-recover from WORKTREE_DIRTY / WORKTREE_DIVERGENT quarantines.
10547
11340
  // The quarantine is self-healing (dir renamed away, ref backed up, branch reset to
10548
11341
  // origin), so the next dispatch starts from a clean worktree. Auto-flip to pending up
10549
11342
  // to ENGINE_DEFAULTS.quarantineAutoRecoveryMax times so WIs without downstream deps and
10550
11343
  // no human watching don't rot in `failed`. _failureClass is stamped on the WI only by
10551
- // the force-demote path (engine/dispatch.js); the standard non-retryable path leaves
11344
+ // the force-demote path (engine/orchestration/dispatch.js); the standard non-retryable path leaves
10552
11345
  // it on the dispatch record but embeds the class name in failReason — so detection
10553
11346
  // covers both shapes.
10554
11347
  if (item.status === WI_STATUS.FAILED && !isItemCompleted(item)) {
@@ -10629,6 +11422,37 @@ function discoverFromWorkItems(config, project) {
10629
11422
  }
10630
11423
  }
10631
11424
 
11425
+ // W-mrzqfozm00035365 — recoverable dependency merge-conflict gate. A
11426
+ // downstream item whose dependency branches conflicted during pre-execution
11427
+ // integration carries a `_dependencyConflict` marker instead of a terminal
11428
+ // failure. Decide wait/wake/block from the linked conflict-repair WI's
11429
+ // current status:
11430
+ // wait → repair still in progress; keep pending, do NOT redispatch into
11431
+ // the same conflict (no retry charged).
11432
+ // wake → repair done after detection; clear the marker and fall through
11433
+ // so re-dispatch re-fetches dep refs and recomputes integration.
11434
+ // block → repair failed/cancelled, or completed before re-detection and
11435
+ // branches still conflict; keep pending with an actionable
11436
+ // unresolved reason and DO NOT loop.
11437
+ if (item._dependencyConflict) {
11438
+ const repair = findConflictRepairForBlockedItem(item.id, items, item._dependencyConflict.repairWiId);
11439
+ const gate = evaluateDependencyConflictGate(item, repair);
11440
+ if (gate.action === 'wake') {
11441
+ delete item._dependencyConflict;
11442
+ if (item._pendingReason === DEP_CONFLICT_PENDING_REASON
11443
+ || item._pendingReason === DEP_CONFLICT_UNRESOLVED_PENDING_REASON) {
11444
+ delete item._pendingReason;
11445
+ }
11446
+ needsWrite = true;
11447
+ log('info', `Dependency-conflict gate cleared for ${item.id}: ${gate.reason} — re-running dependency integration on fresh refs`);
11448
+ // fall through to the normal dispatch path.
11449
+ } else {
11450
+ if (item._pendingReason !== gate.pendingReason) { item._pendingReason = gate.pendingReason; needsWrite = true; }
11451
+ skipped.gated++;
11452
+ continue;
11453
+ }
11454
+ }
11455
+
10632
11456
  const key = `work-${project?.name || 'default'}-${item.id}`;
10633
11457
  // Self-heal: collect keys for batched dispatch cleanup.
10634
11458
  selfHealKeys.add(key);
@@ -10795,7 +11619,7 @@ function discoverFromWorkItems(config, project) {
10795
11619
  // extractWorkItemPrRef (title/first-paragraph-of-description text scan),
10796
11620
  // which is fine for prompt-context enrichment (promptItem, above) that
10797
11621
  // downgrades gracefully on a miss. But `dispatch.meta.pr` is a hard signal:
10798
- // `_isPrBackedDispatch`/`getStalePrDispatchReason` (engine/dispatch.js)
11622
+ // `_isPrBackedDispatch`/`getStalePrDispatchReason` (engine/orchestration/dispatch.js)
10799
11623
  // treat ANY dispatch carrying it as PR-backed and will cancel the source
10800
11624
  // work item outright if the tracked PR turns out to be merged/abandoned/
10801
11625
  // context-only or branch-mismatched. A loose text match against an
@@ -10810,7 +11634,7 @@ function discoverFromWorkItems(config, project) {
10810
11634
  // refactor item whose prose mentioned "PR #3015 for context") and trip
10811
11635
  // the gate even though the item has no real PR target. The dashboard's
10812
11636
  // create-time stamp path keeps using the loose form — gate uses
10813
- // structured-only; stamp uses loose. See engine/shared.js
11637
+ // structured-only; stamp uses loose. See engine/core/shared.js
10814
11638
  // extractStructuredWorkItemPrRef for the structured-source list.
10815
11639
  if (!linkedPr && getStructuredWorkItemPrRef(item) && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
10816
11640
  if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
@@ -11362,6 +12186,45 @@ function discoverCentralWorkItems(config) {
11362
12186
  continue;
11363
12187
  }
11364
12188
 
12189
+ // W-ms9tcry701xz0389 — pre-spawn PRD result recovery. A previous
12190
+ // plan-to-prd attempt may have produced a perfectly valid sidecar that was
12191
+ // never imported (the dispatch was interrupted, timed out, or its
12192
+ // completion was not accepted). Because `_prdFilename` is now retry-stable
12193
+ // (see the plan-to-prd var block below), that sidecar still declares THIS
12194
+ // work item's expected identity — so import it and finish, instead of
12195
+ // burning another 10-minute model run recreating identical content.
12196
+ // Runs before agent resolution so a recovered item never claims an agent.
12197
+ // consumePrdResultSidecar fails closed: a sidecar belonging to another
12198
+ // work item or source plan is rejected and left untouched.
12199
+ if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile && item._prdFilename && item.dispatched_to) {
12200
+ const recovery = prdResultSidecar.consumePrdResultSidecar({
12201
+ agentsDir: AGENTS_DIR,
12202
+ agentId: item.dispatched_to,
12203
+ expectedFilename: item._prdFilename,
12204
+ planFile: item.planFile,
12205
+ workItemId: item.id,
12206
+ // W-msa0mrus00mh466f — a recovered revision must normalize exactly like
12207
+ // a completed one, or the sidecar rescue would resurrect the PRD still
12208
+ // flagged revision-requested/stale with its pointers dangling.
12209
+ revisionForPrd: item._revisionForPrd,
12210
+ plansDir: PLANS_DIR,
12211
+ });
12212
+ if (recovery.imported) {
12213
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, {
12214
+ status: WI_STATUS.DONE,
12215
+ completedAt: ts(),
12216
+ _prdFilename: recovery.filename,
12217
+ _artifacts: { ...(item._artifacts || {}), prd: recovery.filename, plan: item.planFile },
12218
+ resultSummary: `Imported the PRD result sidecar left by a prior dispatch attempt (${recovery.filename}) — no re-run needed.`,
12219
+ }));
12220
+ log('info', `plan-to-prd ${item.id}: recovered PRD "${recovery.filename}" from a prior attempt's sidecar — skipping re-dispatch`);
12221
+ continue;
12222
+ }
12223
+ if (recovery.code !== prdResultSidecar.PRD_RESULT_OUTCOME.ABSENT) {
12224
+ log('info', `plan-to-prd ${item.id}: prior sidecar not usable (${recovery.reason}) — dispatching a fresh attempt`);
12225
+ }
12226
+ }
12227
+
11365
12228
  if (isFanOut) {
11366
12229
  // ─── Fan-out: dispatch to ALL idle agents ───────────────────────
11367
12230
  const idleAgents = Object.entries(config.agents)
@@ -11646,13 +12509,29 @@ function discoverCentralWorkItems(config) {
11646
12509
  // `plans/` prefix or separator difference between item.planFile and a
11647
12510
  // PRD's source_plan can't fork a duplicate PRD (#415 class).
11648
12511
  let prdFilename = null;
12512
+ let resolvedPrd = null;
11649
12513
  const planKey = path.basename(String(item.planFile || ''));
11650
12514
  const prdRows = prdStore.listPrdRows();
11651
12515
  const prdFiles = prdRows.filter(row => !row.archived).map(row => row.filename);
12516
+ // Explicit PRD pin (W-msa0mrus00mh466f): a revision / diff-aware regen
12517
+ // work item names the PRD it must rewrite. Honor it before the
12518
+ // source_plan scan so the operation lands on that exact PRD even when
12519
+ // the PRD has no source markdown to match on.
12520
+ if (item._existingPrdFile && prdFiles.includes(item._existingPrdFile)) {
12521
+ const pinned = prdStore.readPrd(item._existingPrdFile);
12522
+ if (pinned && !shared.isPrdArchived(pinned)) {
12523
+ prdFilename = item._existingPrdFile;
12524
+ resolvedPrd = pinned;
12525
+ vars.existing_prd_json = JSON.stringify(pinned, null, 2);
12526
+ log('info', `plan-to-prd: targeting pinned PRD "${prdFilename}" for ${item.id} (_existingPrdFile)`);
12527
+ }
12528
+ }
11652
12529
  for (const pf of prdFiles) {
12530
+ if (prdFilename) break;
11653
12531
  const prd = prdStore.readPrd(pf);
11654
12532
  if (planKey && prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, planKey)) {
11655
12533
  prdFilename = pf;
12534
+ resolvedPrd = prd;
11656
12535
  vars.existing_prd_json = JSON.stringify(prd, null, 2);
11657
12536
  log('info', `plan-to-prd: reusing existing PRD "${pf}" for plan "${item.planFile}" (#884)`);
11658
12537
  break;
@@ -11664,6 +12543,20 @@ function discoverCentralWorkItems(config) {
11664
12543
  // prepared this same tick — REUSE it so concurrent/repeat plan-to-prd
11665
12544
  // runs for one plan converge to a single PRD instead of splitting into
11666
12545
  // <slug>-<date>.json / -2.json / -3.json. Match by basename.
12546
+ if (!prdFilename && planKey) {
12547
+ // W-ms9tcry701xz0389 — RETRY STABILITY comes first: reuse the
12548
+ // filename THIS item already pinned for THIS plan. The externally
12549
+ // visible result identity (the name the agent is told to declare in
12550
+ // its sidecar) must never be re-derived from transient per-dispatch
12551
+ // state. It used to be: the cross-repo slug is `cross-<uid>` with a
12552
+ // fresh uid per dispatch, so every retry invented a new expected
12553
+ // filename, instantly orphaning the previous attempt's otherwise
12554
+ // valid sidecar and forcing a full regeneration.
12555
+ if (item._prdFilename && (!item._planKey || item._planKey === planKey)) {
12556
+ prdFilename = item._prdFilename;
12557
+ log('info', `plan-to-prd: reusing pinned PRD "${prdFilename}" for ${item.id} (retry-stable identity)`);
12558
+ }
12559
+ }
11667
12560
  if (!prdFilename && planKey) {
11668
12561
  for (const otherItem of items) {
11669
12562
  if (!otherItem || otherItem.id === item.id || !otherItem._prdFilename) continue;
@@ -11715,6 +12608,12 @@ function discoverCentralWorkItems(config) {
11715
12608
  while (prdExisting.has(prdFilename)) { prdFilename = prdBase + '-' + prdCounter + '.json'; prdCounter++; }
11716
12609
  }
11717
12610
  vars.prd_filename = prdFilename;
12611
+ // W-msa0mrus00mh466f — a revision dispatch's ONLY distinguishing input is
12612
+ // the operator's feedback. `planFile` names the source markdown, so
12613
+ // {{plan_content}} renders the plan and the work-item description (which
12614
+ // embeds the feedback) is never spliced into this playbook. Carry the
12615
+ // feedback explicitly, read from the PRD that owns it.
12616
+ vars.revision_feedback = shared.resolvePrdRevisionFeedback(item, resolvedPrd, prdFilename);
11718
12617
  // Pin _planKey alongside _prdFilename so the same-plan in-flight reuse
11719
12618
  // above can recognize a sibling prepared earlier THIS tick by plan.
11720
12619
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _prdFilename: prdFilename, _planKey: planKey }));
@@ -11921,10 +12820,10 @@ async function discoverWork(config) {
11921
12820
  // Gated by config.engine?.scheduledWorkDiscoveryEnabled !== false (P-d6f0a2b5).
11922
12821
  if (config.engine?.scheduledWorkDiscoveryEnabled !== false) {
11923
12822
  try {
11924
- const { discoverScheduledWork } = require('./engine/scheduler');
12823
+ const { discoverScheduledWork } = require('./engine/orchestration/scheduler');
11925
12824
  const scheduledWork = discoverScheduledWork(config);
11926
12825
  if (scheduledWork.length > 0) {
11927
- const { createMeeting, getMeetings } = require('./engine/meeting');
12826
+ const { createMeeting, getMeetings } = require('./engine/orchestration/meeting');
11928
12827
  // Separate meetings (no work-items write) from task items
11929
12828
  const taskItems = [];
11930
12829
  for (const item of scheduledWork) {
@@ -11982,7 +12881,7 @@ async function discoverWork(config) {
11982
12881
 
11983
12882
  // Meeting work (multi-round team discussions)
11984
12883
  try {
11985
- const { discoverMeetingWork } = require('./engine/meeting');
12884
+ const { discoverMeetingWork } = require('./engine/orchestration/meeting');
11986
12885
  const meetingWork = discoverMeetingWork(config);
11987
12886
  // #768 — persist immediately, same rationale as the per-project loop above.
11988
12887
  await persistBatch(meetingWork);
@@ -11990,7 +12889,7 @@ async function discoverWork(config) {
11990
12889
 
11991
12890
  // Pipeline orchestration — check stage completions and start ready stages
11992
12891
  try {
11993
- const { discoverPipelineWork } = require('./engine/pipeline');
12892
+ const { discoverPipelineWork } = require('./engine/orchestration/pipeline');
11994
12893
  await discoverPipelineWork(config);
11995
12894
  } catch (e) { log('warn', 'discover pipeline work: ' + e.message); }
11996
12895
 
@@ -11999,7 +12898,7 @@ async function discoverWork(config) {
11999
12898
  // Throttled to ~10 min (P3 decision) — cadence in ENGINE_DEFAULTS.planCompletionScanEvery
12000
12899
  if (tickCount % (ENGINE_DEFAULTS.planCompletionScanEvery || 60) === 0) {
12001
12900
  try {
12002
- const lifecycle = require('./engine/lifecycle');
12901
+ const lifecycle = require('./engine/orchestration/lifecycle');
12003
12902
  for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
12004
12903
  if (completedPlanCache.has(f)) continue;
12005
12904
  const plan = prdStore.readPrd(f);
@@ -12322,7 +13221,7 @@ function _resolveTickHardTimeoutMs() {
12322
13221
  // W-mrktrokd — retry default-schedule startup reconciliation until it completes
12323
13222
  // while enabled, then keep the steady-state tick free of config.json work.
12324
13223
  let _scheduleBootstrapDone = false;
12325
- function _runScheduleBootstrapOnce(config, scheduleBootstrap = require('./engine/schedule-bootstrap')) {
13224
+ function _runScheduleBootstrapOnce(config, scheduleBootstrap = require('./engine/orchestration/schedule-bootstrap')) {
12326
13225
  if (_scheduleBootstrapDone || !scheduleBootstrap.bootstrapEnabled(config)) return [];
12327
13226
  const reconciled = scheduleBootstrap.ensureBootstrapSchedules();
12328
13227
  if (reconciled === null) return [];
@@ -12343,7 +13242,7 @@ async function tickInner() {
12343
13242
  }
12344
13243
 
12345
13244
  // W-mpcyvff6000pf828 (#2653) — control.heartbeat is written by a dedicated
12346
- // 15s interval in engine/cli.js (createHeartbeatWriter), decoupled from
13245
+ // 15s interval in engine/operations/cli.js (createHeartbeatWriter), decoupled from
12347
13246
  // tickInner so a slow tick (cold runtime spawn, sequential PR polls, slow
12348
13247
  // worktree create) cannot starve heartbeats and flip the dashboard to STALE
12349
13248
  // on an otherwise healthy engine.
@@ -12357,7 +13256,7 @@ async function tickInner() {
12357
13256
  // dashboard's #engine-quick-stats "Next tick in Xs" countdown has a reliable
12358
13257
  // anchor (control.json carries no separate tick-counter field). Synchronous
12359
13258
  // mutator, no awaits inside the lock — mirrors the writer contract used by
12360
- // engine/cli.js#writeHeartbeatNow. Distinct from control.heartbeat: this
13259
+ // engine/operations/cli.js#writeHeartbeatNow. Distinct from control.heartbeat: this
12361
13260
  // advances once per tick cadence, not on the 15s heartbeat cadence, so the
12362
13261
  // client can render an accurate "next tick" countdown without coupling to
12363
13262
  // the staleness signal.
@@ -12380,13 +13279,13 @@ async function tickInner() {
12380
13279
  if (_isTickStale(myGeneration)) return;
12381
13280
  safe('checkSteering', () => checkSteering(config));
12382
13281
  safe('checkSpawnPhaseStalls', () => {
12383
- const { checkSpawnPhaseStalls } = require('./engine/spawn-phase-watchdog');
13282
+ const { checkSpawnPhaseStalls } = require('./engine/agents/spawn-phase-watchdog');
12384
13283
  checkSpawnPhaseStalls(activeProcesses, config);
12385
13284
  });
12386
13285
  safe('checkIdleThreshold', () => checkIdleThreshold(config));
12387
13286
 
12388
13287
  // 1b. Check for meeting round timeouts
12389
- safe('meetingTimeouts', () => { const { checkMeetingTimeouts } = require('./engine/meeting'); checkMeetingTimeouts(config); });
13288
+ safe('meetingTimeouts', () => { const { checkMeetingTimeouts } = require('./engine/orchestration/meeting'); checkMeetingTimeouts(config); });
12390
13289
 
12391
13290
  // In stopping state, only track agent completions — skip discovery and dispatch
12392
13291
  if (control.state === 'stopping') {
@@ -12418,7 +13317,7 @@ async function tickInner() {
12418
13317
  // disables it; 4h cadence is enforced inside shouldAutoSweep().
12419
13318
  if ((config.engine?.autoConsolidateMemory ?? ENGINE_DEFAULTS.autoConsolidateMemory) === true) {
12420
13319
  safe('autoSweepKb', () => {
12421
- const { shouldAutoSweep, spawnSweepRunnerDetached } = require('./engine/kb-sweep');
13320
+ const { shouldAutoSweep, spawnSweepRunnerDetached } = require('./engine/memory/kb-sweep');
12422
13321
  const decision = shouldAutoSweep();
12423
13322
  if (!decision.shouldSpawn) return;
12424
13323
  const result = spawnSweepRunnerDetached({
@@ -12447,7 +13346,7 @@ async function tickInner() {
12447
13346
  const keepSweepEvery = Math.max(1, ENGINE_DEFAULTS.keepProcesses?.sweepEvery || 180);
12448
13347
  if (ENGINE_DEFAULTS.keepProcesses?.enabled !== false && tickCount % keepSweepEvery === 0) {
12449
13348
  safe('sweepKeepProcesses', () => {
12450
- const { sweepKeepProcesses } = require('./engine/keep-process-sweep');
13349
+ const { sweepKeepProcesses } = require('./engine/processes/keep-process-sweep');
12451
13350
  const stats = sweepKeepProcesses();
12452
13351
  if (stats.scanned > 0 && (stats.expiredFiles || stats.deadFiles || stats.malformed)) {
12453
13352
  log('info', `keep-processes sweep: scanned=${stats.scanned} expired=${stats.expiredFiles} dead=${stats.deadFiles} malformed=${stats.malformed} killed=${stats.killedPids}`);
@@ -12465,7 +13364,7 @@ async function tickInner() {
12465
13364
  const managedSweepEvery = Math.max(1, ENGINE_DEFAULTS.managedSpawn?.sweepEvery || 180);
12466
13365
  if (ENGINE_DEFAULTS.managedSpawn?.enabled !== false && tickCount % managedSweepEvery === 0) {
12467
13366
  safe('sweepManagedSpawn', () => {
12468
- const { sweepManagedSpawn } = require('./engine/managed-spawn');
13367
+ const { sweepManagedSpawn } = require('./engine/processes/managed-spawn');
12469
13368
  const stats = sweepManagedSpawn();
12470
13369
  if (stats.scanned > 0 && (stats.ttlExpired || stats.deadDropped || stats.rotatedLogs || stats.malformed)) {
12471
13370
  log('info', `managed-spawn sweep: scanned=${stats.scanned} ttl=${stats.ttlExpired} dead=${stats.deadDropped} killed=${stats.killedPids} rotated=${stats.rotatedLogs} malformed=${stats.malformed}`);
@@ -12478,7 +13377,7 @@ async function tickInner() {
12478
13377
  // dispatch-end gcDispatchWorktreeIfOrphan couldn't remove (typically Windows
12479
13378
  // file locks: EPERM/EBUSY on lingering spawn-agent.exe handles, AV scans,
12480
13379
  // node_modules\.bin shims). Delegates to `cleanup.runPeriodicWorktreeSweep`,
12481
- // which runs the two pruners in `engine/worktree-gc.js` against a single
13380
+ // which runs the two pruners in `engine/worktrees/gc.js` against a single
12482
13381
  // shared `dispatchSnap`: (1) in-root `pruneOrphanWorktrees` and (2)
12483
13382
  // out-of-root `pruneOrphanWorktreesFromGitRegistry` (catches `git worktree
12484
13383
  // list` entries outside `worktreeRoot`). Runs at worktreePruneIntervalTicks
@@ -12486,7 +13385,7 @@ async function tickInner() {
12486
13385
  const wtPruneEvery = Math.max(1, ENGINE_DEFAULTS.worktreePruneIntervalTicks || 30);
12487
13386
  if (tickCount % wtPruneEvery === 0) {
12488
13387
  safe('pruneWorktreesPeriodic', () => {
12489
- const { runPeriodicWorktreeSweep } = require('./engine/cleanup');
13388
+ const { runPeriodicWorktreeSweep } = require('./engine/orchestration/cleanup');
12490
13389
  const stats = runPeriodicWorktreeSweep(config);
12491
13390
  const reportStats = (finalStats) => {
12492
13391
  const removed = finalStats.removed
@@ -12512,8 +13411,8 @@ async function tickInner() {
12512
13411
  safe('checkWatches', () => {
12513
13412
  const { checkWatches } = require('./engine/watches');
12514
13413
  const projects = getProjects(config);
12515
- const pullRequestStore = require('./engine/pull-requests-store');
12516
- const workItemStore = require('./engine/work-items-store');
13414
+ const pullRequestStore = require('./engine/persistence/pull-requests-store');
13415
+ const workItemStore = require('./engine/planning/work-items-store');
12517
13416
  const pullRequests = projects.flatMap(p => pullRequestStore.readPullRequestsForScope(p.name));
12518
13417
  const workItems = projects.flatMap(p => workItemStore.readWorkItemsForScope(p.name));
12519
13418
  // Also include central work items
@@ -12523,16 +13422,16 @@ async function tickInner() {
12523
13422
  // best-effort — if a module/file is missing the watch evaluator will
12524
13423
  // simply report "not found" rather than crashing the tick.
12525
13424
  let meetings = [];
12526
- try { meetings = require('./engine/meeting').getMeetings(); } catch { /* optional */ }
13425
+ try { meetings = require('./engine/orchestration/meeting').getMeetings(); } catch { /* optional */ }
12527
13426
 
12528
- const plans = require('./engine/prd-store').listPrdRows()
13427
+ const plans = require('./engine/planning/prd-store').listPrdRows()
12529
13428
  .map(({ filename, plan }) => ({ ...plan, _source: filename, _sourcePlan: plan.source_plan || '' }));
12530
13429
 
12531
13430
  let scheduleRuns = {};
12532
- try { scheduleRuns = require('./engine/small-state-store').readScheduleRuns(); } catch { /* optional */ }
13431
+ try { scheduleRuns = require('./engine/persistence/small-state-store').readScheduleRuns(); } catch { /* optional */ }
12533
13432
 
12534
13433
  let pipelineRuns = {};
12535
- try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
13434
+ try { pipelineRuns = require('./engine/orchestration/pipeline').getPipelineRuns(); } catch { /* optional */ }
12536
13435
 
12537
13436
  let dispatch = { pending: [], active: [], completed: [] };
12538
13437
  try { dispatch = queries.getDispatch() || dispatch; } catch { /* optional */ }
@@ -12776,7 +13675,7 @@ async function tickInner() {
12776
13675
  // W-mrdykpv60005d50d: don't resurrect items whose failure class is explicitly
12777
13676
  // non-retryable (AUTH, INJECTION_FLAGGED, PRE_DISPATCH_EVAL_STUCK, etc.) — mirrors
12778
13677
  // the same isRetryableFailureReason() gate used by the dispatch-completion retry path
12779
- // (engine/dispatch.js:764) and the quarantine auto-recovery block above (engine.js:8804-8811).
13678
+ // (engine/orchestration/dispatch.js:764) and the quarantine auto-recovery block above (engine.js:8804-8811).
12780
13679
  if (!isRetryableFailureReason(item.failReason, item._failureClass)) {
12781
13680
  log('info', `Stall recovery: skipping ${item.id} — failure class '${item._failureClass || 'unknown'}' is non-retryable`);
12782
13681
  continue;
@@ -13103,8 +14002,28 @@ async function tickInner() {
13103
14002
  // item itself resolves to live mode — worktree-mode coding items on
13104
14003
  // the same project remain uncapped.
13105
14004
  const itemProjName = item.project || item.meta?.project?.name || null;
14005
+ // W-ms5tb6ha016fda10: resolved fail-closed from the item's own declaration.
14006
+ // An item that allocates NO local checkout — control-plane (API/CLI only)
14007
+ // or cloud (a remote agent owns the workspace) — cannot legitimately be
14008
+ // blocked by any of the checkout-allocation gates below: the live-mode
14009
+ // concurrency cap, the operator branch hold, the worktree free-space
14010
+ // preflight, or the live-checkout dirty/stale-base precheck. Gating on the
14011
+ // resolved SURFACE rather than on control-plane specifically is what keeps
14012
+ // a second no-checkout surface from silently inheriting repo-shaped gates
14013
+ // (and pending as `worktree_low_disk_space` instead of its own honest
14014
+ // reason). Both predicates fail closed, so anything malformed resolves to
14015
+ // `repo` and keeps today's behavior.
14016
+ const _itemIsControlPlane = shared.isControlPlaneExecution(item, item.type);
14017
+ const _itemExecutionSurface = _itemIsControlPlane
14018
+ ? shared.EXECUTION_SURFACE.CONTROL_PLANE
14019
+ : (shared.isCloudExecution(item, item.type)
14020
+ ? shared.EXECUTION_SURFACE.CLOUD
14021
+ : shared.EXECUTION_SURFACE.REPO);
14022
+ const _itemSkipsCheckoutAllocation =
14023
+ _itemExecutionSurface !== shared.EXECUTION_SURFACE.REPO;
13106
14024
  if (
13107
14025
  itemProjName
14026
+ && !_itemSkipsCheckoutAllocation
13108
14027
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
13109
14028
  && liveProjectsInUse.has(itemProjName)
13110
14029
  ) {
@@ -13122,7 +14041,7 @@ async function tickInner() {
13122
14041
  const _branchHoldProjCfg = shared.findProjectByName(shared.getProjects(config), itemProjName);
13123
14042
  const _allocationBranchHold = await _checkWorktreeBranchHoldForDispatch(
13124
14043
  _branchHoldProjCfg, item.type, itemBranch, config,
13125
- { listWorktrees: listAllocationWorktrees },
14044
+ { listWorktrees: listAllocationWorktrees, executionSurface: _itemExecutionSurface },
13126
14045
  );
13127
14046
  if (_isTickStale(myGeneration)) return;
13128
14047
  if (_allocationBranchHold?.held) {
@@ -13142,7 +14061,9 @@ async function tickInner() {
13142
14061
  // filesystem has the minimum free-space reserve.
13143
14062
  if (itemProjName) {
13144
14063
  const _diskProjCfg = shared.findProjectByName(shared.getProjects(config), itemProjName);
13145
- const _allocationDiskPreflight = _checkWorktreeDiskSpaceForDispatch(_diskProjCfg, item.type, config);
14064
+ const _allocationDiskPreflight = _checkWorktreeDiskSpaceForDispatch(
14065
+ _diskProjCfg, item.type, config, { executionSurface: _itemExecutionSurface },
14066
+ );
13146
14067
  if (_allocationDiskPreflight && !_allocationDiskPreflight.ok) {
13147
14068
  const pendingReason = _persistWorktreeDiskPreflight(item, _allocationDiskPreflight);
13148
14069
  _writeWorktreeDiskPreflightAlert(item, _diskProjCfg, _allocationDiskPreflight);
@@ -13168,12 +14089,13 @@ async function tickInner() {
13168
14089
  // fire rarely, as the exception path rather than the common path.
13169
14090
  if (
13170
14091
  itemProjName
14092
+ && !_itemSkipsCheckoutAllocation
13171
14093
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
13172
14094
  ) {
13173
14095
  const _precheckProjCfg = shared.findProjectByName(shared.getProjects(config), itemProjName);
13174
14096
  if (_precheckProjCfg && shared.resolveCheckoutMode(_precheckProjCfg, item.type) === 'live' && _precheckProjCfg.localPath) {
13175
14097
  const _precheckLocalPath = path.resolve(String(_precheckProjCfg.localPath));
13176
- const _liveCheckoutPrecheck = require('./engine/live-checkout');
14098
+ const _liveCheckoutPrecheck = require('./engine/worktrees/live-checkout');
13177
14099
  const _persistPendingReason = (reason) => {
13178
14100
  item._pendingReason = reason;
13179
14101
  try {
@@ -13303,7 +14225,7 @@ async function tickInner() {
13303
14225
  // rather than awaiting each spawnAgent serially. `spawnAgent` does real
13304
14226
  // wall-clock work per item (prompt build + `git worktree add`, which on a
13305
14227
  // ~250k-file monorepo can take multiple seconds), so the old sequential loop
13306
- // made eager routing (engine/routing.js already fans work across every idle
14228
+ // made eager routing (engine/orchestration/routing.js already fans work across every idle
13307
14229
  // agent in a single allocation pass) visibly "trickle in" one agent at a time,
13308
14230
  // contradicting the stated eager-dispatch design. `toDispatch` is already
13309
14231
  // bounded by `slotsAvailable` (≤ maxConcurrent — the seed loop above decrements
@@ -13594,7 +14516,7 @@ function emitMemoryBaseline(tickN) {
13594
14516
  `gcPauses=${sample.gcCount}/${sample.gcPausesTotalMs.toFixed(2)}ms tickN=${tickN}`);
13595
14517
  }
13596
14518
 
13597
- // ─── Exports (for engine/cli.js and other modules) ──────────────────────────
14519
+ // ─── Exports (for engine/operations/cli.js and other modules) ──────────────────────────
13598
14520
 
13599
14521
  module.exports = {
13600
14522
  // Paths
@@ -13605,13 +14527,15 @@ module.exports = {
13605
14527
  // Utilities
13606
14528
  ts, logTs, dateStamp, log,
13607
14529
  safeJson, safeRead, safeWrite,
14530
+ buildQaSessionServices, // exported for testing (W-msb9kgs402813a97-a)
14531
+ buildQaExecuteJourneyContract, // exported for testing (W-msb9kgs402813a97-c)
13608
14532
 
13609
14533
  // State readers/writers
13610
14534
  getConfig, getControl, getDispatch, getRouting, getNotes,
13611
14535
  getAgentStatus, getInboxFiles, getPrs,
13612
14536
  validateConfig,
13613
14537
 
13614
- // Dispatch management (re-exported from engine/dispatch.js)
14538
+ // Dispatch management (re-exported from engine/orchestration/dispatch.js)
13615
14539
  mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync,
13616
14540
  activeProcesses, realActivityMap, engineRestartGraceExempt,
13617
14541
  get engineRestartGraceUntil() { return engineRestartGraceUntil; },
@@ -13634,6 +14558,9 @@ module.exports = {
13634
14558
  resolveDependencyBranches, buildCrossRepoDepsSection, // exported for testing (P-faea3206)
13635
14559
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
13636
14560
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
14561
+ buildDependencyConflictMarker, findConflictRepairForBlockedItem, // exported for testing (W-mrzqfozm00035365)
14562
+ evaluateDependencyConflictGate, shouldReviveFailedDependencyConflict, // exported for testing (W-mrzqfozm00035365)
14563
+ DEP_CONFLICT_PENDING_REASON, DEP_CONFLICT_UNRESOLVED_PENDING_REASON, // exported for testing (W-mrzqfozm00035365)
13637
14564
  runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
13638
14565
  _fetchWithTransientRetry, _classifyFreshBaseFetchFailure, _probeBranchLocally, // exported for testing
13639
14566
  isWorktreeRetryableError, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
@@ -13650,6 +14577,8 @@ module.exports = {
13650
14577
  _tryAutoResolveLiveCheckoutWorktreeConflict, // exported for testing (W-mr28h2j2000y0de1 review — auto-resolve worktree conflicts)
13651
14578
  _isOutputTruncated, AGENT_OUTPUT_CAP_BYTES, // P-8e4c2a17: exported for testing (OUTPUT_TRUNCATED detection)
13652
14579
  promoteCheckpointSteeringForClose, // exported for testing
14580
+ resolveCloseCompletionBoundary, // exported for testing (W-ms9tcs3o01y03383)
14581
+ releasePendingSteeringResumeForCompletedTurn, // exported for testing (W-ms9tcs3o01y03383)
13653
14582
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
13654
14583
  isPrReviewCurrent, isPrFixedAfterReview, // exported for testing (W-mrej8p740006b0bf — SHA-based review freshness)
13655
14584
  ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
@@ -13661,10 +14590,10 @@ module.exports = {
13661
14590
  resolvePriorExploreContext, // exported for testing (P-mqyp0008v022w3x4 SHERLOC)
13662
14591
  renderProjectWorkItemPromptForAgent, // exported for testing
13663
14592
 
13664
- // Timeout / Steering / Idle (re-exported from engine/timeout.js)
14593
+ // Timeout / Steering / Idle (re-exported from engine/orchestration/timeout.js)
13665
14594
  checkTimeouts, checkSteering, checkIdleThreshold,
13666
14595
 
13667
- // Cleanup (re-exported from engine/cleanup.js)
14596
+ // Cleanup (re-exported from engine/orchestration/cleanup.js)
13668
14597
  runCleanup,
13669
14598
 
13670
14599
  // Post-completion / lifecycle
@@ -13750,7 +14679,7 @@ function ensureGitHooksInstalled() {
13750
14679
 
13751
14680
  if (require.main === module) {
13752
14681
  try { ensureGitHooksInstalled(); } catch { /* best-effort */ }
13753
- const { handleCommand } = require('./engine/cli');
14682
+ const { handleCommand } = require('./engine/operations/cli');
13754
14683
  const [cmd, ...args] = process.argv.slice(2);
13755
14684
  handleCommand(cmd, args);
13756
14685
  }