@tea-agent/loop-agent 0.41.1 → 0.42.0-next.10

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 (302) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +397 -5
  3. package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
  4. package/dist/adapters/context-transfer/pi-session.js +61 -0
  5. package/dist/application/dag/generate-task-dag.js +75 -19
  6. package/dist/application/dag/run-dag.js +41 -0
  7. package/dist/application/task-lifecycle/advance.js +24 -5
  8. package/dist/application/task-lifecycle/observe.js +171 -17
  9. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  10. package/dist/application/task-lifecycle/recommendations.js +13 -2
  11. package/dist/build-stamp.json +3 -3
  12. package/dist/cli/command-definitions.js +19 -0
  13. package/dist/cli/program.js +29 -2
  14. package/dist/commands/client-recovery.js +3 -0
  15. package/dist/commands/dag-artifact.js +284 -0
  16. package/dist/commands/dag-context.js +184 -0
  17. package/dist/commands/dag-follow-up.js +138 -0
  18. package/dist/commands/dag-rerun-task.js +2 -0
  19. package/dist/commands/dag-rerun.js +206 -1
  20. package/dist/commands/init.js +27 -1
  21. package/dist/commands/task-advance.js +19 -0
  22. package/dist/executors/dag-pi-executor.js +4013 -86
  23. package/dist/executors/pi-executor.js +15 -4
  24. package/dist/executors/pi-read-budget-policy.js +239 -0
  25. package/dist/executors/pi-sdk-executor.js +3 -0
  26. package/dist/executors/shell-executor.js +978 -188
  27. package/dist/executors/shell-write-guard.js +7 -0
  28. package/dist/{worker → infrastructure}/console/app-data.js +4 -0
  29. package/dist/infrastructure/console/artifact-revision-store.js +430 -0
  30. package/dist/infrastructure/console/context-export-store.js +160 -0
  31. package/dist/infrastructure/console/dir-lock.js +132 -0
  32. package/dist/infrastructure/console/operation-store.js +417 -0
  33. package/dist/infrastructure/harness/artifact-store.js +10 -1
  34. package/dist/infrastructure/harness/atomic-write.js +12 -2
  35. package/dist/shared/context-transfer/artifact-revision.js +172 -0
  36. package/dist/shared/context-transfer.js +418 -0
  37. package/dist/shared/dag-failure-category.js +12 -0
  38. package/dist/shared/openspec-spec.js +70 -4
  39. package/dist/shared/operator/capabilities.js +21 -0
  40. package/dist/shared/operator/safe-run-summary.js +1 -0
  41. package/dist/shared/path-safety.js +93 -0
  42. package/dist/shared/preview.js +28 -4
  43. package/dist/task/config-types.js +107 -6
  44. package/dist/task/contract/adopt.js +4 -0
  45. package/dist/task/contract/import-revision.js +4 -0
  46. package/dist/task/contract/project.js +3 -0
  47. package/dist/task/contract/schema.js +2 -1
  48. package/dist/task/frontend-project-capability.js +203 -20
  49. package/dist/task/runtime.js +5 -2
  50. package/dist/task/source-prepare/build-draft.js +3 -3
  51. package/dist/task/source-prepare/fragment-inventory.js +64 -25
  52. package/dist/task/source-prepare/prepare.js +126 -21
  53. package/dist/task/source-prepare/semantic-intake.js +6 -2
  54. package/dist/task/source-references.js +22 -1
  55. package/dist/task/task-demand-routing.js +3 -0
  56. package/dist/worker/console/chat/chat-event-store.js +2 -2
  57. package/dist/worker/console/chat/model-resolver.js +7 -4
  58. package/dist/worker/console/chat/pi-runtime.js +86 -5
  59. package/dist/worker/console/chat/resource-preferences-store.js +1 -1
  60. package/dist/worker/console/chat/routes.js +136 -53
  61. package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
  62. package/dist/worker/console/chat/session-catalog.js +2 -0
  63. package/dist/worker/console/chat/session-store.js +43 -1
  64. package/dist/worker/console/chat/shortcuts.js +18 -2
  65. package/dist/worker/console/chat/user-questions.js +1 -1
  66. package/dist/worker/console/console-update-runtime.js +1 -1
  67. package/dist/worker/console/context-transfer-diagnostics.js +198 -0
  68. package/dist/worker/console/dag-confirmation.js +1 -1
  69. package/dist/worker/console/dag-execution-receipt.js +1 -1
  70. package/dist/worker/console/doctor.js +1 -1
  71. package/dist/worker/console/draft-store.js +1 -1
  72. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  73. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  74. package/dist/worker/console/human-gate-token.js +1 -1
  75. package/dist/worker/console/index.js +6 -3
  76. package/dist/worker/console/interview/assessment.js +1 -1
  77. package/dist/worker/console/interview/session.js +1 -1
  78. package/dist/worker/console/operation-runner.js +45 -4
  79. package/dist/worker/console/operation-sse.js +1 -1
  80. package/dist/worker/console/operation-wait.js +1 -1
  81. package/dist/worker/console/operator-actions.js +273 -10
  82. package/dist/worker/console/operator-surface-health.js +1 -1
  83. package/dist/worker/console/operator-user-error.js +169 -0
  84. package/dist/worker/console/pi-plugins.js +57 -0
  85. package/dist/worker/console/pi-readiness.js +3 -2
  86. package/dist/worker/console/routes.js +637 -8
  87. package/dist/worker/console/security.js +35 -0
  88. package/dist/worker/console/server.js +29 -4
  89. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-D7oU983t.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
  90. package/dist/worker/console/static/assets/{arc-BrKSRdFS.js → arc-COptKq2S.js} +1 -1
  91. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CiXV7L56.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
  92. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-D_Hs_gIG.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
  93. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DbM-Lqqh.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
  94. package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
  95. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D1M8uqJw.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
  96. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-4D3RFHT6.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CdwpY_n9.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BLeLZ9PG.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CeRNEMvc.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
  100. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CjkHP8Vy.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
  101. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-B54ZUQY-.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
  102. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-4bKaMVmx.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
  103. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Cyjfznu_.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
  104. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CDN-zGFV.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
  105. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
  106. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
  107. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-HxJmz411.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
  108. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DtSt5K2w.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
  109. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-VtkSpRmG.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
  110. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Bj4DT4du.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
  111. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Bpuk6BmP.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
  112. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BHYGv_yO.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
  113. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DEx3WwnT.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
  114. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Cj0dS5e8.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
  115. package/dist/worker/console/static/assets/{diagram-UB23O5K3-B97h8l21.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
  116. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DMSFAhBd.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
  117. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DhVOCbt6.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
  118. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-GlakITWL.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
  119. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-VxPpu9eQ.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
  120. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bs_e5DRT.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
  121. package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
  122. package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
  123. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BXYuU4Uw.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
  124. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-C34erCbU.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
  125. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DZCAiOU3.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
  126. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-C9gaTdUm.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
  127. package/dist/worker/console/static/assets/{linear-CvI0Z17U.js → linear-D59YJ9kB.js} +1 -1
  128. package/dist/worker/console/static/assets/{mermaid.core-DMMSIx7j.js → mermaid.core-Bl13LpNa.js} +5 -5
  129. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BKKP0EO6.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
  130. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-C3LiFbl5.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
  131. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Bb3Om2Dz.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
  132. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DNthMjzc.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
  133. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-miFsr4Tv.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
  134. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-yXUncmn6.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
  135. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DLeWKLak.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
  136. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CI3he1R3.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
  137. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B7jfDkS5.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
  138. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BmrZod7J.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
  139. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
  140. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CDOlHVSY.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
  141. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
  142. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cz-XX1gy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
  143. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-caV4cI8C.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
  144. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-dImPlnIO.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
  145. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CtRYVJcn.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
  146. package/dist/worker/console/static/index.html +7 -2
  147. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  148. package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
  149. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
  150. package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
  151. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
  152. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
  153. package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
  154. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
  155. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
  156. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
  157. package/dist/worker/console/static-src/shell/console-update-reload.js +52 -0
  158. package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
  159. package/dist/worker/console/workspace-context.js +142 -2
  160. package/dist/worker/console/workspace-registry.js +2 -2
  161. package/dist/worker/continuation/worker-continuation.js +278 -0
  162. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  163. package/dist/worker/observe/health.js +1 -1
  164. package/dist/worker/observe/node-transparency.js +572 -0
  165. package/dist/worker/observe/routes.js +55 -1
  166. package/dist/worker/observe/static/api.js +69 -5
  167. package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
  168. package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
  169. package/dist/worker/observe/static/dag-helpers.js +4 -2
  170. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  171. package/dist/worker/observe/static/inspect-workspace.js +23 -0
  172. package/dist/worker/observe/static/kpi.js +2 -2
  173. package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
  174. package/dist/worker/observe/static/operator-chrome.js +37 -23
  175. package/dist/worker/observe/static/relations.js +2 -2
  176. package/dist/worker/observe/static/router.d.ts +12 -1
  177. package/dist/worker/observe/static/router.js +48 -6
  178. package/dist/worker/observe/static/run-processing.js +4 -2
  179. package/dist/worker/observe/static/shell-chrome.js +2 -2
  180. package/dist/worker/observe/static/state.js +8 -1
  181. package/dist/worker/observe/static/styles.css +1092 -86
  182. package/dist/worker/observe/static/views/dag-inspector.js +1228 -64
  183. package/dist/worker/observe/static/views/dag.js +10 -2
  184. package/dist/worker/observe/static/views/dags.js +3 -1
  185. package/dist/worker/observe/static/views/dashboard.js +14 -6
  186. package/dist/worker/observe/static/views/pool.js +7 -2
  187. package/dist/worker/observe/static/views/run.js +12 -3
  188. package/dist/worker/observe/static/views/session-timeline.js +142 -18
  189. package/dist/worker/observe/static/views/task.js +7 -2
  190. package/dist/worker/outcomes/adapters.js +19 -0
  191. package/dist/worker/outcomes/types.js +1 -0
  192. package/dist/worker/pool/attempt-lease.js +97 -66
  193. package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
  194. package/dist/worker/pool/reconcile.js +46 -1
  195. package/dist/worker/pool/run-store.js +2 -0
  196. package/dist/worker/pool/state-projection.js +2 -0
  197. package/dist/worker/task-spec/workflow-routing.js +8 -3
  198. package/dist/workflows/dag/artifact-bindings.js +149 -0
  199. package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
  200. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
  201. package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
  202. package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
  203. package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
  204. package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
  205. package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
  206. package/dist/workflows/dag/budget-enforcement.js +10 -0
  207. package/dist/workflows/dag/context-receipt.js +305 -0
  208. package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
  209. package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
  210. package/dist/workflows/dag/context-transfer/renderers.js +93 -0
  211. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  212. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  213. package/dist/workflows/dag/frontend-closeout.js +221 -0
  214. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  215. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  216. package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
  217. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  218. package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
  219. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  220. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  221. package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
  222. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  223. package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
  224. package/dist/workflows/dag/frontend-recovery-run.js +166 -34
  225. package/dist/workflows/dag/frontend-repair.js +1 -432
  226. package/dist/workflows/dag/frontend-review-context.js +261 -15
  227. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  228. package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
  229. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  230. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  231. package/dist/workflows/dag/frontend-shape.js +435 -0
  232. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  233. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  234. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  235. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  236. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  237. package/dist/workflows/dag/frontend-verification-trace.js +252 -25
  238. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  239. package/dist/workflows/dag/frontend-writer-admission.js +319 -0
  240. package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
  241. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  242. package/dist/workflows/dag/init-hybrid.js +1174 -562
  243. package/dist/workflows/dag/interrupt-request.js +7 -0
  244. package/dist/workflows/dag/node-execution.js +940 -31
  245. package/dist/workflows/dag/path-safety.js +1 -0
  246. package/dist/workflows/dag/prompt.js +99 -7
  247. package/dist/workflows/dag/recovery-lease.js +170 -0
  248. package/dist/workflows/dag/report.js +37 -1
  249. package/dist/workflows/dag/rerun-feedback.js +315 -1
  250. package/dist/workflows/dag/rerun-plan.js +568 -3
  251. package/dist/workflows/dag/rerun-run.js +642 -18
  252. package/dist/workflows/dag/rerun-task.js +251 -13
  253. package/dist/workflows/dag/retry-policy.js +219 -104
  254. package/dist/workflows/dag/runner.js +596 -121
  255. package/dist/workflows/dag/scheduler.js +133 -20
  256. package/dist/workflows/dag/skill-snapshot.js +17 -0
  257. package/dist/workflows/dag/types.js +426 -22
  258. package/dist/workflows/dag/validate.js +28 -12
  259. package/docs/architecture/runtime-boundaries.md +6 -6
  260. package/docs/architecture/worker-and-feature.md +1 -1
  261. package/docs/examples/README.md +5 -0
  262. package/docs/init-surface.manifest.json +30 -12
  263. package/docs/skills/vetted-skill-registry.md +4 -2
  264. package/docs/templates/README.md +2 -0
  265. package/docs/templates/agent-dag-report.schema.json +8 -2
  266. package/docs/templates/agent-dag.schema.json +33 -1
  267. package/docs/templates/backend-test-dag.json +23 -20
  268. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  269. package/docs/templates/frontend-implementation-dag.json +89 -0
  270. package/docs/templates/product-line/task.yaml +1 -1
  271. package/docs/templates/spec-registry.schema.json +45 -0
  272. package/harness.json +1 -1
  273. package/package.json +6 -3
  274. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  275. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  276. package/skills/frontend-contract/SKILL.md +23 -0
  277. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  278. package/skills/frontend-design-review/SKILL.md +22 -41
  279. package/skills/frontend-plan/SKILL.md +26 -0
  280. package/skills/frontend-plan/references/decision-contract.md +37 -0
  281. package/skills/frontend-plan/references/design-decisions.md +17 -0
  282. package/skills/frontend-review/SKILL.md +20 -15
  283. package/skills/frontend-review/references/review-findings.md +6 -7
  284. package/skills/frontend-scout/SKILL.md +25 -0
  285. package/skills/frontend-scout/references/design-evidence.md +16 -0
  286. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  287. package/skills/frontend-verification/SKILL.md +1 -1
  288. package/skills/loop-agent/references/command-reference.md +7 -0
  289. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  290. package/dist/worker/console/operation-store.js +0 -169
  291. package/dist/worker/console/static/assets/channel-D1xajGpe.js +0 -1
  292. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BmuGTfRr.js +0 -1
  293. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BmuGTfRr.js +0 -1
  294. package/dist/worker/console/static/assets/index-BLzcAykx.js +0 -451
  295. package/dist/worker/console/static/assets/index-Cs9JUSPs.css +0 -1
  296. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-D9ykKEWP.js +0 -1
  297. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BvPOXALT.js +0 -8
  298. package/skills/frontend-implementation/SKILL.md +0 -52
  299. package/skills/frontend-implementation/references/code-standards.md +0 -33
  300. package/skills/frontend-implementation/references/design-spec.md +0 -56
  301. package/skills/frontend-implementation/references/node-contracts.md +0 -31
  302. /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash } from "node:crypto";
