@tea-agent/loop-agent 0.42.0-next.9 → 0.43.0-next.1

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 (157) hide show
  1. package/CHANGELOG.md +65 -59
  2. package/dist/application/dag/run-dag.js +3 -2
  3. package/dist/application/task-lifecycle/advance.js +9 -4
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/program.js +1 -1
  6. package/dist/commands/dag-rerun-task.js +2 -0
  7. package/dist/commands/task-source-prepare.js +3 -1
  8. package/dist/executors/dag-pi-executor.js +1514 -175
  9. package/dist/executors/pi-extension-resolver.js +14 -2
  10. package/dist/executors/shell-executor.js +131 -54
  11. package/dist/shared/dag-failure-category.js +6 -0
  12. package/dist/task/contract/apply.js +36 -2
  13. package/dist/task/source-prepare/parse-intent.js +7 -0
  14. package/dist/worker/console/chat/chat-event-store.js +4 -2
  15. package/dist/worker/console/chat/pi-runtime.js +45 -2
  16. package/dist/worker/console/chat/resource-loader.js +4 -1
  17. package/dist/worker/console/chat/routes.js +28 -12
  18. package/dist/worker/console/chat/sdd-data-alignment.js +13 -2
  19. package/dist/worker/console/chat/session-store.js +5 -1
  20. package/dist/worker/console/chat/subagents/agent-tool.js +62 -0
  21. package/dist/worker/console/chat/subagents/explore-agent.js +169 -0
  22. package/dist/worker/console/chat/subagents/index.js +5 -0
  23. package/dist/worker/console/chat/subagents/orchestrator.js +273 -0
  24. package/dist/worker/console/chat/subagents/tool-policy.js +53 -0
  25. package/dist/worker/console/chat/subagents/types.js +13 -0
  26. package/dist/worker/console/chat/tool-preview.js +10 -0
  27. package/dist/worker/console/chat/tools.js +11 -1
  28. package/dist/worker/console/chat/turn-process.js +1 -0
  29. package/dist/worker/console/interview/tools.js +1 -0
  30. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C6n9_m0P.js → abnfDiagram-N423BO3Z-B0Q0nClf.js} +1 -1
  31. package/dist/worker/console/static/assets/{arc-DQh-IfZ1.js → arc-DCPjC19G.js} +1 -1
  32. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-54NnrwUC.js → architectureDiagram-T3A2C74G-CP5n9jmG.js} +1 -1
  33. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-pivRALGK.js → blockDiagram-VBNYF7ZC-CmaWi_Wg.js} +1 -1
  34. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BR7OV2NJ.js → c4Diagram-5PPSVZJV-Ct5tcMfZ.js} +1 -1
  35. package/dist/worker/console/static/assets/channel-DAS07MdS.js +1 -0
  36. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B1Aq6BcK.js → chunk-2GRJ4B5K-C0osm1Zf.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CbdU0rjo.js → chunk-2Q5K7J3B-CDWviED7.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Dqi2GJeD.js → chunk-5RXB4S5H-mtSk1-j7.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BYn1Hu4R.js → chunk-5VM5RSS4-D5J9PHC7.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DRLjDL0k.js → chunk-6Q2QTUOP-Begg4WAa.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CY9Xx2jV.js → chunk-GF5L2VYU-sF1AEy7T.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BNCZs7_z.js → chunk-JWPE2WC7-BqbsWXZb.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DZ8AStLL.js → chunk-KBJHAD2P-BKJrPcJ6.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DsxjYrzz.js → chunk-RYQCIY6F-CcwNMRho.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Ddx1KC1l.js → chunk-XXDRQBXY-BEJi3iIs.js} +1 -1
  46. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +1 -0
  47. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +1 -0
  48. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-W9TveCnK.js → cose-bilkent-JH36ORCC-DRLgTvVu.js} +1 -1
  49. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-l9j_ztZH.js → cynefin-VYW2F7L2-BoAVG3cs.js} +1 -1
  50. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BMJMKi4G.js → cynefinDiagram-MW4NZA55-Bzol06hR.js} +1 -1
  51. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Bb4mG9pH.js → dagre-VZM6K2ZE-UmliJM77.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BMgL_Qv0.js → diagram-7IWD3JNH-BWLcJkFo.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Bvo2T4OQ.js → diagram-B4RE2ZJO-CgApOm-O.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-_5kWRN9c.js → diagram-LBJQPF4R-Bh3RgTLs.js} +1 -1
  55. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DqdMrltM.js → diagram-Q27KOJAE-Dsh-D5nE.js} +1 -1
  56. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CDDYsNkp.js → diagram-UB23O5K3-Dkbbpcpb.js} +1 -1
  57. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-c4nfnZUV.js → ebnfDiagram-BXEA7PRR-3EBaA3t3.js} +1 -1
  58. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DnkUcNMs.js → erDiagram-JOGREHBK-Bq2Dc5ok.js} +1 -1
  59. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-Dz0KGLZE.js → flowDiagram-UKHOOZJN-De7y55ha.js} +1 -1
  60. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cj1t1uka.js → ganttDiagram-PKOTCBZU-fLiDbQRh.js} +1 -1
  61. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-tp2FrBHd.js → gitGraphDiagram-DS77QQ5N-Du617mp1.js} +1 -1
  62. package/dist/worker/console/static/assets/index-C0O48S_P.js +449 -0
  63. package/dist/worker/console/static/assets/index-CzKf4U8P.css +1 -0
  64. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DQwJS8DH.js → infoDiagram-6WML65LV-BwmlF-XP.js} +1 -1
  65. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-eimCQWD4.js → ishikawaDiagram-WSZJBQD7-zsGtS59u.js} +1 -1
  66. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Dd9Gbwgv.js → journeyDiagram-NVQOT4AX-DTTPTe3f.js} +1 -1
  67. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Qve3cyft.js → kanban-definition-27J2QSJJ-BkXg9aP-.js} +1 -1
  68. package/dist/worker/console/static/assets/{linear-UXKSe36Z.js → linear-7U2ue5IE.js} +1 -1
  69. package/dist/worker/console/static/assets/{mermaid.core-CWhj4JXN.js → mermaid.core-BUuGHmWO.js} +5 -5
  70. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-yTcwf4SJ.js → mindmap-definition-FAOFIHXS-7q6fX0Sv.js} +1 -1
  71. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-_7qToaZP.js → pegDiagram-VL7TDLO6-CkQPJN55.js} +1 -1
  72. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CXmrKmDm.js → pieDiagram-7S7Q4E2Y-CCal_tfX.js} +1 -1
  73. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-hMmrJyaS.js → quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js} +1 -1
  74. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BXw8tCBe.js → railroadDiagram-AXF67PYL-CJmww7D-.js} +1 -1
  75. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BmjgnLZl.js → requirementDiagram-LRYGKXZP-D6ldWJEv.js} +1 -1
  76. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bj77SPpN.js → sankeyDiagram-W5VNT64P-VGh33I9e.js} +1 -1
  77. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-B2FDVysL.js → sequenceDiagram-SI44F4Z6-CCkDrIjU.js} +1 -1
  78. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-UChNY9ZZ.js → sizeCapture-X5ZJPWSS-NC8f5Otb.js} +1 -1
  79. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-wbAEkbd7.js → stateDiagram-OKZ733FA-_92ezZdF.js} +1 -1
  80. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +1 -0
  81. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DCEkU8HR.js → swimlanes-SLNWSIFB-BdUCGtbP.js} +2 -2
  82. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B1cw_0Uy.js +8 -0
  83. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BGp7H06U.js → timeline-definition-Z64GVDOM-CdD1H8ia.js} +1 -1
  84. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DsCIQuWR.js → vennDiagram-T6HMQDX7-Dkdk3oFo.js} +1 -1
  85. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-cRZLP4Xe.js → wardleyDiagram-T6FBY63Y-CKu2uPPY.js} +1 -1
  86. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CP0xLR9Y.js → xychartDiagram-ELKLHX3M-ClqAnuG8.js} +1 -1
  87. package/dist/worker/console/static/index.html +2 -2
  88. package/dist/worker/console/static-src/chat-view-types.js +1 -1
  89. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +23 -0
  90. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +32 -0
  91. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +21 -2
  92. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +2 -0
  93. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +2 -0
  94. package/dist/worker/console/static-src/operator-chat/useChatStream.js +30 -14
  95. package/dist/worker/console/static-src/operator-chat/useChatThread.js +133 -5
  96. package/dist/worker/console/static-src/shell/workspace-route.js +4 -0
  97. package/dist/worker/observe/static/constants.js +22 -22
  98. package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
  99. package/dist/worker/observe/static/dag-history-labels.js +1 -0
  100. package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
  101. package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
  102. package/dist/worker/observe/static/dag-node-purpose.js +10 -10
  103. package/dist/worker/observe/static/index.html +4 -4
  104. package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
  105. package/dist/worker/observe/static/relations.js +5 -5
  106. package/dist/worker/observe/static/styles.css +171 -0
  107. package/dist/worker/observe/static/views/dag-graph.js +1 -1
  108. package/dist/worker/observe/static/views/dag-inspector.js +329 -174
  109. package/dist/worker/observe/static/views/dag.js +16 -12
  110. package/dist/worker/observe/static/views/session-timeline.js +5 -3
  111. package/dist/workflows/dag/backend-test-plan-protocol.js +104 -0
  112. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  113. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  114. package/dist/workflows/dag/frontend-design-policy.js +101 -16
  115. package/dist/workflows/dag/frontend-implementation-contract.js +410 -41
  116. package/dist/workflows/dag/frontend-plan-render.js +13 -2
  117. package/dist/workflows/dag/frontend-prewrite-gate.js +1 -1
  118. package/dist/workflows/dag/frontend-recovery-controller.js +25 -1
  119. package/dist/workflows/dag/frontend-recovery-plan.js +4 -1
  120. package/dist/workflows/dag/frontend-recovery-run.js +142 -16
  121. package/dist/workflows/dag/frontend-risk.js +2 -0
  122. package/dist/workflows/dag/frontend-shadow-dual-write.js +17 -1
  123. package/dist/workflows/dag/frontend-shape.js +16 -6
  124. package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
  125. package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
  126. package/dist/workflows/dag/frontend-verification-trace.js +38 -4
  127. package/dist/workflows/dag/frontend-writer-admission.js +2 -2
  128. package/dist/workflows/dag/init-hybrid.js +57 -28
  129. package/dist/workflows/dag/node-execution.js +201 -85
  130. package/dist/workflows/dag/recovery-lease.js +106 -16
  131. package/dist/workflows/dag/rerun-feedback.js +1 -0
  132. package/dist/workflows/dag/rerun-plan.js +90 -4
  133. package/dist/workflows/dag/rerun-run.js +28 -6
  134. package/dist/workflows/dag/rerun-task.js +221 -18
  135. package/dist/workflows/dag/retry-policy.js +27 -10
  136. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  137. package/dist/workflows/dag/runner.js +220 -18
  138. package/dist/workflows/dag/structured-output-repair.js +4 -1
  139. package/dist/workflows/dag/types.js +4 -3
  140. package/dist/workflows/dag/validate.js +10 -8
  141. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  142. package/docs/operations/README.md +1 -0
  143. package/docs/templates/agent-dag.schema.json +2 -2
  144. package/docs/templates/backend-test-dag.json +6 -4
  145. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  146. package/harness.json +1 -1
  147. package/package.json +4 -5
  148. package/skills/frontend-plan/SKILL.md +14 -1
  149. package/skills/frontend-plan/references/decision-contract.md +101 -5
  150. package/skills/frontend-plan/references/design-decisions.md +32 -0
  151. package/dist/worker/console/static/assets/channel-DsZxrgqe.js +0 -1
  152. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CuToPeGV.js +0 -1
  153. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CuToPeGV.js +0 -1
  154. package/dist/worker/console/static/assets/index-D9Sc0f0y.js +0 -449
  155. package/dist/worker/console/static/assets/index-DDQc5a50.css +0 -1
  156. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-yPoT19ft.js +0 -1
  157. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-VWdarchG.js +0 -8
