@tea-agent/loop-agent 0.41.0-next.3 → 0.41.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/CHANGELOG.md +264 -32
  2. package/dist/application/dag/generate-task-dag.js +75 -19
  3. package/dist/application/task-lifecycle/advance.js +24 -5
  4. package/dist/application/task-lifecycle/observe.js +171 -17
  5. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  6. package/dist/application/task-lifecycle/recommendations.js +13 -2
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +7 -0
  9. package/dist/cli/program.js +5 -0
  10. package/dist/commands/client-recovery.js +3 -0
  11. package/dist/commands/dag-follow-up.js +138 -0
  12. package/dist/commands/init.js +27 -1
  13. package/dist/commands/task-advance.js +19 -0
  14. package/dist/executors/dag-pi-executor.js +3784 -231
  15. package/dist/executors/pi-executor.js +15 -4
  16. package/dist/executors/pi-read-budget-policy.js +239 -0
  17. package/dist/executors/shell-executor.js +945 -217
  18. package/dist/executors/shell-write-guard.js +7 -0
  19. package/dist/shared/dag-failure-category.js +12 -0
  20. package/dist/shared/openspec-spec.js +70 -4
  21. package/dist/shared/operator/capabilities.js +7 -0
  22. package/dist/task/config-types.js +34 -7
  23. package/dist/task/contract/adopt.js +4 -0
  24. package/dist/task/contract/import-revision.js +4 -0
  25. package/dist/task/contract/project.js +3 -0
  26. package/dist/task/contract/schema.js +2 -1
  27. package/dist/task/frontend-project-capability.js +203 -20
  28. package/dist/task/runtime.js +5 -2
  29. package/dist/task/source-prepare/build-draft.js +3 -3
  30. package/dist/task/source-prepare/fragment-inventory.js +15 -9
  31. package/dist/task/source-prepare/prepare.js +89 -1
  32. package/dist/task/source-prepare/semantic-intake.js +6 -2
  33. package/dist/task/source-references.js +22 -1
  34. package/dist/worker/console/chat/pi-runtime.js +2 -3
  35. package/dist/worker/console/chat/routes.js +3 -12
  36. package/dist/worker/console/chat/semantic-activity.js +11 -12
  37. package/dist/worker/console/chat/session-stats.js +0 -96
  38. package/dist/worker/console/chat/turn-process.js +4 -28
  39. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  40. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  41. package/dist/worker/console/index.js +3 -0
  42. package/dist/worker/console/operation-store.js +226 -6
  43. package/dist/worker/console/operator-actions.js +169 -7
  44. package/dist/worker/console/pi-plugins.js +1 -3
  45. package/dist/worker/console/server.js +3 -0
  46. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DwVJxUxz.js → abnfDiagram-N423BO3Z-Bho4we4c.js} +1 -1
  47. package/dist/worker/console/static/assets/{arc-DqfCcXdu.js → arc-TBDkTeK0.js} +1 -1
  48. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-qMVtggRv.js → architectureDiagram-T3A2C74G-BF_fZrn2.js} +1 -1
  49. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-ECrV38dh.js → blockDiagram-VBNYF7ZC-CCnPgUe2.js} +1 -1
  50. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DBaaLqQv.js → c4Diagram-5PPSVZJV-BCYZJLAT.js} +1 -1
  51. package/dist/worker/console/static/assets/channel-bnXW0U3C.js +1 -0
  52. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BvfNHkAz.js → chunk-2GRJ4B5K-D8K6adhU.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-C5StiIbc.js → chunk-2Q5K7J3B-DkT1Q78b.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-HabGiJkS.js → chunk-5RXB4S5H-DuhvpIr7.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BiTg127i.js → chunk-5VM5RSS4-BnK6dC49.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CZsXBPuR.js → chunk-6Q2QTUOP-DPz__z7r.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D-r1MxLK.js → chunk-GF5L2VYU-0kkppM2j.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DQxb9plr.js → chunk-JWPE2WC7-BB41iK5t.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMndGvCT.js → chunk-KBJHAD2P-CiVrxKHU.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ByL4BgOJ.js → chunk-RYQCIY6F-C2h9Xje1.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BVe1b1gL.js → chunk-XXDRQBXY-3iOSHdbM.js} +1 -1
  62. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-rZpq_twR.js +1 -0
  63. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-rZpq_twR.js +1 -0
  64. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BAyGzN8j.js → cose-bilkent-JH36ORCC-BN3A7tkk.js} +1 -1
  65. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-C5yVYU7E.js → cynefin-VYW2F7L2-CTuBy0Or.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-x-MwsEm9.js → cynefinDiagram-MW4NZA55-wb6tUlMN.js} +1 -1
  67. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CT5Tle5F.js → dagre-VZM6K2ZE-GP1Ivx8B.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BARERhlS.js → diagram-7IWD3JNH-ejrcK6L0.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BwfUz0VM.js → diagram-B4RE2ZJO-YFkCCvUU.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Mk4r2WvJ.js → diagram-LBJQPF4R-B4rrvIaL.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-KUrxuBs0.js → diagram-Q27KOJAE-C-YIZcUf.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CjZOceZn.js → diagram-UB23O5K3-BPF5wcL1.js} +1 -1
  73. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-rnwUCiEI.js → ebnfDiagram-BXEA7PRR-CU5SzXjO.js} +1 -1
  74. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DM4j23FM.js → erDiagram-JOGREHBK-CpL414DE.js} +1 -1
  75. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DD5HdDEs.js → flowDiagram-UKHOOZJN-MrhLzNau.js} +1 -1
  76. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CIAGSF47.js → ganttDiagram-PKOTCBZU-Bg4qFuUo.js} +1 -1
  77. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DbwFbVEL.js → gitGraphDiagram-DS77QQ5N-ColxSXLR.js} +1 -1
  78. package/dist/worker/console/static/assets/index-D3CC4eUz.css +1 -0
  79. package/dist/worker/console/static/assets/index-DLt4ZFvD.js +437 -0
  80. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-B-Pb0T66.js → infoDiagram-6WML65LV-Czvtm6gc.js} +1 -1
  81. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B9Unk-YX.js → ishikawaDiagram-WSZJBQD7-B7Xp8HyJ.js} +1 -1
  82. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C2F3JkPt.js → journeyDiagram-NVQOT4AX-DWYZcvph.js} +1 -1
  83. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Bl8-Xcoz.js → kanban-definition-27J2QSJJ-KTLZRUjx.js} +1 -1
  84. package/dist/worker/console/static/assets/{linear-yK4uZmC5.js → linear-_Exn7bfl.js} +1 -1
  85. package/dist/worker/console/static/assets/{mermaid.core-Bpa6PmuE.js → mermaid.core-Cxg8kX8V.js} +5 -5
  86. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BfGRoCSe.js → mindmap-definition-FAOFIHXS-oKrlo2Bt.js} +1 -1
  87. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Bz6_HtST.js → pegDiagram-VL7TDLO6-XAeteiNd.js} +1 -1
  88. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-i0Umbsby.js → pieDiagram-7S7Q4E2Y-BTf1K-lp.js} +1 -1
  89. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DaVQBobe.js → quadrantDiagram-CIZ2JOQS-C0IlY0Jf.js} +1 -1
  90. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DMTGnSq7.js → railroadDiagram-AXF67PYL-BVbKtZhW.js} +1 -1
  91. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-2ao_DaSq.js → requirementDiagram-LRYGKXZP-C5e4JFcr.js} +1 -1
  92. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-2SG1lTB9.js → sankeyDiagram-W5VNT64P-Cz30hc29.js} +1 -1
  93. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BQYOLybR.js → sequenceDiagram-SI44F4Z6-DS33n6d0.js} +1 -1
  94. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-C8g3yjuI.js → sizeCapture-X5ZJPWSS-46w7v-9T.js} +1 -1
  95. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-q_F8nAQG.js → stateDiagram-OKZ733FA-pbcD5SsZ.js} +1 -1
  96. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-zBMYiUyi.js +1 -0
  97. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-8LrWpH2s.js → swimlanes-SLNWSIFB-DuivYBSj.js} +2 -2
  98. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-d_rms-5i.js +8 -0
  99. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BQoY3qzl.js → timeline-definition-Z64GVDOM-Cb9VZVIl.js} +1 -1
  100. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BDu1UjSp.js → vennDiagram-T6HMQDX7-D2-mfOgR.js} +1 -1
  101. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C48M7Y0a.js → wardleyDiagram-T6FBY63Y-DeS2aC76.js} +1 -1
  102. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YU6FTl_F.js → xychartDiagram-ELKLHX3M-WGsrFjxH.js} +1 -1
  103. package/dist/worker/console/static/index.html +2 -2
  104. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  105. package/dist/worker/console/static-src/app/useRunProgress.js +2 -9
  106. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -2
  107. package/dist/worker/console/static-src/operator-chat/useChatThread.js +0 -6
  108. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +3 -28
  109. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +0 -106
  110. package/dist/worker/console/workspace-context.js +111 -1
  111. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  112. package/dist/worker/observability/read-model.js +0 -4
  113. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  114. package/dist/worker/observe/static/views/dag-inspector.js +7 -2
  115. package/dist/worker/observe/static/views/session-timeline.js +15 -46
  116. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  117. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  118. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  119. package/dist/workflows/dag/frontend-closeout.js +221 -0
  120. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  121. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  122. package/dist/workflows/dag/frontend-implementation-contract.js +932 -162
  123. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  124. package/dist/workflows/dag/frontend-prewrite-gate.js +255 -349
  125. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  126. package/dist/workflows/dag/frontend-read-budgets.js +12 -0
  127. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  128. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  129. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  130. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  131. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  132. package/dist/workflows/dag/frontend-repair.js +1 -432
  133. package/dist/workflows/dag/frontend-review-context.js +261 -15
  134. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  135. package/dist/workflows/dag/frontend-shadow-dual-write.js +914 -0
  136. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  137. package/dist/workflows/dag/frontend-shape-facts.js +409 -0
  138. package/dist/workflows/dag/frontend-shape.js +427 -0
  139. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  140. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  141. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  142. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  143. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  144. package/dist/workflows/dag/frontend-verification-trace.js +249 -24
  145. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  146. package/dist/workflows/dag/frontend-writer-admission.js +285 -0
  147. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  148. package/dist/workflows/dag/init-hybrid.js +685 -494
  149. package/dist/workflows/dag/interrupt-request.js +7 -0
  150. package/dist/workflows/dag/node-execution.js +732 -4
  151. package/dist/workflows/dag/prompt.js +34 -1
  152. package/dist/workflows/dag/recovery-lease.js +80 -0
  153. package/dist/workflows/dag/report.js +37 -1
  154. package/dist/workflows/dag/rerun-feedback.js +256 -1
  155. package/dist/workflows/dag/rerun-plan.js +10 -2
  156. package/dist/workflows/dag/rerun-run.js +23 -0
  157. package/dist/workflows/dag/rerun-task.js +29 -0
  158. package/dist/workflows/dag/retry-policy.js +214 -104
  159. package/dist/workflows/dag/runner.js +429 -122
  160. package/dist/workflows/dag/scheduler.js +114 -16
  161. package/dist/workflows/dag/types.js +239 -12
  162. package/dist/workflows/dag/validate.js +28 -12
  163. package/docs/init-surface.manifest.json +30 -12
  164. package/docs/skills/vetted-skill-registry.md +4 -2
  165. package/docs/templates/README.md +2 -0
  166. package/docs/templates/agent-dag-report.schema.json +8 -2
  167. package/docs/templates/agent-dag.schema.json +1 -1
  168. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  169. package/docs/templates/frontend-implementation-dag.json +89 -0
  170. package/docs/templates/spec-registry.schema.json +45 -0
  171. package/harness.json +3 -3
  172. package/package.json +1 -1
  173. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  174. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  175. package/skills/frontend-contract/SKILL.md +23 -0
  176. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  177. package/skills/frontend-design-review/SKILL.md +22 -41
  178. package/skills/frontend-plan/SKILL.md +26 -0
  179. package/skills/frontend-plan/references/decision-contract.md +37 -0
  180. package/skills/frontend-plan/references/design-decisions.md +17 -0
  181. package/skills/frontend-review/SKILL.md +20 -15
  182. package/skills/frontend-review/references/review-findings.md +6 -7
  183. package/skills/frontend-scout/SKILL.md +25 -0
  184. package/skills/frontend-scout/references/design-evidence.md +16 -0
  185. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  186. package/skills/frontend-verification/SKILL.md +1 -1
  187. package/skills/loop-agent/references/command-reference.md +1 -0
  188. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  189. package/dist/worker/console/static/assets/channel-CL1NRAmS.js +0 -1
  190. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-PBGzzjZA.js +0 -1
  191. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-PBGzzjZA.js +0 -1
  192. package/dist/worker/console/static/assets/index-BfleX-qj.css +0 -1
  193. package/dist/worker/console/static/assets/index-Bih9w9h9.js +0 -451
  194. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrxGBDJu.js +0 -1
  195. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BhpDxzAf.js +0 -8
  196. package/dist/worker/console/static-src/operator-chat/dag-progress-link.js +0 -22
  197. package/skills/frontend-implementation/SKILL.md +0 -52
  198. package/skills/frontend-implementation/references/code-standards.md +0 -33
  199. package/skills/frontend-implementation/references/design-spec.md +0 -56
  200. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -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, 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,12 @@ 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 { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, } from "./recovery-lease.js";
