@tea-agent/loop-agent 0.44.0-next.1 → 0.44.0-next.11

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 (245) hide show
  1. package/CHANGELOG.md +379 -416
  2. package/dist/application/evaluation/budget.js +21 -0
  3. package/dist/application/evaluation/corpus-hash.js +10 -15
  4. package/dist/application/evaluation/corpus.js +2 -1
  5. package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
  6. package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
  7. package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
  8. package/dist/application/evaluation/frontend-pair-registration.js +143 -0
  9. package/dist/application/evaluation/frontend-paired-summary.js +150 -0
  10. package/dist/application/evaluation/frontend-run-observation.js +144 -0
  11. package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
  12. package/dist/application/evaluation/types.js +45 -12
  13. package/dist/application/task-lifecycle/observe.js +26 -1
  14. package/dist/build-stamp.json +3 -3
  15. package/dist/cli/command-definitions.js +11 -0
  16. package/dist/cli/program.js +4 -0
  17. package/dist/commands/task-advance.js +3 -5
  18. package/dist/commands/task-source-prepare.js +13 -1
  19. package/dist/executors/dag-pi/guards.js +4 -0
  20. package/dist/executors/dag-pi/model-config.js +30 -0
  21. package/dist/executors/dag-pi/plan/fact-adoption.js +55 -0
  22. package/dist/{workflows/dag/frontend-plan-completeness.js → executors/dag-pi/plan/facts.js} +22 -28
  23. package/dist/executors/dag-pi/plan/finalize-tools.js +259 -0
  24. package/dist/executors/dag-pi/plan/ledger-contract.js +55 -0
  25. package/dist/executors/dag-pi/plan/record-tools.js +861 -0
  26. package/dist/executors/dag-pi/plan/schema.js +129 -0
  27. package/dist/executors/dag-pi/plan/session.js +1 -0
  28. package/dist/executors/dag-pi/plan/tool-helpers.js +16 -0
  29. package/dist/executors/dag-pi/sessions/index.js +6 -0
  30. package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
  31. package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
  32. package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
  33. package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
  34. package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
  35. package/dist/executors/dag-pi/tools/contract-tools.js +515 -0
  36. package/dist/executors/dag-pi/tools/design-terminal-tools.js +159 -0
  37. package/dist/executors/dag-pi/tools/plan-decision-tools.js +529 -0
  38. package/dist/executors/dag-pi/tools/review-terminal-tools.js +159 -0
  39. package/dist/executors/dag-pi/tools/scout-evidence-tools.js +270 -0
  40. package/dist/executors/dag-pi-executor.js +733 -5782
  41. package/dist/executors/pi-executor.js +6 -1
  42. package/dist/executors/pi-sdk-executor.js +112 -19
  43. package/dist/executors/shell-executor.js +88 -12
  44. package/dist/executors/shell-presets.js +1 -1
  45. package/dist/executors/shell-write-guard.js +26 -3
  46. package/dist/infrastructure/console/operation-store.js +48 -14
  47. package/dist/infrastructure/evaluation/corpus-store.js +37 -12
  48. package/dist/shared/file-identity.js +23 -0
  49. package/dist/shared/git-workspace-status.js +482 -0
  50. package/dist/shared/operator/capabilities.js +10 -5
  51. package/dist/shared/path-safety.js +2 -2
  52. package/dist/shared/wizard-local-telemetry.js +51 -0
  53. package/dist/task/config-types.js +22 -8
  54. package/dist/task/contract/project.js +13 -0
  55. package/dist/task/contract/schema.js +2 -7
  56. package/dist/task/frontend-preflight.js +1 -1
  57. package/dist/task/source-prepare/parse-intent.js +124 -5
  58. package/dist/task/source-prepare/prepare.js +6 -1
  59. package/dist/task/source-prepare/semantic-intake.js +18 -3
  60. package/dist/task/source-prepare/source-execution.js +65 -0
  61. package/dist/task/source-prepare/source-fidelity-pi.js +22 -9
  62. package/dist/task/source-prepare/source-provider-budget.js +290 -0
  63. package/dist/worker/console/chat/assistant-content.js +5 -34
  64. package/dist/worker/console/chat/chat-event-store.js +62 -10
  65. package/dist/worker/console/chat/context-insights.js +723 -0
  66. package/dist/worker/console/chat/conversation-nodes.js +383 -0
  67. package/dist/worker/console/chat/db-connections.js +854 -0
  68. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +1 -1
  69. package/dist/worker/console/chat/pi-runtime/custom-tools/sql-tools.js +655 -0
  70. package/dist/worker/console/chat/pi-runtime.js +445 -36
  71. package/dist/worker/console/chat/provider-error.js +0 -12
  72. package/dist/worker/console/chat/repo-browser.js +52 -4
  73. package/dist/worker/console/chat/routes.js +577 -72
  74. package/dist/worker/console/chat/sdd-data-alignment.js +8 -31
  75. package/dist/worker/console/chat/session-store.js +147 -19
  76. package/dist/worker/console/chat/shortcuts.js +7 -0
  77. package/dist/worker/console/chat/timeline-message-order.js +22 -0
  78. package/dist/worker/console/chat/tools.js +34 -0
  79. package/dist/worker/console/chat/turn-process.js +237 -66
  80. package/dist/worker/console/chat/user-turn-outline.js +19 -0
  81. package/dist/worker/console/console-update-runtime.js +2 -2
  82. package/dist/worker/console/dag-confirmation.js +3 -0
  83. package/dist/worker/console/dag-execution-receipt.js +3 -0
  84. package/dist/worker/console/operator-actions.js +32 -3
  85. package/dist/worker/console/operator-user-error.js +28 -1
  86. package/dist/worker/console/pi-readiness.js +4 -4
  87. package/dist/worker/console/prd-intake-bridge.js +5 -12
  88. package/dist/worker/console/recovery-cta.js +14 -14
  89. package/dist/worker/console/server.js +5 -1
  90. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-8-j6y-sd.js → abnfDiagram-N423BO3Z-r1NAPU_5.js} +1 -1
  91. package/dist/worker/console/static/assets/{arc-eoQiMvuk.js → arc-dT5T4sVL.js} +1 -1
  92. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C4A3uMcI.js → architectureDiagram-T3A2C74G-CP_cOq46.js} +1 -1
  93. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-D4zD2F-Q.js → blockDiagram-VBNYF7ZC-sToEZxEf.js} +1 -1
  94. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-j1RkJziL.js → c4Diagram-5PPSVZJV-DSxwwi2m.js} +1 -1
  95. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +1 -0
  96. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-YBHmhik1.js → chunk-2GRJ4B5K-B9fh0IkF.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-COfWyo9P.js → chunk-2Q5K7J3B-CAUNl-A6.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-I99OUkHY.js → chunk-5RXB4S5H-BomeqbNX.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-XKxoJNJ4.js → chunk-5VM5RSS4-BNAuTOLE.js} +1 -1
  100. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DLT_cYx1.js → chunk-6Q2QTUOP-DfzjRD5y.js} +1 -1
  101. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CxcKZ9mV.js → chunk-GF5L2VYU-C3uE-yc2.js} +1 -1
  102. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-V1EqXdjY.js → chunk-JWPE2WC7-C3sQMMYr.js} +1 -1
  103. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DebTtdFp.js → chunk-KBJHAD2P-D_AtEYQL.js} +1 -1
  104. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-B-ivNRDf.js → chunk-RYQCIY6F-DW4TwhKv.js} +1 -1
  105. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DC_Ds11b.js → chunk-XXDRQBXY-CTQDmJcp.js} +1 -1
  106. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +1 -0
  107. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +1 -0
  108. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-sWEqKwIb.js → cose-bilkent-JH36ORCC-iMqPuBsL.js} +1 -1
  109. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BXa_dcu4.js → cynefin-VYW2F7L2-DktIuDWs.js} +1 -1
  110. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-C-Mle84F.js → cynefinDiagram-MW4NZA55-BgVnjyEs.js} +1 -1
  111. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CXPDBITe.js → dagre-VZM6K2ZE-CR25E4bu.js} +1 -1
  112. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CC-WJQfa.js → diagram-7IWD3JNH-CPM2h5HY.js} +1 -1
  113. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CDAV5Vs4.js → diagram-B4RE2ZJO-DqF0x2g1.js} +1 -1
  114. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CmFiAcNz.js → diagram-LBJQPF4R-G8BYp2eb.js} +1 -1
  115. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DPBZHuyn.js → diagram-Q27KOJAE-BbC4kT_D.js} +1 -1
  116. package/dist/worker/console/static/assets/{diagram-UB23O5K3-jUlm_Ds3.js → diagram-UB23O5K3-CKZwZ-G5.js} +1 -1
  117. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DWhQ3mfY.js → ebnfDiagram-BXEA7PRR-kJDmheia.js} +1 -1
  118. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Tr2gMqet.js → erDiagram-JOGREHBK-DFTVHyxB.js} +1 -1
  119. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DJjVQHPA.js → flowDiagram-UKHOOZJN-BqdN5_os.js} +1 -1
  120. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-D74dQ4u0.js → ganttDiagram-PKOTCBZU-SYDjVakh.js} +1 -1
  121. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js → gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js} +1 -1
  122. package/dist/worker/console/static/assets/index-B28Onmfy.css +1 -0
  123. package/dist/worker/console/static/assets/index-CTMZsDmh.js +486 -0
  124. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-ILCbxJyb.js → infoDiagram-6WML65LV-DIeAmJ45.js} +1 -1
  125. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DeuWBQ89.js → ishikawaDiagram-WSZJBQD7-oikqUAlo.js} +1 -1
  126. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CF5ih8Fk.js → journeyDiagram-NVQOT4AX-mKEF_xYT.js} +1 -1
  127. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-C7yOSuRO.js → kanban-definition-27J2QSJJ-BdWJHPnN.js} +1 -1
  128. package/dist/worker/console/static/assets/{linear-BDZ9riWi.js → linear-DGRlieMH.js} +1 -1
  129. package/dist/worker/console/static/assets/{mermaid.core-7pKqYtpZ.js → mermaid.core-4P9yPC9c.js} +5 -5
  130. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-LeJDybSU.js → mindmap-definition-FAOFIHXS-5PTSkos6.js} +1 -1
  131. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BJvT3pMD.js → pegDiagram-VL7TDLO6-DONIFpG5.js} +1 -1
  132. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-_rGqpMan.js → pieDiagram-7S7Q4E2Y-D_Z4S70M.js} +1 -1
  133. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DPcSv5aA.js → quadrantDiagram-CIZ2JOQS-CAsjh8iY.js} +1 -1
  134. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Drxx4hkJ.js → railroadDiagram-AXF67PYL-Cx4Dd6ac.js} +1 -1
  135. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BCTUdU4z.js → requirementDiagram-LRYGKXZP-cQb7mwyH.js} +1 -1
  136. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B6wzbZmB.js → sankeyDiagram-W5VNT64P-Cd-DhUdf.js} +1 -1
  137. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BGt8d3QQ.js → sequenceDiagram-SI44F4Z6-AlKYRgIk.js} +1 -1
  138. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-7nlhJKo7.js → sizeCapture-X5ZJPWSS-CUy_ET4e.js} +1 -1
  139. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Cv2stqMA.js → stateDiagram-OKZ733FA-CTuVe6Sa.js} +1 -1
  140. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +1 -0
  141. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-qDAo4Yc1.js → swimlanes-SLNWSIFB-ChFLJb-v.js} +2 -2
  142. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +8 -0
  143. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CKbDNKTr.js → timeline-definition-Z64GVDOM-DZZeiAPr.js} +1 -1
  144. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DI-9EHic.js → vennDiagram-T6HMQDX7-t4O91cZU.js} +1 -1
  145. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BrzzRDpX.js → wardleyDiagram-T6FBY63Y-CV5qGHZu.js} +1 -1
  146. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BclH5hGh.js → xychartDiagram-ELKLHX3M-DyXbytUI.js} +1 -1
  147. package/dist/worker/console/static/index.html +2 -2
  148. package/dist/worker/console/static-src/app/console-types.js +9 -1
  149. package/dist/worker/console/static-src/operator-chat/animation-frame-batcher.js +38 -0
  150. package/dist/worker/console/static-src/operator-chat/chat-link.js +20 -0
  151. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +186 -5
  152. package/dist/worker/console/static-src/operator-chat/chat-stream-continuity.js +101 -0
  153. package/dist/worker/console/static-src/operator-chat/compaction-message.js +18 -3
  154. package/dist/worker/console/static-src/operator-chat/context-insights-store.js +64 -0
  155. package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +98 -0
  156. package/dist/worker/console/static-src/operator-chat/file-palette-nav.js +81 -0
  157. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +17 -3
  158. package/dist/worker/console/static-src/operator-chat/process-label.js +27 -5
  159. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +16 -7
  160. package/dist/worker/console/static-src/operator-chat/transcript-mode.js +21 -0
  161. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +4 -1
  162. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +13 -5
  163. package/dist/worker/console/static-src/operator-chat/turn-state-projection.js +166 -0
  164. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +56 -2
  165. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +211 -40
  166. package/dist/worker/console/static-src/operator-chat/useChatStream.js +124 -27
  167. package/dist/worker/console/static-src/operator-chat/useChatThread.js +88 -20
  168. package/dist/worker/console/static-src/operator-chat/useComposer.js +80 -6
  169. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +13 -3
  170. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +36 -17
  171. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +18 -0
  172. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  173. package/dist/worker/console/static-src/shell/console-update-reload.js +1 -1
  174. package/dist/worker/console/workspace-context.js +2 -0
  175. package/dist/worker/console/workspace-initialization.js +3 -3
  176. package/dist/worker/observability/read-model.js +23 -2
  177. package/dist/worker/observe/node-context-insights.js +479 -0
  178. package/dist/worker/observe/static/console-theme.css +174 -0
  179. package/dist/worker/observe/static/console-theme.js +36 -0
  180. package/dist/worker/observe/static/operator-chrome.css +85 -0
  181. package/dist/worker/observe/static/operator-chrome.js +51 -0
  182. package/dist/worker/scheduler/scheduled-goal-dispatch.js +3 -2
  183. package/dist/worker/scheduler/scheduled-goal-evidence.js +66 -3
  184. package/dist/worker/scheduler/scheduled-goal-store.js +10 -0
  185. package/dist/workflows/dag/budget-enforcement.js +31 -2
  186. package/dist/workflows/dag/checkpoint.js +686 -0
  187. package/dist/workflows/dag/frontend-closeout.js +3 -1
  188. package/dist/workflows/dag/frontend-contract-facts.js +11 -8
  189. package/dist/workflows/dag/frontend-design-policy.js +6 -6
  190. package/dist/workflows/dag/frontend-durable-tools.js +90 -70
  191. package/dist/workflows/dag/frontend-implementation-contract.js +146 -57
  192. package/dist/workflows/dag/frontend-plan-canary.js +66 -16
  193. package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
  194. package/dist/workflows/dag/frontend-plan-progress.js +92 -0
  195. package/dist/workflows/dag/frontend-recovery-plan.js +10 -5
  196. package/dist/workflows/dag/frontend-recovery-run.js +51 -46
  197. package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
  198. package/dist/workflows/dag/frontend-review-context.js +29 -1
  199. package/dist/workflows/dag/frontend-review-scopes.js +92 -2
  200. package/dist/workflows/dag/frontend-risk.js +8 -3
  201. package/dist/workflows/dag/frontend-root-observation.js +261 -0
  202. package/dist/workflows/dag/frontend-session-budget.js +52 -39
  203. package/dist/workflows/dag/frontend-session-context.js +10 -0
  204. package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
  205. package/dist/workflows/dag/frontend-typed-event-store.js +11 -1
  206. package/dist/workflows/dag/frontend-verification-trace.js +6 -0
  207. package/dist/workflows/dag/frontend-writer-admission.js +3 -1
  208. package/dist/workflows/dag/hybrid/sources.js +1812 -0
  209. package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
  210. package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
  211. package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
  212. package/dist/workflows/dag/hybrid/templates/index.js +8 -0
  213. package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
  214. package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
  215. package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
  216. package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
  217. package/dist/workflows/dag/hybrid/types.js +32 -0
  218. package/dist/workflows/dag/init-hybrid.js +57 -7106
  219. package/dist/workflows/dag/node-execution.js +11 -11
  220. package/dist/workflows/dag/output-protocol.js +1 -0
  221. package/dist/workflows/dag/rerun-feedback.js +35 -4
  222. package/dist/workflows/dag/rerun-task.js +62 -66
  223. package/dist/workflows/dag/runner.js +38 -1159
  224. package/dist/workflows/dag/terminal-status.js +502 -0
  225. package/dist/workflows/dag/types.js +15 -8
  226. package/dist/workflows/loop/benchmark.js +11 -8
  227. package/docs/README.md +1 -1
  228. package/docs/architecture/dag-execution.md +1 -1
  229. package/docs/architecture/runtime-boundaries.md +1 -1
  230. package/docs/governance/harness-methodology-verification.md +11 -0
  231. package/docs/templates/backend-test-dag.json +5 -4
  232. package/docs/templates/frontend-implementation-contract.schema.json +31 -11
  233. package/package.json +6 -4
  234. package/skills/frontend-design-review/SKILL.md +8 -11
  235. package/skills/frontend-design-review/references/review-checklist.md +3 -4
  236. package/skills/frontend-review/SKILL.md +5 -1
  237. package/skills/loop-agent/references/command-reference.md +8 -0
  238. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  239. package/dist/worker/console/static/assets/channel-ChE7y-cx.js +0 -1
  240. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +0 -1
  241. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +0 -1
  242. package/dist/worker/console/static/assets/index-BWkIfcrK.css +0 -1
  243. package/dist/worker/console/static/assets/index-Cdkvw_H6.js +0 -469
  244. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +0 -1
  245. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +0 -8