@@ -13,7 +13,7 @@ import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./p
13
13
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
14
14
  import { materializeDeclaredArtifactFacts } from "./artifact-bindings.js";
15
15
  import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry, } from "./backend-test-writer-completeness.js";
16
- import { computeBackoffDelayMs, isCanonicalFinalVerifyShellRetryCandidate, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, projectFrontendNodeProtocolFailureReason, resolveFrontendPlanBackupRoute, resolveFrontendPlanRetryStep, VERIFY_SHELL_RETRY_POLICY, } from "./retry-policy.js";
16
+ import { computeBackoffDelayMs, isCanonicalFinalVerifyShellRetryCandidate, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, OUTPUT_LIMIT_RETRY_CATEGORY, projectFrontendNodeProtocolFailureReason, resolveFrontendPlanBackupRoute, resolveFrontendPlanRetryStep, VERIFY_SHELL_RETRY_POLICY, } from "./retry-policy.js";
17
17
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
18
18
  import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
19
19
  import { getStructuredContractValidator } from "./contract-output-registry.js";
@@ -21,6 +21,7 @@ import "./contract-validator-registrations.js";
21
21
  import { computeNormalizedFailureFingerprint } from "./frontend-recovery-lineage.js";
22
22
  import { parseLedgerJson } from "../../task/source-prepare/ledger.js";