30
35
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
31
36
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
32
37
  import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
@@ -46,6 +51,51 @@ export function toKebabSlug(text, maxLen = 40) {
46
51
  .replace(/^-|-$/g, "")
47
52
  .slice(0, maxLen) || "dag");
48
53
  }
54
+ export async function loadCanonicalFrontendTaskSourceBinding(cwd, spec) {
55
+ const binding = spec.sourceBinding;
56
+ if (!binding?.taskId)
57
+ throw new Error("canonical frontend DAG requires sourceBinding.taskId");
58
+ const requirement = binding.sources.find((source) => source.kind === "requirement");
59
+ if (!requirement)
60
+ throw new Error("canonical frontend DAG requires a bound requirement source");
61
+ const constraint = binding.sources.find((source) => source.kind === "constraint");
62
+ const readFreshSource = async (source) => {
63
+ let bytes;
64
+ try {
65
+ bytes = await readFile(path.resolve(cwd, source.path));
66
+ }
67
+ catch (error) {
68
+ throw new Error(`canonical frontend ${source.kind} source freshness read failed: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
71
+ if (actualSha256 !== source.sha256) {
72
+ throw new Error(`canonical frontend ${source.kind} source sha256 drifted: expected ${source.sha256}, received ${actualSha256}`);
73
+ }
74
+ return bytes.toString("utf8");
75
+ };
76
+ const requirementMarkdown = await readFreshSource(requirement);
77
+ const constraintMarkdown = constraint ? await readFreshSource(constraint) : "";
78
+ return {
79
+ taskId: binding.taskId,
80
+ requirementMarkdown,
81
+ constraintMarkdown,
82
+ sourceDigest: computeFrontendShapeSourceDigest({ requirementMarkdown, constraintMarkdown }),
83
+ };
84
+ }
85
+ export async function ensureFreshFrontendShapeCapsuleRecord(input) {
86
+ const freshSource = await loadCanonicalFrontendTaskSourceBinding(input.cwd, input.spec);
87
+ if (freshSource.taskId !== input.canonicalSource.taskId ||
88
+ freshSource.sourceDigest !== input.canonicalSource.sourceDigest) {
89
+ throw new Error("canonical frontend task source freshness binding drifted before transition commit");
90
+ }
91
+ await (input.ensureRecord ?? ensureFrontendShapeCapsuleRecord)({
92
+ repoRoot: input.cwd,
93
+ taskId: input.canonicalSource.taskId,
94
+ parentRunId: input.parentRunId,
95
+ terminalOutcome: input.terminalOutcome,
96
+ capsule: input.capsule,
97
+ });
98
+ }
49
99
  export function extractTaskIdFromDagSpec(spec) {
50
100
  if (spec.objective) {
51
101
  const match = spec.objective.match(TASK_ID_IN_TEXT_PATTERN);
@@ -180,6 +230,15 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
180
230
  ...(opts.workerAssociation
181
231
  ? { workerAssociation: structuredClone(opts.workerAssociation) }
182
232
  : {}),
233
+ ...(isCanonicalFrontendShapeDag(new Map(spec.tasks.map((task) => [task.id, task])))
234
+ ? {
235
+ frontendShapeState: {
236
+ frozenTopology: buildFrontendTopologyBinding(spec.tasks.map((task) => task.id)),
237
+ revision: 0,
238
+ committedFacts: {},
239
+ },
240
+ }
241
+ : {}),
183
242
  };
184
243
  initRunBudgetLedger(state, spec.budget);
185
244
  return state;
@@ -301,6 +360,7 @@ export async function runDag(spec, opts) {
301
360
  activeRunDir,
302
361
  completedRunDir,
303
362
  pausedRunDir,
363
+ recovery: opts.recovery,
304
364
  });
305
365
  }
306
366
  finally {
@@ -309,6 +369,16 @@ export async function runDag(spec, opts) {
309
369
  export async function runDagContinuation(opts) {
310
370
  const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), opts.spec.budget);
311
371
  const state = opts.state;
372
+ const canonicalFrontend = isCanonicalFrontendShapeDag(new Map(opts.spec.tasks.map((task) => [task.id, task])));
373
+ if (canonicalFrontend) {
374
+ assertFrontendTopologyBinding(state.frontendShapeState?.frozenTopology, opts.spec.tasks.map((task) => task.id));
375
+ }
376
+ else if (state.frontendShapeState) {
377
+ throw new Error("non-frontend DAG unexpectedly contains frontend topology state");
378
+ }
379
+ if (state.frontendShapeState?.terminalOutcome) {
380
+ throw new Error(`dag run ${state.runId} is terminal due to frontend shape ${state.frontendShapeState.terminalOutcome}`);
381
+ }
312
382
  state.status = "running";
313
383
  const resumedAt = new Date().toISOString();
314
384
  state.runner = {
@@ -333,6 +403,7 @@ export async function runDagContinuation(opts) {
333
403
  activeRunDir,
334
404
  completedRunDir,
335
405
  pausedRunDir,
406
+ recovery: opts.recovery,
336
407
  });
337
408
  }
338
409
  export async function resumeDagRun(opts) {
@@ -435,9 +506,13 @@ export async function resumeDagRun(opts) {
435
506
  }
436
507
  async function executeDagCheckpoint(input) {
437
508
  const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
509
+ bindFrontendShapeStateToRunTopology(state);
438
510
  let runDir = initialRunDir;
439
511
  let stateWriteQueue = Promise.resolve();
512
+ let shapeTransitionBarrier;
440
513
  const persistState = async (options) => {
514
+ if (shapeTransitionBarrier)
515
+ await shapeTransitionBarrier;
441
516
  stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
442
517
  await stateWriteQueue;
443
518
  };
@@ -562,12 +637,50 @@ async function executeDagCheckpoint(input) {
562
637
  .map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
563
638
  .filter((rank) => rank.length > 0)
564
639
  : [];
640
+ const canonicalFrontendSource = isCanonicalFrontendShapeDag(tasksById)
641
+ ? await loadCanonicalFrontendTaskSourceBinding(cwd, spec)
642
+ : undefined;
565
643
  const executeRanks = (selectedRanks) => executeDagRanksOnce({
566
644
  state,
567
645
  ranks: selectedRanks,
568
646
  tasksById,
569
647
  maxConcurrent,
570
648
  persistState,
649
+ commitFrontendShapeTransition: async (request) => {
650
+ let releaseBarrier;
651
+ shapeTransitionBarrier = new Promise((resolve) => {
652
+ releaseBarrier = resolve;
653
+ });
654
+ try {
655
+ if (!canonicalFrontendSource) {
656
+ throw new Error("frontend shape transition requires canonical task source binding");
657
+ }
658
+ return await commitFrontendShapeTransitionCas({
659
+ state,
660
+ tasksById,
661
+ request,
662
+ taskSourceDigest: canonicalFrontendSource.sourceDigest,
663
+ ensureRecord: async (capsule, terminalOutcome) => {
664
+ await ensureFreshFrontendShapeCapsuleRecord({
665
+ cwd,
666
+ spec,
667
+ canonicalSource: canonicalFrontendSource,
668
+ parentRunId: state.runId,
669
+ terminalOutcome,
670
+ capsule,
671
+ });
672
+ },
673
+ persist: async (nextState) => {
674
+ stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, nextState));
675
+ await stateWriteQueue;
676
+ },
677
+ });
678
+ }
679
+ finally {
680
+ releaseBarrier();
681
+ shapeTransitionBarrier = undefined;
682
+ }
683
+ },
571
684
  runDir,
572
685
  abortSignal: input.abortSignal,
573
686
  createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
@@ -603,6 +716,18 @@ async function executeDagCheckpoint(input) {
603
716
  });
604
717
  const node = state.nodes[nodeId];
605
718
  if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
719
+ if (node.status === "FINISHED") {
720
+ if (canonicalFrontendSource) {
721
+ await commitRuntimeFrontendShapeFact({
722
+ state,
723
+ tasksById,
724
+ sourceNodeId: nodeId,
725
+ taskId: canonicalFrontendSource.taskId,
726
+ requirementMarkdown: canonicalFrontendSource.requirementMarkdown,
727
+ constraintMarkdown: canonicalFrontendSource.constraintMarkdown,
728
+ });
729
+ }
730
+ }
606
731
  recordFinishedNodeBudget(state, node);
607
732
  await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
608
733
  await persistState();
@@ -612,6 +737,9 @@ async function executeDagCheckpoint(input) {
612
737
  let pausedByNodeId;
613
738
  while (true) {
614
739
  pausedByNodeId = await executeRanks(convergenceRanks);
740
+ if (state.frontendShapeState?.terminalOutcome) {
741
+ break;
742
+ }
615
743
  if (pausedByNodeId) {
616
744
  break;
617
745
  }
@@ -667,7 +795,7 @@ async function executeDagCheckpoint(input) {
667
795
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
668
796
  }
669
797
  else {
670
- const { recoveryPending } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
798
+ const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd, spec.frontendRecovery?.maxContinuations ?? 1);
671
799
  await finalizeRunOwnedInterrupt({
672
800
  runDir,
673
801
  state,
@@ -675,52 +803,65 @@ async function executeDagCheckpoint(input) {
675
803
  interruptAborted: interruptController.signal.aborted,
676
804
  });
677
805
  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,
806
+ if (recoveryPending && input.recovery && recoveryTrigger) {
807
+ // M6 recovery operation controller path: construct/reuse one recovery
808
+ // operation keyed by a deterministic clientRequestId and supervise it
809
+ // to terminal, instead of staging an in-process child run.
810
+ const created = await createRecoveryOperation({
811
+ cwd,
812
+ runDir,
813
+ spec,
814
+ state,
815
+ trigger: recoveryTrigger,
816
+ deps: input.recovery,
817
+ });
818
+ if (created.kind === "conflict") {
819
+ // Same clientRequestId with a different payload → fail closed; never
820
+ // create a second recovery operation (AC-004).
821
+ state.status = "failed";
822
+ state.terminalReason =
823
+ "frontend recovery request-id reuse conflict";
824
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
825
+ }
826
+ else if (created.kind === "lease-held") {
827
+ // A different recovery entrance has already durably claimed this
828
+ // parent. Do not "help" by running its operation: that would reopen
829
+ // the exact two-child-DAG race the lease prevents.
830
+ state.status = "failed";
831
+ state.terminalReason =
832
+ "frontend recovery parent lease already held";
833
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
834
+ }
835
+ else {
836
+ const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
837
+ const result = await runRecoveryOperationController({
838
+ operationId: created.operation.operationId,
691
839
  parentRunId: state.runId,
840
+ deps: {
841
+ ...input.recovery,
842
+ failureOwner: input.recovery.failureOwner ??
843
+ recoveryTrigger.failureOwner,
844
+ protocolFailureReason: input.recovery.protocolFailureReason ??
845
+ recoveryTrigger.protocolFailureReason,
846
+ recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
847
+ },
692
848
  });
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
- });
849
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded"
850
+ ? "recovered"
851
+ : "auto-recovery-blocked");
711
852
  }
712
853
  }
713
854
  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);
855
+ // Parent is always archived to completed/ — no active/ retention for an
856
+ // in-process child CAS anymore.
857
+ try {
858
+ const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
859
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
860
+ }
861
+ catch (error) {
862
+ console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
723
863
  }
864
+ runDir = await moveToCompletedRunDir(runDir, completedRunDir);
724
865
  }
725
866
  await relocateRunArtifactPaths({
726
867
  runDir,
@@ -853,39 +994,21 @@ function isSuccessfulConvergenceTerminal(state) {
853
994
  return true;
854
995
  return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
855
996
  }
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
997
  function buildCandidateContractInvalidResult(state, result, artifactHash) {
874
- const recovery = state.frontendRecoveryState;
875
998
  return {
876
999
  schemaVersion: 1,
877
1000
  outcome: "candidate-contract-invalid",
878
1001
  origin: {
879
1002
  kind: "frontend-prewrite-gate",
880
- parentRunId: recovery?.parentRunId ?? state.runId,
881
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
882
- requestId: recovery?.requestId ?? "",
883
- failedNodeId: result.selectedPlanNodeId,
1003
+ parentRunId: state.runId,
1004
+ requestId: state.runId,
1005
+ failedNodeId: result.failureSource ?? "frontend-plan-pi",
884
1006
  },
885
1007
  failureClass: {
886
1008
  code: "candidate-contract-invalid",
887
1009
  classification: result.classification,
888
- reason: result.failureReason ?? "candidate contract invalid",
1010
+ reason: result.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; ") ??
1011
+ "candidate contract invalid",
889
1012
  },
890
1013
  evidenceRefs: [
891
1014
  {
@@ -897,15 +1020,13 @@ function buildCandidateContractInvalidResult(state, result, artifactHash) {
897
1020
  };
898
1021
  }
899
1022
  function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
900
- const recovery = state.frontendRecoveryState;
901
1023
  return {
902
1024
  schemaVersion: 1,
903
1025
  outcome: "auto-recovery-blocked",
904
1026
  origin: {
905
1027
  kind: "frontend-writer",
906
- parentRunId: recovery?.parentRunId ?? state.runId,
907
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
908
- requestId: recovery?.requestId ?? "",
1028
+ parentRunId: state.runId,
1029
+ requestId: state.runId,
909
1030
  failedNodeId,
910
1031
  },
911
1032
  failureClass: {
@@ -916,63 +1037,115 @@ function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
916
1037
  evidenceRefs: [],
917
1038
  };
918
1039
  }
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
- }
1040
+ function buildRecoveryResultForTrigger(state, trigger, outcome) {
1041
+ return {
1042
+ schemaVersion: 1,
1043
+ outcome,
1044
+ origin: {
1045
+ kind: trigger.failureSource === "frontend-implement-pi"
1046
+ ? "frontend-writer"
1047
+ : "frontend-prewrite-gate",
1048
+ parentRunId: state.runId,
1049
+ requestId: trigger.requestId,
1050
+ failedNodeId: trigger.failureSource,
1051
+ },
1052
+ evidenceRefs: [],
1053
+ };
938
1054
  }
939
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1055
+ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
940
1056
  const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1057
+ if (state.frontendShapeState?.terminalOutcome &&
1058
+ state.frontendShapeState.transitionCapsule) {
1059
+ state.status = "failed";
1060
+ return { recoveryPending: false };
1061
+ }
941
1062
  // Terminal override: a denied frontend writer must never surface as a
942
1063
  // 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;
1064
+ // was SKIPPED. Fail closed on blocked before aggregation.
1065
+ let recoveryTrigger;
945
1066
  const skipFrontendRecovery = state.failureCategory === "controller-interrupted";
946
1067
  const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
947
1068
  if (hasFrontendWriter && !skipFrontendRecovery) {
948
1069
  const admission = await readFrontendPrewriteResult(runDir);
949
1070
  if (admission.ok) {
950
- if (admission.result.classification === "blocked") {
1071
+ // M8: admission has only admitted|blocked. A blocked admission whose
1072
+ // failureSource is frontend-plan-pi is the contract-invalid analog of
1073
+ // the old retryable-invalid path (candidate continuation may re-plan);
1074
+ // every other blocked admission is terminal.
1075
+ const planContractInvalid = admission.result.classification === "blocked" &&
1076
+ admission.result.failureSource === "frontend-plan-pi";
1077
+ // A blocked admission caused by the design review committing
1078
+ // `request_design_changes` is a definite finding (not a protocol or
1079
+ // environment error): the design was reviewed and rejected. It is the
1080
+ // design-review analog of review-request-changes — not terminal, but
1081
+ // re-enters the next plan round with the committed design findings.
1082
+ // The failureSource may be frontend-writer-admission-shell or
1083
+ // frontend-design-policy-shell; the committed design fact is the
1084
+ // authoritative signal.
1085
+ const designRequestFact = await readCommittedDesignRequestFact({
1086
+ runDir,
1087
+ });
1088
+ const designRejected = admission.result.classification === "blocked" &&
1089
+ designRequestFact !== undefined;
1090
+ const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1091
+ admission.result.classification === "stale") &&
1092
+ !planContractInvalid &&
1093
+ !designRejected;
1094
+ if (terminalAdmissionDenial) {
951
1095
  state.status = "failed";
952
1096
  return { recoveryPending: false };
953
1097
  }
954
- if (admission.result.classification === "retryable-invalid") {
1098
+ if (designRejected && designRequestFact) {
1099
+ state.terminalReason = "design-request-changes";
1100
+ try {
1101
+ await writeFrontendReviewFindingsPrd({
1102
+ runDir,
1103
+ parentRunId: state.runId,
1104
+ fact: designRequestFact,
1105
+ });
1106
+ }
1107
+ catch (error) {
1108
+ console.warn(`[run-dag] warning: failed to write frontend design findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1109
+ }
1110
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1111
+ if (continuationCount < frontendRecoveryQuota) {
1112
+ recoveryTrigger = {
1113
+ requestId: state.runId,
1114
+ failureSource: "frontend-design-review-pi",
1115
+ failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
1116
+ protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
1117
+ recoveryMode: "new-dag",
1118
+ };
1119
+ }
1120
+ }
1121
+ if (planContractInvalid) {
955
1122
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
956
- if (continuationCount >= 1) {
957
- // Continuation quota exhausted: a second retryable-invalid candidate
1123
+ if (continuationCount >= frontendRecoveryQuota) {
1124
+ // Continuation quota exhausted: a second contract-invalid candidate
958
1125
  // is authoritative candidate-contract-invalid → forced failed.
959
1126
  state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
960
1127
  state.status = "failed";
961
1128
  return { recoveryPending: false };
962
1129
  }
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;
1130
+ // Root continuation still available: emit a recovery trigger for the
1131
+ // M6 operation controller. The parent no longer writes
1132
+ // frontendRecoveryState or stays in active/ for a child CAS.
1133
+ recoveryTrigger = {
1134
+ requestId: state.runId,
1135
+ failureSource: admission.result.failureSource ?? "frontend-plan-pi",
1136
+ failureOwner: "plan",
1137
+ protocolFailureReason: "frontend candidate contract invalid",
1138
+ recoveryMode: "new-dag",
1139
+ };
968
1140
  }
969
1141
  }
970
1142
  }
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) {
1143
+ // Phase 5: writer transient partial write → rollback + recovery trigger.
1144
+ // Only for frontend-implementation writers with a remaining root
1145
+ // continuation, and only when the rollback journal (captured before the
1146
+ // provider call) restores cleanly; otherwise the run stays failed with
1147
+ // auto-recovery-blocked.
1148
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
976
1149
  const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
977
1150
  const node = state.nodes[id];
978
1151
  return (node &&
@@ -983,15 +1156,20 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
983
1156
  });