@@ -1,44 +1,32 @@
1
+ import { assertFrontendPairExecution } from "../../application/evaluation/frontend-pair-registration.js";
2
+ import { sourceBudgetContractIdentity } from "../../application/evaluation/budget.js";
3
+ import { summarizeFrozenSourceUsage } from "../../task/source-prepare/source-provider-budget.js";
4
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
1
5
  import { readdir, readFile } from "node:fs/promises";
2
- import { createHash } from "node:crypto";
3
6
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
7
  import { hostname } from "node:os";
5
8
  import path from "node:path";
6
- import { isHardBudgetBreached, resolveEffectiveMaxConcurrent, } from "../../application/evaluation/budget.js";
9
+ import { resolveEffectiveMaxConcurrent, } from "../../application/evaluation/budget.js";
7
10
  import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-store.js";
8
11
  import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
9
- import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, validateCumulativeBudgetSuccessor, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
10
- import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, readDagRunState, requireActiveDagRun, } from "./lifecycle.js";
11
- import { markInterruptNeedsReconcile, mergeAbortSignals, readInterruptRequest, runnerIdentityFromState, settleDagInterrupt, startInterruptWatcher, } from "./interrupt-request.js";
12
- import { moveToCompletedRunDir, moveToPausedRunDir, prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
13
- import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
14
- import { createDagNodeExecutor } from "./executor-registry.js";
15
- import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
12
+ import { assertFrozenBudget, initRunBudgetLedger, } from "./budget-enforcement.js";
13
+ import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
14
+ import { prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
16
15
  import { assertValidDagSpec } from "./validate.js";
17
16
  import { assertRuntimeContractCompatible, DAG_CONTROLLER_CAPABILITIES, } from "./runtime-contract.js";
18
17
  import { captureControllerIdentity, verifyControllerIdentityForResume, } from "./controller-identity.js";
19
18
  import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
20
19
  import { normalizeDagPromptSources } from "./prompt-source.js";
21
- import { relocateConvergenceArtifactPaths, relocateRunArtifactPaths, } from "./upstream-artifacts.js";
22
20
  import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSnapshot, } from "./skill-snapshot.js";
23
- import { captureWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, WORKSPACE_CHECKPOINT_TERMINAL_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
24
- import { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions, executeDagNode, } from "./node-execution.js";
25
- import { runConvergencePassController, } from "./convergence/controller.js";
26
- import { executeDagRanksOnce, FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isConditionSkippedReason, readFrontendPrewriteResult, } from "./scheduler.js";
21
+ import { captureWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
22
+ import { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions, } from "./node-execution.js";
27
23
  import { topoSortToRanks } from "./topo.js";
28
- import { isFrontendWriterTransientPartialWrite } from "./frontend-writer-recovery.js";
29
- import { classifyReviewIssueCategoryFailureOwner, readCommittedDesignRequestFact, readCommittedReviewRequestFact, writeFrontendReviewFindingsPrd, } from "./frontend-review-findings.js";
30
- import { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas, commitRuntimeFrontendShapeFact, isCanonicalFrontendShapeDag, } from "./frontend-shape-facts.js";
31
- import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFrontendShapeSourceDigest, } from "./frontend-shape.js";
32
- import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
24
+ import { isCanonicalFrontendShapeDag, } from "./frontend-shape-facts.js";
25
+ import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, } from "./frontend-shape.js";
33
26
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
34
- import { computeActionPayloadHash, } from "../../infrastructure/console/operation-store.js";
35
- import { classifyFailureOwner, failureOwnerSchema } from "./frontend-recovery-capsule.js";
36
- import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, claimDagRecoveryLease, } from "./recovery-lease.js";
37
- import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
38
- import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
39
- import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
40
- import { executeDynamicReduction } from "./dynamic-runtime/reduction.js";
41
- import { installRunnerExitDiagnostics } from "./runner-exit-diagnostics.js";
27
+ import { executeDagCheckpoint, notifyRunObserver, } from "./checkpoint.js";
28
+ export { buildCandidateContractInvalidResult, buildWriterPartialWriteBlockedResult, buildRecoveryResultForTrigger, collectSupersededIntermediateFailures, finalizeTerminalRunStatus, isSuccessfulConvergenceTerminal, createRecoveryOperation, SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS } from "./terminal-status.js";
29
+ export { assertRecoveredFrontendLineage, finalizeRunOwnedInterrupt, buildRankAwareExecuteNode, executeDagCheckpoint, concurrentSiblingWriteSetsForNode, ensureFreshFrontendShapeCapsuleRecord, loadCanonicalFrontendTaskSourceBinding, notifyRunObserver } from "./checkpoint.js";
42
30
  export { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions };
