@tea-agent/loop-agent 0.42.0-next.14 → 0.42.0-next.16

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 (113) hide show
  1. package/CHANGELOG.md +19 -3
  2. package/dist/application/task-lifecycle/advance.js +9 -4
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/task-source-prepare.js +3 -1
  5. package/dist/executors/dag-pi-executor.js +1437 -154
  6. package/dist/executors/shell-executor.js +107 -35
  7. package/dist/shared/dag-failure-category.js +6 -0
  8. package/dist/task/contract/apply.js +36 -2
  9. package/dist/task/source-prepare/parse-intent.js +7 -0
  10. package/dist/worker/console/chat/pi-runtime.js +45 -2
  11. package/dist/worker/console/chat/resource-loader.js +4 -1
  12. package/dist/worker/console/chat/routes.js +8 -0
  13. package/dist/worker/console/chat/subagents/agent-tool.js +62 -0
  14. package/dist/worker/console/chat/subagents/explore-agent.js +169 -0
  15. package/dist/worker/console/chat/subagents/index.js +5 -0
  16. package/dist/worker/console/chat/subagents/orchestrator.js +273 -0
  17. package/dist/worker/console/chat/subagents/tool-policy.js +53 -0
  18. package/dist/worker/console/chat/subagents/types.js +13 -0
  19. package/dist/worker/console/chat/tool-preview.js +10 -0
  20. package/dist/worker/console/chat/tools.js +11 -1
  21. package/dist/worker/console/interview/tools.js +1 -0
  22. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BGMbAdAG.js → abnfDiagram-N423BO3Z-CgXb0EVO.js} +1 -1
  23. package/dist/worker/console/static/assets/{arc-CJu1LND8.js → arc-DN59MZqN.js} +1 -1
  24. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BmJh9gMQ.js → architectureDiagram-T3A2C74G-BUk3sWpn.js} +1 -1
  25. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BWjaX3ZF.js → blockDiagram-VBNYF7ZC-IH-cPBFE.js} +1 -1
  26. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-CLEOAxLB.js → c4Diagram-5PPSVZJV-BJWf1nGo.js} +1 -1
  27. package/dist/worker/console/static/assets/channel-i1DjpDIw.js +1 -0
  28. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CydnyQUX.js → chunk-2GRJ4B5K-BCPb5H0y.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-tjJ1IoZ1.js → chunk-2Q5K7J3B-Cfs3jeW2.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DR358Phd.js → chunk-5RXB4S5H-BhQI1B_T.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-B-LuWFZD.js → chunk-5VM5RSS4-Cc_m4gyV.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-FG53fA6v.js → chunk-6Q2QTUOP-JPqDS3IV.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bkt-w8SZ.js → chunk-GF5L2VYU-AeGX6EAg.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-WxHo0z2I.js → chunk-JWPE2WC7-pEyTjekV.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CqHrBJhp.js → chunk-KBJHAD2P-Bi5UopKd.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-D4MVae_f.js → chunk-RYQCIY6F-FkGFJxgQ.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CamBh0Ll.js → chunk-XXDRQBXY-D-7LGrV2.js} +1 -1
  38. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgDRdE1V.js +1 -0
  39. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgDRdE1V.js +1 -0
  40. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C3qheCuW.js → cose-bilkent-JH36ORCC-CCEDNDaX.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DQlWfkS7.js → cynefin-VYW2F7L2-DDd-t7fm.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BAxa9rFb.js → cynefinDiagram-MW4NZA55-LWe4Ikzr.js} +1 -1
  43. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-YM09nLJw.js → dagre-VZM6K2ZE-BBCZScc-.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-I4W9EJj2.js → diagram-7IWD3JNH-B6liRVig.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DH8eJ8Ff.js → diagram-B4RE2ZJO-DO_hIrpW.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-D0D0ntDW.js → diagram-LBJQPF4R-dmCy93uR.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BIISKq1s.js → diagram-Q27KOJAE-D6mFTIxP.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-UB23O5K3--xyAWj0w.js → diagram-UB23O5K3-DCteVUfY.js} +1 -1
  49. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B3sYXLJ4.js → ebnfDiagram-BXEA7PRR-DTv530VK.js} +1 -1
  50. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-zRSYWhJP.js → erDiagram-JOGREHBK-DCXDMxCr.js} +1 -1
  51. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BUYlie03.js → flowDiagram-UKHOOZJN-BwEBLOJh.js} +1 -1
  52. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DP4kzzrM.js → ganttDiagram-PKOTCBZU-Ck-Vymjm.js} +1 -1
  53. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CAPoUxTF.js → gitGraphDiagram-DS77QQ5N-Hqs6X_3L.js} +1 -1
  54. package/dist/worker/console/static/assets/{index-CWhTyxvc.js → index-BmMi-Bve.js} +71 -71
  55. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BXPfjekV.js → infoDiagram-6WML65LV-Cjc6M9eg.js} +1 -1
  56. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-aOI3pV6t.js → ishikawaDiagram-WSZJBQD7-CPchYZMl.js} +1 -1
  57. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DAPZuvi4.js → journeyDiagram-NVQOT4AX-BABdJNNC.js} +1 -1
  58. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-atwDzL3C.js → kanban-definition-27J2QSJJ-1oXhbM4j.js} +1 -1
  59. package/dist/worker/console/static/assets/{linear-D5E09qji.js → linear-PTmQ9LkV.js} +1 -1
  60. package/dist/worker/console/static/assets/{mermaid.core-CE4idY-s.js → mermaid.core-B6Lxil0V.js} +5 -5
  61. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CbZxtPca.js → mindmap-definition-FAOFIHXS-BUqjNGEw.js} +1 -1
  62. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-De-FYEte.js → pegDiagram-VL7TDLO6-8D1N-orJ.js} +1 -1
  63. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Bs0USobz.js → pieDiagram-7S7Q4E2Y-BlPMN9d9.js} +1 -1
  64. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bq3ZrGCJ.js → quadrantDiagram-CIZ2JOQS-BIL5YDes.js} +1 -1
  65. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bk8ojyIj.js → railroadDiagram-AXF67PYL-Dqmil3ie.js} +1 -1
  66. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CGhoyws1.js → requirementDiagram-LRYGKXZP-C_-Dp4mH.js} +1 -1
  67. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DmVSQs3b.js → sankeyDiagram-W5VNT64P-C0s5-bQS.js} +1 -1
  68. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C80h1u0Q.js → sequenceDiagram-SI44F4Z6-DiVCHPfi.js} +1 -1
  69. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CcbksQVZ.js → sizeCapture-X5ZJPWSS-CxQ9WVvx.js} +1 -1
  70. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CFTTTQKj.js → stateDiagram-OKZ733FA-D9mFHA-Y.js} +1 -1
  71. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqXQxfFC.js +1 -0
  72. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Cw7Jr7Tg.js → swimlanes-SLNWSIFB-DDnC5l-M.js} +2 -2
  73. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CXwWNRJl.js +8 -0
  74. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CVcdTVa5.js → timeline-definition-Z64GVDOM-C8QySPC-.js} +1 -1
  75. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BpLbmWp9.js → vennDiagram-T6HMQDX7-BeYdDiz6.js} +1 -1
  76. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-6PXfbwj-.js → wardleyDiagram-T6FBY63Y-D8hjFWLH.js} +1 -1
  77. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Bjkcz4ih.js → xychartDiagram-ELKLHX3M-ClqG_JGK.js} +1 -1
  78. package/dist/worker/console/static/index.html +1 -1
  79. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +21 -2
  80. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  81. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  82. package/dist/workflows/dag/frontend-design-policy.js +100 -15
  83. package/dist/workflows/dag/frontend-implementation-contract.js +56 -8
  84. package/dist/workflows/dag/frontend-plan-render.js +13 -2
  85. package/dist/workflows/dag/frontend-risk.js +2 -0
  86. package/dist/workflows/dag/frontend-shadow-dual-write.js +17 -1
  87. package/dist/workflows/dag/frontend-shape.js +16 -6
  88. package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
  89. package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
  90. package/dist/workflows/dag/frontend-verification-trace.js +32 -0
  91. package/dist/workflows/dag/init-hybrid.js +16 -13
  92. package/dist/workflows/dag/node-execution.js +178 -88
  93. package/dist/workflows/dag/rerun-feedback.js +1 -0
  94. package/dist/workflows/dag/rerun-plan.js +90 -4
  95. package/dist/workflows/dag/rerun-run.js +7 -0
  96. package/dist/workflows/dag/retry-policy.js +16 -10
  97. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  98. package/dist/workflows/dag/runner.js +67 -7
  99. package/dist/workflows/dag/structured-output-repair.js +4 -1
  100. package/dist/workflows/dag/validate.js +10 -8
  101. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  102. package/docs/operations/README.md +1 -0
  103. package/docs/templates/agent-dag.schema.json +2 -2
  104. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  105. package/package.json +2 -3
  106. package/skills/frontend-plan/SKILL.md +14 -1
  107. package/skills/frontend-plan/references/decision-contract.md +84 -10
  108. package/skills/frontend-plan/references/design-decisions.md +32 -0
  109. package/dist/worker/console/static/assets/channel-DOsaUk1k.js +0 -1
  110. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D4-D49vY.js +0 -1
  111. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D4-D49vY.js +0 -1
  112. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C4YCesmi.js +0 -1
  113. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-lN2U_DLl.js +0 -8
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { z } from "zod";
4
4
  import { assertTaskContractBindingFresh } from "./task-contract-binding.js";