984
1157
  if (writerNodeId) {
985
1158
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
986
- if (continuationCount < 1) {
1159
+ if (continuationCount < frontendRecoveryQuota) {
987
1160
  const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
988
1161
  const rollback = await rollbackFrontendWriter({
989
1162
  cwd: repoRoot,
990
1163
  parentRunId: state.runId,
991
1164
  });
992
1165
  if (rollback.ok) {
993
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state, "frontend-implement-pi");
994
- recoveryPending = true;
1166
+ recoveryTrigger = {
1167
+ requestId: state.runId,
1168
+ failureSource: "frontend-implement-pi",
1169
+ failureOwner: "implementation",
1170
+ protocolFailureReason: "frontend writer transient partial write",
1171
+ recoveryMode: "continuation",
1172
+ };
995
1173
  }
996
1174
  else {
997
1175
  state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
@@ -1001,6 +1179,64 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1001
1179
  }
1002
1180
  }
1003
1181
  }
1182
+ // Phase 6: verification failure → automatic recovery (re-implement +
1183
+ // re-verify). The verify shell is terminal by design so the failed run's
1184
+ // evidence stays clean; the M6 recovery operation re-runs implement and
1185
+ // verify in a fresh continuation, guarded by the same continuation quota,
1186
+ // budget fold and failure fingerprint as the other recovery paths.
1187
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
1188
+ const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1189
+ if (verifyFailed) {
1190
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1191
+ if (continuationCount < frontendRecoveryQuota) {
1192
+ recoveryTrigger = {
1193
+ requestId: state.runId,
1194
+ failureSource: "frontend-verify-shell",
1195
+ failureOwner: "implementation",
1196
+ protocolFailureReason: "frontend verification failure",
1197
+ recoveryMode: "continuation",
1198
+ };
1199
+ }
1200
+ }
1201
+ }
1202
+ // Phase 7: frontend review "request changes" → not passed. When the review
1203
+ // node FINISHED but committed `request_review_changes` (typed terminal
1204
+ // verdict), the implementation was reviewed and rejected. The run must not
1205
+ // surface as finished: terminal aggregation below stays partial_failed, a
1206
+ // terminal reason is recorded, the committed findings are rendered into a
1207
+ // downstream-task PRD draft (run-owned), and a recovery trigger carries the
1208
+ // issueCategory-derived owner so a later DAG round consumes the findings
1209
+ // instead of repeating the previous flow.
1210
+ const reviewRequestFact = await readCommittedReviewRequestFact({
1211
+ runDir,
1212
+ });
1213
+ if (reviewRequestFact) {
1214
+ state.terminalReason = "review-request-changes";
1215
+ try {
1216
+ await writeFrontendReviewFindingsPrd({
1217
+ runDir,
1218
+ parentRunId: state.runId,
1219
+ fact: reviewRequestFact,
1220
+ });
1221
+ }
1222
+ catch (error) {
1223
+ // The PRD draft is additive; a write failure must not mask the
1224
+ // not-passed verdict. The terminal aggregation below still applies.
1225
+ console.warn(`[run-dag] warning: failed to write frontend review findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1226
+ }
1227
+ if (!recoveryTrigger && !skipFrontendRecovery) {
1228
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1229
+ if (continuationCount < frontendRecoveryQuota) {
1230
+ recoveryTrigger = {
1231
+ requestId: state.runId,
1232
+ failureSource: "frontend-review-pi",
1233
+ failureOwner: classifyReviewIssueCategoryFailureOwner(reviewRequestFact.issueCategory),
1234
+ protocolFailureReason: `frontend review request_review_changes (issueCategory=${reviewRequestFact.issueCategory})`,
1235
+ recoveryMode: "new-dag",
1236
+ };
1237
+ }
1238
+ }
1239
+ }
1004
1240
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
1005
1241
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
1006
1242
  if (state.convergence && supersededFailures.length > 0) {
@@ -1016,7 +1252,13 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1016
1252
  if (effectiveErrorCount === 0 &&
1017
1253
  successTerminal &&
1018
1254
  finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
1019
- state.status = "finished";
1255
+ // Phase 7 guard: a committed `request_review_changes` means the review
1256
+ // rejected the implementation; the run must never surface as finished
1257
+ // even though every node technically FINISHED.
1258
+ state.status =
1259
+ state.terminalReason === "review-request-changes"
1260
+ ? "partial_failed"
1261
+ : "finished";
1020
1262
  }
1021
1263
  else if (finishedCount > 0) {
1022
1264
  state.status = "partial_failed";
@@ -1024,19 +1266,84 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1024
1266
  else {
1025
1267
  state.status = "failed";
1026
1268
  }
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");
1034
- }
1035
- else if (!state.frontendRecoveryResult) {
1036
- settleFrontendRecovery(state, "auto-recovery-blocked");
1037
- }
1269
+ return recoveryTrigger
1270
+ ? { recoveryPending: true, recovery: recoveryTrigger }
1271
+ : { recoveryPending: false };
1272
+ }
1273
+ /**
1274
+ * Construct (or reuse) the recovery operation for a failed frontend run.
1275
+ *
1276
+ * A parent-run atomic lease is acquired before `OperationStore.create`, then
1277
+ * its operation id is persisted into that lease before the controller may run.
1278
+ * `clientRequestId` remains a useful store-level replay key, but is not a
1279
+ * cross-process recovery admission boundary. The continuation DagSpec/reset
1280
+ * partition is frozen via `computeFrontendRecoveryPlanForSource` and
1281
+ * serialized under the run dir's bounded `.runtime/` namespace. `planHash` is
1282
+ * the second idempotency dimension of `actionParams`.
1283
+ */
1284
+ async function createRecoveryOperation(input) {
1285
+ const { cwd, runDir, spec, state, trigger } = input;
1286
+ const recoveryLease = await acquireDagRecoveryLease({
1287
+ cwd,
1288
+ parentRunId: state.runId,
1289
+ requestId: trigger.requestId,
1290
+ kind: "auto-frontend-recovery",
1291
+ });
1292
+ if (recoveryLease.kind === "held") {
1293
+ return { kind: "lease-held", lease: recoveryLease.lease };
1294
+ }
1295
+ const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1296
+ const plan = computeFrontendRecoveryPlanForSource({
1297
+ spec,
1298
+ parentRunId: state.runId,
1299
+ requestId: trigger.requestId,
1300
+ failureSource: trigger.failureSource,
1301
+ });
1302
+ const planHash = createHash("sha256")
1303
+ .update(JSON.stringify(plan))
1304
+ .digest("hex");
1305
+ const continuationPath = path.join(runDir, ".runtime", "frontend-recovery", `${trigger.requestId}.json`);
1306
+ await writeJsonAtomic(continuationPath, {
1307
+ schemaVersion: 1,
1308
+ parentRunId: state.runId,
1309
+ requestId: trigger.requestId,
1310
+ failureSource: trigger.failureSource,
1311
+ recoveryMode: trigger.recoveryMode,
1312
+ plan,
1313
+ });
1314
+ const created = await input.deps.store.create({
1315
+ clientRequestId: `dag-recovery-${state.runId}`,
1316
+ action: "dagRunTask",
1317
+ actionParams: {
1318
+ parentRunId: state.runId,
1319
+ requestId: trigger.requestId,
1320
+ failureSource: trigger.failureSource,
1321
+ failureOwner: trigger.failureOwner,
1322
+ protocolFailureReason: trigger.protocolFailureReason,
1323
+ recoveryMode: trigger.recoveryMode,
1324
+ planHash,
1325
+ },
1326
+ cliArgs: [
1327
+ "dag",
1328
+ "execute",
1329
+ "--dag",
1330
+ continuationPath,
1331
+ "--cwd",
1332
+ cwd,
1333
+ "--json",
1334
+ ],
1335
+ });
1336
+ if (created.kind !== "conflict") {
1337
+ // The durable lease must name the operation before controller execution.
1338
+ // A crash in this narrow window leaves a safely blocked reservation rather
1339
+ // than a second process that can launch another recovery DAG.
1340
+ await bindDagRecoveryLeaseExecution({
1341
+ leasePath: recoveryLease.leasePath,
1342
+ lease: recoveryLease.lease,
1343
+ executionRef: created.operation.operationId,
1344
+ });
1038
1345
  }
1039
- return { recoveryPending };
1346
+ return created;
1040
1347
  }
1041
1348
  function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
1042
1349
  if (rankWriterNodeIds.length <= 1)