agentic-orchestrator 0.1.28 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (836) hide show
  1. package/.claude/settings.local.json +46 -1
  2. package/.cortexrc +28 -0
  3. package/.github/agents/copilot-instructions.md +29 -0
  4. package/.github/copilot-instructions.md +93 -0
  5. package/.vscode/settings.json +13 -0
  6. package/.vscode/tms.code-snippets +223 -0
  7. package/AGENTS.md +72 -1
  8. package/Agentic-Orchestrator.iml +12 -11
  9. package/CLAUDE.md +72 -1
  10. package/CONSTITUTION.md +504 -0
  11. package/FUTURE-ENHANCEMENTS.md +85 -0
  12. package/NEXT-TASKS.md +25 -0
  13. package/PROMPTS.md +161 -0
  14. package/README.md +126 -29
  15. package/agentic/orchestrator/agents.yaml +4 -3
  16. package/agentic/orchestrator/defaults/policy.defaults.yaml +39 -3
  17. package/agentic/orchestrator/gates.yaml +15 -3
  18. package/agentic/orchestrator/policy.yaml +47 -3
  19. package/agentic/orchestrator/prompts/builder.system.md +69 -20
  20. package/agentic/orchestrator/prompts/planner-intake.system.md +149 -0
  21. package/agentic/orchestrator/prompts/planner.system.md +113 -40
  22. package/agentic/orchestrator/prompts/qa.system.md +73 -18
  23. package/agentic/orchestrator/prompts/reconciler.system.md +119 -0
  24. package/agentic/orchestrator/schemas/agents.schema.json +89 -1
  25. package/agentic/orchestrator/schemas/execution-control.schema.json +242 -0
  26. package/agentic/orchestrator/schemas/index.schema.json +234 -0
  27. package/agentic/orchestrator/schemas/intake.review.schema.json +82 -0
  28. package/agentic/orchestrator/schemas/organizer-ordering-artifact.schema.json +75 -0
  29. package/agentic/orchestrator/schemas/plan.schema.json +44 -0
  30. package/agentic/orchestrator/schemas/policy.schema.json +238 -9
  31. package/agentic/orchestrator/schemas/policy.user.schema.json +129 -1
  32. package/agentic/orchestrator/schemas/spec.manifest.bootstrap.schema.json +101 -0
  33. package/agentic/orchestrator/schemas/spec.manifest.verified.schema.json +80 -0
  34. package/agentic/orchestrator/schemas/state.schema.json +298 -3
  35. package/agentic/orchestrator/tools/catalog.json +145 -15
  36. package/agentic/orchestrator/tools/schemas/input/doctor.run.input.schema.json +18 -0
  37. package/agentic/orchestrator/tools/schemas/input/evidence.latest.input.schema.json +4 -0
  38. package/agentic/orchestrator/tools/schemas/input/evidence.verify_chain.input.schema.json +13 -0
  39. package/agentic/orchestrator/tools/schemas/input/feature.intake_submit.input.schema.json +11 -0
  40. package/agentic/orchestrator/tools/schemas/input/feature.question_answer.input.schema.json +15 -0
  41. package/agentic/orchestrator/tools/schemas/input/feature.question_create.input.schema.json +21 -0
  42. package/agentic/orchestrator/tools/schemas/input/feature.question_list.input.schema.json +13 -0
  43. package/agentic/orchestrator/tools/schemas/input/feature.ready_to_merge.input.schema.json +5 -0
  44. package/agentic/orchestrator/tools/schemas/input/feature.send_message.input.schema.json +1 -1
  45. package/agentic/orchestrator/tools/schemas/input/replay.timeline_get.input.schema.json +32 -0
  46. package/agentic/orchestrator/tools/schemas/input/repo.conflict_abort.input.schema.json +16 -0
  47. package/agentic/orchestrator/tools/schemas/input/repo.conflict_files.input.schema.json +16 -0
  48. package/agentic/orchestrator/tools/schemas/input/repo.reconcile_mainline.input.schema.json +37 -0
  49. package/agentic/orchestrator/tools/schemas/input/repo.resolve_conflict.input.schema.json +40 -0
  50. package/agentic/orchestrator/tools/schemas/input/runtime.execution_request_list.input.schema.json +7 -0
  51. package/agentic/orchestrator/tools/schemas/input/runtime.execution_request_submit.input.schema.json +25 -0
  52. package/agentic/orchestrator/tools/schemas/output/doctor.run.output.schema.json +34 -0
  53. package/agentic/orchestrator/tools/schemas/output/evidence.verify_chain.output.schema.json +23 -0
  54. package/agentic/orchestrator/tools/schemas/output/feature.get_context.output.schema.json +62 -2
  55. package/agentic/orchestrator/tools/schemas/output/feature.intake_submit.output.schema.json +24 -0
  56. package/agentic/orchestrator/tools/schemas/output/feature.question_answer.output.schema.json +21 -0
  57. package/agentic/orchestrator/tools/schemas/output/feature.question_create.output.schema.json +12 -0
  58. package/agentic/orchestrator/tools/schemas/output/feature.question_list.output.schema.json +14 -0
  59. package/agentic/orchestrator/tools/schemas/output/feature.ready_to_merge.output.schema.json +31 -0
  60. package/agentic/orchestrator/tools/schemas/output/feature.send_message.output.schema.json +8 -18
  61. package/agentic/orchestrator/tools/schemas/output/replay.timeline_get.output.schema.json +64 -0
  62. package/agentic/orchestrator/tools/schemas/output/repo.conflict_abort.output.schema.json +16 -0
  63. package/agentic/orchestrator/tools/schemas/output/repo.conflict_files.output.schema.json +22 -0
  64. package/agentic/orchestrator/tools/schemas/output/repo.reconcile_mainline.output.schema.json +61 -0
  65. package/agentic/orchestrator/tools/schemas/output/repo.resolve_conflict.output.schema.json +19 -0
  66. package/agentic/orchestrator/tools/schemas/output/report.dashboard.output.schema.json +26 -0
  67. package/agentic/orchestrator/tools/schemas/output/runtime.execution_request_list.output.schema.json +17 -0
  68. package/agentic/orchestrator/tools/schemas/output/runtime.execution_request_submit.output.schema.json +24 -0
  69. package/agentic/orchestrator/tools.md +13 -0
  70. package/apps/control-plane/scripts/validate-mcp-contracts.ts +1 -1
  71. package/apps/control-plane/src/application/kernel-tool-wiring.ts +140 -2
  72. package/apps/control-plane/src/application/services/activity-monitor-service.ts +44 -1
  73. package/apps/control-plane/src/application/services/bootstrap-manifest-generator-service.ts +251 -0
  74. package/apps/control-plane/src/application/services/checkpoint-service.ts +87 -27
  75. package/apps/control-plane/src/application/services/collision-override-service.ts +906 -0
  76. package/apps/control-plane/src/application/services/collision-queue-service.ts +129 -38
  77. package/apps/control-plane/src/application/services/cost-tracking-service.ts +94 -0
  78. package/apps/control-plane/src/application/services/execution-control-service.ts +599 -0
  79. package/apps/control-plane/src/application/services/feature-deletion-service.ts +37 -1
  80. package/apps/control-plane/src/application/services/feature-lifecycle-service.ts +182 -4
  81. package/apps/control-plane/src/application/services/feature-send-message-service.ts +17 -8
  82. package/apps/control-plane/src/application/services/feature-state-service.ts +191 -6
  83. package/apps/control-plane/src/application/services/gate-service.ts +121 -2
  84. package/apps/control-plane/src/application/services/git-reconciliation-service.ts +1591 -0
  85. package/apps/control-plane/src/application/services/intake-service.ts +1468 -0
  86. package/apps/control-plane/src/application/services/merge-service.ts +308 -17
  87. package/apps/control-plane/src/application/services/notifier-service.ts +3 -1
  88. package/apps/control-plane/src/application/services/performance-analytics-service.ts +75 -0
  89. package/apps/control-plane/src/application/services/plan-service.ts +336 -20
  90. package/apps/control-plane/src/application/services/question-service.ts +693 -0
  91. package/apps/control-plane/src/application/services/reactions-service.ts +73 -17
  92. package/apps/control-plane/src/application/services/replay-timeline-service.ts +295 -0
  93. package/apps/control-plane/src/application/services/reporting-service.ts +194 -10
  94. package/apps/control-plane/src/application/services/run-lease-service.ts +121 -5
  95. package/apps/control-plane/src/application/services/worktree-watchdog-service.ts +95 -8
  96. package/apps/control-plane/src/application/tools/tool-metadata.ts +7 -0
  97. package/apps/control-plane/src/application/usage-types.ts +138 -0
  98. package/apps/control-plane/src/cli/add-command-handler.ts +162 -0
  99. package/apps/control-plane/src/cli/answer-command-handler.ts +113 -0
  100. package/apps/control-plane/src/cli/attach-command-handler.ts +12 -3
  101. package/apps/control-plane/src/cli/cli-argument-parser.ts +133 -11
  102. package/apps/control-plane/src/cli/collision-command-handler.ts +113 -0
  103. package/apps/control-plane/src/cli/command-catalog.ts +479 -0
  104. package/apps/control-plane/src/cli/complete-command-handler.ts +23 -0
  105. package/apps/control-plane/src/cli/completion-command-handler.ts +25 -0
  106. package/apps/control-plane/src/cli/completion-resolver.ts +319 -0
  107. package/apps/control-plane/src/cli/completion-shell-renderer.ts +58 -0
  108. package/apps/control-plane/src/cli/dashboard-command-handler.ts +111 -1
  109. package/apps/control-plane/src/cli/dashboard-runtime-runner.ts +1036 -0
  110. package/apps/control-plane/src/cli/dashboard-runtime.ts +31 -0
  111. package/apps/control-plane/src/cli/help-command-handler.ts +17 -185
  112. package/apps/control-plane/src/cli/init-command-handler.ts +51 -6
  113. package/apps/control-plane/src/cli/merge-command-handler.ts +200 -0
  114. package/apps/control-plane/src/cli/questions-command-handler.ts +70 -0
  115. package/apps/control-plane/src/cli/replay-command-handler.ts +98 -0
  116. package/apps/control-plane/src/cli/resume-command-handler.ts +231 -16
  117. package/apps/control-plane/src/cli/retry-command-handler.ts +229 -17
  118. package/apps/control-plane/src/cli/retry-resume-decision.ts +75 -0
  119. package/apps/control-plane/src/cli/rollback-command-handler.ts +4 -2
  120. package/apps/control-plane/src/cli/run-command-handler.ts +35 -1
  121. package/apps/control-plane/src/cli/spec-ingestion-service.ts +45 -55
  122. package/apps/control-plane/src/cli/spec-preparation.ts +114 -0
  123. package/apps/control-plane/src/cli/spec-utils.ts +90 -11
  124. package/apps/control-plane/src/cli/status-command-handler.ts +122 -0
  125. package/apps/control-plane/src/cli/types.ts +41 -3
  126. package/apps/control-plane/src/core/collisions.ts +150 -31
  127. package/apps/control-plane/src/core/constants.ts +18 -1
  128. package/apps/control-plane/src/core/error-codes.ts +39 -0
  129. package/apps/control-plane/src/core/execution-control.ts +56 -0
  130. package/apps/control-plane/src/core/feature-resume-phase.ts +118 -0
  131. package/apps/control-plane/src/core/gate-freshness.ts +359 -0
  132. package/apps/control-plane/src/core/gate-log-extractor.ts +97 -0
  133. package/apps/control-plane/src/core/gates.ts +90 -1
  134. package/apps/control-plane/src/core/intake-artifacts.ts +295 -0
  135. package/apps/control-plane/src/core/kernel-types.ts +11 -0
  136. package/apps/control-plane/src/core/kernel.ts +604 -16
  137. package/apps/control-plane/src/core/mainline-conflict.ts +22 -0
  138. package/apps/control-plane/src/core/merge-repair.ts +149 -0
  139. package/apps/control-plane/src/core/path-layout.ts +46 -2
  140. package/apps/control-plane/src/core/path-rules.ts +11 -3
  141. package/apps/control-plane/src/core/plan-submit-recovery.ts +130 -0
  142. package/apps/control-plane/src/core/questions.ts +49 -0
  143. package/apps/control-plane/src/core/runtime-sessions.ts +4 -0
  144. package/apps/control-plane/src/core/schemas.ts +40 -1
  145. package/apps/control-plane/src/core/tool-caller.ts +25 -1
  146. package/apps/control-plane/src/core/utils/index-normalizer.ts +25 -4
  147. package/apps/control-plane/src/core/worktree-diff.ts +66 -0
  148. package/apps/control-plane/src/index.ts +29 -1
  149. package/apps/control-plane/src/interfaces/cli/bootstrap.ts +300 -6
  150. package/apps/control-plane/src/mcp/kernel-tool-executor.ts +17 -0
  151. package/apps/control-plane/src/mcp/tool-runtime.ts +63 -4
  152. package/apps/control-plane/src/providers/api-worker-provider.ts +62 -15
  153. package/apps/control-plane/src/providers/cli-worker-provider.ts +1037 -61
  154. package/apps/control-plane/src/providers/output-parsers/generic-output-parser.ts +99 -1
  155. package/apps/control-plane/src/providers/output-parsers/types.ts +2 -0
  156. package/apps/control-plane/src/providers/provider-defaults.ts +116 -7
  157. package/apps/control-plane/src/providers/providers.ts +225 -21
  158. package/apps/control-plane/src/providers/worker-provider-factory.ts +26 -2
  159. package/apps/control-plane/src/supervisor/artifact-stager.ts +52 -0
  160. package/apps/control-plane/src/supervisor/build-wave-executor.ts +477 -166
  161. package/apps/control-plane/src/supervisor/execution-enrollment-service.ts +408 -0
  162. package/apps/control-plane/src/supervisor/organizer-enrollment-scheduler.ts +117 -0
  163. package/apps/control-plane/src/supervisor/organizer-sidecar-service.ts +394 -0
  164. package/apps/control-plane/src/supervisor/plan-conformance-scorer.ts +2 -5
  165. package/apps/control-plane/src/supervisor/planner-phase.ts +85 -0
  166. package/apps/control-plane/src/supervisor/planning-wave-executor.ts +993 -64
  167. package/apps/control-plane/src/supervisor/prompt-bundle-loader.ts +20 -1
  168. package/apps/control-plane/src/supervisor/qa-wave-executor.ts +384 -177
  169. package/apps/control-plane/src/supervisor/run-coordinator.ts +723 -20
  170. package/apps/control-plane/src/supervisor/runtime.ts +485 -9
  171. package/apps/control-plane/src/supervisor/session-orchestrator.ts +220 -1
  172. package/apps/control-plane/src/supervisor/types.ts +152 -1
  173. package/apps/control-plane/src/supervisor/worker-decision-loop.ts +1030 -92
  174. package/apps/control-plane/test/activity-monitor.spec.ts +76 -0
  175. package/apps/control-plane/test/add-command-handler.spec.ts +189 -0
  176. package/apps/control-plane/test/application/services/feature-state-service.spec.ts +208 -0
  177. package/apps/control-plane/test/artifact-stager.spec.ts +93 -0
  178. package/apps/control-plane/test/batch-operations.spec.ts +58 -0
  179. package/apps/control-plane/test/bootstrap-edge-cases.spec.ts +50 -2
  180. package/apps/control-plane/test/bootstrap-manifest-generator-service.spec.ts +99 -0
  181. package/apps/control-plane/test/bootstrap.spec.ts +177 -4
  182. package/apps/control-plane/test/checkpoint-service.spec.ts +977 -29
  183. package/apps/control-plane/test/cli-argument-parser.spec.ts +119 -0
  184. package/apps/control-plane/test/cli-helpers.spec.ts +1202 -12
  185. package/apps/control-plane/test/cli.unit.spec.ts +797 -16
  186. package/apps/control-plane/test/collision-command-handler.spec.ts +182 -0
  187. package/apps/control-plane/test/collision-override-service.spec.ts +878 -0
  188. package/apps/control-plane/test/collision-queue.spec.ts +430 -2
  189. package/apps/control-plane/test/collisions.spec.ts +209 -1
  190. package/apps/control-plane/test/core-utils.spec.ts +61 -0
  191. package/apps/control-plane/test/cost-tracking.spec.ts +224 -0
  192. package/apps/control-plane/test/dashboard-api.integration.spec.ts +185 -5
  193. package/apps/control-plane/test/dashboard-client.spec.ts +948 -0
  194. package/apps/control-plane/test/dashboard-command.spec.ts +138 -6
  195. package/apps/control-plane/test/dashboard-runtime-runner.spec.ts +1550 -0
  196. package/apps/control-plane/test/dashboard-runtime.spec.ts +138 -0
  197. package/apps/control-plane/test/dashboard-ui-utils.spec.ts +56 -12
  198. package/apps/control-plane/test/dependency-scheduler.spec.ts +7 -1
  199. package/apps/control-plane/test/env-file.spec.ts +76 -0
  200. package/apps/control-plane/test/execution-control-service.spec.ts +535 -0
  201. package/apps/control-plane/test/execution-enrollment-service.spec.ts +648 -0
  202. package/apps/control-plane/test/feature-lifecycle.spec.ts +126 -0
  203. package/apps/control-plane/test/feature-resume-phase.spec.ts +164 -0
  204. package/apps/control-plane/test/feature-send-message-service.spec.ts +161 -0
  205. package/apps/control-plane/test/feature-state-service.spec.ts +295 -0
  206. package/apps/control-plane/test/fs.spec.ts +80 -0
  207. package/apps/control-plane/test/gate-freshness.spec.ts +590 -0
  208. package/apps/control-plane/test/gate-log-extractor.spec.ts +170 -0
  209. package/apps/control-plane/test/gates.spec.ts +108 -0
  210. package/apps/control-plane/test/git-reconciliation-service.spec.ts +2307 -0
  211. package/apps/control-plane/test/helpers.ts +65 -0
  212. package/apps/control-plane/test/incremental-gates.spec.ts +271 -0
  213. package/apps/control-plane/test/index-normalizer.spec.ts +98 -0
  214. package/apps/control-plane/test/init-wizard.spec.ts +17 -0
  215. package/apps/control-plane/test/intake-artifacts.spec.ts +203 -0
  216. package/apps/control-plane/test/intake-service.spec.ts +3176 -0
  217. package/apps/control-plane/test/kernel-collision-replay.spec.ts +3 -2
  218. package/apps/control-plane/test/kernel-tool-executor.spec.ts +77 -0
  219. package/apps/control-plane/test/kernel-tool-wiring.spec.ts +279 -0
  220. package/apps/control-plane/test/kernel.branches.spec.ts +15 -2
  221. package/apps/control-plane/test/kernel.coverage.spec.ts +7 -3
  222. package/apps/control-plane/test/kernel.coverage2.spec.ts +731 -2
  223. package/apps/control-plane/test/kernel.spec.ts +464 -2
  224. package/apps/control-plane/test/mainline-conflict.spec.ts +66 -0
  225. package/apps/control-plane/test/mcp-helpers.spec.ts +79 -0
  226. package/apps/control-plane/test/mcp.spec.ts +177 -13
  227. package/apps/control-plane/test/merge-command-handler.spec.ts +531 -0
  228. package/apps/control-plane/test/merge-service.spec.ts +570 -4
  229. package/apps/control-plane/test/notifier-service.spec.ts +26 -0
  230. package/apps/control-plane/test/organizer-enrollment-scheduler.spec.ts +340 -0
  231. package/apps/control-plane/test/organizer-ordering-artifact.spec.ts +95 -0
  232. package/apps/control-plane/test/organizer-sidecar-service.spec.ts +468 -0
  233. package/apps/control-plane/test/output-loop-detector.spec.ts +6 -0
  234. package/apps/control-plane/test/path-layout.spec.ts +70 -0
  235. package/apps/control-plane/test/performance-analytics.spec.ts +124 -0
  236. package/apps/control-plane/test/plan-conformance-scorer.spec.ts +53 -0
  237. package/apps/control-plane/test/plan-service.spec.ts +686 -4
  238. package/apps/control-plane/test/planning-wave-executor.spec.ts +3272 -86
  239. package/apps/control-plane/test/policy-loader-service.spec.ts +5 -0
  240. package/apps/control-plane/test/prompt-overlay.spec.ts +65 -0
  241. package/apps/control-plane/test/provider-command-runner-epipe.spec.ts +64 -0
  242. package/apps/control-plane/test/providers/api-worker-provider.spec.ts +129 -0
  243. package/apps/control-plane/test/providers/cli-worker-provider.spec.ts +148 -0
  244. package/apps/control-plane/test/providers/usage-types.spec.ts +98 -0
  245. package/apps/control-plane/test/providers.spec.ts +293 -16
  246. package/apps/control-plane/test/question-command-handlers.spec.ts +156 -0
  247. package/apps/control-plane/test/question-service.spec.ts +1119 -0
  248. package/apps/control-plane/test/reactions.spec.ts +114 -0
  249. package/apps/control-plane/test/replay-command-handler.spec.ts +144 -0
  250. package/apps/control-plane/test/replay-timeline-service.spec.ts +459 -0
  251. package/apps/control-plane/test/response.spec.ts +31 -0
  252. package/apps/control-plane/test/resume-command.spec.ts +757 -9
  253. package/apps/control-plane/test/retry-resume-decision.spec.ts +133 -0
  254. package/apps/control-plane/test/rollback-command-handler.spec.ts +334 -0
  255. package/apps/control-plane/test/rollback-command.spec.ts +120 -0
  256. package/apps/control-plane/test/run-coordinator.spec.ts +3062 -404
  257. package/apps/control-plane/test/schemas/state.schema.spec.ts +71 -0
  258. package/apps/control-plane/test/service-retry-paths.spec.ts +112 -0
  259. package/apps/control-plane/test/services.spec.ts +472 -2
  260. package/apps/control-plane/test/session-management.spec.ts +346 -1
  261. package/apps/control-plane/test/spec-ingestion.spec.ts +102 -28
  262. package/apps/control-plane/test/spec-preparation.spec.ts +182 -0
  263. package/apps/control-plane/test/supervisor-collaborators.spec.ts +191 -3
  264. package/apps/control-plane/test/supervisor.calltool.spec.ts +198 -0
  265. package/apps/control-plane/test/supervisor.spec.ts +95 -16
  266. package/apps/control-plane/test/supervisor.unit.spec.ts +385 -18
  267. package/apps/control-plane/test/tool-runtime.spec.ts +122 -0
  268. package/apps/control-plane/test/worker-decision-loop.spec.ts +3479 -476
  269. package/apps/control-plane/test/worker-execution-policy.spec.ts +1416 -6
  270. package/apps/control-plane/test/worker-provider-adapters.spec.ts +1894 -37
  271. package/apps/control-plane/test/worker-provider-factory.spec.ts +81 -0
  272. package/apps/control-plane/test/worktree-watchdog-service.spec.ts +125 -0
  273. package/apps/control-plane/vitest.config.ts +5 -0
  274. package/config/agentic/orchestrator/agents.yaml +22 -1
  275. package/config/agentic/orchestrator/gates.yaml +24 -7
  276. package/config/agentic/orchestrator/policy.yaml +23 -1
  277. package/config/agentic/orchestrator/prompts/builder.system.md +69 -20
  278. package/config/agentic/orchestrator/prompts/organizer.system.md +85 -0
  279. package/config/agentic/orchestrator/prompts/overrides/builder.claude.md +28 -0
  280. package/config/agentic/orchestrator/prompts/overrides/builder.codex.md +28 -0
  281. package/config/agentic/orchestrator/prompts/overrides/planner.claude.md +20 -0
  282. package/config/agentic/orchestrator/prompts/overrides/planner.codex.md +20 -0
  283. package/config/agentic/orchestrator/prompts/planner-intake.system.md +149 -0
  284. package/config/agentic/orchestrator/prompts/planner.system.md +113 -40
  285. package/config/agentic/orchestrator/prompts/qa.system.md +75 -18
  286. package/config/agentic/orchestrator/prompts/reconciler.system.md +119 -0
  287. package/dist/apps/control-plane/application/kernel-tool-wiring.d.ts +26 -2
  288. package/dist/apps/control-plane/application/kernel-tool-wiring.js +40 -2
  289. package/dist/apps/control-plane/application/kernel-tool-wiring.js.map +1 -1
  290. package/dist/apps/control-plane/application/services/activity-monitor-service.js +37 -1
  291. package/dist/apps/control-plane/application/services/activity-monitor-service.js.map +1 -1
  292. package/dist/apps/control-plane/application/services/bootstrap-manifest-generator-service.d.ts +4 -0
  293. package/dist/apps/control-plane/application/services/bootstrap-manifest-generator-service.js +188 -0
  294. package/dist/apps/control-plane/application/services/bootstrap-manifest-generator-service.js.map +1 -0
  295. package/dist/apps/control-plane/application/services/checkpoint-service.d.ts +5 -0
  296. package/dist/apps/control-plane/application/services/checkpoint-service.js +69 -24
  297. package/dist/apps/control-plane/application/services/checkpoint-service.js.map +1 -1
  298. package/dist/apps/control-plane/application/services/collision-override-service.d.ts +139 -0
  299. package/dist/apps/control-plane/application/services/collision-override-service.js +568 -0
  300. package/dist/apps/control-plane/application/services/collision-override-service.js.map +1 -0
  301. package/dist/apps/control-plane/application/services/collision-queue-service.d.ts +15 -0
  302. package/dist/apps/control-plane/application/services/collision-queue-service.js +92 -33
  303. package/dist/apps/control-plane/application/services/collision-queue-service.js.map +1 -1
  304. package/dist/apps/control-plane/application/services/cost-tracking-service.d.ts +11 -0
  305. package/dist/apps/control-plane/application/services/cost-tracking-service.js +75 -0
  306. package/dist/apps/control-plane/application/services/cost-tracking-service.js.map +1 -1
  307. package/dist/apps/control-plane/application/services/execution-control-service.d.ts +75 -0
  308. package/dist/apps/control-plane/application/services/execution-control-service.js +421 -0
  309. package/dist/apps/control-plane/application/services/execution-control-service.js.map +1 -0
  310. package/dist/apps/control-plane/application/services/feature-deletion-service.d.ts +1 -0
  311. package/dist/apps/control-plane/application/services/feature-deletion-service.js +23 -1
  312. package/dist/apps/control-plane/application/services/feature-deletion-service.js.map +1 -1
  313. package/dist/apps/control-plane/application/services/feature-lifecycle-service.d.ts +24 -1
  314. package/dist/apps/control-plane/application/services/feature-lifecycle-service.js +132 -3
  315. package/dist/apps/control-plane/application/services/feature-lifecycle-service.js.map +1 -1
  316. package/dist/apps/control-plane/application/services/feature-send-message-service.js +16 -8
  317. package/dist/apps/control-plane/application/services/feature-send-message-service.js.map +1 -1
  318. package/dist/apps/control-plane/application/services/feature-state-service.d.ts +36 -0
  319. package/dist/apps/control-plane/application/services/feature-state-service.js +163 -6
  320. package/dist/apps/control-plane/application/services/feature-state-service.js.map +1 -1
  321. package/dist/apps/control-plane/application/services/gate-service.d.ts +2 -1
  322. package/dist/apps/control-plane/application/services/gate-service.js +95 -5
  323. package/dist/apps/control-plane/application/services/gate-service.js.map +1 -1
  324. package/dist/apps/control-plane/application/services/git-reconciliation-service.d.ts +92 -0
  325. package/dist/apps/control-plane/application/services/git-reconciliation-service.js +1097 -0
  326. package/dist/apps/control-plane/application/services/git-reconciliation-service.js.map +1 -0
  327. package/dist/apps/control-plane/application/services/intake-service.d.ts +63 -0
  328. package/dist/apps/control-plane/application/services/intake-service.js +1050 -0
  329. package/dist/apps/control-plane/application/services/intake-service.js.map +1 -0
  330. package/dist/apps/control-plane/application/services/merge-service.d.ts +5 -1
  331. package/dist/apps/control-plane/application/services/merge-service.js +233 -18
  332. package/dist/apps/control-plane/application/services/merge-service.js.map +1 -1
  333. package/dist/apps/control-plane/application/services/notifier-service.d.ts +1 -1
  334. package/dist/apps/control-plane/application/services/notifier-service.js +1 -0
  335. package/dist/apps/control-plane/application/services/notifier-service.js.map +1 -1
  336. package/dist/apps/control-plane/application/services/performance-analytics-service.d.ts +11 -0
  337. package/dist/apps/control-plane/application/services/performance-analytics-service.js +59 -0
  338. package/dist/apps/control-plane/application/services/performance-analytics-service.js.map +1 -1
  339. package/dist/apps/control-plane/application/services/plan-service.d.ts +5 -0
  340. package/dist/apps/control-plane/application/services/plan-service.js +254 -15
  341. package/dist/apps/control-plane/application/services/plan-service.js.map +1 -1
  342. package/dist/apps/control-plane/application/services/question-service.d.ts +72 -0
  343. package/dist/apps/control-plane/application/services/question-service.js +507 -0
  344. package/dist/apps/control-plane/application/services/question-service.js.map +1 -0
  345. package/dist/apps/control-plane/application/services/reactions-service.d.ts +2 -0
  346. package/dist/apps/control-plane/application/services/reactions-service.js +60 -17
  347. package/dist/apps/control-plane/application/services/reactions-service.js.map +1 -1
  348. package/dist/apps/control-plane/application/services/replay-timeline-service.d.ts +39 -0
  349. package/dist/apps/control-plane/application/services/replay-timeline-service.js +205 -0
  350. package/dist/apps/control-plane/application/services/replay-timeline-service.js.map +1 -0
  351. package/dist/apps/control-plane/application/services/reporting-service.d.ts +59 -0
  352. package/dist/apps/control-plane/application/services/reporting-service.js +121 -9
  353. package/dist/apps/control-plane/application/services/reporting-service.js.map +1 -1
  354. package/dist/apps/control-plane/application/services/run-lease-service.d.ts +20 -0
  355. package/dist/apps/control-plane/application/services/run-lease-service.js +81 -4
  356. package/dist/apps/control-plane/application/services/run-lease-service.js.map +1 -1
  357. package/dist/apps/control-plane/application/services/worktree-watchdog-service.d.ts +10 -0
  358. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js +65 -8
  359. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js.map +1 -1
  360. package/dist/apps/control-plane/application/tools/tool-metadata.js +7 -0
  361. package/dist/apps/control-plane/application/tools/tool-metadata.js.map +1 -1
  362. package/dist/apps/control-plane/application/usage-types.d.ts +65 -0
  363. package/dist/apps/control-plane/application/usage-types.js +75 -0
  364. package/dist/apps/control-plane/application/usage-types.js.map +1 -0
  365. package/dist/apps/control-plane/cli/add-command-handler.d.ts +18 -0
  366. package/dist/apps/control-plane/cli/add-command-handler.js +110 -0
  367. package/dist/apps/control-plane/cli/add-command-handler.js.map +1 -0
  368. package/dist/apps/control-plane/cli/answer-command-handler.d.ts +8 -0
  369. package/dist/apps/control-plane/cli/answer-command-handler.js +96 -0
  370. package/dist/apps/control-plane/cli/answer-command-handler.js.map +1 -0
  371. package/dist/apps/control-plane/cli/attach-command-handler.js +8 -3
  372. package/dist/apps/control-plane/cli/attach-command-handler.js.map +1 -1
  373. package/dist/apps/control-plane/cli/cli-argument-parser.js +131 -11
  374. package/dist/apps/control-plane/cli/cli-argument-parser.js.map +1 -1
  375. package/dist/apps/control-plane/cli/collision-command-handler.d.ts +8 -0
  376. package/dist/apps/control-plane/cli/collision-command-handler.js +90 -0
  377. package/dist/apps/control-plane/cli/collision-command-handler.js.map +1 -0
  378. package/dist/apps/control-plane/cli/command-catalog.d.ts +21 -0
  379. package/dist/apps/control-plane/cli/command-catalog.js +416 -0
  380. package/dist/apps/control-plane/cli/command-catalog.js.map +1 -0
  381. package/dist/apps/control-plane/cli/complete-command-handler.d.ts +15 -0
  382. package/dist/apps/control-plane/cli/complete-command-handler.js +26 -0
  383. package/dist/apps/control-plane/cli/complete-command-handler.js.map +1 -0
  384. package/dist/apps/control-plane/cli/completion-command-handler.d.ts +8 -0
  385. package/dist/apps/control-plane/cli/completion-command-handler.js +20 -0
  386. package/dist/apps/control-plane/cli/completion-command-handler.js.map +1 -0
  387. package/dist/apps/control-plane/cli/completion-resolver.d.ts +1 -0
  388. package/dist/apps/control-plane/cli/completion-resolver.js +250 -0
  389. package/dist/apps/control-plane/cli/completion-resolver.js.map +1 -0
  390. package/dist/apps/control-plane/cli/completion-shell-renderer.d.ts +3 -0
  391. package/dist/apps/control-plane/cli/completion-shell-renderer.js +53 -0
  392. package/dist/apps/control-plane/cli/completion-shell-renderer.js.map +1 -0
  393. package/dist/apps/control-plane/cli/dashboard-command-handler.d.ts +1 -0
  394. package/dist/apps/control-plane/cli/dashboard-command-handler.js +84 -1
  395. package/dist/apps/control-plane/cli/dashboard-command-handler.js.map +1 -1
  396. package/dist/apps/control-plane/cli/dashboard-runtime-runner.d.ts +81 -0
  397. package/dist/apps/control-plane/cli/dashboard-runtime-runner.js +724 -0
  398. package/dist/apps/control-plane/cli/dashboard-runtime-runner.js.map +1 -0
  399. package/dist/apps/control-plane/cli/dashboard-runtime.d.ts +1 -0
  400. package/dist/apps/control-plane/cli/dashboard-runtime.js +26 -0
  401. package/dist/apps/control-plane/cli/dashboard-runtime.js.map +1 -0
  402. package/dist/apps/control-plane/cli/help-command-handler.js +13 -172
  403. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  404. package/dist/apps/control-plane/cli/init-command-handler.js +51 -6
  405. package/dist/apps/control-plane/cli/init-command-handler.js.map +1 -1
  406. package/dist/apps/control-plane/cli/merge-command-handler.d.ts +8 -0
  407. package/dist/apps/control-plane/cli/merge-command-handler.js +139 -0
  408. package/dist/apps/control-plane/cli/merge-command-handler.js.map +1 -0
  409. package/dist/apps/control-plane/cli/questions-command-handler.d.ts +8 -0
  410. package/dist/apps/control-plane/cli/questions-command-handler.js +59 -0
  411. package/dist/apps/control-plane/cli/questions-command-handler.js.map +1 -0
  412. package/dist/apps/control-plane/cli/replay-command-handler.d.ts +15 -0
  413. package/dist/apps/control-plane/cli/replay-command-handler.js +55 -0
  414. package/dist/apps/control-plane/cli/replay-command-handler.js.map +1 -0
  415. package/dist/apps/control-plane/cli/resume-command-handler.d.ts +2 -0
  416. package/dist/apps/control-plane/cli/resume-command-handler.js +180 -17
  417. package/dist/apps/control-plane/cli/resume-command-handler.js.map +1 -1
  418. package/dist/apps/control-plane/cli/retry-command-handler.js +202 -16
  419. package/dist/apps/control-plane/cli/retry-command-handler.js.map +1 -1
  420. package/dist/apps/control-plane/cli/retry-resume-decision.d.ts +26 -0
  421. package/dist/apps/control-plane/cli/retry-resume-decision.js +61 -0
  422. package/dist/apps/control-plane/cli/retry-resume-decision.js.map +1 -0
  423. package/dist/apps/control-plane/cli/rollback-command-handler.js +3 -2
  424. package/dist/apps/control-plane/cli/rollback-command-handler.js.map +1 -1
  425. package/dist/apps/control-plane/cli/run-command-handler.js +26 -2
  426. package/dist/apps/control-plane/cli/run-command-handler.js.map +1 -1
  427. package/dist/apps/control-plane/cli/spec-ingestion-service.d.ts +2 -0
  428. package/dist/apps/control-plane/cli/spec-ingestion-service.js +37 -48
  429. package/dist/apps/control-plane/cli/spec-ingestion-service.js.map +1 -1
  430. package/dist/apps/control-plane/cli/spec-preparation.d.ts +14 -0
  431. package/dist/apps/control-plane/cli/spec-preparation.js +81 -0
  432. package/dist/apps/control-plane/cli/spec-preparation.js.map +1 -0
  433. package/dist/apps/control-plane/cli/spec-utils.d.ts +4 -0
  434. package/dist/apps/control-plane/cli/spec-utils.js +70 -11
  435. package/dist/apps/control-plane/cli/spec-utils.js.map +1 -1
  436. package/dist/apps/control-plane/cli/status-command-handler.js +69 -0
  437. package/dist/apps/control-plane/cli/status-command-handler.js.map +1 -1
  438. package/dist/apps/control-plane/cli/types.d.ts +41 -4
  439. package/dist/apps/control-plane/cli/types.js +9 -1
  440. package/dist/apps/control-plane/cli/types.js.map +1 -1
  441. package/dist/apps/control-plane/core/collisions.d.ts +37 -19
  442. package/dist/apps/control-plane/core/collisions.js +87 -12
  443. package/dist/apps/control-plane/core/collisions.js.map +1 -1
  444. package/dist/apps/control-plane/core/constants.d.ts +17 -1
  445. package/dist/apps/control-plane/core/constants.js +18 -1
  446. package/dist/apps/control-plane/core/constants.js.map +1 -1
  447. package/dist/apps/control-plane/core/error-codes.d.ts +39 -0
  448. package/dist/apps/control-plane/core/error-codes.js +39 -0
  449. package/dist/apps/control-plane/core/error-codes.js.map +1 -1
  450. package/dist/apps/control-plane/core/execution-control.d.ts +45 -0
  451. package/dist/apps/control-plane/core/execution-control.js +2 -0
  452. package/dist/apps/control-plane/core/execution-control.js.map +1 -0
  453. package/dist/apps/control-plane/core/feature-resume-phase.d.ts +3 -0
  454. package/dist/apps/control-plane/core/feature-resume-phase.js +88 -0
  455. package/dist/apps/control-plane/core/feature-resume-phase.js.map +1 -0
  456. package/dist/apps/control-plane/core/gate-freshness.d.ts +48 -0
  457. package/dist/apps/control-plane/core/gate-freshness.js +267 -0
  458. package/dist/apps/control-plane/core/gate-freshness.js.map +1 -0
  459. package/dist/apps/control-plane/core/gate-log-extractor.d.ts +22 -0
  460. package/dist/apps/control-plane/core/gate-log-extractor.js +66 -0
  461. package/dist/apps/control-plane/core/gate-log-extractor.js.map +1 -0
  462. package/dist/apps/control-plane/core/gates.d.ts +11 -2
  463. package/dist/apps/control-plane/core/gates.js +67 -3
  464. package/dist/apps/control-plane/core/gates.js.map +1 -1
  465. package/dist/apps/control-plane/core/intake-artifacts.d.ts +109 -0
  466. package/dist/apps/control-plane/core/intake-artifacts.js +143 -0
  467. package/dist/apps/control-plane/core/intake-artifacts.js.map +1 -0
  468. package/dist/apps/control-plane/core/kernel-types.d.ts +8 -0
  469. package/dist/apps/control-plane/core/kernel.d.ts +256 -8
  470. package/dist/apps/control-plane/core/kernel.js +400 -14
  471. package/dist/apps/control-plane/core/kernel.js.map +1 -1
  472. package/dist/apps/control-plane/core/mainline-conflict.d.ts +7 -0
  473. package/dist/apps/control-plane/core/mainline-conflict.js +20 -0
  474. package/dist/apps/control-plane/core/mainline-conflict.js.map +1 -0
  475. package/dist/apps/control-plane/core/merge-repair.d.ts +35 -0
  476. package/dist/apps/control-plane/core/merge-repair.js +99 -0
  477. package/dist/apps/control-plane/core/merge-repair.js.map +1 -0
  478. package/dist/apps/control-plane/core/path-layout.d.ts +10 -0
  479. package/dist/apps/control-plane/core/path-layout.js +32 -2
  480. package/dist/apps/control-plane/core/path-layout.js.map +1 -1
  481. package/dist/apps/control-plane/core/path-rules.js +9 -3
  482. package/dist/apps/control-plane/core/path-rules.js.map +1 -1
  483. package/dist/apps/control-plane/core/plan-submit-recovery.d.ts +22 -0
  484. package/dist/apps/control-plane/core/plan-submit-recovery.js +78 -0
  485. package/dist/apps/control-plane/core/plan-submit-recovery.js.map +1 -0
  486. package/dist/apps/control-plane/core/questions.d.ts +40 -0
  487. package/dist/apps/control-plane/core/questions.js +2 -0
  488. package/dist/apps/control-plane/core/questions.js.map +1 -0
  489. package/dist/apps/control-plane/core/runtime-sessions.d.ts +4 -0
  490. package/dist/apps/control-plane/core/schemas.d.ts +2 -0
  491. package/dist/apps/control-plane/core/schemas.js +31 -1
  492. package/dist/apps/control-plane/core/schemas.js.map +1 -1
  493. package/dist/apps/control-plane/core/tool-caller.d.ts +18 -1
  494. package/dist/apps/control-plane/core/utils/index-normalizer.js +17 -4
  495. package/dist/apps/control-plane/core/utils/index-normalizer.js.map +1 -1
  496. package/dist/apps/control-plane/core/worktree-diff.d.ts +4 -0
  497. package/dist/apps/control-plane/core/worktree-diff.js +52 -0
  498. package/dist/apps/control-plane/core/worktree-diff.js.map +1 -0
  499. package/dist/apps/control-plane/index.d.ts +10 -2
  500. package/dist/apps/control-plane/index.js +9 -2
  501. package/dist/apps/control-plane/index.js.map +1 -1
  502. package/dist/apps/control-plane/interfaces/cli/bootstrap.js +236 -6
  503. package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
  504. package/dist/apps/control-plane/mcp/kernel-tool-executor.js +16 -0
  505. package/dist/apps/control-plane/mcp/kernel-tool-executor.js.map +1 -1
  506. package/dist/apps/control-plane/mcp/tool-runtime.d.ts +5 -0
  507. package/dist/apps/control-plane/mcp/tool-runtime.js +40 -5
  508. package/dist/apps/control-plane/mcp/tool-runtime.js.map +1 -1
  509. package/dist/apps/control-plane/providers/api-worker-provider.d.ts +2 -2
  510. package/dist/apps/control-plane/providers/api-worker-provider.js +40 -9
  511. package/dist/apps/control-plane/providers/api-worker-provider.js.map +1 -1
  512. package/dist/apps/control-plane/providers/cli-worker-provider.d.ts +59 -3
  513. package/dist/apps/control-plane/providers/cli-worker-provider.js +758 -46
  514. package/dist/apps/control-plane/providers/cli-worker-provider.js.map +1 -1
  515. package/dist/apps/control-plane/providers/output-parsers/generic-output-parser.js +91 -1
  516. package/dist/apps/control-plane/providers/output-parsers/generic-output-parser.js.map +1 -1
  517. package/dist/apps/control-plane/providers/output-parsers/types.d.ts +2 -0
  518. package/dist/apps/control-plane/providers/provider-defaults.d.ts +12 -0
  519. package/dist/apps/control-plane/providers/provider-defaults.js +103 -7
  520. package/dist/apps/control-plane/providers/provider-defaults.js.map +1 -1
  521. package/dist/apps/control-plane/providers/providers.d.ts +50 -4
  522. package/dist/apps/control-plane/providers/providers.js +145 -14
  523. package/dist/apps/control-plane/providers/providers.js.map +1 -1
  524. package/dist/apps/control-plane/providers/worker-provider-factory.d.ts +2 -0
  525. package/dist/apps/control-plane/providers/worker-provider-factory.js +8 -1
  526. package/dist/apps/control-plane/providers/worker-provider-factory.js.map +1 -1
  527. package/dist/apps/control-plane/supervisor/artifact-stager.d.ts +5 -0
  528. package/dist/apps/control-plane/supervisor/artifact-stager.js +45 -0
  529. package/dist/apps/control-plane/supervisor/artifact-stager.js.map +1 -0
  530. package/dist/apps/control-plane/supervisor/build-wave-executor.d.ts +24 -1
  531. package/dist/apps/control-plane/supervisor/build-wave-executor.js +362 -150
  532. package/dist/apps/control-plane/supervisor/build-wave-executor.js.map +1 -1
  533. package/dist/apps/control-plane/supervisor/execution-enrollment-service.d.ts +41 -0
  534. package/dist/apps/control-plane/supervisor/execution-enrollment-service.js +311 -0
  535. package/dist/apps/control-plane/supervisor/execution-enrollment-service.js.map +1 -0
  536. package/dist/apps/control-plane/supervisor/organizer-enrollment-scheduler.d.ts +15 -0
  537. package/dist/apps/control-plane/supervisor/organizer-enrollment-scheduler.js +93 -0
  538. package/dist/apps/control-plane/supervisor/organizer-enrollment-scheduler.js.map +1 -0
  539. package/dist/apps/control-plane/supervisor/organizer-sidecar-service.d.ts +44 -0
  540. package/dist/apps/control-plane/supervisor/organizer-sidecar-service.js +311 -0
  541. package/dist/apps/control-plane/supervisor/organizer-sidecar-service.js.map +1 -0
  542. package/dist/apps/control-plane/supervisor/plan-conformance-scorer.js +2 -5
  543. package/dist/apps/control-plane/supervisor/plan-conformance-scorer.js.map +1 -1
  544. package/dist/apps/control-plane/supervisor/planner-phase.d.ts +3 -0
  545. package/dist/apps/control-plane/supervisor/planner-phase.js +70 -0
  546. package/dist/apps/control-plane/supervisor/planner-phase.js.map +1 -0
  547. package/dist/apps/control-plane/supervisor/planning-wave-executor.d.ts +42 -0
  548. package/dist/apps/control-plane/supervisor/planning-wave-executor.js +753 -55
  549. package/dist/apps/control-plane/supervisor/planning-wave-executor.js.map +1 -1
  550. package/dist/apps/control-plane/supervisor/prompt-bundle-loader.js +19 -1
  551. package/dist/apps/control-plane/supervisor/prompt-bundle-loader.js.map +1 -1
  552. package/dist/apps/control-plane/supervisor/qa-wave-executor.d.ts +21 -0
  553. package/dist/apps/control-plane/supervisor/qa-wave-executor.js +287 -156
  554. package/dist/apps/control-plane/supervisor/qa-wave-executor.js.map +1 -1
  555. package/dist/apps/control-plane/supervisor/run-coordinator.d.ts +30 -1
  556. package/dist/apps/control-plane/supervisor/run-coordinator.js +561 -17
  557. package/dist/apps/control-plane/supervisor/run-coordinator.js.map +1 -1
  558. package/dist/apps/control-plane/supervisor/runtime.d.ts +84 -0
  559. package/dist/apps/control-plane/supervisor/runtime.js +393 -3
  560. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  561. package/dist/apps/control-plane/supervisor/session-orchestrator.d.ts +54 -0
  562. package/dist/apps/control-plane/supervisor/session-orchestrator.js +176 -1
  563. package/dist/apps/control-plane/supervisor/session-orchestrator.js.map +1 -1
  564. package/dist/apps/control-plane/supervisor/types.d.ts +142 -1
  565. package/dist/apps/control-plane/supervisor/types.js.map +1 -1
  566. package/dist/apps/control-plane/supervisor/worker-decision-loop.d.ts +68 -2
  567. package/dist/apps/control-plane/supervisor/worker-decision-loop.js +723 -89
  568. package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
  569. package/docs/core/ARCHITECTURE.md +227 -0
  570. package/docs/core/DECISIONS.md +94 -0
  571. package/docs/core/DOMAIN-LOGIC.md +60 -0
  572. package/docs/core/PATTERNS.md +201 -0
  573. package/docs/core/TROUBLESHOOTING.md +347 -0
  574. package/docs/core/intentgraph-dependencies.json +39860 -0
  575. package/docs/core/intentgraph.index.json +46580 -0
  576. package/docs/plans/2026-03-10-gate-failure-targeted-repair-design.md +224 -0
  577. package/docs/plans/2026-03-10-gate-failure-targeted-repair.md +1032 -0
  578. package/docs/superpowers/plans/2026-03-16-provider-cli-config.md +743 -0
  579. package/docs/superpowers/plans/2026-03-23-reconcile-divergence-fix.md +777 -0
  580. package/docs/superpowers/plans/2026-03-28-ordering-agent-implementation.md +1754 -0
  581. package/docs/superpowers/plans/2026-03-29-drop-zone-and-provider-optimization.md +1108 -0
  582. package/docs/superpowers/plans/2026-03-29-merge-target-feature-branch.md +685 -0
  583. package/docs/superpowers/plans/2026-03-29-organizer-sidecar-runtime-loop.md +1289 -0
  584. package/docs/superpowers/specs/2026-03-23-reconcile-divergence-fix-design.md +118 -0
  585. package/docs/superpowers/specs/2026-03-28-ordering-agent-spec-audit-design.md +50 -0
  586. package/docs/superpowers/specs/2026-03-29-drop-zone-and-provider-optimization-design.md +254 -0
  587. package/docs/superpowers/specs/2026-03-29-merge-target-feature-branch-design.md +152 -0
  588. package/docs/superpowers/specs/2026-03-29-organizer-sidecar-runtime-loop-design.md +225 -0
  589. package/package.json +3 -2
  590. package/packages/web-dashboard/package.json +2 -1
  591. package/packages/web-dashboard/src/app/analytics/page.tsx +36 -2
  592. package/packages/web-dashboard/src/app/api/actions/route.ts +274 -63
  593. package/packages/web-dashboard/src/app/api/actions/status/route.ts +35 -0
  594. package/packages/web-dashboard/src/app/api/analytics/provider/route.ts +18 -0
  595. package/packages/web-dashboard/src/app/api/collisions/approve/route.ts +58 -0
  596. package/packages/web-dashboard/src/app/api/features/[id]/checkpoint-diff/route.ts +36 -0
  597. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/route.ts +29 -0
  598. package/packages/web-dashboard/src/app/api/features/[id]/conflicts/abort/route.ts +29 -0
  599. package/packages/web-dashboard/src/app/api/features/[id]/conflicts/files/route.ts +30 -0
  600. package/packages/web-dashboard/src/app/api/features/[id]/conflicts/resolve/route.ts +51 -0
  601. package/packages/web-dashboard/src/app/api/features/[id]/conflicts/route.ts +75 -0
  602. package/packages/web-dashboard/src/app/api/features/[id]/diff/route.ts +16 -2
  603. package/packages/web-dashboard/src/app/api/features/[id]/files/route.ts +26 -0
  604. package/packages/web-dashboard/src/app/api/features/[id]/gate-history/route.ts +27 -0
  605. package/packages/web-dashboard/src/app/api/features/[id]/genealogy/route.ts +26 -0
  606. package/packages/web-dashboard/src/app/api/features/[id]/history/run/[runId]/route.ts +20 -0
  607. package/packages/web-dashboard/src/app/api/features/[id]/history/runs/route.ts +34 -0
  608. package/packages/web-dashboard/src/app/api/features/[id]/intake-workspace/route.ts +20 -0
  609. package/packages/web-dashboard/src/app/api/features/[id]/live-output/route.ts +74 -0
  610. package/packages/web-dashboard/src/app/api/features/[id]/plan/amend/route.ts +21 -0
  611. package/packages/web-dashboard/src/app/api/features/[id]/plan-progress/route.ts +20 -0
  612. package/packages/web-dashboard/src/app/api/features/[id]/planner-artifacts/[artifact]/route.ts +78 -0
  613. package/packages/web-dashboard/src/app/api/features/[id]/planner-lifecycle/route.ts +20 -0
  614. package/packages/web-dashboard/src/app/api/features/[id]/planning-workspace/route.ts +20 -0
  615. package/packages/web-dashboard/src/app/api/features/[id]/questions/[questionId]/answer/route.ts +27 -0
  616. package/packages/web-dashboard/src/app/api/features/[id]/questions/route.ts +18 -0
  617. package/packages/web-dashboard/src/app/api/features/[id]/review/route.ts +14 -7
  618. package/packages/web-dashboard/src/app/api/features/[id]/route.ts +57 -2
  619. package/packages/web-dashboard/src/app/api/features/[id]/spec/route.ts +30 -0
  620. package/packages/web-dashboard/src/app/api/features/[id]/triage/route.ts +83 -0
  621. package/packages/web-dashboard/src/app/api/features/[id]/worker-events/route.ts +40 -0
  622. package/packages/web-dashboard/src/app/api/launch/preview/route.ts +86 -0
  623. package/packages/web-dashboard/src/app/api/launch/submit/route.ts +180 -0
  624. package/packages/web-dashboard/src/app/api/mainline/status/route.ts +74 -0
  625. package/packages/web-dashboard/src/app/api/merge-queue/route.ts +13 -0
  626. package/packages/web-dashboard/src/app/api/policy/budget/route.ts +14 -0
  627. package/packages/web-dashboard/src/app/api/projects/route.ts +11 -7
  628. package/packages/web-dashboard/src/app/api/reconciler/queue/route.ts +47 -0
  629. package/packages/web-dashboard/src/app/api/run/route.ts +26 -2
  630. package/packages/web-dashboard/src/app/api/runtime/events/route.ts +227 -0
  631. package/packages/web-dashboard/src/app/api/runtime/operations/route.ts +269 -0
  632. package/packages/web-dashboard/src/app/api/runtime/questions/route.ts +11 -0
  633. package/packages/web-dashboard/src/app/api/runtime/runs/route.ts +80 -0
  634. package/packages/web-dashboard/src/app/api/status/route.ts +4 -2
  635. package/packages/web-dashboard/src/app/feature/[id]/page.tsx +32 -42
  636. package/packages/web-dashboard/src/app/globals.css +34 -3
  637. package/packages/web-dashboard/src/app/launch/page.tsx +362 -0
  638. package/packages/web-dashboard/src/app/layout.tsx +23 -1
  639. package/packages/web-dashboard/src/app/page.tsx +263 -272
  640. package/packages/web-dashboard/src/components/dashboard/attention-strip.tsx +52 -0
  641. package/packages/web-dashboard/src/components/dashboard/collision-approval-drawer.tsx +185 -0
  642. package/packages/web-dashboard/src/components/dashboard/command-center-header.tsx +102 -0
  643. package/packages/web-dashboard/src/components/dashboard/mainline-status-banner.tsx +84 -0
  644. package/packages/web-dashboard/src/components/dashboard/merged-archive.tsx +36 -0
  645. package/packages/web-dashboard/src/components/dashboard/prioritized-queues.tsx +98 -0
  646. package/packages/web-dashboard/src/components/dashboard/reconciler-queue-card.tsx +115 -0
  647. package/packages/web-dashboard/src/components/dashboard/secondary-diagnostics-rail.tsx +48 -0
  648. package/packages/web-dashboard/src/components/dashboard/task-filter-bar.tsx +74 -0
  649. package/packages/web-dashboard/src/components/dashboard/triage-drawer.tsx +455 -0
  650. package/packages/web-dashboard/src/components/diff-viewer.tsx +19 -3
  651. package/packages/web-dashboard/src/components/evidence-viewer.tsx +65 -51
  652. package/packages/web-dashboard/src/components/feature-card.tsx +90 -7
  653. package/packages/web-dashboard/src/components/feature-cost-panel.tsx +112 -11
  654. package/packages/web-dashboard/src/components/feature-list-view.tsx +25 -4
  655. package/packages/web-dashboard/src/components/features/runtime-inspector/EventsTimelineView.tsx +260 -0
  656. package/packages/web-dashboard/src/components/features/runtime-inspector/OperationsListView.tsx +172 -0
  657. package/packages/web-dashboard/src/components/features/runtime-inspector/RuntimeInspectorPanel.tsx +896 -0
  658. package/packages/web-dashboard/src/components/filter-bar.tsx +7 -39
  659. package/packages/web-dashboard/src/components/focus/ActionableRiskList.tsx +46 -0
  660. package/packages/web-dashboard/src/components/focus/AgentRolePerformanceCard.tsx +200 -0
  661. package/packages/web-dashboard/src/components/focus/BlockedGuidanceBanner.tsx +149 -0
  662. package/packages/web-dashboard/src/components/focus/CheckpointInspector.tsx +123 -0
  663. package/packages/web-dashboard/src/components/focus/CheckpointRail.tsx +118 -0
  664. package/packages/web-dashboard/src/components/focus/CheckpointScrubber.tsx +249 -0
  665. package/packages/web-dashboard/src/components/focus/CollisionApprovalBanner.tsx +192 -0
  666. package/packages/web-dashboard/src/components/focus/CollisionRadar.tsx +136 -0
  667. package/packages/web-dashboard/src/components/focus/ConflictStatusCard.tsx +52 -0
  668. package/packages/web-dashboard/src/components/focus/ContextSidebar.tsx +108 -0
  669. package/packages/web-dashboard/src/components/focus/DiagnosisPanel.tsx +68 -0
  670. package/packages/web-dashboard/src/components/focus/FeatureDecisionBanner.tsx +68 -0
  671. package/packages/web-dashboard/src/components/focus/FeatureQuestionAnswerPanel.tsx +167 -0
  672. package/packages/web-dashboard/src/components/focus/FocusHeader.tsx +54 -0
  673. package/packages/web-dashboard/src/components/focus/FocusLayout.tsx +283 -0
  674. package/packages/web-dashboard/src/components/focus/GateFlakinessSummary.tsx +144 -0
  675. package/packages/web-dashboard/src/components/focus/GenealogyTree.tsx +34 -0
  676. package/packages/web-dashboard/src/components/focus/HeroBlock.tsx +67 -0
  677. package/packages/web-dashboard/src/components/focus/LiveAgentConsole.tsx +277 -0
  678. package/packages/web-dashboard/src/components/focus/MergeQueueCard.tsx +78 -0
  679. package/packages/web-dashboard/src/components/focus/OperationalSummaryCard.tsx +227 -0
  680. package/packages/web-dashboard/src/components/focus/PinnedActions.tsx +96 -0
  681. package/packages/web-dashboard/src/components/focus/PlanAmendmentPanel.tsx +250 -0
  682. package/packages/web-dashboard/src/components/focus/PlanProgressPanel.tsx +133 -0
  683. package/packages/web-dashboard/src/components/focus/PlannerArtifactViewer.tsx +158 -0
  684. package/packages/web-dashboard/src/components/focus/PlannerLifecycleHeader.tsx +141 -0
  685. package/packages/web-dashboard/src/components/focus/ProgressSnapshotCard.tsx +113 -0
  686. package/packages/web-dashboard/src/components/focus/RecentMaterialChanges.tsx +69 -0
  687. package/packages/web-dashboard/src/components/focus/RoleLogViewer.tsx +436 -0
  688. package/packages/web-dashboard/src/components/focus/RunHistoryBrowser.tsx +62 -0
  689. package/packages/web-dashboard/src/components/focus/SpecViewer.tsx +172 -0
  690. package/packages/web-dashboard/src/components/focus/TabBar.tsx +33 -0
  691. package/packages/web-dashboard/src/components/focus/UsageBurnChart.tsx +212 -0
  692. package/packages/web-dashboard/src/components/focus/VerificationSummaryCard.tsx +122 -0
  693. package/packages/web-dashboard/src/components/focus/tabs/ChangesTab.tsx +325 -0
  694. package/packages/web-dashboard/src/components/focus/tabs/ConflictsTab.tsx +395 -0
  695. package/packages/web-dashboard/src/components/focus/tabs/GatesQaTab.tsx +38 -0
  696. package/packages/web-dashboard/src/components/focus/tabs/HistoryTab.tsx +213 -0
  697. package/packages/web-dashboard/src/components/focus/tabs/IntakeTab.tsx +429 -0
  698. package/packages/web-dashboard/src/components/focus/tabs/OverviewTab.tsx +217 -0
  699. package/packages/web-dashboard/src/components/focus/tabs/PlanningTab.tsx +390 -0
  700. package/packages/web-dashboard/src/components/focus/tabs/ReviewTab.tsx +497 -0
  701. package/packages/web-dashboard/src/components/focus/tabs/RuntimeTab.tsx +213 -0
  702. package/packages/web-dashboard/src/components/focus/tabs/TranscriptTab.tsx +315 -0
  703. package/packages/web-dashboard/src/components/gate-results.tsx +2 -2
  704. package/packages/web-dashboard/src/components/human-input-panel.tsx +33 -57
  705. package/packages/web-dashboard/src/components/kanban-board.tsx +4 -0
  706. package/packages/web-dashboard/src/components/launch/launch-draft-card.tsx +154 -0
  707. package/packages/web-dashboard/src/components/plan-viewer.tsx +147 -69
  708. package/packages/web-dashboard/src/components/quick-launch-panel.tsx +20 -47
  709. package/packages/web-dashboard/src/components/summary-bar.tsx +30 -76
  710. package/packages/web-dashboard/src/lib/aop-client.ts +2484 -36
  711. package/packages/web-dashboard/src/lib/blocked-state-guidance.ts +475 -0
  712. package/packages/web-dashboard/src/lib/collision-radar.ts +136 -0
  713. package/packages/web-dashboard/src/lib/dashboard-action-states.ts +204 -0
  714. package/packages/web-dashboard/src/lib/dashboard-runtime-client.ts +439 -0
  715. package/packages/web-dashboard/src/lib/dashboard-utils.ts +179 -18
  716. package/packages/web-dashboard/src/lib/drop-zone-utils.ts +92 -0
  717. package/packages/web-dashboard/src/lib/focus-detail-derivations.ts +958 -0
  718. package/packages/web-dashboard/src/lib/focus-view.ts +300 -0
  719. package/packages/web-dashboard/src/lib/health-diagnosis.ts +356 -0
  720. package/packages/web-dashboard/src/lib/launch-contracts.ts +77 -0
  721. package/packages/web-dashboard/src/lib/launch-markdown.ts +103 -0
  722. package/packages/web-dashboard/src/lib/launch-page-preview.ts +89 -0
  723. package/packages/web-dashboard/src/lib/live-feed.ts +1 -1
  724. package/packages/web-dashboard/src/lib/multi-project-config.ts +33 -0
  725. package/packages/web-dashboard/src/lib/orchestrator-tools.ts +881 -60
  726. package/packages/web-dashboard/src/lib/planner-workspace.ts +1285 -0
  727. package/packages/web-dashboard/src/lib/review-contracts.ts +5 -3
  728. package/packages/web-dashboard/src/lib/runtime-files.ts +285 -0
  729. package/packages/web-dashboard/src/lib/tool-catalog.ts +51 -0
  730. package/packages/web-dashboard/src/lib/types.ts +731 -3
  731. package/packages/web-dashboard/src/lib/usage-burn.ts +175 -0
  732. package/packages/web-dashboard/src/lib/worktree-diff.ts +128 -0
  733. package/packages/web-dashboard/src/styles/dashboard.module.css +1742 -459
  734. package/packages/web-dashboard/test/api/actions/route.spec.ts +675 -0
  735. package/packages/web-dashboard/test/api/features/diff.route.spec.ts +57 -0
  736. package/packages/web-dashboard/test/api/features/feature.route.spec.ts +99 -0
  737. package/packages/web-dashboard/test/api/features/live-output.route.spec.ts +123 -0
  738. package/packages/web-dashboard/test/api/features/plan-amend.route.spec.ts +95 -0
  739. package/packages/web-dashboard/test/api/features/planner-workspaces.route.spec.ts +162 -0
  740. package/packages/web-dashboard/test/api/features/question-answer.route.spec.ts +99 -0
  741. package/packages/web-dashboard/test/api/features/triage.route.spec.ts +195 -0
  742. package/packages/web-dashboard/test/api/launch/preview.route.spec.ts +149 -0
  743. package/packages/web-dashboard/test/api/launch/submit.route.spec.ts +382 -0
  744. package/packages/web-dashboard/test/api/runtime/events/route.spec.ts +164 -0
  745. package/packages/web-dashboard/test/api/runtime/operations/route.spec.ts +156 -0
  746. package/packages/web-dashboard/test/api/runtime/runs/route.spec.ts +112 -0
  747. package/packages/web-dashboard/test/components/changes-tab.spec.tsx +76 -0
  748. package/packages/web-dashboard/test/components/command-center-root.spec.tsx +87 -0
  749. package/packages/web-dashboard/test/components/diagnosis-panel.spec.tsx +59 -0
  750. package/packages/web-dashboard/test/components/feature-card.spec.tsx +45 -0
  751. package/packages/web-dashboard/test/components/focus-layout.spec.tsx +299 -0
  752. package/packages/web-dashboard/test/components/gate-results.spec.tsx +39 -0
  753. package/packages/web-dashboard/test/components/gates-qa-tab.spec.tsx +118 -0
  754. package/packages/web-dashboard/test/components/human-input-panel.spec.tsx +54 -0
  755. package/packages/web-dashboard/test/components/intake-tab.spec.tsx +210 -0
  756. package/packages/web-dashboard/test/components/kanban-board.spec.tsx +35 -0
  757. package/packages/web-dashboard/test/components/launch-draft-card.spec.tsx +114 -0
  758. package/packages/web-dashboard/test/components/launch-page.spec.tsx +79 -0
  759. package/packages/web-dashboard/test/components/overview-tab.spec.tsx +236 -0
  760. package/packages/web-dashboard/test/components/planning-tab.spec.tsx +202 -0
  761. package/packages/web-dashboard/test/components/review-tab.spec.tsx +169 -0
  762. package/packages/web-dashboard/test/components/role-log-viewer.spec.ts +42 -0
  763. package/packages/web-dashboard/test/components/runtime-inspector.spec.tsx +22 -0
  764. package/packages/web-dashboard/test/components/runtime-tab.spec.tsx +133 -0
  765. package/packages/web-dashboard/test/components/transcript-tab.spec.tsx +46 -0
  766. package/packages/web-dashboard/test/components/triage-drawer.spec.tsx +159 -0
  767. package/packages/web-dashboard/test/lib/aop-client.spec.ts +235 -0
  768. package/packages/web-dashboard/test/lib/dashboard-runtime-client.spec.ts +144 -0
  769. package/packages/web-dashboard/test/lib/focus-detail-derivations.spec.ts +314 -0
  770. package/packages/web-dashboard/test/lib/focus-view.spec.ts +248 -0
  771. package/packages/web-dashboard/test/lib/health-diagnosis.spec.ts +277 -0
  772. package/packages/web-dashboard/test/lib/launch-markdown.spec.ts +36 -0
  773. package/packages/web-dashboard/test/lib/multi-project-config.spec.ts +54 -0
  774. package/packages/web-dashboard/test/lib/orchestrator-tools.spec.ts +352 -0
  775. package/packages/web-dashboard/test/lib/planner-workspace.spec.ts +289 -0
  776. package/packages/web-dashboard/test/lib/worktree-diff.spec.ts +119 -0
  777. package/packages/web-dashboard/vitest.config.ts +2 -0
  778. package/spec-files/completed/agentic_orchestrator_add_feature_to_active_execution_spec.md +557 -0
  779. package/spec-files/completed/agentic_orchestrator_dashboard_command_center_redesign_spec.md +1147 -0
  780. package/spec-files/completed/agentic_orchestrator_execution_mode_spec.md +18 -16
  781. package/spec-files/completed/agentic_orchestrator_feature_focus_view_track_a_spec.md +672 -0
  782. package/spec-files/completed/agentic_orchestrator_feature_focus_view_track_b_spec.md +794 -0
  783. package/spec-files/completed/agentic_orchestrator_feature_focus_view_track_c_decision_centric_remediation_spec.md +1037 -0
  784. package/spec-files/completed/agentic_orchestrator_feature_focus_view_ux_redesign_spec.md +1432 -0
  785. package/spec-files/completed/agentic_orchestrator_focus_plan_tab_intake_planning_workspace_spec.md +921 -0
  786. package/spec-files/completed/agentic_orchestrator_intentional_collision_override_spec.md +584 -0
  787. package/spec-files/completed/agentic_orchestrator_interactive_planning_intake_and_requirements_verification_spec.md +1185 -0
  788. package/spec-files/completed/agentic_orchestrator_reactive_execution_enrollment_spec.md +864 -0
  789. package/spec-files/{outstanding → completed}/agentic_orchestrator_runtime_inspection_spec.md +92 -19
  790. package/spec-files/completed/agentic_orchestrator_scope_aware_run_lease_spec.md +408 -0
  791. package/spec-files/completed/git-reconciliation-engine.md +827 -0
  792. package/spec-files/outstanding/agentic_orchestrator_dashboard_quick_launch_and_control_surface_spec.md +331 -0
  793. package/spec-files/outstanding/agentic_orchestrator_enterprise_governance_dashboard_spec.md +16 -6
  794. package/spec-files/outstanding/agentic_orchestrator_evidence_integrity_doctor_spec.md +60 -9
  795. package/spec-files/outstanding/agentic_orchestrator_focus_plan_tab_execution_contract_workspace_spec.md +616 -0
  796. package/spec-files/outstanding/agentic_orchestrator_headless_standby_dashboard_runtime_spec.md +310 -0
  797. package/spec-files/outstanding/agentic_orchestrator_human_input_interaction_protocol_spec.md +175 -72
  798. package/spec-files/outstanding/agentic_orchestrator_interactive_rename_cleanup_spec.md +197 -0
  799. package/spec-files/outstanding/agentic_orchestrator_interactive_resume_and_reconciliation_disposition_spec.md +412 -0
  800. package/spec-files/outstanding/agentic_orchestrator_knowledge_canary_spec.md +166 -137
  801. package/spec-files/outstanding/agentic_orchestrator_observability_replay_spec.md +3 -3
  802. package/spec-files/outstanding/agentic_orchestrator_phase_specific_agent_profiles_and_token_telemetry_spec.md +303 -0
  803. package/spec-files/outstanding/agentic_orchestrator_planning_review_quality_spec.md +18 -5
  804. package/spec-files/outstanding/agentic_orchestrator_policy_stratification_spec.md +225 -0
  805. package/spec-files/outstanding/agentic_orchestrator_quality_adoption_execution_spec.md +77 -50
  806. package/spec-files/outstanding/agentic_orchestrator_ready_to_merge_branch_handoff_spec.md +724 -0
  807. package/spec-files/outstanding/agentic_orchestrator_remove_deterministic_mode_spec.md +263 -0
  808. package/spec-files/outstanding/agentic_orchestrator_request_more_context_and_dashboard_human_input_spec.md +456 -0
  809. package/spec-files/outstanding/agentic_orchestrator_spec_coverage_and_reconciliation_enforcement_spec.md +1411 -0
  810. package/spec-files/outstanding/agentic_orchestrator_spec_ordering_agent_spec.md +370 -0
  811. package/spec-files/outstanding/shadow_workspace_implementation_spec.md +1 -1
  812. package/spec-files/progress.md +2026 -120
  813. package/specs/001-runtime-inspection/checklists/requirements.md +35 -0
  814. package/specs/001-runtime-inspection/design.md +338 -0
  815. package/specs/001-runtime-inspection/spec.md +95 -0
  816. package/specs/002-scope-aware-lease/checklists/requirements.md +35 -0
  817. package/specs/002-scope-aware-lease/contracts/lease-registry.schema.json +101 -0
  818. package/specs/002-scope-aware-lease/data-model.md +236 -0
  819. package/specs/002-scope-aware-lease/plan.md +766 -0
  820. package/specs/002-scope-aware-lease/quickstart.md +150 -0
  821. package/specs/002-scope-aware-lease/research.md +135 -0
  822. package/specs/002-scope-aware-lease/spec.md +128 -0
  823. package/specs/002-scope-aware-lease/tasks.md +767 -0
  824. package/tsconfig.json +1 -1
  825. package/vitest.config.ts +28 -0
  826. package/ARCHITECTURE_ADHERENCE_ANALYSIS.md +0 -871
  827. package/packages/web-dashboard/next-env.d.ts +0 -6
  828. package/packages/web-dashboard/src/components/detail-panel.tsx +0 -1124
  829. package/packages/web-dashboard/src/components/review-workspace.tsx +0 -1162
  830. /package/spec-files/{outstanding → completed}/agentic_orchestrator_artifact_database_publishing_spec.md +0 -0
  831. /package/spec-files/{outstanding → completed}/agentic_orchestrator_cli_shell_tab_completion_spec.md +0 -0
  832. /package/spec-files/{outstanding → completed}/agentic_orchestrator_dashboard_diff_and_agent_console_spec.md +0 -0
  833. /package/spec-files/{outstanding → completed}/agentic_orchestrator_performance_improvements_spec.md +0 -0
  834. /package/spec-files/{outstanding → completed}/agentic_orchestrator_persistent_worker_runtime_spec.md +0 -0
  835. /package/spec-files/{outstanding → completed}/agentic_orchestrator_provider_auth_bootstrap_spec.md +0 -0
  836. /package/spec-files/{outstanding → completed}/agentic_orchestrator_real_worker_provider_execution_spec.md +0 -0