3
3
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
4
  import { hostname } from "node:os";
5
5
  import path from "node:path";
@@ -7,7 +7,7 @@ import { isHardBudgetBreached, resolveEffectiveMaxConcurrent, } from "../../appl
7
7
  import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-store.js";
8
8
  import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
9
9
  import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
10
- import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readDagRunSpec, readDagRunState, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
10
+ import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, readDagRunState, requireActiveDagRun, } from "./lifecycle.js";
11
11
  import { markInterruptNeedsReconcile, mergeAbortSignals, readInterruptRequest, runnerIdentityFromState, settleDagInterrupt, startInterruptWatcher, } from "./interrupt-request.js";
12
12
  import { moveToCompletedRunDir, moveToPausedRunDir, prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
13
13
  import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
@@ -26,7 +26,13 @@ import { runConvergencePassController, } from "./convergence/controller.js";
26
26
  import { executeDagRanksOnce, FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isConditionSkippedReason, readFrontendPrewriteResult, } from "./scheduler.js";
27
27
  import { topoSortToRanks } from "./topo.js";
28
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";
29
33
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
34
+ import { computeActionPayloadHash, } from "../../infrastructure/console/operation-store.js";
35
+ import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, claimDagRecoveryLease, } from "./recovery-lease.js";
30
36
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
31
37
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
32
38
  import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
