@tea-agent/loop-agent 0.42.0-next.8 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +56 -50
  2. package/dist/application/dag/run-dag.js +41 -0
  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 +1809 -213
  9. package/dist/executors/pi-extension-resolver.js +14 -2
  10. package/dist/executors/shell-executor.js +178 -73
  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/routes.js +4 -0
  98. package/dist/worker/observe/static/constants.js +22 -22
  99. package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
  100. package/dist/worker/observe/static/dag-history-labels.js +1 -0
  101. package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
  102. package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
  103. package/dist/worker/observe/static/dag-node-purpose.js +10 -10
  104. package/dist/worker/observe/static/index.html +4 -4
  105. package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
  106. package/dist/worker/observe/static/relations.js +5 -5
  107. package/dist/worker/observe/static/styles.css +171 -0
  108. package/dist/worker/observe/static/views/dag-graph.js +1 -1
  109. package/dist/worker/observe/static/views/dag-inspector.js +329 -174
  110. package/dist/worker/observe/static/views/dag.js +16 -12
  111. package/dist/worker/observe/static/views/session-timeline.js +5 -3
  112. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +18 -0
  113. package/dist/workflows/dag/backend-test-plan-protocol.js +104 -0
  114. package/dist/workflows/dag/backend-test-scenario-param.js +30 -1
  115. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  116. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  117. package/dist/workflows/dag/frontend-design-policy.js +101 -16
  118. package/dist/workflows/dag/frontend-implementation-contract.js +410 -41
  119. package/dist/workflows/dag/frontend-plan-render.js +13 -2
  120. package/dist/workflows/dag/frontend-prewrite-gate.js +1 -1
  121. package/dist/workflows/dag/frontend-recovery-controller.js +25 -1
  122. package/dist/workflows/dag/frontend-recovery-plan.js +6 -2
  123. package/dist/workflows/dag/frontend-recovery-run.js +169 -15
  124. package/dist/workflows/dag/frontend-risk.js +2 -0
  125. package/dist/workflows/dag/frontend-shadow-dual-write.js +17 -1
  126. package/dist/workflows/dag/frontend-shape.js +16 -6
  127. package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
  128. package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
  129. package/dist/workflows/dag/frontend-verification-trace.js +38 -4
  130. package/dist/workflows/dag/frontend-writer-admission.js +46 -12
  131. package/dist/workflows/dag/init-hybrid.js +59 -29
  132. package/dist/workflows/dag/node-execution.js +277 -85
  133. package/dist/workflows/dag/recovery-lease.js +106 -16
  134. package/dist/workflows/dag/rerun-feedback.js +60 -0
  135. package/dist/workflows/dag/rerun-plan.js +90 -4
  136. package/dist/workflows/dag/rerun-run.js +28 -6
  137. package/dist/workflows/dag/rerun-task.js +224 -15
  138. package/dist/workflows/dag/retry-policy.js +27 -10
  139. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  140. package/dist/workflows/dag/runner.js +238 -29
  141. package/dist/workflows/dag/scheduler.js +21 -6
  142. package/dist/workflows/dag/structured-output-repair.js +4 -1
  143. package/dist/workflows/dag/types.js +4 -3
  144. package/dist/workflows/dag/validate.js +10 -8
  145. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  146. package/docs/operations/README.md +1 -0
  147. package/docs/templates/agent-dag.schema.json +2 -2
  148. package/docs/templates/backend-test-dag.json +6 -4
  149. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  150. package/harness.json +1 -1
  151. package/package.json +4 -5
  152. package/skills/frontend-plan/SKILL.md +14 -1
  153. package/skills/frontend-plan/references/decision-contract.md +101 -5
  154. package/skills/frontend-plan/references/design-decisions.md +32 -0
  155. package/dist/worker/console/static/assets/channel-DsZxrgqe.js +0 -1
  156. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CuToPeGV.js +0 -1
  157. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CuToPeGV.js +0 -1
  158. package/dist/worker/console/static/assets/index-D9Sc0f0y.js +0 -449
  159. package/dist/worker/console/static/assets/index-DDQc5a50.css +0 -1
  160. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-yPoT19ft.js +0 -1
  161. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-VWdarchG.js +0 -8
@@ -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, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
10
+ import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, readDagRunState, requireActiveDagRun, } from "./lifecycle.js";
11
11
  import { markInterruptNeedsReconcile, mergeAbortSignals, readInterruptRequest, runnerIdentityFromState, settleDagInterrupt, startInterruptWatcher, } from "./interrupt-request.js";
12
12
  import { moveToCompletedRunDir, moveToPausedRunDir, prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
13
13
  import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
@@ -31,11 +31,14 @@ import { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas,
31
31
  import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFrontendShapeSourceDigest, } from "./frontend-shape.js";
32
32
  import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
33
33
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
34
- import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, } from "./recovery-lease.js";
34
+ import { computeActionPayloadHash, } from "../../infrastructure/console/operation-store.js";
35
+ import { classifyFailureOwner, failureOwnerSchema } from "./frontend-recovery-capsule.js";
36
+ import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, claimDagRecoveryLease, } from "./recovery-lease.js";
35
37
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
36
38
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
37
39
  import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
