@tea-agent/loop-agent 0.39.0-beta.6 → 0.39.0-beta.8

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 (94) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/cli/command-definitions.js +7 -0
  4. package/dist/cli/program.js +5 -0
  5. package/dist/commands/client-recovery.js +3 -0
  6. package/dist/commands/dag-follow-up.js +138 -0
  7. package/dist/commands/task-advance.js +19 -0
  8. package/dist/executors/dag-pi-executor.js +44 -11
  9. package/dist/executors/pi-executor.js +7 -0
  10. package/dist/executors/shell-executor.js +1 -1
  11. package/dist/shared/dag-failure-category.js +12 -0
  12. package/dist/task/config-types.js +15 -0
  13. package/dist/task/frontend-project-capability.js +19 -0
  14. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CfDSkLtp.js → abnfDiagram-N423BO3Z-bPer1waP.js} +1 -1
  15. package/dist/worker/console/static/assets/{arc-BlefM6I5.js → arc-BLdkwucW.js} +1 -1
  16. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C4tHfzHn.js → architectureDiagram-T3A2C74G-Bw4wo4I1.js} +1 -1
  17. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-G-G77Rh1.js → blockDiagram-VBNYF7ZC-DUUkc0gr.js} +1 -1
  18. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DWgxmY72.js → c4Diagram-5PPSVZJV-BC3O0nob.js} +1 -1
  19. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +1 -0
  20. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CJQcfbCY.js → chunk-2GRJ4B5K-Dd4g2nti.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-vUPviH9y.js → chunk-2Q5K7J3B-s2EDm7-n.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DzRXSTeh.js → chunk-5RXB4S5H-GqtHM2yQ.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CWfgqm07.js → chunk-5VM5RSS4-BzRFrN8j.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BtTDauXA.js → chunk-6Q2QTUOP-Bw9nFjCJ.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bc-NNt84.js → chunk-GF5L2VYU-B4n-03Qm.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-B_f-emL4.js → chunk-JWPE2WC7-Dt75ZdBD.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DV-jgJ66.js → chunk-KBJHAD2P-B_afMhHB.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DMqzn4RZ.js → chunk-RYQCIY6F-Bd8Rqqg9.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CVNtlAm4.js → chunk-XXDRQBXY-9boOMji4.js} +1 -1
  30. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +1 -0
  31. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +1 -0
  32. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BBZBhVTd.js → cose-bilkent-JH36ORCC-BtCycfCc.js} +1 -1
  33. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DbMU2PZ2.js → cynefin-VYW2F7L2-eTOts9L1.js} +1 -1
  34. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dj0v8rjc.js → cynefinDiagram-MW4NZA55-Cibd2mjg.js} +1 -1
  35. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BW8R4WvJ.js → dagre-VZM6K2ZE-CDjDZtDV.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DEgWz3ld.js → diagram-7IWD3JNH-Btexn-JJ.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BAA01rlR.js → diagram-B4RE2ZJO-Cyl8sdpM.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Cau6qqpW.js → diagram-LBJQPF4R-Ev-83E1t.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-8HiPsLos.js → diagram-Q27KOJAE-41LqE0Mc.js} +1 -1
  40. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CmkXuTYL.js → diagram-UB23O5K3-BC39T5R3.js} +1 -1
  41. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DCV_0seW.js → ebnfDiagram-BXEA7PRR-DzcNRuri.js} +1 -1
  42. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-gckfWESj.js → erDiagram-JOGREHBK-B5Frvvnz.js} +1 -1
  43. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D2mT2BUu.js → flowDiagram-UKHOOZJN-BapxROIM.js} +1 -1
  44. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-B_HR5qEw.js → ganttDiagram-PKOTCBZU-Cd4d4wQ0.js} +1 -1
  45. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BUw4fpSa.js → gitGraphDiagram-DS77QQ5N-CrL6buFW.js} +1 -1
  46. package/dist/worker/console/static/assets/{index-DPbsruV5.js → index-DBh_x_Ek.js} +46 -46
  47. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BSRAqEj0.js → infoDiagram-6WML65LV-BrSiNxyd.js} +1 -1
  48. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CLxE8hUp.js → ishikawaDiagram-WSZJBQD7-gvlvY-n9.js} +1 -1
  49. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-juFzVXIj.js → journeyDiagram-NVQOT4AX-D-YaPzRQ.js} +1 -1
  50. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CwLHVhOU.js → kanban-definition-27J2QSJJ-BsmDv9E_.js} +1 -1
  51. package/dist/worker/console/static/assets/{linear-9LfUyPAs.js → linear-UWaoDN3h.js} +1 -1
  52. package/dist/worker/console/static/assets/{mermaid.core-TPPHfQbe.js → mermaid.core-BlvpGVG8.js} +5 -5
  53. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-Dn_Z9RxF.js → mindmap-definition-FAOFIHXS-CUCIP5CP.js} +1 -1
  54. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CiEMKTgm.js → pegDiagram-VL7TDLO6-CB1aQUAK.js} +1 -1
  55. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-JASaGxjZ.js → pieDiagram-7S7Q4E2Y-BwehHxhf.js} +1 -1
  56. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-ChmH0ply.js → quadrantDiagram-CIZ2JOQS-CFz30APB.js} +1 -1
  57. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CtyM-HGQ.js → railroadDiagram-AXF67PYL-BwNmw1i6.js} +1 -1
  58. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BN9y_Bvs.js → requirementDiagram-LRYGKXZP-jg-tPXlI.js} +1 -1
  59. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-IyS0GQ1-.js → sankeyDiagram-W5VNT64P-BlyReDKl.js} +1 -1
  60. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DjxGXurN.js → sequenceDiagram-SI44F4Z6-DBUzW1f3.js} +1 -1
  61. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Cr3Z0S8y.js → sizeCapture-X5ZJPWSS-BYcI2RAr.js} +1 -1
  62. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BjJFC6pW.js → stateDiagram-OKZ733FA-D7AiyJdo.js} +1 -1
  63. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +1 -0
  64. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DeMUJQiW.js → swimlanes-SLNWSIFB-BHGt7Kkv.js} +2 -2
  65. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +8 -0
  66. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bc2Rv2Oo.js → timeline-definition-Z64GVDOM-OPjooWcX.js} +1 -1
  67. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CDhRUeqG.js → vennDiagram-T6HMQDX7-ggsWDvrO.js} +1 -1
  68. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DaN_z3Xy.js → wardleyDiagram-T6FBY63Y-DjNe-BjZ.js} +1 -1
  69. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-C1vTLBDG.js → xychartDiagram-ELKLHX3M-Byp63Hei.js} +1 -1
  70. package/dist/worker/console/static/index.html +1 -1
  71. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
  72. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  73. package/dist/workflows/dag/backend-test-pytest-collection.js +87 -4
  74. package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
  75. package/dist/workflows/dag/frontend-design-policy.js +19 -0
  76. package/dist/workflows/dag/frontend-implementation-contract.js +82 -3
  77. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  78. package/dist/workflows/dag/frontend-shape.js +5 -3
  79. package/dist/workflows/dag/init-hybrid.js +94 -1
  80. package/dist/workflows/dag/node-execution.js +7 -0
  81. package/dist/workflows/dag/report.js +37 -1
  82. package/dist/workflows/dag/rerun-feedback.js +124 -1
  83. package/dist/workflows/dag/retry-policy.js +29 -9
  84. package/dist/workflows/dag/runner.js +89 -7
  85. package/dist/workflows/dag/types.js +16 -0
  86. package/dist/workflows/dag/validate.js +9 -3
  87. package/docs/templates/agent-dag-report.schema.json +8 -2
  88. package/docs/templates/agent-dag.schema.json +1 -1
  89. package/package.json +1 -1
  90. package/dist/worker/console/static/assets/channel-DsdX3ZUX.js +0 -1
  91. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CrvCyJ-8.js +0 -1
  92. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CrvCyJ-8.js +0 -1
  93. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Bq44O9Nt.js +0 -1
  94. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BT4tWyW3.js +0 -8