@@ -46,6 +52,51 @@ export function toKebabSlug(text, maxLen = 40) {
46
52
  .replace(/^-|-$/g, "")
47
53
  .slice(0, maxLen) || "dag");
48
54
  }
55
+ export async function loadCanonicalFrontendTaskSourceBinding(cwd, spec) {
56
+ const binding = spec.sourceBinding;
57
+ if (!binding?.taskId)
58
+ throw new Error("canonical frontend DAG requires sourceBinding.taskId");
59
+ const requirement = binding.sources.find((source) => source.kind === "requirement");
60
+ if (!requirement)
61
+ throw new Error("canonical frontend DAG requires a bound requirement source");
62
+ const constraint = binding.sources.find((source) => source.kind === "constraint");
63
+ const readFreshSource = async (source) => {
64
+ let bytes;
65
+ try {
66
+ bytes = await readFile(path.resolve(cwd, source.path));
67
+ }
68
+ catch (error) {
69
+ throw new Error(`canonical frontend ${source.kind} source freshness read failed: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
72
+ if (actualSha256 !== source.sha256) {
73
+ throw new Error(`canonical frontend ${source.kind} source sha256 drifted: expected ${source.sha256}, received ${actualSha256}`);
74
+ }
75
+ return bytes.toString("utf8");
76
+ };
77
+ const requirementMarkdown = await readFreshSource(requirement);
78
+ const constraintMarkdown = constraint ? await readFreshSource(constraint) : "";
79
+ return {
80
+ taskId: binding.taskId,
81
+ requirementMarkdown,
82
+ constraintMarkdown,
83
+ sourceDigest: computeFrontendShapeSourceDigest({ requirementMarkdown, constraintMarkdown }),
84
+ };
85
+ }
86
+ export async function ensureFreshFrontendShapeCapsuleRecord(input) {
87
+ const freshSource = await loadCanonicalFrontendTaskSourceBinding(input.cwd, input.spec);
88
+ if (freshSource.taskId !== input.canonicalSource.taskId ||
89
+ freshSource.sourceDigest !== input.canonicalSource.sourceDigest) {
90
+ throw new Error("canonical frontend task source freshness binding drifted before transition commit");
91
+ }
92
+ await (input.ensureRecord ?? ensureFrontendShapeCapsuleRecord)({
93
+ repoRoot: input.cwd,
94
+ taskId: input.canonicalSource.taskId,
95
+ parentRunId: input.parentRunId,
96
+ terminalOutcome: input.terminalOutcome,
97
+ capsule: input.capsule,
98
+ });
99
+ }
49
100
  export function extractTaskIdFromDagSpec(spec) {
50
101
  if (spec.objective) {
51
102
  const match = spec.objective.match(TASK_ID_IN_TEXT_PATTERN);
@@ -180,6 +231,15 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
180
231
  ...(opts.workerAssociation
181
232
  ? { workerAssociation: structuredClone(opts.workerAssociation) }
182
233
  : {}),
234
+ ...(isCanonicalFrontendShapeDag(new Map(spec.tasks.map((task) => [task.id, task])))
235
+ ? {
236
+ frontendShapeState: {
237
+ frozenTopology: buildFrontendTopologyBinding(spec.tasks.map((task) => task.id)),
238
+ revision: 0,
239
+ committedFacts: {},
240
+ },
241
+ }
242
+ : {}),
183
243
  };
184
244
  initRunBudgetLedger(state, spec.budget);
185
245
  return state;
@@ -301,14 +361,38 @@ export async function runDag(spec, opts) {
301
361
  activeRunDir,
302
362
  completedRunDir,
303
363
  pausedRunDir,
364
+ recovery: opts.recovery,
304
365
  });
305
366
  }
306
367
  finally {
307
368
  }
308
369
  }
309
370
  export async function runDagContinuation(opts) {
371
+ assertValidDagSpec(opts.spec);
372
+ assertFrozenBudget(opts.spec.budget, opts.state.budget, opts.state.runId);
373
+ if (opts.spec.budget) {
374
+ const ledger = opts.state.budgetLedger;
375
+ if (!ledger ||
376
+ ledger.mode !== opts.spec.budget.mode ||
377
+ JSON.stringify(ledger.limits) !== JSON.stringify(opts.spec.budget.limits)) {
378
+ throw new Error(`dag continuation ${opts.state.runId} is missing a matching cumulative budget ledger`);
379
+ }
380
+ }
381
+ const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent, } = await import("./task-contract-binding.js");
382
+ assertDagSpecAllowsNewWriterExecution(opts.spec);
383
+ await assertTaskContractBindingConsistent({ repoRoot: opts.cwd, spec: opts.spec });
310
384
  const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), opts.spec.budget);
311
385
  const state = opts.state;
386
+ const canonicalFrontend = isCanonicalFrontendShapeDag(new Map(opts.spec.tasks.map((task) => [task.id, task])));
387
+ if (canonicalFrontend) {
388
+ assertFrontendTopologyBinding(state.frontendShapeState?.frozenTopology, opts.spec.tasks.map((task) => task.id));
389
+ }
390
+ else if (state.frontendShapeState) {
391
+ throw new Error("non-frontend DAG unexpectedly contains frontend topology state");
392
+ }
393
+ if (state.frontendShapeState?.terminalOutcome) {
394
+ throw new Error(`dag run ${state.runId} is terminal due to frontend shape ${state.frontendShapeState.terminalOutcome}`);
395
+ }
312
396
  state.status = "running";
313
397
  const resumedAt = new Date().toISOString();
314
398
  state.runner = {
@@ -328,11 +412,13 @@ export async function runDagContinuation(opts) {
328
412
  cwd: opts.cwd,
329
413
  maxConcurrent,
330
414
  executeNode: opts.executeNode,
415
+ beforeTerminalArchive: opts.beforeTerminalArchive,
331
416
  observer: opts.observer,
332
417
  abortSignal: opts.abortSignal,
333
418
  activeRunDir,
334
419
  completedRunDir,
335
420
  pausedRunDir,
421
+ recovery: opts.recovery,
336
422
  });
337
423
  }
338
424
  export async function resumeDagRun(opts) {
@@ -435,9 +521,13 @@ export async function resumeDagRun(opts) {
435
521
  }
436
522
  async function executeDagCheckpoint(input) {
437
523
  const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
524
+ bindFrontendShapeStateToRunTopology(state);
438
525
  let runDir = initialRunDir;
439
526
  let stateWriteQueue = Promise.resolve();
527
+ let shapeTransitionBarrier;
440
528
  const persistState = async (options) => {
529
+ if (shapeTransitionBarrier)
530
+ await shapeTransitionBarrier;
441
531
  stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
442
532
  await stateWriteQueue;
443
533
  };
@@ -562,12 +652,50 @@ async function executeDagCheckpoint(input) {
562
652
  .map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
563
653
  .filter((rank) => rank.length > 0)
564
654
  : [];
655
+ const canonicalFrontendSource = isCanonicalFrontendShapeDag(tasksById)
656
+ ? await loadCanonicalFrontendTaskSourceBinding(cwd, spec)
657
+ : undefined;
565
658
  const executeRanks = (selectedRanks) => executeDagRanksOnce({
566
659
  state,
567
660
  ranks: selectedRanks,
568
661
  tasksById,
569
662
  maxConcurrent,
570
663
  persistState,
664
+ commitFrontendShapeTransition: async (request) => {
665
+ let releaseBarrier;
666
+ shapeTransitionBarrier = new Promise((resolve) => {
667
+ releaseBarrier = resolve;
668
+ });
669
+ try {
670
+ if (!canonicalFrontendSource) {
671
+ throw new Error("frontend shape transition requires canonical task source binding");
672
+ }
673
+ return await commitFrontendShapeTransitionCas({
674
+ state,
675
+ tasksById,
676
+ request,
677
+ taskSourceDigest: canonicalFrontendSource.sourceDigest,
678
+ ensureRecord: async (capsule, terminalOutcome) => {
679
+ await ensureFreshFrontendShapeCapsuleRecord({
680
+ cwd,
681
+ spec,
682
+ canonicalSource: canonicalFrontendSource,
683
+ parentRunId: state.runId,
684
+ terminalOutcome,
685
+ capsule,
686
+ });
687
+ },
688
+ persist: async (nextState) => {
689
+ stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, nextState));
690
+ await stateWriteQueue;
691
+ },
692
+ });
693
+ }
694
+ finally {
695
+ releaseBarrier();
696
+ shapeTransitionBarrier = undefined;
697
+ }
698
+ },
571
699
  runDir,
572
700
  abortSignal: input.abortSignal,
573
701
  createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
@@ -603,6 +731,18 @@ async function executeDagCheckpoint(input) {
603
731
  });
604
732
  const node = state.nodes[nodeId];
605
733
  if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
734
+ if (node.status === "FINISHED") {
735
+ if (canonicalFrontendSource) {
736
+ await commitRuntimeFrontendShapeFact({
737
+ state,
738
+ tasksById,
739
+ sourceNodeId: nodeId,
740
+ taskId: canonicalFrontendSource.taskId,
741
+ requirementMarkdown: canonicalFrontendSource.requirementMarkdown,
742
+ constraintMarkdown: canonicalFrontendSource.constraintMarkdown,
743
+ });
744
+ }
745
+ }
606
746
  recordFinishedNodeBudget(state, node);
607
747
  await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
608
748
  await persistState();
@@ -612,6 +752,9 @@ async function executeDagCheckpoint(input) {
612
752
  let pausedByNodeId;
613
753
  while (true) {
614
754
  pausedByNodeId = await executeRanks(convergenceRanks);
755
+ if (state.frontendShapeState?.terminalOutcome) {
756
+ break;
757
+ }
615
758
  if (pausedByNodeId) {
616
759
  break;
617
760
  }
@@ -667,7 +810,7 @@ async function executeDagCheckpoint(input) {
667
810
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
668
811
  }
669
812
  else {
670
- const { recoveryPending } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
813
+ const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd, spec.frontendRecovery?.maxContinuations ?? 1);
671
814
  await finalizeRunOwnedInterrupt({
672
815
  runDir,
673
816
  state,
@@ -675,52 +818,103 @@ async function executeDagCheckpoint(input) {
675
818
  interruptAborted: interruptController.signal.aborted,
676
819
  });
677
820
  await persistState();
678
- if (recoveryPending) {
679
- // Recovery coordinator (phase 3c AC-2/AC-3): materialize the reserved
680
- // child and run it synchronously. Dynamic import avoids a static
681
- // runner ↔ frontend-recovery-run module cycle. stageRecoveryChild
682
- // locates the parent via the canonical active/ layout, so recovery
683
- // only fires when this run is still the canonical active run dir
684
- // (direct runDagContinuation callers using an arbitrary runDir keep
685
- // the previous scheduler-layer skip semantics).
686
- const canonicalActiveDir = getDagRunDir(cwd, "active", state.runId);
687
- if (path.resolve(runDir) === path.resolve(canonicalActiveDir)) {
688
- const { stageRecoveryChild } = await import("./frontend-recovery-run.js");
689
- const staged = await stageRecoveryChild({
690
- cwd,
821
+ if (recoveryPending && input.recovery && recoveryTrigger) {
822
+ // M6 recovery operation controller path: construct/reuse one recovery
823
+ // operation keyed by a deterministic clientRequestId and supervise it
824
+ // to terminal, instead of staging an in-process child run.
825
+ const created = await createRecoveryOperation({
826
+ cwd,
827
+ runDir,
828
+ spec,
829
+ state,
830
+ trigger: recoveryTrigger,
831
+ deps: input.recovery,
832
+ });
833
+ if (created.kind === "conflict") {
834
+ // Same clientRequestId with a different payload → fail closed; never
835
+ // create a second recovery operation (AC-004).
836
+ state.status = "failed";
837
+ state.terminalReason =
838
+ "frontend recovery request-id reuse conflict";
839
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
840
+ }
841
+ else if (created.kind === "lease-held") {
842
+ // A different recovery entrance has already durably claimed this
843
+ // parent. Do not "help" by running its operation: that would reopen
844
+ // the exact two-child-DAG race the lease prevents.
845
+ state.status = "failed";
846
+ state.terminalReason =
847
+ "frontend recovery parent lease already held";
848
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
849
+ }
850
+ else {
851
+ const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
852
+ const result = await runRecoveryOperationController({
853
+ operationId: created.operation.operationId,
691
854
  parentRunId: state.runId,
855
+ observeOnly: created.kind === "observed",
856
+ deps: {
857
+ ...input.recovery,
858
+ failureOwner: input.recovery.failureOwner ??
859
+ recoveryTrigger.failureOwner,
860
+ protocolFailureReason: input.recovery.protocolFailureReason ??
861
+ recoveryTrigger.protocolFailureReason,
862
+ recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
863
+ },
692
864
  });
693
- // stageRecoveryChild CAS-advanced the parent's on-disk lineage
694
- // (child-staging → child-running + childRunId). Refresh the in-memory
695
- // copy so the terminal persistState below does not regress the parent
696
- // back to stale child-staging.
697
- const parentOnDisk = await readDagRunState(runDir);
698
- state.frontendRecoveryState = parentOnDisk.frontendRecoveryState;
699
- const childState = await readDagRunState(staged.childRunDir);
700
- const childSpec = await readDagRunSpec(staged.childRunDir);
701
- await runDagContinuation({
702
- cwd,
703
- spec: childSpec,
704
- state: childState,
705
- runDir: staged.childRunDir,
706
- maxConcurrent,
707
- executeNode: input.executeNode,
708
- observer: input.observer,
709
- abortSignal,
710
- });
865
+ // The recovery CLI stages its child by writing a fresh
866
+ // `frontendRecoveryState` into the parent run on disk. A successful
867
+ // operation without that bound lineage is not a successful recovery:
868
+ // fail closed instead of archiving a false-positive `recovered` result.
869
+ let recoveryLineageError;
870
+ if (result.terminalReason === "succeeded") {
871
+ try {
872
+ const persistedParentState = await readDagRunState(runDir);
873
+ const persistedRecovery = persistedParentState.frontendRecoveryState;
874
+ if (!persistedRecovery ||
875
+ persistedRecovery.phase !== "child-running" ||
876
+ persistedRecovery.parentRunId !== state.runId ||
877
+ persistedRecovery.requestId !== recoveryTrigger.requestId ||
878
+ !persistedRecovery.childRunId) {
879
+ throw new Error("persisted recovery lineage is missing or unbound");
880
+ }
881
+ await assertRecoveredFrontendLineage({
882
+ cwd,
883
+ rootRunId: state.runId,
884
+ rootRequestId: recoveryTrigger.requestId,
885
+ recovery: persistedRecovery,
886
+ maxContinuations: spec.frontendRecovery?.maxContinuations ?? 1,
887
+ });
888
+ state.frontendRecoveryState = structuredClone(persistedRecovery);
889
+ }
890
+ catch (error) {
891
+ recoveryLineageError =
892
+ error instanceof Error ? error.message : String(error);
893
+ state.status = "failed";
894
+ state.terminalReason =
895
+ `frontend recovery lineage invalid: ${recoveryLineageError}`;
896
+ }
897
+ }
898
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded" && !recoveryLineageError
899
+ ? "recovered"
900
+ : "auto-recovery-blocked");
711
901
  }
712
902
  }
713
903
  await notifyRunObserver(input.observer, "onRunFinish", state);
714
- if (!recoveryPending) {
715
- try {
716
- const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
717
- await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
718
- }
719
- catch (error) {
720
- console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
721
- }
722
- runDir = await moveToCompletedRunDir(runDir, completedRunDir);
904
+ if (input.beforeTerminalArchive) {
905
+ await input.beforeTerminalArchive({ runDir, state });
906
+ await persistState();
907
+ }
908
+ // Parent is always archived to completed/ — no active/ retention for an
909
+ // in-process child CAS anymore (recovery runs as a separate operation).
910
+ try {
911
+ const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
912
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
723
913
  }
914
+ catch (error) {
915
+ console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
916
+ }
917
+ runDir = await moveToCompletedRunDir(runDir, completedRunDir);
724
918
  }
725
919
  await relocateRunArtifactPaths({
726
920
  runDir,
@@ -748,6 +942,56 @@ async function executeDagCheckpoint(input) {
748
942
  process.removeListener("SIGINT", onSigInt);
749
943
  }
750
944
  }
945
+ async function assertRecoveredFrontendLineage(input) {
946
+ const maxContinuations = Math.max(1, Math.min(5, input.maxContinuations));
947
+ let parentRunId = input.rootRunId;
948
+ let recovery = input.recovery;
949
+ const recoveryRootRunId = recovery.recoveryRootRunId;
950
+ const visited = new Set();
951
+ if (recoveryRootRunId !== input.rootRunId) {
952
+ throw new Error("recovery root lineage binding mismatch");
953
+ }
954
+ for (let depth = 0; depth < maxContinuations; depth += 1) {
955
+ // The persisted parent intent represents the edge about to be traversed,
956
+ // so its counters equal the number of already-completed continuation edges.
957
+ // Each completed child increments both counters exactly once; no edge may
958
+ // exceed the frozen DAG quota.
959
+ if (recovery.continuationCount !== depth ||
960
+ recovery.attemptIndex !== depth) {
961
+ throw new Error("recovery child lineage counters are not monotonic");
962
+ }
963
+ if (recovery.phase !== "child-running" ||
964
+ recovery.parentRunId !== parentRunId ||
965
+ recovery.recoveryRootRunId !== recoveryRootRunId ||
966
+ !recovery.childRunId ||
967
+ (depth === 0
968
+ ? recovery.requestId !== input.rootRequestId
969
+ : recovery.requestId !== parentRunId)) {
970
+ throw new Error("persisted recovery lineage is missing or unbound");
971
+ }
972
+ if (visited.has(recovery.childRunId)) {
973
+ throw new Error("recovery child lineage contains a cycle");
974
+ }
975
+ visited.add(recovery.childRunId);
976
+ const child = await locateDagRun(input.cwd, recovery.childRunId);
977
+ if (!child || child.lifecycle !== "completed") {
978
+ throw new Error("recovery child is not completed");
979
+ }
980
+ const childState = await readDagRunState(child.runDir);
981
+ if (childState.runId !== recovery.childRunId) {
982
+ throw new Error("recovery child state binding mismatch");
983
+ }
984
+ if (childState.status === "finished")
985
+ return;
986
+ if (childState.frontendRecoveryResult?.outcome !== "recovered" ||
987
+ !childState.frontendRecoveryState) {
988
+ throw new Error("recovery child terminal state is not successful");
989
+ }
990
+ parentRunId = childState.runId;
991
+ recovery = childState.frontendRecoveryState;
992
+ }
993
+ throw new Error("recovery child lineage exceeds the continuation quota");
994
+ }
751
995
  async function finalizeRunOwnedInterrupt(input) {
752
996
  const request = await readInterruptRequest(input.runDir);
753
997
  if (!request)
@@ -853,39 +1097,21 @@ function isSuccessfulConvergenceTerminal(state) {
853
1097
  return true;
854
1098
  return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
855
1099
  }
856
- function buildFrontendRecoveryIntent(state, failureSource) {
857
- const existing = state.frontendRecoveryState;
858
- const requestId = existing?.requestId ?? randomUUID();
859
- return {
860
- schemaVersion: 1,
861
- phase: "child-staging",
862
- requestId,
863
- recoveryRootRunId: existing?.recoveryRootRunId ?? state.runId,
864
- parentRunId: state.runId,
865
- ...(existing?.childRunId ? { childRunId: existing.childRunId } : {}),
866
- attemptId: existing?.attemptId ?? `recovery-${requestId}`,
867
- attemptIndex: existing?.attemptIndex ?? 0,
868
- continuationCount: existing?.continuationCount ?? 0,
869
- ...(failureSource ? { failureSource } : {}),
870
- revision: (existing?.revision ?? 0) + 1,
871
- };
872
- }
873
1100
  function buildCandidateContractInvalidResult(state, result, artifactHash) {
874
- const recovery = state.frontendRecoveryState;
875
1101
  return {
876
1102
  schemaVersion: 1,
877
1103
  outcome: "candidate-contract-invalid",
878
1104
  origin: {
879
1105
  kind: "frontend-prewrite-gate",
880
- parentRunId: recovery?.parentRunId ?? state.runId,
881
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
882
- requestId: recovery?.requestId ?? "",
883
- failedNodeId: result.selectedPlanNodeId,
1106
+ parentRunId: state.runId,
1107
+ requestId: state.runId,
1108
+ failedNodeId: result.failureSource ?? "frontend-plan-pi",
884
1109
  },
885
1110
  failureClass: {
886
1111
  code: "candidate-contract-invalid",
887
1112
  classification: result.classification,
888
- reason: result.failureReason ?? "candidate contract invalid",
1113
+ reason: result.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; ") ??
1114
+ "candidate contract invalid",
889
1115
  },
890
1116
  evidenceRefs: [
891
1117
  {
@@ -897,15 +1123,13 @@ function buildCandidateContractInvalidResult(state, result, artifactHash) {
897
1123
  };
898
1124
  }
899
1125
  function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
900
- const recovery = state.frontendRecoveryState;
901
1126
  return {
902
1127
  schemaVersion: 1,
903
1128
  outcome: "auto-recovery-blocked",
904
1129
  origin: {
905
1130
  kind: "frontend-writer",
906
- parentRunId: recovery?.parentRunId ?? state.runId,
907
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
908
- requestId: recovery?.requestId ?? "",
1131
+ parentRunId: state.runId,
1132
+ requestId: state.runId,
909
1133
  failedNodeId,
910
1134
  },
911
1135
  failureClass: {
@@ -916,63 +1140,119 @@ function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
916
1140
  evidenceRefs: [],
917
1141
  };
918
1142
  }
919
- function settleFrontendRecovery(state, outcome) {
920
- const recovery = state.frontendRecoveryState;
921
- if (!recovery)
922
- return;
923
- recovery.phase = "settled";
924
- if (!state.frontendRecoveryResult) {
925
- state.frontendRecoveryResult = {
926
- schemaVersion: 1,
927
- outcome,
928
- origin: {
929
- kind: "frontend-prewrite-gate",
930
- parentRunId: recovery.parentRunId,
931
- ...(recovery.childRunId ? { childRunId: recovery.childRunId } : {}),
932
- requestId: recovery.requestId,
933
- failedNodeId: "",
934
- },
935
- evidenceRefs: [],
936
- };
937
- }
1143
+ function buildRecoveryResultForTrigger(state, trigger, outcome) {
1144
+ return {
1145
+ schemaVersion: 1,
1146
+ outcome,
1147
+ origin: {
1148
+ kind: trigger.failureSource === "frontend-implement-pi"
1149
+ ? "frontend-writer"
1150
+ : "frontend-prewrite-gate",
1151
+ parentRunId: state.runId,
1152
+ requestId: trigger.requestId,
1153
+ failedNodeId: trigger.failureSource,
1154
+ },
1155
+ evidenceRefs: [],
1156
+ };
938
1157
  }
939
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1158
+ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
940
1159
  const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1160
+ if (state.frontendShapeState?.terminalOutcome &&
1161
+ state.frontendShapeState.transitionCapsule) {
1162
+ state.status = "failed";
1163
+ return { recoveryPending: false };
1164
+ }
941
1165
  // Terminal override: a denied frontend writer must never surface as a
942
1166
  // mechanical partial_failed just because prewrite FINISHED while the writer
943
- // was SKIPPED. Fail closed on retryable-invalid/blocked before aggregation.
944
- let recoveryPending = false;
945
- const skipFrontendRecovery = state.failureCategory === "controller-interrupted";
1167
+ // was SKIPPED. Fail closed on blocked before aggregation.
1168
+ let recoveryTrigger;
1169
+ const skipFrontendRecovery = state.failureCategory === "controller-interrupted" ||
1170
+ state.continuation?.writerTransaction !== undefined;
946
1171
  const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
947
1172
  if (hasFrontendWriter && !skipFrontendRecovery) {
948
1173
  const admission = await readFrontendPrewriteResult(runDir);
949
1174
  if (admission.ok) {
950
- if (admission.result.classification === "blocked") {
1175
+ // M8: admission uses the accepted/blocked state machine. A blocked admission whose
1176
+ // failureSource is frontend-plan-pi is the contract-invalid analog of
1177
+ // the old retryable-invalid path (candidate continuation may re-plan);
1178
+ // every other blocked admission is terminal.
1179
+ const planContractInvalid = admission.result.classification === "blocked" &&
1180
+ admission.result.failureSource === "frontend-plan-pi";
1181
+ // A blocked admission caused by the design review committing
1182
+ // `request_design_changes` is a definite finding (not a protocol or
1183
+ // environment error): the design was reviewed and rejected. It is the
1184
+ // design-review analog of review-request-changes — not terminal, but
1185
+ // re-enters the next plan round with the committed design findings.
1186
+ // The failureSource may be frontend-writer-admission-shell or
1187
+ // frontend-design-policy-shell; the committed design fact is the
1188
+ // authoritative signal.
1189
+ const designRequestFact = await readCommittedDesignRequestFact({
1190
+ runDir,
1191
+ });
1192
+ const designRejected = admission.result.classification === "blocked" &&
1193
+ designRequestFact !== undefined;
1194
+ const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1195
+ admission.result.classification === "stale") &&
1196
+ !planContractInvalid &&
1197
+ !designRejected;
1198
+ if (terminalAdmissionDenial) {
951
1199
  state.status = "failed";
952
1200
  return { recoveryPending: false };
953
1201
  }
954
- if (admission.result.classification === "retryable-invalid") {
1202
+ if (designRejected && designRequestFact) {
1203
+ state.terminalReason = "design-request-changes";
1204
+ try {
1205
+ await writeFrontendReviewFindingsPrd({
1206
+ runDir,
1207
+ parentRunId: state.runId,
1208
+ fact: designRequestFact,
1209
+ });
1210
+ }
1211
+ catch (error) {
1212
+ console.warn(`[run-dag] warning: failed to write frontend design findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1213
+ }
1214
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1215
+ if (continuationCount < frontendRecoveryQuota) {
1216
+ recoveryTrigger = {
1217
+ requestId: state.runId,
1218
+ // The rejected design is a plan input defect. Reset from the
1219
+ // planner so the next child can incorporate the committed
1220
+ // findings before design review runs again.
1221
+ failureSource: "frontend-plan-pi",
1222
+ failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
1223
+ protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
1224
+ recoveryMode: "new-dag",
1225
+ };
1226
+ }
1227
+ }
1228
+ if (planContractInvalid) {
955
1229
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
956
- if (continuationCount >= 1) {
957
- // Continuation quota exhausted: a second retryable-invalid candidate
1230
+ if (continuationCount >= frontendRecoveryQuota) {
1231
+ // Continuation quota exhausted: a second contract-invalid candidate
958
1232
  // is authoritative candidate-contract-invalid → forced failed.
959
1233
  state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
960
1234
  state.status = "failed";
961
1235
  return { recoveryPending: false };
962
1236
  }
963
- // Root continuation still available: record the recovery intent and let
964
- // mechanical aggregation settle partial_failed/failed. The parent keeps
965
- // phase != settled (recovery in progress) and no final root manifest.
966
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state);
967
- recoveryPending = true;
1237
+ // Root continuation still available: emit a recovery trigger for the
1238
+ // M6 operation controller. The parent no longer writes
1239
+ // frontendRecoveryState or stays in active/ for a child CAS.
1240
+ recoveryTrigger = {
1241
+ requestId: state.runId,
1242
+ failureSource: admission.result.failureSource ?? "frontend-plan-pi",
1243
+ failureOwner: "plan",
1244
+ protocolFailureReason: "frontend candidate contract invalid",
1245
+ recoveryMode: "new-dag",
1246
+ };
968
1247
  }
969
1248
  }
970
1249
  }
