@tea-agent/loop-agent 0.39.0-beta.2 → 0.39.0-beta.4

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 (202) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +187 -92
  3. package/README.md +9 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/task-lifecycle/recommendations.js +13 -2
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/command-definitions.js +16 -5
  8. package/dist/cli/program.js +16 -2
  9. package/dist/cli/update/notifier.js +2 -2
  10. package/dist/cli.js +2 -1
  11. package/dist/commands/dag-rerun.js +55 -1
  12. package/dist/commands/init-model-catalog.js +464 -0
  13. package/dist/commands/init-upgrade.js +265 -97
  14. package/dist/commands/init.js +429 -28
  15. package/dist/commands/inspect-next.js +8 -0
  16. package/dist/executors/dag-pi-executor.js +1691 -29
  17. package/dist/executors/pi-extension-resolver.js +233 -0
  18. package/dist/executors/pi-playwright-cli-tool.js +51 -12
  19. package/dist/executors/pi-sdk-executor.js +180 -33
  20. package/dist/executors/shell-executor.js +1152 -171
  21. package/dist/executors/shell-write-guard.js +14 -0
  22. package/dist/shared/dag-prompt-override.js +28 -0
  23. package/dist/shared/openspec-spec.js +70 -4
  24. package/dist/shared/operator/capabilities.js +32 -0
  25. package/dist/shared/playwright-cli-command-policy.js +15 -0
  26. package/dist/shared/update/console-notifier.js +100 -0
  27. package/dist/{cli → shared}/update/npm-client.js +41 -8
  28. package/dist/{cli → shared}/update/state.js +41 -13
  29. package/dist/task/config-types.js +19 -5
  30. package/dist/task/contract/project.js +3 -0
  31. package/dist/task/contract/schema.js +2 -1
  32. package/dist/task/frontend-project-capability.js +60 -17
  33. package/dist/task/source-prepare/parse-intent.js +24 -4
  34. package/dist/task/source-prepare/prepare.js +36 -0
  35. package/dist/worker/cli.js +31 -1
  36. package/dist/worker/console/chat/pi-console-config.js +81 -10
  37. package/dist/worker/console/chat/routes.js +1 -0
  38. package/dist/worker/console/dag-execution-receipt.js +33 -0
  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 +263 -0
  44. package/dist/worker/console/operator-user-error.js +2 -2
  45. package/dist/worker/console/routes.js +43 -2
  46. package/dist/worker/console/security.js +63 -9
  47. package/dist/worker/console/server.js +151 -6
  48. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-zsNyvGaH.js} +1 -1
  49. package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-BDjZ5kE1.js} +1 -1
  50. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-BkgaujnN.js} +1 -1
  51. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-CqxOJi0j.js} +1 -1
  52. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-C2JHLdRi.js} +1 -1
  53. package/dist/worker/console/static/assets/channel-BuqaGKAT.js +1 -0
  54. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-DF_YSvOS.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-D3Ort5Vo.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-XfAfZeUC.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-SLBibhf5.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-Dz29Ye_f.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-DB2G4H8V.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-CIoGkxdu.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-CG78FGkn.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-ZDHAvXPd.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-BKkkC4VA.js} +1 -1
  64. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +1 -0
  65. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +1 -0
  66. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-SposUZsO.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-BSCtQpD7.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-DgFHrw9l.js} +1 -1
  69. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-BVBasYbj.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-6r_4rN_c.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-JmuaB41P.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-xXvAY3sk.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-D2B3lY2o.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-tK54tMEX.js} +1 -1
  75. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-CNtELzV-.js} +1 -1
  76. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-C1xKMKcW.js} +1 -1
  77. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-CZ7dlPMz.js} +1 -1
  78. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-BqGb42Xe.js} +1 -1
  79. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-D_BlXEtt.js} +1 -1
  80. package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-D5zGX6fG.js} +68 -68
  81. package/dist/worker/console/static/assets/index-DBbhESQ_.css +1 -0
  82. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-Vce2vmIe.js} +1 -1
  83. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-DDwVBAvI.js} +1 -1
  84. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-y_Ew-wv4.js} +1 -1
  85. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-CjXJFp1K.js} +1 -1
  86. package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-1itMst1Q.js} +1 -1
  87. package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-C-D3ZRUa.js} +5 -5
  88. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-BiYywmZc.js} +1 -1
  89. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-l2fydNWA.js} +1 -1
  90. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-Dz0hNbYW.js} +1 -1
  91. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-BujI_mLZ.js} +1 -1
  92. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-pWe84beb.js} +1 -1
  93. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-ByYu2frg.js} +1 -1
  94. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-CoPnilFP.js} +1 -1
  95. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-1Md2LnS0.js} +1 -1
  96. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-DqqqAoSH.js} +1 -1
  97. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-C4sGkf--.js} +1 -1
  98. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +1 -0
  99. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-ByxJrUzW.js} +2 -2
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +8 -0
  101. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-D8MLgM0O.js} +1 -1
  102. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DnBis-wZ.js} +1 -1
  103. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-uuyYT7UO.js} +1 -1
  104. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-BjgabhwK.js} +1 -1
  105. package/dist/worker/console/static/index.html +2 -2
  106. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  107. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  108. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  109. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  110. package/dist/worker/observe/node-input.js +74 -3
  111. package/dist/worker/observe/node-process.js +377 -0
  112. package/dist/worker/observe/routes.js +36 -3
  113. package/dist/worker/observe/shell-handler-keys.js +34 -0
  114. package/dist/worker/observe/static/api.js +66 -0
  115. package/dist/worker/observe/static/constants.js +8 -1
  116. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  117. package/dist/worker/observe/static/format.js +23 -0
  118. package/dist/worker/observe/static/markdown-render.js +20 -1
  119. package/dist/worker/observe/static/operator-chrome.js +4 -0
  120. package/dist/worker/observe/static/state.js +13 -2
  121. package/dist/worker/observe/static/styles.css +73 -0
  122. package/dist/worker/observe/static/views/dag-inspector.js +612 -8
  123. package/dist/worker/observe/static/views/dag.js +8 -1
  124. package/dist/worker/observe/static/views/failures.js +5 -2
  125. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  126. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +20 -2
  127. package/dist/workflows/dag/contract-output-registry.js +15 -0
  128. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  129. package/dist/workflows/dag/failure-category.js +12 -0
  130. package/dist/workflows/dag/frontend-closeout.js +221 -0
  131. package/dist/workflows/dag/frontend-design-policy.js +366 -0
  132. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  133. package/dist/workflows/dag/frontend-implementation-contract.js +378 -160
  134. package/dist/workflows/dag/frontend-prewrite-gate.js +343 -391
  135. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  136. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  137. package/dist/workflows/dag/frontend-recovery-controller.js +201 -0
  138. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  139. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  140. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  141. package/dist/workflows/dag/frontend-repair.js +7 -424
  142. package/dist/workflows/dag/frontend-review-context.js +195 -8
  143. package/dist/workflows/dag/frontend-risk.js +15 -2
  144. package/dist/workflows/dag/frontend-shadow-dual-write.js +665 -0
  145. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  146. package/dist/workflows/dag/frontend-shape-facts.js +409 -0
  147. package/dist/workflows/dag/frontend-shape.js +425 -0
  148. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  149. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  150. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  151. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  152. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  153. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  154. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  155. package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
  156. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  157. package/dist/workflows/dag/frontend-typed-event-store.js +439 -0
  158. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  159. package/dist/workflows/dag/frontend-verification-trace.js +52 -17
  160. package/dist/workflows/dag/frontend-worktree-diff.js +97 -18
  161. package/dist/workflows/dag/frontend-writer-admission.js +260 -0
  162. package/dist/workflows/dag/frontend-writer-status.js +244 -0
  163. package/dist/workflows/dag/init-hybrid.js +572 -500
  164. package/dist/workflows/dag/node-execution.js +307 -70
  165. package/dist/workflows/dag/prompt.js +10 -18
  166. package/dist/workflows/dag/rerun-plan.js +144 -5
  167. package/dist/workflows/dag/rerun-run.js +58 -6
  168. package/dist/workflows/dag/retry-policy.js +228 -4
  169. package/dist/workflows/dag/runner.js +314 -119
  170. package/dist/workflows/dag/scheduler.js +114 -16
  171. package/dist/workflows/dag/structured-output-repair.js +712 -0
  172. package/dist/workflows/dag/types.js +235 -23
  173. package/dist/workflows/dag/upstream-artifacts.js +0 -4
  174. package/dist/workflows/dag/validate.js +60 -10
  175. package/docs/init-surface.manifest.json +5 -0
  176. package/docs/operations/README.md +1 -1
  177. package/docs/templates/agent-dag.schema.json +44 -0
  178. package/docs/templates/frontend-test-dag.json +8 -10
  179. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  180. package/docs/templates/init-managed-agents.md +13 -3
  181. package/harness.json +3 -3
  182. package/package.json +5 -1
  183. package/scripts/next-info.mjs +356 -0
  184. package/scripts/next-publish-gate.mjs +238 -0
  185. package/scripts/release-source-binding.mjs +251 -0
  186. package/skills/codebase-scout/SKILL.md +1 -1
  187. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  188. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  189. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  190. package/skills/frontend-implementation/SKILL.md +4 -4
  191. package/skills/frontend-implementation/references/design-spec.md +11 -7
  192. package/skills/frontend-implementation/references/node-contracts.md +20 -11
  193. package/skills/loop-agent/references/command-reference.md +11 -4
  194. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  195. package/skills/playwright-cli/SKILL.md +22 -0
  196. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  197. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
  198. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
  199. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
  200. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
  201. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
  202. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
