@tea-agent/loop-agent 0.42.0-next.2 → 0.42.0-next.3

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 (193) hide show
  1. package/CHANGELOG.md +242 -1
  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 +942 -187
  18. package/dist/executors/shell-write-guard.js +7 -0
  19. package/dist/infrastructure/console/operation-store.js +226 -6
  20. package/dist/shared/dag-failure-category.js +12 -0
  21. package/dist/shared/openspec-spec.js +70 -4
  22. package/dist/shared/operator/capabilities.js +7 -0
  23. package/dist/task/config-types.js +34 -5
  24. package/dist/task/contract/adopt.js +4 -0
  25. package/dist/task/contract/import-revision.js +4 -0
  26. package/dist/task/contract/project.js +3 -0
  27. package/dist/task/contract/schema.js +2 -1
  28. package/dist/task/frontend-project-capability.js +203 -20
  29. package/dist/task/runtime.js +5 -2
  30. package/dist/task/source-prepare/build-draft.js +3 -3
  31. package/dist/task/source-prepare/fragment-inventory.js +15 -9
  32. package/dist/task/source-prepare/prepare.js +89 -1
  33. package/dist/task/source-prepare/semantic-intake.js +6 -2
  34. package/dist/task/source-references.js +22 -1
  35. package/dist/worker/console/chat/chat-ui-policy.js +4 -2
  36. package/dist/worker/console/chat/pi-runtime.js +173 -7
  37. package/dist/worker/console/chat/repo-browser.js +10 -3
  38. package/dist/worker/console/chat/routes.js +30 -19
  39. package/dist/worker/console/chat/session-store.js +117 -70
  40. package/dist/worker/console/chat/shortcuts.js +1 -1
  41. package/dist/worker/console/chat/turn-process.js +111 -56
  42. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  43. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  44. package/dist/worker/console/index.js +3 -0
  45. package/dist/worker/console/operator-actions.js +169 -7
  46. package/dist/worker/console/server.js +3 -0
  47. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CeznJ4Iw.js} +1 -1
  48. package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-B4u9u7SX.js} +1 -1
  49. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-oghpT81p.js} +1 -1
  50. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-BPJ5Kvcj.js} +1 -1
  51. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-CoIu69pd.js} +1 -1
  52. package/dist/worker/console/static/assets/channel-DmIXqJ6B.js +1 -0
  53. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-DtgiAsSc.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-YSu_EVSD.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-DaRO-NB6.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-Dh0ZKiGA.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DOrvbR-H.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-C3b22LN2.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-CXlqO8l6.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-BblTBrDu.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-DswqPALe.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-CfTwVOlc.js} +1 -1
  63. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-MFV5Qxb5.js +1 -0
  64. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-MFV5Qxb5.js +1 -0
  65. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-bLD8jVFo.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-BvoG1fh5.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-DEPAdJu3.js} +1 -1
  68. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-DmrNvBLy.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-CdwzIwvs.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-CJ3hj-md.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-C6YJT2a4.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-C_LPfjhX.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-xvsSb5Ie.js} +1 -1
  74. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-Rxp2tKcD.js} +1 -1
  75. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-DRyLYmZh.js} +1 -1
  76. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-TLSU_D2B.js} +1 -1
  77. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DGqeTvHC.js} +1 -1
  78. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-BZxyH2wh.js} +1 -1
  79. package/dist/worker/console/static/assets/index-C8V8K9b4.js +451 -0
  80. package/dist/worker/console/static/assets/index-Cnf_KxTQ.css +1 -0
  81. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-BsYtzx01.js} +1 -1
  82. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-Qr-ezGv-.js} +1 -1
  83. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-DYbB3ibo.js} +1 -1
  84. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-BHJHewZO.js} +1 -1
  85. package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-CmPIPmCG.js} +1 -1
  86. package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-F--C1uV0.js} +5 -5
  87. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-YTXfL1st.js} +1 -1
  88. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-DdDMYXP9.js} +1 -1
  89. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-C3nH4KVd.js} +1 -1
  90. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-kXyxjG7Y.js} +1 -1
  91. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-KNzbl-7w.js} +1 -1
  92. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-CSwI6dG0.js} +1 -1
  93. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-Bl1AyuXy.js} +1 -1
  94. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-2y4EmXQa.js} +1 -1
  95. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-E8v8YrMg.js} +1 -1
  96. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-eGGlR856.js} +1 -1
  97. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8e4J3at.js +1 -0
  98. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-B7g7xc07.js} +2 -2
  99. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Do88I1Nb.js +8 -0
  100. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-DR3VCHBG.js} +1 -1
  101. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-LUvapZIy.js} +1 -1
  102. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-C7GDcaXq.js} +1 -1
  103. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CbNAqoGK.js} +1 -1
  104. package/dist/worker/console/static/index.html +2 -2
  105. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  106. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
  107. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
  108. package/dist/worker/console/workspace-context.js +111 -1
  109. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  110. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  111. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  112. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  113. package/dist/workflows/dag/frontend-closeout.js +221 -0
  114. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  115. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  116. package/dist/workflows/dag/frontend-implementation-contract.js +907 -162
  117. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  118. package/dist/workflows/dag/frontend-prewrite-gate.js +255 -349
  119. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  120. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  121. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  122. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  123. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  124. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  125. package/dist/workflows/dag/frontend-repair.js +1 -432
  126. package/dist/workflows/dag/frontend-review-context.js +261 -15
  127. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  128. package/dist/workflows/dag/frontend-shadow-dual-write.js +914 -0
  129. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  130. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  131. package/dist/workflows/dag/frontend-shape.js +435 -0
  132. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  133. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  134. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  135. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  136. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  137. package/dist/workflows/dag/frontend-verification-trace.js +249 -24
  138. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  139. package/dist/workflows/dag/frontend-writer-admission.js +285 -0
  140. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  141. package/dist/workflows/dag/init-hybrid.js +677 -492
  142. package/dist/workflows/dag/interrupt-request.js +7 -0
  143. package/dist/workflows/dag/node-execution.js +732 -4
  144. package/dist/workflows/dag/prompt.js +34 -1
  145. package/dist/workflows/dag/recovery-lease.js +80 -0
  146. package/dist/workflows/dag/report.js +37 -1
  147. package/dist/workflows/dag/rerun-feedback.js +256 -1
  148. package/dist/workflows/dag/rerun-plan.js +17 -6
  149. package/dist/workflows/dag/rerun-run.js +23 -0
  150. package/dist/workflows/dag/rerun-task.js +29 -0
  151. package/dist/workflows/dag/retry-policy.js +214 -104
  152. package/dist/workflows/dag/runner.js +433 -126
  153. package/dist/workflows/dag/scheduler.js +114 -16
  154. package/dist/workflows/dag/types.js +239 -12
  155. package/dist/workflows/dag/validate.js +28 -12
  156. package/docs/examples/README.md +5 -0
  157. package/docs/init-surface.manifest.json +30 -12
  158. package/docs/skills/vetted-skill-registry.md +4 -2
  159. package/docs/templates/README.md +2 -0
  160. package/docs/templates/agent-dag-report.schema.json +8 -2
  161. package/docs/templates/agent-dag.schema.json +1 -1
  162. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  163. package/docs/templates/frontend-implementation-dag.json +89 -0
  164. package/docs/templates/spec-registry.schema.json +45 -0
  165. package/harness.json +1 -1
  166. package/package.json +1 -1
  167. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  168. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  169. package/skills/frontend-contract/SKILL.md +23 -0
  170. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  171. package/skills/frontend-design-review/SKILL.md +22 -41
  172. package/skills/frontend-plan/SKILL.md +26 -0
  173. package/skills/frontend-plan/references/decision-contract.md +37 -0
  174. package/skills/frontend-plan/references/design-decisions.md +17 -0
  175. package/skills/frontend-review/SKILL.md +20 -15
  176. package/skills/frontend-review/references/review-findings.md +6 -7
  177. package/skills/frontend-scout/SKILL.md +25 -0
  178. package/skills/frontend-scout/references/design-evidence.md +16 -0
  179. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  180. package/skills/frontend-verification/SKILL.md +1 -1
  181. package/skills/loop-agent/references/command-reference.md +1 -0
  182. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  183. package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
  184. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
  185. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
  186. package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
  187. package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
  188. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
  189. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
  190. package/skills/frontend-implementation/SKILL.md +0 -52
  191. package/skills/frontend-implementation/references/code-standards.md +0 -33
  192. package/skills/frontend-implementation/references/design-spec.md +0 -56
  193. 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 {
@@ -322,6 +382,16 @@ export async function runDagContinuation(opts) {
322
382
  await assertTaskContractBindingConsistent({ repoRoot: opts.cwd, spec: opts.spec });
323
383
  const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), opts.spec.budget);