971
- // Phase 5: writer transient partial write → rollback + recovery intent. Only
972
- // for frontend-implementation writers with a remaining root continuation, and
973
- // only when the rollback journal (captured before the provider call) restores
974
- // cleanly; otherwise the run stays failed with auto-recovery-blocked.
975
- if (hasFrontendWriter && !recoveryPending && !skipFrontendRecovery) {
1250
+ // Phase 5: writer transient partial write → rollback + recovery trigger.
1251
+ // Only for frontend-implementation writers with a remaining root
1252
+ // continuation, and only when the rollback journal (captured before the
1253
+ // provider call) restores cleanly; otherwise the run stays failed with
1254
+ // auto-recovery-blocked.
1255
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
976
1256
  const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
977
1257
  const node = state.nodes[id];
978
1258
  return (node &&
@@ -983,15 +1263,20 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
983
1263
  });
984
1264
  if (writerNodeId) {
985
1265
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
986
- if (continuationCount < 1) {
1266
+ if (continuationCount < frontendRecoveryQuota) {
987
1267
  const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
988
1268
  const rollback = await rollbackFrontendWriter({
989
1269
  cwd: repoRoot,
990
1270
  parentRunId: state.runId,
991
1271
  });
992
1272
  if (rollback.ok) {
993
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state, "frontend-implement-pi");
994
- recoveryPending = true;
1273
+ recoveryTrigger = {
1274
+ requestId: state.runId,
1275
+ failureSource: "frontend-implement-pi",
1276
+ failureOwner: "implementation",
1277
+ protocolFailureReason: "frontend writer transient partial write",
1278
+ recoveryMode: "continuation",
1279
+ };
995
1280
  }
996
1281
  else {
997
1282
  state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
@@ -1001,6 +1286,64 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1001
1286
  }
1002
1287
  }
1003
1288
  }