@@ -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,6 +26,9 @@ 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 { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas, commitRuntimeFrontendShapeFact, isCanonicalFrontendShapeDag, } from "./frontend-shape-facts.js";
30
+ import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFrontendShapeSourceDigest, } from "./frontend-shape.js";
31
+ import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
29
32
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
30
33
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
31
34
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
@@ -46,6 +49,51 @@ export function toKebabSlug(text, maxLen = 40) {
46
49
  .replace(/^-|-$/g, "")
47
50
  .slice(0, maxLen) || "dag");
48
51
  }
52
+ export async function loadCanonicalFrontendTaskSourceBinding(cwd, spec) {
53
+ const binding = spec.sourceBinding;
54
+ if (!binding?.taskId)
55
+ throw new Error("canonical frontend DAG requires sourceBinding.taskId");
56
+ const requirement = binding.sources.find((source) => source.kind === "requirement");
57
+ if (!requirement)
58
+ throw new Error("canonical frontend DAG requires a bound requirement source");
59
+ const constraint = binding.sources.find((source) => source.kind === "constraint");
60
+ const readFreshSource = async (source) => {
61
+ let bytes;
62
+ try {
63
+ bytes = await readFile(path.resolve(cwd, source.path));
64
+ }
65
+ catch (error) {
66
+ throw new Error(`canonical frontend ${source.kind} source freshness read failed: ${error instanceof Error ? error.message : String(error)}`);
67
+ }
68
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
69
+ if (actualSha256 !== source.sha256) {
70
+ throw new Error(`canonical frontend ${source.kind} source sha256 drifted: expected ${source.sha256}, received ${actualSha256}`);
71
+ }
72
+ return bytes.toString("utf8");
73
+ };
74
+ const requirementMarkdown = await readFreshSource(requirement);
75
+ const constraintMarkdown = constraint ? await readFreshSource(constraint) : "";
76
+ return {
77
+ taskId: binding.taskId,
78
+ requirementMarkdown,
79
+ constraintMarkdown,
80
+ sourceDigest: computeFrontendShapeSourceDigest({ requirementMarkdown, constraintMarkdown }),
81
+ };
82
+ }
83
+ export async function ensureFreshFrontendShapeCapsuleRecord(input) {
84
+ const freshSource = await loadCanonicalFrontendTaskSourceBinding(input.cwd, input.spec);
85
+ if (freshSource.taskId !== input.canonicalSource.taskId ||
86
+ freshSource.sourceDigest !== input.canonicalSource.sourceDigest) {
87
+ throw new Error("canonical frontend task source freshness binding drifted before transition commit");
88
+ }
89
+ await (input.ensureRecord ?? ensureFrontendShapeCapsuleRecord)({
90
+ repoRoot: input.cwd,
91
+ taskId: input.canonicalSource.taskId,
92
+ parentRunId: input.parentRunId,
93
+ terminalOutcome: input.terminalOutcome,
94
+ capsule: input.capsule,
95
+ });
96
+ }
49
97
  export function extractTaskIdFromDagSpec(spec) {
50
98
  if (spec.objective) {
51
99
  const match = spec.objective.match(TASK_ID_IN_TEXT_PATTERN);
@@ -180,6 +228,15 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
180
228
  ...(opts.workerAssociation
181
229
  ? { workerAssociation: structuredClone(opts.workerAssociation) }
182
230
  : {}),
231
+ ...(isCanonicalFrontendShapeDag(new Map(spec.tasks.map((task) => [task.id, task])))
232
+ ? {
233
+ frontendShapeState: {
234
+ frozenTopology: buildFrontendTopologyBinding(spec.tasks.map((task) => task.id)),
235
+ revision: 0,
236
+ committedFacts: {},
237
+ },
238
+ }
239
+ : {}),
183
240
  };
184
241
  initRunBudgetLedger(state, spec.budget);
185
242
  return state;
@@ -301,6 +358,7 @@ export async function runDag(spec, opts) {
301
358
  activeRunDir,
302
359
  completedRunDir,
303
360
  pausedRunDir,
361
+ recovery: opts.recovery,
304
362
  });