@@ -3,6 +3,148 @@ import { STATUS, TOOLS } from '../src/core/constants.js';
3
3
  import { RunCoordinator } from '../src/supervisor/run-coordinator.js';
4
4
 
5
5
  describe('RunCoordinator iteration behavior', () => {
6
+ it('waits on awaiting_input features and resumes waves after the answer restores a runnable status', async () => {
7
+ vi.useFakeTimers();
8
+ try {
9
+ let status: string = STATUS.AWAITING_INPUT;
10
+ const kernel = {
11
+ ensureLoaded: vi.fn(async () => undefined),
12
+ recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
13
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
14
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
15
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
16
+ getRuntimeSessions: vi.fn(async () => ({
17
+ run_id: 'run:test',
18
+ owner_instance_id: 'owner:test',
19
+ orchestrator_session_id: 'orch:test',
20
+ feature_sessions: {},
21
+ })),
22
+ getPolicySnapshot: vi.fn(() => ({
23
+ reactions: {
24
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
25
+ },
26
+ })),
27
+ updateState: vi.fn(async () => ({})),
28
+ checkBudget: vi.fn(async () => ({
29
+ over_budget: false,
30
+ alert_threshold_reached: false,
31
+ current_cost_usd: 0,
32
+ limit_usd: -1,
33
+ alert_threshold: 0.8,
34
+ })),
35
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
36
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
37
+ };
38
+
39
+ const provider = {
40
+ selection: {
41
+ provider: 'custom',
42
+ model: 'model-test',
43
+ },
44
+ };
45
+
46
+ const toolCaller = {
47
+ callTool: vi.fn(async (_role: string, toolName: string) => {
48
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
49
+ return { ok: true, data: { features: [] } };
50
+ }
51
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
52
+ return { ok: true, data: { front_matter: { status } } };
53
+ }
54
+ return { ok: true, data: {} };
55
+ }),
56
+ };
57
+
58
+ const state = {
59
+ runId: 'run:test',
60
+ ownerInstanceId: 'owner:test',
61
+ orchestratorSessionId: 'orch:test',
62
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
63
+ queue: [] as Array<{ feature_id: string }>,
64
+ runMetadata: {},
65
+ };
66
+
67
+ const sessionOrchestrator = {
68
+ ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
69
+ cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
70
+ initializeFeatureCluster: vi.fn(async (featureId: string) => {
71
+ state.sessionsByFeature.set(featureId, {
72
+ planner: `${featureId}:planner`,
73
+ builder: `${featureId}:builder`,
74
+ qa: `${featureId}:qa`,
75
+ });
76
+ }),
77
+ closeFeatureCluster: vi.fn(async () => undefined),
78
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
79
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
80
+ };
81
+
82
+ const planningWaveExecutor = {
83
+ run: vi.fn(async () => {
84
+ status = STATUS.MERGED;
85
+ }),
86
+ runPostQaReconciliation: vi.fn(async () => undefined),
87
+ clearFeatureTracking: vi.fn(),
88
+ };
89
+
90
+ const buildWaveExecutor = {
91
+ run: vi.fn(async () => undefined),
92
+ clearFeatureTracking: vi.fn(),
93
+ };
94
+
95
+ const qaWaveExecutor = {
96
+ run: vi.fn(async () => undefined),
97
+ clearFeatureTracking: vi.fn(),
98
+ };
99
+
100
+ const leaseHeartbeatService = {
101
+ renew: vi.fn(async () => undefined),
102
+ };
103
+
104
+ const executionEnrollmentService = {
105
+ start: vi.fn(async () => undefined),
106
+ stop: vi.fn(async () => undefined),
107
+ notifyNewRequestHint: vi.fn(async () => undefined),
108
+ noteSafeCheckpoint: vi.fn(async () => undefined),
109
+ hasPendingWork: vi.fn(() => false),
110
+ drainReadyDecisions: vi.fn(async () => []),
111
+ };
112
+
113
+ const coordinator = new RunCoordinator({
114
+ kernel: kernel as never,
115
+ provider: provider as never,
116
+ toolCaller: toolCaller as never,
117
+ state: state as never,
118
+ sessionOrchestrator: sessionOrchestrator as never,
119
+ planningWaveExecutor: planningWaveExecutor as never,
120
+ buildWaveExecutor: buildWaveExecutor as never,
121
+ qaWaveExecutor: qaWaveExecutor as never,
122
+ leaseHeartbeatService: leaseHeartbeatService as never,
123
+ maxActiveFeatures: 1,
124
+ maxParallelGateRuns: 2,
125
+ maxIterationsPerPhase: 1,
126
+ takeoverStaleRun: false,
127
+ providerConfigRefHash: () => 'hash',
128
+ executionEnrollmentService: executionEnrollmentService as never,
129
+ });
130
+
131
+ const startPromise = coordinator.start([{ feature_id: 'feature_waiting' }]);
132
+
133
+ await vi.advanceTimersByTimeAsync(500);
134
+ expect(planningWaveExecutor.run).not.toHaveBeenCalled();
135
+ expect(executionEnrollmentService.noteSafeCheckpoint).toHaveBeenCalled();
136
+
137
+ status = STATUS.PLANNING;
138
+ await vi.advanceTimersByTimeAsync(1_500);
139
+ await startPromise;
140
+
141
+ expect(planningWaveExecutor.run).toHaveBeenCalledWith(['feature_waiting']);
142
+ expect(leaseHeartbeatService.renew).toHaveBeenCalled();
143
+ } finally {
144
+ vi.useRealTimers();
145
+ }
146
+ });
147
+
6
148
  it('GIVEN_long_running_wave_WHEN_iteration_exceeds_lease_interval_THEN_background_renew_keeps_run_lease_fresh', async () => {
7
149
  vi.useFakeTimers();
8
150
  try {
@@ -10,6 +152,7 @@ describe('RunCoordinator iteration behavior', () => {
10
152
  ensureLoaded: vi.fn(async () => undefined),
11
153
  recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
12
154
  acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
155
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
13
156
  pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
14
157
  getRuntimeSessions: vi.fn(async () => ({
15
158
  run_id: 'run:test',
@@ -17,6 +160,11 @@ describe('RunCoordinator iteration behavior', () => {
17
160
  orchestrator_session_id: 'orch:test',
18
161
  feature_sessions: {},
19
162
  })),
163
+ getPolicySnapshot: vi.fn(() => ({
164
+ reactions: {
165
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
166
+ },
167
+ })),
20
168
  updateState: vi.fn(async () => ({})),
21
169
  checkBudget: vi.fn(async () => ({
22
170
  over_budget: false,
@@ -25,6 +173,8 @@ describe('RunCoordinator iteration behavior', () => {
25
173
  limit_usd: -1,
26
174
  alert_threshold: 0.8,
27
175
  })),
176
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
177
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
28
178
  };
29
179
 
30
180
  const provider = {
@@ -127,6 +277,7 @@ describe('RunCoordinator iteration behavior', () => {
127
277
  ensureLoaded: vi.fn(async () => undefined),
128
278
  recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
129
279
  acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
280
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
130
281
  pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
131
282
  getRuntimeSessions: vi.fn(async () => ({
132
283
  run_id: 'run:test',
@@ -134,7 +285,23 @@ describe('RunCoordinator iteration behavior', () => {
134
285
  orchestrator_session_id: 'orch:test',
135
286
  feature_sessions: {},
136
287
  })),
137
- updateState: vi.fn(async () => ({})),
288
+ getPolicySnapshot: vi.fn(() => ({
289
+ reactions: {
290
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
291
+ },
292
+ })),
293
+ updateState: vi.fn(
294
+ async (
295
+ _featureId: string,
296
+ _scope: unknown,
297
+ updater?: (frontMatter: Record<string, unknown>) => Promise<unknown>,
298
+ ) => {
299
+ if (typeof updater === 'function') {
300
+ await updater({});
301
+ }
302
+ return {};
303
+ },
304
+ ),
138
305
  checkBudget: vi.fn(async () => ({
139
306
  over_budget: false,
140
307
  alert_threshold_reached: false,
@@ -142,6 +309,8 @@ describe('RunCoordinator iteration behavior', () => {
142
309
  limit_usd: -1,
143
310
  alert_threshold: 0.8,
144
311
  })),
312
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
313
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
145
314
  };
146
315
 
147
316
  const provider = {
@@ -153,6 +322,9 @@ describe('RunCoordinator iteration behavior', () => {
153
322
 
154
323
  const toolCaller = {
155
324
  callTool: vi.fn(async (_role: string, toolName: string) => {
325
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
326
+ return { ok: true, data: { front_matter: { status: STATUS.PLANNING } } };
327
+ }
156
328
  if (toolName === TOOLS.REPORT_DASHBOARD) {
157
329
  return { ok: true, data: { features: [] } };
158
330
  }
@@ -243,11 +415,13 @@ describe('RunCoordinator iteration behavior', () => {
243
415
  );
244
416
  });
245
417
 
246
- it('GIVEN_terminal_active_feature_WHEN_slots_free_up_THEN_promotes_next_queued_feature_deterministically', async () => {
418
+ it('GIVEN_phase_reconciliation_enabled_WHEN_running_iteration_THEN_reconciles_mainline_before_planning_build_and_qa', async () => {
419
+ let featureStatus: string = STATUS.PLANNING;
247
420
  const kernel = {
248
421
  ensureLoaded: vi.fn(async () => undefined),
249
422
  recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
250
423
  acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
424
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
251
425
  pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
252
426
  getRuntimeSessions: vi.fn(async () => ({
253
427
  run_id: 'run:test',
@@ -255,6 +429,16 @@ describe('RunCoordinator iteration behavior', () => {
255
429
  orchestrator_session_id: 'orch:test',
256
430
  feature_sessions: {},
257
431
  })),
432
+ getPolicySnapshot: vi.fn(() => ({
433
+ reactions: {
434
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
435
+ },
436
+ reconciliation: {
437
+ enabled: true,
438
+ check_before_phases: ['planning', 'building', 'qa'],
439
+ auto_merge_non_conflicting: true,
440
+ },
441
+ })),
258
442
  updateState: vi.fn(async () => ({})),
259
443
  checkBudget: vi.fn(async () => ({
260
444
  over_budget: false,
@@ -263,35 +447,17 @@ describe('RunCoordinator iteration behavior', () => {
263
447
  limit_usd: -1,
264
448
  alert_threshold: 0.8,
265
449
  })),
450
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
451
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
266
452
  };
267
453
 
268
- const provider = {
269
- selection: {
270
- provider: 'custom',
271
- model: 'model-test',
272
- },
273
- };
274
-
275
- const statusReads = new Map<string, string[]>([
276
- ['feature_a', ['planning', 'merged']],
277
- ['feature_b', ['planning']],
278
- ]);
279
-
280
454
  const toolCaller = {
281
- callTool: vi.fn(async (_role: string, toolName: string, args: Record<string, unknown>) => {
455
+ callTool: vi.fn(async (_role: string, toolName: string) => {
282
456
  if (toolName === TOOLS.FEATURE_STATE_GET) {
283
- const featureId = String(args.feature_id);
284
- const states = statusReads.get(featureId) ?? ['planning'];
285
- const status = states.shift() ?? states.at(-1) ?? 'planning';
286
- statusReads.set(featureId, states.length === 0 ? [status] : states);
287
- return {
288
- ok: true,
289
- data: {
290
- front_matter: {
291
- status,
292
- },
293
- },
294
- };
457
+ return { ok: true, data: { front_matter: { status: featureStatus } } };
458
+ }
459
+ if (toolName === TOOLS.REPO_RECONCILE_MAINLINE) {
460
+ return { ok: true, data: { status: 'up_to_date' } };
295
461
  }
296
462
  if (toolName === TOOLS.REPORT_DASHBOARD) {
297
463
  return { ok: true, data: { features: [] } };
@@ -300,6 +466,13 @@ describe('RunCoordinator iteration behavior', () => {
300
466
  }),
301
467
  };
302
468
 
469
+ const provider = {
470
+ selection: {
471
+ provider: 'custom',
472
+ model: 'model-test',
473
+ },
474
+ };
475
+
303
476
  const state = {
304
477
  runId: 'run:test',
305
478
  ownerInstanceId: 'owner:test',
@@ -312,33 +485,30 @@ describe('RunCoordinator iteration behavior', () => {
312
485
  const sessionOrchestrator = {
313
486
  ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
314
487
  cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
315
- initializeFeatureCluster: vi.fn(async (featureId: string) => {
316
- state.sessionsByFeature.set(featureId, {
317
- planner: `${featureId}:planner`,
318
- builder: `${featureId}:builder`,
319
- qa: `${featureId}:qa`,
320
- });
321
- }),
322
- closeFeatureCluster: vi.fn(async (featureId: string) => {
323
- state.sessionsByFeature.delete(featureId);
324
- }),
488
+ initializeFeatureCluster: vi.fn(async () => undefined),
325
489
  reconcileQueuedFeatures: vi.fn(async () => undefined),
326
490
  enforceActiveFeatureInvariant: vi.fn(async () => undefined),
327
491
  };
328
492
 
329
493
  const planningWaveExecutor = {
330
- run: vi.fn(async () => undefined),
494
+ run: vi.fn(async () => {
495
+ featureStatus = STATUS.BUILDING;
496
+ }),
331
497
  runPostQaReconciliation: vi.fn(async () => undefined),
332
498
  clearFeatureTracking: vi.fn(),
333
499
  };
334
500
 
335
501
  const buildWaveExecutor = {
336
- run: vi.fn(async () => undefined),
502
+ run: vi.fn(async () => {
503
+ featureStatus = STATUS.QA;
504
+ }),
337
505
  clearFeatureTracking: vi.fn(),
338
506
  };
339
507
 
340
508
  const qaWaveExecutor = {
341
- run: vi.fn(async () => undefined),
509
+ run: vi.fn(async () => {
510
+ featureStatus = STATUS.READY_TO_MERGE;
511
+ }),
342
512
  clearFeatureTracking: vi.fn(),
343
513
  };
344
514
 
@@ -358,32 +528,43 @@ describe('RunCoordinator iteration behavior', () => {
358
528
  leaseHeartbeatService: leaseHeartbeatService as never,
359
529
  maxActiveFeatures: 1,
360
530
  maxParallelGateRuns: 2,
361
- maxIterationsPerPhase: 2,
531
+ maxIterationsPerPhase: 1,
362
532
  takeoverStaleRun: false,
363
533
  providerConfigRefHash: () => 'hash',
364
534
  });
365
535
 
366
- const result = await coordinator.start([
367
- { feature_id: 'feature_a' },
368
- { feature_id: 'feature_b' },
369
- ]);
536
+ await coordinator.start([{ feature_id: 'feature_a' }]);
370
537
 
371
- expect(result.queue_depth).toBe(0);
372
- expect(sessionOrchestrator.initializeFeatureCluster).toHaveBeenNthCalledWith(1, 'feature_a');
373
- expect(sessionOrchestrator.closeFeatureCluster).toHaveBeenCalledWith('feature_a');
374
- expect(planningWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feature_a');
375
- expect(buildWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feature_a');
376
- expect(qaWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feature_a');
377
- expect(sessionOrchestrator.initializeFeatureCluster).toHaveBeenNthCalledWith(2, 'feature_b');
378
- expect(planningWaveExecutor.run).toHaveBeenNthCalledWith(1, ['feature_a']);
379
- expect(planningWaveExecutor.run).toHaveBeenNthCalledWith(2, ['feature_b']);
538
+ const reconcileCalls = toolCaller.callTool.mock.calls.filter(
539
+ ([, toolName]) => toolName === TOOLS.REPO_RECONCILE_MAINLINE,
540
+ );
541
+ expect(reconcileCalls).toHaveLength(3);
542
+ expect(reconcileCalls.map((call) => (call as unknown[])[2])).toEqual([
543
+ {
544
+ feature_id: 'feature_a',
545
+ auto_merge_non_conflicting: true,
546
+ commit_message: 'chore: checkpoint feature_a before planning reconciliation',
547
+ },
548
+ {
549
+ feature_id: 'feature_a',
550
+ auto_merge_non_conflicting: true,
551
+ commit_message: 'chore: checkpoint feature_a before building reconciliation',
552
+ },
553
+ {
554
+ feature_id: 'feature_a',
555
+ auto_merge_non_conflicting: true,
556
+ commit_message: 'chore: checkpoint feature_a before qa reconciliation',
557
+ },
558
+ ]);
380
559
  });
381
560
 
382
- it('GIVEN_orchestrator_priority_request_WHEN_running_iteration_THEN_reorders_active_feature_execution', async () => {
561
+ it('GIVEN_phase_reconciliation_detects_mainline_conflict_WHEN_planning_starts_THEN_feature_is_skipped_for_that_phase', async () => {
562
+ let featureStatus: string = STATUS.PLANNING;
383
563
  const kernel = {
384
564
  ensureLoaded: vi.fn(async () => undefined),
385
565
  recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
386
566
  acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
567
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
387
568
  pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
388
569
  getRuntimeSessions: vi.fn(async () => ({
389
570
  run_id: 'run:test',
@@ -391,6 +572,16 @@ describe('RunCoordinator iteration behavior', () => {
391
572
  orchestrator_session_id: 'orch:test',
392
573
  feature_sessions: {},
393
574
  })),
575
+ getPolicySnapshot: vi.fn(() => ({
576
+ reactions: {
577
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
578
+ },
579
+ reconciliation: {
580
+ enabled: true,
581
+ check_before_phases: ['planning'],
582
+ auto_merge_non_conflicting: true,
583
+ },
584
+ })),
394
585
  updateState: vi.fn(async () => ({})),
395
586
  checkBudget: vi.fn(async () => ({
396
587
  over_budget: false,
@@ -399,26 +590,18 @@ describe('RunCoordinator iteration behavior', () => {
399
590
  limit_usd: -1,
400
591
  alert_threshold: 0.8,
401
592
  })),
402
- };
403
-
404
- const provider = {
405
- selection: {
406
- provider: 'custom',
407
- model: 'model-test',
408
- },
593
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
594
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
409
595
  };
410
596
 
411
597
  const toolCaller = {
412
- callTool: vi.fn(async (_role: string, toolName: string, _args: Record<string, unknown>) => {
598
+ callTool: vi.fn(async (_role: string, toolName: string) => {
413
599
  if (toolName === TOOLS.FEATURE_STATE_GET) {
414
- return {
415
- ok: true,
416
- data: {
417
- front_matter: {
418
- status: 'planning',
419
- },
420
- },
421
- };
600
+ return { ok: true, data: { front_matter: { status: featureStatus } } };
601
+ }
602
+ if (toolName === TOOLS.REPO_RECONCILE_MAINLINE) {
603
+ featureStatus = STATUS.BLOCKED;
604
+ return { ok: true, data: { status: 'conflict_detected' } };
422
605
  }
423
606
  if (toolName === TOOLS.REPORT_DASHBOARD) {
424
607
  return { ok: true, data: { features: [] } };
@@ -427,6 +610,13 @@ describe('RunCoordinator iteration behavior', () => {
427
610
  }),
428
611
  };
429
612
 
613
+ const provider = {
614
+ selection: {
615
+ provider: 'custom',
616
+ model: 'model-test',
617
+ },
618
+ };
619
+
430
620
  const state = {
431
621
  runId: 'run:test',
432
622
  ownerInstanceId: 'owner:test',
@@ -439,14 +629,7 @@ describe('RunCoordinator iteration behavior', () => {
439
629
  const sessionOrchestrator = {
440
630
  ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
441
631
  cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
442
- initializeFeatureCluster: vi.fn(async (featureId: string) => {
443
- state.sessionsByFeature.set(featureId, {
444
- planner: `${featureId}:planner`,
445
- builder: `${featureId}:builder`,
446
- qa: `${featureId}:qa`,
447
- });
448
- }),
449
- closeFeatureCluster: vi.fn(async (_featureId: string) => undefined),
632
+ initializeFeatureCluster: vi.fn(async () => undefined),
450
633
  reconcileQueuedFeatures: vi.fn(async () => undefined),
451
634
  enforceActiveFeatureInvariant: vi.fn(async () => undefined),
452
635
  };
@@ -454,31 +637,23 @@ describe('RunCoordinator iteration behavior', () => {
454
637
  const planningWaveExecutor = {
455
638
  run: vi.fn(async () => undefined),
456
639
  runPostQaReconciliation: vi.fn(async () => undefined),
640
+ clearFeatureTracking: vi.fn(),
457
641
  };
458
642
 
459
643
  const buildWaveExecutor = {
460
644
  run: vi.fn(async () => undefined),
645
+ clearFeatureTracking: vi.fn(),
461
646
  };
462
647
 
463
648
  const qaWaveExecutor = {
464
649
  run: vi.fn(async () => undefined),
650
+ clearFeatureTracking: vi.fn(),
465
651
  };
466
652
 
467
653
  const leaseHeartbeatService = {
468
654
  renew: vi.fn(async () => undefined),
469
655
  };
470
656
 
471
- const workerDecisionRunner = {
472
- execute: vi.fn(async () => ({
473
- planSubmission: false,
474
- patchApplied: false,
475
- noteLogged: false,
476
- requestHandled: true,
477
- priorityOrder: ['feature_b'],
478
- toolResults: [],
479
- })),
480
- };
481
-
482
657
  const coordinator = new RunCoordinator({
483
658
  kernel: kernel as never,
484
659
  provider: provider as never,
@@ -489,50 +664,32 @@ describe('RunCoordinator iteration behavior', () => {
489
664
  buildWaveExecutor: buildWaveExecutor as never,
490
665
  qaWaveExecutor: qaWaveExecutor as never,
491
666
  leaseHeartbeatService: leaseHeartbeatService as never,
492
- maxActiveFeatures: 2,
667
+ maxActiveFeatures: 1,
493
668
  maxParallelGateRuns: 2,
494
669
  maxIterationsPerPhase: 1,
495
670
  takeoverStaleRun: false,
496
671
  providerConfigRefHash: () => 'hash',
497
- workerDecisionRunner: workerDecisionRunner as never,
498
672
  });
499
673
 
500
- await coordinator.start([{ feature_id: 'feature_a' }, { feature_id: 'feature_b' }]);
674
+ await coordinator.start([{ feature_id: 'feature_a' }]);
501
675
 
502
- expect(planningWaveExecutor.run).toHaveBeenNthCalledWith(1, ['feature_b', 'feature_a']);
676
+ const reconcileCall = toolCaller.callTool.mock.calls.find(
677
+ ([, toolName]) => toolName === TOOLS.REPO_RECONCILE_MAINLINE,
678
+ );
679
+ expect((reconcileCall as unknown[] | undefined)?.[2]).toEqual({
680
+ feature_id: 'feature_a',
681
+ auto_merge_non_conflicting: true,
682
+ commit_message: 'chore: checkpoint feature_a before planning reconciliation',
683
+ });
684
+ expect(planningWaveExecutor.run).toHaveBeenCalledWith([]);
503
685
  });
504
- });
505
686
 
506
- describe('RunCoordinator notification and budget branches', () => {
507
- beforeEach(() => {
508
- vi.clearAllMocks();
509
- });
510
-
511
- function makeCoordinatorDeps(
512
- opts: {
513
- checkBudgetImpl?: () => Promise<{
514
- over_budget: boolean;
515
- alert_threshold_reached: boolean;
516
- current_cost_usd: number;
517
- limit_usd: number;
518
- alert_threshold: number;
519
- }>;
520
- toolCallerImpl?: (role: string, toolName: string) => Promise<{ ok: boolean; data: unknown }>;
521
- notifier?: { notify: ReturnType<typeof vi.fn> };
522
- prMonitor?: { checkAndUpdate: ReturnType<typeof vi.fn> };
523
- issueTracker?: {
524
- getIssue: ReturnType<typeof vi.fn>;
525
- addComment: ReturnType<typeof vi.fn>;
526
- updateIssueStatus: ReturnType<typeof vi.fn>;
527
- };
528
- maxIterationsPerPhase?: number;
529
- maxActiveFeatures?: number;
530
- } = {},
531
- ) {
687
+ it('GIVEN_terminal_active_feature_WHEN_slots_free_up_THEN_promotes_next_queued_feature_deterministically', async () => {
532
688
  const kernel = {
533
689
  ensureLoaded: vi.fn(async () => undefined),
534
690
  recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
535
691
  acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
692
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
536
693
  pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
537
694
  getRuntimeSessions: vi.fn(async () => ({
538
695
  run_id: 'run:test',
@@ -540,16 +697,56 @@ describe('RunCoordinator notification and budget branches', () => {
540
697
  orchestrator_session_id: 'orch:test',
541
698
  feature_sessions: {},
542
699
  })),
700
+ getPolicySnapshot: vi.fn(() => ({
701
+ reactions: {
702
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
703
+ },
704
+ })),
543
705
  updateState: vi.fn(async () => ({})),
544
- checkBudget: opts.checkBudgetImpl
545
- ? vi.fn(opts.checkBudgetImpl)
546
- : vi.fn(async () => ({
547
- over_budget: false,
548
- alert_threshold_reached: false,
549
- current_cost_usd: 0,
550
- limit_usd: -1,
551
- alert_threshold: 0.8,
552
- })),
706
+ checkBudget: vi.fn(async () => ({
707
+ over_budget: false,
708
+ alert_threshold_reached: false,
709
+ current_cost_usd: 0,
710
+ limit_usd: -1,
711
+ alert_threshold: 0.8,
712
+ })),
713
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
714
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
715
+ };
716
+
717
+ const provider = {
718
+ selection: {
719
+ provider: 'custom',
720
+ model: 'model-test',
721
+ },
722
+ };
723
+
724
+ const statusReads = new Map<string, string[]>([
725
+ ['feature_a', ['planning', 'merged']],
726
+ ['feature_b', ['planning']],
727
+ ]);
728
+
729
+ const toolCaller = {
730
+ callTool: vi.fn(async (_role: string, toolName: string, args: Record<string, unknown>) => {
731
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
732
+ const featureId = String(args.feature_id);
733
+ const states = statusReads.get(featureId) ?? ['planning'];
734
+ const status = states.shift() ?? states.at(-1) ?? 'planning';
735
+ statusReads.set(featureId, states.length === 0 ? [status] : states);
736
+ return {
737
+ ok: true,
738
+ data: {
739
+ front_matter: {
740
+ status,
741
+ },
742
+ },
743
+ };
744
+ }
745
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
746
+ return { ok: true, data: { features: [] } };
747
+ }
748
+ return { ok: true, data: {} };
749
+ }),
553
750
  };
554
751
 
555
752
  const state = {
@@ -561,22 +758,19 @@ describe('RunCoordinator notification and budget branches', () => {
561
758
  runMetadata: {},
562
759
  };
563
760
 
564
- const defaultToolCallerImpl = async (_role: string, toolName: string) => {
565
- if (toolName === TOOLS.REPORT_DASHBOARD) {
566
- return { ok: true, data: { features: [] } };
567
- }
568
- return { ok: true, data: { front_matter: { status: STATUS.PLANNING } } };
569
- };
570
-
571
- const toolCaller = {
572
- callTool: opts.toolCallerImpl ? vi.fn(opts.toolCallerImpl) : vi.fn(defaultToolCallerImpl),
573
- };
574
-
575
761
  const sessionOrchestrator = {
576
762
  ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
577
763
  cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
578
- initializeFeatureCluster: vi.fn(async () => undefined),
579
- closeFeatureCluster: vi.fn(async () => undefined),
764
+ initializeFeatureCluster: vi.fn(async (featureId: string) => {
765
+ state.sessionsByFeature.set(featureId, {
766
+ planner: `${featureId}:planner`,
767
+ builder: `${featureId}:builder`,
768
+ qa: `${featureId}:qa`,
769
+ });
770
+ }),
771
+ closeFeatureCluster: vi.fn(async (featureId: string) => {
772
+ state.sessionsByFeature.delete(featureId);
773
+ }),
580
774
  reconcileQueuedFeatures: vi.fn(async () => undefined),
581
775
  enforceActiveFeatureInvariant: vi.fn(async () => undefined),
582
776
  };
@@ -584,11 +778,22 @@ describe('RunCoordinator notification and budget branches', () => {
584
778
  const planningWaveExecutor = {
585
779
  run: vi.fn(async () => undefined),
586
780
  runPostQaReconciliation: vi.fn(async () => undefined),
781
+ clearFeatureTracking: vi.fn(),
782
+ };
783
+
784
+ const buildWaveExecutor = {
785
+ run: vi.fn(async () => undefined),
786
+ clearFeatureTracking: vi.fn(),
787
+ };
788
+
789
+ const qaWaveExecutor = {
790
+ run: vi.fn(async () => undefined),
791
+ clearFeatureTracking: vi.fn(),
792
+ };
793
+
794
+ const leaseHeartbeatService = {
795
+ renew: vi.fn(async () => undefined),
587
796
  };
588
- const buildWaveExecutor = { run: vi.fn(async () => undefined) };
589
- const qaWaveExecutor = { run: vi.fn(async () => undefined) };
590
- const leaseHeartbeatService = { renew: vi.fn(async () => undefined) };
591
- const provider = { selection: { provider: 'test', model: 'test-model' } };
592
797
 
593
798
  const coordinator = new RunCoordinator({
594
799
  kernel: kernel as never,
@@ -600,110 +805,1145 @@ describe('RunCoordinator notification and budget branches', () => {
600
805
  buildWaveExecutor: buildWaveExecutor as never,
601
806
  qaWaveExecutor: qaWaveExecutor as never,
602
807
  leaseHeartbeatService: leaseHeartbeatService as never,
603
- maxActiveFeatures: opts.maxActiveFeatures ?? 5,
808
+ maxActiveFeatures: 1,
604
809
  maxParallelGateRuns: 2,
605
- maxIterationsPerPhase: opts.maxIterationsPerPhase ?? 1,
810
+ maxIterationsPerPhase: 2,
606
811
  takeoverStaleRun: false,
607
812
  providerConfigRefHash: () => 'hash',
608
- notifier: opts.notifier as never,
609
- prMonitor: opts.prMonitor as never,
610
- issueTracker: opts.issueTracker as never,
611
- });
612
-
613
- return { coordinator, kernel, toolCaller, sessionOrchestrator };
614
- }
615
-
616
- it('GIVEN_over_budget_feature_WHEN_pauseOverBudgetFeatures_runs_THEN_feature_removed_and_state_updated', async () => {
617
- const { coordinator, kernel, sessionOrchestrator } = makeCoordinatorDeps({
618
- checkBudgetImpl: async () => ({
619
- over_budget: true,
620
- alert_threshold_reached: false,
621
- current_cost_usd: 5.5,
622
- limit_usd: 5.0,
623
- alert_threshold: 0.8,
624
- }),
625
813
  });
626
814
 
627
- await coordinator.start([{ feature_id: 'feature_x' }]);
815
+ const result = await coordinator.start([
816
+ { feature_id: 'feature_a' },
817
+ { feature_id: 'feature_b' },
818
+ ]);
628
819
 
629
- expect(kernel.updateState).toHaveBeenCalledWith('feature_x', null, expect.any(Function));
630
- const updateStateCall = kernel.updateState.mock.calls[0] as unknown[] | undefined;
631
- expect(updateStateCall).toBeDefined();
632
- const callbackArg = updateStateCall?.[2] as
633
- | ((fm: object) => Promise<{ frontMatter: { status: string } }>)
634
- | undefined;
635
- expect(callbackArg).toBeDefined();
636
- if (callbackArg == null) {
637
- throw new Error('missing updateState callback');
638
- }
639
- const result = await callbackArg({});
640
- expect(result.frontMatter.status).toBe(STATUS.PAUSED_BUDGET);
641
- expect(sessionOrchestrator.closeFeatureCluster).toHaveBeenCalledWith('feature_x');
820
+ expect(result.queue_depth).toBe(0);
821
+ expect(sessionOrchestrator.initializeFeatureCluster).toHaveBeenNthCalledWith(1, 'feature_a');
822
+ expect(sessionOrchestrator.closeFeatureCluster).toHaveBeenCalledWith('feature_a');
823
+ expect(planningWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feature_a');
824
+ expect(buildWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feature_a');
825
+ expect(qaWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feature_a');
826
+ expect(sessionOrchestrator.initializeFeatureCluster).toHaveBeenNthCalledWith(2, 'feature_b');
827
+ expect(planningWaveExecutor.run).toHaveBeenCalledTimes(2);
828
+ expect(planningWaveExecutor.run).toHaveBeenNthCalledWith(1, ['feature_b']);
829
+ expect(planningWaveExecutor.run).toHaveBeenNthCalledWith(2, ['feature_b']);
642
830
  });
643
831
 
644
- it('GIVEN_over_budget_feature_with_notifier_WHEN_pauseOverBudgetFeatures_runs_THEN_notifies_budget_exceeded', async () => {
645
- const notifier = { notify: vi.fn(async () => undefined) };
646
- const { coordinator } = makeCoordinatorDeps({
647
- checkBudgetImpl: async () => ({
648
- over_budget: true,
832
+ it('GIVEN_orchestrator_priority_request_WHEN_running_iteration_THEN_reorders_active_feature_execution', async () => {
833
+ const kernel = {
834
+ ensureLoaded: vi.fn(async () => undefined),
835
+ recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
836
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
837
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
838
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
839
+ getRuntimeSessions: vi.fn(async () => ({
840
+ run_id: 'run:test',
841
+ owner_instance_id: 'owner:test',
842
+ orchestrator_session_id: 'orch:test',
843
+ feature_sessions: {},
844
+ })),
845
+ getPolicySnapshot: vi.fn(() => ({
846
+ reactions: {
847
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
848
+ },
849
+ })),
850
+ updateState: vi.fn(async () => ({})),
851
+ checkBudget: vi.fn(async () => ({
852
+ over_budget: false,
649
853
  alert_threshold_reached: false,
650
- current_cost_usd: 5.5,
651
- limit_usd: 5.0,
854
+ current_cost_usd: 0,
855
+ limit_usd: -1,
652
856
  alert_threshold: 0.8,
653
- }),
654
- notifier,
655
- });
656
-
657
- await coordinator.start([{ feature_id: 'feature_x' }]);
658
-
659
- expect(notifier.notify).toHaveBeenCalledWith(
660
- 'budget_exceeded',
661
- expect.objectContaining({ feature_id: 'feature_x' }),
662
- );
663
- });
664
-
665
- it('GIVEN_alert_threshold_reached_WHEN_pauseOverBudgetFeatures_runs_THEN_notifies_budget_alert', async () => {
666
- const notifier = { notify: vi.fn(async () => undefined) };
667
- const { coordinator } = makeCoordinatorDeps({
668
- checkBudgetImpl: async () => ({
669
- over_budget: false,
670
- alert_threshold_reached: true,
671
- current_cost_usd: 4.5,
672
- limit_usd: 5.0,
673
- alert_threshold: 0.9,
674
- }),
675
- notifier,
676
- });
677
-
678
- await coordinator.start([{ feature_id: 'feature_x' }]);
857
+ })),
858
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
859
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
860
+ };
679
861
 
680
- expect(notifier.notify).toHaveBeenCalledWith(
681
- 'budget_alert',
682
- expect.objectContaining({ feature_id: 'feature_x' }),
683
- );
684
- });
862
+ const provider = {
863
+ selection: {
864
+ provider: 'custom',
865
+ model: 'model-test',
866
+ },
867
+ };
685
868
 
686
- it('GIVEN_feature_transitions_to_BLOCKED_from_PLANNING_WHEN_notifyStatusTransitions_THEN_notifies_collision_detected', async () => {
687
- const notifier = { notify: vi.fn(async () => undefined) };
688
- let callCount = 0;
689
- const { coordinator } = makeCoordinatorDeps({
690
- // calls: 1=init_rebalance, 2=iter1_notify, 3=iter1_rebalance, 4=iter2_notify, 5=iter2_rebalance
691
- toolCallerImpl: async (_role, toolName) => {
869
+ const toolCaller = {
870
+ callTool: vi.fn(async (_role: string, toolName: string, _args: Record<string, unknown>) => {
692
871
  if (toolName === TOOLS.FEATURE_STATE_GET) {
693
- callCount += 1;
694
- const status = callCount <= 3 ? STATUS.PLANNING : STATUS.BLOCKED;
695
- return { ok: true, data: { front_matter: { status } } };
872
+ return {
873
+ ok: true,
874
+ data: {
875
+ front_matter: {
876
+ status: 'planning',
877
+ },
878
+ },
879
+ };
696
880
  }
697
881
  if (toolName === TOOLS.REPORT_DASHBOARD) {
698
882
  return { ok: true, data: { features: [] } };
699
883
  }
700
884
  return { ok: true, data: {} };
701
- },
702
- notifier,
703
- maxIterationsPerPhase: 2,
704
- });
705
-
706
- await coordinator.start([{ feature_id: 'feature_x' }]);
885
+ }),
886
+ };
887
+
888
+ const state = {
889
+ runId: 'run:test',
890
+ ownerInstanceId: 'owner:test',
891
+ orchestratorSessionId: 'orch:test',
892
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
893
+ queue: [] as Array<{ feature_id: string }>,
894
+ runMetadata: {},
895
+ };
896
+
897
+ const sessionOrchestrator = {
898
+ ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
899
+ cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
900
+ initializeFeatureCluster: vi.fn(async (featureId: string) => {
901
+ state.sessionsByFeature.set(featureId, {
902
+ planner: `${featureId}:planner`,
903
+ builder: `${featureId}:builder`,
904
+ qa: `${featureId}:qa`,
905
+ });
906
+ }),
907
+ closeFeatureCluster: vi.fn(async (_featureId: string) => undefined),
908
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
909
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
910
+ };
911
+
912
+ const planningWaveExecutor = {
913
+ run: vi.fn(async () => undefined),
914
+ runPostQaReconciliation: vi.fn(async () => undefined),
915
+ };
916
+
917
+ const buildWaveExecutor = {
918
+ run: vi.fn(async () => undefined),
919
+ };
920
+
921
+ const qaWaveExecutor = {
922
+ run: vi.fn(async () => undefined),
923
+ };
924
+
925
+ const leaseHeartbeatService = {
926
+ renew: vi.fn(async () => undefined),
927
+ };
928
+
929
+ const workerDecisionRunner = {
930
+ execute: vi.fn(async () => ({
931
+ planSubmission: false,
932
+ patchApplied: false,
933
+ noteLogged: false,
934
+ requestHandled: true,
935
+ priorityOrder: ['feature_b'],
936
+ toolResults: [],
937
+ })),
938
+ };
939
+
940
+ const coordinator = new RunCoordinator({
941
+ kernel: kernel as never,
942
+ provider: provider as never,
943
+ toolCaller: toolCaller as never,
944
+ state: state as never,
945
+ sessionOrchestrator: sessionOrchestrator as never,
946
+ planningWaveExecutor: planningWaveExecutor as never,
947
+ buildWaveExecutor: buildWaveExecutor as never,
948
+ qaWaveExecutor: qaWaveExecutor as never,
949
+ leaseHeartbeatService: leaseHeartbeatService as never,
950
+ maxActiveFeatures: 2,
951
+ maxParallelGateRuns: 2,
952
+ maxIterationsPerPhase: 1,
953
+ takeoverStaleRun: false,
954
+ providerConfigRefHash: () => 'hash',
955
+ workerDecisionRunner: workerDecisionRunner as never,
956
+ });
957
+
958
+ await coordinator.start([{ feature_id: 'feature_a' }, { feature_id: 'feature_b' }]);
959
+
960
+ expect(planningWaveExecutor.run).toHaveBeenNthCalledWith(1, ['feature_b', 'feature_a']);
961
+ });
962
+ });
963
+
964
+ describe('RunCoordinator enrollment checkpoints', () => {
965
+ it('runs a planning mini-pass when planning work is admitted during the build cursor', async () => {
966
+ const kernel = {
967
+ getRuntimeSessions: vi.fn(async () => ({
968
+ feature_sessions: {},
969
+ })),
970
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
971
+ updateExecutionRequestProgress: vi.fn(async () => ({
972
+ data: { updated: true, progress: null },
973
+ })),
974
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
975
+ };
976
+ const state = {
977
+ runId: 'run:test',
978
+ ownerInstanceId: 'owner:test',
979
+ orchestratorSessionId: 'orch:test',
980
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>([
981
+ ['feature_build', { planner: 'planner:build', builder: 'builder:build', qa: 'qa:build' }],
982
+ ]),
983
+ queue: [] as Array<{ feature_id: string }>,
984
+ runMetadata: {},
985
+ };
986
+ const planningWaveExecutor = {
987
+ run: vi.fn(async () => undefined),
988
+ runPostQaReconciliation: vi.fn(async () => undefined),
989
+ clearFeatureTracking: vi.fn(),
990
+ };
991
+ const buildWaveExecutor = {
992
+ run: vi.fn(async () => undefined),
993
+ clearFeatureTracking: vi.fn(),
994
+ };
995
+ const qaWaveExecutor = {
996
+ run: vi.fn(async () => undefined),
997
+ clearFeatureTracking: vi.fn(),
998
+ };
999
+ const sessionOrchestrator = {
1000
+ closeFeatureCluster: vi.fn(async () => undefined),
1001
+ initializeFeatureCluster: vi.fn(async (featureId: string) => {
1002
+ state.sessionsByFeature.set(featureId, {
1003
+ planner: `${featureId}:planner`,
1004
+ builder: `${featureId}:builder`,
1005
+ qa: `${featureId}:qa`,
1006
+ });
1007
+ }),
1008
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
1009
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
1010
+ };
1011
+ const toolCaller = {
1012
+ callTool: vi.fn(async (_role: string, toolName: string, args?: { feature_id?: string }) => {
1013
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
1014
+ const featureId = args?.feature_id;
1015
+ return {
1016
+ ok: true,
1017
+ data: {
1018
+ front_matter: {
1019
+ status: featureId === 'feature_planning' ? STATUS.PLANNING : STATUS.BUILDING,
1020
+ version: 1,
1021
+ },
1022
+ },
1023
+ };
1024
+ }
1025
+ return { ok: true, data: {} };
1026
+ }),
1027
+ };
1028
+ const executionEnrollmentService = {
1029
+ start: vi.fn(async () => undefined),
1030
+ stop: vi.fn(async () => undefined),
1031
+ notifyNewRequestHint: vi.fn(async () => undefined),
1032
+ noteSafeCheckpoint: vi.fn(async () => undefined),
1033
+ hasPendingWork: vi.fn(() => true),
1034
+ drainReadyDecisions: vi
1035
+ .fn()
1036
+ .mockResolvedValueOnce([
1037
+ {
1038
+ request: {
1039
+ request_id: 'exec_req_001',
1040
+ request_type: 'add_features',
1041
+ status: 'pending',
1042
+ requested_at: new Date(0).toISOString(),
1043
+ processed_at: null,
1044
+ requested_by: 'cli:add',
1045
+ operation_id: 'op:add:feature_planning',
1046
+ features: [
1047
+ {
1048
+ feature_id: 'feature_planning',
1049
+ spec_path: '.aop/features/feature_planning/spec.md',
1050
+ },
1051
+ ],
1052
+ progress: {
1053
+ detected_at: new Date(0).toISOString(),
1054
+ last_evaluated_at: new Date(0).toISOString(),
1055
+ queued: [],
1056
+ skipped: [],
1057
+ deferred: [],
1058
+ },
1059
+ outcome: null,
1060
+ },
1061
+ decisions: [
1062
+ {
1063
+ request_id: 'exec_req_001',
1064
+ feature_id: 'feature_planning',
1065
+ disposition: 'queue_now',
1066
+ reason: 'capacity_available',
1067
+ },
1068
+ ],
1069
+ progress: {
1070
+ detected_at: new Date(0).toISOString(),
1071
+ last_evaluated_at: new Date(0).toISOString(),
1072
+ queued: [],
1073
+ skipped: [],
1074
+ deferred: [],
1075
+ },
1076
+ },
1077
+ ])
1078
+ .mockResolvedValue([]),
1079
+ };
1080
+
1081
+ const coordinator = new RunCoordinator({
1082
+ kernel: kernel as never,
1083
+ provider: { selection: { provider: 'custom', model: 'test-model' } } as never,
1084
+ toolCaller: toolCaller as never,
1085
+ state: state as never,
1086
+ sessionOrchestrator: sessionOrchestrator as never,
1087
+ planningWaveExecutor: planningWaveExecutor as never,
1088
+ buildWaveExecutor: buildWaveExecutor as never,
1089
+ qaWaveExecutor: qaWaveExecutor as never,
1090
+ leaseHeartbeatService: { renew: vi.fn(async () => undefined) } as never,
1091
+ maxActiveFeatures: 2,
1092
+ maxParallelGateRuns: 1,
1093
+ maxIterationsPerPhase: 1,
1094
+ takeoverStaleRun: false,
1095
+ providerConfigRefHash: () => 'hash',
1096
+ executionEnrollmentService: executionEnrollmentService as never,
1097
+ });
1098
+
1099
+ (
1100
+ coordinator as unknown as {
1101
+ currentActiveFeatureIds: string[];
1102
+ currentPhaseCursor: 'building';
1103
+ }
1104
+ ).currentActiveFeatureIds = ['feature_build'];
1105
+ (
1106
+ coordinator as unknown as {
1107
+ currentActiveFeatureIds: string[];
1108
+ currentPhaseCursor: 'building';
1109
+ }
1110
+ ).currentPhaseCursor = 'building';
1111
+
1112
+ await coordinator.handleSafeCheckpoint();
1113
+
1114
+ expect(sessionOrchestrator.initializeFeatureCluster).toHaveBeenCalledWith('feature_planning');
1115
+ expect(planningWaveExecutor.run).toHaveBeenCalledWith(['feature_build', 'feature_planning']);
1116
+ expect(buildWaveExecutor.run).not.toHaveBeenCalled();
1117
+ expect(qaWaveExecutor.run).not.toHaveBeenCalled();
1118
+ expect(kernel.markExecutionRequestProcessed).toHaveBeenCalledWith(
1119
+ 'run:test',
1120
+ 'owner:test',
1121
+ 'exec_req_001',
1122
+ 'applied',
1123
+ {
1124
+ queued: ['feature_planning'],
1125
+ skipped: [],
1126
+ },
1127
+ );
1128
+ });
1129
+
1130
+ it('skips safe checkpoints when enrollment has no pending work', async () => {
1131
+ const executionEnrollmentService = {
1132
+ start: vi.fn(async () => undefined),
1133
+ stop: vi.fn(async () => undefined),
1134
+ notifyNewRequestHint: vi.fn(async () => undefined),
1135
+ noteSafeCheckpoint: vi.fn(async () => undefined),
1136
+ hasPendingWork: vi.fn(() => false),
1137
+ drainReadyDecisions: vi.fn(async () => []),
1138
+ };
1139
+
1140
+ const coordinator = new RunCoordinator({
1141
+ kernel: {
1142
+ getRuntimeSessions: vi.fn(async () => ({ feature_sessions: {} })),
1143
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
1144
+ } as never,
1145
+ provider: { selection: { provider: 'custom', model: 'test-model' } } as never,
1146
+ toolCaller: { callTool: vi.fn(async () => ({ ok: true, data: {} })) } as never,
1147
+ state: {
1148
+ runId: 'run:test',
1149
+ ownerInstanceId: 'owner:test',
1150
+ orchestratorSessionId: 'orch:test',
1151
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
1152
+ queue: [] as Array<{ feature_id: string }>,
1153
+ runMetadata: {},
1154
+ } as never,
1155
+ sessionOrchestrator: {
1156
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
1157
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
1158
+ } as never,
1159
+ planningWaveExecutor: { run: vi.fn(async () => undefined) } as never,
1160
+ buildWaveExecutor: { run: vi.fn(async () => undefined) } as never,
1161
+ qaWaveExecutor: { run: vi.fn(async () => undefined) } as never,
1162
+ leaseHeartbeatService: { renew: vi.fn(async () => undefined) } as never,
1163
+ maxActiveFeatures: 1,
1164
+ maxParallelGateRuns: 1,
1165
+ maxIterationsPerPhase: 1,
1166
+ takeoverStaleRun: false,
1167
+ providerConfigRefHash: () => 'hash',
1168
+ executionEnrollmentService: executionEnrollmentService as never,
1169
+ });
1170
+
1171
+ await coordinator.handleSafeCheckpoint();
1172
+
1173
+ expect(executionEnrollmentService.noteSafeCheckpoint).not.toHaveBeenCalled();
1174
+ expect(executionEnrollmentService.drainReadyDecisions).not.toHaveBeenCalled();
1175
+ });
1176
+ });
1177
+
1178
+ describe('RunCoordinator helpers', () => {
1179
+ const baseDeps = () => {
1180
+ const toolCaller = {
1181
+ callTool: vi.fn(async (_role: string, toolName: string) => {
1182
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
1183
+ return { ok: true, data: { front_matter: { status: STATUS.PLANNING } } };
1184
+ }
1185
+ return { ok: true, data: {} };
1186
+ }),
1187
+ };
1188
+
1189
+ const state = {
1190
+ runId: 'run:test',
1191
+ ownerInstanceId: 'owner:test',
1192
+ orchestratorSessionId: 'orch',
1193
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
1194
+ queue: [] as Array<{ feature_id: string }>,
1195
+ runMetadata: {},
1196
+ };
1197
+
1198
+ const sessionOrchestrator = {
1199
+ closeFeatureCluster: vi.fn(async () => undefined),
1200
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
1201
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
1202
+ initializeFeatureCluster: vi.fn(async () => undefined),
1203
+ };
1204
+
1205
+ const kernel = {
1206
+ ensureLoaded: vi.fn(async () => undefined),
1207
+ recoverFromState: vi.fn(async () => undefined),
1208
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
1209
+ releaseRunLease: vi.fn(async () => ({})),
1210
+ pruneFeatureSessionAssignments: vi.fn(async () => ({})),
1211
+ getRuntimeSessions: vi.fn(async () => ({})),
1212
+ getPolicySnapshot: vi.fn(() => ({
1213
+ reactions: {
1214
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
1215
+ },
1216
+ })),
1217
+ checkBudget: vi.fn(async () => ({
1218
+ over_budget: false,
1219
+ alert_threshold_reached: false,
1220
+ current_cost_usd: 1,
1221
+ limit_usd: 10,
1222
+ alert_threshold: 0.8,
1223
+ })),
1224
+ updateState: vi.fn(async () => ({})),
1225
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
1226
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
1227
+ };
1228
+
1229
+ const noopWave = { run: vi.fn(async () => undefined), clearFeatureTracking: vi.fn() };
1230
+ const planningWaveExecutor = {
1231
+ ...noopWave,
1232
+ runPostQaReconciliation: vi.fn(async () => undefined),
1233
+ };
1234
+ const buildWaveExecutor = { ...noopWave };
1235
+ const qaWaveExecutor = { ...noopWave };
1236
+ const leaseHeartbeatService = { renew: vi.fn(async () => undefined) };
1237
+
1238
+ const coordinator = new RunCoordinator({
1239
+ kernel: kernel as never,
1240
+ provider: { selection: { provider: 'p', model: 'm' } } as never,
1241
+ toolCaller: toolCaller as never,
1242
+ state: state as never,
1243
+ sessionOrchestrator: sessionOrchestrator as never,
1244
+ planningWaveExecutor: planningWaveExecutor as never,
1245
+ buildWaveExecutor: buildWaveExecutor as never,
1246
+ qaWaveExecutor: qaWaveExecutor as never,
1247
+ leaseHeartbeatService: leaseHeartbeatService as never,
1248
+ maxActiveFeatures: 2,
1249
+ maxParallelGateRuns: 1,
1250
+ maxIterationsPerPhase: 1,
1251
+ takeoverStaleRun: false,
1252
+ providerConfigRefHash: () => 'hash',
1253
+ notifier: { notify: vi.fn(async () => undefined) } as never,
1254
+ prMonitor: {
1255
+ checkAndUpdate: vi.fn(async () => undefined),
1256
+ } as never,
1257
+ issueTracker: {
1258
+ addComment: vi.fn(async () => undefined),
1259
+ updateIssueStatus: vi.fn(async () => undefined),
1260
+ getIssue: vi.fn(async () => ({
1261
+ id: '123',
1262
+ title: 'title',
1263
+ body: '',
1264
+ status: 'open',
1265
+ url: 'u',
1266
+ })),
1267
+ } as never,
1268
+ workerDecisionRunner: {
1269
+ execute: vi.fn(async () => ({ priorityOrder: ['b', 'a'] })),
1270
+ } as never,
1271
+ });
1272
+
1273
+ return {
1274
+ coordinator,
1275
+ kernel,
1276
+ toolCaller,
1277
+ sessionOrchestrator,
1278
+ state,
1279
+ planningWaveExecutor,
1280
+ buildWaveExecutor,
1281
+ qaWaveExecutor,
1282
+ } as const;
1283
+ };
1284
+
1285
+ it('GIVEN_over_budget_and_alert_features_WHEN_pausing_THEN_notifies_and_removes_over_budget', async () => {
1286
+ const { coordinator, kernel, sessionOrchestrator } = baseDeps();
1287
+ vi.mocked(kernel.checkBudget).mockResolvedValueOnce({
1288
+ over_budget: true,
1289
+ alert_threshold_reached: false,
1290
+ current_cost_usd: 11,
1291
+ limit_usd: 10,
1292
+ alert_threshold: 0.8,
1293
+ } as any);
1294
+ vi.mocked(kernel.checkBudget).mockResolvedValueOnce({
1295
+ over_budget: false,
1296
+ alert_threshold_reached: true,
1297
+ current_cost_usd: 8,
1298
+ limit_usd: 10,
1299
+ alert_threshold: 0.8,
1300
+ } as any);
1301
+
1302
+ const remaining = await (coordinator as any).pauseOverBudgetFeatures(['over', 'warn']);
1303
+
1304
+ expect(remaining).toEqual(['warn']);
1305
+ expect(sessionOrchestrator.closeFeatureCluster).toHaveBeenCalledWith('over');
1306
+ expect(kernel.updateState).toHaveBeenCalledWith('over', null, expect.any(Function));
1307
+ });
1308
+
1309
+ it('GIVEN_worker_priority_order_WHEN_apply_prioritization_THEN_reorders_active_features', async () => {
1310
+ const { coordinator } = baseDeps();
1311
+
1312
+ const reordered = await (coordinator as any).applyOrchestratorPrioritization(
1313
+ ['a', 'b', 'c'],
1314
+ 1,
1315
+ );
1316
+
1317
+ expect(reordered).toEqual(['b', 'a', 'c']);
1318
+ });
1319
+
1320
+ it('GIVEN_status_changes_WHEN_notifyStatusTransitions_THEN_triggers_notifier_and_issue_tracker', async () => {
1321
+ const { coordinator, toolCaller } = baseDeps();
1322
+ vi.mocked(toolCaller.callTool).mockImplementation(async (_role: string, toolName: string) => {
1323
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
1324
+ return {
1325
+ ok: true,
1326
+ data: {
1327
+ front_matter: {
1328
+ status: STATUS.READY_TO_MERGE,
1329
+ gates: { fast: 'pass', full: 'pass' },
1330
+ version: 3,
1331
+ worktree_branch: 'feat/123',
1332
+ pr: {},
1333
+ },
1334
+ },
1335
+ } as any;
1336
+ }
1337
+ return { ok: true, data: {} } as any;
1338
+ });
1339
+
1340
+ await (coordinator as any).notifyStatusTransitions(['feature_notify']);
1341
+
1342
+ const prMonitor = (coordinator as any).prMonitor;
1343
+ const notifier = (coordinator as any).notifier;
1344
+ const issueTracker = (coordinator as any).issueTracker;
1345
+
1346
+ expect(notifier.notify).toHaveBeenCalledWith('ready_to_merge', expect.any(Object));
1347
+ expect(prMonitor.checkAndUpdate).toHaveBeenCalledWith('feature_notify', 'feat/123');
1348
+ expect(issueTracker.addComment).toHaveBeenCalled();
1349
+ expect(issueTracker.updateIssueStatus).toHaveBeenCalledWith('notify', STATUS.READY_TO_MERGE);
1350
+ });
1351
+
1352
+ it('GIVEN_feature_tracking_WHEN_clearWaveTracking_called_THEN_invokes_executor_clear_methods', () => {
1353
+ const { coordinator, planningWaveExecutor, buildWaveExecutor, qaWaveExecutor } = baseDeps();
1354
+
1355
+ (coordinator as any).clearWaveTracking('feat_clear');
1356
+
1357
+ expect(planningWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feat_clear');
1358
+ expect(buildWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feat_clear');
1359
+ expect(qaWaveExecutor.clearFeatureTracking).toHaveBeenCalledWith('feat_clear');
1360
+ });
1361
+
1362
+ it('GIVEN_tool_call_failure_WHEN_readFeatureStateSnapshot_runs_THEN_returns_null_status', async () => {
1363
+ const { coordinator } = baseDeps();
1364
+ vi.spyOn(coordinator as any, 'toolCaller', 'get').mockReturnValue({
1365
+ callTool: vi.fn(async () => {
1366
+ throw new Error('fail');
1367
+ }),
1368
+ });
1369
+
1370
+ const snapshot = await (coordinator as any).readFeatureStateSnapshot('feat_missing');
1371
+ expect(snapshot).toEqual({
1372
+ status: null,
1373
+ statusReason: null,
1374
+ branch: 'feat_missing',
1375
+ hasPr: false,
1376
+ });
1377
+ });
1378
+
1379
+ it('GIVEN_multiple_scoped_features_WHEN_rebalanceActiveFeatures_runs_THEN_sorts_with_comparator', async () => {
1380
+ const { coordinator, state, kernel, sessionOrchestrator } = baseDeps();
1381
+ state.queue = [{ feature_id: 'b' }, { feature_id: 'a' }];
1382
+ vi.mocked(kernel.checkBudget).mockResolvedValue({
1383
+ over_budget: false,
1384
+ alert_threshold_reached: false,
1385
+ current_cost_usd: 1,
1386
+ limit_usd: 10,
1387
+ alert_threshold: 0.8,
1388
+ } as any);
1389
+ vi.mocked(sessionOrchestrator.initializeFeatureCluster).mockResolvedValue();
1390
+
1391
+ const result = await (coordinator as any).rebalanceActiveFeatures([]);
1392
+ expect(result).toEqual(['a', 'b']);
1393
+ });
1394
+
1395
+ it('GIVEN_background_renewal_WHEN_error_occurs_THEN_throwIfBackgroundLeaseRenewalFailed_throws', async () => {
1396
+ vi.useFakeTimers();
1397
+ const { coordinator } = baseDeps();
1398
+ (coordinator as any).leaseHeartbeatService.renew = vi.fn(async () => {
1399
+ throw new Error('lease-fail');
1400
+ });
1401
+
1402
+ const originalInterval = (RunCoordinator as any).BACKGROUND_LEASE_RENEW_INTERVAL_MS;
1403
+ (RunCoordinator as any).BACKGROUND_LEASE_RENEW_INTERVAL_MS = 1;
1404
+
1405
+ const background = (coordinator as any).startBackgroundLeaseRenewal();
1406
+ await vi.advanceTimersByTimeAsync(2);
1407
+ expect(background.error()).toBeInstanceOf(Error);
1408
+ await background.stop();
1409
+ expect(() =>
1410
+ (coordinator as any).throwIfBackgroundLeaseRenewalFailed(background.error()),
1411
+ ).toThrow('lease-fail');
1412
+ (RunCoordinator as any).BACKGROUND_LEASE_RENEW_INTERVAL_MS = originalInterval;
1413
+ vi.useRealTimers();
1414
+ });
1415
+
1416
+ it('GIVEN_static_asRecord_WHEN_non_object_passed_THEN_returns_empty_object', () => {
1417
+ const result = (RunCoordinator as any).asRecord(null);
1418
+ expect(result).toEqual({});
1419
+ });
1420
+
1421
+ it('GIVEN_feature_status_lookup_WHEN_readFeatureStatus_called_THEN_returns_status_or_null', async () => {
1422
+ const { coordinator, toolCaller } = baseDeps();
1423
+ vi.mocked(toolCaller.callTool).mockRejectedValueOnce(new Error('fail'));
1424
+ const nullStatus = await (coordinator as any).readFeatureStatus('missing');
1425
+ expect(nullStatus).toBeNull();
1426
+
1427
+ vi.mocked(toolCaller.callTool).mockResolvedValue({
1428
+ ok: true,
1429
+ data: { front_matter: { status: STATUS.BUILDING } },
1430
+ } as any);
1431
+ const status = await (coordinator as any).readFeatureStatus('feat');
1432
+ expect(status).toBe(STATUS.BUILDING);
1433
+ });
1434
+
1435
+ it('GIVEN_issue_tracker_errors_WHEN_syncIssueTrackerStatus_runs_THEN_catches_failures', async () => {
1436
+ const { coordinator } = baseDeps();
1437
+ (coordinator as any).issueTracker = {
1438
+ getIssue: vi.fn(async () => {
1439
+ throw new Error('fail');
1440
+ }),
1441
+ addComment: vi.fn(async () => {
1442
+ throw new Error('comment-fail');
1443
+ }),
1444
+ updateIssueStatus: vi.fn(async () => {
1445
+ throw new Error('status-fail');
1446
+ }),
1447
+ };
1448
+
1449
+ await (coordinator as any).syncIssueTrackerStatus('feature_issue', STATUS.PLANNING, undefined);
1450
+ expect((coordinator as any).issueTracker.getIssue).toHaveBeenCalled();
1451
+ expect((coordinator as any).issueTracker.addComment).toHaveBeenCalled();
1452
+ expect((coordinator as any).issueTracker.updateIssueStatus).toHaveBeenCalled();
1453
+ });
1454
+
1455
+ it('GIVEN_queue_entries_WHEN_applyOrchestratorPrioritization_runs_THEN_maps_queue_and_sorts_remaining', async () => {
1456
+ const { coordinator, state } = baseDeps();
1457
+ state.queue = [{ feature_id: 'z' }, { feature_id: 'y' }];
1458
+ const result = await (coordinator as any).applyOrchestratorPrioritization(['x', 'y', 'z'], 2);
1459
+ expect(result.slice(-2)).toEqual(['y', 'z']);
1460
+ });
1461
+
1462
+ it('GIVEN_enrollment_batch_with_races_and_deferred_work_WHEN_applying_THEN_updates_progress_instead_of_marking_processed', async () => {
1463
+ const { coordinator, kernel, state } = baseDeps();
1464
+ (kernel as any).updateExecutionRequestProgress = vi.fn(async () => ({
1465
+ data: { updated: true },
1466
+ }));
1467
+ state.sessionsByFeature.set('feature_active', {
1468
+ planner: 'planner:active',
1469
+ builder: 'builder:active',
1470
+ qa: 'qa:active',
1471
+ });
1472
+ state.queue = [{ feature_id: 'feature_queued' }];
1473
+ vi.mocked(kernel.getRuntimeSessions).mockResolvedValue({
1474
+ feature_sessions: {
1475
+ feature_assigned: {
1476
+ planner_session_id: 'planner:assigned',
1477
+ builder_session_id: 'builder:assigned',
1478
+ qa_session_id: 'qa:assigned',
1479
+ },
1480
+ },
1481
+ } as any);
1482
+ const readFeatureStatusMock = (featureId: string): Promise<string> =>
1483
+ Promise.resolve(featureId === 'feature_terminal' ? STATUS.MERGED : STATUS.PLANNING);
1484
+ (coordinator as any).readFeatureStatus = readFeatureStatusMock;
1485
+
1486
+ const queued = await (coordinator as any).applyEnrollmentDecisionBatch({
1487
+ request: {
1488
+ request_id: 'exec_req_002',
1489
+ request_type: 'add_features',
1490
+ status: 'pending',
1491
+ requested_at: new Date(0).toISOString(),
1492
+ processed_at: null,
1493
+ requested_by: 'cli:add',
1494
+ operation_id: 'op:add:mixed',
1495
+ features: [
1496
+ { feature_id: 'feature_active', spec_path: '.aop/features/feature_active/spec.md' },
1497
+ { feature_id: 'feature_queued', spec_path: '.aop/features/feature_queued/spec.md' },
1498
+ { feature_id: 'feature_assigned', spec_path: '.aop/features/feature_assigned/spec.md' },
1499
+ { feature_id: 'feature_terminal', spec_path: '.aop/features/feature_terminal/spec.md' },
1500
+ ],
1501
+ progress: null,
1502
+ outcome: null,
1503
+ },
1504
+ decisions: [
1505
+ {
1506
+ request_id: 'exec_req_002',
1507
+ feature_id: 'feature_active',
1508
+ disposition: 'queue_now',
1509
+ reason: 'capacity_available',
1510
+ },
1511
+ {
1512
+ request_id: 'exec_req_002',
1513
+ feature_id: 'feature_queued',
1514
+ disposition: 'queue_now',
1515
+ reason: 'capacity_available',
1516
+ },
1517
+ {
1518
+ request_id: 'exec_req_002',
1519
+ feature_id: 'feature_assigned',
1520
+ disposition: 'queue_now',
1521
+ reason: 'capacity_available',
1522
+ },
1523
+ {
1524
+ request_id: 'exec_req_002',
1525
+ feature_id: 'feature_terminal',
1526
+ disposition: 'queue_now',
1527
+ reason: 'capacity_available',
1528
+ },
1529
+ ],
1530
+ progress: {
1531
+ detected_at: '2026-03-16T00:00:00.000Z',
1532
+ last_evaluated_at: '2026-03-16T00:00:01.000Z',
1533
+ queued: [],
1534
+ skipped: [],
1535
+ deferred: [{ feature_id: 'feature_waiting', reason: 'capacity_full' }],
1536
+ },
1537
+ });
1538
+
1539
+ expect(queued).toEqual([]);
1540
+ expect((kernel as any).updateExecutionRequestProgress).toHaveBeenCalledWith(
1541
+ 'run:test',
1542
+ 'owner:test',
1543
+ 'exec_req_002',
1544
+ expect.objectContaining({
1545
+ skipped: [
1546
+ { feature_id: 'feature_active', reason: 'already_active' },
1547
+ { feature_id: 'feature_assigned', reason: 'already_assigned_cluster' },
1548
+ { feature_id: 'feature_queued', reason: 'already_queued' },
1549
+ { feature_id: 'feature_terminal', reason: 'terminal_status' },
1550
+ ],
1551
+ deferred: [{ feature_id: 'feature_waiting', reason: 'capacity_full' }],
1552
+ }),
1553
+ );
1554
+ expect(kernel.markExecutionRequestProcessed).not.toHaveBeenCalled();
1555
+ });
1556
+
1557
+ it('GIVEN_forced_build_and_qa_phases_WHEN_running_checkpoint_mini_pass_THEN_dispatches_the_matching_wave', async () => {
1558
+ const planningRun = vi.fn(async () => undefined);
1559
+ const buildRun = vi.fn(async () => undefined);
1560
+ const qaRun = vi.fn(async () => undefined);
1561
+ const coordinator = new RunCoordinator({
1562
+ kernel: {
1563
+ ensureLoaded: vi.fn(async () => undefined),
1564
+ recoverFromState: vi.fn(async () => undefined),
1565
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
1566
+ releaseRunLease: vi.fn(async () => ({})),
1567
+ pruneFeatureSessionAssignments: vi.fn(async () => ({})),
1568
+ getRuntimeSessions: vi.fn(async () => ({})),
1569
+ getPolicySnapshot: vi.fn(() => ({
1570
+ reactions: {
1571
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
1572
+ },
1573
+ })),
1574
+ checkBudget: vi.fn(async () => ({
1575
+ over_budget: false,
1576
+ alert_threshold_reached: false,
1577
+ current_cost_usd: 0,
1578
+ limit_usd: -1,
1579
+ alert_threshold: 0.8,
1580
+ })),
1581
+ updateState: vi.fn(async () => ({})),
1582
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
1583
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
1584
+ } as never,
1585
+ provider: { selection: { provider: 'p', model: 'm' } } as never,
1586
+ toolCaller: { callTool: vi.fn(async () => ({ ok: true, data: {} })) } as never,
1587
+ state: {
1588
+ runId: 'run:test',
1589
+ ownerInstanceId: 'owner:test',
1590
+ orchestratorSessionId: 'orch',
1591
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
1592
+ queue: [] as Array<{ feature_id: string }>,
1593
+ runMetadata: {},
1594
+ } as never,
1595
+ sessionOrchestrator: {
1596
+ closeFeatureCluster: vi.fn(async () => undefined),
1597
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
1598
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
1599
+ initializeFeatureCluster: vi.fn(async () => undefined),
1600
+ } as never,
1601
+ planningWaveExecutor: {
1602
+ run: planningRun,
1603
+ runPostQaReconciliation: vi.fn(async () => undefined),
1604
+ clearFeatureTracking: vi.fn(),
1605
+ } as never,
1606
+ buildWaveExecutor: { run: buildRun, clearFeatureTracking: vi.fn() } as never,
1607
+ qaWaveExecutor: { run: qaRun, clearFeatureTracking: vi.fn() } as never,
1608
+ leaseHeartbeatService: { renew: vi.fn(async () => undefined) } as never,
1609
+ maxActiveFeatures: 2,
1610
+ maxParallelGateRuns: 1,
1611
+ maxIterationsPerPhase: 1,
1612
+ takeoverStaleRun: false,
1613
+ providerConfigRefHash: () => 'hash',
1614
+ });
1615
+ (coordinator as any).currentActiveFeatureIds = ['feature_a', 'feature_b'];
1616
+
1617
+ vi.spyOn(coordinator as any, 'resolveCheckpointMiniPassPhase').mockResolvedValueOnce(
1618
+ 'building',
1619
+ );
1620
+ await (coordinator as any).runCheckpointMiniPass(['feature_a']);
1621
+
1622
+ vi.spyOn(coordinator as any, 'resolveCheckpointMiniPassPhase').mockResolvedValueOnce('qa');
1623
+ await (coordinator as any).runCheckpointMiniPass(['feature_b']);
1624
+
1625
+ vi.spyOn(coordinator as any, 'resolveCheckpointMiniPassPhase').mockResolvedValueOnce(null);
1626
+ await (coordinator as any).runCheckpointMiniPass(['feature_c']);
1627
+
1628
+ expect(planningRun).not.toHaveBeenCalled();
1629
+ expect(buildRun).toHaveBeenCalledWith(['feature_a', 'feature_b'], 1);
1630
+ expect(qaRun).toHaveBeenCalledWith(['feature_a', 'feature_b'], 1);
1631
+ });
1632
+ });
1633
+
1634
+ describe('RunCoordinator notification and budget branches', () => {
1635
+ beforeEach(() => {
1636
+ vi.clearAllMocks();
1637
+ });
1638
+
1639
+ function makeCoordinatorDeps(
1640
+ opts: {
1641
+ checkBudgetImpl?: () => Promise<{
1642
+ over_budget: boolean;
1643
+ alert_threshold_reached: boolean;
1644
+ current_cost_usd: number;
1645
+ limit_usd: number;
1646
+ alert_threshold: number;
1647
+ }>;
1648
+ toolCallerImpl?: (
1649
+ role: string,
1650
+ toolName: string,
1651
+ args?: Record<string, unknown>,
1652
+ ) => Promise<{ ok: boolean; data: unknown }>;
1653
+ notifier?: { notify: ReturnType<typeof vi.fn> };
1654
+ prMonitor?: { checkAndUpdate: ReturnType<typeof vi.fn> };
1655
+ issueTracker?: {
1656
+ getIssue: ReturnType<typeof vi.fn>;
1657
+ addComment: ReturnType<typeof vi.fn>;
1658
+ updateIssueStatus: ReturnType<typeof vi.fn>;
1659
+ };
1660
+ policySnapshot?: Record<string, unknown>;
1661
+ maxIterationsPerPhase?: number;
1662
+ maxActiveFeatures?: number;
1663
+ executionEnrollmentService?: {
1664
+ start: ReturnType<typeof vi.fn>;
1665
+ stop: ReturnType<typeof vi.fn>;
1666
+ notifyNewRequestHint: ReturnType<typeof vi.fn>;
1667
+ noteSafeCheckpoint: ReturnType<typeof vi.fn>;
1668
+ hasPendingWork: ReturnType<typeof vi.fn>;
1669
+ drainReadyDecisions: ReturnType<typeof vi.fn>;
1670
+ };
1671
+ } = {},
1672
+ ) {
1673
+ const kernel = {
1674
+ ensureLoaded: vi.fn(async () => undefined),
1675
+ recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
1676
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
1677
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
1678
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
1679
+ getRuntimeSessions: vi.fn(async () => ({
1680
+ run_id: 'run:test',
1681
+ owner_instance_id: 'owner:test',
1682
+ orchestrator_session_id: 'orch:test',
1683
+ feature_sessions: {},
1684
+ })),
1685
+ getPolicySnapshot: vi.fn(
1686
+ () =>
1687
+ opts.policySnapshot ?? {
1688
+ reactions: {
1689
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
1690
+ },
1691
+ },
1692
+ ),
1693
+ updateState: vi.fn(async () => ({})),
1694
+ checkBudget: opts.checkBudgetImpl
1695
+ ? vi.fn(opts.checkBudgetImpl)
1696
+ : vi.fn(async () => ({
1697
+ over_budget: false,
1698
+ alert_threshold_reached: false,
1699
+ current_cost_usd: 0,
1700
+ limit_usd: -1,
1701
+ alert_threshold: 0.8,
1702
+ })),
1703
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
1704
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
1705
+ };
1706
+
1707
+ const state = {
1708
+ runId: 'run:test',
1709
+ ownerInstanceId: 'owner:test',
1710
+ orchestratorSessionId: 'orch:test',
1711
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
1712
+ queue: [] as Array<{ feature_id: string }>,
1713
+ runMetadata: {},
1714
+ };
1715
+
1716
+ const defaultToolCallerImpl = async (
1717
+ _role: string,
1718
+ toolName: string,
1719
+ _args?: Record<string, unknown>,
1720
+ ) => {
1721
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
1722
+ return { ok: true, data: { features: [] } };
1723
+ }
1724
+ return { ok: true, data: { front_matter: { status: STATUS.PLANNING } } };
1725
+ };
1726
+
1727
+ const toolCaller = {
1728
+ callTool: opts.toolCallerImpl ? vi.fn(opts.toolCallerImpl) : vi.fn(defaultToolCallerImpl),
1729
+ };
1730
+
1731
+ const sessionOrchestrator = {
1732
+ ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
1733
+ cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
1734
+ initializeFeatureCluster: vi.fn(async () => undefined),
1735
+ closeFeatureCluster: vi.fn(async () => undefined),
1736
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
1737
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
1738
+ };
1739
+
1740
+ const planningWaveExecutor = {
1741
+ run: vi.fn(async () => undefined),
1742
+ runPostQaReconciliation: vi.fn(async () => undefined),
1743
+ };
1744
+ const buildWaveExecutor = { run: vi.fn(async () => undefined) };
1745
+ const qaWaveExecutor = { run: vi.fn(async () => undefined) };
1746
+ const leaseHeartbeatService = { renew: vi.fn(async () => undefined) };
1747
+ const provider = { selection: { provider: 'test', model: 'test-model' } };
1748
+
1749
+ const coordinator = new RunCoordinator({
1750
+ kernel: kernel as never,
1751
+ provider: provider as never,
1752
+ toolCaller: toolCaller as never,
1753
+ state: state as never,
1754
+ sessionOrchestrator: sessionOrchestrator as never,
1755
+ planningWaveExecutor: planningWaveExecutor as never,
1756
+ buildWaveExecutor: buildWaveExecutor as never,
1757
+ qaWaveExecutor: qaWaveExecutor as never,
1758
+ leaseHeartbeatService: leaseHeartbeatService as never,
1759
+ maxActiveFeatures: opts.maxActiveFeatures ?? 5,
1760
+ maxParallelGateRuns: 2,
1761
+ maxIterationsPerPhase: opts.maxIterationsPerPhase ?? 1,
1762
+ takeoverStaleRun: false,
1763
+ providerConfigRefHash: () => 'hash',
1764
+ notifier: opts.notifier as never,
1765
+ prMonitor: opts.prMonitor as never,
1766
+ issueTracker: opts.issueTracker as never,
1767
+ executionEnrollmentService: opts.executionEnrollmentService as never,
1768
+ });
1769
+
1770
+ return {
1771
+ coordinator,
1772
+ kernel,
1773
+ toolCaller,
1774
+ sessionOrchestrator,
1775
+ planningWaveExecutor,
1776
+ buildWaveExecutor,
1777
+ qaWaveExecutor,
1778
+ };
1779
+ }
1780
+
1781
+ it('GIVEN_over_budget_feature_WHEN_pauseOverBudgetFeatures_runs_THEN_feature_removed_and_state_updated', async () => {
1782
+ const { coordinator, kernel, sessionOrchestrator } = makeCoordinatorDeps({
1783
+ checkBudgetImpl: async () => ({
1784
+ over_budget: true,
1785
+ alert_threshold_reached: false,
1786
+ current_cost_usd: 5.5,
1787
+ limit_usd: 5.0,
1788
+ alert_threshold: 0.8,
1789
+ }),
1790
+ });
1791
+
1792
+ await coordinator.start([{ feature_id: 'feature_x' }]);
1793
+
1794
+ expect(kernel.updateState).toHaveBeenCalledWith('feature_x', null, expect.any(Function));
1795
+ const updateStateCall = kernel.updateState.mock.calls[0] as unknown[] | undefined;
1796
+ expect(updateStateCall).toBeDefined();
1797
+ const callbackArg = updateStateCall?.[2] as
1798
+ | ((fm: object) => Promise<{ frontMatter: { status: string } }>)
1799
+ | undefined;
1800
+ expect(callbackArg).toBeDefined();
1801
+ if (callbackArg == null) {
1802
+ throw new Error('missing updateState callback');
1803
+ }
1804
+ const result = await callbackArg({});
1805
+ expect(result.frontMatter.status).toBe(STATUS.PAUSED_BUDGET);
1806
+ expect(sessionOrchestrator.closeFeatureCluster).toHaveBeenCalledWith('feature_x');
1807
+ });
1808
+
1809
+ it('GIVEN_over_budget_feature_with_notifier_WHEN_pauseOverBudgetFeatures_runs_THEN_notifies_budget_exceeded', async () => {
1810
+ const notifier = { notify: vi.fn(async () => undefined) };
1811
+ const { coordinator } = makeCoordinatorDeps({
1812
+ checkBudgetImpl: async () => ({
1813
+ over_budget: true,
1814
+ alert_threshold_reached: false,
1815
+ current_cost_usd: 5.5,
1816
+ limit_usd: 5.0,
1817
+ alert_threshold: 0.8,
1818
+ }),
1819
+ notifier,
1820
+ });
1821
+
1822
+ await coordinator.start([{ feature_id: 'feature_x' }]);
1823
+
1824
+ expect(notifier.notify).toHaveBeenCalledWith(
1825
+ 'budget_exceeded',
1826
+ expect.objectContaining({ feature_id: 'feature_x' }),
1827
+ );
1828
+ });
1829
+
1830
+ it('GIVEN_alert_threshold_reached_WHEN_pauseOverBudgetFeatures_runs_THEN_notifies_budget_alert', async () => {
1831
+ const notifier = { notify: vi.fn(async () => undefined) };
1832
+ const { coordinator } = makeCoordinatorDeps({
1833
+ checkBudgetImpl: async () => ({
1834
+ over_budget: false,
1835
+ alert_threshold_reached: true,
1836
+ current_cost_usd: 4.5,
1837
+ limit_usd: 5.0,
1838
+ alert_threshold: 0.9,
1839
+ }),
1840
+ notifier,
1841
+ });
1842
+
1843
+ await coordinator.start([{ feature_id: 'feature_x' }]);
1844
+
1845
+ expect(notifier.notify).toHaveBeenCalledWith(
1846
+ 'budget_alert',
1847
+ expect.objectContaining({ feature_id: 'feature_x' }),
1848
+ );
1849
+ });
1850
+
1851
+ it('GIVEN_feature_transitions_to_BLOCKED_from_PLANNING_WHEN_notifyStatusTransitions_THEN_notifies_collision_detected', async () => {
1852
+ const notifier = { notify: vi.fn(async () => undefined) };
1853
+ let featureStatus: string = STATUS.PLANNING;
1854
+ let reconciliationCount = 0;
1855
+ const kernel = {
1856
+ ensureLoaded: vi.fn(async () => undefined),
1857
+ recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
1858
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
1859
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
1860
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
1861
+ getRuntimeSessions: vi.fn(async () => ({
1862
+ run_id: 'run:test',
1863
+ owner_instance_id: 'owner:test',
1864
+ orchestrator_session_id: 'orch:test',
1865
+ feature_sessions: {},
1866
+ })),
1867
+ getPolicySnapshot: vi.fn(() => ({
1868
+ reactions: {
1869
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
1870
+ },
1871
+ })),
1872
+ updateState: vi.fn(async () => ({})),
1873
+ checkBudget: vi.fn(async () => ({
1874
+ over_budget: false,
1875
+ alert_threshold_reached: false,
1876
+ current_cost_usd: 0,
1877
+ limit_usd: -1,
1878
+ alert_threshold: 0.8,
1879
+ })),
1880
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
1881
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
1882
+ };
1883
+
1884
+ const toolCaller = {
1885
+ callTool: vi.fn(async (_role: string, toolName: string) => {
1886
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
1887
+ return { ok: true, data: { front_matter: { status: featureStatus } } };
1888
+ }
1889
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
1890
+ return { ok: true, data: { features: [] } };
1891
+ }
1892
+ return { ok: true, data: {} };
1893
+ }),
1894
+ };
1895
+
1896
+ const state = {
1897
+ runId: 'run:test',
1898
+ ownerInstanceId: 'owner:test',
1899
+ orchestratorSessionId: 'orch:test',
1900
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
1901
+ queue: [] as Array<{ feature_id: string }>,
1902
+ runMetadata: {},
1903
+ };
1904
+
1905
+ const sessionOrchestrator = {
1906
+ ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
1907
+ cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
1908
+ initializeFeatureCluster: vi.fn(async () => undefined),
1909
+ closeFeatureCluster: vi.fn(async () => undefined),
1910
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
1911
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
1912
+ };
1913
+
1914
+ const planningWaveExecutor = {
1915
+ run: vi.fn(async () => undefined),
1916
+ runPostQaReconciliation: vi.fn(async () => {
1917
+ reconciliationCount += 1;
1918
+ if (reconciliationCount === 2) {
1919
+ featureStatus = STATUS.BLOCKED;
1920
+ }
1921
+ }),
1922
+ };
1923
+ const buildWaveExecutor = { run: vi.fn(async () => undefined) };
1924
+ const qaWaveExecutor = { run: vi.fn(async () => undefined) };
1925
+ const leaseHeartbeatService = { renew: vi.fn(async () => undefined) };
1926
+ const provider = { selection: { provider: 'test', model: 'test-model' } };
1927
+
1928
+ const coordinator = new RunCoordinator({
1929
+ kernel: kernel as never,
1930
+ provider: provider as never,
1931
+ toolCaller: toolCaller as never,
1932
+ state: state as never,
1933
+ sessionOrchestrator: sessionOrchestrator as never,
1934
+ planningWaveExecutor: planningWaveExecutor as never,
1935
+ buildWaveExecutor: buildWaveExecutor as never,
1936
+ qaWaveExecutor: qaWaveExecutor as never,
1937
+ leaseHeartbeatService: leaseHeartbeatService as never,
1938
+ maxActiveFeatures: 5,
1939
+ maxParallelGateRuns: 2,
1940
+ maxIterationsPerPhase: 2,
1941
+ takeoverStaleRun: false,
1942
+ providerConfigRefHash: () => 'hash',
1943
+ notifier: notifier as never,
1944
+ });
1945
+
1946
+ await coordinator.start([{ feature_id: 'feature_x' }]);
707
1947
 
708
1948
  expect(notifier.notify).toHaveBeenCalledWith(
709
1949
  'collision_detected',
@@ -711,72 +1951,867 @@ describe('RunCoordinator notification and budget branches', () => {
711
1951
  );
712
1952
  });
713
1953
 
714
- it('GIVEN_feature_transitions_to_BLOCKED_from_non_PLANNING_WHEN_notifyStatusTransitions_THEN_notifies_gate_failed', async () => {
715
- const notifier = { notify: vi.fn(async () => undefined) };
716
- let callCount = 0;
717
- const { coordinator } = makeCoordinatorDeps({
1954
+ it('GIVEN_feature_transitions_to_BLOCKED_from_non_PLANNING_WHEN_notifyStatusTransitions_THEN_notifies_gate_failed', async () => {
1955
+ const notifier = { notify: vi.fn(async () => undefined) };
1956
+ let callCount = 0;
1957
+ const { coordinator } = makeCoordinatorDeps({
1958
+ toolCallerImpl: async (_role, toolName) => {
1959
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
1960
+ callCount += 1;
1961
+ const status = callCount === 1 ? STATUS.BUILDING : STATUS.BLOCKED;
1962
+ return { ok: true, data: { front_matter: { status } } };
1963
+ }
1964
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
1965
+ return { ok: true, data: { features: [] } };
1966
+ }
1967
+ return { ok: true, data: {} };
1968
+ },
1969
+ notifier,
1970
+ });
1971
+
1972
+ await coordinator.start([{ feature_id: 'feature_x' }]);
1973
+
1974
+ expect(notifier.notify).toHaveBeenCalledWith(
1975
+ 'gate_failed',
1976
+ expect.objectContaining({ feature_id: 'feature_x' }),
1977
+ );
1978
+ });
1979
+
1980
+ it('GIVEN_feature_transitions_to_READY_TO_MERGE_WHEN_notifyStatusTransitions_THEN_notifies_and_calls_prMonitor', async () => {
1981
+ const notifier = { notify: vi.fn(async () => undefined) };
1982
+ const prMonitor = { checkAndUpdate: vi.fn(async () => null) };
1983
+ let callCount = 0;
1984
+ const { coordinator } = makeCoordinatorDeps({
1985
+ toolCallerImpl: async (_role, toolName) => {
1986
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
1987
+ callCount += 1;
1988
+ const status = callCount === 1 ? STATUS.PLANNING : STATUS.READY_TO_MERGE;
1989
+ return { ok: true, data: { front_matter: { status } } };
1990
+ }
1991
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
1992
+ return { ok: true, data: { features: [] } };
1993
+ }
1994
+ return { ok: true, data: {} };
1995
+ },
1996
+ notifier,
1997
+ prMonitor,
1998
+ });
1999
+
2000
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2001
+
2002
+ expect(notifier.notify).toHaveBeenCalledWith(
2003
+ 'ready_to_merge',
2004
+ expect.objectContaining({ feature_id: 'feature_x' }),
2005
+ );
2006
+ expect(prMonitor.checkAndUpdate).toHaveBeenCalledWith('feature_x', 'feature_x');
2007
+ });
2008
+
2009
+ it('GIVEN_feature_has_existing_pr_WHEN_status_is_unchanged_THEN_pr_monitor_polls_continuously', async () => {
2010
+ const prMonitor = { checkAndUpdate: vi.fn(async () => null) };
2011
+ const { coordinator } = makeCoordinatorDeps({
2012
+ toolCallerImpl: async (_role, toolName) => {
2013
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2014
+ return {
2015
+ ok: true,
2016
+ data: {
2017
+ front_matter: {
2018
+ status: STATUS.QA,
2019
+ pr: { number: 77, url: 'https://example.com/pr/77' },
2020
+ },
2021
+ },
2022
+ };
2023
+ }
2024
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2025
+ return { ok: true, data: { features: [] } };
2026
+ }
2027
+ return { ok: true, data: {} };
2028
+ },
2029
+ prMonitor,
2030
+ maxIterationsPerPhase: 2,
2031
+ });
2032
+
2033
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2034
+
2035
+ expect(prMonitor.checkAndUpdate).toHaveBeenCalledWith('feature_x', 'feature_x');
2036
+ });
2037
+
2038
+ it('GIVEN_feature_transitions_to_MERGED_WHEN_notifyStatusTransitions_THEN_notifies_feature_merged', async () => {
2039
+ const notifier = { notify: vi.fn(async () => undefined) };
2040
+ let callCount = 0;
2041
+ const { coordinator } = makeCoordinatorDeps({
2042
+ toolCallerImpl: async (_role, toolName) => {
2043
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2044
+ callCount += 1;
2045
+ const status = callCount <= 2 ? STATUS.PLANNING : STATUS.MERGED;
2046
+ return { ok: true, data: { front_matter: { status } } };
2047
+ }
2048
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2049
+ return { ok: true, data: { features: [] } };
2050
+ }
2051
+ return { ok: true, data: {} };
2052
+ },
2053
+ notifier,
2054
+ });
2055
+
2056
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2057
+
2058
+ expect(notifier.notify).toHaveBeenCalledWith(
2059
+ 'feature_merged',
2060
+ expect.objectContaining({ feature_id: 'feature_x' }),
2061
+ );
2062
+ });
2063
+
2064
+ it('GIVEN_feature_transitions_to_terminal_status_WHEN_notifyStatusTransitions_runs_THEN_calls_notifyNewRequestHint', async () => {
2065
+ const executionEnrollmentService = {
2066
+ start: vi.fn(async () => undefined),
2067
+ stop: vi.fn(async () => undefined),
2068
+ notifyNewRequestHint: vi.fn(async () => undefined),
2069
+ noteSafeCheckpoint: vi.fn(async () => undefined),
2070
+ hasPendingWork: vi.fn(() => false),
2071
+ drainReadyDecisions: vi.fn(async () => []),
2072
+ };
2073
+ let callCount = 0;
2074
+ const { coordinator } = makeCoordinatorDeps({
2075
+ executionEnrollmentService,
2076
+ toolCallerImpl: async (_role, toolName) => {
2077
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2078
+ callCount += 1;
2079
+ const status = callCount <= 2 ? STATUS.PLANNING : STATUS.MERGED;
2080
+ return { ok: true, data: { front_matter: { status } } };
2081
+ }
2082
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2083
+ return { ok: true, data: { features: [] } };
2084
+ }
2085
+ return { ok: true, data: {} };
2086
+ },
2087
+ });
2088
+
2089
+ await coordinator.start([{ feature_id: 'feature_terminal' }]);
2090
+
2091
+ // notifyNewRequestHint is called once at startup and once when the feature reaches MERGED
2092
+ expect(executionEnrollmentService.notifyNewRequestHint).toHaveBeenCalledTimes(2);
2093
+ });
2094
+
2095
+ it('GIVEN_issue_tracker_WHEN_feature_first_enters_PLANNING_THEN_fetches_issue_and_stores_in_frontmatter', async () => {
2096
+ const issueTracker = {
2097
+ getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
2098
+ addComment: vi.fn(async () => undefined),
2099
+ updateIssueStatus: vi.fn(async () => undefined),
2100
+ };
2101
+ const { coordinator, kernel } = makeCoordinatorDeps({ issueTracker });
2102
+
2103
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2104
+
2105
+ expect(issueTracker.getIssue).toHaveBeenCalledWith('x');
2106
+ expect(kernel.updateState).toHaveBeenCalledWith('feature_x', null, expect.any(Function));
2107
+ });
2108
+
2109
+ it('GIVEN_issue_tracker_WHEN_status_transitions_THEN_adds_comment', async () => {
2110
+ const issueTracker = {
2111
+ getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
2112
+ addComment: vi.fn(async () => undefined),
2113
+ updateIssueStatus: vi.fn(async () => undefined),
2114
+ };
2115
+ const { coordinator } = makeCoordinatorDeps({ issueTracker });
2116
+
2117
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2118
+
2119
+ expect(issueTracker.addComment).toHaveBeenCalledWith('x', expect.stringContaining('planning'));
2120
+ });
2121
+
2122
+ it('GIVEN_issue_tracker_WHEN_status_transitions_THEN_syncs_tracker_status_for_non_terminal_states', async () => {
2123
+ const issueTracker = {
2124
+ getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
2125
+ addComment: vi.fn(async () => undefined),
2126
+ updateIssueStatus: vi.fn(async () => undefined),
2127
+ };
2128
+ const { coordinator } = makeCoordinatorDeps({ issueTracker });
2129
+
2130
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2131
+
2132
+ expect(issueTracker.updateIssueStatus).toHaveBeenCalledWith('x', STATUS.PLANNING);
2133
+ });
2134
+
2135
+ it('GIVEN_issue_tracker_WHEN_status_is_MERGED_THEN_updates_issue_status_to_closed', async () => {
2136
+ const issueTracker = {
2137
+ getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
2138
+ addComment: vi.fn(async () => undefined),
2139
+ updateIssueStatus: vi.fn(async () => undefined),
2140
+ };
2141
+ let callCount = 0;
2142
+ const { coordinator } = makeCoordinatorDeps({
2143
+ toolCallerImpl: async (_role, toolName) => {
2144
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2145
+ callCount += 1;
2146
+ const status = callCount <= 2 ? STATUS.PLANNING : STATUS.MERGED;
2147
+ return { ok: true, data: { front_matter: { status } } };
2148
+ }
2149
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2150
+ return { ok: true, data: { features: [] } };
2151
+ }
2152
+ return { ok: true, data: {} };
2153
+ },
2154
+ issueTracker,
2155
+ });
2156
+
2157
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2158
+
2159
+ expect(issueTracker.updateIssueStatus).toHaveBeenCalledWith('x', STATUS.MERGED);
2160
+ });
2161
+
2162
+ it('GIVEN_same_status_seen_twice_WHEN_notifyStatusTransitions_THEN_skips_duplicate_notification', async () => {
2163
+ const notifier = { notify: vi.fn(async () => undefined) };
2164
+ const { coordinator } = makeCoordinatorDeps({
2165
+ // always returns PLANNING — second iteration sees same status, should not re-notify
2166
+ notifier,
2167
+ maxIterationsPerPhase: 2,
2168
+ });
2169
+
2170
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2171
+
2172
+ // notify called once only — second iteration sees same status, skipped
2173
+ const planningNotifies = notifier.notify.mock.calls.filter(
2174
+ (c: unknown[]) => c[0] !== 'budget_alert' && c[0] !== 'budget_exceeded',
2175
+ );
2176
+ expect(planningNotifies.length).toBe(0); // PLANNING has no notifier branch, just confirm no error
2177
+ });
2178
+
2179
+ it('GIVEN_issue_tracker_returns_issue_without_title_WHEN_first_planning_transition_THEN_skips_updateState', async () => {
2180
+ const issueTracker = {
2181
+ getIssue: vi.fn(async () => ({ id: '123', title: '', url: 'http://test' })),
2182
+ addComment: vi.fn(async () => undefined),
2183
+ updateIssueStatus: vi.fn(async () => undefined),
2184
+ };
2185
+ const { coordinator, kernel } = makeCoordinatorDeps({ issueTracker });
2186
+
2187
+ await coordinator.start([{ feature_id: 'feature_x' }]);
2188
+
2189
+ expect(issueTracker.getIssue).toHaveBeenCalledWith('x');
2190
+ // updateState should NOT be called since issue has no title
2191
+ const stateUpdateForIssue = kernel.updateState.mock.calls.filter(
2192
+ (c: unknown[]) => c[0] === 'feature_x',
2193
+ );
2194
+ expect(stateUpdateForIssue.length).toBe(0);
2195
+ });
2196
+
2197
+ it('GIVEN_priority_order_with_unknown_feature_WHEN_applyOrchestratorPrioritization_runs_THEN_skips_unknown_and_appends_rest', async () => {
2198
+ const workerDecisionRunner = {
2199
+ execute: vi.fn(async () => ({
2200
+ planSubmission: false,
2201
+ patchApplied: false,
2202
+ noteLogged: false,
2203
+ requestHandled: true,
2204
+ priorityOrder: ['unknown_feature', 'feature_b'],
2205
+ toolResults: [],
2206
+ })),
2207
+ };
2208
+
2209
+ const { ...rest } = makeCoordinatorDeps({ maxActiveFeatures: 2, maxIterationsPerPhase: 1 });
2210
+ // Rebuild coordinator with workerDecisionRunner
2211
+ const kernel2 = {
2212
+ ensureLoaded: vi.fn(async () => undefined),
2213
+ recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
2214
+ acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
2215
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
2216
+ pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
2217
+ getRuntimeSessions: vi.fn(async () => ({
2218
+ run_id: 'run:test',
2219
+ owner_instance_id: 'owner:test',
2220
+ orchestrator_session_id: 'orch:test',
2221
+ feature_sessions: {},
2222
+ })),
2223
+ getPolicySnapshot: vi.fn(() => ({
2224
+ reactions: {
2225
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
2226
+ },
2227
+ })),
2228
+ updateState: vi.fn(async () => ({})),
2229
+ checkBudget: vi.fn(async () => ({
2230
+ over_budget: false,
2231
+ alert_threshold_reached: false,
2232
+ current_cost_usd: 0,
2233
+ limit_usd: -1,
2234
+ alert_threshold: 0.8,
2235
+ })),
2236
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
2237
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
2238
+ };
2239
+ const state2 = {
2240
+ runId: 'run:test',
2241
+ ownerInstanceId: 'owner:test',
2242
+ orchestratorSessionId: 'orch:test',
2243
+ sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
2244
+ queue: [] as Array<{ feature_id: string }>,
2245
+ runMetadata: {},
2246
+ };
2247
+ const toolCaller2 = {
2248
+ callTool: vi.fn(async (_role: string, toolName: string) => {
2249
+ if (toolName === 'report.dashboard') {
2250
+ return { ok: true, data: { features: [] } };
2251
+ }
2252
+ return { ok: true, data: { front_matter: { status: STATUS.PLANNING } } };
2253
+ }),
2254
+ };
2255
+ const sessionOrchestrator2 = {
2256
+ ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
2257
+ cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
2258
+ initializeFeatureCluster: vi.fn(async () => undefined),
2259
+ closeFeatureCluster: vi.fn(async () => undefined),
2260
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
2261
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
2262
+ };
2263
+ const coord2 = new RunCoordinator({
2264
+ kernel: kernel2 as never,
2265
+ provider: { selection: { provider: 'test', model: 'test' } } as never,
2266
+ toolCaller: toolCaller2 as never,
2267
+ state: state2 as never,
2268
+ sessionOrchestrator: sessionOrchestrator2 as never,
2269
+ planningWaveExecutor: {
2270
+ run: vi.fn(async () => undefined),
2271
+ runPostQaReconciliation: vi.fn(async () => undefined),
2272
+ } as never,
2273
+ buildWaveExecutor: { run: vi.fn(async () => undefined) } as never,
2274
+ qaWaveExecutor: { run: vi.fn(async () => undefined) } as never,
2275
+ leaseHeartbeatService: { renew: vi.fn(async () => undefined) } as never,
2276
+ maxActiveFeatures: 2,
2277
+ maxParallelGateRuns: 2,
2278
+ maxIterationsPerPhase: 1,
2279
+ takeoverStaleRun: false,
2280
+ providerConfigRefHash: () => 'hash',
2281
+ workerDecisionRunner: workerDecisionRunner as never,
2282
+ });
2283
+
2284
+ void rest; // suppress unused warning
2285
+ await coord2.start([{ feature_id: 'feature_a' }, { feature_id: 'feature_b' }]);
2286
+ // Should complete without error; unknown_feature was skipped in prioritization
2287
+ expect(workerDecisionRunner.execute).toHaveBeenCalled();
2288
+ });
2289
+
2290
+ it('GIVEN_blocked_feature_with_passing_fast_gate_evidence_WHEN_iteration_runs_THEN_advances_to_QA', async () => {
2291
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2292
+ toolCallerImpl: async (_role, toolName) => {
2293
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2294
+ return {
2295
+ ok: true,
2296
+ data: {
2297
+ front_matter: {
2298
+ status: STATUS.BLOCKED,
2299
+ version: 3,
2300
+ gates: { fast: 'pass', full: 'na' },
2301
+ },
2302
+ },
2303
+ };
2304
+ }
2305
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2306
+ return { ok: true, data: { features: [] } };
2307
+ }
2308
+ return { ok: true, data: {} };
2309
+ },
2310
+ });
2311
+
2312
+ await coordinator.start([{ feature_id: 'blocked_fast_pass' }]);
2313
+
2314
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2315
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2316
+ );
2317
+ const recoveryCall = patchCalls.find((c) => {
2318
+ const callArgs = c as unknown[];
2319
+ const args = callArgs[2] as { patch?: { front_matter?: { status?: string } } } | undefined;
2320
+ return args?.patch?.front_matter?.status === STATUS.QA;
2321
+ });
2322
+ expect(recoveryCall).toBeDefined();
2323
+ });
2324
+
2325
+ it('GIVEN_blocked_feature_with_passing_full_gate_evidence_WHEN_iteration_runs_THEN_advances_to_READY_TO_MERGE', async () => {
2326
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2327
+ toolCallerImpl: async (_role, toolName) => {
2328
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2329
+ return {
2330
+ ok: true,
2331
+ data: {
2332
+ front_matter: {
2333
+ status: STATUS.BLOCKED,
2334
+ version: 7,
2335
+ gates: { fast: 'pass', full: 'pass' },
2336
+ },
2337
+ },
2338
+ };
2339
+ }
2340
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2341
+ return { ok: true, data: { features: [] } };
2342
+ }
2343
+ return { ok: true, data: {} };
2344
+ },
2345
+ });
2346
+
2347
+ await coordinator.start([{ feature_id: 'blocked_full_pass' }]);
2348
+
2349
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2350
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2351
+ );
2352
+ const recoveryCall = patchCalls.find((c) => {
2353
+ const callArgs = c as unknown[];
2354
+ const args = callArgs[2] as { patch?: { front_matter?: { status?: string } } } | undefined;
2355
+ return args?.patch?.front_matter?.status === STATUS.READY_TO_MERGE;
2356
+ });
2357
+ expect(recoveryCall).toBeDefined();
2358
+ });
2359
+
2360
+ it('GIVEN_blocked_feature_with_failed_fast_gate_and_retry_policy_WHEN_iteration_runs_THEN_returns_to_BUILDING', async () => {
2361
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2362
+ toolCallerImpl: async (_role, toolName) => {
2363
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2364
+ return {
2365
+ ok: true,
2366
+ data: {
2367
+ front_matter: {
2368
+ status: STATUS.BLOCKED,
2369
+ version: 2,
2370
+ gates: { fast: 'fail', full: 'na' },
2371
+ evidence: { last_gate_mode: 'fast' },
2372
+ gate_retry_count: 0,
2373
+ },
2374
+ },
2375
+ };
2376
+ }
2377
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2378
+ return { ok: true, data: { features: [] } };
2379
+ }
2380
+ return { ok: true, data: {} };
2381
+ },
2382
+ });
2383
+
2384
+ await coordinator.start([{ feature_id: 'blocked_fast_fail' }]);
2385
+
2386
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2387
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2388
+ );
2389
+ const recoveryCall = patchCalls.find((c) => {
2390
+ const callArgs = c as unknown[];
2391
+ const args = callArgs[2] as
2392
+ | { patch?: { front_matter?: { status?: string; status_reason?: string } } }
2393
+ | undefined;
2394
+ return args?.patch?.front_matter?.status === STATUS.BUILDING;
2395
+ });
2396
+ expect(recoveryCall).toBeDefined();
2397
+ if (recoveryCall) {
2398
+ const callArgs = recoveryCall as unknown[];
2399
+ const args = callArgs[2] as
2400
+ | { patch?: { front_matter?: { status_reason?: string } } }
2401
+ | undefined;
2402
+ expect(args?.patch?.front_matter?.status_reason).toBe(
2403
+ 'recovered_from_blocked:gate_retry:fast',
2404
+ );
2405
+ }
2406
+ });
2407
+
2408
+ it('GIVEN_blocked_feature_with_failed_full_gate_and_retry_policy_WHEN_iteration_runs_THEN_returns_to_QA', async () => {
2409
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2410
+ toolCallerImpl: async (_role, toolName) => {
2411
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2412
+ return {
2413
+ ok: true,
2414
+ data: {
2415
+ front_matter: {
2416
+ status: STATUS.BLOCKED,
2417
+ version: 4,
2418
+ gates: { fast: 'pass', full: 'fail' },
2419
+ evidence: { last_gate_mode: 'full' },
2420
+ gate_retry_count: 1,
2421
+ },
2422
+ },
2423
+ };
2424
+ }
2425
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2426
+ return { ok: true, data: { features: [] } };
2427
+ }
2428
+ return { ok: true, data: {} };
2429
+ },
2430
+ });
2431
+
2432
+ await coordinator.start([{ feature_id: 'blocked_full_fail' }]);
2433
+
2434
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2435
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2436
+ );
2437
+ const recoveryCall = patchCalls.find((c) => {
2438
+ const callArgs = c as unknown[];
2439
+ const args = callArgs[2] as { patch?: { front_matter?: { status?: string } } } | undefined;
2440
+ return args?.patch?.front_matter?.status === STATUS.QA;
2441
+ });
2442
+ expect(recoveryCall).toBeDefined();
2443
+ });
2444
+
2445
+ it('GIVEN_blocked_feature_with_failed_gate_and_retry_limit_reached_WHEN_iteration_runs_THEN_does_not_requeue', async () => {
2446
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2447
+ policySnapshot: {
2448
+ reactions: {
2449
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 1 },
2450
+ },
2451
+ },
2452
+ toolCallerImpl: async (_role, toolName) => {
2453
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2454
+ return {
2455
+ ok: true,
2456
+ data: {
2457
+ front_matter: {
2458
+ status: STATUS.BLOCKED,
2459
+ version: 5,
2460
+ gates: { fast: 'fail', full: 'na' },
2461
+ evidence: { last_gate_mode: 'fast' },
2462
+ gate_retry_count: 1,
2463
+ },
2464
+ },
2465
+ };
2466
+ }
2467
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2468
+ return { ok: true, data: { features: [] } };
2469
+ }
2470
+ return { ok: true, data: {} };
2471
+ },
2472
+ });
2473
+
2474
+ await coordinator.start([{ feature_id: 'blocked_fast_fail_maxed' }]);
2475
+
2476
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2477
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2478
+ );
2479
+ expect(patchCalls).toHaveLength(0);
2480
+ });
2481
+
2482
+ it('GIVEN_blocked_feature_with_duplicate_checkpoint_WHEN_iteration_runs_THEN_keeps_feature_blocked_until_new_work_exists', async () => {
2483
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2484
+ toolCallerImpl: async (_role, toolName) => {
2485
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2486
+ return {
2487
+ ok: true,
2488
+ data: {
2489
+ front_matter: {
2490
+ status: STATUS.BLOCKED,
2491
+ version: 6,
2492
+ status_reason: 'Gate mode fast failed',
2493
+ gates: { fast: 'fail', full: 'na' },
2494
+ evidence: { last_gate_mode: 'fast' },
2495
+ gate_retry_count: 0,
2496
+ checkpoints: [
2497
+ {
2498
+ checkpoint_id: 'checkpoint-stale',
2499
+ timestamp: new Date().toISOString(),
2500
+ files_changed: ['src/stale.ts'],
2501
+ validation_status: 'skipped',
2502
+ diff_snapshot: '.aop/features/blocked_stale/checkpoints/checkpoint-stale.diff',
2503
+ net_new_worktree_change: false,
2504
+ duplicate_of_checkpoint_id: 'checkpoint-prev',
2505
+ },
2506
+ ],
2507
+ },
2508
+ },
2509
+ };
2510
+ }
2511
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2512
+ return { ok: true, data: { features: [] } };
2513
+ }
2514
+ return { ok: true, data: {} };
2515
+ },
2516
+ });
2517
+
2518
+ await coordinator.start([{ feature_id: 'blocked_stale_retry' }]);
2519
+
2520
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2521
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2522
+ );
2523
+ const staleBlockCall = patchCalls.find((c) => {
2524
+ const args = c[2] as
2525
+ | { patch?: { front_matter?: { status?: string; status_reason?: string } } }
2526
+ | undefined;
2527
+ return (
2528
+ args?.patch?.front_matter?.status === STATUS.BLOCKED &&
2529
+ args.patch.front_matter.status_reason === 'blocked:gate_retry_waiting_for_new_work:fast'
2530
+ );
2531
+ });
2532
+ expect(staleBlockCall).toBeDefined();
2533
+ expect(
2534
+ patchCalls.some((c) => {
2535
+ const args = c[2] as { patch?: { front_matter?: { status?: string } } } | undefined;
2536
+ return args?.patch?.front_matter?.status === STATUS.BUILDING;
2537
+ }),
2538
+ ).toBe(false);
2539
+ });
2540
+
2541
+ it('GIVEN_blocked_feature_with_stale_fast_evidence_and_valid_checkpoint_WHEN_iteration_runs_THEN_recovers_into_gate_refresh', async () => {
2542
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2543
+ toolCallerImpl: async (_role, toolName) => {
2544
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2545
+ return {
2546
+ ok: true,
2547
+ data: {
2548
+ front_matter: {
2549
+ status: STATUS.BLOCKED,
2550
+ version: 6,
2551
+ status_reason: 'Gate mode fast failed',
2552
+ gates: { fast: 'fail', full: 'na' },
2553
+ evidence: { last_gate_mode: 'fast' },
2554
+ gate_retry_count: 0,
2555
+ checkpoints: [
2556
+ {
2557
+ checkpoint_id: 'checkpoint-new',
2558
+ timestamp: new Date().toISOString(),
2559
+ files_changed: ['src/new.ts'],
2560
+ validation_status: 'valid',
2561
+ diff_snapshot: '.aop/features/blocked_refresh/checkpoints/checkpoint-new.diff',
2562
+ diff_hash: 'diff-new',
2563
+ net_new_worktree_change: true,
2564
+ },
2565
+ ],
2566
+ },
2567
+ },
2568
+ };
2569
+ }
2570
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
2571
+ return {
2572
+ ok: true,
2573
+ data: {
2574
+ latest_evidence: {
2575
+ mode: 'fast',
2576
+ overall: 'fail',
2577
+ input_diff_hash: 'diff-old',
2578
+ },
2579
+ gate_evidence_by_mode: {
2580
+ fast: {
2581
+ mode: 'fast',
2582
+ overall: 'fail',
2583
+ input_diff_hash: 'diff-old',
2584
+ },
2585
+ },
2586
+ },
2587
+ };
2588
+ }
2589
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
2590
+ return { ok: true, data: { features: [] } };
2591
+ }
2592
+ return { ok: true, data: {} };
2593
+ },
2594
+ });
2595
+
2596
+ await coordinator.start([{ feature_id: 'blocked_refresh' }]);
2597
+
2598
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2599
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2600
+ );
2601
+ const recoveryCall = patchCalls.find((c) => {
2602
+ const args = c[2] as
2603
+ | { patch?: { front_matter?: { status?: string; status_reason?: string } } }
2604
+ | undefined;
2605
+ return (
2606
+ args?.patch?.front_matter?.status === STATUS.BUILDING &&
2607
+ args.patch.front_matter.status_reason === 'recovered_from_blocked:gate_refresh:fast'
2608
+ );
2609
+ });
2610
+ expect(recoveryCall).toBeDefined();
2611
+ });
2612
+
2613
+ it('GIVEN_blocked_feature_with_stale_full_evidence_and_valid_checkpoint_WHEN_iteration_runs_THEN_recovers_into_gate_refresh', async () => {
2614
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
718
2615
  toolCallerImpl: async (_role, toolName) => {
719
2616
  if (toolName === TOOLS.FEATURE_STATE_GET) {
720
- callCount += 1;
721
- const status = callCount === 1 ? STATUS.BUILDING : STATUS.BLOCKED;
722
- return { ok: true, data: { front_matter: { status } } };
2617
+ return {
2618
+ ok: true,
2619
+ data: {
2620
+ front_matter: {
2621
+ status: STATUS.BLOCKED,
2622
+ version: 6,
2623
+ status_reason: 'Gate mode full failed',
2624
+ gates: { fast: 'fail', full: 'fail' },
2625
+ evidence: { last_gate_mode: 'full' },
2626
+ gate_retry_count: 0,
2627
+ checkpoints: [
2628
+ {
2629
+ checkpoint_id: 'checkpoint-new',
2630
+ timestamp: new Date().toISOString(),
2631
+ files_changed: ['src/new.ts'],
2632
+ validation_status: 'valid',
2633
+ diff_snapshot:
2634
+ '.aop/features/blocked_refresh_full/checkpoints/checkpoint-new.diff',
2635
+ diff_hash: 'diff-new',
2636
+ net_new_worktree_change: true,
2637
+ },
2638
+ ],
2639
+ },
2640
+ },
2641
+ };
2642
+ }
2643
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
2644
+ return {
2645
+ ok: true,
2646
+ data: {
2647
+ latest_evidence: {
2648
+ mode: 'full',
2649
+ overall: 'fail',
2650
+ input_diff_hash: 'diff-old',
2651
+ },
2652
+ gate_evidence_by_mode: {
2653
+ full: {
2654
+ mode: 'full',
2655
+ overall: 'fail',
2656
+ input_diff_hash: 'diff-old',
2657
+ },
2658
+ },
2659
+ },
2660
+ };
723
2661
  }
724
2662
  if (toolName === TOOLS.REPORT_DASHBOARD) {
725
2663
  return { ok: true, data: { features: [] } };
726
2664
  }
727
2665
  return { ok: true, data: {} };
728
2666
  },
729
- notifier,
730
2667
  });
731
2668
 
732
- await coordinator.start([{ feature_id: 'feature_x' }]);
2669
+ await coordinator.start([{ feature_id: 'blocked_refresh_full' }]);
733
2670
 
734
- expect(notifier.notify).toHaveBeenCalledWith(
735
- 'gate_failed',
736
- expect.objectContaining({ feature_id: 'feature_x' }),
2671
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2672
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
737
2673
  );
2674
+ const recoveryCall = patchCalls.find((c) => {
2675
+ const args = c[2] as
2676
+ | { patch?: { front_matter?: { status?: string; status_reason?: string } } }
2677
+ | undefined;
2678
+ return (
2679
+ args?.patch?.front_matter?.status === STATUS.QA &&
2680
+ args.patch.front_matter.status_reason === 'recovered_from_blocked:gate_refresh:full'
2681
+ );
2682
+ });
2683
+ expect(recoveryCall).toBeDefined();
738
2684
  });
739
2685
 
740
- it('GIVEN_feature_transitions_to_READY_TO_MERGE_WHEN_notifyStatusTransitions_THEN_notifies_and_calls_prMonitor', async () => {
741
- const notifier = { notify: vi.fn(async () => undefined) };
742
- const prMonitor = { checkAndUpdate: vi.fn(async () => null) };
743
- let callCount = 0;
744
- const { coordinator } = makeCoordinatorDeps({
2686
+ it('GIVEN_blocked_feature_with_stale_fast_evidence_and_latest_skipped_duplicate_checkpoint_WHEN_iteration_runs_THEN_recovers_into_gate_refresh', async () => {
2687
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
745
2688
  toolCallerImpl: async (_role, toolName) => {
746
2689
  if (toolName === TOOLS.FEATURE_STATE_GET) {
747
- callCount += 1;
748
- const status = callCount === 1 ? STATUS.PLANNING : STATUS.READY_TO_MERGE;
749
- return { ok: true, data: { front_matter: { status } } };
2690
+ return {
2691
+ ok: true,
2692
+ data: {
2693
+ front_matter: {
2694
+ status: STATUS.BLOCKED,
2695
+ version: 6,
2696
+ status_reason: 'blocked:gate_retry_waiting_for_new_work:fast',
2697
+ gates: { fast: 'fail', full: 'na' },
2698
+ evidence: { last_gate_mode: 'fast' },
2699
+ gate_retry_count: 0,
2700
+ checkpoints: [
2701
+ {
2702
+ checkpoint_id: 'checkpoint-valid',
2703
+ timestamp: new Date().toISOString(),
2704
+ files_changed: ['src/new.ts'],
2705
+ validation_status: 'valid',
2706
+ diff_snapshot:
2707
+ '.aop/features/blocked_refresh_duplicate/checkpoints/checkpoint-valid.diff',
2708
+ diff_hash: 'diff-new',
2709
+ net_new_worktree_change: true,
2710
+ },
2711
+ {
2712
+ checkpoint_id: 'checkpoint-skipped',
2713
+ timestamp: new Date().toISOString(),
2714
+ files_changed: [],
2715
+ validation_status: 'skipped',
2716
+ diff_snapshot:
2717
+ '.aop/features/blocked_refresh_duplicate/checkpoints/checkpoint-skipped.diff',
2718
+ diff_hash: 'diff-new',
2719
+ duplicate_of_checkpoint_id: 'checkpoint-valid',
2720
+ net_new_worktree_change: false,
2721
+ },
2722
+ ],
2723
+ },
2724
+ },
2725
+ };
2726
+ }
2727
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
2728
+ return {
2729
+ ok: true,
2730
+ data: {
2731
+ latest_evidence: {
2732
+ mode: 'fast',
2733
+ overall: 'fail',
2734
+ input_diff_hash: 'diff-old',
2735
+ },
2736
+ gate_evidence_by_mode: {
2737
+ fast: {
2738
+ mode: 'fast',
2739
+ overall: 'fail',
2740
+ input_diff_hash: 'diff-old',
2741
+ },
2742
+ },
2743
+ },
2744
+ };
750
2745
  }
751
2746
  if (toolName === TOOLS.REPORT_DASHBOARD) {
752
2747
  return { ok: true, data: { features: [] } };
753
2748
  }
754
2749
  return { ok: true, data: {} };
755
2750
  },
756
- notifier,
757
- prMonitor,
758
2751
  });
759
2752
 
760
- await coordinator.start([{ feature_id: 'feature_x' }]);
2753
+ await coordinator.start([{ feature_id: 'blocked_refresh_duplicate' }]);
761
2754
 
762
- expect(notifier.notify).toHaveBeenCalledWith(
763
- 'ready_to_merge',
764
- expect.objectContaining({ feature_id: 'feature_x' }),
2755
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2756
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
765
2757
  );
766
- expect(prMonitor.checkAndUpdate).toHaveBeenCalledWith('feature_x', 'feature_x');
2758
+ const recoveryCall = patchCalls.find((c) => {
2759
+ const args = c[2] as
2760
+ | { patch?: { front_matter?: { status?: string; status_reason?: string } } }
2761
+ | undefined;
2762
+ return (
2763
+ args?.patch?.front_matter?.status === STATUS.BUILDING &&
2764
+ args.patch.front_matter.status_reason === 'recovered_from_blocked:gate_refresh:fast'
2765
+ );
2766
+ });
2767
+ expect(recoveryCall).toBeDefined();
767
2768
  });
768
2769
 
769
- it('GIVEN_feature_has_existing_pr_WHEN_status_is_unchanged_THEN_pr_monitor_polls_continuously', async () => {
770
- const prMonitor = { checkAndUpdate: vi.fn(async () => null) };
771
- const { coordinator } = makeCoordinatorDeps({
2770
+ it('GIVEN_blocked_feature_with_stale_merge_evidence_and_valid_checkpoint_WHEN_iteration_runs_THEN_leaves_merge_repair_blocked_until_operator_reroutes', async () => {
2771
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
772
2772
  toolCallerImpl: async (_role, toolName) => {
773
2773
  if (toolName === TOOLS.FEATURE_STATE_GET) {
774
2774
  return {
775
2775
  ok: true,
776
2776
  data: {
777
2777
  front_matter: {
778
- status: STATUS.QA,
779
- pr: { number: 77, url: 'https://example.com/pr/77' },
2778
+ status: STATUS.BLOCKED,
2779
+ version: 6,
2780
+ status_reason: 'Gate mode merge failed',
2781
+ gates: { fast: 'pass', full: 'pass', merge: 'fail' },
2782
+ evidence: { last_gate_mode: 'merge' },
2783
+ gate_retry_count: 0,
2784
+ checkpoints: [
2785
+ {
2786
+ checkpoint_id: 'checkpoint-merge-new',
2787
+ timestamp: new Date().toISOString(),
2788
+ files_changed: ['src/new.ts'],
2789
+ validation_status: 'valid',
2790
+ diff_snapshot:
2791
+ '.aop/features/blocked_refresh_merge/checkpoints/checkpoint-merge-new.diff',
2792
+ diff_hash: 'diff-merge-new',
2793
+ net_new_worktree_change: true,
2794
+ },
2795
+ ],
2796
+ },
2797
+ },
2798
+ };
2799
+ }
2800
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
2801
+ return {
2802
+ ok: true,
2803
+ data: {
2804
+ latest_evidence: {
2805
+ mode: 'merge',
2806
+ overall: 'fail',
2807
+ input_diff_hash: 'diff-merge-old',
2808
+ },
2809
+ gate_evidence_by_mode: {
2810
+ merge: {
2811
+ mode: 'merge',
2812
+ overall: 'fail',
2813
+ input_diff_hash: 'diff-merge-old',
2814
+ },
780
2815
  },
781
2816
  },
782
2817
  };
@@ -786,161 +2821,547 @@ describe('RunCoordinator notification and budget branches', () => {
786
2821
  }
787
2822
  return { ok: true, data: {} };
788
2823
  },
789
- prMonitor,
790
- maxIterationsPerPhase: 2,
791
2824
  });
792
2825
 
793
- await coordinator.start([{ feature_id: 'feature_x' }]);
2826
+ await coordinator.start([{ feature_id: 'blocked_refresh_merge' }]);
794
2827
 
795
- expect(prMonitor.checkAndUpdate).toHaveBeenCalledWith('feature_x', 'feature_x');
2828
+ expect(toolCaller.callTool).not.toHaveBeenCalledWith('orchestrator', TOOLS.GATES_RUN, {
2829
+ feature_id: 'blocked_refresh_merge',
2830
+ mode: 'merge',
2831
+ });
2832
+ const patchCalls = toolCaller.callTool.mock.calls.filter(
2833
+ (c) => c[1] === TOOLS.FEATURE_STATE_PATCH,
2834
+ );
2835
+ expect(
2836
+ patchCalls.some((c) => {
2837
+ const args = c[2] as { patch?: { front_matter?: { status_reason?: string } } } | undefined;
2838
+ return (
2839
+ args?.patch?.front_matter?.status_reason === 'recovered_from_blocked:gate_refresh:merge'
2840
+ );
2841
+ }),
2842
+ ).toBe(false);
796
2843
  });
797
2844
 
798
- it('GIVEN_feature_transitions_to_MERGED_WHEN_notifyStatusTransitions_THEN_notifies_feature_merged', async () => {
799
- const notifier = { notify: vi.fn(async () => undefined) };
800
- let callCount = 0;
801
- const { coordinator } = makeCoordinatorDeps({
2845
+ it('GIVEN_blocked_feature_with_stale_merge_evidence_WHEN_recoverBlockedFeatures_runs_THEN_keeps_waiting_for_operator_reroute', async () => {
2846
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
802
2847
  toolCallerImpl: async (_role, toolName) => {
803
2848
  if (toolName === TOOLS.FEATURE_STATE_GET) {
804
- callCount += 1;
805
- const status = callCount === 1 ? STATUS.PLANNING : STATUS.MERGED;
806
- return { ok: true, data: { front_matter: { status } } };
2849
+ return {
2850
+ ok: true,
2851
+ data: {
2852
+ front_matter: {
2853
+ status: STATUS.BLOCKED,
2854
+ version: 6,
2855
+ status_reason: 'Gate mode merge failed',
2856
+ gates: { fast: 'pass', full: 'pass', merge: 'fail' },
2857
+ evidence: { last_gate_mode: 'merge' },
2858
+ gate_retry_count: 0,
2859
+ checkpoints: [
2860
+ {
2861
+ checkpoint_id: 'checkpoint-merge-new',
2862
+ validation_status: 'valid',
2863
+ diff_hash: 'diff-merge-new',
2864
+ net_new_worktree_change: true,
2865
+ },
2866
+ ],
2867
+ },
2868
+ },
2869
+ };
2870
+ }
2871
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
2872
+ return {
2873
+ ok: true,
2874
+ data: {
2875
+ latest_evidence: {
2876
+ mode: 'merge',
2877
+ overall: 'fail',
2878
+ input_diff_hash: 'diff-merge-old',
2879
+ },
2880
+ gate_evidence_by_mode: {
2881
+ merge: {
2882
+ mode: 'merge',
2883
+ overall: 'fail',
2884
+ input_diff_hash: 'diff-merge-old',
2885
+ },
2886
+ },
2887
+ },
2888
+ };
2889
+ }
2890
+ return { ok: true, data: {} };
2891
+ },
2892
+ });
2893
+
2894
+ await (coordinator as any).recoverBlockedFeatures(['blocked_refresh_merge']);
2895
+
2896
+ expect(toolCaller.callTool).not.toHaveBeenCalledWith('orchestrator', TOOLS.GATES_RUN, {
2897
+ feature_id: 'blocked_refresh_merge',
2898
+ mode: 'merge',
2899
+ });
2900
+ });
2901
+
2902
+ it('GIVEN_blocked_merge_failed_feature_in_active_run_WHEN_other_feature_is_resumed_THEN_planning_wave_excludes_merge_blocked_feature', async () => {
2903
+ const { coordinator, planningWaveExecutor } = makeCoordinatorDeps({
2904
+ toolCallerImpl: async (_role, toolName, args) => {
2905
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2906
+ if (args?.feature_id === 'feature_merge_blocked') {
2907
+ return {
2908
+ ok: true,
2909
+ data: {
2910
+ front_matter: {
2911
+ status: STATUS.BLOCKED,
2912
+ version: 11,
2913
+ gates: { plan: 'pass', fast: 'pass', full: 'pass', merge: 'fail' },
2914
+ evidence: { last_gate_mode: 'merge' },
2915
+ merge_repair: {
2916
+ attempts: 1,
2917
+ max_attempts: 5,
2918
+ active: true,
2919
+ requested_at: null,
2920
+ last_failed_at: null,
2921
+ last_gate_evidence_path: null,
2922
+ last_failure_summary: null,
2923
+ reviewer_message: null,
2924
+ exhausted_at: null,
2925
+ },
2926
+ status_reason: 'blocked:gate_retry_waiting_for_new_work:merge',
2927
+ },
2928
+ },
2929
+ };
2930
+ }
2931
+ return {
2932
+ ok: true,
2933
+ data: {
2934
+ front_matter: {
2935
+ status: STATUS.PLANNING,
2936
+ version: 4,
2937
+ gates: { plan: 'na', fast: 'na', full: 'na', merge: 'na' },
2938
+ },
2939
+ },
2940
+ };
807
2941
  }
808
2942
  if (toolName === TOOLS.REPORT_DASHBOARD) {
809
2943
  return { ok: true, data: { features: [] } };
810
2944
  }
811
2945
  return { ok: true, data: {} };
812
2946
  },
813
- notifier,
2947
+ maxActiveFeatures: 2,
814
2948
  });
815
2949
 
816
- await coordinator.start([{ feature_id: 'feature_x' }]);
2950
+ await coordinator.start([
2951
+ { feature_id: 'feature_merge_blocked' },
2952
+ { feature_id: 'feature_resumed' },
2953
+ ]);
817
2954
 
818
- expect(notifier.notify).toHaveBeenCalledWith(
819
- 'feature_merged',
820
- expect.objectContaining({ feature_id: 'feature_x' }),
821
- );
2955
+ expect(planningWaveExecutor.run).toHaveBeenCalledWith(['feature_resumed']);
822
2956
  });
823
2957
 
824
- it('GIVEN_issue_tracker_WHEN_feature_first_enters_PLANNING_THEN_fetches_issue_and_stores_in_frontmatter', async () => {
825
- const issueTracker = {
826
- getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
827
- addComment: vi.fn(async () => undefined),
828
- updateIssueStatus: vi.fn(async () => undefined),
829
- };
830
- const { coordinator, kernel } = makeCoordinatorDeps({ issueTracker });
2958
+ it('GIVEN_blocked_feature_with_passing_full_gate_WHEN_recoverBlockedFeatures_runs_THEN_marks_ready_to_merge', async () => {
2959
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
2960
+ toolCallerImpl: async (_role: string, toolName: string) => {
2961
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
2962
+ return {
2963
+ ok: true,
2964
+ data: {
2965
+ front_matter: {
2966
+ status: STATUS.BLOCKED,
2967
+ gates: { full: 'pass' },
2968
+ evidence: {},
2969
+ version: 4,
2970
+ recovery: {
2971
+ state: 'retrying',
2972
+ cause: 'provider_output_invalid',
2973
+ role: 'planner',
2974
+ },
2975
+ },
2976
+ },
2977
+ };
2978
+ }
2979
+ return { ok: true, data: {} };
2980
+ },
2981
+ });
831
2982
 
832
- await coordinator.start([{ feature_id: 'feature_x' }]);
2983
+ await (coordinator as any).recoverBlockedFeatures(['feature_blocked']);
833
2984
 
834
- expect(issueTracker.getIssue).toHaveBeenCalledWith('x');
835
- expect(kernel.updateState).toHaveBeenCalledWith('feature_x', null, expect.any(Function));
2985
+ expect(toolCaller.callTool).toHaveBeenCalledWith('orchestrator', TOOLS.FEATURE_STATE_PATCH, {
2986
+ feature_id: 'feature_blocked',
2987
+ expected_version: 4,
2988
+ patch: {
2989
+ front_matter: {
2990
+ status: STATUS.READY_TO_MERGE,
2991
+ status_reason: 'recovered_from_blocked:passing_gate_evidence',
2992
+ recovery: null,
2993
+ },
2994
+ },
2995
+ });
836
2996
  });
837
2997
 
838
- it('GIVEN_issue_tracker_WHEN_status_transitions_THEN_adds_comment', async () => {
839
- const issueTracker = {
840
- getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
841
- addComment: vi.fn(async () => undefined),
842
- updateIssueStatus: vi.fn(async () => undefined),
843
- };
844
- const { coordinator } = makeCoordinatorDeps({ issueTracker });
2998
+ it('GIVEN_blocked_feature_with_passing_full_gate_and_empty_checkpoint_diff_WHEN_recoverBlockedFeatures_runs_THEN_does_not_mark_ready_to_merge', async () => {
2999
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3000
+ toolCallerImpl: async (_role: string, toolName: string) => {
3001
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3002
+ return {
3003
+ ok: true,
3004
+ data: {
3005
+ front_matter: {
3006
+ status: STATUS.BLOCKED,
3007
+ gates: { full: 'pass' },
3008
+ evidence: {},
3009
+ version: 4,
3010
+ checkpoints: [
3011
+ {
3012
+ checkpoint_id: 'cp-empty',
3013
+ validation_status: 'valid',
3014
+ diff_hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
3015
+ net_new_worktree_change: false,
3016
+ },
3017
+ ],
3018
+ },
3019
+ },
3020
+ };
3021
+ }
3022
+ return { ok: true, data: {} };
3023
+ },
3024
+ });
845
3025
 
846
- await coordinator.start([{ feature_id: 'feature_x' }]);
3026
+ await (coordinator as any).recoverBlockedFeatures(['feature_blocked']);
847
3027
 
848
- expect(issueTracker.addComment).toHaveBeenCalledWith('x', expect.stringContaining('planning'));
3028
+ expect(
3029
+ toolCaller.callTool.mock.calls.some(
3030
+ (call) =>
3031
+ call[1] === TOOLS.FEATURE_STATE_PATCH &&
3032
+ (call[2] as { patch?: { front_matter?: { status?: string } } }).patch?.front_matter
3033
+ ?.status === STATUS.READY_TO_MERGE,
3034
+ ),
3035
+ ).toBe(false);
849
3036
  });
850
3037
 
851
- it('GIVEN_issue_tracker_WHEN_status_transitions_THEN_syncs_tracker_status_for_non_terminal_states', async () => {
852
- const issueTracker = {
853
- getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
854
- addComment: vi.fn(async () => undefined),
855
- updateIssueStatus: vi.fn(async () => undefined),
856
- };
857
- const { coordinator } = makeCoordinatorDeps({ issueTracker });
3038
+ it('GIVEN_blocked_feature_with_stale_fast_gate_evidence_WHEN_recoverBlockedFeatures_runs_THEN_refreshes_from_building', async () => {
3039
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3040
+ toolCallerImpl: async (_role: string, toolName: string) => {
3041
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3042
+ return {
3043
+ ok: true,
3044
+ data: {
3045
+ front_matter: {
3046
+ status: STATUS.BLOCKED,
3047
+ gates: { fast: 'fail' },
3048
+ evidence: { last_gate_mode: 'fast' },
3049
+ gate_retry_count: 0,
3050
+ version: 6,
3051
+ checkpoints: [
3052
+ {
3053
+ checkpoint_id: 'cp-1',
3054
+ validation_status: 'valid',
3055
+ diff_hash: 'diff-1',
3056
+ net_new_worktree_change: true,
3057
+ },
3058
+ ],
3059
+ },
3060
+ },
3061
+ };
3062
+ }
3063
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
3064
+ return { ok: true, data: { gate_evidence_by_mode: {}, latest_evidence: null } };
3065
+ }
3066
+ return { ok: true, data: {} };
3067
+ },
3068
+ });
3069
+
3070
+ await (coordinator as any).recoverBlockedFeatures(['feature_refresh']);
3071
+
3072
+ expect(toolCaller.callTool).toHaveBeenCalledWith('orchestrator', TOOLS.FEATURE_STATE_PATCH, {
3073
+ feature_id: 'feature_refresh',
3074
+ expected_version: 6,
3075
+ patch: {
3076
+ front_matter: {
3077
+ status: STATUS.BUILDING,
3078
+ status_reason: 'recovered_from_blocked:gate_refresh:fast',
3079
+ },
3080
+ },
3081
+ });
3082
+ });
3083
+
3084
+ it('GIVEN_blocked_feature_without_new_work_WHEN_recoverBlockedFeatures_runs_THEN_records_waiting_reason', async () => {
3085
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3086
+ toolCallerImpl: async (_role: string, toolName: string) => {
3087
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3088
+ return {
3089
+ ok: true,
3090
+ data: {
3091
+ front_matter: {
3092
+ status: STATUS.BLOCKED,
3093
+ gates: { fast: 'fail' },
3094
+ evidence: { last_gate_mode: 'fast' },
3095
+ gate_retry_count: 0,
3096
+ status_reason: 'blocked:legacy',
3097
+ version: 2,
3098
+ checkpoints: [
3099
+ {
3100
+ checkpoint_id: 'cp-2',
3101
+ validation_status: 'valid',
3102
+ net_new_worktree_change: false,
3103
+ },
3104
+ ],
3105
+ },
3106
+ },
3107
+ };
3108
+ }
3109
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
3110
+ return { ok: true, data: {} };
3111
+ }
3112
+ return { ok: true, data: {} };
3113
+ },
3114
+ });
3115
+
3116
+ await (coordinator as any).recoverBlockedFeatures(['feature_waiting']);
3117
+
3118
+ expect(toolCaller.callTool).toHaveBeenCalledWith('orchestrator', TOOLS.FEATURE_STATE_PATCH, {
3119
+ feature_id: 'feature_waiting',
3120
+ expected_version: 2,
3121
+ patch: {
3122
+ front_matter: {
3123
+ status: STATUS.BLOCKED,
3124
+ status_reason: 'blocked:gate_retry_waiting_for_new_work:fast',
3125
+ },
3126
+ },
3127
+ });
3128
+ });
3129
+
3130
+ it('GIVEN_blocked_feature_with_checkpoint_invalid_and_net_new_work_WHEN_recoverBlockedFeatures_runs_THEN_returns_to_building', async () => {
3131
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3132
+ toolCallerImpl: async (_role: string, toolName: string) => {
3133
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3134
+ return {
3135
+ ok: true,
3136
+ data: {
3137
+ front_matter: {
3138
+ version: 11,
3139
+ status: STATUS.BLOCKED,
3140
+ status_reason:
3141
+ 'interactive_checkpoint_invalid: Builder checkpoint invalid: lint error',
3142
+ gates: { plan: 'pass', fast: 'na', full: 'na' },
3143
+ evidence: {},
3144
+ checkpoints: [{ checkpoint_id: 'cp-1', net_new_worktree_change: true }],
3145
+ },
3146
+ },
3147
+ };
3148
+ }
3149
+ return { ok: true, data: {} };
3150
+ },
3151
+ });
3152
+
3153
+ await (coordinator as any).recoverBlockedFeatures(['feature_cp_invalid']);
3154
+
3155
+ expect(toolCaller.callTool).toHaveBeenCalledWith('orchestrator', TOOLS.FEATURE_STATE_PATCH, {
3156
+ feature_id: 'feature_cp_invalid',
3157
+ expected_version: 11,
3158
+ patch: {
3159
+ front_matter: {
3160
+ status: STATUS.BUILDING,
3161
+ status_reason: 'recovered_from_blocked:worker_retry:interactive_checkpoint_invalid',
3162
+ },
3163
+ },
3164
+ });
3165
+ });
3166
+
3167
+ it('GIVEN_blocked_feature_with_checkpoint_invalid_and_no_new_work_WHEN_recoverBlockedFeatures_runs_THEN_stays_blocked', async () => {
3168
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3169
+ toolCallerImpl: async (_role: string, toolName: string) => {
3170
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3171
+ return {
3172
+ ok: true,
3173
+ data: {
3174
+ front_matter: {
3175
+ version: 12,
3176
+ status: STATUS.BLOCKED,
3177
+ status_reason:
3178
+ 'interactive_checkpoint_invalid: Builder checkpoint invalid: lint error',
3179
+ gates: { plan: 'pass', fast: 'na', full: 'na' },
3180
+ evidence: {},
3181
+ checkpoints: [
3182
+ {
3183
+ checkpoint_id: 'cp-1',
3184
+ net_new_worktree_change: false,
3185
+ },
3186
+ ],
3187
+ },
3188
+ },
3189
+ };
3190
+ }
3191
+ return { ok: true, data: {} };
3192
+ },
3193
+ });
3194
+
3195
+ await (coordinator as any).recoverBlockedFeatures(['feature_cp_no_new']);
3196
+
3197
+ const statePatches = toolCaller.callTool.mock.calls.filter(
3198
+ (call: unknown[]) => call[1] === TOOLS.FEATURE_STATE_PATCH,
3199
+ );
3200
+ expect(statePatches).toHaveLength(0);
3201
+ });
858
3202
 
859
- await coordinator.start([{ feature_id: 'feature_x' }]);
3203
+ it('GIVEN_blocked_feature_with_provider_output_invalid_and_net_new_work_WHEN_recoverBlockedFeatures_runs_THEN_returns_to_building', async () => {
3204
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3205
+ toolCallerImpl: async (_role: string, toolName: string) => {
3206
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3207
+ return {
3208
+ ok: true,
3209
+ data: {
3210
+ front_matter: {
3211
+ version: 13,
3212
+ status: STATUS.BLOCKED,
3213
+ status_reason: 'provider_output_invalid: Builder emitted malformed worker outputs',
3214
+ gates: { plan: 'pass', fast: 'na', full: 'na' },
3215
+ evidence: {},
3216
+ checkpoints: [{ checkpoint_id: 'cp-2', net_new_worktree_change: true }],
3217
+ },
3218
+ },
3219
+ };
3220
+ }
3221
+ return { ok: true, data: {} };
3222
+ },
3223
+ });
860
3224
 
861
- expect(issueTracker.updateIssueStatus).toHaveBeenCalledWith('x', STATUS.PLANNING);
3225
+ await (coordinator as any).recoverBlockedFeatures(['feature_output_invalid']);
3226
+
3227
+ expect(toolCaller.callTool).toHaveBeenCalledWith('orchestrator', TOOLS.FEATURE_STATE_PATCH, {
3228
+ feature_id: 'feature_output_invalid',
3229
+ expected_version: 13,
3230
+ patch: {
3231
+ front_matter: {
3232
+ status: STATUS.BUILDING,
3233
+ status_reason: 'recovered_from_blocked:worker_retry:provider_output_invalid',
3234
+ },
3235
+ },
3236
+ });
862
3237
  });
863
3238
 
864
- it('GIVEN_issue_tracker_WHEN_status_is_MERGED_THEN_updates_issue_status_to_closed', async () => {
865
- const issueTracker = {
866
- getIssue: vi.fn(async () => ({ id: '123', title: 'Test Issue', url: 'http://test' })),
867
- addComment: vi.fn(async () => undefined),
868
- updateIssueStatus: vi.fn(async () => undefined),
869
- };
870
- let callCount = 0;
871
- const { coordinator } = makeCoordinatorDeps({
872
- toolCallerImpl: async (_role, toolName) => {
3239
+ it('GIVEN_blocked_feature_with_retryable_full_failure_WHEN_recoverBlockedFeatures_runs_THEN_returns_to_qa', async () => {
3240
+ const { coordinator, toolCaller } = makeCoordinatorDeps({
3241
+ toolCallerImpl: async (_role: string, toolName: string) => {
873
3242
  if (toolName === TOOLS.FEATURE_STATE_GET) {
874
- callCount += 1;
875
- const status = callCount === 1 ? STATUS.PLANNING : STATUS.MERGED;
876
- return { ok: true, data: { front_matter: { status } } };
3243
+ return {
3244
+ ok: true,
3245
+ data: {
3246
+ front_matter: {
3247
+ status: STATUS.BLOCKED,
3248
+ gates: { full: 'fail' },
3249
+ evidence: { last_gate_mode: 'full' },
3250
+ gate_retry_count: 0,
3251
+ version: 9,
3252
+ checkpoints: [
3253
+ {
3254
+ checkpoint_id: 'cp-3',
3255
+ validation_status: 'valid',
3256
+ net_new_worktree_change: true,
3257
+ },
3258
+ ],
3259
+ },
3260
+ },
3261
+ };
877
3262
  }
878
- if (toolName === TOOLS.REPORT_DASHBOARD) {
879
- return { ok: true, data: { features: [] } };
3263
+ if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
3264
+ return { ok: true, data: {} };
880
3265
  }
881
3266
  return { ok: true, data: {} };
882
3267
  },
883
- issueTracker,
884
3268
  });
885
3269
 
886
- await coordinator.start([{ feature_id: 'feature_x' }]);
3270
+ await (coordinator as any).recoverBlockedFeatures(['feature_retry']);
887
3271
 
888
- expect(issueTracker.updateIssueStatus).toHaveBeenCalledWith('x', STATUS.MERGED);
3272
+ expect(toolCaller.callTool).toHaveBeenCalledWith('orchestrator', TOOLS.FEATURE_STATE_PATCH, {
3273
+ feature_id: 'feature_retry',
3274
+ expected_version: 9,
3275
+ patch: {
3276
+ front_matter: {
3277
+ status: STATUS.QA,
3278
+ status_reason: 'recovered_from_blocked:gate_retry:full',
3279
+ },
3280
+ },
3281
+ });
889
3282
  });
3283
+ });
890
3284
 
891
- it('GIVEN_same_status_seen_twice_WHEN_notifyStatusTransitions_THEN_skips_duplicate_notification', async () => {
892
- const notifier = { notify: vi.fn(async () => undefined) };
893
- const { coordinator } = makeCoordinatorDeps({
894
- // always returns PLANNING — second iteration sees same status, should not re-notify
895
- notifier,
896
- maxIterationsPerPhase: 2,
897
- });
3285
+ describe('RunCoordinator checkpoint freshness helpers', () => {
3286
+ const hasNetNewCheckpointWork = (frontMatter: Record<string, unknown>): boolean =>
3287
+ (
3288
+ RunCoordinator as unknown as {
3289
+ hasNetNewCheckpointWork: (frontMatter: Record<string, unknown>) => boolean;
3290
+ }
3291
+ ).hasNetNewCheckpointWork(frontMatter);
898
3292
 
899
- await coordinator.start([{ feature_id: 'feature_x' }]);
3293
+ it('treats missing checkpoints as net-new work to avoid false negatives', () => {
3294
+ expect(hasNetNewCheckpointWork({})).toBe(true);
3295
+ });
900
3296
 
901
- // notify called once only second iteration sees same status, skipped
902
- const planningNotifies = notifier.notify.mock.calls.filter(
903
- (c: unknown[]) => c[0] !== 'budget_alert' && c[0] !== 'budget_exceeded',
904
- );
905
- expect(planningNotifies.length).toBe(0); // PLANNING has no notifier branch, just confirm no error
3297
+ it('treats malformed latest checkpoints as net-new work to avoid blocking retries', () => {
3298
+ expect(hasNetNewCheckpointWork({ checkpoints: ['invalid-checkpoint'] })).toBe(true);
906
3299
  });
907
3300
 
908
- it('GIVEN_issue_tracker_returns_issue_without_title_WHEN_first_planning_transition_THEN_skips_updateState', async () => {
909
- const issueTracker = {
910
- getIssue: vi.fn(async () => ({ id: '123', title: '', url: 'http://test' })),
911
- addComment: vi.fn(async () => undefined),
912
- updateIssueStatus: vi.fn(async () => undefined),
913
- };
914
- const { coordinator, kernel } = makeCoordinatorDeps({ issueTracker });
3301
+ it('treats duplicate checkpoints as not net-new work', () => {
3302
+ expect(
3303
+ hasNetNewCheckpointWork({
3304
+ checkpoints: [
3305
+ {
3306
+ checkpoint_id: 'checkpoint-2',
3307
+ duplicate_of_checkpoint_id: 'checkpoint-1',
3308
+ },
3309
+ ],
3310
+ }),
3311
+ ).toBe(false);
3312
+ });
915
3313
 
916
- await coordinator.start([{ feature_id: 'feature_x' }]);
3314
+ it('treats explicit no-new-work checkpoints as not net-new work', () => {
3315
+ expect(
3316
+ hasNetNewCheckpointWork({
3317
+ checkpoints: [
3318
+ {
3319
+ checkpoint_id: 'checkpoint-2',
3320
+ net_new_worktree_change: false,
3321
+ },
3322
+ ],
3323
+ }),
3324
+ ).toBe(false);
3325
+ });
917
3326
 
918
- expect(issueTracker.getIssue).toHaveBeenCalledWith('x');
919
- // updateState should NOT be called since issue has no title
920
- const stateUpdateForIssue = kernel.updateState.mock.calls.filter(
921
- (c: unknown[]) => c[0] === 'feature_x',
922
- );
923
- expect(stateUpdateForIssue.length).toBe(0);
3327
+ it('treats checkpoints with positive net-new work as retry-eligible', () => {
3328
+ expect(
3329
+ hasNetNewCheckpointWork({
3330
+ checkpoints: [
3331
+ {
3332
+ checkpoint_id: 'checkpoint-3',
3333
+ net_new_worktree_change: true,
3334
+ },
3335
+ ],
3336
+ }),
3337
+ ).toBe(true);
924
3338
  });
925
3339
 
926
- it('GIVEN_priority_order_with_unknown_feature_WHEN_applyOrchestratorPrioritization_runs_THEN_skips_unknown_and_appends_rest', async () => {
927
- const workerDecisionRunner = {
928
- execute: vi.fn(async () => ({
929
- planSubmission: false,
930
- patchApplied: false,
931
- noteLogged: false,
932
- requestHandled: true,
933
- priorityOrder: ['unknown_feature', 'feature_b'],
934
- toolResults: [],
935
- })),
936
- };
3340
+ it('ignores blank duplicate checkpoint ids when determining net-new work', () => {
3341
+ expect(
3342
+ hasNetNewCheckpointWork({
3343
+ checkpoints: [
3344
+ {
3345
+ checkpoint_id: 'checkpoint-4',
3346
+ duplicate_of_checkpoint_id: ' ',
3347
+ },
3348
+ ],
3349
+ }),
3350
+ ).toBe(true);
3351
+ });
3352
+ });
937
3353
 
938
- const { ...rest } = makeCoordinatorDeps({ maxActiveFeatures: 2, maxIterationsPerPhase: 1 });
939
- // Rebuild coordinator with workerDecisionRunner
940
- const kernel2 = {
3354
+ describe('RunCoordinator issue tracker helpers', () => {
3355
+ function makeIssueCoordinator(issueTracker?: {
3356
+ getIssue: ReturnType<typeof vi.fn>;
3357
+ addComment: ReturnType<typeof vi.fn>;
3358
+ updateIssueStatus: ReturnType<typeof vi.fn>;
3359
+ }) {
3360
+ const kernel = {
941
3361
  ensureLoaded: vi.fn(async () => undefined),
942
3362
  recoverFromState: vi.fn(async () => ({ data: { recovered: true } })),
943
3363
  acquireRunLease: vi.fn(async () => ({ data: { took_over_stale: false } })),
3364
+ releaseRunLease: vi.fn(async () => ({ data: { released: true } })),
944
3365
  pruneFeatureSessionAssignments: vi.fn(async () => ({ data: { removed: [] as string[] } })),
945
3366
  getRuntimeSessions: vi.fn(async () => ({
946
3367
  run_id: 'run:test',
@@ -948,6 +3369,11 @@ describe('RunCoordinator notification and budget branches', () => {
948
3369
  orchestrator_session_id: 'orch:test',
949
3370
  feature_sessions: {},
950
3371
  })),
3372
+ getPolicySnapshot: vi.fn(() => ({
3373
+ reactions: {
3374
+ gate_failed: { enabled: true, action: 'retry_with_agent_repair', max_retries: 2 },
3375
+ },
3376
+ })),
951
3377
  updateState: vi.fn(async () => ({})),
952
3378
  checkBudget: vi.fn(async () => ({
953
3379
  over_budget: false,
@@ -956,37 +3382,29 @@ describe('RunCoordinator notification and budget branches', () => {
956
3382
  limit_usd: -1,
957
3383
  alert_threshold: 0.8,
958
3384
  })),
3385
+ drainPendingExecutionRequests: vi.fn(async () => ({ data: { items: [] } })),
3386
+ markExecutionRequestProcessed: vi.fn(async () => ({ data: { updated: true } })),
959
3387
  };
960
- const state2 = {
961
- runId: 'run:test',
962
- ownerInstanceId: 'owner:test',
963
- orchestratorSessionId: 'orch:test',
964
- sessionsByFeature: new Map<string, { planner: string; builder: string; qa: string }>(),
965
- queue: [] as Array<{ feature_id: string }>,
966
- runMetadata: {},
967
- };
968
- const toolCaller2 = {
969
- callTool: vi.fn(async (_role: string, toolName: string) => {
970
- if (toolName === 'report.dashboard') {
971
- return { ok: true, data: { features: [] } };
972
- }
973
- return { ok: true, data: { front_matter: { status: STATUS.PLANNING } } };
974
- }),
975
- };
976
- const sessionOrchestrator2 = {
977
- ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
978
- cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
979
- initializeFeatureCluster: vi.fn(async () => undefined),
980
- closeFeatureCluster: vi.fn(async () => undefined),
981
- reconcileQueuedFeatures: vi.fn(async () => undefined),
982
- enforceActiveFeatureInvariant: vi.fn(async () => undefined),
983
- };
984
- const coord2 = new RunCoordinator({
985
- kernel: kernel2 as never,
986
- provider: { selection: { provider: 'test', model: 'test' } } as never,
987
- toolCaller: toolCaller2 as never,
988
- state: state2 as never,
989
- sessionOrchestrator: sessionOrchestrator2 as never,
3388
+ const coordinator = new RunCoordinator({
3389
+ kernel: kernel as never,
3390
+ provider: { selection: { provider: 'test', model: 'test-model' } } as never,
3391
+ toolCaller: { callTool: vi.fn(async () => ({ ok: true, data: {} })) } as never,
3392
+ state: {
3393
+ runId: 'run:test',
3394
+ ownerInstanceId: 'owner:test',
3395
+ orchestratorSessionId: 'orch:test',
3396
+ sessionsByFeature: new Map(),
3397
+ queue: [],
3398
+ runMetadata: {},
3399
+ } as never,
3400
+ sessionOrchestrator: {
3401
+ ensureGlobalOrchestratorSession: vi.fn(async () => undefined),
3402
+ cleanupOrphanWorkerSessions: vi.fn(async () => undefined),
3403
+ initializeFeatureCluster: vi.fn(async () => undefined),
3404
+ closeFeatureCluster: vi.fn(async () => undefined),
3405
+ reconcileQueuedFeatures: vi.fn(async () => undefined),
3406
+ enforceActiveFeatureInvariant: vi.fn(async () => undefined),
3407
+ } as never,
990
3408
  planningWaveExecutor: {
991
3409
  run: vi.fn(async () => undefined),
992
3410
  runPostQaReconciliation: vi.fn(async () => undefined),
@@ -994,17 +3412,257 @@ describe('RunCoordinator notification and budget branches', () => {
994
3412
  buildWaveExecutor: { run: vi.fn(async () => undefined) } as never,
995
3413
  qaWaveExecutor: { run: vi.fn(async () => undefined) } as never,
996
3414
  leaseHeartbeatService: { renew: vi.fn(async () => undefined) } as never,
997
- maxActiveFeatures: 2,
3415
+ maxActiveFeatures: 1,
3416
+ maxParallelGateRuns: 1,
3417
+ maxIterationsPerPhase: 1,
3418
+ takeoverStaleRun: false,
3419
+ providerConfigRefHash: () => 'hash',
3420
+ issueTracker: issueTracker as never,
3421
+ });
3422
+
3423
+ return { coordinator, kernel };
3424
+ }
3425
+
3426
+ it('returns early when issue tracking is not configured', async () => {
3427
+ const { coordinator, kernel } = makeIssueCoordinator();
3428
+ await (
3429
+ coordinator as unknown as {
3430
+ syncIssueTrackerStatus: (
3431
+ featureId: string,
3432
+ status: string,
3433
+ prevStatus: string | undefined,
3434
+ ) => Promise<void>;
3435
+ }
3436
+ ).syncIssueTrackerStatus('feature_no_issue_tracker', STATUS.BLOCKED, STATUS.PLANNING);
3437
+
3438
+ expect(kernel.updateState).not.toHaveBeenCalled();
3439
+ });
3440
+
3441
+ it('stores fetched issue context when planning starts with a titled issue', async () => {
3442
+ const issueTracker = {
3443
+ getIssue: vi.fn(async () => ({
3444
+ id: 'ISSUE-1',
3445
+ title: 'Investigate resume loop',
3446
+ body: 'Details',
3447
+ status: 'open',
3448
+ url: 'https://example.test/issues/1',
3449
+ })),
3450
+ addComment: vi.fn(async () => undefined),
3451
+ updateIssueStatus: vi.fn(async () => undefined),
3452
+ };
3453
+ const { coordinator, kernel } = makeIssueCoordinator(issueTracker);
3454
+
3455
+ await (
3456
+ coordinator as unknown as {
3457
+ syncIssueTrackerStatus: (
3458
+ featureId: string,
3459
+ status: string,
3460
+ prevStatus: string | undefined,
3461
+ ) => Promise<void>;
3462
+ }
3463
+ ).syncIssueTrackerStatus('feature_resume_loop', STATUS.PLANNING, undefined);
3464
+
3465
+ expect(kernel.updateState).toHaveBeenCalledTimes(1);
3466
+ expect(issueTracker.addComment).toHaveBeenCalledWith(
3467
+ 'resume_loop',
3468
+ 'AOP: feature status changed to `planning`',
3469
+ );
3470
+ });
3471
+
3472
+ it('skips issue-context persistence when the fetched issue has no title', async () => {
3473
+ const issueTracker = {
3474
+ getIssue: vi.fn(async () => ({
3475
+ id: 'ISSUE-2',
3476
+ title: '',
3477
+ body: 'Details',
3478
+ status: 'open',
3479
+ url: 'https://example.test/issues/2',
3480
+ })),
3481
+ addComment: vi.fn(async () => undefined),
3482
+ updateIssueStatus: vi.fn(async () => undefined),
3483
+ };
3484
+ const { coordinator, kernel } = makeIssueCoordinator(issueTracker);
3485
+
3486
+ await (
3487
+ coordinator as unknown as {
3488
+ syncIssueTrackerStatus: (
3489
+ featureId: string,
3490
+ status: string,
3491
+ prevStatus: string | undefined,
3492
+ ) => Promise<void>;
3493
+ }
3494
+ ).syncIssueTrackerStatus('feature_missing_title', STATUS.PLANNING, undefined);
3495
+
3496
+ expect(kernel.updateState).not.toHaveBeenCalled();
3497
+ expect(issueTracker.addComment).toHaveBeenCalledWith(
3498
+ 'missing_title',
3499
+ 'AOP: feature status changed to `planning`',
3500
+ );
3501
+ });
3502
+ });
3503
+
3504
+ describe('prepareFeaturesForPhase divergence handling', () => {
3505
+ it('GIVEN_reconcile_throws_remote_local_divergence_WHEN_prepare_phase_THEN_patches_to_blocked_and_notifies', async () => {
3506
+ const notifySpy = vi.fn();
3507
+
3508
+ const callToolMock = vi.fn(
3509
+ async (_role: string, toolName: string, _args?: Record<string, unknown>) => {
3510
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3511
+ return {
3512
+ ok: true,
3513
+ data: {
3514
+ front_matter: {
3515
+ status: 'building',
3516
+ version: 1,
3517
+ },
3518
+ },
3519
+ };
3520
+ }
3521
+ if (toolName === TOOLS.REPO_RECONCILE_MAINLINE) {
3522
+ const err = new Error('Local main differs from origin/main') as Error & {
3523
+ code?: string;
3524
+ };
3525
+ err.code = 'remote_local_divergence';
3526
+ throw err;
3527
+ }
3528
+ if (toolName === TOOLS.FEATURE_STATE_PATCH) {
3529
+ return { ok: true, data: {} };
3530
+ }
3531
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
3532
+ return { ok: true, data: { features: [] } };
3533
+ }
3534
+ return { ok: true, data: {} };
3535
+ },
3536
+ );
3537
+
3538
+ const coordinator = new RunCoordinator({
3539
+ kernel: {
3540
+ getPolicySnapshot: () => ({
3541
+ reconciliation: {
3542
+ enabled: true,
3543
+ check_before_phases: ['building'],
3544
+ auto_merge_non_conflicting: true,
3545
+ },
3546
+ }),
3547
+ } as never,
3548
+ provider: { selection: { provider: 'test', model: 'test' } } as never,
3549
+ toolCaller: { callTool: callToolMock } as never,
3550
+ state: { runId: 'run:test', ownerInstanceId: 'owner:test' } as never,
3551
+ sessionOrchestrator: {} as never,
3552
+ planningWaveExecutor: {} as never,
3553
+ buildWaveExecutor: {} as never,
3554
+ qaWaveExecutor: {} as never,
3555
+ leaseHeartbeatService: {} as never,
3556
+ maxActiveFeatures: 1,
998
3557
  maxParallelGateRuns: 2,
999
3558
  maxIterationsPerPhase: 1,
1000
3559
  takeoverStaleRun: false,
1001
3560
  providerConfigRefHash: () => 'hash',
1002
- workerDecisionRunner: workerDecisionRunner as never,
3561
+ notifier: { notify: notifySpy } as never,
1003
3562
  });
1004
3563
 
1005
- void rest; // suppress unused warning
1006
- await coord2.start([{ feature_id: 'feature_a' }, { feature_id: 'feature_b' }]);
1007
- // Should complete without error; unknown_feature was skipped in prioritization
1008
- expect(workerDecisionRunner.execute).toHaveBeenCalled();
3564
+ const eligible = await (
3565
+ coordinator as unknown as {
3566
+ prepareFeaturesForPhase: (ids: string[], phase: string) => Promise<string[]>;
3567
+ }
3568
+ ).prepareFeaturesForPhase(['feat_diverged'], 'building');
3569
+
3570
+ expect(eligible).toEqual([]);
3571
+
3572
+ // Verify FEATURE_STATE_PATCH was called to set BLOCKED
3573
+ const patchCall = callToolMock.mock.calls.find(
3574
+ (c: unknown[]) => c[1] === TOOLS.FEATURE_STATE_PATCH,
3575
+ );
3576
+ expect(patchCall).toBeDefined();
3577
+ const patchArgs = patchCall?.[2];
3578
+ expect(patchArgs).toMatchObject({
3579
+ feature_id: 'feat_diverged',
3580
+ patch: {
3581
+ front_matter: {
3582
+ status: 'blocked',
3583
+ status_reason: 'remote_local_divergence',
3584
+ },
3585
+ },
3586
+ });
3587
+
3588
+ // Verify notification was emitted
3589
+ expect(notifySpy).toHaveBeenCalledWith(
3590
+ 'remote_local_divergence',
3591
+ expect.objectContaining({
3592
+ feature_id: 'feat_diverged',
3593
+ }),
3594
+ );
3595
+ });
3596
+
3597
+ it('GIVEN_first_feature_throws_divergence_WHEN_prepare_phase_THEN_second_feature_still_evaluated', async () => {
3598
+ const notifySpy = vi.fn();
3599
+
3600
+ let reconcileCallCount = 0;
3601
+ const callToolMock = vi.fn(
3602
+ async (_role: string, toolName: string, _args?: Record<string, unknown>) => {
3603
+ if (toolName === TOOLS.FEATURE_STATE_GET) {
3604
+ return {
3605
+ ok: true,
3606
+ data: {
3607
+ front_matter: {
3608
+ status: 'building',
3609
+ version: 1,
3610
+ },
3611
+ },
3612
+ };
3613
+ }
3614
+ if (toolName === TOOLS.REPO_RECONCILE_MAINLINE) {
3615
+ reconcileCallCount++;
3616
+ if (reconcileCallCount === 1) {
3617
+ const err = new Error('diverged') as Error & { code?: string };
3618
+ err.code = 'remote_local_divergence';
3619
+ throw err;
3620
+ }
3621
+ return { ok: true, data: { status: 'up_to_date' } };
3622
+ }
3623
+ if (toolName === TOOLS.FEATURE_STATE_PATCH) {
3624
+ return { ok: true, data: {} };
3625
+ }
3626
+ if (toolName === TOOLS.REPORT_DASHBOARD) {
3627
+ return { ok: true, data: { features: [] } };
3628
+ }
3629
+ return { ok: true, data: {} };
3630
+ },
3631
+ );
3632
+
3633
+ const coordinator = new RunCoordinator({
3634
+ kernel: {
3635
+ getPolicySnapshot: () => ({
3636
+ reconciliation: {
3637
+ enabled: true,
3638
+ check_before_phases: ['building'],
3639
+ auto_merge_non_conflicting: true,
3640
+ },
3641
+ }),
3642
+ } as never,
3643
+ provider: { selection: { provider: 'test', model: 'test' } } as never,
3644
+ toolCaller: { callTool: callToolMock } as never,
3645
+ state: { runId: 'run:test', ownerInstanceId: 'owner:test' } as never,
3646
+ sessionOrchestrator: {} as never,
3647
+ planningWaveExecutor: {} as never,
3648
+ buildWaveExecutor: {} as never,
3649
+ qaWaveExecutor: {} as never,
3650
+ leaseHeartbeatService: {} as never,
3651
+ maxActiveFeatures: 1,
3652
+ maxParallelGateRuns: 2,
3653
+ maxIterationsPerPhase: 1,
3654
+ takeoverStaleRun: false,
3655
+ providerConfigRefHash: () => 'hash',
3656
+ notifier: { notify: notifySpy } as never,
3657
+ });
3658
+
3659
+ const eligible = await (
3660
+ coordinator as unknown as {
3661
+ prepareFeaturesForPhase: (ids: string[], phase: string) => Promise<string[]>;
3662
+ }
3663
+ ).prepareFeaturesForPhase(['feat_bad', 'feat_good'], 'building');
3664
+
3665
+ expect(eligible).toEqual(['feat_good']);
3666
+ expect(reconcileCallCount).toBe(2);
1009
3667
  });
1010
3668
  });