@tea-agent/loop-agent 0.39.0-beta.7 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ - 前端 design-review 拒绝纳入自动恢复(方向 A 姊妹实现):`request_design_changes`(definite finding)不再让 writer-admission blocked 变成 terminal failed——runner 终态判定识别 committed design fact 后生成 recovery trigger(new-dag,从 plan 重启)+ 渲染 design findings PRD 草稿 + `deriveDagRerunFeedback` 增加 design typed-fact 通道(findings 注入下一轮 `<rerun_feedback>`),受同一 `frontendRecoveryQuota` 控制;无 design fact 的 blocked admission 仍保持 terminal failed。design-review 发现"AC 未被契约/测试锚定"这类真问题会在写代码前自动进入修正轮,而非一次失败即停。
6
+ - 前端 DAG 两个闭环补齐(P0-1 / P2-4):
7
+ - **P0-1 前端 recovery 配额可配置**:`task.json frontendRecovery.maxContinuations`(默认 1,可配 2-5)冻结进 DAG spec(`spec.frontendRecovery`),runner 的 `finalizeTerminalRunStatus` 4 处硬编码 `< 1` 改为按配额判定——verify 偶发失败导致 run 真失败的场景可放宽到 2-3 次自动恢复,不必人工介入。
8
+ - **P2-4 `dag follow-up` 命令**:`loop-agent dag follow-up <parent-run-id> [--task-id <id>] [--execute] [--profile <p>]`——读取 parent run 的 `contracts/frontend-review-findings-prd.md`,createTask + importPrd(taskKind=frontend-implementation + PRD 内容)生成下游修复任务,`--execute` 直接 advance 走前端 DAG。review findings 从"只落文件的草稿"变成"一步进入下一轮 DAG 的任务",闭环补齐。
9
+ - frontend-plan-pi 重试上限从 5 收紧到 3(`FRONTEND_PLAN_LADDER_RETRY_POLICY.maxAttempts`):每次 attempt 都是完整跑一遍 plan(读上下文 + 提交 record_* ledger),5 次在坏情况下等于 5 倍 plan token 成本;3 次仍覆盖常见失败(invalid-output 格式滑动、read-burst、传输类),四档阶梯只部分可遍历即判 non-converging,减少单节点无限烧 token 风险。
10
+
5
11
  - 前端 review `request_review_changes` 不再被静默当作成功(方向 A):`frontend-review-pi` 提交 typed `request_review_changes` 时,run 终态判定为「未通过」(`partial_failed` + `terminalReason=review-request-changes`,即使全部节点 FINISHED 也不显示成功),committed findings 自动渲染为 run-owned 下游任务 PRD 草稿 `contracts/frontend-review-findings-prd.md`(含 issueCategory/evidenceRefs/findings/restartPhase 指引),并生成 recovery trigger(issueCategory→failureOwner 映射,new-dag)。`deriveDagRerunFeedback` 新增 typed-fact 通道:无文本 verdict、无 convergence 的 frontend 场景也能把 committed `request_review_changes` findings 注入下一轮 `<rerun_feedback>`,问题不再丢失、下一轮从 restartPhase 定点修复而非重复流程。
6
12
  - 前端 review findings PRD 草稿携带修复范围路由信号(方向 A 补充):`deriveFrontendReviewFixScope` 从 committed findings 确定性推导建议 shape(单文件无远程 API→micro、同目录集中多文件→small、跨目录/API 信号→standard)与建议 complexity、目标文件、建议 allowedPaths;PRD 标题改为「修复 review 发现(<shape>):<目标文件>」,正文新增「修复范围(供下一轮任务生成路由)」段(含 small 信号词,如「单文件局部修复」「无远程接口」),下一轮任务以该 PRD 生成 DAG 时,`resolveFrontendTaskShape` 能把小修复路由到 micro=7/small=10 节点拓扑,而非默认 11 节点 standard 全流程。
7
13
  - 前端 shape 分类 API 否定词覆盖中文空格形式(冒烟暴露边界):`无 API/网络调用`、`无 API` 等表述不再被误判为远程 API 信号(原 `\b(...)\b` 对 CJK 开头无词边界),`resolveFrontendTaskShape` 与 `deriveFrontendReviewFixScope` 的 hasApiSignal 判定同步修正——带「无 API/无接口」声明的单文件小修复正确路由 micro/small,而非误升级 standard。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.39.0-beta.7",