5
+ import { workspaceDriftedPaths, } from "./workspace-checkpoint.js";
5
6
  import { isSafeReadOnlyPiRetryCandidate, isTransportCrashedExclusiveWriter } from "./retry-policy.js";
6
7
  import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
7
8
  import { composeDagPromptOverridePrompt, MAX_DAG_RERUN_PROMPT_OVERRIDE_CHARS, } from "../../shared/dag-prompt-override.js";
@@ -1110,6 +1111,46 @@ function deriveSuggestedAction(input) {
1110
1111
  }
1111
1112
  return "manual";
1112
1113
  }
1114
+ /**
1115
+ * Gitignore-style-lite matcher for readSet/writeSet path patterns in scoped
1116
+ * workspace-drift evaluation: literal paths match exactly; `**` spans path
1117
+ * segments (a trailing `/**` also matches when nothing follows), `*` stays
1118
+ * within one segment, `?` is one non-separator char.
1119
+ */
1120
+ function pathMatchesWorkspacePattern(pathValue, pattern) {
1121
+ const normalizedPath = pathValue.replace(/\\/g, "/").replace(/^\.\//, "");
1122
+ const trimmed = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "");
1123
+ if (!trimmed || trimmed === "/")
1124
+ return false;
1125
+ if (trimmed === normalizedPath)
1126
+ return true;
1127
+ let source = "";
1128
+ for (let index = 0; index < trimmed.length; index += 1) {
1129
+ const char = trimmed[index];
1130
+ if (char === "*") {
1131
+ if (trimmed[index + 1] === "*") {
1132
+ if (trimmed[index + 2] === "/") {
1133
+ source += "(?:.*/)?";
1134
+ index += 2;
1135
+ }
1136
+ else {
1137
+ source += ".*";
1138
+ index += 1;
1139
+ }
1140
+ }
1141
+ else {
1142
+ source += "[^/]*";
1143
+ }
1144
+ }
1145
+ else if (char === "?") {
1146
+ source += "[^/]";
1147
+ }
1148
+ else {
1149
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1150
+ }
1151
+ }
1152
+ return new RegExp(`^${source}$`).test(normalizedPath);
1153
+ }
1113
1154
  function deriveRisk(reasonCodes, resetCount) {
1114
1155
  if (reasonCodes.includes("restart-subgraph-contains-writer") ||
1115
1156
  reasonCodes.includes("restart-subgraph-contains-unsafe-shell") ||
@@ -1213,7 +1254,14 @@ export async function evaluateDagRerunPlan(input) {
1213
1254
  blockedReasons.push("parent-lifecycle-ineligible");
1214
1255
  }
1215
1256
  else if (input.parentState.status !== "failed" &&
1216
- input.parentState.status !== "partial_failed") {
1257
+ input.parentState.status !== "partial_failed" &&
1258
+ // A superseded parent is a terminal operator judgment about the run as
1259
+ // a whole, not about its deterministic read-only compute: its FINISHED
1260
+ // upstream nodes are still valid fact donors for the imported set, and
1261
+ // every unsafe case (writers in the reset subgraph, decision gates,
1262
+ // stale checkpoints, unresolved ERROR nodes outside the closure) keeps
1263
+ // its own dedicated block below.
1264
+ input.parentState.status !== "superseded") {
1217
1265
  reasonCodes.push("parent-lifecycle-ineligible");
1218
1266
  blockedReasons.push("parent-lifecycle-ineligible");
1219
1267
  }
@@ -1241,8 +1289,36 @@ export async function evaluateDagRerunPlan(input) {
1241
1289
  else if (input.currentWorkspace?.fingerprint &&
1242
1290
  input.parentTerminalWorkspace.fingerprint !==
1243
1291
  input.currentWorkspace.fingerprint) {
1244
- reasonCodes.push("workspace-drift");
1245
- blockedReasons.push("workspace-drift");
1292
+ // Whole-fingerprint drift is refined to content-level paths: the rerun
1293
+ // re-derives (readSet) and rewrites (writeSet) the reset subgraph's own
1294
+ // paths, so drift confined to those paths is recomputed by the rerun
1295
+ // itself and must not force a full standalone rerun. Drift anywhere
1296
+ // else — especially inputs of imported fact-reuse nodes — keeps the
1297
+ // strict block. Indeterminate checkpoints fail closed.
1298
+ const driftedPaths = workspaceDriftedPaths(input.parentTerminalWorkspace, input.currentWorkspace);
1299
+ const importedReadPaths = importedNodeIds.flatMap((nodeId) => {
1300
+ const task = tasksByIdForPlan.get(nodeId);
1301
+ return task?.readSet ?? [];
1302
+ });
1303
+ const scopedDrift = driftedPaths !== undefined &&
1304
+ driftedPaths.length > 0 &&
1305
+ driftedPaths.every((driftPath) => !importedReadPaths.some((pattern) => pathMatchesWorkspacePattern(driftPath, pattern)) &&
1306
+ resetNodeIds.some((nodeId) => {
1307
+ const task = tasksByIdForPlan.get(nodeId);
1308
+ if (!task)
1309
+ return false;
1310
+ return [
1311
+ ...(task.writeSet ?? []),
1312
+ ...(task.readSet ?? []),
1313
+ ].some((pattern) => pathMatchesWorkspacePattern(driftPath, pattern));
1314
+ }));
1315
+ if (scopedDrift) {
1316
+ reasonCodes.push("workspace-drift-scoped");
1317
+ }
1318
+ else {
1319
+ reasonCodes.push("workspace-drift");
1320
+ blockedReasons.push("workspace-drift");
1321
+ }
1246
1322
  }
1247
1323
  else if (!input.currentWorkspace?.fingerprint) {
1248
1324
  reasonCodes.push("workspace-checkpoint-missing");
@@ -1293,8 +1369,18 @@ export async function evaluateDagRerunPlan(input) {
1293
1369
  }
1294
1370
  for (const nodeId of importedNodeIds) {
1295
1371
  const record = input.parentState.nodes[nodeId];
1296
- if (!record)
1372
+ if (!record) {
1373
+ reasonCodes.push("parent-facts-invalid");
1374
+ blockedReasons.push("parent-facts-invalid");
1375
+ blockingNodes.push({ nodeId, reasonCode: "parent-facts-invalid" });
1297
1376
  continue;
1377
+ }
1378
+ if (record.status === "PENDING" || record.status === "RUNNING") {
1379
+ reasonCodes.push("parent-facts-invalid");
1380
+ blockedReasons.push("parent-facts-invalid");
1381
+ blockingNodes.push({ nodeId, reasonCode: "parent-facts-invalid" });
1382
+ continue;
1383
+ }
1298
1384
  if (record.status === "ERROR") {
1299
1385
  reasonCodes.push("parent-facts-invalid");
1300
1386
  blockedReasons.push("parent-facts-invalid");
@@ -784,6 +784,13 @@ async function stageContinuationRun(input) {
784
784
  if (!parentRecord) {
785
785
  throw new Error(`parent node record missing for imported node: ${nodeId}`);
786
786
  }
787
+ if (parentRecord.status !== "FINISHED" &&
788
+ !(parentRecord.status === "SKIPPED" &&
789
+ (parentRecord.skippedReason?.includes("condition") ||
790
+ parentRecord.skippedReason?.includes("runIf") ||
791
+ parentRecord.skippedReason?.includes("run-if")))) {
792
+ throw new Error(`parent node is not a settled fact donor for imported node: ${nodeId}`);
793
+ }
787
794
  const { manifestNode, importedRecord } = await importNodeFacts({
788
795
  parentRunDir: input.parentRunDir,
789
796
  newRunDir: runDir,
@@ -7,8 +7,8 @@ import { TYPED_EVENT_FACT_KINDS } from "./frontend-typed-event-store.js";
7
7
  * is re-exported here unchanged so retry-policy stays the single import
8
8
  * surface for retry policies and helpers.
9
9
  */
10
- export { ALL_DAG_RETRY_CATEGORIES, CONTEXT_OVERFLOW_RETRY_CATEGORY, DEFAULT_DAG_RETRY_CATEGORIES, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, PROTOCOL_AWARE_DAG_RETRY_CATEGORIES, PROTOCOL_INVALID_RETRY_CATEGORY, READ_BURST_RETRY_CATEGORY, REVIEW_TERMINAL_MISSING_RETRY_CATEGORY, STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY, STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, dagRetryBackoffSchema, dagRetryCategorySchema, dagRetryPolicySchema, } from "./dag-retry-schema.js";
11
- import { CONTEXT_OVERFLOW_RETRY_CATEGORY, DEFAULT_DAG_RETRY_CATEGORIES, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, PROTOCOL_AWARE_DAG_RETRY_CATEGORIES, READ_BURST_RETRY_CATEGORY, REVIEW_TERMINAL_MISSING_RETRY_CATEGORY, STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "./dag-retry-schema.js";
10
+ export { ALL_DAG_RETRY_CATEGORIES, CONTEXT_OVERFLOW_RETRY_CATEGORY, DEFAULT_DAG_RETRY_CATEGORIES, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, OUTPUT_LIMIT_RETRY_CATEGORY, PROTOCOL_AWARE_DAG_RETRY_CATEGORIES, PROTOCOL_INVALID_RETRY_CATEGORY, READ_BURST_RETRY_CATEGORY, REVIEW_TERMINAL_MISSING_RETRY_CATEGORY, STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY, STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, dagRetryBackoffSchema, dagRetryCategorySchema, dagRetryPolicySchema, } from "./dag-retry-schema.js";
11
+ import { CONTEXT_OVERFLOW_RETRY_CATEGORY, DEFAULT_DAG_RETRY_CATEGORIES, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, OUTPUT_LIMIT_RETRY_CATEGORY, PROTOCOL_AWARE_DAG_RETRY_CATEGORIES, READ_BURST_RETRY_CATEGORY, REVIEW_TERMINAL_MISSING_RETRY_CATEGORY, STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "./dag-retry-schema.js";
12
12
  const RETRY_SAFE_PI_ROLES = new Set([
13
13
  "planner",
14
14
  "scout",
@@ -19,7 +19,7 @@ const RETRY_SAFE_PI_ROLES = new Set([
19
19
  ]);
20
20
  /**
21
21
  * The default retry policy applied to safe generated read-only Pi nodes.
22
- * Total attempts: 3, exponential backoff with cap.
22
+ * Total attempts: 5, exponential backoff with cap.
23
23
  *
24
24
  * Includes `context-overflow`: a read-only node that blew the context window
25
25
  * (400 request too large, e.g. a review node accumulating too many reads) is a
@@ -29,7 +29,7 @@ const RETRY_SAFE_PI_ROLES = new Set([
29
29
  * design-review/review fell straight to ERROR without a retry attempt.
30
30
  */
31
31
  export const DEFAULT_READ_ONLY_PI_RETRY_POLICY = {
32
- maxAttempts: 3,
32
+ maxAttempts: 5,
33
33
  backoff: "exponential",
34
34
  initialDelayMs: 2000,
35
35
  maxDelayMs: 30000,
@@ -125,7 +125,10 @@ export const WRITER_EMPTY_DIFF_RETRY_POLICY = {
125
125
  backoff: "exponential",
126
126
  initialDelayMs: 2000,
127
127
  maxDelayMs: 30000,
128
- retryCategories: [WRITER_EMPTY_DIFF_RETRY_CATEGORY],
128
+ retryCategories: [
129
+ WRITER_EMPTY_DIFF_RETRY_CATEGORY,
130
+ OUTPUT_LIMIT_RETRY_CATEGORY,
131
+ ],
129
132
  };
130
133
  /**
131
134
  * Bounded transport retry for standard exclusive implementers when a provider
@@ -140,6 +143,7 @@ export const WRITER_TRANSPORT_RETRY_POLICY = {
140
143
  retryCategories: [
141
144
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
142
145
  CONTEXT_OVERFLOW_RETRY_CATEGORY,
146
+ OUTPUT_LIMIT_RETRY_CATEGORY,
143
147
  ],
144
148
  };
145
149
  /**
@@ -175,6 +179,7 @@ export const TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY = {
175
179
  retryCategories: [
176
180
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
177
181
  CONTEXT_OVERFLOW_RETRY_CATEGORY,
182
+ OUTPUT_LIMIT_RETRY_CATEGORY,
178
183
  ],
179
184
  };
180
185
  /**
@@ -182,13 +187,14 @@ export const TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY = {
182
187
  * recovery attempts driven by Completeness Gate (missing/broken target files).
183
188
  */
184
189
  export const BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY = {
185
- maxAttempts: 3,
190
+ maxAttempts: 5,
186
191
  backoff: "exponential",
187
192
  initialDelayMs: 2000,
188
193
  maxDelayMs: 30000,
189
194
  retryCategories: [
190
195
  WRITER_EMPTY_DIFF_RETRY_CATEGORY,
191
196
  INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
197
+ OUTPUT_LIMIT_RETRY_CATEGORY,
192
198
  ],
193
199
  };
194
200
  /** Markdown shard writers use one full attempt plus at most one bounded binding repair. */
@@ -472,9 +478,9 @@ function reasonMandatedFrontendPlanRetryStep(reason) {
472
478
  }
473
479
  }
474
480
  /**
475
- * Opt-in retry policy mounted on the frontend-plan-pi node. maxAttempts=3
476
- * bounds plan retry cost: the four-rung ladder is only partially traversable
477
- * before the run fails non-converging (rungs: normal → bounded-tool-only →
481
+ * Opt-in retry policy mounted on the frontend-plan-pi node. maxAttempts=5
482
+ * lets the producer validate and repair each generated increment while still
483
+ * bounding the four-rung ladder (normal → bounded-tool-only →
478
484
  * compact-terminal-first → backup-model). `invalid-output` (typed-fact schema
479
485
  * violations, e.g. verification targets referencing undeclared UI states)
480
486
  * retries at the `normal` rung with the contractCheck reason injected via
@@ -485,7 +491,7 @@ export const FRONTEND_PLAN_LADDER_RETRY_POLICY = {
485
491
  // incremental record_* calls; dense call bursts hit provider rate limits
486
492
  // whose windows exceed the old 30s cap. Longer backoff gives the limit
487
493
  // window time to expire before the next attempt.
488
- maxAttempts: 3,
494
+ maxAttempts: 5,
489
495
  backoff: "exponential",
490
496
  initialDelayMs: 5000,
491
497
  maxDelayMs: 60000,
@@ -0,0 +1,125 @@
1
+ import { appendFileSync, mkdirSync, unlinkSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Runner exit-time diagnostics (silent-death forensics).
6
+ *
7
+ * A DAG runner can disappear without a crash report and without stderr (five
8
+ * reproductions during frontend recovery/long-plan runs, 2026-09-06): when a
9
+ * supervisor hard-kills the process or the event loop is wedged, no JS runs,
10
+ * so nothing is persisted and the run is only discoverable later as an
11
+ * orphaned RUNNING state with a frozen heartbeat. That leaves no evidence of
12
+ * WHERE the runner was when it died or whether the exit was even observable
13
+ * by Node.
14
+ *
15
+ * This module gives every abnormal death a durable trace next to state.json:
16
+ * - an `armed` line is written when the checkpoint starts executing;
17
+ * - a `process-exit` line is appended from the process `exit` hook with the
18
+ * exit code and a live state snapshot (status, terminalReason, RUNNING
19
+ * nodes, heartbeat staleness);
20
+ * - `uncaught-exception` / `unhandled-rejection` lines record the error
21
+ * before the process is allowed to crash exactly as it would have
22
+ * via uncaughtExceptionMonitor without changing the host's crash policy.
23
+ *
24
+ * On a clean terminal return the runner calls stop(), which removes every
25
+ * listener and deletes the journal, so completed runs carry no noise. A file
26
+ * left behind therefore means: diagnostics were armed, then the process went
27
+ * away through a path Node could not observe (`armed` line only → external
28
+ * SIGKILL/OOM/event-loop wedge) or through an observed abnormal exit (`armed`
29
+ * + one or more event lines → the reason is recorded in the file).
30
+ *
31
+ * Journal writes are strictly synchronous and best-effort: an append failure
32
+ * must never alter the runner's own crash/exit behavior.
33
+ */
34
+ export const RUNNER_EXIT_DIAGNOSTICS_FILE = "runner-exit-diagnostics.jsonl";
35
+ function describeError(error) {
36
+ if (error instanceof Error) {
37
+ return { name: error.name, message: error.message };
38
+ }
39
+ try {
40
+ return { message: String(error) };
41
+ }
42
+ catch {
43
+ return { message: "<unstringifiable error>" };
44
+ }
45
+ }
46
+ function appendJournalLineSync(journalPath, record) {
47
+ try {
48
+ mkdirSync(path.dirname(journalPath), { recursive: true });
49
+ appendFileSync(journalPath, `${JSON.stringify(record)}\n`, "utf8");
50
+ }
51
+ catch {
52
+ // Best-effort: journaling must never crash or alter exit behavior.
53
+ }
54
+ }
55
+ function removeJournalSync(journalPath) {
56
+ try {
57
+ unlinkSync(journalPath);
58
+ }
59
+ catch {
60
+ // Tolerate a missing journal (e.g. nothing was ever written).
61
+ }
62
+ }
63
+ export function installRunnerExitDiagnostics(options) {
64
+ const journalPath = path.join(options.runDir, RUNNER_EXIT_DIAGNOSTICS_FILE);
65
+ const target = options.target ?? process;
66
+ const armedAt = Date.now();
67
+ let stopped = false;
68
+ const base = {
69
+ runId: options.runId,
70
+ title: options.title,
71
+ pid: process.pid,
72
+ hostname: hostname(),
73
+ };
74
+ const safeSnapshot = () => {
75
+ try {
76
+ return options.snapshot();
77
+ }
78
+ catch {
79
+ return { status: "<snapshot-error>", running: [] };
80
+ }
81
+ };
82
+ const record = (kind, extra) => ({
83
+ ts: new Date().toISOString(),
84
+ kind,
85
+ ...base,
86
+ ...(extra ?? {}),
87
+ state: safeSnapshot(),
88
+ });
89
+ appendJournalLineSync(journalPath, {
90
+ ...record("armed"),
91
+ armedAt: new Date(armedAt).toISOString(),
92
+ });
93
+ const onProcessExit = (code) => {
94
+ if (stopped)
95
+ return;
96
+ appendJournalLineSync(journalPath, record("process-exit", {
97
+ code: typeof code === "number" ? code : process.exitCode ?? 0,
98
+ elapsedMs: Date.now() - armedAt,
99
+ }));
100
+ };
101
+ const onUncaughtException = (error, origin) => {
102
+ if (stopped)
103
+ return;
104
+ appendJournalLineSync(journalPath, record(origin === "unhandledRejection" ? "unhandled-rejection" : "uncaught-exception", {
105
+ error: describeError(error),
106
+ elapsedMs: Date.now() - armedAt,
107
+ }));
108
+ // Monitor listeners neither suppress fatal errors nor force a crash
109
+ // when the host has its own handlers or a nonfatal rejection policy.
110
+ };
111
+ target.on("exit", onProcessExit);
112
+ target.on("uncaughtExceptionMonitor", onUncaughtException);
113
+ return {
114
+ journalPath,
115
+ stop: () => {
116
+ if (stopped)
117
+ return;
118
+ stopped = true;
119
+ target.removeListener("exit", onProcessExit);
120
+ target.removeListener("uncaughtExceptionMonitor", onUncaughtException);
121
+ // Clean completion: leave completed runs free of diagnostics noise.
122
+ removeJournalSync(journalPath);
123
+ },
124
+ };
125
+ }
@@ -32,11 +32,13 @@ import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFro
32
32
  import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
33
33
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
34
34
  import { computeActionPayloadHash, } from "../../infrastructure/console/operation-store.js";
35
+ import { classifyFailureOwner, failureOwnerSchema } from "./frontend-recovery-capsule.js";
35
36
  import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, claimDagRecoveryLease, } from "./recovery-lease.js";
36
37
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
37
38
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
38
39
  import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
39
40
  import { executeDynamicReduction } from "./dynamic-runtime/reduction.js";
41
+ import { installRunnerExitDiagnostics } from "./runner-exit-diagnostics.js";
40
42
  export { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions };
41
43
  export async function loadDagSpecFromFile(dagPath) {
42
44
  const raw = JSON.parse(await readFile(dagPath, "utf-8"));
@@ -602,6 +604,36 @@ async function executeDagCheckpoint(input) {
602
604
  const onSigInt = () => shutdownOnSignal("SIGINT");
603
605
  process.once("SIGTERM", onSigTerm);
604
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
+ });
605
637
  try {
606
638
  const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
607
639
  const baseExecuteNode = input.executeNode ??
@@ -938,6 +970,7 @@ async function executeDagCheckpoint(input) {
938
970
  finally {
939
971
  clearInterval(heartbeatTimer);
940
972
  interruptWatcher?.stop();
973
+ exitDiagnostics.stop();
941
974
  process.removeListener("SIGTERM", onSigTerm);
942
975
  process.removeListener("SIGINT", onSigInt);
943
976
  }
@@ -1296,13 +1329,40 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1296
1329
  if (verifyFailed) {
1297
1330
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1298
1331
  if (continuationCount < frontendRecoveryQuota) {
1299
- recoveryTrigger = {
1300
- requestId: state.runId,
1301
- failureSource: "frontend-verify-shell",
1302
- failureOwner: "implementation",
1303
- protocolFailureReason: "frontend verification failure",
1304
- recoveryMode: "continuation",
1305
- };
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`;
1306
1366
  }
1307
1367
  }
1308
1368
  }
@@ -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 [
@@ -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
  }