305
363
  }
306
364
  finally {
@@ -309,6 +367,16 @@ export async function runDag(spec, opts) {
309
367
  export async function runDagContinuation(opts) {
310
368
  const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), opts.spec.budget);
311
369
  const state = opts.state;
370
+ const canonicalFrontend = isCanonicalFrontendShapeDag(new Map(opts.spec.tasks.map((task) => [task.id, task])));
371
+ if (canonicalFrontend) {
372
+ assertFrontendTopologyBinding(state.frontendShapeState?.frozenTopology, opts.spec.tasks.map((task) => task.id));
373
+ }
374
+ else if (state.frontendShapeState) {
375
+ throw new Error("non-frontend DAG unexpectedly contains frontend topology state");
376
+ }
377
+ if (state.frontendShapeState?.terminalOutcome) {
378
+ throw new Error(`dag run ${state.runId} is terminal due to frontend shape ${state.frontendShapeState.terminalOutcome}`);
379
+ }
312
380
  state.status = "running";
313
381
  const resumedAt = new Date().toISOString();
314
382
  state.runner = {
@@ -333,6 +401,7 @@ export async function runDagContinuation(opts) {
333
401
  activeRunDir,
334
402
  completedRunDir,
335
403
  pausedRunDir,
404
+ recovery: opts.recovery,
336
405
  });
337
406
  }