4
- "gitSha": "7f2b8f56e95923c3bde69fffa0575cfeae065c42",
5
- "builtAt": "2026-08-24T02:16:38.537Z"
3
+ "version": "0.39.0-beta.8",
4
+ "gitSha": "b698721e82e0ff98c85484a5b2864d9b28e968f8",
5
+ "builtAt": "2026-08-24T06:32:44.509Z"
6
6
  }
@@ -35,6 +35,7 @@ import { runDagReconcileTasks } from "../commands/dag-reconcile-tasks.js";
35
35
  import { runDagReconcileRun } from "../commands/dag-reconcile-run.js";
36
36
  import { runDagRequestInterrupt } from "../commands/dag-request-interrupt.js";
37
37
  import { runDagRerunTask } from "../commands/dag-rerun-task.js";
38
+ import { runDagFollowUp } from "../commands/dag-follow-up.js";
38
39
  import { runDagRerun } from "../commands/dag-rerun.js";
39
40
  import { runDagResume } from "../commands/dag-resume.js";
40
41
  import { runDagDecisionInspect, runDagDecisionValidate, } from "../workflows/dag/decision-envelope.js";
@@ -135,6 +136,7 @@ const DAG_SUBCOMMANDS = [
135
136
  "request-interrupt",
136
137
  "rerun",
137
138
  "rerun-task",
139
+ "follow-up",
138
140
  "reconcile-tasks",
139
141
  "final-verification",
140
142
  "decision",
@@ -577,6 +579,11 @@ export const COMMAND_DEFINITIONS = [
577
579
  await runDagRerunTask(repoRoot, rerunTaskArgs);
578
580
  return;
579
581
  }
582
+ if (subcommand === "follow-up") {
583
+ const followUpArgs = rest.filter((arg) => Boolean(arg));
584
+ await runDagFollowUp(repoRoot, followUpArgs);
585
+ return;
586
+ }
580
587
  if (subcommand === "reconcile-tasks") {
581
588
  const reconcileArgs = rest.filter((arg) => Boolean(arg));
582
589
  await runDagReconcileTasks(repoRoot, reconcileArgs);
@@ -13,6 +13,7 @@ import { runDagReconcileTasks } from "../commands/dag-reconcile-tasks.js";
13
13
  import { runDagReconcileRun } from "../commands/dag-reconcile-run.js";
14
14
  import { runDagRequestInterrupt } from "../commands/dag-request-interrupt.js";
15
15
  import { runDagRerunTask } from "../commands/dag-rerun-task.js";
16
+ import { runDagFollowUp } from "../commands/dag-follow-up.js";
16
17
  import { runDagRerun } from "../commands/dag-rerun.js";
17
18
  import { runDagReject } from "../commands/dag-reject.js";
18
19
  import { runDagCloseoutDraft, runDagReport } from "../commands/dag-report.js";
@@ -352,6 +353,9 @@ async function runDagAction(repoRoot, subcommand, rest) {
352
353
  case "rerun-task":
353
354
  await runDagRerunTask(repoRoot, args);
354
355
  return;
356
+ case "follow-up":
357
+ await runDagFollowUp(repoRoot, args);
358
+ return;
355
359
  case "reconcile-tasks":
356
360
  await runDagReconcileTasks(repoRoot, args);
357
361
  return;
@@ -511,6 +515,7 @@ function addDagCommand(program, defaultRepoRoot) {
511
515
  "request-interrupt",
512
516
  "rerun",
513
517
  "rerun-task",
518
+ "follow-up",
514
519
  "reconcile-tasks",
515
520
  "final-verification",
516
521
  ], defaultRepoRoot);
@@ -0,0 +1,138 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { FRONTEND_REVIEW_FINDINGS_PRD_REL, readFrontendReviewFindingsPrd, } from "../workflows/dag/frontend-review-findings.js";
4
+ import { locateDagRun } from "../workflows/dag/lifecycle.js";
5
+ import { createTask, getTaskPaths } from "../task/runtime.js";
6
+ import { importPrdDocument } from "../task/source-references.js";
7
+ function parseFlagValue(rawArgs, flag) {
8
+ const index = rawArgs.indexOf(flag);
9
+ if (index < 0 || index + 1 >= rawArgs.length)
10
+ return undefined;
11
+ return rawArgs[index + 1];
12
+ }
13
+ export function parseDagFollowUpArgs(rawArgs) {
14
+ const positional = rawArgs.filter((arg) => !arg.startsWith("-"));
15
+ const parentRunId = positional[0];
16
+ if (!parentRunId) {
17
+ throw new Error("usage: dag follow-up <parent-run-id> [--task-id <id>] [--execute] [--profile <profile>]");
18
+ }
19
+ return {
20
+ parentRunId,
21
+ taskId: parseFlagValue(rawArgs, "--task-id"),
22
+ execute: rawArgs.includes("--execute"),
23
+ profile: parseFlagValue(rawArgs, "--profile"),
24
+ };
25
+ }
26
+ export function deriveFollowUpTaskId(parentRunId) {
27
+ // Deterministic, valid task id derived from the parent run id.
28
+ return `${parentRunId}-followup`;
29
+ }
30
+ /** CLI entry: parse raw args and run the follow-up flow. */
31
+ export async function runDagFollowUp(repoRoot, rawArgs) {
32
+ const parsed = parseDagFollowUpArgs(rawArgs);
33
+ const result = await runDagFollowUpInternal(repoRoot, parsed.parentRunId, {
34
+ taskId: parsed.taskId,
35
+ execute: parsed.execute,
36
+ profile: parsed.profile,
37
+ });
38
+ console.log(JSON.stringify(result, null, 2));
39
+ if (!result.ok) {
40
+ process.exitCode = result.mode === "blocked" ? 2 : 1;
41
+ }
42
+ }
43
+ export async function runDagFollowUpInternal(repoRoot, parentRunId, options = {}) {
44
+ const located = await locateDagRun(repoRoot, parentRunId);
45
+ if (!located) {
46
+ return {
47
+ ok: false,
48
+ mode: "invalid",
49
+ reason: `dag run not found: ${parentRunId}`,
50
+ };
51
+ }
52
+ const prd = await readFrontendReviewFindingsPrd(located.runDir);
53
+ if (!prd) {
54
+ return {
55
+ ok: false,
56
+ mode: "invalid",
57
+ reason: `no frontend review findings PRD found in run ${parentRunId} (${FRONTEND_REVIEW_FINDINGS_PRD_REL}); the review must have committed request_review_changes`,
58
+ };
59
+ }
60
+ const taskId = options.taskId ?? deriveFollowUpTaskId(parentRunId);
61
+ try {
62
+ await createTask(repoRoot, taskId, `修复 review 发现(parent ${parentRunId})`);
63
+ }
64
+ catch (error) {
65
+ return {
66
+ ok: false,
67
+ mode: "blocked",
68
+ reason: `failed to create task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
69
+ };
70
+ }
71
+ // Materialize the PRD draft into the task's source directory so the task
72
+ // lifecycle can import it as its requirement.
73
+ const prdMaterialized = path.join(getTaskPaths(repoRoot, taskId).sourceDir, "review-findings-prd.md");
74
+ try {
75
+ await mkdir(path.dirname(prdMaterialized), { recursive: true });
76
+ await importPrdDocument({
77
+ repoRoot,
78
+ taskId,
79
+ filePath: path.join(located.runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL),
80
+ name: "review-findings-prd",
81
+ });
82
+ }
83
+ catch (error) {
84
+ return {
85
+ ok: false,
86
+ mode: "blocked",
87
+ reason: `failed to import PRD into task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
88
+ };
89
+ }
90
+ if (!options.execute) {
91
+ return {
92
+ ok: true,
93
+ mode: "created",
94
+ taskId,
95
+ prdPath: prdMaterialized,
96
+ next: {
97
+ kind: "advance",
98
+ command: `loop-agent task advance ${taskId} --task-kind frontend-implementation --json`,
99
+ },
100
+ };
101
+ }
102
+ // Execute: advance through the frontend DAG.
103
+ const { advanceTaskLifecycle } = await import("../application/task-lifecycle/advance.js");
104
+ const profile = options.profile === "auto" ||
105
+ options.profile === "minimal" ||
106
+ options.profile === "standard" ||
107
+ options.profile === "reviewed" ||
108
+ options.profile === "supervised"
109
+ ? options.profile
110
+ : undefined;
111
+ const result = await advanceTaskLifecycle({
112
+ repoRoot,
113
+ taskId,
114
+ taskKind: "frontend-implementation",
115
+ taskKindExplicit: true,
116
+ ...(profile ? { profile, profileExplicit: true } : {}),
117
+ observer: undefined,
118
+ });
119
+ const ok = result.lifecycleState === "run-succeeded" ||
120
+ result.lifecycleState === "awaiting-write-set-approval";
121
+ return {
122
+ ok,
123
+ mode: ok ? "created" : "blocked",
124
+ taskId,
125
+ prdPath: prdMaterialized,
126
+ reason: ok
127
+ ? undefined
128
+ : `advance lifecycleState: ${result.lifecycleState}${result.blockers.length > 0
129
+ ? `; blockers: ${result.blockers.map((b) => b.code).join(", ")}`
130
+ : ""}`,
131
+ next: ok
132
+ ? undefined
133
+ : {
134
+ kind: "advance",
135
+ command: `loop-agent task advance ${taskId} --task-kind frontend-implementation --json`,
136
+ },
137
+ };
138
+ }
@@ -219,6 +219,21 @@ const taskConfigObjectSchema = z.object({
219
219
  maxFixLoops: z.number().int().min(0).optional().default(2),
220
220
  /** Supervised DAG convergence is opt-in until runtime smoke evidence is stronger. */
221
221
  convergence: convergenceConfigSchema.optional().default({ enabled: false }),
222
+ /** Frontend recovery continuation quota. Bounds how many times a failed
223
+ * frontend run may auto-recover via M6 recovery operations (1 = current
224
+ * default; raise to 2-3 for more tolerance of transient verify failures). */
225
+ frontendRecovery: z
226
+ .object({
227
+ maxContinuations: z
228
+ .number()
229
+ .int()
230
+ .min(1)
231
+ .max(5)
232
+ .optional()
233
+ .default(1),
234
+ })
235
+ .strict()
236
+ .optional(),
222
237
  /** Outer loop auto mode never writes by default; DAG execute requires this policy plus path/writeSet gates. */
223
238
  loopAutoExecutionPolicy: loopAutoExecutionPolicySchema
224
239
  .optional()
@@ -14,6 +14,7 @@ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
14
14
  // on each other without module-init ordering issues.
15
15
  import { checkDependencies, checkTargetPaths, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
16
16
  import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, persistRuntimeSkeleton, } from "./structured-output-repair.js";
17
+ import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
17
18
  import { isConfigurationVerificationFile, verificationSymbolMatchesContent, } from "./frontend-verification-trace.js";
18
19
  export const frontendNormalizationActionSchema = z.enum([
19
20
  "remove-trailing-commas",
@@ -2075,11 +2076,17 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
2075
2076
  sha256: canonicalArtifact.sha256,
2076
2077
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2077
2078
  };
2079
+ // Human-readable plan summary appended after the artifact pointer. The
2080
+ // pointer remains the machine authority (path/schema/sha256); the
2081
+ // rendered summary is a deterministic view of the canonical contract so
2082
+ // Console/reviewers can read the plan without opening the JSON. It never
2083
+ // participates in downstream validation.
2084
+ const planSummary = renderFrontendPlanMarkdown(analysis.canonical);
2078
2085
  return {
2079
2086
  ok: true,
2080
2087
  contract: analysis.canonical,
2081
2088
  artifact,
2082
- normalizedText: formatStructuredArtifactPointer(artifact),
2089
+ normalizedText: `${formatStructuredArtifactPointer(artifact)}\n\n${planSummary}`,
2083
2090
  };
2084
2091
  }
2085
2092
  catch (error) {
@@ -10,10 +10,17 @@ import { readTypedEventStoreFromJsonl, } from "./frontend-typed-event-store.js";
10
10
  * rendered into a downstream-task PRD draft that a later DAG round can consume
11
11
  * instead of repeating the previous flow. Rendering is deterministic (pure);
12
12
  * only the JSONL read and PRD write are IO.
13
+ *
14
+ * The design-review analog (`request_design_changes`) reuses the same PRD
15
+ * rendering and typed-fact channel: a design rejection is a definite finding
16
+ * (not a protocol/environment error), so it auto-re-enters the next plan round
17
+ * with the committed design findings instead of terminating the run.
13
18
  */
14
19
  export const FRONTEND_REVIEW_FINDINGS_PRD_REL = "contracts/frontend-review-findings-prd.md";
15
20
  /** Review node id whose committed typed facts drive the findings PRD. */
16
21
  export const FRONTEND_REVIEW_NODE_ID = "frontend-review-pi";
22
+ /** Design review node id whose committed typed facts drive the findings PRD. */
23
+ export const FRONTEND_DESIGN_REVIEW_NODE_ID = "frontend-design-review-pi";
17
24
  /** Deterministic issueCategory → failure owner mapping (A+B AC-009 §4.10). */
18
25
  export function classifyReviewIssueCategoryFailureOwner(issueCategory) {
19
26
  switch (issueCategory) {
@@ -122,10 +129,34 @@ export function deriveFrontendReviewFixScope(fact) {
122
129
  * approve_review, missing store, or an unreadable store).
123
130
  */
124
131
  export async function readCommittedReviewRequestFact(input) {
132
+ const { runDir: reviewRunDir } = input;
125
133
  const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
134
+ return readCommittedRequestFactByKind({
135
+ runDir: reviewRunDir,
136
+ nodeId: reviewNodeId,
137
+ factsFile: "review-typed-facts.jsonl",
138
+ kind: "request_review_changes",
139
+ });
140
+ }
141
+ /**
142
+ * Read the committed `request_design_changes` fact from the design review
143
+ * node's typed event store. Returns undefined when no committed request fact
144
+ * exists (e.g. approve_design, missing store, or an unreadable store).
145
+ */
146
+ export async function readCommittedDesignRequestFact(input) {
147
+ const { runDir: designRunDir } = input;
148
+ const designNodeId = input.designNodeId ?? FRONTEND_DESIGN_REVIEW_NODE_ID;
149
+ return readCommittedRequestFactByKind({
150
+ runDir: designRunDir,
151
+ nodeId: designNodeId,
152
+ factsFile: "design-typed-facts.jsonl",
153
+ kind: "request_design_changes",
154
+ });
155
+ }
156
+ async function readCommittedRequestFactByKind(input) {
126
157
  let records;
127
158
  try {
128
- records = await readTypedEventStoreFromJsonl(path.join(input.runDir, reviewNodeId, "review-typed-facts.jsonl"));
159
+ records = await readTypedEventStoreFromJsonl(path.join(input.runDir, input.nodeId, input.factsFile));
129
160
  }
130
161
  catch {
131
162
  return undefined;
@@ -133,9 +164,7 @@ export async function readCommittedReviewRequestFact(input) {
133
164
  const committed = records.filter((record) => record.phase === "committed");
134
165
  if (committed.length === 0)
135
166
  return undefined;
136
- // A committed approve_review means the review passed; only a committed
137
- // request_review_changes carries a "not passed" verdict.
138
- const request = committed.find((record) => record.fact.kind === "request_review_changes");
167
+ const request = committed.find((record) => record.fact.kind === input.kind);
139
168
  if (!request)
140
169
  return undefined;
141
170
  const fact = request.fact;
@@ -166,25 +195,28 @@ function findingLines(findings) {
166
195
  }
167
196
  /**
168
197
  * Deterministically render a downstream-task PRD draft from a committed
169
- * `request_review_changes` fact. Same fact always renders byte-identical
170
- * output; the parent run id and review node id are the only context.
198
+ * `request_review_changes` / `request_design_changes` fact. Same fact always
199
+ * renders byte-identical output; the parent run id and review node id are the
200
+ * only context.
171
201
  */
172
202
  export function renderFrontendReviewFindingsPrd(input) {
173
203
  const { parentRunId, fact } = input;
174
- const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
204
+ const isDesign = fact.kind === "request_design_changes";
205
+ const reviewNodeId = input.reviewNodeId ??
206
+ (isDesign ? FRONTEND_DESIGN_REVIEW_NODE_ID : FRONTEND_REVIEW_NODE_ID);
175
207
  const restartPhase = classifyReviewIssueCategoryFailureOwner(fact.issueCategory);
176
208
  const scope = deriveFrontendReviewFixScope(fact);
177
209
  const targetSummary = scope.targetFiles.length > 0
178
210
  ? scope.targetFiles.join(", ")
179
211
  : fact.issueCategory;
180
212
  return [
181
- `# 修复 review 发现(${scope.shape}):${targetSummary}`,
213
+ `# 修复 ${isDesign ? "设计" : "review"} 发现(${scope.shape}):${targetSummary}`,
182
214
  "",
183
215
  "## 背景",
184
216
  "",
185
217
  `- parentRunId: \`${parentRunId}\``,
186
218
  `- reviewNodeId: \`${reviewNodeId}\``,
187
- `- typedVerdict: \`request_review_changes\``,
219
+ `- typedVerdict: \`${fact.kind}\``,
188
220
  `- issueCategory: \`${fact.issueCategory}\``,
189
221
  `- restartPhase/failureOwner: \`${restartPhase}\``,
190
222
  "",
@@ -216,7 +248,7 @@ export function renderFrontendReviewFindingsPrd(input) {
216
248
  "",
217
249
  `- 按 issueCategory 映射的重启阶段(${restartPhase})定点修复,不复用上一轮未通过的实现。`,
218
250
  "- 逐条关闭上面「问题清单」中的 findings,并保留上一轮已通过的验证目标继续有效。",
219
- "- 完成修复后重新走 frontend DAG 的 implement → verify → review 链,直到 review 提交 approve_review",
251
+ `- 完成修复后重新走 frontend DAG 的 ${isDesign ? "plan → design" : "implement → verify → review"} 链,直到${isDesign ? " design-review 提交 approve_design" : " review 提交 approve_review"}。`,
220
252
  "",
221
253
  ].join("\n");
222
254
  }
@@ -3384,6 +3384,10 @@ async function buildFrontendHybridDagFromTask(sources) {
3384
3384
  ...(spec.advisories ?? []),
3385
3385
  `frontend-task-shape: ${frontendTaskShape.shape} (${frontendTaskShape.reason})`,
3386
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 };
3387
3391
  applyDefaultReadOnlyRetryPolicy(spec);
3388
3392
  parseDagSpec(spec);
3389
3393
  assertValidDagSpec(spec);
@@ -2,7 +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_REVIEW_NODE_ID, readCommittedReviewRequestFact, } from "./frontend-review-findings.js";
5
+ import { FRONTEND_DESIGN_REVIEW_NODE_ID, FRONTEND_REVIEW_NODE_ID, readCommittedDesignRequestFact, readCommittedReviewRequestFact, } from "./frontend-review-findings.js";
6
6
  export const MAX_RERUN_FEEDBACK_CHARS = 12_000;
7
7
  const RERUN_FEEDBACK_TRUNCATION_MARKER = "\n\n...[上一轮反馈已按长度上限截断]...\n\n";
8
8
  const MAX_EVIDENCE_REFS = 8;
@@ -183,6 +183,24 @@ async function readTypedReviewRequestFact(runDir) {
183
183
  },
184
184
  };
185
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
+ }
186
204
  /**
187
205
  * Derive only the latest unresolved verify/review feedback from canonical
188
206
  * parent-run facts. Provider-only failures intentionally return undefined.
@@ -201,12 +219,15 @@ export async function deriveDagRerunFeedback(input) {
201
219
  }
202
220
  }
203
221
  if (!sourceNodeId || !sourceNode) {
204
- // Typed terminal channel: the frontend review commits its verdict as a
205
- // typed fact (request_review_changes) instead of VERDICT text, and the
206
- // frontend DAG has no convergence passHistory. Fall back to reading the
207
- // review node's typed event store so findings still flow into the next
208
- // round as unresolved feedback.
209
- const typedFact = await readTypedReviewRequestFact(input.runDir);
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));
210
231
  if (typedFact) {
211
232
  const parentSourceBindingHash = sourceBindingHash(input.spec);
212
233
  return withDigest({
@@ -216,7 +237,9 @@ export async function deriveDagRerunFeedback(input) {
216
237
  taskId: input.taskId,
217
238
  sourceNodeId: typedFact.nodeId,
218
239
  verdict: "request-revision",
219
- failureCategory: "review-request-changes",
240
+ failureCategory: typedFact.nodeId === FRONTEND_REVIEW_NODE_ID
241
+ ? "review-request-changes"
242
+ : "design-request-changes",
220
243
  feedbackText: scrubAndBoundFeedbackText(formatTypedReviewRequestFeedback(typedFact.fact), input.runDir, input.state.cwd),
221
244
  evidenceRefs: [
222
245
  {
@@ -536,14 +536,16 @@ function reasonMandatedFrontendPlanRetryStep(reason) {
536
536
  }
537
537
  }
538
538
  /**
539
- * Opt-in retry policy mounted on the frontend-plan-pi node. maxAttempts=5 is
540
- * the schema cap, matching the four-rung ladder plus one non-converging stop.
541
- * `invalid-output` (typed-fact schema violations, e.g. verification targets
542
- * referencing undeclared UI states) retries at the `normal` rung with the
543
- * 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).
544
546
  */
545
547
  export const FRONTEND_PLAN_LADDER_RETRY_POLICY = {
546
- maxAttempts: 5,
548
+ maxAttempts: 3,
547
549
  backoff: "exponential",
548
550
  initialDelayMs: 2000,
549
551
  maxDelayMs: 30000,
@@ -26,7 +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, readCommittedReviewRequestFact, writeFrontendReviewFindingsPrd, } from "./frontend-review-findings.js";
29
+ import { classifyReviewIssueCategoryFailureOwner, readCommittedDesignRequestFact, readCommittedReviewRequestFact, writeFrontendReviewFindingsPrd, } from "./frontend-review-findings.js";
30
30
  import { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas, commitRuntimeFrontendShapeFact, isCanonicalFrontendShapeDag, } from "./frontend-shape-facts.js";
31
31
  import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFrontendShapeSourceDigest, } from "./frontend-shape.js";
32
32
  import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
@@ -794,7 +794,7 @@ async function executeDagCheckpoint(input) {
794
794
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
795
795
  }
796
796
  else {
797
- 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);
798
798
  await finalizeRunOwnedInterrupt({
799
799
  runDir,
800
800
  state,
@@ -1042,7 +1042,7 @@ function buildRecoveryResultForTrigger(state, trigger, outcome) {
1042
1042
  evidenceRefs: [],
1043
1043
  };
1044
1044
  }
1045
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1045
+ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
1046
1046
  const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
1047
1047
  if (state.frontendShapeState?.terminalOutcome &&
1048
1048
  state.frontendShapeState.transitionCapsule) {
@@ -1064,16 +1064,53 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1064
1064
  // every other blocked admission is terminal.
1065
1065
  const planContractInvalid = admission.result.classification === "blocked" &&
1066
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;
1067
1080
  const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
1068
1081
  admission.result.classification === "stale") &&
1069
- !planContractInvalid;
1082
+ !planContractInvalid &&
1083
+ !designRejected;
1070
1084
  if (terminalAdmissionDenial) {
1071
1085
  state.status = "failed";
1072
1086
  return { recoveryPending: false };
1073
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
+ }
1074
1111
  if (planContractInvalid) {
1075
1112
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1076
- if (continuationCount >= 1) {
1113
+ if (continuationCount >= frontendRecoveryQuota) {
1077
1114
  // Continuation quota exhausted: a second contract-invalid candidate
1078
1115
  // is authoritative candidate-contract-invalid → forced failed.
1079
1116
  state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
@@ -1109,7 +1146,7 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1109
1146
  });
1110
1147
  if (writerNodeId) {
1111
1148
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1112
- if (continuationCount < 1) {
1149
+ if (continuationCount < frontendRecoveryQuota) {
1113
1150
  const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
1114
1151
  const rollback = await rollbackFrontendWriter({
1115
1152
  cwd: repoRoot,
@@ -1141,7 +1178,7 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1141
1178
  const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
1142
1179
  if (verifyFailed) {
1143
1180
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1144
- if (continuationCount < 1) {
1181
+ if (continuationCount < frontendRecoveryQuota) {
1145
1182
  recoveryTrigger = {
1146
1183
  requestId: state.runId,
1147
1184
  failureSource: "frontend-verify-shell",
@@ -1179,7 +1216,7 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
1179
1216
  }
1180
1217
  if (!recoveryTrigger && !skipFrontendRecovery) {
1181
1218
  const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
1182
- if (continuationCount < 1) {
1219
+ if (continuationCount < frontendRecoveryQuota) {
1183
1220
  recoveryTrigger = {
1184
1221
  requestId: state.runId,
1185
1222
  failureSource: "frontend-review-pi",
@@ -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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.39.0-beta.7",
3
+ "version": "0.39.0-beta.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",