23
23
  import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
24
+ import { collectCanonicalStateFlowNames, extractRequiredDeliverablePaths, resolveFrontendContractRequirements, } from "./frontend-contract-facts.js";
24
25
  import { allowedRepairReadPaths, auditRepairAttemptToolUse, buildStructuredOutputRepairPrompt, freezeStructuredOutputRepairContext, hasNonEmptyStructuredCandidate, isFrontendStructuredRepairSchemaId, isStructuredRepairableFailureCategory, persistStructuredAttemptRaw, sessionEventsByteLength, GOVERNANCE_BLOCKED_CATEGORY, STRUCTURED_REPAIR_EXHAUSTED_CATEGORY, } from "./structured-output-repair.js";
25
26
  import { readFrontendCanonicalCandidate } from "./frontend-implementation-contract.js";
26
27
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
@@ -256,6 +257,9 @@ function frontendPlanValidationRetryGuidance(reason) {
256
257
  if (/verificationTargets.*uiStates|uiStates.*verificationTargets/i.test(reason)) {
257
258
  guidance.push("For this retry, provide verificationTargets[].uiStates as an array; use [] when the target has no named UI state.");
258
259
  }
260
+ if (/duplicate|already recorded/i.test(reason)) {
261
+ guidance.push("For this retry, re-commit the corrected entry with the same id and replace=true; the ledger compiles the latest submission as the full replacement. A duplicate receipt is a correction invitation, not a prohibition, and backfilling a cited fact's reverse reference in-node does not re-derive committed facts.");
262
+ }
259
263
  return guidance;
260
264
  }