43
31
  export async function loadDagSpecFromFile(dagPath) {
44
32
  const raw = JSON.parse(await readFile(dagPath, "utf-8"));
@@ -54,51 +42,6 @@ export function toKebabSlug(text, maxLen = 40) {
54
42
  .replace(/^-|-$/g, "")
55
43
  .slice(0, maxLen) || "dag");
56
44
  }
57
- export async function loadCanonicalFrontendTaskSourceBinding(cwd, spec) {
58
- const binding = spec.sourceBinding;
59
- if (!binding?.taskId)
60
- throw new Error("canonical frontend DAG requires sourceBinding.taskId");
61
- const requirement = binding.sources.find((source) => source.kind === "requirement");
62
- if (!requirement)
63
- throw new Error("canonical frontend DAG requires a bound requirement source");
64
- const constraint = binding.sources.find((source) => source.kind === "constraint");
65
- const readFreshSource = async (source) => {
66
- let bytes;
67
- try {
68
- bytes = await readFile(path.resolve(cwd, source.path));
69
- }
70
- catch (error) {
71
- throw new Error(`canonical frontend ${source.kind} source freshness read failed: ${error instanceof Error ? error.message : String(error)}`);
72
- }
73
- const actualSha256 = createHash("sha256").update(bytes).digest("hex");
74
- if (actualSha256 !== source.sha256) {
75
- throw new Error(`canonical frontend ${source.kind} source sha256 drifted: expected ${source.sha256}, received ${actualSha256}`);
76
- }
77
- return bytes.toString("utf8");
78
- };
79
- const requirementMarkdown = await readFreshSource(requirement);
80
- const constraintMarkdown = constraint ? await readFreshSource(constraint) : "";
81
- return {
82
- taskId: binding.taskId,
83
- requirementMarkdown,
84
- constraintMarkdown,
85
- sourceDigest: computeFrontendShapeSourceDigest({ requirementMarkdown, constraintMarkdown }),
86
- };
87
- }
88
- export async function ensureFreshFrontendShapeCapsuleRecord(input) {
89
- const freshSource = await loadCanonicalFrontendTaskSourceBinding(input.cwd, input.spec);
90
- if (freshSource.taskId !== input.canonicalSource.taskId ||
91
- freshSource.sourceDigest !== input.canonicalSource.sourceDigest) {
92
- throw new Error("canonical frontend task source freshness binding drifted before transition commit");
93
- }
94
- await (input.ensureRecord ?? ensureFrontendShapeCapsuleRecord)({
95
- repoRoot: input.cwd,
96
- taskId: input.canonicalSource.taskId,
97
- parentRunId: input.parentRunId,
98
- terminalOutcome: input.terminalOutcome,
99
- capsule: input.capsule,
100
- });
101
- }
102
45
  export function extractTaskIdFromDagSpec(spec) {
103
46
  if (spec.objective) {
104
47
  const match = spec.objective.match(TASK_ID_IN_TEXT_PATTERN);
@@ -244,6 +187,17 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
244
187
  : {}),
245
188
  };
246
189
  initRunBudgetLedger(state, spec.budget);
190
+ if (spec.sourceProviderBudget) {
191
+ if (sha256OfCanonicalJson(spec.sourceProviderBudget.contract ?? null) !== sha256OfCanonicalJson(sourceBudgetContractIdentity(spec.taskContractBinding) ?? null) || !state.budgetLedger || spec.sourceProviderBudget.ledger.taskId !== spec.sourceBinding?.taskId || spec.sourceProviderBudget.ledger.maxProviderRequests !== state.budgetLedger.limits.maxProviderRequests || spec.sourceProviderBudget.sourceBindingSha256 !== sha256OfCanonicalJson(spec.sourceBinding))
192
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: source snapshot does not match DAG");
193
+ state.sourceProviderBudget = structuredClone(spec.sourceProviderBudget);
194
+ state.budgetLedger.consumed.providerRequests = spec.sourceProviderBudget.ledger.reservedRequests;
195
+ const sourceUsage = summarizeFrozenSourceUsage(spec.sourceProviderBudget);
196
+ if (sourceUsage.complete)
197
+ state.budgetLedger.consumed.tokens = sourceUsage.totalTokens;
198
+ else if (spec.sourceProviderBudget.ledger.reservedRequests > 0)
199
+ state.budgetLedger.consumed.missingTokenNodeIds.push(`task-budget:${spec.sourceProviderBudget.ledger.budgetId}`);
200
+ }
247
201
  return state;
248
202
  }