324
384
  const state = opts.state;
385
+ const canonicalFrontend = isCanonicalFrontendShapeDag(new Map(opts.spec.tasks.map((task) => [task.id, task])));
386
+ if (canonicalFrontend) {
387
+ assertFrontendTopologyBinding(state.frontendShapeState?.frozenTopology, opts.spec.tasks.map((task) => task.id));
388
+ }
389
+ else if (state.frontendShapeState) {
390
+ throw new Error("non-frontend DAG unexpectedly contains frontend topology state");
391
+ }
392
+ if (state.frontendShapeState?.terminalOutcome) {
393
+ throw new Error(`dag run ${state.runId} is terminal due to frontend shape ${state.frontendShapeState.terminalOutcome}`);
394
+ }
325
395
  state.status = "running";
326
396
  const resumedAt = new Date().toISOString();
327
397
  state.runner = {
@@ -347,6 +417,7 @@ export async function runDagContinuation(opts) {
347
417
  activeRunDir,
348
418
  completedRunDir,
349
419
  pausedRunDir,
420
+ recovery: opts.recovery,
350
421
  });
351
422
  }
352
423
  export async function resumeDagRun(opts) {
@@ -449,9 +520,13 @@ export async function resumeDagRun(opts) {
449
520
  }
450
521
  async function executeDagCheckpoint(input) {
451
522
  const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
523
+ bindFrontendShapeStateToRunTopology(state);
452
524
  let runDir = initialRunDir;
453
525
  let stateWriteQueue = Promise.resolve();
526
+ let shapeTransitionBarrier;
454
527
  const persistState = async (options) => {
528
+ if (shapeTransitionBarrier)
529
+ await shapeTransitionBarrier;
455
530
  stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
456
531
  await stateWriteQueue;
457
532
  };
@@ -576,12 +651,50 @@ async function executeDagCheckpoint(input) {
576
651
  .map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
577
652
  .filter((rank) => rank.length > 0)
578
653
  : [];
654
+ const canonicalFrontendSource = isCanonicalFrontendShapeDag(tasksById)
655
+ ? await loadCanonicalFrontendTaskSourceBinding(cwd, spec)
656
+ : undefined;
579
657
  const executeRanks = (selectedRanks) => executeDagRanksOnce({
580
658
  state,
581
659
  ranks: selectedRanks,
582
660
  tasksById,
583
661
  maxConcurrent,
584
662
  persistState,
663
+ commitFrontendShapeTransition: async (request) => {
664
+ let releaseBarrier;
665
+ shapeTransitionBarrier = new Promise((resolve) => {
666
+ releaseBarrier = resolve;
667
+ });
668
+ try {
669
+ if (!canonicalFrontendSource) {
670
+ throw new Error("frontend shape transition requires canonical task source binding");
671
+ }
672
+ return await commitFrontendShapeTransitionCas({
673
+ state,
674
+ tasksById,
675
+ request,
676
+ taskSourceDigest: canonicalFrontendSource.sourceDigest,
677
+ ensureRecord: async (capsule, terminalOutcome) => {
678
+ await ensureFreshFrontendShapeCapsuleRecord({
679
+ cwd,
680
+ spec,
681
+ canonicalSource: canonicalFrontendSource,
682
+ parentRunId: state.runId,
683
+ terminalOutcome,
684
+ capsule,
685
+ });
686
+ },
687
+ persist: async (nextState) => {
688
+ stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, nextState));
689
+ await stateWriteQueue;
690
+ },
691
+ });
692
+ }
693
+ finally {
694
+ releaseBarrier();
695
+ shapeTransitionBarrier = undefined;
696
+ }
697
+ },
585
698
  runDir,
586
699
  abortSignal: input.abortSignal,
587
700
  createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
@@ -617,6 +730,18 @@ async function executeDagCheckpoint(input) {
617
730
  });
618
731
  const node = state.nodes[nodeId];
619
732
  if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
733
+ if (node.status === "FINISHED") {
734
+ if (canonicalFrontendSource) {
735
+ await commitRuntimeFrontendShapeFact({
736
+ state,
737
+ tasksById,
738
+ sourceNodeId: nodeId,
739
+ taskId: canonicalFrontendSource.taskId,
740
+ requirementMarkdown: canonicalFrontendSource.requirementMarkdown,
741
+ constraintMarkdown: canonicalFrontendSource.constraintMarkdown,
742
+ });
743
+ }
744
+ }
620
745
  recordFinishedNodeBudget(state, node);
621
746
  await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
622
747
  await persistState();
@@ -626,6 +751,9 @@ async function executeDagCheckpoint(input) {
626
751
  let pausedByNodeId;
627
752
  while (true) {
628
753
  pausedByNodeId = await executeRanks(convergenceRanks);
754
+ if (state.frontendShapeState?.terminalOutcome) {
755
+ break;
756
+ }
629
757
  if (pausedByNodeId) {
630
758
  break;
631
759
  }
@@ -681,7 +809,7 @@ async function executeDagCheckpoint(input) {
681
809
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
682
810
  }
683
811
  else {
684
- const { recoveryPending } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
812
+ const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd, spec.frontendRecovery?.maxContinuations ?? 1);
685
813
  await finalizeRunOwnedInterrupt({
686
814
  runDir,
687
815
  state,
@@ -689,56 +817,69 @@ async function executeDagCheckpoint(input) {
689
817
  interruptAborted: interruptController.signal.aborted,
690
818
  });
691
819
  await persistState();
692
- if (recoveryPending) {
693
- // Recovery coordinator (phase 3c AC-2/AC-3): materialize the reserved
694
- // child and run it synchronously. Dynamic import avoids a static
695
- // runner ↔ frontend-recovery-run module cycle. stageRecoveryChild
696
- // locates the parent via the canonical active/ layout, so recovery
697
- // only fires when this run is still the canonical active run dir
698
- // (direct runDagContinuation callers using an arbitrary runDir keep
699
- // the previous scheduler-layer skip semantics).
700
- const canonicalActiveDir = getDagRunDir(cwd, "active", state.runId);
701
- if (path.resolve(runDir) === path.resolve(canonicalActiveDir)) {
702
- const { stageRecoveryChild } = await import("./frontend-recovery-run.js");
703
- const staged = await stageRecoveryChild({
704
- cwd,
820
+ if (recoveryPending && input.recovery && recoveryTrigger) {
821
+ // M6 recovery operation controller path: construct/reuse one recovery
822
+ // operation keyed by a deterministic clientRequestId and supervise it
823
+ // to terminal, instead of staging an in-process child run.
824
+ const created = await createRecoveryOperation({
825
+ cwd,
826
+ runDir,
827
+ spec,
828
+ state,
829
+ trigger: recoveryTrigger,
830
+ deps: input.recovery,
831
+ });
832
+ if (created.kind === "conflict") {
833
+ // Same clientRequestId with a different payload → fail closed; never
834
+ // create a second recovery operation (AC-004).
835
+ state.status = "failed";
836
+ state.terminalReason =
837
+ "frontend recovery request-id reuse conflict";
838
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
839
+ }
840
+ else if (created.kind === "lease-held") {
841
+ // A different recovery entrance has already durably claimed this
842
+ // parent. Do not "help" by running its operation: that would reopen
843
+ // the exact two-child-DAG race the lease prevents.
844
+ state.status = "failed";
845
+ state.terminalReason =
846
+ "frontend recovery parent lease already held";
847
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
848
+ }
849
+ else {
850
+ const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
851
+ const result = await runRecoveryOperationController({
852
+ operationId: created.operation.operationId,
705
853
  parentRunId: state.runId,
854
+ deps: {
855
+ ...input.recovery,
856
+ failureOwner: input.recovery.failureOwner ??
857
+ recoveryTrigger.failureOwner,
858
+ protocolFailureReason: input.recovery.protocolFailureReason ??
859
+ recoveryTrigger.protocolFailureReason,
860
+ recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
861
+ },
706
862
  });
707
- // stageRecoveryChild CAS-advanced the parent's on-disk lineage
708
- // (child-staging → child-running + childRunId). Refresh the in-memory
709
- // copy so the terminal persistState below does not regress the parent
710
- // back to stale child-staging.
711
- const parentOnDisk = await readDagRunState(runDir);
712
- state.frontendRecoveryState = parentOnDisk.frontendRecoveryState;
713
- const childState = await readDagRunState(staged.childRunDir);
714
- const childSpec = await readDagRunSpec(staged.childRunDir);
715
- await runDagContinuation({
716
- cwd,
717
- spec: childSpec,
718
- state: childState,
719
- runDir: staged.childRunDir,
720
- maxConcurrent,
721
- executeNode: input.executeNode,
722
- observer: input.observer,
723
- abortSignal,
724
- });
863
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded"
864
+ ? "recovered"
865
+ : "auto-recovery-blocked");
725
866
  }
726
867
  }
727
868
  await notifyRunObserver(input.observer, "onRunFinish", state);
728
- if (!recoveryPending) {
729
- if (input.beforeTerminalArchive) {
730
- await input.beforeTerminalArchive({ runDir, state });
731
- await persistState();
732
- }
733
- try {
734
- const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
735
- await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
736
- }
737
- catch (error) {
738
- console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
739
- }
740
- runDir = await moveToCompletedRunDir(runDir, completedRunDir);
869
+ if (input.beforeTerminalArchive) {
870
+ await input.beforeTerminalArchive({ runDir, state });
871
+ await persistState();
872
+ }
873
+ // Parent is always archived to completed/ — no active/ retention for an
874
+ // in-process child CAS anymore (recovery runs as a separate operation).
875
+ try {
876
+ const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
877
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
741
878
  }
879
+ catch (error) {
880
+ console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
881
+ }
882
+ runDir = await moveToCompletedRunDir(runDir, completedRunDir);
742
883
  }
743
884
  await relocateRunArtifactPaths({
744
885
  runDir,
@@ -871,39 +1012,21 @@ function isSuccessfulConvergenceTerminal(state) {
871
1012
  return true;
872
1013
  return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
873
1014
  }
874
- function buildFrontendRecoveryIntent(state, failureSource) {
875
- const existing = state.frontendRecoveryState;
876
- const requestId = existing?.requestId ?? randomUUID();
877
- return {
878
- schemaVersion: 1,
879
- phase: "child-staging",
880
- requestId,
881
- recoveryRootRunId: existing?.recoveryRootRunId ?? state.runId,
882
- parentRunId: state.runId,
883
- ...(existing?.childRunId ? { childRunId: existing.childRunId } : {}),
884
- attemptId: existing?.attemptId ?? `recovery-${requestId}`,
885
- attemptIndex: existing?.attemptIndex ?? 0,
886
- continuationCount: existing?.continuationCount ?? 0,
887
- ...(failureSource ? { failureSource } : {}),
888
- revision: (existing?.revision ?? 0) + 1,
889
- };
890
- }
891
1015
  function buildCandidateContractInvalidResult(state, result, artifactHash) {
892
- const recovery = state.frontendRecoveryState;
893
1016
  return {
894
1017
  schemaVersion: 1,
895
1018
  outcome: "candidate-contract-invalid",
896
1019
  origin: {
897
1020
  kind: "frontend-prewrite-gate",
898
- parentRunId: recovery?.parentRunId ?? state.runId,
899
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
900
- requestId: recovery?.requestId ?? "",
901
- failedNodeId: result.selectedPlanNodeId,
1021
+ parentRunId: state.runId,
1022
+ requestId: state.runId,
1023
+ failedNodeId: result.failureSource ?? "frontend-plan-pi",
902
1024
  },
903
1025
  failureClass: {
904
1026
  code: "candidate-contract-invalid",
905
1027
  classification: result.classification,
906
- reason: result.failureReason ?? "candidate contract invalid",
1028
+ reason: result.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; ") ??
1029
+ "candidate contract invalid",
907
1030
  },
908
1031
  evidenceRefs: [
909
1032
  {
@@ -915,15 +1038,13 @@ function buildCandidateContractInvalidResult(state, result, artifactHash) {
915
1038
  };
916
1039
  }
917
1040
  function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
918
- const recovery = state.frontendRecoveryState;
919
1041
  return {
920
1042
  schemaVersion: 1,
921
1043
  outcome: "auto-recovery-blocked",
922
1044
  origin: {
923
1045
  kind: "frontend-writer",
924
- parentRunId: recovery?.parentRunId ?? state.runId,
925
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
926
- requestId: recovery?.requestId ?? "",
1046
+ parentRunId: state.runId,
1047
+ requestId: state.runId,
927
1048
  failedNodeId,
928
1049
  },
929
1050
  failureClass: {
@@ -934,64 +1055,116 @@ function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
934
1055
  evidenceRefs: [],
935
1056
  };
936
1057
  }
937
- function settleFrontendRecovery(state, outcome) {
938
- const recovery = state.frontendRecoveryState;
939
- if (!recovery)
940
- return;
941
- recovery.phase = "settled";
942
- if (!state.frontendRecoveryResult) {
943
- state.frontendRecoveryResult = {
944
- schemaVersion: 1,
945
- outcome,
946
- origin: {
947
- kind: "frontend-prewrite-gate",
948
- parentRunId: recovery.parentRunId,
949
- ...(recovery.childRunId ? { childRunId: recovery.childRunId } : {}),
950
- requestId: recovery.requestId,
951
- failedNodeId: "",
952
- },
953
- evidenceRefs: [],
954
- };
955
- }
1058
+ function buildRecoveryResultForTrigger(state, trigger, outcome) {
1059
+ return {
1060
+ schemaVersion: 1,
1061
+ outcome,
1062
+ origin: {
1063
+ kind: trigger.failureSource === "frontend-implement-pi"
1064
+ ? "frontend-writer"
1065
+ : "frontend-prewrite-gate",
1066
+ parentRunId: state.runId,
1067
+ requestId: trigger.requestId,
1068
+ failedNodeId: trigger.failureSource,
1069
+ },
1070
+ evidenceRefs: [],
1071
+ };
956
1072
  }
957
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1073
+ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
958
1074
  const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1075
+ if (state.frontendShapeState?.terminalOutcome &&
1076
+ state.frontendShapeState.transitionCapsule) {
1077
+ state.status = "failed";
1078
+ return { recoveryPending: false };
1079
+ }
959
1080
  // Terminal override: a denied frontend writer must never surface as a
960
1081
  // mechanical partial_failed just because prewrite FINISHED while the writer
961
- // was SKIPPED. Fail closed on retryable-invalid/blocked before aggregation.
962
- let recoveryPending = false;
1082
+ // was SKIPPED. Fail closed on blocked before aggregation.
1083
+ let recoveryTrigger;
963
1084
  const skipFrontendRecovery = state.failureCategory === "controller-interrupted" ||
964
1085
  state.continuation?.writerTransaction !== undefined;
965
1086
  const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
966
1087
  if (hasFrontendWriter && !skipFrontendRecovery) {
967
1088
  const admission = await readFrontendPrewriteResult(runDir);
968
1089
  if (admission.ok) {
969
- if (admission.result.classification === "blocked") {
1090
+ // M8: admission has only admitted|blocked. A blocked admission whose
1091
+ // failureSource is frontend-plan-pi is the contract-invalid analog of
1092
+ // the old retryable-invalid path (candidate continuation may re-plan);
1093
+ // every other blocked admission is terminal.
1094
+ const planContractInvalid = admission.result.classification === "blocked" &&
1095
+ admission.result.failureSource === "frontend-plan-pi";
1096
+ // A blocked admission caused by the design review committing
1097
+ // `request_design_changes` is a definite finding (not a protocol or
1098
+ // environment error): the design was reviewed and rejected. It is the
1099
+ // design-review analog of review-request-changes — not terminal, but
1100
+ // re-enters the next plan round with the committed design findings.
1101
+ // The failureSource may be frontend-writer-admission-shell or
1102
+ // frontend-design-policy-shell; the committed design fact is the
1103
+ // authoritative signal.
1104
+ const designRequestFact = await readCommittedDesignRequestFact({
1105
+ runDir,
1106
+ });
1107
+ const designRejected = admission.result.classification === "blocked" &&
1108
+ designRequestFact !== undefined;
1109
+ const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1110
+ admission.result.classification === "stale") &&
1111
+ !planContractInvalid &&
1112
+ !designRejected;
1113
+ if (terminalAdmissionDenial) {
970
1114
  state.status = "failed";
971
1115
  return { recoveryPending: false };
972
1116
  }
973
- if (admission.result.classification === "retryable-invalid") {
1117
+ if (designRejected && designRequestFact) {
1118
+ state.terminalReason = "design-request-changes";
1119
+ try {
1120
+ await writeFrontendReviewFindingsPrd({
1121
+ runDir,
1122
+ parentRunId: state.runId,
1123
+ fact: designRequestFact,
1124
+ });
1125
+ }
1126
+ catch (error) {
1127
+ console.warn(`[run-dag] warning: failed to write frontend design findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1128
+ }
1129
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1130
+ if (continuationCount < frontendRecoveryQuota) {
1131
+ recoveryTrigger = {
1132
+ requestId: state.runId,
1133
+ failureSource: "frontend-design-review-pi",
1134
+ failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
1135
+ protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
1136
+ recoveryMode: "new-dag",
1137
+ };
1138
+ }
1139
+ }
1140
+ if (planContractInvalid) {
974
1141
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
975
- if (continuationCount >= 1) {
976
- // Continuation quota exhausted: a second retryable-invalid candidate
1142
+ if (continuationCount >= frontendRecoveryQuota) {
1143
+ // Continuation quota exhausted: a second contract-invalid candidate
977
1144
  // is authoritative candidate-contract-invalid → forced failed.
978
1145
  state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
979
1146
  state.status = "failed";
980
1147
  return { recoveryPending: false };
981
1148
  }
982
- // Root continuation still available: record the recovery intent and let
983
- // mechanical aggregation settle partial_failed/failed. The parent keeps
984
- // phase != settled (recovery in progress) and no final root manifest.
985
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state);
986
- recoveryPending = true;
1149
+ // Root continuation still available: emit a recovery trigger for the
1150
+ // M6 operation controller. The parent no longer writes
1151
+ // frontendRecoveryState or stays in active/ for a child CAS.
1152
+ recoveryTrigger = {
1153
+ requestId: state.runId,
1154
+ failureSource: admission.result.failureSource ?? "frontend-plan-pi",
1155
+ failureOwner: "plan",
1156
+ protocolFailureReason: "frontend candidate contract invalid",
1157
+ recoveryMode: "new-dag",
1158
+ };
987
1159
  }
988
1160
  }
989
1161
  }
990
- // Phase 5: writer transient partial write → rollback + recovery intent. Only
991
- // for frontend-implementation writers with a remaining root continuation, and
992
- // only when the rollback journal (captured before the provider call) restores
993
- // cleanly; otherwise the run stays failed with auto-recovery-blocked.
994
- if (hasFrontendWriter && !recoveryPending && !skipFrontendRecovery) {
1162
+ // Phase 5: writer transient partial write → rollback + recovery trigger.
1163
+ // Only for frontend-implementation writers with a remaining root
1164
+ // continuation, and only when the rollback journal (captured before the
1165
+ // provider call) restores cleanly; otherwise the run stays failed with
1166
+ // auto-recovery-blocked.
1167
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
995
1168
  const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
996
1169
  const node = state.nodes[id];
997
1170
  return (node &&
@@ -1002,15 +1175,20 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1002
1175
  });
1003
1176
  if (writerNodeId) {
1004
1177
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1005
- if (continuationCount < 1) {
1178
+ if (continuationCount < frontendRecoveryQuota) {
1006
1179
  const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
1007
1180
  const rollback = await rollbackFrontendWriter({
1008
1181
  cwd: repoRoot,
1009
1182
  parentRunId: state.runId,
1010
1183
  });
1011
1184
  if (rollback.ok) {
1012
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state, "frontend-implement-pi");
1013
- recoveryPending = true;
1185
+ recoveryTrigger = {
1186
+ requestId: state.runId,
1187
+ failureSource: "frontend-implement-pi",
1188
+ failureOwner: "implementation",
1189
+ protocolFailureReason: "frontend writer transient partial write",
1190
+ recoveryMode: "continuation",
1191
+ };
1014
1192
  }
1015
1193
  else {
1016
1194
  state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
@@ -1020,6 +1198,64 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1020
1198
  }
1021
1199
  }
1022
1200
  }
1201
+ // Phase 6: verification failure → automatic recovery (re-implement +
1202
+ // re-verify). The verify shell is terminal by design so the failed run's
1203
+ // evidence stays clean; the M6 recovery operation re-runs implement and
1204
+ // verify in a fresh continuation, guarded by the same continuation quota,
1205
+ // budget fold and failure fingerprint as the other recovery paths.
1206
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
1207
+ const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1208
+ if (verifyFailed) {
1209
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1210
+ if (continuationCount < frontendRecoveryQuota) {
1211
+ recoveryTrigger = {
1212
+ requestId: state.runId,
1213
+ failureSource: "frontend-verify-shell",
1214
+ failureOwner: "implementation",
1215
+ protocolFailureReason: "frontend verification failure",
1216
+ recoveryMode: "continuation",
1217
+ };
1218
+ }
1219
+ }
1220
+ }
1221
+ // Phase 7: frontend review "request changes" → not passed. When the review
1222
+ // node FINISHED but committed `request_review_changes` (typed terminal
1223
+ // verdict), the implementation was reviewed and rejected. The run must not
1224
+ // surface as finished: terminal aggregation below stays partial_failed, a
1225
+ // terminal reason is recorded, the committed findings are rendered into a
1226
+ // downstream-task PRD draft (run-owned), and a recovery trigger carries the
1227
+ // issueCategory-derived owner so a later DAG round consumes the findings
1228
+ // instead of repeating the previous flow.
1229
+ const reviewRequestFact = await readCommittedReviewRequestFact({
1230
+ runDir,
1231
+ });
1232
+ if (reviewRequestFact) {
1233
+ state.terminalReason = "review-request-changes";
1234
+ try {
1235
+ await writeFrontendReviewFindingsPrd({
1236
+ runDir,
1237
+ parentRunId: state.runId,
1238
+ fact: reviewRequestFact,
1239
+ });
1240
+ }
1241
+ catch (error) {
1242
+ // The PRD draft is additive; a write failure must not mask the
1243
+ // not-passed verdict. The terminal aggregation below still applies.
1244
+ console.warn(`[run-dag] warning: failed to write frontend review findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1245
+ }
1246
+ if (!recoveryTrigger && !skipFrontendRecovery) {
1247
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1248
+ if (continuationCount < frontendRecoveryQuota) {
1249
+ recoveryTrigger = {
1250
+ requestId: state.runId,
1251
+ failureSource: "frontend-review-pi",
1252
+ failureOwner: classifyReviewIssueCategoryFailureOwner(reviewRequestFact.issueCategory),
1253
+ protocolFailureReason: `frontend review request_review_changes (issueCategory=${reviewRequestFact.issueCategory})`,
1254
+ recoveryMode: "new-dag",
1255
+ };
1256
+ }
1257
+ }
1258
+ }
1023
1259
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
1024
1260
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
1025
1261
  if (state.convergence && supersededFailures.length > 0) {
@@ -1035,7 +1271,13 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1035
1271
  if (effectiveErrorCount === 0 &&
1036
1272
  successTerminal &&
1037
1273
  finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
1038
- state.status = "finished";
1274
+ // Phase 7 guard: a committed `request_review_changes` means the review
1275
+ // rejected the implementation; the run must never surface as finished
1276
+ // even though every node technically FINISHED.
1277
+ state.status =
1278
+ state.terminalReason === "review-request-changes"
1279
+ ? "partial_failed"
1280
+ : "finished";
1039
1281
  }
1040
1282
  else if (finishedCount > 0) {
1041
1283
  state.status = "partial_failed";
@@ -1043,19 +1285,84 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1043
1285
  else {
1044
1286
  state.status = "failed";
1045
1287
  }
1046
- // Terminal recovery bookkeeping for a child attempt: a child that finished
1047
- // recovered settles its own lineage; any other child terminal settles as
1048
- // auto-recovery-blocked (phase 3 performs no further recovery).
1049
- const recovery = state.frontendRecoveryState;
1050
- if (recovery && recovery.attemptIndex >= 1) {
1051
- if (state.status === "finished") {
1052
- settleFrontendRecovery(state, "recovered");
1053
- }
1054
- else if (!state.frontendRecoveryResult) {
1055
- settleFrontendRecovery(state, "auto-recovery-blocked");
1056
- }
1288
+ return recoveryTrigger
1289
+ ? { recoveryPending: true, recovery: recoveryTrigger }
1290
+ : { recoveryPending: false };
1291
+ }
1292
+ /**
1293
+ * Construct (or reuse) the recovery operation for a failed frontend run.
1294
+ *
1295
+ * A parent-run atomic lease is acquired before `OperationStore.create`, then
1296
+ * its operation id is persisted into that lease before the controller may run.
1297
+ * `clientRequestId` remains a useful store-level replay key, but is not a
1298
+ * cross-process recovery admission boundary. The continuation DagSpec/reset
1299
+ * partition is frozen via `computeFrontendRecoveryPlanForSource` and
1300
+ * serialized under the run dir's bounded `.runtime/` namespace. `planHash` is
1301
+ * the second idempotency dimension of `actionParams`.
1302
+ */
1303
+ async function createRecoveryOperation(input) {
1304
+ const { cwd, runDir, spec, state, trigger } = input;
1305
+ const recoveryLease = await acquireDagRecoveryLease({
1306
+ cwd,
1307
+ parentRunId: state.runId,
1308
+ requestId: trigger.requestId,
1309
+ kind: "auto-frontend-recovery",
1310
+ });
1311
+ if (recoveryLease.kind === "held") {
1312
+ return { kind: "lease-held", lease: recoveryLease.lease };
1313
+ }
1314
+ const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1315
+ const plan = computeFrontendRecoveryPlanForSource({
1316
+ spec,
1317
+ parentRunId: state.runId,
1318
+ requestId: trigger.requestId,
1319
+ failureSource: trigger.failureSource,
1320
+ });
1321
+ const planHash = createHash("sha256")
1322
+ .update(JSON.stringify(plan))
1323
+ .digest("hex");
1324
+ const continuationPath = path.join(runDir, ".runtime", "frontend-recovery", `${trigger.requestId}.json`);
1325
+ await writeJsonAtomic(continuationPath, {
1326
+ schemaVersion: 1,
1327
+ parentRunId: state.runId,
1328
+ requestId: trigger.requestId,
1329
+ failureSource: trigger.failureSource,
1330
+ recoveryMode: trigger.recoveryMode,
1331
+ plan,
1332
+ });
1333
+ const created = await input.deps.store.create({
1334
+ clientRequestId: `dag-recovery-${state.runId}`,
1335
+ action: "dagRunTask",
1336
+ actionParams: {
1337
+ parentRunId: state.runId,
1338
+ requestId: trigger.requestId,
1339
+ failureSource: trigger.failureSource,
1340
+ failureOwner: trigger.failureOwner,
1341
+ protocolFailureReason: trigger.protocolFailureReason,
1342
+ recoveryMode: trigger.recoveryMode,
1343
+ planHash,
1344
+ },
1345
+ cliArgs: [
1346
+ "dag",
1347
+ "execute",
1348
+ "--dag",
1349
+ continuationPath,
1350
+ "--cwd",
1351
+ cwd,
1352
+ "--json",
1353
+ ],
1354
+ });
1355
+ if (created.kind !== "conflict") {
1356
+ // The durable lease must name the operation before controller execution.
1357
+ // A crash in this narrow window leaves a safely blocked reservation rather
1358
+ // than a second process that can launch another recovery DAG.
1359
+ await bindDagRecoveryLeaseExecution({
1360
+ leasePath: recoveryLease.leasePath,
1361
+ lease: recoveryLease.lease,
1362
+ executionRef: created.operation.operationId,
1363
+ });
1057
1364
  }
1058
- return { recoveryPending };
1365
+ return created;
1059
1366
  }
1060
1367
  function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
1061
1368
  if (rankWriterNodeIds.length <= 1)