@@ -2257,6 +2257,83 @@ function frontendMockStrategyMustBeNotNeeded(sources) {
2257
2257
  capabilityStatus === "ambiguous" ||
2258
2258
  !hasDeterministicMockVerification));
2259
2259
  }
2260
+ /**
2261
+ * 候选规范懒加载:prompt 只内联与任务相关的候选,不全部塞给模型。
2262
+ *
2263
+ * - mandatory(任务源显式声明/引用)始终保留——模型必须知道它们存在。
2264
+ * - scan-strict 候选按「路径段是否命中任务源关键词」过滤:列表页任务只
2265
+ * 带出列表相关组件/页面规范,创建页规范不进 prompt。
2266
+ * - runtime 的选型/read 门禁仍消费完整 openspecCandidatePaths(prewrite
2267
+ * gate 用全量);这里只缩小 prompt 体积,不改变门禁语义。未提及的候选
2268
+ * 由 runtime 默认 irrelevant,模型无需枚举。
2269
+ * - 真正读取发生在 plan 侧:模型对 required 选型调用 read 工具,prewrite
2270
+ * 门禁验证 read 事件——按需读取,不预加载正文。
2271
+ */
2272
+ export function filterRelevantOpenspecCandidates(input) {
2273
+ const mandatory = new Set(input.mandatoryPaths);
2274
+ const relevant = [];
2275
+ for (const candidate of input.candidates) {
2276
+ if (mandatory.has(candidate)) {
2277
+ relevant.push(candidate);
2278
+ continue;
2279
+ }
2280
+ if (openspecCandidateMatchesSource(candidate, input.sourceMarkdown)) {
2281
+ relevant.push(candidate);
2282
+ }
2283
+ }
2284
+ // 保持候选发现顺序(确定性);mandatory 与相关候选都按原始顺序出现。
2285
+ return relevant;
2286
+ }
2287
+ /**
2288
+ * 候选路径是否与任务源相关:从任务源提取文件名/路径 token(去扩展名、
2289
+ * 去连字符/下划线),若候选的路径段包含任一 token 即视为相关。纯字符串
2290
+ * 判定,无 IO;找不到 token 时保守保留(避免漏掉模型可能需要的规范)。
2291
+ */
2292
+ function openspecCandidateMatchesSource(candidate, sourceMarkdown) {
2293
+ const sourceTokens = extractOpenspecSourceTokens(sourceMarkdown);
2294
+ if (sourceTokens.size === 0)
2295
+ return true;
2296
+ const candidateLower = candidate.toLowerCase().replace(/\\/g, "/");
2297
+ const candidateSegments = candidateLower.split("/");
2298
+ const candidateBasename = candidateSegments[candidateSegments.length - 1] ?? "";
2299
+ for (const token of sourceTokens) {
2300
+ if (candidateBasename.includes(token) ||
2301
+ candidateLower.includes(`/${token}/`) ||
2302
+ candidateLower.includes(`${token}.`)) {
2303
+ return true;
2304
+ }
2305
+ }
2306
+ return false;
2307
+ }
2308
+ /** 从任务源 markdown 提取显著 token:反引号代码段、路径、组件/页面名。 */
2309
+ function extractOpenspecSourceTokens(markdown) {
2310
+ const tokens = new Set();
2311
+ const push = (raw) => {
2312
+ const cleaned = raw
2313
+ .trim()
2314
+ .replace(/[.*+?^${}()|[\]\\]/g, "")
2315
+ .toLowerCase();
2316
+ if (cleaned.length >= 2 && cleaned.length <= 40)
2317
+ tokens.add(cleaned);
2318
+ };
2319
+ // 反引号内联代码(文件名/组件名)
2320
+ for (const match of markdown.matchAll(/`([^`\n]+)`/g)) {
2321
+ push(match[1] ?? "");
2322
+ }
2323
+ // markdown 链接文本
2324
+ for (const match of markdown.matchAll(/\[([^\]]+)\]\([^)]+\)/g)) {
2325
+ push(match[1] ?? "");
2326
+ }
2327
+ // 路径 token(openspec/ 或 xxx/xxx.md)
2328
+ for (const match of markdown.matchAll(/(?:[A-Za-z0-9_-]+\/)+[A-Za-z0-9_.-]+/g)) {
2329
+ const segments = (match[0] ?? "").split("/");
2330
+ const basename = segments[segments.length - 1] ?? "";
2331
+ push(basename.replace(/\.[a-z0-9]+$/i, ""));
2332
+ for (const segment of segments)
2333
+ push(segment);
2334
+ }
2335
+ return tokens;
2336
+ }
2260
2337
  async function resolveFrontendOpenspecGateConfig(sources) {
2261
2338
  const taskConfig = sources.taskConfig;
2262
2339
  const policy = taskConfig.frontendOpenspec?.policy ?? "cited";
@@ -2870,10 +2947,22 @@ async function buildFrontendHybridDagFromTask(sources) {
2870
2947
  const openspecGate = await resolveFrontendOpenspecGateConfig(sources);
2871
2948
  const requiresOpenspecClassification = openspecGate.openspecPolicy === "cited" &&
2872
2949
  openspecGate.openspecCandidatePaths.length > 0;
2950
+ // Prompt 内联的候选只保留与任务相关的子集:mandatory(任务显式声明/
2951
+ // 引用)始终保留;scan-strict 候选按路径段是否命中任务源关键词过滤。
2952
+ // runtime 的选型/read 门禁仍消费完整 openspecCandidatePaths——这里只
2953
+ // 减小 prompt 体积,不改变门禁语义;未提及的候选 runtime 默认 irrelevant。
2954
+ const promptCandidates = filterRelevantOpenspecCandidates({
2955
+ candidates: openspecGate.openspecCandidatePaths,
2956
+ mandatoryPaths: openspecGate.openspecMandatoryPaths,
2957
+ sourceMarkdown: [
2958
+ sources.requirementMarkdown,
2959
+ sources.constraintMarkdown ?? "",
2960
+ ].join("\n"),
2961
+ });
2873
2962
  const openspecSelectionContext = JSON.stringify({
2874
2963
  schemaVersion: 1,
2875
2964
  schemaId: "frontend-openspec-selection-v1",
2876
- candidates: openspecGate.openspecCandidatePaths,
2965
+ candidates: promptCandidates,
2877
2966
  mandatoryPaths: openspecGate.openspecMandatoryPaths,
2878
2967
  });
2879
2968
  // Generation-frozen component/theme specification bucket (ADR 0016). Derived
@@ -3295,6 +3384,10 @@ async function buildFrontendHybridDagFromTask(sources) {
3295
3384
  ...(spec.advisories ?? []),
3296
3385
  `frontend-task-shape: ${frontendTaskShape.shape} (${frontendTaskShape.reason})`,
3297
3386
  ];
3387
+ // Freeze the frontend recovery continuation quota from task.json so the
3388
+ // runner can bound M6 auto-recovery without re-reading the task config.
3389
+ const frontendMaxContinuations = sources.taskConfig.frontendRecovery?.maxContinuations ?? 1;
3390
+ spec.frontendRecovery = { maxContinuations: frontendMaxContinuations };
3298
3391
  applyDefaultReadOnlyRetryPolicy(spec);
3299
3392
  parseDagSpec(spec);
3300
3393
  assertValidDagSpec(spec);
@@ -258,6 +258,12 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
258
258
  // only to nodes whose authority is still a text contract artifact.
259
259
  if (task.structuredContractOutput.schemaId ===
260
260
  "frontend-implementation-contract-plan-patch-v1") {
261
+ const splitGuidance = /write-set-too-large|split the task/.test(previousProtocolReason ?? "")
262
+ ? [
263
+ "",
264
+ "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.",
265
+ ]
266
+ : [];
261
267
  return [
262
268
  basePrompt,
263
269
  "",
@@ -266,6 +272,7 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
266
272
  previousProtocolReason,
267
273
  "Fix the reported violations by re-committing corrected record_* facts and calling finalize_plan exactly once. The committed typed ledger is the only compile authority.",
268
274
  "Do not emit a fenced JSON contract, protected skeleton fields, or an openspec-citations block; narrative JSON is not validated and wastes the output budget.",
275
+ ...splitGuidance,
269
276
  "</retry_instruction>",
270
277
  ].join("\n");
271
278
  }
@@ -125,6 +125,8 @@ const dagNodeArtifactsReportSchema = z
125
125
  humanEscalationMd: dagArtifactRefSchema,
126
126
  humanApproval: dagArtifactRefSchema,
127
127
  humanRejection: dagArtifactRefSchema,
128
+ changeManifest: dagArtifactRefSchema,
129
+ changeManifestChangedFiles: z.array(z.string()).optional(),
128
130
  })
129
131
  .strict();
130
132
  const dagNodeReportRowSchema = z
@@ -259,6 +261,20 @@ export async function probeArtifact(filePath) {
259
261
  export async function buildNodeArtifactsReport(runDir, nodeId) {
260
262
  const nodeDir = path.join(runDir, nodeId);
261
263
  const artifact = (name) => probeArtifact(path.join(nodeDir, name));
264
+ const changeManifestRef = await artifact("change-manifest.json");
265
+ let changeManifestChangedFiles;
266
+ if (changeManifestRef.exists) {
267
+ try {
268
+ const manifest = JSON.parse(await readFile(path.join(nodeDir, "change-manifest.json"), "utf-8"));
269
+ changeManifestChangedFiles = Array.isArray(manifest.changedFiles)
270
+ ? manifest.changedFiles
271
+ .filter((item) => typeof item === "string")
272
+ : undefined;
273
+ }
274
+ catch {
275
+ // unreadable manifest → leave undefined
276
+ }
277
+ }
262
278
  return {
263
279
  resultSummary: await artifact("result.summary.md"),
264
280
  promptRedacted: await artifact("prompt.redacted.md"),
@@ -270,6 +286,8 @@ export async function buildNodeArtifactsReport(runDir, nodeId) {
270
286
  humanEscalationMd: await artifact("human-escalation.md"),
271
287
  humanApproval: await artifact("human-approval.json"),
272
288
  humanRejection: await artifact("human-rejection.json"),
289
+ changeManifest: changeManifestRef,
290
+ changeManifestChangedFiles,
273
291
  };
274
292
  }
275
293
  function taskById(spec) {
@@ -726,6 +744,12 @@ function formatArtifactFlags(artifacts) {
726
744
  flags.push("approval");
727
745
  if (artifacts.humanRejection.exists)
728
746
  flags.push("rejection");
747
+ if (artifacts.changeManifest.exists) {
748
+ const count = artifacts.changeManifestChangedFiles?.length;
749
+ flags.push(count !== undefined
750
+ ? `changed:${count}`
751
+ : "change-manifest");
752
+ }
729
753
  return flags.length > 0 ? flags.join(",") : "-";
730
754
  }
731
755
  function formatTimestamp(value) {
@@ -749,7 +773,19 @@ function summarizeNodeStatuses(nodes) {
749
773
  return `${total} total / ${finished} finished / ${failed} failed / ${skipped} skipped / ${pending} pending`;
750
774
  }
751
775
  function hasArtifact(artifacts) {
752
- return Object.values(artifacts).some((artifact) => artifact.exists);
776
+ return [
777
+ artifacts.resultSummary,
778
+ artifacts.promptRedacted,
779
+ artifacts.skills,
780
+ artifacts.nodeRecord,
781
+ artifacts.decisionEnvelope,
782
+ artifacts.decisionEnvelopeValidation,
783
+ artifacts.humanEscalationJson,
784
+ artifacts.humanEscalationMd,
785
+ artifacts.humanApproval,
786
+ artifacts.humanRejection,
787
+ artifacts.changeManifest,
788
+ ].some((artifact) => artifact.exists);
753
789
  }
754
790
  function failureRows(run) {
755
791
  return run.nodes.filter((node) => node.status === "ERROR" ||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { parseJsonReviewVerdict } from "./output-protocol.js";
5
+ import { FRONTEND_DESIGN_REVIEW_NODE_ID, FRONTEND_REVIEW_NODE_ID, readCommittedDesignRequestFact, readCommittedReviewRequestFact, } from "./frontend-review-findings.js";
5
6
  export const MAX_RERUN_FEEDBACK_CHARS = 12_000;
6
7
  const RERUN_FEEDBACK_TRUNCATION_MARKER = "\n\n...[上一轮反馈已按长度上限截断]...\n\n";
7
8
  const MAX_EVIDENCE_REFS = 8;
@@ -35,6 +36,44 @@ function requestRevision(text) {
35
36
  return (emphasized?.[1] ?? trimmed).trim() === "VERDICT: request-revision";
36
37
  });
37
38
  }
39
+ /**
40
+ * Deterministically render a typed `request_review_changes` fact into feedback
41
+ * text. Unlike the legacy text channel (VERDICT: request-revision), the
42
+ * frontend review terminal is a committed typed fact; the findings are the
43
+ * actionable body the next round must consume.
44
+ */
45
+ export function formatTypedReviewRequestFeedback(fact) {
46
+ const lines = [];
47
+ lines.push(`frontend review committed request_review_changes (issueCategory=${typeof fact.issueCategory === "string" ? fact.issueCategory : "unknown"})`);
48
+ if (Array.isArray(fact.findings) && fact.findings.length > 0) {
49
+ lines.push("review findings:");
50
+ for (const finding of fact.findings) {
51
+ if (!finding || typeof finding !== "object")
52
+ continue;
53
+ const record = finding;
54
+ const severity = typeof record.severity === "string" ? record.severity : "";
55
+ const issue = typeof record.issue === "string" ? record.issue : "";
56
+ const location = [
57
+ typeof record.file === "string" ? record.file : "",
58
+ typeof record.line === "number" ? String(record.line) : "",
59
+ ]
60
+ .filter(Boolean)
61
+ .join(":");
62
+ const prefix = location ? `[${location}] ` : "";
63
+ const requiredChange = typeof record.requiredChange === "string" &&
64
+ record.requiredChange.length > 0
65
+ ? ` (requiredChange: ${record.requiredChange})`
66
+ : "";
67
+ lines.push(`- ${prefix}${severity}: ${issue}${requiredChange}`);
68
+ }
69
+ }
70
+ if (Array.isArray(fact.evidenceRefs) && fact.evidenceRefs.length > 0) {
71
+ lines.push(`evidenceRefs: ${fact.evidenceRefs
72
+ .filter((ref) => typeof ref === "string")
73
+ .join(", ")}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
38
77
  function pathInside(root, candidate) {
39
78
  const relative = path.relative(root, candidate);
40
79
  return (relative === "" ||
@@ -127,6 +166,41 @@ function evidenceRefs(repoRoot, runDir, pass, sourceNodeId) {
127
166
  }
128
167
  return result;
129
168
  }
169
+ /**
170
+ * Read the committed frontend review request fact (typed terminal channel).
171
+ * Returns { nodeId, fact } when the review node committed `request_review_changes`.
172
+ */
173
+ async function readTypedReviewRequestFact(runDir) {
174
+ const fact = await readCommittedReviewRequestFact({ runDir });
175
+ if (!fact)
176
+ return undefined;
177
+ return {
178
+ nodeId: FRONTEND_REVIEW_NODE_ID,
179
+ fact: {
180
+ issueCategory: fact.issueCategory,
181
+ evidenceRefs: fact.evidenceRefs,
182
+ findings: fact.findings,
183
+ },
184
+ };
185
+ }
186
+ /**
187
+ * Read the committed frontend design request fact (typed terminal channel).
188
+ * Returns { nodeId, fact } when the design review node committed
189
+ * `request_design_changes` — the design analog of request_review_changes.
190
+ */
191
+ async function readTypedDesignRequestFact(runDir) {
192
+ const fact = await readCommittedDesignRequestFact({ runDir });
193
+ if (!fact)
194
+ return undefined;
195
+ return {
196
+ nodeId: FRONTEND_DESIGN_REVIEW_NODE_ID,
197
+ fact: {
198
+ issueCategory: fact.issueCategory,
199
+ evidenceRefs: fact.evidenceRefs,
200
+ findings: fact.findings,
201
+ },
202
+ };
203
+ }
130
204
  /**
131
205
  * Derive only the latest unresolved verify/review feedback from canonical
132
206
  * parent-run facts. Provider-only failures intentionally return undefined.
@@ -144,8 +218,57 @@ export async function deriveDagRerunFeedback(input) {
144
218
  break;
145
219
  }
146
220
  }
147
- if (!sourceNodeId || !sourceNode)
221
+ if (!sourceNodeId || !sourceNode) {
222
+ // Typed terminal channel: the frontend review/design review commit their
223
+ // verdicts as typed facts (request_review_changes / request_design_changes)
224
+ // instead of VERDICT text, and the frontend DAG has no convergence
225
+ // passHistory. Fall back to reading the typed event stores so findings
226
+ // still flow into the next round as unresolved feedback. Prefer review;
227
+ // if absent, fall through to the design review (a design rejection also
228
+ // carries actionable findings the next plan round must consume).
229
+ const typedFact = (await readTypedReviewRequestFact(input.runDir)) ??
230
+ (await readTypedDesignRequestFact(input.runDir));
231
+ if (typedFact) {
232
+ const parentSourceBindingHash = sourceBindingHash(input.spec);
233
+ return withDigest({
234
+ schemaVersion: 1,
235
+ kind: "unresolved-terminal-feedback",
236
+ parentRunId: input.parentRunId,
237
+ taskId: input.taskId,
238
+ sourceNodeId: typedFact.nodeId,
239
+ verdict: "request-revision",
240
+ failureCategory: typedFact.nodeId === FRONTEND_REVIEW_NODE_ID
241
+ ? "review-request-changes"
242
+ : "design-request-changes",
243
+ feedbackText: scrubAndBoundFeedbackText(formatTypedReviewRequestFeedback(typedFact.fact), input.runDir, input.state.cwd),
244
+ evidenceRefs: [
245
+ {
246
+ nodeId: typedFact.nodeId,
247
+ preservedNodeRecordPath: `${typedFact.nodeId}.json`,
248
+ },
249
+ ],
250
+ attemptSummary: {
251
+ completedPasses: 1,
252
+ maxPasses: 1,
253
+ ...(input.state.terminalReason
254
+ ? { terminalReason: input.state.terminalReason }
255
+ : {}),
256
+ },
257
+ binding: {
258
+ ...(parentSourceBindingHash
259
+ ? { sourceCanonicalHash: parentSourceBindingHash }
260
+ : {}),
261
+ ...(input.spec.taskContractBinding?.canonicalHash
262
+ ? {
263
+ taskContractCanonicalHash: input.spec.taskContractBinding.canonicalHash,
264
+ }
265
+ : {}),
266
+ status: "unknown",
267
+ },
268
+ });
269
+ }
148
270
  return undefined;
271
+ }
149
272
  const completedPasses = pass?.pass ?? Math.max(1, input.state.convergence?.currentPass ?? 1);
150
273
  const maxPasses = Math.max(completedPasses, input.state.convergence?.maxPasses ?? 1);
151
274
  const parentSourceBindingHash = sourceBindingHash(input.spec);
@@ -40,6 +40,14 @@ export const REVIEW_TERMINAL_MISSING_RETRY_CATEGORY = "review-terminal-missing";
40
40
  * reduced-reading instruction.
41
41
  */
42
42
  export const READ_BURST_RETRY_CATEGORY = "read-burst";
43
+ /**
44
+ * Provider returned a context-overflow error (400 request-too-large / context
45
+ * window exceeded). The SDK compacts and auto-retries once internally; when it
46
+ * still surfaces, the executor classifies this so retry policies and reports
47
+ * treat it as overflow (not empty-output) and can retry with a compacted
48
+ * session / fresh context.
49
+ */
50
+ export const CONTEXT_OVERFLOW_RETRY_CATEGORY = "context-overflow";
43
51
  /** Retry only a proven no-op from an explicitly opt-in bounded Pi writer. */
44
52
  export const WRITER_EMPTY_DIFF_RETRY_CATEGORY = "writer-empty-diff";
45
53
  /** Retry when a backend-test writer finished but Completeness Gate found missing/broken targets. */
@@ -74,6 +82,7 @@ export const ALL_DAG_RETRY_CATEGORIES = [
74
82
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
75
83
  REVIEW_TERMINAL_MISSING_RETRY_CATEGORY,
76
84
  READ_BURST_RETRY_CATEGORY,
85
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
77
86
  ];
78
87
  const RETRY_SAFE_PI_ROLES = new Set([
79
88
  "planner",
@@ -196,7 +205,10 @@ export const WRITER_TRANSPORT_RETRY_POLICY = {
196
205
  backoff: "exponential",
197
206
  initialDelayMs: 5000,
198
207
  maxDelayMs: 30000,
199
- retryCategories: [WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY],
208
+ retryCategories: [
209
+ WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
210
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
211
+ ],
200
212
  };
201
213
  /**
202
214
  * Implicit runtime retry for canonical final verify shells. Not a DagRetryPolicy:
@@ -219,14 +231,20 @@ export const TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE = "standard-reviewed-superv
219
231
  /**
220
232
  * Target-template exclusive implement-pi transport retry. Same schema category
221
233
  * as frontend (`writer-clean-timeout`) so DagSpec validation stays unchanged;
222
- * only the delay cap is wider. Do not mutate {@link WRITER_TRANSPORT_RETRY_POLICY}.
234
+ * only the delay cap is wider. Context overflow is added so a writer whose
235
+ * session hit the context window (compact+retry inside the SDK) can retry once
236
+ * more at the DAG level with a fresh attempt. Do not mutate
237
+ * {@link WRITER_TRANSPORT_RETRY_POLICY}.
223
238
  */
224
239
  export const TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY = {
225
240
  maxAttempts: 2,
226
241
  backoff: "exponential",
227
242
  initialDelayMs: 5000,
228
243
  maxDelayMs: 60000,
229
- retryCategories: [WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY],
244
+ retryCategories: [
245
+ WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
246
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
247
+ ],
230
248
  };
231
249
  /**
232
250
  * Backend-test generation writers: empty-diff once, plus bounded incomplete-write-set
@@ -518,14 +536,16 @@ function reasonMandatedFrontendPlanRetryStep(reason) {
518
536
  }
519
537
  }
520
538
  /**
521
- * Opt-in retry policy mounted on the frontend-plan-pi node. maxAttempts=5 is
522
- * the schema cap, matching the four-rung ladder plus one non-converging stop.
523
- * `invalid-output` (typed-fact schema violations, e.g. verification targets
524
- * referencing undeclared UI states) retries at the `normal` rung with the
525
- * contractCheck reason injected via `<retry_instruction>` (§5.1 ladder).
539
+ * Opt-in retry policy mounted on the frontend-plan-pi node. maxAttempts=3
540
+ * bounds plan retry cost: the four-rung ladder is only partially traversable
541
+ * before the run fails non-converging (rungs: normal bounded-tool-only
542
+ * compact-terminal-first backup-model). `invalid-output` (typed-fact schema
543
+ * violations, e.g. verification targets referencing undeclared UI states)
544
+ * retries at the `normal` rung with the contractCheck reason injected via
545
+ * `<retry_instruction>` (§5.1 ladder).
526
546
  */
527
547
  export const FRONTEND_PLAN_LADDER_RETRY_POLICY = {
528
- maxAttempts: 5,
548
+ maxAttempts: 3,
529
549
  backoff: "exponential",
530
550
  initialDelayMs: 2000,
531
551
  maxDelayMs: 30000,
@@ -26,6 +26,7 @@ import { runConvergencePassController, } from "./convergence/controller.js";
26
26
  import { executeDagRanksOnce, FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isConditionSkippedReason, readFrontendPrewriteResult, } from "./scheduler.js";
27
27
  import { topoSortToRanks } from "./topo.js";
28
28
  import { isFrontendWriterTransientPartialWrite } from "./frontend-writer-recovery.js";
29
+ import { classifyReviewIssueCategoryFailureOwner, readCommittedDesignRequestFact, readCommittedReviewRequestFact, writeFrontendReviewFindingsPrd, } from "./frontend-review-findings.js";
29
30
  import { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas, commitRuntimeFrontendShapeFact, isCanonicalFrontendShapeDag, } from "./frontend-shape-facts.js";
30
31
  import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFrontendShapeSourceDigest, } from "./frontend-shape.js";
31
32
  import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
@@ -793,7 +794,7 @@ async function executeDagCheckpoint(input) {
793
794
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
794
795
  }
795
796
  else {
796
- const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
797
+ const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd, spec.frontendRecovery?.maxContinuations ?? 1);
797
798
  await finalizeRunOwnedInterrupt({
798
799
  runDir,
799
800
  state,
@@ -1041,7 +1042,7 @@ function buildRecoveryResultForTrigger(state, trigger, outcome) {
1041
1042
  evidenceRefs: [],
1042
1043
  };
1043
1044
  }
1044
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1045
+ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
1045
1046
  const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1046
1047
  if (state.frontendShapeState?.terminalOutcome &&
1047
1048
  state.frontendShapeState.transitionCapsule) {
@@ -1063,16 +1064,53 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1063
1064
  // every other blocked admission is terminal.
1064
1065
  const planContractInvalid = admission.result.classification === "blocked" &&
1065
1066
  admission.result.failureSource === "frontend-plan-pi";
1067
+ // A blocked admission caused by the design review committing
1068
+ // `request_design_changes` is a definite finding (not a protocol or
1069
+ // environment error): the design was reviewed and rejected. It is the
1070
+ // design-review analog of review-request-changes — not terminal, but
1071
+ // re-enters the next plan round with the committed design findings.
1072
+ // The failureSource may be frontend-writer-admission-shell or
1073
+ // frontend-design-policy-shell; the committed design fact is the
1074
+ // authoritative signal.
1075
+ const designRequestFact = await readCommittedDesignRequestFact({
1076
+ runDir,
1077
+ });
1078
+ const designRejected = admission.result.classification === "blocked" &&
1079
+ designRequestFact !== undefined;
1066
1080
  const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1067
1081
  admission.result.classification === "stale") &&
1068
- !planContractInvalid;
1082
+ !planContractInvalid &&
1083
+ !designRejected;
1069
1084
  if (terminalAdmissionDenial) {
1070
1085
  state.status = "failed";
1071
1086
  return { recoveryPending: false };
1072
1087
  }
1088
+ if (designRejected && designRequestFact) {
1089
+ state.terminalReason = "design-request-changes";
1090
+ try {
1091
+ await writeFrontendReviewFindingsPrd({
1092
+ runDir,
1093
+ parentRunId: state.runId,
1094
+ fact: designRequestFact,
1095
+ });
1096
+ }
1097
+ catch (error) {
1098
+ console.warn(`[run-dag] warning: failed to write frontend design findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1099
+ }
1100
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1101
+ if (continuationCount < frontendRecoveryQuota) {
1102
+ recoveryTrigger = {
1103
+ requestId: state.runId,
1104
+ failureSource: "frontend-design-review-pi",
1105
+ failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
1106
+ protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
1107
+ recoveryMode: "new-dag",
1108
+ };
1109
+ }
1110
+ }
1073
1111
  if (planContractInvalid) {
1074
1112
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1075
- if (continuationCount >= 1) {
1113
+ if (continuationCount >= frontendRecoveryQuota) {
1076
1114
  // Continuation quota exhausted: a second contract-invalid candidate
1077
1115
  // is authoritative candidate-contract-invalid → forced failed.
1078
1116
  state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
@@ -1108,7 +1146,7 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1108
1146
  });
1109
1147
  if (writerNodeId) {
1110
1148
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1111
- if (continuationCount < 1) {
1149
+ if (continuationCount < frontendRecoveryQuota) {
1112
1150
  const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
1113
1151
  const rollback = await rollbackFrontendWriter({
1114
1152
  cwd: repoRoot,
@@ -1140,7 +1178,7 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1140
1178
  const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1141
1179
  if (verifyFailed) {
1142
1180
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1143
- if (continuationCount < 1) {
1181
+ if (continuationCount < frontendRecoveryQuota) {
1144
1182
  recoveryTrigger = {
1145
1183
  requestId: state.runId,
1146
1184
  failureSource: "frontend-verify-shell",
@@ -1151,6 +1189,44 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1151
1189
  }
1152
1190
  }
1153
1191
  }
1192
+ // Phase 7: frontend review "request changes" → not passed. When the review
1193
+ // node FINISHED but committed `request_review_changes` (typed terminal
1194
+ // verdict), the implementation was reviewed and rejected. The run must not
1195
+ // surface as finished: terminal aggregation below stays partial_failed, a
1196
+ // terminal reason is recorded, the committed findings are rendered into a
1197
+ // downstream-task PRD draft (run-owned), and a recovery trigger carries the
1198
+ // issueCategory-derived owner so a later DAG round consumes the findings
1199
+ // instead of repeating the previous flow.
1200
+ const reviewRequestFact = await readCommittedReviewRequestFact({
1201
+ runDir,
1202
+ });
1203
+ if (reviewRequestFact) {
1204
+ state.terminalReason = "review-request-changes";
1205
+ try {
1206
+ await writeFrontendReviewFindingsPrd({
1207
+ runDir,
1208
+ parentRunId: state.runId,
1209
+ fact: reviewRequestFact,
1210
+ });
1211
+ }
1212
+ catch (error) {
1213
+ // The PRD draft is additive; a write failure must not mask the
1214
+ // not-passed verdict. The terminal aggregation below still applies.
1215
+ console.warn(`[run-dag] warning: failed to write frontend review findings PRD: ${error instanceof Error ? error.message : String(error)}`);
1216
+ }
1217
+ if (!recoveryTrigger && !skipFrontendRecovery) {
1218
+ const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1219
+ if (continuationCount < frontendRecoveryQuota) {
1220
+ recoveryTrigger = {
1221
+ requestId: state.runId,
1222
+ failureSource: "frontend-review-pi",
1223
+ failureOwner: classifyReviewIssueCategoryFailureOwner(reviewRequestFact.issueCategory),
1224
+ protocolFailureReason: `frontend review request_review_changes (issueCategory=${reviewRequestFact.issueCategory})`,
1225
+ recoveryMode: "new-dag",
1226
+ };
1227
+ }
1228
+ }
1229
+ }
1154
1230
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
1155
1231
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
1156
1232
  if (state.convergence && supersededFailures.length > 0) {
@@ -1166,7 +1242,13 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1166
1242
  if (effectiveErrorCount === 0 &&
1167
1243
  successTerminal &&
1168
1244
  finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
1169
- state.status = "finished";
1245
+ // Phase 7 guard: a committed `request_review_changes` means the review
1246
+ // rejected the implementation; the run must never surface as finished
1247
+ // even though every node technically FINISHED.
1248
+ state.status =
1249
+ state.terminalReason === "review-request-changes"
1250
+ ? "partial_failed"
1251
+ : "finished";
1170
1252
  }
1171
1253
  else if (finishedCount > 0) {
1172
1254
  state.status = "partial_failed";
@@ -1147,6 +1147,22 @@ export const dagSpecSchema = z
1147
1147
  taskContractBinding: dagTaskContractBindingSchema.optional(),
1148
1148
  /** Optional unresolved parent-run feedback for a full task rerun. */
1149
1149
  rerunFeedback: dagRerunFeedbackSchema.optional(),
1150
+ /** Frontend recovery continuation quota (frozen at generation time from
1151
+ * task.json frontendRecovery.maxContinuations). Bounds how many times a
1152
+ * failed frontend run may auto-recover via M6 recovery operations before
1153
+ * the run stays failed and needs human intervention. */
1154
+ frontendRecovery: z
1155
+ .object({
1156
+ maxContinuations: z
1157
+ .number()
1158
+ .int()
1159
+ .min(1)
1160
+ .max(5)
1161
+ .optional()
1162
+ .default(1),
1163
+ })
1164
+ .strict()
1165
+ .optional(),
1150
1166
  /** Frozen backend-test artifact layout (plan A); absent = historical default testcase/ layout. */
1151
1167
  backendTestLayout: z
1152
1168
  .object({
@@ -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, } from "./retry-policy.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";
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([
@@ -656,12 +656,18 @@ function validateRetryPolicyTaskConfig(task, issues) {
656
656
  }
657
657
  if (isWriterTransportCandidate) {
658
658
  const categories = new Set(task.retryPolicy.retryCategories);
659
+ const allowedWriterTransportCategories = new Set([
660
+ WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
661
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
662
+ ]);
663
+ const allAllowed = [...categories].every((category) => allowedWriterTransportCategories.has(category));
659
664
  if (task.retryPolicy.maxAttempts !== 2 ||
660
- categories.size !== 1 ||
665
+ categories.size === 0 ||
666
+ !allAllowed ||
661
667
  !categories.has(WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY)) {
662
668
  issues.push({
663
669
  type: "invalid-retry-policy",
664
- message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only ${WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY}`,
670
+ message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only writer-clean-timeout (plus optional context-overflow)`,
665
671
  });
666
672
  }
667
673
  }