1289
+ // Phase 6: verification failure → automatic recovery (re-implement +
1290
+ // re-verify). The verify shell is terminal by design so the failed run's
1291
+ // evidence stays clean; the M6 recovery operation re-runs implement and
1292
+ // verify in a fresh continuation, guarded by the same continuation quota,
1293
+ // budget fold and failure fingerprint as the other recovery paths.
1294
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
1295
+ const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1296
+ if (verifyFailed) {
1297
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1298
+ if (continuationCount < frontendRecoveryQuota) {
1299
+ recoveryTrigger = {
1300
+ requestId: state.runId,
1301
+ failureSource: "frontend-verify-shell",
1302
+ failureOwner: "implementation",
1303
+ protocolFailureReason: "frontend verification failure",
1304
+ recoveryMode: "continuation",
1305
+ };
1306
+ }
1307
+ }
1308
+ }
1309
+ // Phase 7: frontend review "request changes" → not passed. When the review
1310
+ // node FINISHED but committed `request_review_changes` (typed terminal
1311
+ // verdict), the implementation was reviewed and rejected. The run must not
1312
+ // surface as finished: terminal aggregation below stays partial_failed, a
1313
+ // terminal reason is recorded, the committed findings are rendered into a
1314
+ // downstream-task PRD draft (run-owned), and a recovery trigger carries the
1315
+ // issueCategory-derived owner so a later DAG round consumes the findings
1316
+ // instead of repeating the previous flow.
1317
+ const reviewRequestFact = await readCommittedReviewRequestFact({
1318
+ runDir,
1319
+ });
1320
+ if (reviewRequestFact) {
1321
+ state.terminalReason = "review-request-changes";
1322
+ try {
1323
+ await writeFrontendReviewFindingsPrd({
1324
+ runDir,
1325
+ parentRunId: state.runId,
1326
+ fact: reviewRequestFact,
1327
+ });
1328
+ }
1329
+ catch (error) {
1330
+ // The PRD draft is additive; a write failure must not mask the
1331
+ // not-passed verdict. The terminal aggregation below still applies.
1332
+ console.warn(`[run-dag] warning: failed to write frontend review findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1333
+ }
1334
+ if (!recoveryTrigger && !skipFrontendRecovery) {
1335
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1336
+ if (continuationCount < frontendRecoveryQuota) {
1337
+ recoveryTrigger = {
1338
+ requestId: state.runId,
1339
+ failureSource: "frontend-review-pi",
1340
+ failureOwner: classifyReviewIssueCategoryFailureOwner(reviewRequestFact.issueCategory),
1341
+ protocolFailureReason: `frontend review request_review_changes (issueCategory=${reviewRequestFact.issueCategory})`,
1342
+ recoveryMode: "new-dag",
1343
+ };
1344
+ }
1345
+ }
1346
+ }
1004
1347
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
1005
1348
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
1006
1349
  if (state.convergence && supersededFailures.length > 0) {
@@ -1016,7 +1359,13 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1016
1359
  if (effectiveErrorCount === 0 &&
1017
1360
  successTerminal &&
1018
1361
  finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
1019
- state.status = "finished";
1362
+ // Phase 7 guard: a committed `request_review_changes` means the review
1363
+ // rejected the implementation; the run must never surface as finished
1364
+ // even though every node technically FINISHED.
1365
+ state.status =
1366
+ state.terminalReason === "review-request-changes"
1367
+ ? "partial_failed"
1368
+ : "finished";
1020
1369
  }
1021
1370
  else if (finishedCount > 0) {
1022
1371
  state.status = "partial_failed";
@@ -1024,19 +1373,145 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1024
1373
  else {
1025
1374
  state.status = "failed";
1026
1375
  }
1027
- // Terminal recovery bookkeeping for a child attempt: a child that finished
1028
- // recovered settles its own lineage; any other child terminal settles as
1029
- // auto-recovery-blocked (phase 3 performs no further recovery).
1030
- const recovery = state.frontendRecoveryState;
1031
- if (recovery && recovery.attemptIndex >= 1) {
1032
- if (state.status === "finished") {
1033
- settleFrontendRecovery(state, "recovered");
1376
+ return recoveryTrigger
1377
+ ? { recoveryPending: true, recovery: recoveryTrigger }
1378
+ : { recoveryPending: false };
1379
+ }
1380
+ /**
1381
+ * Construct (or reuse) the recovery operation for a failed frontend run.
1382
+ *
1383
+ * A parent-run atomic lease is acquired before `OperationStore.create`, then
1384
+ * its operation id is persisted into that lease before the controller may run.
1385
+ * `clientRequestId` remains a useful store-level replay key, but is not a
1386
+ * cross-process recovery admission boundary. The reset partition is frozen via
1387
+ * `computeFrontendRecoveryPlanForSource` and serialized under the run dir's
1388
+ * bounded `.runtime/` namespace for audit. The operation invokes
1389
+ * `dag rerun-task`, which regenerates a valid child DagSpec and carries typed
1390
+ * parent feedback through the managed rerun path. `planHash` is the second
1391
+ * idempotency dimension of `actionParams`.
1392
+ */
1393
+ async function createRecoveryOperation(input) {
1394
+ const { cwd, runDir, spec, state, trigger } = input;
1395
+ const recoveryLease = await acquireDagRecoveryLease({
1396
+ cwd,
1397
+ parentRunId: state.runId,
1398
+ requestId: trigger.requestId,
1399
+ kind: "auto-frontend-recovery",
1400
+ });
1401
+ let effectiveLease = recoveryLease;
1402
+ let observeOnly = false;
1403
+ if (recoveryLease.kind === "held") {
1404
+ if (recoveryLease.lease.kind !== "auto-frontend-recovery" ||
1405
+ recoveryLease.lease.requestId !== trigger.requestId) {
1406
+ return { kind: "lease-held", lease: recoveryLease.lease };
1407
+ }
1408
+ const claimed = await claimDagRecoveryLease({
1409
+ leasePath: recoveryLease.leasePath,
1410
+ lease: recoveryLease.lease,
1411
+ });
1412
+ effectiveLease = claimed;
1413
+ if (claimed.kind === "held") {
1414
+ if (!claimed.lease.executionRef) {
1415
+ return { kind: "lease-held", lease: claimed.lease };
1416
+ }
1417
+ observeOnly = true;
1418
+ }
1419
+ }
1420
+ const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1421
+ const plan = computeFrontendRecoveryPlanForSource({
1422
+ spec,
1423
+ parentRunId: state.runId,
1424
+ requestId: trigger.requestId,
1425
+ failureSource: trigger.failureSource,
1426
+ });
1427
+ const planHash = createHash("sha256")
1428
+ .update(JSON.stringify(plan))
1429
+ .digest("hex");
1430
+ const continuationPath = path.join(runDir, ".runtime", "frontend-recovery", `${trigger.requestId}.json`);
1431
+ await writeJsonAtomic(continuationPath, {
1432
+ schemaVersion: 1,
1433
+ parentRunId: state.runId,
1434
+ requestId: trigger.requestId,
1435
+ failureSource: trigger.failureSource,
1436
+ recoveryMode: trigger.recoveryMode,
1437
+ planHash,
1438
+ plan,
1439
+ });
1440
+ const operationInput = {
1441
+ clientRequestId: `dag-recovery-${state.runId}`,
1442
+ action: "dagRunTask",
1443
+ actionParams: {
1444
+ parentRunId: state.runId,
1445
+ requestId: trigger.requestId,
1446
+ failureSource: trigger.failureSource,
1447
+ failureOwner: trigger.failureOwner,
1448
+ protocolFailureReason: trigger.protocolFailureReason,
1449
+ recoveryMode: trigger.recoveryMode,
1450
+ planHash,
1451
+ },
1452
+ cliArgs: [
1453
+ "dag",
1454
+ "rerun-task",
1455
+ "--run-id",
1456
+ state.runId,
1457
+ "--reason",
1458
+ trigger.protocolFailureReason ?? "frontend automatic recovery",
1459
+ "--request-id",
1460
+ trigger.requestId,
1461
+ "--frontend-recovery",
1462
+ "--json",
1463
+ ],
1464
+ };
1465
+ let created;
1466
+ if (effectiveLease.lease.executionRef) {
1467
+ // Replay the exact operation bound by the durable lease. A missing record
1468
+ // cannot be replaced with a new operation id without violating the fence.
1469
+ const existing = await input.deps.store.get(effectiveLease.lease.executionRef);
1470
+ if (!existing) {
1471
+ throw new Error(`frontend recovery lease operation not found: ${effectiveLease.lease.executionRef}`);
1034
1472
  }
1035
- else if (!state.frontendRecoveryResult) {
1036
- settleFrontendRecovery(state, "auto-recovery-blocked");
1473
+ if (existing.clientRequestId !== operationInput.clientRequestId ||
1474
+ existing.action !== operationInput.action ||
1475
+ existing.payloadHash !==
1476
+ computeActionPayloadHash(operationInput.action, operationInput.actionParams)) {
1477
+ throw new Error("frontend recovery lease execution identity drift: referenced operation payload does not match the recovery request");
1037
1478
  }
1479
+ created = { kind: "idempotent", operation: existing };
1480
+ }
1481
+ else {
1482
+ created = await input.deps.store.create(operationInput);
1483
+ }
1484
+ if (created.kind !== "conflict" && !observeOnly) {
1485
+ // The durable lease must name the operation before controller execution.
1486
+ // This also repairs a lease left behind by a crash before the original bind.
1487
+ await bindDagRecoveryLeaseExecution({
1488
+ leasePath: effectiveLease.leasePath,
1489
+ lease: effectiveLease.lease,
1490
+ executionRef: created.operation.operationId,
1491
+ });
1492
+ // Repair CLI args on both fresh and replayed operations. This closes the
1493
+ // crash window after operation creation but before args were persisted.
1494
+ await input.deps.store.update(created.operation.operationId, {
1495
+ cliArgs: [
1496
+ "dag",
1497
+ "rerun-task",
1498
+ "--run-id",
1499
+ state.runId,
1500
+ "--reason",
1501
+ trigger.protocolFailureReason ?? "frontend automatic recovery",
1502
+ "--request-id",
1503
+ trigger.requestId,
1504
+ "--frontend-recovery",
1505
+ "--recovery-operation-id",
1506
+ created.operation.operationId,
1507
+ "--json",
1508
+ ],
1509
+ });
1510
+ }
1511
+ if (created.kind !== "conflict" && observeOnly) {
1512
+ return { kind: "observed", operation: created.operation };
1038
1513
  }
1039
- return { recoveryPending };
1514
+ return created;
1040
1515
  }
1041
1516
  function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
1042
1517
  if (rankWriterNodeIds.length <= 1)