38
40
  import { executeDynamicReduction } from "./dynamic-runtime/reduction.js";
41
+ import { installRunnerExitDiagnostics } from "./runner-exit-diagnostics.js";
39
42
  export { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions };
40
43
  export async function loadDagSpecFromFile(dagPath) {
41
44
  const raw = JSON.parse(await readFile(dagPath, "utf-8"));
@@ -601,6 +604,36 @@ async function executeDagCheckpoint(input) {
601
604
  const onSigInt = () => shutdownOnSignal("SIGINT");
602
605
  process.once("SIGTERM", onSigTerm);
603
606
  process.once("SIGINT", onSigInt);
607
+ // Exit-time diagnostics (silent-death forensics): write an `armed` line at
608
+ // checkpoint start and append process-exit / uncaught-exception /
609
+ // unhandled-rejection lines with a live state snapshot so an abnormal death
610
+ // (supervisor hard kill, wedged event loop, crash with lost stderr) leaves
611
+ // a durable trace next to state.json. Removed on clean terminal return.
612
+ const exitDiagnostics = installRunnerExitDiagnostics({
613
+ runDir,
614
+ runId: state.runId,
615
+ title: state.title,
616
+ snapshot: () => {
617
+ const heartbeatAt = state.runner?.heartbeatAt;
618
+ return {
619
+ status: state.status,
620
+ terminalReason: state.terminalReason,
621
+ failureCategory: state.failureCategory,
622
+ heartbeatAt,
623
+ heartbeatAgeMs: heartbeatAt === undefined
624
+ ? undefined
625
+ : Math.max(0, Date.now() - Date.parse(heartbeatAt)),
626
+ running: Object.values(state.nodes)
627
+ .filter((node) => node.status === "RUNNING")
628
+ .map((node) => ({
629
+ id: node.id,
630
+ attempt: node.currentAttempt,
631
+ startedAt: node.startedAt,
632
+ lastMeaningfulProgressAt: node.lastMeaningfulProgressAt,
633
+ })),
634
+ };
635
+ },
636
+ });
604
637
  try {
605
638
  const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
606
639
  const baseExecuteNode = input.executeNode ??
@@ -851,6 +884,7 @@ async function executeDagCheckpoint(input) {
851
884
  const result = await runRecoveryOperationController({
852
885
  operationId: created.operation.operationId,
853
886
  parentRunId: state.runId,
887
+ observeOnly: created.kind === "observed",
854
888
  deps: {
855
889
  ...input.recovery,
856
890
  failureOwner: input.recovery.failureOwner ??
@@ -860,7 +894,40 @@ async function executeDagCheckpoint(input) {
860
894
  recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
861
895
  },
862
896
  });
863
- state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded"
897
+ // The recovery CLI stages its child by writing a fresh
898
+ // `frontendRecoveryState` into the parent run on disk. A successful
899
+ // operation without that bound lineage is not a successful recovery:
900
+ // fail closed instead of archiving a false-positive `recovered` result.
901
+ let recoveryLineageError;
902
+ if (result.terminalReason === "succeeded") {
903
+ try {
904
+ const persistedParentState = await readDagRunState(runDir);
905
+ const persistedRecovery = persistedParentState.frontendRecoveryState;
906
+ if (!persistedRecovery ||
907
+ persistedRecovery.phase !== "child-running" ||
908
+ persistedRecovery.parentRunId !== state.runId ||
909
+ persistedRecovery.requestId !== recoveryTrigger.requestId ||
910
+ !persistedRecovery.childRunId) {
911
+ throw new Error("persisted recovery lineage is missing or unbound");
912
+ }
913
+ await assertRecoveredFrontendLineage({
914
+ cwd,
915
+ rootRunId: state.runId,
916
+ rootRequestId: recoveryTrigger.requestId,
917
+ recovery: persistedRecovery,
918
+ maxContinuations: spec.frontendRecovery?.maxContinuations ?? 1,
919
+ });
920
+ state.frontendRecoveryState = structuredClone(persistedRecovery);
921
+ }
922
+ catch (error) {
923
+ recoveryLineageError =
924
+ error instanceof Error ? error.message : String(error);
925
+ state.status = "failed";
926
+ state.terminalReason =
927
+ `frontend recovery lineage invalid: ${recoveryLineageError}`;
928
+ }
929
+ }
930
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded" && !recoveryLineageError
864
931
  ? "recovered"
865
932
  : "auto-recovery-blocked");
866
933
  }
@@ -903,10 +970,61 @@ async function executeDagCheckpoint(input) {
903
970
  finally {
904
971
  clearInterval(heartbeatTimer);
905
972
  interruptWatcher?.stop();
973
+ exitDiagnostics.stop();
906
974
  process.removeListener("SIGTERM", onSigTerm);
907
975
  process.removeListener("SIGINT", onSigInt);
908
976
  }
909
977
  }
978
+ async function assertRecoveredFrontendLineage(input) {
979
+ const maxContinuations = Math.max(1, Math.min(5, input.maxContinuations));
980
+ let parentRunId = input.rootRunId;
981
+ let recovery = input.recovery;
982
+ const recoveryRootRunId = recovery.recoveryRootRunId;
983
+ const visited = new Set();
984
+ if (recoveryRootRunId !== input.rootRunId) {
985
+ throw new Error("recovery root lineage binding mismatch");
986
+ }
987
+ for (let depth = 0; depth < maxContinuations; depth += 1) {
988
+ // The persisted parent intent represents the edge about to be traversed,
989
+ // so its counters equal the number of already-completed continuation edges.
990
+ // Each completed child increments both counters exactly once; no edge may
991
+ // exceed the frozen DAG quota.
992
+ if (recovery.continuationCount !== depth ||
993
+ recovery.attemptIndex !== depth) {
994
+ throw new Error("recovery child lineage counters are not monotonic");
995
+ }
996
+ if (recovery.phase !== "child-running" ||
997
+ recovery.parentRunId !== parentRunId ||
998
+ recovery.recoveryRootRunId !== recoveryRootRunId ||
999
+ !recovery.childRunId ||
1000
+ (depth === 0
1001
+ ? recovery.requestId !== input.rootRequestId
1002
+ : recovery.requestId !== parentRunId)) {
1003
+ throw new Error("persisted recovery lineage is missing or unbound");
1004
+ }
1005
+ if (visited.has(recovery.childRunId)) {
1006
+ throw new Error("recovery child lineage contains a cycle");
1007
+ }
1008
+ visited.add(recovery.childRunId);
1009
+ const child = await locateDagRun(input.cwd, recovery.childRunId);
1010
+ if (!child || child.lifecycle !== "completed") {
1011
+ throw new Error("recovery child is not completed");
1012
+ }
1013
+ const childState = await readDagRunState(child.runDir);
1014
+ if (childState.runId !== recovery.childRunId) {
1015
+ throw new Error("recovery child state binding mismatch");
1016
+ }
1017
+ if (childState.status === "finished")
1018
+ return;
1019
+ if (childState.frontendRecoveryResult?.outcome !== "recovered" ||
1020
+ !childState.frontendRecoveryState) {
1021
+ throw new Error("recovery child terminal state is not successful");
1022
+ }
1023
+ parentRunId = childState.runId;
1024
+ recovery = childState.frontendRecoveryState;
1025
+ }
1026
+ throw new Error("recovery child lineage exceeds the continuation quota");
1027
+ }
910
1028
  async function finalizeRunOwnedInterrupt(input) {
911
1029
  const request = await readInterruptRequest(input.runDir);
912
1030
  if (!request)
@@ -1087,7 +1205,7 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1087
1205
  if (hasFrontendWriter && !skipFrontendRecovery) {
1088
1206
  const admission = await readFrontendPrewriteResult(runDir);
1089
1207
  if (admission.ok) {
1090
- // M8: admission has only admitted|blocked. A blocked admission whose
1208
+ // M8: admission uses the accepted/blocked state machine. A blocked admission whose
1091
1209
  // failureSource is frontend-plan-pi is the contract-invalid analog of
1092
1210
  // the old retryable-invalid path (candidate continuation may re-plan);
1093
1211
  // every other blocked admission is terminal.
@@ -1130,7 +1248,10 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1130
1248
  if (continuationCount < frontendRecoveryQuota) {
1131
1249
  recoveryTrigger = {
1132
1250
  requestId: state.runId,
1133
- failureSource: "frontend-design-review-pi",
1251
+ // The rejected design is a plan input defect. Reset from the
1252
+ // planner so the next child can incorporate the committed
1253
+ // findings before design review runs again.
1254
+ failureSource: "frontend-plan-pi",
1134
1255
  failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
1135
1256
  protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
1136
1257
  recoveryMode: "new-dag",
@@ -1208,13 +1329,40 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1208
1329
  if (verifyFailed) {
1209
1330
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1210
1331
  if (continuationCount < frontendRecoveryQuota) {
1211
- recoveryTrigger = {
1212
- requestId: state.runId,
1213
- failureSource: "frontend-verify-shell",
1214
- failureOwner: "implementation",
1215
- protocolFailureReason: "frontend verification failure",
1216
- recoveryMode: "continuation",
1217
- };
1332
+ let recoveryOwner = classifyFailureOwner({
1333
+ nodeId: "frontend-verify-shell",
1334
+ rawFailureCategory: state.nodes["frontend-verify-shell"]?.failureCategory,
1335
+ });
1336
+ try {
1337
+ const verifyFailure = JSON.parse(await readFile(path.join(runDir, "contracts/frontend-verify-failure.json"), "utf8"));
1338
+ const owner = failureOwnerSchema.safeParse(verifyFailure.failureOwner);
1339
+ if (verifyFailure.schemaVersion === 1 &&
1340
+ verifyFailure.schemaId === "frontend-verify-failure-v1" &&
1341
+ verifyFailure.nodeId === "frontend-verify-shell" &&
1342
+ verifyFailure.failureCategory === state.nodes["frontend-verify-shell"]?.failureCategory &&
1343
+ owner.success) {
1344
+ recoveryOwner = owner.data;
1345
+ }
1346
+ else {
1347
+ recoveryOwner = "unknown";
1348
+ }
1349
+ }
1350
+ catch {
1351
+ // The node's classified category remains the fallback; never turn
1352
+ // unknown/environment/configuration failures into writer work.
1353
+ }
1354
+ if (recoveryOwner === "implementation")
1355
+ recoveryTrigger = {
1356
+ requestId: state.runId,
1357
+ failureSource: "frontend-verify-shell",
1358
+ failureOwner: recoveryOwner,
1359
+ protocolFailureReason: "frontend verification failure",
1360
+ recoveryMode: "continuation",
1361
+ };
1362
+ else
1363
+ state.terminalReason = recoveryOwner === "environment"
1364
+ ? "frontend-verification-wait-external"
1365
+ : `frontend-verification-${recoveryOwner}-requires-contract-review`;
1218
1366
  }
1219
1367
  }
1220
1368
  }
@@ -1295,10 +1443,12 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1295
1443
  * A parent-run atomic lease is acquired before `OperationStore.create`, then
1296
1444
  * its operation id is persisted into that lease before the controller may run.
1297
1445
  * `clientRequestId` remains a useful store-level replay key, but is not a
1298
- * cross-process recovery admission boundary. The continuation DagSpec/reset
1299
- * partition is frozen via `computeFrontendRecoveryPlanForSource` and
1300
- * serialized under the run dir's bounded `.runtime/` namespace. `planHash` is
1301
- * the second idempotency dimension of `actionParams`.
1446
+ * cross-process recovery admission boundary. The reset partition is frozen via
1447
+ * `computeFrontendRecoveryPlanForSource` and serialized under the run dir's
1448
+ * bounded `.runtime/` namespace for audit. The operation invokes
1449
+ * `dag rerun-task`, which regenerates a valid child DagSpec and carries typed
1450
+ * parent feedback through the managed rerun path. `planHash` is the second
1451
+ * idempotency dimension of `actionParams`.
1302
1452
  */
1303
1453
  async function createRecoveryOperation(input) {
1304
1454
  const { cwd, runDir, spec, state, trigger } = input;
@@ -1308,8 +1458,24 @@ async function createRecoveryOperation(input) {
1308
1458
  requestId: trigger.requestId,
1309
1459
  kind: "auto-frontend-recovery",
1310
1460
  });
1461
+ let effectiveLease = recoveryLease;
1462
+ let observeOnly = false;
1311
1463
  if (recoveryLease.kind === "held") {
1312
- return { kind: "lease-held", lease: recoveryLease.lease };
1464
+ if (recoveryLease.lease.kind !== "auto-frontend-recovery" ||
1465
+ recoveryLease.lease.requestId !== trigger.requestId) {
1466
+ return { kind: "lease-held", lease: recoveryLease.lease };
1467
+ }
1468
+ const claimed = await claimDagRecoveryLease({
1469
+ leasePath: recoveryLease.leasePath,
1470
+ lease: recoveryLease.lease,
1471
+ });
1472
+ effectiveLease = claimed;
1473
+ if (claimed.kind === "held") {
1474
+ if (!claimed.lease.executionRef) {
1475
+ return { kind: "lease-held", lease: claimed.lease };
1476
+ }
1477
+ observeOnly = true;
1478
+ }
1313
1479
  }
1314
1480
  const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1315
1481
  const plan = computeFrontendRecoveryPlanForSource({
@@ -1328,9 +1494,10 @@ async function createRecoveryOperation(input) {
1328
1494
  requestId: trigger.requestId,
1329
1495
  failureSource: trigger.failureSource,
1330
1496
  recoveryMode: trigger.recoveryMode,
1497
+ planHash,
1331
1498
  plan,
1332
1499
  });
1333
- const created = await input.deps.store.create({
1500
+ const operationInput = {
1334
1501
  clientRequestId: `dag-recovery-${state.runId}`,
1335
1502
  action: "dagRunTask",
1336
1503
  actionParams: {
@@ -1344,23 +1511,65 @@ async function createRecoveryOperation(input) {
1344
1511
  },
1345
1512
  cliArgs: [
1346
1513
  "dag",
1347
- "execute",
1348
- "--dag",
1349
- continuationPath,
1350
- "--cwd",
1351
- cwd,
1514
+ "rerun-task",
1515
+ "--run-id",
1516
+ state.runId,
1517
+ "--reason",
1518
+ trigger.protocolFailureReason ?? "frontend automatic recovery",
1519
+ "--request-id",
1520
+ trigger.requestId,
1521
+ "--frontend-recovery",
1352
1522
  "--json",
1353
1523
  ],
1354
- });
1355
- if (created.kind !== "conflict") {
1524
+ };
1525
+ let created;
1526
+ if (effectiveLease.lease.executionRef) {
1527
+ // Replay the exact operation bound by the durable lease. A missing record
1528
+ // cannot be replaced with a new operation id without violating the fence.
1529
+ const existing = await input.deps.store.get(effectiveLease.lease.executionRef);
1530
+ if (!existing) {
1531
+ throw new Error(`frontend recovery lease operation not found: ${effectiveLease.lease.executionRef}`);
1532
+ }
1533
+ if (existing.clientRequestId !== operationInput.clientRequestId ||
1534
+ existing.action !== operationInput.action ||
1535
+ existing.payloadHash !==
1536
+ computeActionPayloadHash(operationInput.action, operationInput.actionParams)) {
1537
+ throw new Error("frontend recovery lease execution identity drift: referenced operation payload does not match the recovery request");
1538
+ }
1539
+ created = { kind: "idempotent", operation: existing };
1540
+ }
1541
+ else {
1542
+ created = await input.deps.store.create(operationInput);
1543
+ }
1544
+ if (created.kind !== "conflict" && !observeOnly) {
1356
1545
  // The durable lease must name the operation before controller execution.
1357
- // A crash in this narrow window leaves a safely blocked reservation rather
1358
- // than a second process that can launch another recovery DAG.
1546
+ // This also repairs a lease left behind by a crash before the original bind.
1359
1547
  await bindDagRecoveryLeaseExecution({
1360
- leasePath: recoveryLease.leasePath,
1361
- lease: recoveryLease.lease,
1548
+ leasePath: effectiveLease.leasePath,
1549
+ lease: effectiveLease.lease,
1362
1550
  executionRef: created.operation.operationId,
1363
1551
  });
1552
+ // Repair CLI args on both fresh and replayed operations. This closes the
1553
+ // crash window after operation creation but before args were persisted.
1554
+ await input.deps.store.update(created.operation.operationId, {
1555
+ cliArgs: [
1556
+ "dag",
1557
+ "rerun-task",
1558
+ "--run-id",
1559
+ state.runId,
1560
+ "--reason",
1561
+ trigger.protocolFailureReason ?? "frontend automatic recovery",
1562
+ "--request-id",
1563
+ trigger.requestId,
1564
+ "--frontend-recovery",
1565
+ "--recovery-operation-id",
1566
+ created.operation.operationId,
1567
+ "--json",
1568
+ ],
1569
+ });
1570
+ }
1571
+ if (created.kind !== "conflict" && observeOnly) {
1572
+ return { kind: "observed", operation: created.operation };
1364
1573
  }
1365
1574
  return created;
1366
1575
  }
@@ -4,7 +4,7 @@ import { isPauseOnHumanDecisionGate } from "./decision-envelope.js";
4
4
  import { evaluateConditionExpression } from "./dynamic-runtime/condition.js";
5
5
  import { sha256Hex } from "./frontend-implementation-contract.js";
6
6
  import { boundaryForTargetNode, computeFrontendShapeNodeDigest, evaluateFrontendShapeBoundary, } from "./frontend-shape-facts.js";
7
- import { FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT, frontendWriterAdmissionResultV1Schema, } from "./frontend-writer-admission.js";
7
+ import { FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT, frontendWriterAdmissionResultV1Schema, normalizeConcreteWriteSetPath, } from "./frontend-writer-admission.js";
8
8
  import { FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH, FRONTEND_RECOVERY_INTENT_REL_DIR, } from "./frontend-recovery-plan.js";
9
9
  export function isConditionSkippedReason(reason) {
10
10
  return Boolean(reason?.startsWith("condition "));
@@ -174,10 +174,7 @@ export async function readFrontendWriterAdmissionResult(runDir) {
174
174
  /** Authorize only the `accepted` classification (four-state admission).
175
175
  * `requires-human-approval`, `blocked`, and `stale` are all denied. */
176
176
  export function isFrontendWriterAuthorized(result) {
177
- return result.classification === "accepted" ||
178
- result.classification === "admitted"
179
- ? "authorized"
180
- : "denied";
177
+ return result.classification === "accepted" ? "authorized" : "denied";
181
178
  }
182
179
  /** Fail-closed: leave no PENDING nodes that look "still scheduled" after abort. */
183
180
  export function markPendingNodesControllerInterrupted(state, reason = "run aborted by controller (abortSignal)") {
@@ -417,6 +414,12 @@ export async function executeDagRanksOnce(input) {
417
414
  continue;
418
415
  }
419
416
  const decision = isFrontendWriterAuthorized(admission.result);
417
+ const normalizedWriteSet = admission.result.writeSet
418
+ .map(normalizeConcreteWriteSetPath)
419
+ .filter((entry) => entry !== undefined);
420
+ const concreteWriteSetValid = (admission.result.writeSet.length > 0 &&
421
+ normalizedWriteSet.length === admission.result.writeSet.length &&
422
+ new Set(normalizedWriteSet).size === normalizedWriteSet.length);
420
423
  node.frontendWriterAdmission = {
421
424
  schemaVersion: 1,
422
425
  writerNodeId: id,
@@ -428,12 +431,24 @@ export async function executeDagRanksOnce(input) {
428
431
  ? `classification: ${admission.result.classification}`
429
432
  : null,
430
433
  };
431
- if (decision === "denied") {
434
+ if (decision === "denied" || !concreteWriteSetValid) {
435
+ node.frontendWriterAdmission.reason = decision === "denied"
436
+ ? `classification: ${admission.result.classification}`
437
+ : "admission writeSet is not a concrete unique path set";
432
438
  node.status = "SKIPPED";
433
439
  node.skippedReason = "frontend-prewrite-not-authorized";
434
440
  node.finishedAt = checkedAt;
435
441
  frontendAdmissionSettled.push(id);
436
442
  }
443
+ else {
444
+ node.runtimeWriteAuthorization = {
445
+ schemaVersion: 1,
446
+ status: "validated",
447
+ approvalSourceNodeId: FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT,
448
+ approvalDigest: admission.result.admissionDigest,
449
+ effectiveWriteSet: normalizedWriteSet,
450
+ };
451
+ }
437
452
  }
438
453
  if (frontendAdmissionSettled.length > 0)
439
454
  await input.persistState();
@@ -112,7 +112,10 @@ export function isStructuredRepairableFailureCategory(category) {
112
112
  return (category === "invalid-output" ||
113
113
  category === "structured-output-truncated" ||
114
114
  category === "output-too-large" ||
115
- category === "empty-output");
115
+ category === "empty-output" ||
116
+ // A length-truncated structured turn with a non-empty candidate artifact
117
+ // still repairs best through the frozen artifact repair prompt.
118
+ category === "output-limit");
116
119
  }
117
120
  export function formatStructuredArtifactPointer(input) {
118
121
  return [
@@ -1548,7 +1548,8 @@ export const frontendRecoveryPhaseSchema = z.enum([
1548
1548
  * the child carries a frozen lineage snapshot so the activation gate can prove
1549
1549
  * it is the reserved child of a child-running parent.
1550
1550
  *
1551
- * Invariants: `attemptIndex ∈ {0,1}`; `continuationCount ∈ {0,1}`; a child's
1551
+ * Invariants: `attemptIndex` and `continuationCount` are bounded by the frozen
1552
+ * frontend recovery quota (currently 1-5); a child's
1552
1553
  * `recoveryRootRunId` always equals the root runId; `revision` is the CAS
1553
1554
  * pre-comparison counter. `revision` monotonicity is enforced at runtime by the
1554
1555
  * runner's `(existing?.revision ?? 0) + 1` CAS write (the schema only bounds it
@@ -1567,8 +1568,8 @@ export const frontendRecoveryStateSchema = z
1567
1568
  parentRunId: z.string().min(1),
1568
1569
  childRunId: z.string().min(1).optional(),
1569
1570
  attemptId: z.string().min(1),
1570
- attemptIndex: z.number().int().min(0).max(1),
1571
- continuationCount: z.number().int().min(0).max(1),
1571
+ attemptIndex: z.number().int().min(0).max(5),
1572
+ continuationCount: z.number().int().min(0).max(5),
1572
1573
  /** Node id of the reset-closure root: frontend-plan-pi /
1573
1574
  * frontend-plan-revision-pi / frontend-prewrite-gate-shell /
1574
1575
  * frontend-implement-pi (writer partial write). */
@@ -3,7 +3,7 @@ import { resolveShellCommands } from "../../executors/shell-executor.js";
3
3
  import { pathMatchesPattern } from "../../shared/git-progress.js";
4
4
  import { resolveRepairTaskForGate } from "./repair-artifact.js";
5
5
  import { topoSortToRanks } from "./topo.js";
6
- import { isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, CONTEXT_OVERFLOW_RETRY_CATEGORY, } from "./retry-policy.js";
6
+ import { isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, OUTPUT_LIMIT_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, CONTEXT_OVERFLOW_RETRY_CATEGORY, } from "./retry-policy.js";
7
7
  import { isBackendTestCompletenessRetryCandidate } from "./backend-test-writer-completeness.js";
8
8
  import { defaultFrontendTestLayout, frontendTestLayoutFromSpec, } from "./frontend-test-layout.js";
9
9
  const GOVERNANCE_WARNING_TYPES = new Set([
@@ -630,27 +630,28 @@ function validateRetryPolicyTaskConfig(task, issues) {
630
630
  const allowed = new Set([
631
631
  WRITER_EMPTY_DIFF_RETRY_CATEGORY,
632
632
  INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
633
+ OUTPUT_LIMIT_RETRY_CATEGORY,
633
634
  ]);
634
635
  const unsupported = task.retryPolicy.retryCategories.filter((category) => !allowed.has(category));
635
636
  if (task.retryPolicy.maxAttempts < 2 ||
636
- task.retryPolicy.maxAttempts > 3 ||
637
+ task.retryPolicy.maxAttempts > 5 ||
637
638
  unsupported.length > 0 ||
638
639
  !categories.has(WRITER_EMPTY_DIFF_RETRY_CATEGORY)) {
639
640
  issues.push({
640
641
  type: "invalid-retry-policy",
641
- message: `task ${task.id} backend-test writer retryPolicy must use 2..3 total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY} and/or ${INCOMPLETE_WRITE_SET_RETRY_CATEGORY}`,
642
+ message: `task ${task.id} backend-test writer retryPolicy must use 2..5 total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}, ${INCOMPLETE_WRITE_SET_RETRY_CATEGORY}, and/or ${OUTPUT_LIMIT_RETRY_CATEGORY}`,
642
643
  });
643
644
  }
644
645
  return;
645
646
  }
646
647
  if (isWriterEmptyDiffCandidate &&
647
648
  (task.retryPolicy.maxAttempts !== 2 ||
648
- task.retryPolicy.retryCategories.length !== 1 ||
649
- task.retryPolicy.retryCategories[0] !==
650
- WRITER_EMPTY_DIFF_RETRY_CATEGORY)) {
649
+ !task.retryPolicy.retryCategories.includes(WRITER_EMPTY_DIFF_RETRY_CATEGORY) ||
650
+ task.retryPolicy.retryCategories.some((category) => category !== WRITER_EMPTY_DIFF_RETRY_CATEGORY &&
651
+ category !== OUTPUT_LIMIT_RETRY_CATEGORY))) {
651
652
  issues.push({
652
653
  type: "invalid-retry-policy",
653
- message: `task ${task.id} writer retryPolicy must use exactly two total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}`,
654
+ message: `task ${task.id} writer retryPolicy must use exactly two total attempts and ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}, with optional ${OUTPUT_LIMIT_RETRY_CATEGORY}`,
654
655
  });
655
656
  return;
656
657
  }
@@ -659,6 +660,7 @@ function validateRetryPolicyTaskConfig(task, issues) {
659
660
  const allowedWriterTransportCategories = new Set([
660
661
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
661
662
  CONTEXT_OVERFLOW_RETRY_CATEGORY,
663
+ OUTPUT_LIMIT_RETRY_CATEGORY,
662
664
  ]);
663
665
  const allAllowed = [...categories].every((category) => allowedWriterTransportCategories.has(category));
664
666
  if (task.retryPolicy.maxAttempts !== 2 ||
@@ -667,7 +669,7 @@ function validateRetryPolicyTaskConfig(task, issues) {
667
669
  !categories.has(WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY)) {
668
670
  issues.push({
669
671
  type: "invalid-retry-policy",
670
- message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only writer-clean-timeout (plus optional context-overflow)`,
672
+ message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only writer-clean-timeout (plus optional context-overflow/output-limit)`,
671
673
  });
672
674
  }
673
675
  }
@@ -184,3 +184,69 @@ export function workspaceFingerprintsMatch(left, right) {
184
184
  return false;
185
185
  return left.fingerprint === right.fingerprint;
186
186
  }
187
+ /**
188
+ * Path-level drift between two checkpoints: paths whose content hash,
189
+ * porcelain state, or presence differs (changed paths plus stable Task
190
+ * Contract governance inputs). The whole-tree fingerprint also moves on HEAD
191
+ * commits or porcelain churn that leaves every path's content untouched;
192
+ * only content-level path diffs are actionable for scoped rerun eligibility.
193
+ * Returns undefined when either checkpoint lacks a path inventory so callers
194
+ * fail closed to the strict whole-fingerprint comparison.
195
+ */
196
+ export function workspaceDriftedPaths(parent, current) {
197
+ // changedPaths is only the porcelain inventory; it cannot explain files
198
+ // already committed between two different HEADs. Never localize that drift.
199
+ if (!parent.gitHeadSha ||
200
+ !current.gitHeadSha ||
201
+ parent.gitHeadSha !== current.gitHeadSha) {
202
+ return undefined;
203
+ }
204
+ if (!Array.isArray(parent.changedPaths) || !Array.isArray(current.changedPaths)) {
205
+ return undefined;
206
+ }
207
+ // Porcelain v1 records only the rename destination in this checkpoint
208
+ // schema, so the removed source path cannot be proven safe for imported
209
+ // readers. Keep scoped reuse fail-closed until both sides expose complete
210
+ // rename lineage.
211
+ if (parent.changedPaths.some((entry) => entry.state === "renamed") ||
212
+ current.changedPaths.some((entry) => entry.state === "renamed")) {
213
+ return undefined;
214
+ }
215
+ if ((parent.stableGovernanceInputs === undefined) !==
216
+ (current.stableGovernanceInputs === undefined)) {
217
+ return undefined;
218
+ }
219
+ const drifted = new Set();
220
+ const parentByPath = new Map(parent.changedPaths.map((entry) => [entry.path, entry]));
221
+ const currentByPath = new Map(current.changedPaths.map((entry) => [entry.path, entry]));
222
+ for (const [pathKey, parentEntry] of parentByPath) {
223
+ const currentEntry = currentByPath.get(pathKey);
224
+ if (!currentEntry) {
225
+ drifted.add(pathKey);
226
+ continue;
227
+ }
228
+ if (parentEntry.contentSha256 !== currentEntry.contentSha256 ||
229
+ parentEntry.state !== currentEntry.state ||
230
+ parentEntry.mode !== currentEntry.mode) {
231
+ drifted.add(pathKey);
232
+ }
233
+ }
234
+ for (const pathKey of currentByPath.keys()) {
235
+ if (!parentByPath.has(pathKey))
236
+ drifted.add(pathKey);
237
+ }
238
+ if (parent.stableGovernanceInputs && current.stableGovernanceInputs) {
239
+ const parentGov = new Map(parent.stableGovernanceInputs.map((entry) => [entry.path, entry.contentSha256]));
240
+ const currentGov = new Map(current.stableGovernanceInputs.map((entry) => [entry.path, entry.contentSha256]));
241
+ for (const [pathKey, hash] of parentGov) {
242
+ const other = currentGov.get(pathKey);
243
+ if (other === undefined || other !== hash)
244
+ drifted.add(pathKey);
245
+ }
246
+ for (const [pathKey, hash] of currentGov) {
247
+ if (parentGov.get(pathKey) !== hash)
248
+ drifted.add(pathKey);
249
+ }
250
+ }
251
+ return [...drifted];
252
+ }
@@ -11,5 +11,6 @@
11
11
  - [`production-readiness.md`](production-readiness.md):交付前的生产就绪判断。
12
12
  - [`agent-worker-production-readiness.md`](agent-worker-production-readiness.md):agent-worker Feature/Task Pool 外层编排的 Production Readiness v1 操作合同。
13
13
  - [`backend-test-jacoco-coverage.md`](backend-test-jacoco-coverage.md):Java 被测服务挂载 JaCoCo tcpserver agent 的 Maven、`java -jar` 与 Docker 操作手册。
14
+ - [`console-chat-dogfood-playbook.md`](console-chat-dogfood-playbook.md):Operator Chat(含 Explore 子 Agent)真实模型 dogfood 的 CLI 驱动步骤、mutation 双 token / SSE 超时 / question 应答等已知陷阱与验证方式。
14
15
 
15
16
  验证命令仍以 [`../governance/verification-matrix.md`](../governance/verification-matrix.md) 为准。
@@ -466,9 +466,9 @@
466
466
  "retryCategories": {
467
467
  "type": "array",
468
468
  "items": {
469
- "enum": ["timeout", "network", "rate-limit", "unavailable", "empty-output", "output-too-large", "protocol-invalid", "invalid-output", "writer-empty-diff", "incomplete-write-set", "writer-clean-timeout", "read-burst", "context-overflow"]
469
+ "enum": ["timeout", "network", "rate-limit", "unavailable", "empty-output", "output-limit", "output-too-large", "protocol-invalid", "invalid-output", "writer-empty-diff", "incomplete-write-set", "writer-clean-timeout", "read-burst", "context-overflow"]
470
470
  },
471
- "default": ["timeout", "network", "rate-limit", "unavailable", "empty-output"],
471
+ "default": ["timeout", "network", "rate-limit", "unavailable", "empty-output", "output-limit"],
472
472
  "description": "Failure categories eligible for retry. quota/auth/write-guard are never eligible. invalid-output is allowed only on explicit structured/protocol policies; writer-empty-diff and incomplete-write-set are reserved for requireChangedFiles generation/completeness writers; writer-clean-timeout is reserved for standard bounded writers after explicit zero-write-tool-call and empty-diff evidence."
473
473
  }
474
474
  }