338
407
  export async function resumeDagRun(opts) {
@@ -435,9 +504,13 @@ export async function resumeDagRun(opts) {
435
504
  }
436
505
  async function executeDagCheckpoint(input) {
437
506
  const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
507
+ bindFrontendShapeStateToRunTopology(state);
438
508
  let runDir = initialRunDir;
439
509
  let stateWriteQueue = Promise.resolve();
510
+ let shapeTransitionBarrier;
440
511
  const persistState = async (options) => {
512
+ if (shapeTransitionBarrier)
513
+ await shapeTransitionBarrier;
441
514
  stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
442
515
  await stateWriteQueue;
443
516
  };
@@ -562,12 +635,50 @@ async function executeDagCheckpoint(input) {
562
635
  .map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
563
636
  .filter((rank) => rank.length > 0)
564
637
  : [];
638
+ const canonicalFrontendSource = isCanonicalFrontendShapeDag(tasksById)
639
+ ? await loadCanonicalFrontendTaskSourceBinding(cwd, spec)
640
+ : undefined;
565
641
  const executeRanks = (selectedRanks) => executeDagRanksOnce({
566
642
  state,
567
643
  ranks: selectedRanks,
568
644
  tasksById,
569
645
  maxConcurrent,
570
646
  persistState,
647
+ commitFrontendShapeTransition: async (request) => {
648
+ let releaseBarrier;
649
+ shapeTransitionBarrier = new Promise((resolve) => {
650
+ releaseBarrier = resolve;
651
+ });
652
+ try {
653
+ if (!canonicalFrontendSource) {
654
+ throw new Error("frontend shape transition requires canonical task source binding");
655
+ }
656
+ return await commitFrontendShapeTransitionCas({
657
+ state,
658
+ tasksById,
659
+ request,
660
+ taskSourceDigest: canonicalFrontendSource.sourceDigest,
661
+ ensureRecord: async (capsule, terminalOutcome) => {
662
+ await ensureFreshFrontendShapeCapsuleRecord({
663
+ cwd,
664
+ spec,
665
+ canonicalSource: canonicalFrontendSource,
666
+ parentRunId: state.runId,
667
+ terminalOutcome,
668
+ capsule,
669
+ });
670
+ },
671
+ persist: async (nextState) => {
672
+ stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, nextState));
673
+ await stateWriteQueue;
674
+ },
675
+ });
676
+ }
677
+ finally {
678
+ releaseBarrier();
679
+ shapeTransitionBarrier = undefined;
680
+ }
681
+ },
571
682
  runDir,
572
683
  abortSignal: input.abortSignal,
573
684
  createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
@@ -603,6 +714,18 @@ async function executeDagCheckpoint(input) {
603
714
  });
604
715
  const node = state.nodes[nodeId];
605
716
  if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
717
+ if (node.status === "FINISHED") {
718
+ if (canonicalFrontendSource) {
719
+ await commitRuntimeFrontendShapeFact({
720
+ state,
721
+ tasksById,
722
+ sourceNodeId: nodeId,
723
+ taskId: canonicalFrontendSource.taskId,
724
+ requirementMarkdown: canonicalFrontendSource.requirementMarkdown,
725
+ constraintMarkdown: canonicalFrontendSource.constraintMarkdown,
726
+ });
727
+ }
728
+ }
606
729
  recordFinishedNodeBudget(state, node);
607
730
  await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
608
731
  await persistState();
@@ -612,6 +735,9 @@ async function executeDagCheckpoint(input) {
612
735
  let pausedByNodeId;
613
736
  while (true) {
614
737
  pausedByNodeId = await executeRanks(convergenceRanks);
738
+ if (state.frontendShapeState?.terminalOutcome) {
739
+ break;
740
+ }
615
741
  if (pausedByNodeId) {
616
742
  break;
617
743
  }
@@ -667,7 +793,7 @@ async function executeDagCheckpoint(input) {
667
793
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
668
794
  }
669
795
  else {
670
- const { recoveryPending } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
796
+ const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
671
797
  await finalizeRunOwnedInterrupt({
672
798
  runDir,
673
799
  state,
@@ -675,52 +801,56 @@ async function executeDagCheckpoint(input) {
675
801
  interruptAborted: interruptController.signal.aborted,
676
802
  });
677
803
  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,
804
+ if (recoveryPending && input.recovery && recoveryTrigger) {
805
+ // M6 recovery operation controller path: construct/reuse one recovery
806
+ // operation keyed by a deterministic clientRequestId and supervise it
807
+ // to terminal, instead of staging an in-process child run.
808
+ const created = await createRecoveryOperation({
809
+ cwd,
810
+ runDir,
811
+ spec,
812
+ state,
813
+ trigger: recoveryTrigger,
814
+ deps: input.recovery,
815
+ });
816
+ if (created.kind === "conflict") {
817
+ // Same clientRequestId with a different payload → fail closed; never
818
+ // create a second recovery operation (AC-004).
819
+ state.status = "failed";
820
+ state.terminalReason =
821
+ "frontend recovery request-id reuse conflict";
822
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
823
+ }
824
+ else {
825
+ const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
826
+ const result = await runRecoveryOperationController({
827
+ operationId: created.operation.operationId,
691
828
  parentRunId: state.runId,
829
+ deps: {
830
+ ...input.recovery,
831
+ failureOwner: input.recovery.failureOwner ??
832
+ recoveryTrigger.failureOwner,
833
+ protocolFailureReason: input.recovery.protocolFailureReason ??
834
+ recoveryTrigger.protocolFailureReason,
835
+ recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
836
+ },
692
837
  });
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
- });
838
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded"
839
+ ? "recovered"
840
+ : "auto-recovery-blocked");
711
841
  }
712
842
  }
713
843
  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);
844
+ // Parent is always archived to completed/ — no active/ retention for an
845
+ // in-process child CAS anymore.
846
+ try {
847
+ const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
848
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
723
849
  }
850
+ catch (error) {
851
+ console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
852
+ }
853
+ runDir = await moveToCompletedRunDir(runDir, completedRunDir);
724
854
  }
725
855
  await relocateRunArtifactPaths({
726
856
  runDir,
@@ -853,39 +983,21 @@ function isSuccessfulConvergenceTerminal(state) {
853
983
  return true;
854
984
  return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
855
985
  }
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
986
  function buildCandidateContractInvalidResult(state, result, artifactHash) {
874
- const recovery = state.frontendRecoveryState;
875
987
  return {
876
988
  schemaVersion: 1,
877
989
  outcome: "candidate-contract-invalid",
878
990
  origin: {
879
991
  kind: "frontend-prewrite-gate",
880
- parentRunId: recovery?.parentRunId ?? state.runId,
881
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
882
- requestId: recovery?.requestId ?? "",
883
- failedNodeId: result.selectedPlanNodeId,
992
+ parentRunId: state.runId,
993
+ requestId: state.runId,
994
+ failedNodeId: result.failureSource ?? "frontend-plan-pi",
884
995
  },
885
996
  failureClass: {
886
997
  code: "candidate-contract-invalid",
887
998
  classification: result.classification,
888
- reason: result.failureReason ?? "candidate contract invalid",
999
+ reason: result.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; ") ??
1000
+ "candidate contract invalid",
889
1001
  },
890
1002
  evidenceRefs: [
891
1003
  {
@@ -897,15 +1009,13 @@ function buildCandidateContractInvalidResult(state, result, artifactHash) {
897
1009
  };
898
1010
  }
899
1011
  function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
900
- const recovery = state.frontendRecoveryState;
901
1012
  return {
902
1013
  schemaVersion: 1,
903
1014
  outcome: "auto-recovery-blocked",
904
1015
  origin: {
905
1016
  kind: "frontend-writer",
906
- parentRunId: recovery?.parentRunId ?? state.runId,
907
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
908
- requestId: recovery?.requestId ?? "",
1017
+ parentRunId: state.runId,
1018
+ requestId: state.runId,
909
1019
  failedNodeId,
910
1020
  },
911
1021
  failureClass: {
@@ -916,63 +1026,78 @@ function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
916
1026
  evidenceRefs: [],
917
1027
  };
918
1028
  }
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
- }
1029
+ function buildRecoveryResultForTrigger(state, trigger, outcome) {
1030
+ return {
1031
+ schemaVersion: 1,
1032
+ outcome,
1033
+ origin: {
1034
+ kind: trigger.failureSource === "frontend-implement-pi"
1035
+ ? "frontend-writer"
1036
+ : "frontend-prewrite-gate",
1037
+ parentRunId: state.runId,
1038
+ requestId: trigger.requestId,
1039
+ failedNodeId: trigger.failureSource,
1040
+ },
1041
+ evidenceRefs: [],
1042
+ };
938
1043
  }
939
1044
  export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
940
1045
  const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1046
+ if (state.frontendShapeState?.terminalOutcome &&
1047
+ state.frontendShapeState.transitionCapsule) {
1048
+ state.status = "failed";
1049
+ return { recoveryPending: false };
1050
+ }
941
1051
  // Terminal override: a denied frontend writer must never surface as a
942
1052
  // 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;
1053
+ // was SKIPPED. Fail closed on blocked before aggregation.
1054
+ let recoveryTrigger;
945
1055
  const skipFrontendRecovery = state.failureCategory === "controller-interrupted";
946
1056
  const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
947
1057
  if (hasFrontendWriter && !skipFrontendRecovery) {
948
1058
  const admission = await readFrontendPrewriteResult(runDir);
949
1059
  if (admission.ok) {
950
- if (admission.result.classification === "blocked") {
1060
+ // M8: admission has only admitted|blocked. A blocked admission whose
1061
+ // failureSource is frontend-plan-pi is the contract-invalid analog of
1062
+ // the old retryable-invalid path (candidate continuation may re-plan);
1063
+ // every other blocked admission is terminal.
1064
+ const planContractInvalid = admission.result.classification === "blocked" &&
1065
+ admission.result.failureSource === "frontend-plan-pi";
1066
+ const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1067
+ admission.result.classification === "stale") &&
1068
+ !planContractInvalid;
1069
+ if (terminalAdmissionDenial) {
951
1070
  state.status = "failed";
952
1071
  return { recoveryPending: false };
953
1072
  }
954
- if (admission.result.classification === "retryable-invalid") {
1073
+ if (planContractInvalid) {
955
1074
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
956
1075
  if (continuationCount >= 1) {
957
- // Continuation quota exhausted: a second retryable-invalid candidate
1076
+ // Continuation quota exhausted: a second contract-invalid candidate
958
1077
  // is authoritative candidate-contract-invalid → forced failed.
959
1078
  state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
960
1079
  state.status = "failed";
961
1080
  return { recoveryPending: false };
962
1081
  }
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;
1082
+ // Root continuation still available: emit a recovery trigger for the
1083
+ // M6 operation controller. The parent no longer writes
1084
+ // frontendRecoveryState or stays in active/ for a child CAS.
1085
+ recoveryTrigger = {
1086
+ requestId: state.runId,
1087
+ failureSource: admission.result.failureSource ?? "frontend-plan-pi",
1088
+ failureOwner: "plan",
1089
+ protocolFailureReason: "frontend candidate contract invalid",
1090
+ recoveryMode: "new-dag",
1091
+ };
968
1092
  }
969
1093
  }
970
1094
  }
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) {
1095
+ // Phase 5: writer transient partial write → rollback + recovery trigger.
1096
+ // Only for frontend-implementation writers with a remaining root
1097
+ // continuation, and only when the rollback journal (captured before the
1098
+ // provider call) restores cleanly; otherwise the run stays failed with
1099
+ // auto-recovery-blocked.
1100
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
976
1101
  const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
977
1102
  const node = state.nodes[id];
978
1103
  return (node &&
@@ -990,8 +1115,13 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
990
1115
  parentRunId: state.runId,
991
1116
  });
992
1117
  if (rollback.ok) {
993
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state, "frontend-implement-pi");
994
- recoveryPending = true;
1118
+ recoveryTrigger = {
1119
+ requestId: state.runId,
1120
+ failureSource: "frontend-implement-pi",
1121
+ failureOwner: "implementation",
1122
+ protocolFailureReason: "frontend writer transient partial write",
1123
+ recoveryMode: "continuation",
1124
+ };
995
1125
  }
996
1126
  else {
997
1127
  state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
@@ -1001,6 +1131,26 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1001
1131
  }
1002
1132
  }
1003
1133
  }
1134
+ // Phase 6: verification failure → automatic recovery (re-implement +
1135
+ // re-verify). The verify shell is terminal by design so the failed run's
1136
+ // evidence stays clean; the M6 recovery operation re-runs implement and
1137
+ // verify in a fresh continuation, guarded by the same continuation quota,
1138
+ // budget fold and failure fingerprint as the other recovery paths.
1139
+ if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
1140
+ const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1141
+ if (verifyFailed) {
1142
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1143
+ if (continuationCount < 1) {
1144
+ recoveryTrigger = {
1145
+ requestId: state.runId,
1146
+ failureSource: "frontend-verify-shell",
1147
+ failureOwner: "implementation",
1148
+ protocolFailureReason: "frontend verification failure",
1149
+ recoveryMode: "continuation",
1150
+ };
1151
+ }
1152
+ }
1153
+ }
1004
1154
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
1005
1155
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
1006
1156
  if (state.convergence && supersededFailures.length > 0) {
@@ -1024,19 +1174,64 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1024
1174
  else {
1025
1175
  state.status = "failed";
1026
1176
  }
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
- }
1038
- }
1039
- return { recoveryPending };
1177
+ return recoveryTrigger
1178
+ ? { recoveryPending: true, recovery: recoveryTrigger }
1179
+ : { recoveryPending: false };
1180
+ }
1181
+ /**
1182
+ * Construct (or reuse) the recovery operation for a failed frontend run.
1183
+ *
1184
+ * The operation is keyed by a deterministic `clientRequestId`
1185
+ * (`dag-recovery-<parentRunId>`), so repeated wakeups of the same parent hit
1186
+ * `OperationStore.create`'s idempotent short-circuit instead of creating a
1187
+ * second recovery run (AC-004). The continuation DagSpec/reset partition is
1188
+ * frozen via `computeFrontendRecoveryPlanForSource` and serialized under the
1189
+ * run dir's bounded `.runtime/` namespace — never the repo root or `.harness`
1190
+ * siblings. `planHash` is the second idempotency dimension of `actionParams`.
1191
+ */
1192
+ async function createRecoveryOperation(input) {
1193
+ const { cwd, runDir, spec, state, trigger } = input;
1194
+ const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1195
+ const plan = computeFrontendRecoveryPlanForSource({
1196
+ spec,
1197
+ parentRunId: state.runId,
1198
+ requestId: trigger.requestId,
1199
+ failureSource: trigger.failureSource,
1200
+ });
1201
+ const planHash = createHash("sha256")
1202
+ .update(JSON.stringify(plan))
1203
+ .digest("hex");
1204
+ const continuationPath = path.join(runDir, ".runtime", "frontend-recovery", `${trigger.requestId}.json`);
1205
+ await writeJsonAtomic(continuationPath, {
1206
+ schemaVersion: 1,
1207
+ parentRunId: state.runId,
1208
+ requestId: trigger.requestId,
1209
+ failureSource: trigger.failureSource,
1210
+ recoveryMode: trigger.recoveryMode,
1211
+ plan,
1212
+ });
1213
+ return input.deps.store.create({
1214
+ clientRequestId: `dag-recovery-${state.runId}`,
1215
+ action: "dagRunTask",
1216
+ actionParams: {
1217
+ parentRunId: state.runId,
1218
+ requestId: trigger.requestId,
1219
+ failureSource: trigger.failureSource,
1220
+ failureOwner: trigger.failureOwner,
1221
+ protocolFailureReason: trigger.protocolFailureReason,
1222
+ recoveryMode: trigger.recoveryMode,
1223
+ planHash,
1224
+ },
1225
+ cliArgs: [
1226
+ "dag",
1227
+ "execute",
1228
+ "--dag",
1229
+ continuationPath,
1230
+ "--cwd",
1231
+ cwd,
1232
+ "--json",
1233
+ ],
1234
+ });
1040
1235
  }
1041
1236
  function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
1042
1237
  if (rankWriterNodeIds.length <= 1)