249
203
  export function assertFrozenEvaluationBinding(spec, state) {
@@ -280,6 +234,12 @@ export async function runDag(spec, opts) {
280
234
  if (!runningIdentity) {
281
235
  throw new Error("running controller identity could not be resolved; refuse to create an unpinned DAG run");
282
236
  }
237
+ if (spec.evaluation?.frontendPair)
238
+ await assertFrontendPairExecution({
239
+ repoRoot: opts.cwd, binding: spec.evaluation,
240
+ controllerFingerprint: runningIdentity.packageFingerprint.value,
241
+ startedAt: new Date().toISOString(),
242
+ });
283
243
  assertRuntimeContractCompatible(spec, {
284
244
  ...DAG_CONTROLLER_CAPABILITIES,
285
245
  controllerVersion: runningIdentity.packageVersion,
@@ -453,6 +413,12 @@ export async function resumeDagRun(opts) {
453
413
  if (!runningIdentity) {
454
414
  throw new Error("running controller identity could not be resolved; refuse to resume an unpinned DAG run");
455
415
  }
416
+ if (spec.evaluation?.frontendPair)
417
+ await assertFrontendPairExecution({
418
+ repoRoot: opts.cwd, binding: spec.evaluation,
419
+ controllerFingerprint: runningIdentity.packageFingerprint.value,
420
+ startedAt: state.startedAt,
421
+ });
456
422
  assertRuntimeContractCompatible(spec, {
457
423
  ...DAG_CONTROLLER_CAPABILITIES,
458
424
  controllerVersion: runningIdentity.packageVersion,
@@ -521,947 +487,12 @@ export async function resumeDagRun(opts) {
521
487
  finally {
522
488
  }
523
489
  }
524
- async function executeDagCheckpoint(input) {
525
- const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
526
- bindFrontendShapeStateToRunTopology(state);
527
- let runDir = initialRunDir;
528
- let stateWriteQueue = Promise.resolve();
529
- let shapeTransitionBarrier;
530
- const persistState = async (options) => {
531
- if (shapeTransitionBarrier)
532
- await shapeTransitionBarrier;
533
- stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
534
- await stateWriteQueue;
535
- };
536
- const runnerLivenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy);
537
- const heartbeatTimer = setInterval(() => {
538
- if (!state.runner)
539
- return;
540
- // Runner lease only — never counts as meaningful Pi progress.
541
- state.runner.heartbeatAt = new Date().toISOString();
542
- const tasksByIdForPolicy = new Map(spec.tasks.map((task) => [task.id, task]));
543
- for (const node of Object.values(state.nodes)) {
544
- if (node.status !== "RUNNING")
545
- continue;
546
- // Lease clock is separate from meaningful activity.
547
- node.lastLeaseAt = state.runner.heartbeatAt;
548
- const task = tasksByIdForPolicy.get(node.id);
549
- const policy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task?.livenessPolicy);
550
- const evaluation = evaluateNodeLiveness({
551
- node,
552
- policy,
553
- runnerHeartbeatAt: state.runner.heartbeatAt,
554
- });
555
- node.livenessStatus = evaluation.status;
556
- // Intentionally do NOT refresh lastActivityAt / lastMeaningfulProgressAt
557
- // from the runner lease timer.
558
- }
559
- void persistState().catch(() => { });
560
- }, runnerLivenessPolicy.heartbeatIntervalMs);
561
- heartbeatTimer.unref();
562
- await persistState();
563
- const interruptController = new AbortController();
564
- const expectedRunnerIdentity = runnerIdentityFromState(state);
565
- const interruptWatcher = expectedRunnerIdentity
566
- ? startInterruptWatcher({
567
- runDir,
568
- runId: state.runId,
569
- expectedRunnerIdentity,
570
- controller: interruptController,
571
- })
572
- : undefined;
573
- const abortSignal = mergeAbortSignals(input.abortSignal, interruptController.signal);
574
- // Graceful terminal persistence: an outer SIGTERM/SIGINT (operator hard
575
- // timeout, supervision layer, or shell wall-clock) must not leave the run
576
- // orphaned in RUNNING with a dead heartbeat. Persist a terminal failed
577
- // state with an explicit terminalReason before exiting so downstream
578
- // liveness/doctor tooling sees a diagnosable terminal instead of an orphan.
579
- let terminalSignal;
580
- const shutdownOnSignal = (signal) => {
581
- if (terminalSignal)
582
- return;
583
- terminalSignal = signal;
584
- clearInterval(heartbeatTimer);
585
- if (state.runner)
586
- state.runner.heartbeatAt = new Date().toISOString();
587
- state.status = "failed";
588
- state.finishedAt = new Date().toISOString();
589
- state.terminalReason = `runner terminated by ${signal} before run completion`;
590
- console.warn(`[run-dag] received ${signal}; persisting terminal state and exiting`);
591
- const exit = () => process.exit(signal === "SIGINT" ? 130 : 143);
592
- // Best-effort atomic persist with a hard exit deadline so a stuck write
593
- // queue cannot keep the process alive past the supervisor's kill window.
594
- const hardExit = setTimeout(exit, 5000);
595
- hardExit.unref();
596
- void persistState()
597
- .catch(() => { })
598
- .finally(() => {
599
- clearTimeout(hardExit);
600
- exit();
601
- });
602
- };
603
- const onSigTerm = () => shutdownOnSignal("SIGTERM");
604
- const onSigInt = () => shutdownOnSignal("SIGINT");
605
- process.once("SIGTERM", onSigTerm);
606
- process.once("SIGINT", onSigInt);
607
- // Exit-time diagnostics (silent-death forensics): write an `armed` line at
608
- // checkpoint start and append process-exit / uncaught-exception /
609
- // unhandled-rejection lines with a live state snapshot so an abnormal death
610
- // (supervisor hard kill, wedged event loop, crash with lost stderr) leaves
611
- // a durable trace next to state.json. Removed on clean terminal return.
612
- const exitDiagnostics = installRunnerExitDiagnostics({
613
- runDir,
614
- runId: state.runId,
615
- title: state.title,
616
- snapshot: () => {
617
- const heartbeatAt = state.runner?.heartbeatAt;
618
- return {
619
- status: state.status,
620
- terminalReason: state.terminalReason,
621
- failureCategory: state.failureCategory,
622
- heartbeatAt,
623
- heartbeatAgeMs: heartbeatAt === undefined
624
- ? undefined
625
- : Math.max(0, Date.now() - Date.parse(heartbeatAt)),
626
- running: Object.values(state.nodes)
627
- .filter((node) => node.status === "RUNNING")
628
- .map((node) => ({
629
- id: node.id,
630
- attempt: node.currentAttempt,
631
- startedAt: node.startedAt,
632
- lastMeaningfulProgressAt: node.lastMeaningfulProgressAt,
633
- })),
634
- };
635
- },
636
- });
637
- try {
638
- const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
639
- const baseExecuteNode = input.executeNode ??
640
- createDagNodeExecutor({
641
- runDir,
642
- runId: state.runId,
643
- spec,
644
- });
645
- const executeDynamicNode = async (dynamicInput) => {
646
- const { task } = dynamicInput;
647
- if (task.dynamicExpansion) {
648
- return executeDynamicMapExpansion({
649
- ...dynamicInput,
650
- expansion: task.dynamicExpansion,
651
- executeDynamicNode,
652
- });
653
- }
654
- if (task.dynamicCondition) {
655
- return executeDynamicCondition({
656
- task,
657
- condition: task.dynamicCondition,
658
- tasksById: dynamicInput.tasksById,
659
- state: dynamicInput.state,
660
- });
661
- }
662
- if (task.dynamicLoopUntil) {
663
- return executeDynamicLoopUntil({
664
- ...dynamicInput,
665
- loop: task.dynamicLoopUntil,
666
- executeDynamicNode,
667
- });
668
- }
669
- return executeDynamicReduction({
670
- task,
671
- reduction: task.dynamicReduction,
672
- state: dynamicInput.state,
673
- runDir: dynamicInput.runDir,
674
- });
675
- };
676
- const deferTerminalCloseout = state.convergence !== undefined && tasksById.has("closeout-pi");
677
- const convergenceRanks = deferTerminalCloseout
678
- ? ranks
679
- .map((rank) => rank.filter((nodeId) => nodeId !== "closeout-pi"))
680
- .filter((rank) => rank.length > 0)
681
- : ranks;
682
- const terminalCloseoutRanks = deferTerminalCloseout
683
- ? ranks
684
- .map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
685
- .filter((rank) => rank.length > 0)
686
- : [];
687
- const canonicalFrontendSource = isCanonicalFrontendShapeDag(tasksById)
688
- ? await loadCanonicalFrontendTaskSourceBinding(cwd, spec)
689
- : undefined;
690
- const executeRanks = (selectedRanks) => executeDagRanksOnce({
691
- state,
692
- ranks: selectedRanks,
693
- tasksById,
694
- maxConcurrent,
695
- persistState,
696
- commitFrontendShapeTransition: async (request) => {
697
- let releaseBarrier;
698
- shapeTransitionBarrier = new Promise((resolve) => {
699
- releaseBarrier = resolve;
700
- });
701
- try {
702
- if (!canonicalFrontendSource) {
703
- throw new Error("frontend shape transition requires canonical task source binding");
704
- }
705
- return await commitFrontendShapeTransitionCas({
706
- state,
707
- tasksById,
708
- request,
709
- taskSourceDigest: canonicalFrontendSource.sourceDigest,
710
- ensureRecord: async (capsule, terminalOutcome) => {
711
- await ensureFreshFrontendShapeCapsuleRecord({
712
- cwd,
713
- spec,
714
- canonicalSource: canonicalFrontendSource,
715
- parentRunId: state.runId,
716
- terminalOutcome,
717
- capsule,
718
- });
719
- },
720
- persist: async (nextState) => {
721
- stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, nextState));
722
- await stateWriteQueue;
723
- },
724
- });
725
- }
726
- finally {
727
- releaseBarrier();
728
- shapeTransitionBarrier = undefined;
729
- }
730
- },
731
- runDir,
732
- abortSignal: input.abortSignal,
733
- createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
734
- baseExecuteNode,
735
- customExecuteNode: input.executeNode,
736
- rankWriterNodeIds,
737
- tasksById,
738
- meta: { runDir, runId: state.runId, spec },
739
- }),
740
- executeScheduledNode: async (nodeId, executeNode, onPause) => {
741
- if (abortSignal?.aborted)
742
- return;
743
- if (isHardBudgetBreached(state.budgetLedger))
744
- return;
745
- const preBreach = preflightBudgetOrBreach(state);
746
- if (preBreach && isHardBudgetBreached(state.budgetLedger)) {
747
- await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
748
- await persistState();
749
- return;
750
- }
751
- await executeDagNode({
752
- nodeId,
753
- tasksById,
754
- state,
755
- spec,
756
- cwd,
757
- runDir,
758
- executeNode,
759
- executeDynamicNode,
760
- observer: input.observer,
761
- persistState,
762
- onPause,
763
- });
764
- const node = state.nodes[nodeId];
765
- if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
766
- if (node.status === "FINISHED") {
767
- if (canonicalFrontendSource) {
768
- await commitRuntimeFrontendShapeFact({
769
- state,
770
- tasksById,
771
- sourceNodeId: nodeId,
772
- taskId: canonicalFrontendSource.taskId,
773
- requirementMarkdown: canonicalFrontendSource.requirementMarkdown,
774
- constraintMarkdown: canonicalFrontendSource.constraintMarkdown,
775
- });
776
- }
777
- }
778
- recordFinishedNodeBudget(state, node);
779
- await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
780
- await persistState();
781
- }
782
- },
783
- });
784
- let pausedByNodeId;
785
- while (true) {
786
- pausedByNodeId = await executeRanks(convergenceRanks);
787
- if (state.frontendShapeState?.terminalOutcome) {
788
- break;
789
- }
790
- if (pausedByNodeId) {
791
- break;
792
- }
793
- if (isHardBudgetBreached(state.budgetLedger)) {
794
- break;
795
- }
796
- const convergenceDecision = await runConvergencePassController({
797
- spec,
798
- state,
799
- tasksById,
800
- runDir,
801
- cwd,
802
- persistState,
803
- });
804
- if (convergenceDecision.pausedByNodeId) {
805
- pausedByNodeId = convergenceDecision.pausedByNodeId;
806
- break;
807
- }
808
- if (convergenceDecision.retry) {
809
- continue;
810
- }
811
- break;
812
- }
813
- if (!pausedByNodeId &&
814
- terminalCloseoutRanks.length > 0 &&
815
- state.convergence?.terminalReason &&
816
- !isHardBudgetBreached(state.budgetLedger)) {
817
- // Convergence closeout is a terminal phase, not an ordinary descendant
818
- // rank. Running it only after the controller records terminalReason avoids
819
- // stale success handoffs on intermediate request-revision passes while
820
- // still producing a truthful failure handoff for max-passes/non-retry.
821
- pausedByNodeId = await executeRanks(terminalCloseoutRanks);
822
- }
823
- interruptWatcher?.stop();
824
- // Stop heartbeats before terminal archive. A late heartbeat writing
825
- // state.json under completed/ trips the completed-facts write guard and
826
- // makes run-dag exit non-zero after every node already finished.
827
- clearInterval(heartbeatTimer);
828
- await stateWriteQueue;
829
- state.finishedAt = new Date().toISOString();
830
- const runDirBeforeTransfer = runDir;
831
- await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
832
- if (pausedByNodeId) {
833
- state.status = "paused";
834
- await finalizeRunOwnedInterrupt({
835
- runDir,
836
- state,
837
- paused: true,
838
- interruptAborted: interruptController.signal.aborted,
839
- });
840
- await persistState();
841
- await notifyRunObserver(input.observer, "onRunFinish", state);
842
- runDir = await moveToPausedRunDir(runDir, pausedRunDir);
843
- }
844
- else {
845
- const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd, spec.frontendRecovery?.maxContinuations ?? 1);
846
- await finalizeRunOwnedInterrupt({
847
- runDir,
848
- state,
849
- paused: false,
850
- interruptAborted: interruptController.signal.aborted,
851
- });
852
- await persistState();
853
- if (recoveryPending && input.recovery && recoveryTrigger) {
854
- // M6 recovery operation controller path: construct/reuse one recovery
855
- // operation keyed by a deterministic clientRequestId and supervise it
856
- // to terminal, instead of staging an in-process child run.
857
- const created = await createRecoveryOperation({
858
- cwd,
859
- runDir,
860
- spec,
861
- state,
862
- trigger: recoveryTrigger,
863
- deps: input.recovery,
864
- });
865
- if (created.kind === "conflict") {
866
- // Same clientRequestId with a different payload → fail closed; never
867
- // create a second recovery operation (AC-004).
868
- state.status = "failed";
869
- state.terminalReason =
870
- "frontend recovery request-id reuse conflict";
871
- state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
872
- }
873
- else if (created.kind === "lease-held") {
874
- // A different recovery entrance has already durably claimed this
875
- // parent. Do not "help" by running its operation: that would reopen
876
- // the exact two-child-DAG race the lease prevents.
877
- state.status = "failed";
878
- state.terminalReason =
879
- "frontend recovery parent lease already held";
880
- state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
881
- }
882
- else {
883
- const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
884
- const result = await runRecoveryOperationController({
885
- operationId: created.operation.operationId,
886
- parentRunId: state.runId,
887
- observeOnly: created.kind === "observed",
888
- deps: {
889
- ...input.recovery,
890
- budget: input.recovery.budget ?? state.budget,
891
- readLedgers: input.recovery.readLedgers ?? (async () => {
892
- if (!state.budget)
893
- return [];
894
- const location = await locateDagRun(cwd, state.runId);
895
- let current = await readDagRunState(location?.runDir ?? runDir);
896
- const visited = new Set([current.runId]);
897
- while (current.frontendRecoveryState?.parentRunId === current.runId && current.frontendRecoveryState.childRunId && current.frontendRecoveryState.childRunId !== current.runId) {
898
- if (["child-staging", "child-activating"].includes(current.frontendRecoveryState.phase))
899
- break;
900
- const childId = current.frontendRecoveryState.childRunId;
901
- if (visited.has(childId))
902
- throw Error("FRONTEND_PROVIDER_BUDGET_LINEAGE_INVALID: cyclic recovery");
903
- visited.add(childId);
904
- const childLocation = await locateDagRun(cwd, childId);
905
- if (!childLocation)
906
- throw Error("FRONTEND_PROVIDER_BUDGET_LINEAGE_INVALID: missing recovery child");
907
- const child = await readDagRunState(childLocation.runDir);
908
- validateCumulativeBudgetSuccessor(current, child);
909
- current = child;
910
- }
911
- if (!current.budgetLedger)
912
- throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: recovery ledger missing");
913
- return [current.budgetLedger];
914
- }),
915
- failureOwner: input.recovery.failureOwner ??
916
- recoveryTrigger.failureOwner,
917
- protocolFailureReason: input.recovery.protocolFailureReason ??
918
- recoveryTrigger.protocolFailureReason,
919
- recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
920
- },
921
- });
922
- // The recovery CLI stages its child by writing a fresh
923
- // `frontendRecoveryState` into the parent run on disk. A successful
924
- // operation without that bound lineage is not a successful recovery:
925
- // fail closed instead of archiving a false-positive `recovered` result.
926
- let recoveryLineageError;
927
- if (result.terminalReason === "succeeded") {
928
- try {
929
- const persistedParentState = await readDagRunState(runDir);
930
- const persistedRecovery = persistedParentState.frontendRecoveryState;
931
- if (!persistedRecovery ||
932
- persistedRecovery.phase !== "child-running" ||
933
- persistedRecovery.parentRunId !== state.runId ||
934
- persistedRecovery.requestId !== recoveryTrigger.requestId ||
935
- !persistedRecovery.childRunId) {
936
- throw new Error("persisted recovery lineage is missing or unbound");
937
- }
938
- await assertRecoveredFrontendLineage({
939
- cwd,
940
- rootRunId: state.runId,
941
- rootRequestId: recoveryTrigger.requestId,
942
- recovery: persistedRecovery,
943
- maxContinuations: spec.frontendRecovery?.maxContinuations ?? 1,
944
- });
945
- state.frontendRecoveryState = structuredClone(persistedRecovery);
946
- }
947
- catch (error) {
948
- recoveryLineageError =
949
- error instanceof Error ? error.message : String(error);
950
- state.status = "failed";
951
- state.terminalReason =
952
- `frontend recovery lineage invalid: ${recoveryLineageError}`;
953
- }
954
- }
955
- state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded" && !recoveryLineageError
956
- ? "recovered"
957
- : "auto-recovery-blocked");
958
- }
959
- }
960
- await notifyRunObserver(input.observer, "onRunFinish", state);
961
- if (input.beforeTerminalArchive) {
962
- await input.beforeTerminalArchive({ runDir, state });
963
- await persistState();
964
- }
965
- // Parent is always archived to completed/ — no active/ retention for an
966
- // in-process child CAS anymore (recovery runs as a separate operation).
967
- try {
968
- const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
969
- await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
970
- }
971
- catch (error) {
972
- console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
973
- }
974
- runDir = await moveToCompletedRunDir(runDir, completedRunDir);
975
- }
976
- await relocateRunArtifactPaths({
977
- runDir,
978
- oldRunDir: runDirBeforeTransfer,
979
- nodes: state.nodes,
980
- });
981
- if (state.convergence) {
982
- relocateConvergenceArtifactPaths(state.convergence, runDirBeforeTransfer, runDir);
983
- }
984
- await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
985
- await persistState({ allowCompletedFactsWrite: true });
986
- return {
987
- title: state.title,
988
- runId: state.runId,
989
- status: state.status,
990
- ranks,
991
- nodes: state.nodes,
992
- runDir,
993
- };
994
- }
995
- finally {
996
- clearInterval(heartbeatTimer);
997
- interruptWatcher?.stop();
998
- exitDiagnostics.stop();
999
- process.removeListener("SIGTERM", onSigTerm);
1000
- process.removeListener("SIGINT", onSigInt);
1001
- }
1002
- }
1003
- async function assertRecoveredFrontendLineage(input) {
1004
- const maxContinuations = Math.max(1, Math.min(5, input.maxContinuations));
1005
- let parentRunId = input.rootRunId;
1006
- let recovery = input.recovery;
1007
- const recoveryRootRunId = recovery.recoveryRootRunId;
1008
- const visited = new Set();
1009
- if (recoveryRootRunId !== input.rootRunId) {
1010
- throw new Error("recovery root lineage binding mismatch");
1011
- }
1012
- for (let depth = 0; depth < maxContinuations; depth += 1) {
1013
- // The persisted parent intent represents the edge about to be traversed,
1014
- // so its counters equal the number of already-completed continuation edges.
1015
- // Each completed child increments both counters exactly once; no edge may
1016
- // exceed the frozen DAG quota.
1017
- if (recovery.continuationCount !== depth ||
1018
- recovery.attemptIndex !== depth) {
1019
- throw new Error("recovery child lineage counters are not monotonic");
1020
- }
1021
- if (recovery.phase !== "child-running" ||
1022
- recovery.parentRunId !== parentRunId ||
1023
- recovery.recoveryRootRunId !== recoveryRootRunId ||
1024
- !recovery.childRunId ||
1025
- (depth === 0
1026
- ? recovery.requestId !== input.rootRequestId
1027
- : recovery.requestId !== parentRunId)) {
1028
- throw new Error("persisted recovery lineage is missing or unbound");
1029
- }
1030
- if (visited.has(recovery.childRunId)) {
1031
- throw new Error("recovery child lineage contains a cycle");
1032
- }
1033
- visited.add(recovery.childRunId);
1034
- const child = await locateDagRun(input.cwd, recovery.childRunId);
1035
- if (!child || child.lifecycle !== "completed") {
1036
- throw new Error("recovery child is not completed");
1037
- }
1038
- const childState = await readDagRunState(child.runDir);
1039
- if (childState.runId !== recovery.childRunId) {
1040
- throw new Error("recovery child state binding mismatch");
1041
- }
1042
- if (childState.status === "finished")
1043
- return;
1044
- if (childState.frontendRecoveryResult?.outcome !== "recovered" ||
1045
- !childState.frontendRecoveryState) {
1046
- throw new Error("recovery child terminal state is not successful");
1047
- }
1048
- parentRunId = childState.runId;
1049
- recovery = childState.frontendRecoveryState;
1050
- }
1051
- throw new Error("recovery child lineage exceeds the continuation quota");
1052
- }
1053
- async function finalizeRunOwnedInterrupt(input) {
1054
- const request = await readInterruptRequest(input.runDir);
1055
- if (!request)
1056
- return;
1057
- if (request.status === "settled" || request.status === "needs-reconcile") {
1058
- return;
1059
- }
1060
- const hasUnconfirmed = Object.values(input.state.nodes).some((node) => node.failureCategory === "termination-unconfirmed");
1061
- if (hasUnconfirmed || input.paused) {
1062
- await markInterruptNeedsReconcile({
1063
- runDir: input.runDir,
1064
- outcome: hasUnconfirmed
1065
- ? "termination-unconfirmed"
1066
- : "paused-before-settle",
1067
- });
1068
- return;
1069
- }
1070
- if (input.interruptAborted &&
1071
- request.status === "acknowledged" &&
1072
- (input.state.status === "failed" ||
1073
- input.state.status === "partial_failed" ||
1074
- input.state.failureCategory === "controller-interrupted")) {
1075
- await settleDagInterrupt({
1076
- runDir: input.runDir,
1077
- outcome: "controller-interrupted",
1078
- });
1079
- return;
1080
- }
1081
- if (request.status === "requested") {
1082
- await markInterruptNeedsReconcile({
1083
- runDir: input.runDir,
1084
- outcome: "runner-did-not-acknowledge",
1085
- });
1086
- return;
1087
- }
1088
- await markInterruptNeedsReconcile({
1089
- runDir: input.runDir,
1090
- outcome: "run-exited-before-interrupt-settled",
1091
- });
1092
- }
1093
- async function notifyRunObserver(observer, event, state) {
1094
- try {
1095
- await observer?.[event]?.(state);
1096
- }
1097
- catch {
1098
- // Observers are derived views; they must not affect canonical DAG execution.
1099
- }
1100
- }
1101
- const SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS = new Set([
1102
- "review-pass",
1103
- "hard-verify-pass",
1104
- ]);
1105
490
  /**
1106
491
  * Intermediate soft-verify failures may be excluded from terminal error
1107
492
  * aggregation only when repair + final hard verify succeeded and, when a review
1108
493
  * gate is present, convergence ended in review-pass. Historical node status is
1109
494
  * never rewritten.
1110
495
  */