261
265
  function compactRetryText(text, maxChars) {
@@ -325,6 +329,8 @@ const FRONTEND_CONTRACT_RECORD_TOOL_NAMES_LOCAL = new Set([
325
329
  "record_handoff_intent",
326
330
  "record_open_question",
327
331
  "record_split_proposal",
332
+ "record_ui_state",
333
+ "record_required_deliverables",
328
334
  ]);
329
335
  async function countContractRecordSubmissions(runDir, nodeId) {
330
336
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
@@ -553,8 +559,9 @@ export function renderFrontendPlanInputContext(input) {
553
559
  : []);
554
560
  const contractFacts = committedFacts(input.contractRecords);
555
561
  const scoutFacts = committedFacts(input.scoutRecords);
556
- const requirements = contractFacts
557
- .filter((fact) => fact.kind === "requirement" && fact.origin === "contract")
562
+ const requirementFacts = resolveFrontendContractRequirements(contractFacts);
563
+ const requiredDeliverables = extractRequiredDeliverablePaths(contractFacts);
564
+ const requirements = requirementFacts
558
565
  .map((fact) => ({
559
566
  id: planInputText(fact.id, 80),
560
567
  text: planInputText(fact.text),
@@ -580,19 +587,28 @@ export function renderFrontendPlanInputContext(input) {
580
587
  paths: fact.paths,
581
588
  conflicts: fact.conflicts,
582
589
  }));
590
+ // Authoritative UI states (contract-declared): the source's UI-state table
591
+ // extracted by the contract node. The planner binds these ids instead of
592
+ // inventing list-visibility variants.
593
+ const declaredUiStates = contractFacts
594
+ .filter((fact) => fact.kind === "ui-state-declaration" && fact.origin === "contract")
595
+ .map((fact) => ({
596
+ id: planInputText(fact.id, 80),
597
+ trigger: planInputText(fact.trigger),
598
+ observableOutcome: planInputText(fact.observableOutcome),
599
+ }))
600
+ .filter((state) => state.id !== undefined);
601
+ // Replay registry edits and state-flow removals/additions in commit order.
602
+ const committedUxNames = collectCanonicalStateFlowNames(input.planRecords ?? [], true);
603
+ const committedUiStateNames = [...committedUxNames.uiStateNames];
604
+ const committedInteractionNames = [...committedUxNames.interactionNames];
583
605
  // Reviewer-rubric scaffold: the design reviewer re-runs the design-policy
584
606
  // checks on the committed facts, so publish the checklist to the producer.
585
607
  // Requirements whose contract evidence expects behavioural verification are
586
608
  // enumerated explicitly — those are the slots the reviewer finds missing
587
609
  // when the plan models interactions ad hoc (r8/r9 findings).
588
- const behaviorRequiredIds = requirements
589
- .filter((requirement) => {
590
- const fact = contractFacts.find((candidate) => candidate.kind === "requirement" &&
591
- candidate.origin === "contract" &&
592
- candidate.id === requirement.id);
593
- const evidence = fact?.evidence;
594
- return evidence?.behavior === "required";
595
- })
610
+ const behaviorRequiredIds = requirementFacts
611
+ .filter((requirement) => requirement.evidence.behavior === "required")
596
612
  .map((requirement) => requirement.id);
597
613
  const serializeAtCap = (cap) => JSON.stringify({
598
614
  requirements: requirements.map((requirement) => ({
@@ -600,6 +616,7 @@ export function renderFrontendPlanInputContext(input) {
600
616
  text: planInputText(requirement.text, cap.text),
601
617
  sourceFragmentIds: planInputStrings(requirement.sourceFragmentIds).slice(0, cap.array),
602
618
  })),
619
+ requiredDeliverables,
603
620
  targetSurface: targetSurface.map((surface) => ({
604
621
  completeness: planInputText(surface.completeness, 32),
605
622
  entrypoint: planInputText(surface.entrypoint, cap.text),
@@ -615,6 +632,18 @@ export function renderFrontendPlanInputContext(input) {
615
632
  paths: planInputStrings(evidence.paths).slice(0, cap.array),
616
633
  conflicts: planInputStrings(evidence.conflicts).slice(0, cap.array),
617
634
  })),
635
+ declaredUiStates: declaredUiStates.map((state) => ({
636
+ id: state.id,
637
+ trigger: planInputText(state.trigger, cap.text),
638
+ observableOutcome: planInputText(state.observableOutcome, cap.text),
639
+ })),
640
+ committedUx: committedUiStateNames.length > 0 ||
641
+ committedInteractionNames.length > 0
642
+ ? {
643
+ uiStateNames: committedUiStateNames,
644
+ interactionNames: committedInteractionNames,
645
+ }
646
+ : undefined,
618
647
  });
619
648
  let serialized = serializeAtCap(FRONTEND_PLAN_INPUT_CAP_LADDER[0]);
620
649
  for (const cap of FRONTEND_PLAN_INPUT_CAP_LADDER.slice(1)) {
@@ -628,6 +657,7 @@ export function renderFrontendPlanInputContext(input) {
628
657
  // to the minimum, and declare the degradation instead of corrupting JSON.
629
658
  let fallback = {
630
659
  degraded: "requirement-texts-truncated",
660
+ requiredDeliverables,
631
661
  requirements: requirements.map((requirement) => ({
632
662
  id: requirement.id,
633
663
  text: "(truncated)",
@@ -652,20 +682,20 @@ export function renderFrontendPlanInputContext(input) {
652
682
  serialized = bounded;
653
683
  }
654
684
  const checklistLines = [
655
- "1. Every interaction you record needs a uiComponentChoices entry whose purpose equals the interaction name, or one decision=reuse-existing choice covering behavioural interactions.",
656
- "2. Every applicable UI state needs a purpose-matching component choice or a stylingStrategy.",
657
- "3. Every requirement marked (behavior) below needs modelled interactions plus at least one verification target that references it.",
658
- "4. targets.files must name the concrete deliverable files; never leave the scope broader than the frozen requirements state.",
685
+ "1. Record the GLOBAL UX vocabulary with record_state_registry BEFORE any record_state_flow: one stable behavior-domain name per state/interaction (e.g. planner-task-edit), never one name per AC number, and never a rename of an already-recorded concept. Coverage slices by AC; UX does not.",
686
+ "2. Every recorded interaction/uiState name must be in that registry, and uiState names must use the contract's declared authoritative ids (declaredUiStates below) when present.",
687
+ "3. Every interaction and every applicable UI state must be covered by a uiComponentChoices entry: its purpose equals the name, or the choice lists the name in covers (one choice may cover many ids). A reuse-existing decision without evidencePath is rejected; stylingStrategy alone covers nothing.",
688
+ "4. Every requirement marked (behavior) below needs modelled interactions plus at least one verification target that references it.",
659
689
  "5. Verification targets may only reference UI states and requirements you actually recorded (the record_* tools reject unknown references).",
660
690
  `Requirements requiring behavioural coverage: ${behaviorRequiredIds.length > 0 ? behaviorRequiredIds.join(", ") : "(none)"}`,
661
- "6. A decision=new component must declare sourceRequirementIds and pass sourceFragmentId for the frozen PRD fragment whose section matches the component's purpose — the runtime validates that binding and derives specReference (path/section/line); the reviewer checks purpose↔citation consistency.",
691
+ "6. A decision=new component must declare sourceRequirementIds and pass sourceFragmentId for the frozen PRD fragment whose section matches the component's purpose — the runtime validates that binding and derives specReference (path/section/line); the reviewer checks purpose↔citation consistency. A decision=reuse-existing component must pass evidencePath pointing at the existing repo file that proves the reuse.",
662
692
  ...[...input.componentSourceCitations ?? []]
663
693
  .filter(([id]) => behaviorRequiredIds.includes(id))
664
694
  .flatMap(([id, citations]) => citations.map((citation) => ` ${id} + ${citation.fragmentId} → ${citation.section}${citation.line ? ` (line ${citation.line})` : ""}`)),
665
695
  ];
666
696
  return [
667
697
  "<frontend_plan_input>",
668
- "Committed Contract/Scout facts, compiled by the runner. Treat them as the complete planning evidence.",
698
+ "Committed Contract/Scout facts, compiled by the runner. Treat them as the complete planning evidence. declaredUiStates are the authoritative UI states from the task source; committedUx (retry attempts) is the UX vocabulary already recorded — reuse those names, never re-invent them.",
669
699
  serialized,
670
700
  "Do not read upstream artifacts, task sources, or repository files. If this input cannot support a decision, record a genuine evidence gap.",
671
701
  "</frontend_plan_input>",
@@ -692,6 +722,16 @@ export async function buildFrontendPlanInputContext(runDir, componentSourceCitat
692
722
  // forbids reading anything.
693
723
  throw new Error(`frontend-plan-input-unavailable: cannot read committed typed facts (${error instanceof Error ? error.message : String(error)})`);
694
724
  }
725
+ // Retry-attempt continuity (UX slice visibility): the plan node's own
726
+ // committed facts are absent on the first attempt and present on retries;
727
+ // a missing file is normal there, not a broken pipeline.
728
+ let planRecords = [];
729
+ try {
730
+ planRecords = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-plan-pi", "plan-typed-facts.jsonl"));
731
+ }
732
+ catch {
733
+ planRecords = [];
734
+ }
695
735
  const committedCount = [...contractRecords, ...scoutRecords].filter((record) => record.phase === "committed").length;
696
736
  if (committedCount === 0) {
697
737
  throw new Error(`frontend-plan-input-unavailable: no committed Contract/Scout facts in ${contractFactsPath} / ${scoutFactsPath}`);
@@ -699,6 +739,7 @@ export async function buildFrontendPlanInputContext(runDir, componentSourceCitat
699
739
  return renderFrontendPlanInputContext({
700
740
  contractRecords,
701
741
  scoutRecords,
742
+ planRecords,
702
743
  componentSourceCitations,
703
744
  });
704
745
  }
@@ -721,77 +762,27 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
721
762
  "</retry_instruction>",
722
763
  ].join("\n");
723
764
  }
724
- if (frontendPlanRetryStep === "compact-terminal-first") {
725
- return [
726
- basePrompt,
727
- "",
728
- "<retry_instruction>",
729
- "Frontend plan retry ladder step: compact-terminal-first.",
730
- "Do NOT rewrite the narrative. Only adopt already staged/quarantined fresh facts, fill the missing required facts, then call finalize_plan exactly once.",
731
- "Keep the existing committed ledger intact; do not re-derive already committed facts.",
732
- "</retry_instruction>",
733
- ].join("\n");
734
- }
735
- if (frontendPlanRetryStep === "bounded-tool-only") {
736
- return [
737
- basePrompt,
738
- "",
739
- "<retry_instruction>",
740
- "Frontend plan retry ladder step: bounded-tool-only.",
741
- "Reduce reasoning and wall-clock time. Use only read / fact / terminal tools and only for the necessary missing facts, then call finalize_plan exactly once.",
742
- "Do not expand scope or re-derive already committed facts.",
743
- "</retry_instruction>",
744
- ].join("\n");
745
- }
746
- if (frontendPlanRetryStep === "backup-model") {
747
- return [
748
- basePrompt,
749
- "",
750
- "<retry_instruction>",
751
- "Frontend plan retry ladder step: backup-model.",
752
- "A backup provider/model route was selected after repeated non-converging failures. Re-derive only the missing committed facts, then call finalize_plan exactly once; do not repeat the failed strategy.",
753
- "</retry_instruction>",
754
- ].join("\n");
755
- }
756
- if (previousFailureCategory === "protocol-invalid" &&
757
- task.outputProtocol &&
758
- previousProtocolReason) {
759
- return [
760
- basePrompt,
761
- "",
762
- buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
763
- ].join("\n");
764
- }
765
- if (previousFailureCategory === "review-terminal-missing") {
765
+ if (task.id === "generate-backend-md-plan-pi" &&
766
+ (previousFailureCategory === "output-too-large" ||
767
+ previousFailureCategory === "invalid-output")) {
766
768
  return [
767
769
  basePrompt,
768
770
  "",
769
771
  "<retry_instruction>",
770
- "The review emitted a verdict in response text but never committed the authoritative typed terminal tool call (approve_review / request_review_changes). The response text is NOT the authority: no branch or gate reads it.",
771
- "Call exactly one typed terminal tool to finish: approve_review (implementation passes, no Critical/Important findings) or request_review_changes (with typed issueCategory, at least one evidenceRef, and non-empty findings). Do not repeat the review analysis; commit the terminal tool once and stop.",
772
- "</retry_instruction>",
773
- ].join("\n");
774
- }
775
- if (previousFailureCategory === "read-burst") {
776
- if (task.id !== FRONTEND_PLAN_NODE_ID) {
777
- return [
778
- basePrompt,
779
- "",
780
- "<retry_instruction>",
781
- "The previous attempt exceeded its read budget. Start a fresh, evidence-minimal pass: do not re-read task sources, upstream stdout, skills, or full diff artifacts already represented by a canonical context artifact.",
782
- "Read the smallest relevant summary first, then only the specific source file or diff fragment needed for the required decision. Finish the required terminal/output protocol as soon as evidence is sufficient.",
783
- "</retry_instruction>",
784
- ].join("\n");
785
- }
786
- return [
787
- basePrompt,
788
- "",
789
- "<retry_instruction>",
790
- "Previous plan attempt issued too many read-only tool calls (read/grep/ls/find) and blew up the context window. Trust the upstream frontend-contract-pi typed requirement facts and frontend-scout-pi target surface already provided — do NOT re-read contract/scout stdout, PRD/source files, or component sources you already inspected.",
791
- "Minimize discovery reads: only read what you genuinely need, once. Commit record_plan_requirement / record_plan_verification_target / record_* facts directly from the facts already in context (one tool call per message), then call finalize_plan exactly once.",
772
+ "The previous backend-test plan attempt was truncated or failed its mandatory Markdown protocol.",
773
+ previousProtocolReason ?? "The previous plan artifact was incomplete.",
774
+ "Return the final Markdown artifact immediately. Do not output analysis, reasoning, source summaries, or planning narration.",
775
+ "Emit the complete section skeleton first, including exactly one ## Coverage Scope, exactly one ## Coverage Matrix, optional ## Scenario Partitions only when applicable, and exactly one ## Module Index with its required 8-column table and at least one module row.",
776
+ "After the complete skeleton exists, fill only concise table rows within the remaining output budget. Do not use code fences.",
792
777
  "</retry_instruction>",
793
778
  ].join("\n");
794
779
  }
780
+ // Repair-category guidance must outrank the retry ladder position: the
781
+ // ladder advances monotonically on transport failures (e.g. length →
782
+ // compact-terminal-first), and its "keep the committed ledger intact"
783
+ // instruction directly contradicts the repair action for invalid-output /
784
+ // truncated ledger facts (re-commit corrected record_* facts). When both
785
+ // apply, the model receives the repair instruction, not the rung script.
795
786
  if (previousFailureCategory === "invalid-output" &&
796
787
  task.structuredContractOutput &&
797
788
  previousProtocolReason) {
@@ -805,7 +796,7 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
805
796
  const splitGuidance = /write-set-too-large|split the task/.test(previousProtocolReason ?? "")
806
797
  ? [
807
798
  "",
808
- "The writeSet is too large for one implement node. Do NOT delete implementation files to squeeze under the limit that drops required work. Split the task via record_split_proposal (or narrow targets.files to a genuine subset) so each implement node stays bounded; the full file set must remain covered across the split.",
799
+ "The writeSet is too large for one implement node. Preserve the complete requirement and file coverage. This needs a Contract-level task split, not a Plan formatting repair. Report the write-set-too-large finding and the affected paths for the controller to resume Contract/split orchestration. Plan cannot change protected targets.files or call Contract-only tools; do not invent a split tool or discard required files.",
809
800
  ]
810
801
  : [];
811
802
  return [
@@ -867,8 +858,96 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
867
858
  "</retry_instruction>",
868
859
  ].join("\n");
869
860
  }
861
+ if (frontendPlanRetryStep === "compact-terminal-first") {
862
+ return [
863
+ basePrompt,
864
+ "",
865
+ "<retry_instruction>",
866
+ "Frontend plan retry ladder step: compact-terminal-first.",
867
+ "Do NOT rewrite the narrative. Only adopt already staged/quarantined fresh facts, fill the missing required facts, then call finalize_plan exactly once.",
868
+ "Keep the existing committed ledger intact; do not re-derive already committed facts.",
869
+ "</retry_instruction>",
870
+ ].join("\n");
871
+ }
872
+ if (frontendPlanRetryStep === "bounded-tool-only") {
873
+ return [
874
+ basePrompt,
875
+ "",
876
+ "<retry_instruction>",
877
+ "Frontend plan retry ladder step: bounded-tool-only.",
878
+ "Reduce reasoning and wall-clock time. Use only read / fact / terminal tools and only for the necessary missing facts, then call finalize_plan exactly once.",
879
+ "Do not expand scope or re-derive already committed facts.",
880
+ "</retry_instruction>",
881
+ ].join("\n");
882
+ }
883
+ if (frontendPlanRetryStep === "backup-model") {
884
+ return [
885
+ basePrompt,
886
+ "",
887
+ "<retry_instruction>",
888
+ "Frontend plan retry ladder step: backup-model.",
889
+ "A backup provider/model route was selected after repeated non-converging failures. Re-derive only the missing committed facts, then call finalize_plan exactly once; do not repeat the failed strategy.",
890
+ "</retry_instruction>",
891
+ ].join("\n");
892
+ }
893
+ if (previousFailureCategory === "protocol-invalid" &&
894
+ task.outputProtocol &&
895
+ previousProtocolReason) {
896
+ return [
897
+ basePrompt,
898
+ "",
899
+ buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
900
+ ].join("\n");
901
+ }
902
+ if (previousFailureCategory === "review-terminal-missing") {
903
+ return [
904
+ basePrompt,
905
+ "",
906
+ "<retry_instruction>",
907
+ "The review emitted a verdict in response text but never committed the authoritative typed terminal tool call (approve_review / request_review_changes). The response text is NOT the authority: no branch or gate reads it.",
908
+ "Call exactly one typed terminal tool to finish: approve_review (implementation passes, no Critical/Important findings) or request_review_changes (with typed issueCategory, at least one evidenceRef, and non-empty findings). Do not repeat the review analysis; commit the terminal tool once and stop.",
909
+ "</retry_instruction>",
910
+ ].join("\n");
911
+ }
912
+ if (previousFailureCategory === "read-burst") {
913
+ if (task.id !== FRONTEND_PLAN_NODE_ID) {
914
+ return [
915
+ basePrompt,
916
+ "",
917
+ "<retry_instruction>",
918
+ "The previous attempt exceeded its read budget. Start a fresh, evidence-minimal pass: do not re-read task sources, upstream stdout, skills, or full diff artifacts already represented by a canonical context artifact.",
919
+ "Read the smallest relevant summary first, then only the specific source file or diff fragment needed for the required decision. Finish the required terminal/output protocol as soon as evidence is sufficient.",
920
+ "</retry_instruction>",
921
+ ].join("\n");
922
+ }
923
+ return [
924
+ basePrompt,
925
+ "",
926
+ "<retry_instruction>",
927
+ "Previous plan attempt issued too many read-only tool calls (read/grep/ls/find) and blew up the context window. Trust the upstream frontend-contract-pi typed requirement facts and frontend-scout-pi target surface already provided — do NOT re-read contract/scout stdout, PRD/source files, or component sources you already inspected.",
928
+ "Minimize discovery reads: only read what you genuinely need, once. Commit record_plan_requirement / record_plan_verification_target / record_* facts directly from the facts already in context (one tool call per message), then call finalize_plan exactly once.",
929
+ "</retry_instruction>",
930
+ ].join("\n");
931
+ }
932
+ // Generic output-limit fallback. Every branch above this one carries a
933
+ // more precise instruction for the same capacity signal (contract repair
934
+ // reasons, ladder rungs — compact-terminal-first is the reason-mandated
935
+ // rung for length-before-terminal —, protocol/review/read-burst repair),
936
+ // so output-limit must not shadow them.
937
+ if (previousFailureCategory === OUTPUT_LIMIT_RETRY_CATEGORY) {
938
+ return [
939
+ basePrompt,
940
+ "",
941
+ "<retry_instruction>",
942
+ "The previous turn ended with stopReason=length before the required output was complete. This is output-capacity truncation, not empty output.",
943
+ "Continue incrementally from already committed typed facts and current in-scope workspace files. Do not repeat completed discovery, decisions, facts, or writes.",
944
+ "Generate the smallest unfinished unit next, validate its required format immediately, repair any format error in this node, and only then continue to the next unfinished unit or terminal tool.",
945
+ "Keep prose minimal and finish the required terminal/output protocol as soon as the remaining work is valid.",
946
+ "</retry_instruction>",
947
+ ].join("\n");
948
+ }
870
949
  if (previousFailureCategory === "writer-empty-diff") {
871
- const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
950
+ const maxAttempts = task.retryPolicy?.maxAttempts ?? 5;
872
951
  // When a completeness progress exists for this writer, fold the concrete
873
952
  // target paths into the empty-diff retry so the model does not guess and
874
953
  // does not need to read a forbidden `.harness/**` evidence file.
@@ -896,7 +975,7 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
896
975
  ].join("\n");
897
976
  }
898
977
  if (previousFailureCategory === "incomplete-write-set") {
899
- const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
978
+ const maxAttempts = task.retryPolicy?.maxAttempts ?? 5;
900
979
  const bindingOnly = task.id?.startsWith("generate-backend-md-case-") === true &&
901
980
  (recoveryTargetPaths?.length ?? 0) === 1 &&
902
981
  Object.values(recoveryDiagnostics ?? {}).flat().some((detail) => /(?:unclassified Test Points|duplicate Test Point bindings)/i.test(detail));
@@ -1712,6 +1791,43 @@ export async function executeDagNode(input) {
1712
1791
  durationMs: 0,
1713
1792
  };
1714
1793
  }
1794
+ // stopReason=length on top of a bare empty-output verdict is a provider
1795
+ // capacity signal, not a true empty response: reroute it through the
1796
+ // dedicated output-limit retry path while keeping the raw category for
1797
+ // diagnostics. Bare empty-output and transport aliases (network,
1798
+ // nonzero-exit, unknown) are rerouted — categories that already carry a
1799
+ // precise repair instruction (output-too-large, invalid-output,
1800
+ // protocol-invalid, structured-output-truncated via the validators below,
1801
+ // writer categories) keep their classification so their exact retry
1802
+ // guidance still reaches the model.
1803
+ if (task.executor === "pi" &&
1804
+ !result.ok &&
1805
+ result.stopReason === "length" &&
1806
+ (result.failureCategory === undefined ||
1807
+ ["empty-output", "network", "nonzero-exit", "unknown"].includes(result.failureCategory))) {
1808
+ result = {
1809
+ ...result,
1810
+ rawFailureCategory: result.rawFailureCategory ?? result.failureCategory,
1811
+ failureCategory: OUTPUT_LIMIT_RETRY_CATEGORY,
1812
+ stderr: [
1813
+ result.stderr,
1814
+ "output-limit: stopReason=length; preserve completed work and retry only the unfinished output",
1815
+ ]
1816
+ .filter(Boolean)
1817
+ .join("\n"),
1818
+ };
1819
+ }
1820
+ if (task.id === "generate-backend-md-plan-pi" &&
1821
+ !result.ok &&
1822
+ (result.failureCategory === "output-too-large" ||
1823
+ result.failureCategory === "invalid-output")) {
1824
+ const marker = "backend-test Markdown plan";
1825
+ const markerIndex = result.stderr?.lastIndexOf(marker) ?? -1;
1826
+ previousProtocolReason =
1827
+ markerIndex >= 0
1828
+ ? result.stderr.slice(markerIndex, markerIndex + 4_000).trim()
1829
+ : `backend-test Markdown plan attempt failed with ${result.failureCategory}`;
1830
+ }
1715
1831
  if (isFrontendStructuredRepairSchemaId(task.structuredContractOutput?.schemaId)) {
1716
1832
  const rawText = canonicalNodeOutput(result);
1717
1833
  if (rawText.trim().length > 0) {
@@ -1834,7 +1950,7 @@ export async function executeDagNode(input) {
1834
1950
  !result.ok &&
1835
1951
  retryPolicy !== undefined) {
1836
1952
  const submissions = await countContractRecordSubmissions(runDir, nodeId);
1837
- if (submissions === 0) {
1953
+ if (submissions === 0 && result.stopReason !== "length") {
1838
1954
  result = {
1839
1955
  ...result,
1840
1956
  failureCategory: "empty-output",
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { hostname as localHostname } from "node:os";
3
4
  import path from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
4
6
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
7
  import { DAG_RUNS_DIR } from "./lifecycle.js";
6
8
  function recoveryLeasePath(cwd, parentRunId) {
@@ -23,6 +25,11 @@ function parseLease(raw, parentRunId) {
23
25
  record.kind !== "standalone-task-rerun") ||
24
26
  typeof record.leaseToken !== "string" ||
25
27
  record.leaseToken.length === 0 ||
28
+ typeof record.ownerPid !== "number" ||
29
+ !Number.isInteger(record.ownerPid) ||
30
+ record.ownerPid <= 0 ||
31
+ typeof record.ownerHostname !== "string" ||
32
+ record.ownerHostname.length === 0 ||
26
33
  typeof record.acquiredAt !== "string" ||
27
34
  typeof record.executionRef !== "undefined" &&
28
35
  typeof record.executionRef !== "string") {
@@ -30,6 +37,60 @@ function parseLease(raw, parentRunId) {
30
37
  }
31
38
  return record;
32
39
  }
40
+ const LEASE_MUTATION_LOCK_STALE_MS = 60_000;
41
+ const LEASE_MUTATION_LOCK_RETRIES = 200;
42
+ async function withLeaseMutationLock(leasePath, operation) {
43
+ const lockPath = `${leasePath}.mutation-lock`;
44
+ for (let attempt = 0; attempt < LEASE_MUTATION_LOCK_RETRIES; attempt += 1) {
45
+ try {
46
+ await mkdir(lockPath);
47
+ }
48
+ catch (error) {
49
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) {
50
+ throw error;
51
+ }
52
+ try {
53
+ const lockStat = await stat(lockPath);
54
+ if (Date.now() - lockStat.mtimeMs >= LEASE_MUTATION_LOCK_STALE_MS) {
55
+ await rm(lockPath, { recursive: true, force: true });
56
+ continue;
57
+ }
58
+ }
59
+ catch (statError) {
60
+ if (statError &&
61
+ typeof statError === "object" &&
62
+ "code" in statError &&
63
+ statError.code === "ENOENT") {
64
+ continue;
65
+ }
66
+ throw statError;
67
+ }
68
+ await delay(10);
69
+ continue;
70
+ }
71
+ try {
72
+ return await operation();
73
+ }
74
+ finally {
75
+ await rm(lockPath, { recursive: true, force: true });
76
+ }
77
+ }
78
+ throw new Error("dag recovery lease mutation lock is busy");
79
+ }
80
+ function defaultIsProcessAlive(pid) {
81
+ try {
82
+ process.kill(pid, 0);
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ function leaseOwnerIsAlive(input) {
90
+ if (input.lease.ownerHostname !== input.hostname)
91
+ return true;
92
+ return input.isProcessAlive(input.lease.ownerPid);
93
+ }
33
94
  export async function acquireDagRecoveryLease(input) {
34
95
  const leasePath = recoveryLeasePath(input.cwd, input.parentRunId);
35
96
  const lease = {
@@ -38,13 +99,15 @@ export async function acquireDagRecoveryLease(input) {
38
99
  requestId: input.requestId,
39
100
  kind: input.kind,
40
101
  leaseToken: randomBytes(16).toString("hex"),
102
+ ownerPid: process.pid,
103
+ ownerHostname: localHostname(),
41
104
  acquiredAt: (input.now ?? (() => new Date()))().toISOString(),
42
105
  };
43
106
  await mkdir(path.dirname(leasePath), { recursive: true });
44
107
  try {
45
108
  // `wx` is the cross-process compare-and-create primitive. The lease is
46
- // deliberately immutable for a parent run: a later recovery must follow
47
- // the committed child lineage rather than fork another DAG.
109
+ // parent-scoped for the full recovery lineage. Ownership may only be
110
+ // replaced by claimDagRecoveryLease after the recorded owner is proven dead.
48
111
  await writeFile(leasePath, `${JSON.stringify(lease, null, 2)}\n`, {
49
112
  encoding: "utf8",
50
113
  flag: "wx",
@@ -59,22 +122,49 @@ export async function acquireDagRecoveryLease(input) {
59
122
  return { kind: "held", leasePath, lease: existing };
60
123
  }
61
124
  }
125
+ /**
126
+ * Reclaim a lease only after its recorded same-host owner is proven dead.
127
+ * The mutation lock serializes reclaim with execution binding, so a live owner
128
+ * can never race a stale-owner takeover and launch a second recovery.
129
+ */
130
+ export async function claimDagRecoveryLease(input) {
131
+ const hostname = input.hostname ?? localHostname();
132
+ const isProcessAlive = input.isProcessAlive ?? defaultIsProcessAlive;
133
+ return withLeaseMutationLock(input.leasePath, async () => {
134
+ const existing = parseLease(JSON.parse(await readFile(input.leasePath, "utf8")), input.lease.parentRunId);
135
+ if (existing.leaseToken !== input.lease.leaseToken ||
136
+ leaseOwnerIsAlive({ lease: existing, hostname, isProcessAlive })) {
137
+ return { kind: "held", leasePath: input.leasePath, lease: existing };
138
+ }
139
+ const claimed = {
140
+ ...existing,
141
+ leaseToken: randomBytes(16).toString("hex"),
142
+ ownerPid: process.pid,
143
+ ownerHostname: hostname,
144
+ acquiredAt: (input.now ?? (() => new Date()))().toISOString(),
145
+ };
146
+ await writeJsonAtomic(input.leasePath, claimed);
147
+ return { kind: "acquired", leasePath: input.leasePath, lease: claimed };
148
+ });
149
+ }
62
150
  /** Persist the operation/run identity before the caller is allowed to execute it. */
63
151
  export async function bindDagRecoveryLeaseExecution(input) {
64
152
  if (!input.executionRef.trim()) {
65
153
  throw new Error("dag recovery lease executionRef is required");
66
154
  }
67
- const existing = parseLease(JSON.parse(await readFile(input.leasePath, "utf8")), input.lease.parentRunId);
68
- if (existing.leaseToken !== input.lease.leaseToken) {
69
- throw new Error("dag recovery lease ownership drift");
70
- }
71
- if (existing.executionRef && existing.executionRef !== input.executionRef) {
72
- throw new Error("dag recovery lease execution identity drift");
73
- }
74
- const bound = {
75
- ...existing,
76
- executionRef: input.executionRef,
77
- };
78
- await writeJsonAtomic(input.leasePath, bound);
79
- return bound;
155
+ return withLeaseMutationLock(input.leasePath, async () => {
156
+ const existing = parseLease(JSON.parse(await readFile(input.leasePath, "utf8")), input.lease.parentRunId);
157
+ if (existing.leaseToken !== input.lease.leaseToken) {
158
+ throw new Error("dag recovery lease ownership drift");
159
+ }
160
+ if (existing.executionRef && existing.executionRef !== input.executionRef) {
161
+ throw new Error("dag recovery lease execution identity drift");
162
+ }
163
+ const bound = {
164
+ ...existing,
165
+ executionRef: input.executionRef,
166
+ };
167
+ await writeJsonAtomic(input.leasePath, bound);
168
+ return bound;
169
+ });
80
170
  }
@@ -408,6 +408,7 @@ export async function deriveDagRerunFeedback(input) {
408
408
  return (node?.status === "ERROR" &&
409
409
  [
410
410
  "empty-output",
411
+ "output-limit",
411
412
  "invalid-output",
412
413
  "writer-thinking-exhausted",
413
414
  "writer-budget-exhausted",