1111
- function collectSupersededIntermediateFailures(state) {
1112
- const supersededIds = new Set();
1113
- const supersededFailures = [];
1114
- const soft = state.nodes["soft-verify-shell"];
1115
- if (!soft || soft.status !== "ERROR") {
1116
- return { supersededIds, supersededFailures };
1117
- }
1118
- const repair = state.nodes["repair-pi"];
1119
- const hard = state.nodes["hard-verify-shell"];
1120
- if (repair?.status !== "FINISHED" || hard?.status !== "FINISHED") {
1121
- return { supersededIds, supersededFailures };
1122
- }
1123
- const observesReview = Boolean(state.nodes["review-gate-shell"]);
1124
- const terminalReason = state.convergence?.terminalReason;
1125
- if (observesReview) {
1126
- if (terminalReason !== "review-pass") {
1127
- return { supersededIds, supersededFailures };
1128
- }
1129
- }
1130
- else if (terminalReason && terminalReason !== "hard-verify-pass") {
1131
- return { supersededIds, supersededFailures };
1132
- }
1133
- // Evidence: at least one successful repair pass after soft failure, with hard verify finished in terminal state.
1134
- const hasRepairEvidence = !state.convergence?.enabled ||
1135
- state.convergence.passHistory.some((pass) => pass.reason === "hard-verify-failed" ||
1136
- pass.reason === "review-request-revision" ||
1137
- pass.status === "retrying") ||
1138
- repair.status === "FINISHED";
1139
- if (!hasRepairEvidence) {
1140
- return { supersededIds, supersededFailures };
1141
- }
1142
- supersededIds.add("soft-verify-shell");
1143
- supersededFailures.push({
1144
- nodeId: "soft-verify-shell",
1145
- preservedStatus: "ERROR",
1146
- supersededBy: ["repair-pi", "hard-verify-shell"],
1147
- pass: state.convergence?.currentPass,
1148
- reason: "intermediate-soft-verify-covered-by-repair-and-final-hard",
1149
- });
1150
- return { supersededIds, supersededFailures };
1151
- }
1152
- function isSuccessfulConvergenceTerminal(state) {
1153
- const reason = state.convergence?.terminalReason;
1154
- if (!reason)
1155
- return true;
1156
- return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
1157
- }
1158
- function buildCandidateContractInvalidResult(state, result, artifactHash) {
1159
- return {
1160
- schemaVersion: 1,
1161
- outcome: "candidate-contract-invalid",
1162
- origin: {
1163
- kind: "frontend-prewrite-gate",
1164
- parentRunId: state.runId,
1165
- requestId: state.runId,
1166
- failedNodeId: result.failureSource ?? "frontend-plan-pi",
1167
- },
1168
- failureClass: {
1169
- code: "candidate-contract-invalid",
1170
- classification: result.classification,
1171
- reason: result.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; ") ??
1172
- "candidate contract invalid",
1173
- },
1174
- evidenceRefs: [
1175
- {
1176
- runId: state.runId,
1177
- relativePath: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
1178
- sha256: artifactHash,
1179
- },
1180
- ],
1181
- };
1182
- }
1183
- function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
1184
- return {
1185
- schemaVersion: 1,
1186
- outcome: "auto-recovery-blocked",
1187
- origin: {
1188
- kind: "frontend-writer",
1189
- parentRunId: state.runId,
1190
- requestId: state.runId,
1191
- failedNodeId,
1192
- },
1193
- failureClass: {
1194
- code: "writer-transient-partial-write",
1195
- classification: "transient",
1196
- reason,
1197
- },
1198
- evidenceRefs: [],
1199
- };
1200
- }
1201
- function buildRecoveryResultForTrigger(state, trigger, outcome) {
1202
- return {
1203
- schemaVersion: 1,
1204
- outcome,
1205
- origin: {
1206
- kind: trigger.failureSource === "frontend-implement-pi"
1207
- ? "frontend-writer"
1208
- : "frontend-prewrite-gate",
1209
- parentRunId: state.runId,
1210
- requestId: trigger.requestId,
1211
- failedNodeId: trigger.failureSource,
1212
- },
1213
- evidenceRefs: [],
1214
- };
1215
- }
1216
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
1217
- const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1218
- if (state.frontendShapeState?.terminalOutcome &&
1219
- state.frontendShapeState.transitionCapsule) {
1220
- state.status = "failed";
1221
- return { recoveryPending: false };
1222
- }
1223
- // Terminal override: a denied frontend writer must never surface as a
1224
- // mechanical partial_failed just because prewrite FINISHED while the writer
1225
- // was SKIPPED. Fail closed on blocked before aggregation.
1226
- let recoveryTrigger;
1227
- const skipFrontendRecovery = state.failureCategory === "controller-interrupted" ||
1228
- state.continuation?.writerTransaction !== undefined;
1229
- const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
1230
- if (hasFrontendWriter && !skipFrontendRecovery) {
1231
- const admission = await readFrontendPrewriteResult(runDir);
1232
- if (admission.ok) {
1233
- // M8: admission uses the accepted/blocked state machine. A blocked admission whose
1234
- // failureSource is frontend-plan-pi is the contract-invalid analog of
1235
- // the old retryable-invalid path (candidate continuation may re-plan);
1236
- // every other blocked admission is terminal.
1237
- const planContractInvalid = admission.result.classification === "blocked" &&
1238
- admission.result.failureSource === "frontend-plan-pi";
1239
- // A blocked admission caused by the design review committing
1240
- // `request_design_changes` is a definite finding (not a protocol or
1241
- // environment error): the design was reviewed and rejected. It is the
1242
- // design-review analog of review-request-changes — not terminal, but
1243
- // re-enters the next plan round with the committed design findings.
1244
- // The failureSource may be frontend-writer-admission-shell or
1245
- // frontend-design-policy-shell; the committed design fact is the
1246
- // authoritative signal.
1247
- const designRequestFact = await readCommittedDesignRequestFact({
1248
- runDir,
1249
- });
1250
- const designRejected = admission.result.classification === "blocked" &&
1251
- designRequestFact !== undefined;
1252
- const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1253
- admission.result.classification === "stale") &&
1254
- !planContractInvalid &&
1255
- !designRejected;
1256
- if (terminalAdmissionDenial) {
1257
- state.status = "failed";
1258
- return { recoveryPending: false };
1259
- }
1260
- if (designRejected && designRequestFact) {
1261
- state.terminalReason = "design-request-changes";
1262
- try {
1263
- await writeFrontendReviewFindingsPrd({
1264
- runDir,
1265
- parentRunId: state.runId,
1266
- fact: designRequestFact,
1267
- });
1268
- }
1269
- catch (error) {
1270
- console.warn(`[run-dag] warning: failed to write frontend design findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1271
- }
1272
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1273
- if (continuationCount < frontendRecoveryQuota) {
1274
- recoveryTrigger = {
1275
- requestId: state.runId,
1276
- // The rejected design is a plan input defect. Reset from the
1277
- // planner so the next child can incorporate the committed
1278
- // findings before design review runs again.
1279
- failureSource: "frontend-plan-pi",
1280
- failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
1281
- protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
1282
- recoveryMode: "new-dag",
1283
- };
1284
- }
1285
- }
1286
- if (planContractInvalid) {
1287
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1288
- if (continuationCount >= frontendRecoveryQuota) {
1289
- // Continuation quota exhausted: a second contract-invalid candidate
1290
- // is authoritative candidate-contract-invalid → forced failed.
1291
- state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
1292
- state.status = "failed";
1293
- return { recoveryPending: false };
1294
- }
1295
- // Root continuation still available: emit a recovery trigger for the
1296
- // M6 operation controller. The parent no longer writes
1297
- // frontendRecoveryState or stays in active/ for a child CAS.
1298
- recoveryTrigger = {
1299
- requestId: state.runId,
1300
- failureSource: admission.result.failureSource ?? "frontend-plan-pi",
1301
- failureOwner: "plan",
1302
- protocolFailureReason: "frontend candidate contract invalid",
1303
- recoveryMode: "new-dag",
1304
- };
1305
- }
1306
- }
1307
- }
1308
- // Phase 5: writer transient partial write → rollback + recovery trigger.
1309
- // Only for frontend-implementation writers with a remaining root
1310
- // continuation, and only when the rollback journal (captured before the
1311
- // provider call) restores cleanly; otherwise the run stays failed with
1312
- // auto-recovery-blocked.
1313
- if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
1314
- const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
1315
- const node = state.nodes[id];
1316
- return (node &&
1317
- node.status === "ERROR" &&
1318
- isFrontendWriterTransientPartialWrite({
1319
- failureCategory: node.failureCategory,
1320
- }));
1321
- });
1322
- if (writerNodeId) {
1323
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1324
- if (continuationCount < frontendRecoveryQuota) {
1325
- const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
1326
- const rollback = await rollbackFrontendWriter({
1327
- cwd: repoRoot,
1328
- parentRunId: state.runId,
1329
- });
1330
- if (rollback.ok) {
1331
- recoveryTrigger = {
1332
- requestId: state.runId,
1333
- failureSource: "frontend-implement-pi",
1334
- failureOwner: "implementation",
1335
- protocolFailureReason: "frontend writer transient partial write",
1336
- recoveryMode: "continuation",
1337
- };
1338
- }
1339
- else {
1340
- state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
1341
- state.status = "failed";
1342
- return { recoveryPending: false };
1343
- }
1344
- }
1345
- }
1346
- }
1347
- // Phase 6: verification failure → automatic recovery (re-implement +
1348
- // re-verify). The verify shell is terminal by design so the failed run's
1349
- // evidence stays clean; the M6 recovery operation re-runs implement and
1350
- // verify in a fresh continuation, guarded by the same continuation quota,
1351
- // budget fold and failure fingerprint as the other recovery paths.
1352
- if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
1353
- const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1354
- if (verifyFailed) {
1355
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1356
- if (continuationCount < frontendRecoveryQuota) {
1357
- let recoveryOwner = classifyFailureOwner({
1358
- nodeId: "frontend-verify-shell",
1359
- rawFailureCategory: state.nodes["frontend-verify-shell"]?.failureCategory,
1360
- });
1361
- try {
1362
- const verifyFailure = JSON.parse(await readFile(path.join(runDir, "contracts/frontend-verify-failure.json"), "utf8"));
1363
- const owner = failureOwnerSchema.safeParse(verifyFailure.failureOwner);
1364
- if (verifyFailure.schemaVersion === 1 &&
1365
- verifyFailure.schemaId === "frontend-verify-failure-v1" &&
1366
- verifyFailure.nodeId === "frontend-verify-shell" &&
1367
- verifyFailure.failureCategory === state.nodes["frontend-verify-shell"]?.failureCategory &&
1368
- owner.success) {
1369
- recoveryOwner = owner.data;
1370
- }
1371
- else {
1372
- recoveryOwner = "unknown";
1373
- }
1374
- }
1375
- catch {
1376
- // The node's classified category remains the fallback; never turn
1377
- // unknown/environment/configuration failures into writer work.
1378
- }
1379
- if (recoveryOwner === "implementation")
1380
- recoveryTrigger = {
1381
- requestId: state.runId,
1382
- failureSource: "frontend-verify-shell",
1383
- failureOwner: recoveryOwner,
1384
- protocolFailureReason: "frontend verification failure",
1385
- recoveryMode: "continuation",
1386
- };
1387
- else
1388
- state.terminalReason = recoveryOwner === "environment"
1389
- ? "frontend-verification-wait-external"
1390
- : `frontend-verification-${recoveryOwner}-requires-contract-review`;
1391
- }
1392
- }
1393
- }
1394
- // Phase 7: frontend review "request changes" → not passed. When the review
1395
- // node FINISHED but committed `request_review_changes` (typed terminal
1396
- // verdict), the implementation was reviewed and rejected. The run must not
1397
- // surface as finished: terminal aggregation below stays partial_failed, a
1398
- // terminal reason is recorded, the committed findings are rendered into a
1399
- // downstream-task PRD draft (run-owned), and a recovery trigger carries the
1400
- // issueCategory-derived owner so a later DAG round consumes the findings
1401
- // instead of repeating the previous flow.
1402
- const reviewRequestFact = await readCommittedReviewRequestFact({
1403
- runDir,
1404
- });
1405
- if (reviewRequestFact) {
1406
- state.terminalReason = "review-request-changes";
1407
- try {
1408
- await writeFrontendReviewFindingsPrd({
1409
- runDir,
1410
- parentRunId: state.runId,
1411
- fact: reviewRequestFact,
1412
- });
1413
- }
1414
- catch (error) {
1415
- // The PRD draft is additive; a write failure must not mask the
1416
- // not-passed verdict. The terminal aggregation below still applies.
1417
- console.warn(`[run-dag] warning: failed to write frontend review findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1418
- }
1419
- if (!recoveryTrigger && !skipFrontendRecovery) {
1420
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1421
- if (continuationCount < frontendRecoveryQuota) {
1422
- recoveryTrigger = {
1423
- requestId: state.runId,
1424
- failureSource: "frontend-review-pi",
1425
- failureOwner: classifyReviewIssueCategoryFailureOwner(reviewRequestFact.issueCategory),
1426
- protocolFailureReason: `frontend review request_review_changes (issueCategory=${reviewRequestFact.issueCategory})`,
1427
- recoveryMode: "new-dag",
1428
- };
1429
- }
1430
- }
1431
- }
1432
- const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
1433
- const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
1434
- if (state.convergence && supersededFailures.length > 0) {
1435
- state.convergence.supersededFailures = supersededFailures;
1436
- }
1437
- // Prefer map keys: DagNodeRecord may not always mirror the map key on `.id`.
1438
- const effectiveErrorCount = Object.entries(state.nodes).filter(([nodeId, n]) => n.status === "ERROR" && !supersededIds.has(nodeId)).length;
1439
- const supersededErrorCount = Object.entries(state.nodes).filter(([nodeId, n]) => n.status === "ERROR" && supersededIds.has(nodeId)).length;
1440
- const conditionSkippedCount = Object.values(state.nodes).filter((n) => n.status === "SKIPPED" && isConditionSkippedReason(n.skippedReason)).length;
1441
- const successTerminal = isSuccessfulConvergenceTerminal(state);
1442
- // Superseded intermediate ERRORs remain ERROR in history but count as settled
1443
- // for terminal aggregation only (they do not inflate effectiveErrorCount).
1444
- if (effectiveErrorCount === 0 &&
1445
- successTerminal &&
1446
- finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
1447
- // Phase 7 guard: a committed `request_review_changes` means the review
1448
- // rejected the implementation; the run must never surface as finished
1449
- // even though every node technically FINISHED.
1450
- state.status =
1451
- state.terminalReason === "review-request-changes"
1452
- ? "partial_failed"
1453
- : "finished";
1454
- }
1455
- else if (finishedCount > 0) {
1456
- state.status = "partial_failed";
1457
- }
1458
- else {
1459
- state.status = "failed";
1460
- }
1461
- return recoveryTrigger
1462
- ? { recoveryPending: true, recovery: recoveryTrigger }
1463
- : { recoveryPending: false };
1464
- }
1465
496
  /**
1466
497
  * Construct (or reuse) the recovery operation for a failed frontend run.
1467
498
  *
@@ -1475,155 +506,3 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1475
506
  * parent feedback through the managed rerun path. `planHash` is the second
1476
507
  * idempotency dimension of `actionParams`.
1477
508
  */
1478
- async function createRecoveryOperation(input) {
1479
- const { cwd, runDir, spec, state, trigger } = input;
1480
- const recoveryLease = await acquireDagRecoveryLease({
1481
- cwd,
1482
- parentRunId: state.runId,
1483
- requestId: trigger.requestId,
1484
- kind: "auto-frontend-recovery",
1485
- });
1486
- let effectiveLease = recoveryLease;
1487
- let observeOnly = false;
1488
- if (recoveryLease.kind === "held") {
1489
- if (recoveryLease.lease.kind !== "auto-frontend-recovery" ||
1490
- recoveryLease.lease.requestId !== trigger.requestId) {
1491
- return { kind: "lease-held", lease: recoveryLease.lease };
1492
- }
1493
- const claimed = await claimDagRecoveryLease({
1494
- leasePath: recoveryLease.leasePath,
1495
- lease: recoveryLease.lease,
1496
- });
1497
- effectiveLease = claimed;
1498
- if (claimed.kind === "held") {
1499
- if (!claimed.lease.executionRef) {
1500
- return { kind: "lease-held", lease: claimed.lease };
1501
- }
1502
- observeOnly = true;
1503
- }
1504
- }
1505
- const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1506
- const plan = computeFrontendRecoveryPlanForSource({
1507
- spec,
1508
- parentRunId: state.runId,
1509
- requestId: trigger.requestId,
1510
- failureSource: trigger.failureSource,
1511
- });
1512
- const planHash = createHash("sha256")
1513
- .update(JSON.stringify(plan))
1514
- .digest("hex");
1515
- const continuationPath = path.join(runDir, ".runtime", "frontend-recovery", `${trigger.requestId}.json`);
1516
- await writeJsonAtomic(continuationPath, {
1517
- schemaVersion: 1,
1518
- parentRunId: state.runId,
1519
- requestId: trigger.requestId,
1520
- failureSource: trigger.failureSource,
1521
- recoveryMode: trigger.recoveryMode,
1522
- planHash,
1523
- plan,
1524
- });
1525
- const operationInput = {
1526
- clientRequestId: `dag-recovery-${state.runId}`,
1527
- action: "dagRunTask",
1528
- actionParams: {
1529
- parentRunId: state.runId,
1530
- requestId: trigger.requestId,
1531
- failureSource: trigger.failureSource,
1532
- failureOwner: trigger.failureOwner,
1533
- protocolFailureReason: trigger.protocolFailureReason,
1534
- recoveryMode: trigger.recoveryMode,
1535
- planHash,
1536
- },
1537
- cliArgs: [
1538
- "dag",
1539
- "rerun-task",
1540
- "--run-id",
1541
- state.runId,
1542
- "--reason",
1543
- trigger.protocolFailureReason ?? "frontend automatic recovery",
1544
- "--request-id",
1545
- trigger.requestId,
1546
- "--frontend-recovery",
1547
- "--json",
1548
- ],
1549
- };
1550
- let created;
1551
- if (effectiveLease.lease.executionRef) {
1552
- // Replay the exact operation bound by the durable lease. A missing record
1553
- // cannot be replaced with a new operation id without violating the fence.
1554
- const existing = await input.deps.store.get(effectiveLease.lease.executionRef);
1555
- if (!existing) {
1556
- throw new Error(`frontend recovery lease operation not found: ${effectiveLease.lease.executionRef}`);
1557
- }
1558
- if (existing.clientRequestId !== operationInput.clientRequestId ||
1559
- existing.action !== operationInput.action ||
1560
- existing.payloadHash !==
1561
- computeActionPayloadHash(operationInput.action, operationInput.actionParams)) {
1562
- throw new Error("frontend recovery lease execution identity drift: referenced operation payload does not match the recovery request");
1563
- }
1564
- created = { kind: "idempotent", operation: existing };
1565
- }
1566
- else {
1567
- created = await input.deps.store.create(operationInput);
1568
- }
1569
- if (created.kind !== "conflict" && !observeOnly) {
1570
- // The durable lease must name the operation before controller execution.
1571
- // This also repairs a lease left behind by a crash before the original bind.
1572
- await bindDagRecoveryLeaseExecution({
1573
- leasePath: effectiveLease.leasePath,
1574
- lease: effectiveLease.lease,
1575
- executionRef: created.operation.operationId,
1576
- });
1577
- // Repair CLI args on both fresh and replayed operations. This closes the
1578
- // crash window after operation creation but before args were persisted.
1579
- await input.deps.store.update(created.operation.operationId, {
1580
- cliArgs: [
1581
- "dag",
1582
- "rerun-task",
1583
- "--run-id",
1584
- state.runId,
1585
- "--reason",
1586
- trigger.protocolFailureReason ?? "frontend automatic recovery",
1587
- "--request-id",
1588
- trigger.requestId,
1589
- "--frontend-recovery",
1590
- "--recovery-operation-id",
1591
- created.operation.operationId,
1592
- "--json",
1593
- ],
1594
- });
1595
- }
1596
- if (created.kind !== "conflict" && observeOnly) {
1597
- return { kind: "observed", operation: created.operation };
1598
- }
1599
- return created;
1600
- }
1601
- function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
1602
- if (rankWriterNodeIds.length <= 1)
1603
- return [];
1604
- return rankWriterNodeIds
1605
- .filter((id) => id !== nodeId)
1606
- .map((id) => tasksById.get(id)?.writeSet ?? [])
1607
- .filter((writeSet) => writeSet.length > 0);
1608
- }
1609
- function buildRankAwareExecuteNode(input) {
1610
- if (input.customExecuteNode) {
1611
- return input.baseExecuteNode;
1612
- }
1613
- return async (nodeInput) => {
1614
- const isPiWriter = nodeInput.task.executor === "pi" &&
1615
- nodeInput.task.toolProfile === "write" &&
1616
- Boolean(nodeInput.model);
1617
- if (!isPiWriter) {
1618
- return input.baseExecuteNode(nodeInput);
1619
- }
1620
- const siblingWriteSets = nodeInput.task.writePolicy === "exclusive"
1621
- ? concurrentSiblingWriteSetsForNode(input.rankWriterNodeIds, nodeInput.task.id, input.tasksById)
1622
- : [];
1623
- return executeDagPiNode({ ...nodeInput, model: nodeInput.model }, {
1624
- ...input.meta,
1625
- concurrentSiblingWriteSets: siblingWriteSets.length > 0 ? siblingWriteSets : undefined,
1626
- writeGuardAttribution: siblingWriteSets.length > 0 ? "best-effort" : "per-node",
1627
- });
1628
- };
1629
- }