agentflowctl 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -7,14 +7,15 @@ import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
7
7
  import { escapeXml, opinion, reviewIssue } from "./feedback.js";
8
8
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
9
9
  import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
10
- import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
10
+ import { flowDir, logDir, planArbitrationPath, planReviewStatePath, projectRoot, runDir, worktreeDir } from "./paths.js";
11
11
  import { CMD_AGENT, nextLogFile } from "./logs.js";
12
12
  import { exec } from "./proc.js";
13
13
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
14
14
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
15
15
  import { AcceptanceList, ArbiterResult, ConsistentReviewResult, RepoConfig, TaskList, } from "./schemas.js";
16
- import { addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
16
+ import { addRetry, addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
17
17
  import { clearModelReviewFailure, clearModelReviewStage, recordModelReviewFailure, selectModel } from "./modelSelection.js";
18
+ import { applyReviewVerdicts, dirtyGroups, dirtyTaskIds, extractPlanEvidence, groupReviewerCount, layeredReview, neighborTasks, planContentKey, planOverview, planReviewIndex, readPendingArbitration, readPlanReviewState, repliesForTasks, reviewFingerprint, roundProgress, } from "./planReview.js";
18
19
  import { orderTasks, taskAcceptance, validateTaskComplexity } from "./tasks.js";
19
20
  import { readJsonFile, renderPrompt, tail } from "./util.js";
20
21
  // ───────────────────────── 共用工具 ─────────────────────────
@@ -33,6 +34,10 @@ export class QuotaPause extends Error {
33
34
  }
34
35
  /** 這次執行中已確認額度用完的 agent;程序結束即清空,resume 時會重新嘗試 */
35
36
  const exhausted = new Set();
37
+ /** 只供測試使用:清掉這個程序內記下的額度用完 agent,模擬新啟動的程序 */
38
+ export function resetQuotaState() {
39
+ exhausted.clear();
40
+ }
36
41
  function handoffTarget(run) {
37
42
  return ["spec", "plan", "plan_review", "plan_fix"].includes(run.stage) ? "plan" : "code";
38
43
  }
@@ -61,7 +66,7 @@ async function agentStep(run, planned, step, prompt, mode) {
61
66
  info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
62
67
  agent = sub;
63
68
  }
64
- const selected = selectModel(run, cfg, agent, step, /^T-\d+-/.test(step) ? loadOrderedTasks(run)[run.taskIndex]?.complexity : undefined, mode.kind === "review" ? agent : undefined);
69
+ const selected = selectModel(run, cfg, agent, step, /^T-\d+-/.test(step) ? loadOrderedTasks(run)[run.taskIndex]?.complexity : undefined, mode.kind === "review" ? agent : undefined, mode.modelScope);
65
70
  info(run, `🤖 ${step}:${agent} 使用 ${selected.name ?? "CLI 預設(名稱未知)"}${selected.insufficient ? `(低於目標 ${selected.targetStrength})` : ""}`);
66
71
  const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
67
72
  prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
@@ -112,18 +117,24 @@ function reportMeta(run, agent, r) {
112
117
  if (r.meta.concerns)
113
118
  info(run, ` 💭 ${agent} 的疑慮:${r.meta.concerns}`);
114
119
  }
115
- function readFeedback(run) {
116
- const p = flowFile(run, "feedback.md");
120
+ /** 讀 .flow/ 下的文字檔,沒有檔就當空字串 */
121
+ function flowText(run, name) {
122
+ const p = flowFile(run, name);
117
123
  return existsSync(p) ? readFileSync(p, "utf8") : "";
118
124
  }
119
- /** 關卡未通過:寫入 feedback.md 給下一次嘗試參考,超過上限就讓整個 run 失敗 */
120
- function retry(run, key, reason, backTo) {
125
+ function readFeedback(run) {
126
+ return flowText(run, "feedback.md");
127
+ }
128
+ /** 關卡未通過:寫入 feedback.md 給下一次嘗試參考,並把原因分類記進 retries.jsonl;超過上限就讓整個 run 失敗 */
129
+ function retry(run, key, reason, backTo, category) {
121
130
  const n = (run.attempts[key] ?? 0) + 1;
122
131
  const attempts = { ...run.attempts, [key]: n };
132
+ const final = n >= config.maxAttempts;
123
133
  mkdirSync(flowDir(run.id), { recursive: true });
124
134
  writeFileSync(flowFile(run, "feedback.md"), `# 前次嘗試未通過(第 ${n} 次)\n\n${reason}\n`);
125
- if (n >= config.maxAttempts) {
126
- return { ...run, attempts, stage: "failed", failedStage: backTo, failureReason: `${key} 連續失敗 ${n} 次:${tail(reason, 500)}` };
135
+ addRetry(run.id, { key, backTo, category, attempt: n, final }, run.updatedAt);
136
+ if (final) {
137
+ return { ...run, attempts, stage: "failed", failedStage: backTo, failureCategory: "retry_limit", failureReason: `${key} 連續失敗 ${n} 次:${tail(reason, 500)}` };
127
138
  }
128
139
  info(run, `⚠️ ${key} 未通過,重試(${n}/${config.maxAttempts})`);
129
140
  return { ...run, attempts, stage: backTo };
@@ -161,24 +172,26 @@ async function specStage(run) {
161
172
  const { r } = outcome;
162
173
  await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
163
174
  if (!r.ok)
164
- return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec");
175
+ return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec", "agent_error");
165
176
  if (!existsSync(flowFile(run, "spec.md")))
166
- return retry(run, "spec", "缺少 .flow/spec.md", "spec");
177
+ return retry(run, "spec", "缺少 .flow/spec.md", "spec", "missing_artifact");
167
178
  const ac = readJsonFile(flowFile(run, "acceptance.json"), AcceptanceList);
168
179
  if (!ac.ok)
169
- return retry(run, "spec", ac.error, "spec");
180
+ return retry(run, "spec", ac.error, "spec", "format_invalid");
170
181
  const ids = ac.data.map((a) => a.id);
171
182
  if (new Set(ids).size !== ids.length)
172
- return retry(run, "spec", "驗收條件 id 有重複", "spec");
183
+ return retry(run, "spec", "驗收條件 id 有重複", "spec", "format_invalid");
173
184
  const handoffError = finishHandoff(run, outcome, "writer");
174
185
  if (handoffError)
175
- return retry(run, "spec", handoffError, "spec");
186
+ return retry(run, "spec", handoffError, "spec", "handoff_invalid");
176
187
  return succeed(run, "spec", "plan");
177
188
  }
178
189
  // ── 規格與計畫檔案:計畫審查、仲裁與計畫定案後的所有階段只能讀,不能改 ──
179
190
  const PLAN_FILES = ["spec.md", "acceptance.json", "plan.md", "tasks.json"];
180
191
  /** 計畫定案後(實作、修正、程式碼審查)另外依賴排好的任務順序,同樣不能被改 */
181
192
  const LOCKED_FILES = [...PLAN_FILES, "tasks.ordered.json"];
193
+ /** 審查、修訂與仲裁的快照另外包含審查回應;不要併進 PLAN_FILES,定案後的階段不依賴它 */
194
+ const PLAN_REPLY_FILES = [...PLAN_FILES, "plan-replies.md"];
182
195
  function snapshotPlan(run, files = PLAN_FILES) {
183
196
  return Object.fromEntries(files.map((f) => [f, existsSync(flowFile(run, f)) ? readFileSync(flowFile(run, f), "utf8") : undefined]));
184
197
  }
@@ -232,7 +245,7 @@ function announceTasks(run, ordered) {
232
245
  function planSettled(run, key) {
233
246
  const pending = openActions(readHandoff(run.id), "plan");
234
247
  if (pending.length)
235
- return retry(run, "plan-handoff", `計畫仍有未結交接事項:${pending.map((item) => item.id).join("、")}`, "plan_fix");
248
+ return retry(run, "plan-handoff", `計畫仍有未結交接事項:${pending.map((item) => item.id).join("、")}`, "plan_fix", "open_handoff");
236
249
  const next = run.autopilot ? "implement" : "awaiting_approval";
237
250
  const ordered = loadOrderedTasks(run);
238
251
  announceTasks(run, ordered);
@@ -252,59 +265,75 @@ async function planStage(run) {
252
265
  const { r, agent: actual } = outcome;
253
266
  await discardChanges(worktreeDir(run.id));
254
267
  if (!r.ok)
255
- return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan");
268
+ return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan", "agent_error");
256
269
  const ordered = validatePlan(run);
257
270
  if (typeof ordered === "string")
258
- return retry(run, "plan", ordered, "plan");
271
+ return retry(run, "plan", ordered, "plan", "format_invalid");
259
272
  const handoffError = finishHandoff(run, outcome, "writer");
260
273
  if (handoffError)
261
- return retry(run, "plan", handoffError, "plan");
274
+ return retry(run, "plan", handoffError, "plan", "handoff_invalid");
262
275
  acceptPlan(run, ordered);
263
276
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
264
277
  return { ...succeed(run, "plan", "plan_review"), planWriter: actual };
265
278
  }
266
279
  async function planReviewStage(run) {
267
280
  const cfg = loadRepoConfig();
268
- const author = run.planWriter ?? planAgent(run.cycle, run.id);
269
- const round = (run.attempts["plan-review"] ?? 0) + 1;
270
- const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
271
- const issues = [];
272
- const issueLines = []; // 只含意見內容、不含審查者名稱,用來偵測僵持
273
- let firstObjector;
274
- for (const reviewer of panel) {
275
- info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
276
- const snap = snapshotPlan(run);
277
- rmSync(flowFile(run, "plan-review.json"), { force: true });
278
- const outcome = await agentStep(run, reviewer, "plan-review", renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }), { kind: "review", slot: panel.indexOf(reviewer), reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
279
- const { r } = outcome;
280
- await discardChanges(worktreeDir(run.id));
281
- const tampered = restorePlan(run, snap);
282
- if (tampered.length)
283
- info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
284
- if (!r.ok)
285
- return retry(recordModelReviewFailure(run, "plan-review", reviewer), "plan-review-run", `Agent 執行失敗:${r.summary}`, "plan_review");
286
- const review = readJsonFile(flowFile(run, "plan-review.json"), ConsistentReviewResult);
287
- if (!review.ok)
288
- return retry(recordModelReviewFailure(run, "plan-review", reviewer), "plan-review-run", review.error, "plan_review");
289
- const handoffError = finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: review.data.verdict });
290
- if (handoffError)
291
- return retry(recordModelReviewFailure(run, "plan-review", reviewer), "plan-review-run", handoffError, "plan_review");
292
- run = clearModelReviewFailure(run, "plan-review", reviewer);
293
- // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
294
- mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
295
- renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
296
- if (review.data.verdict === "approve") {
297
- info(run, ` ✓ ${reviewer} 核准計畫`);
298
- continue;
299
- }
300
- info(run, ` ✗ ${reviewer} 要求修改計畫`);
301
- firstObjector ??= reviewer;
302
- const lines = review.data.items
303
- .filter((i) => i.status !== "met")
304
- .map((i) => reviewIssue(i.criterion, i.status, i.note));
305
- issueLines.push(...lines);
306
- issues.push(opinion(reviewer, lines));
281
+ const pending = pendingArbitration(run, cfg);
282
+ if (pending) {
283
+ info(run, "⚖️ 上次仲裁沒有完成,直接回到仲裁(不重跑計畫審查)");
284
+ return arbitratePlan({ ...run, planReviewer: pending.planReviewer });
285
+ }
286
+ const layered = loadLayeredPlan(run, cfg);
287
+ return layered ? planReviewLayered(run, layered, cfg) : planReviewFull(run, cfg);
288
+ }
289
+ /**
290
+ * 整份審查與分層審查共用的單次呼叫:還原審查者改過的計畫檔、驗證裁決與交接。
291
+ * 失敗時回傳已呼叫 retry 的 run、沒有 collected,呼叫端應直接回傳這個 run。
292
+ */
293
+ async function collectPlanReview(run, spec) {
294
+ const { reviewer, step } = spec;
295
+ const snap = snapshotPlan(run, PLAN_REPLY_FILES);
296
+ rmSync(flowFile(run, spec.output), { force: true });
297
+ const outcome = await agentStep(run, reviewer, step, spec.prompt, {
298
+ kind: "review", slot: spec.slot, modelScope: spec.scope,
299
+ reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
300
+ });
301
+ await discardChanges(worktreeDir(run.id));
302
+ const tampered = restorePlan(run, snap);
303
+ if (tampered.length)
304
+ info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
305
+ const stop = (category, reason) => ({
306
+ run: retry(recordModelReviewFailure(run, step, reviewer, spec.scope), "plan-review-run", reason, "plan_review", category),
307
+ });
308
+ if (!outcome.r.ok)
309
+ return stop("agent_error", `Agent 執行失敗:${outcome.r.summary}`);
310
+ const review = readJsonFile(flowFile(run, spec.output), ConsistentReviewResult);
311
+ if (!review.ok)
312
+ return stop("format_invalid", review.error);
313
+ // 群審查不帶關卡:索引要求修改並新增事項後,群的核准不算矛盾;最後由 planSettled 檢查未結事項
314
+ const handoffError = finishHandoff(run, outcome, "reviewer", spec.gated ? { target: "plan", verdict: review.data.verdict } : undefined);
315
+ if (handoffError)
316
+ return stop("handoff_invalid", handoffError);
317
+ run = clearModelReviewFailure(run, step, reviewer, spec.scope);
318
+ // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
319
+ mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
320
+ renameSync(flowFile(run, spec.output), join(runDir(run.id), "reviews", spec.archive));
321
+ if (review.data.verdict === "approve") {
322
+ info(run, ` ✓ ${reviewer} 核准${spec.subject}`);
323
+ return { run, collected: { reviewer, verdict: "approve", issueLines: [] } };
307
324
  }
325
+ info(run, ` ✗ ${reviewer} 要求修改${spec.subject}`);
326
+ const issueLines = review.data.items
327
+ .filter((i) => i.status !== "met")
328
+ .map((i) => reviewIssue(i.criterion, i.status, i.note));
329
+ return { run, collected: { reviewer, verdict: "changes_requested", issueLines } };
330
+ }
331
+ /** 一輪審查都產出合法裁決後:全部核准就定案,否則退回修訂,僵持或達輪數上限時交付仲裁 */
332
+ async function concludePlanReview(run, cfg, round, calls) {
333
+ const objections = calls.filter((call) => call.verdict !== "approve");
334
+ const issueLines = objections.flatMap((call) => call.issueLines);
335
+ const issues = objections.map((call) => opinion(call.reviewer, call.issueLines));
336
+ const firstObjector = objections[0]?.reviewer;
308
337
  run = clearModelReviewStage(run, "plan-review");
309
338
  const attempts = { ...run.attempts };
310
339
  delete attempts["plan-review-run"];
@@ -314,7 +343,7 @@ async function planReviewStage(run) {
314
343
  const report = `計畫審查要求修改:\n\n${issues.join("\n\n")}`;
315
344
  // 僵持偵測:意見和上一輪完全相同,代表修改沒有進展
316
345
  const lastPath = flowFile(run, "plan-review-last.txt");
317
- const fingerprint = [...issueLines].sort().join("\n");
346
+ const fingerprint = reviewFingerprint(issueLines);
318
347
  const stalled = existsSync(lastPath) && readFileSync(lastPath, "utf8") === fingerprint;
319
348
  writeFileSync(lastPath, fingerprint);
320
349
  const exhausted = round >= config.maxAttempts;
@@ -326,9 +355,165 @@ async function planReviewStage(run) {
326
355
  // 給仲裁者的爭議清單不含任何模型名稱
327
356
  writeFileSync(flowFile(run, "dispute.md"), `# 尚未解決的審查意見\n\n${[...new Set(issueLines)].join("\n")}\n`);
328
357
  info(run, `⚖️ 計畫審查${stalled ? "意見沒有變化" : `已達 ${round} 輪`},交付仲裁`);
358
+ // 仲裁暫停(裁決無效或額度用完)後 resume 仍停在 plan_review,靠這份紀錄直接回到仲裁
359
+ mkdirSync(runDir(run.id), { recursive: true });
360
+ writeFileSync(planArbitrationPath(run.id), JSON.stringify({ planReviewer: firstObjector, planKey: currentPlanKey(run) }, null, 2));
329
361
  return arbitratePlan({ ...run, planReviewer: firstObjector });
330
362
  }
331
- return { ...retry(run, "plan-review", report, "plan_fix"), planReviewer: firstObjector };
363
+ return { ...retry(run, "plan-review", report, "plan_fix", "review_changes"), planReviewer: firstObjector };
364
+ }
365
+ /** 整份審查:每位審查者讀完整份規格與計畫 */
366
+ async function planReviewFull(run, cfg) {
367
+ // 整份審查不維護分層狀態;之後若又切回分層,不能沿用這之前的 approve
368
+ rmSync(planReviewStatePath(run.id), { force: true });
369
+ const author = run.planWriter ?? planAgent(run.cycle, run.id);
370
+ const round = (run.attempts["plan-review"] ?? 0) + 1;
371
+ const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
372
+ const calls = [];
373
+ for (const [slot, reviewer] of panel.entries()) {
374
+ info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
375
+ const passed = await collectPlanReview(run, {
376
+ reviewer, step: "plan-review", slot, gated: true, subject: "計畫",
377
+ prompt: renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }),
378
+ output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
379
+ });
380
+ run = passed.run;
381
+ if (!passed.collected)
382
+ return run;
383
+ calls.push(passed.collected);
384
+ }
385
+ return concludePlanReview(run, cfg, round, calls);
386
+ }
387
+ /** 本輪進度的雜湊涵蓋的檔案:任一份變了,就不能沿用先前的審查結果 */
388
+ const PLAN_KEY_FILES = ["tasks.json", "acceptance.json", "plan.md", "plan-replies.md"];
389
+ function currentPlanKey(run) {
390
+ return planContentKey(PLAN_KEY_FILES.map((name) => flowText(run, name)));
391
+ }
392
+ /**
393
+ * 上次交付仲裁卻沒有得出裁決(暫停)時的紀錄。計畫在這之間被改過、或爭議清單不見了,
394
+ * 就當成沒有待完成的仲裁並刪掉紀錄,重新審查。暫停期間關掉了 planArbiter 也一樣,
395
+ * 連同爭議清單一起刪掉。
396
+ */
397
+ function pendingArbitration(run, cfg) {
398
+ const path = planArbitrationPath(run.id);
399
+ if (!existsSync(path))
400
+ return undefined;
401
+ const pending = readPendingArbitration(readFileSync(path, "utf8"));
402
+ if (cfg.planArbiter && pending && pending.planKey === currentPlanKey(run) && existsSync(flowFile(run, "dispute.md")))
403
+ return pending;
404
+ rmSync(path, { force: true });
405
+ if (!cfg.planArbiter)
406
+ rmSync(flowFile(run, "dispute.md"), { force: true });
407
+ return undefined;
408
+ }
409
+ /** 符合分層條件時讀出分層審查需要的資料;不符合時回傳 undefined,已達門檻的會印出原因 */
410
+ function loadLayeredPlan(run, cfg) {
411
+ const tasks = readJsonFile(flowFile(run, "tasks.json"), TaskList);
412
+ const acceptance = readJsonFile(flowFile(run, "acceptance.json"), AcceptanceList);
413
+ if (!tasks.ok || !acceptance.ok)
414
+ return undefined;
415
+ const planMd = flowText(run, "plan.md");
416
+ const decision = layeredReview(tasks.data, planMd, cfg.planReviewLayers);
417
+ if (!decision.layered) {
418
+ if (decision.reason)
419
+ info(run, `📋 這次計畫審查讀整份計畫:${decision.reason}`);
420
+ return undefined;
421
+ }
422
+ const statePath = planReviewStatePath(run.id);
423
+ const state = existsSync(statePath) ? readPlanReviewState(readFileSync(statePath, "utf8")) : undefined;
424
+ return {
425
+ tasks: tasks.data, acceptance: acceptance.data, planMd, state,
426
+ dirty: dirtyGroups(decision.groups, dirtyTaskIds(state?.reviewed, tasks.data, acceptance.data, planMd)),
427
+ };
428
+ }
429
+ /** 分層審查:每輪一次索引審查,再只審有變動的任務群 */
430
+ async function planReviewLayered(run, layered, cfg) {
431
+ const author = run.planWriter ?? planAgent(run.cycle, run.id);
432
+ const round = (run.attempts["plan-review"] ?? 0) + 1;
433
+ const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
434
+ const replies = flowText(run, "plan-replies.md");
435
+ const statePath = planReviewStatePath(run.id);
436
+ const reviewed = layered.state?.reviewed;
437
+ const planKey = currentPlanKey(run);
438
+ // 同一輪因某一呼叫失敗而重跑時,沿用已成功的呼叫:不重複付費,已套用的交接也不會被重新提出
439
+ const progress = roundProgress(layered.state, round, planKey) ?? { round, planKey, calls: [] };
440
+ const saveState = (state) => {
441
+ mkdirSync(runDir(run.id), { recursive: true });
442
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
443
+ };
444
+ const done = new Map(progress.calls.map((call) => [call.key, call]));
445
+ const calls = [];
446
+ // 沿用的呼叫也佔一格,重跑時每個呼叫的 slot 才不會變
447
+ let nextSlot = 0;
448
+ /** 執行或沿用一次呼叫;失敗時回傳 false,run 已是 retry 後的狀態 */
449
+ const runCall = async (key, taskIds, label, spec) => {
450
+ const slot = nextSlot++;
451
+ const reused = done.get(key);
452
+ if (reused) {
453
+ info(run, ` ↪ 沿用本輪已完成的${label}(${reused.reviewer})`);
454
+ calls.push(reused);
455
+ return true;
456
+ }
457
+ info(run, `🧐 ${label}第 ${round} 輪(${spec.reviewer},作者 ${author})`);
458
+ // run 是外層參數,刻意在閉包裡更新:後續呼叫與最後的彙總都要看到 retry、clearModelReviewFailure 之後的 run
459
+ const passed = await collectPlanReview(run, { ...spec, slot });
460
+ run = passed.run;
461
+ if (!passed.collected)
462
+ return false;
463
+ // 真的執行並成功就是有進展:同一輪不同呼叫輪流失敗時,不會累計到重試上限而讓 run 失敗。
464
+ // 進度寫在 round 裡、不會重跑,所以一輪最多失敗「呼叫數 × maxAttempts」次。
465
+ // 整份審查每次重跑整輪,不能這樣歸零,否則同一位審查者反覆失敗會無限重試。
466
+ const attempts = { ...run.attempts };
467
+ delete attempts["plan-review-run"];
468
+ run = { ...run, attempts };
469
+ const call = { key, ...passed.collected, ...(taskIds ? { taskIds } : {}) };
470
+ progress.calls.push(call);
471
+ calls.push(call);
472
+ saveState({ version: 1, ...(reviewed ? { reviewed } : {}), round: progress });
473
+ return true;
474
+ };
475
+ for (const reviewer of panel) {
476
+ const ok = await runCall(`index:${reviewer}`, undefined, "計畫索引審查", {
477
+ reviewer, step: "plan-review", gated: true, subject: "計畫索引",
478
+ prompt: renderPrompt("plan-review-index", {
479
+ reviewer, author, requirement: run.requirement,
480
+ index: planReviewIndex(layered.tasks),
481
+ acceptance: JSON.stringify(layered.acceptance, null, 2),
482
+ overview: planOverview(layered.planMd),
483
+ replies,
484
+ }),
485
+ output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
486
+ });
487
+ if (!ok)
488
+ return run;
489
+ }
490
+ for (const group of layered.dirty) {
491
+ const groupTasks = layered.tasks.filter((task) => group.taskIds.includes(task.id));
492
+ const acceptanceIds = new Set(groupTasks.flatMap((task) => task.acceptance));
493
+ const neighbors = neighborTasks(layered.tasks, group.taskIds).map(({ id, title, description, dependsOn }) => ({ id, title, description, dependsOn }));
494
+ const groupPanel = reviewers(run.cycle, author, groupReviewerCount(groupTasks, cfg.planReviewQuorum), `${run.id}:plan-group:${group.id}:${round}`);
495
+ for (const reviewer of groupPanel) {
496
+ const ok = await runCall(`group:${group.id}:${group.taskIds.join(",")}:${reviewer}`, group.taskIds, `計畫群 ${group.id} 審查`, {
497
+ reviewer, step: "plan-review-group", gated: false, subject: `任務群 ${group.id}`, scope: group.id,
498
+ prompt: renderPrompt("plan-review-group", {
499
+ reviewer, author, groupId: group.id,
500
+ files: group.files.join("、") || "(這群的描述沒有點名檔案)",
501
+ tasks: JSON.stringify(groupTasks, null, 2),
502
+ neighbors: neighbors.length ? JSON.stringify(neighbors, null, 2) : "(沒有跨群的直接相依)",
503
+ acceptance: JSON.stringify(layered.acceptance.filter((item) => acceptanceIds.has(item.id)), null, 2),
504
+ evidence: extractPlanEvidence(layered.planMd, group.taskIds),
505
+ replies: repliesForTasks(replies, group.taskIds),
506
+ }),
507
+ output: "plan-review-group.json", archive: `plan-review-${round}-${group.id}-${reviewer}.json`,
508
+ });
509
+ if (!ok)
510
+ return run;
511
+ }
512
+ }
513
+ // 只放這一輪真的審過的群(含沿用的),沒審到的任務才留得住前次 verdict
514
+ const groupVerdicts = calls.flatMap((call) => call.taskIds ? [{ taskIds: call.taskIds, verdict: call.verdict }] : []);
515
+ saveState({ version: 1, reviewed: applyReviewVerdicts(reviewed, layered.tasks, layered.acceptance, layered.planMd, groupVerdicts) });
516
+ return concludePlanReview(run, cfg, round, calls);
332
517
  }
333
518
  async function planFixStage(run) {
334
519
  const cfg = loadRepoConfig();
@@ -340,24 +525,35 @@ async function planFixStage(run) {
340
525
  });
341
526
  info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
342
527
  const feedback = readFeedback(run);
343
- const snap = snapshotPlan(run);
344
- const outcome = await agentStep(run, agent, "plan-fix", renderPrompt("plan-fix", { requirement: run.requirement, testPattern: cfg.testPattern }), { kind: "write", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
528
+ const snap = snapshotPlan(run, PLAN_REPLY_FILES);
529
+ // 回應每輪整份覆寫:先刪掉上一輪的,agent 沒寫時下一輪才不會讀到舊回應;失敗時快照會還原
530
+ const clearReplies = () => rmSync(flowFile(run, "plan-replies.md"), { force: true });
531
+ clearReplies();
532
+ let outcome;
533
+ try {
534
+ outcome = await agentStep(run, agent, "plan-fix", renderPrompt("plan-fix", { requirement: run.requirement, testPattern: cfg.testPattern }), { kind: "write", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); clearReplies(); } });
535
+ }
536
+ catch (err) {
537
+ // 所有 agent 額度都用完而暫停:還原成進入時的內容,resume 前的計畫審查回應仍在
538
+ restorePlan(run, snap);
539
+ throw err;
540
+ }
345
541
  const { r, agent: actual } = outcome;
346
542
  await discardChanges(worktreeDir(run.id)); // 只允許改 .flow/
347
543
  if (!r.ok) {
348
544
  restorePlan(run, snap);
349
- return retry(run, "plan-fix", `${feedback}\n\n(上次修改時 Agent 執行失敗:${r.summary})`, "plan_fix");
545
+ return retry(run, "plan-fix", `${feedback}\n\n(上次修改時 Agent 執行失敗:${r.summary})`, "plan_fix", "agent_error");
350
546
  }
351
547
  const ordered = validatePlan(run);
352
548
  if (typeof ordered === "string") {
353
549
  restorePlan(run, snap);
354
- return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix");
550
+ return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix", "format_invalid");
355
551
  }
356
552
  const handoffError = finishHandoff(run, outcome, "writer");
357
553
  if (handoffError) {
358
554
  restorePlan(run, snap);
359
555
  // 計畫已還原,要保留原本的審查意見,否則下一次修正不知道要改什麼
360
- return retry(run, "plan-fix", `${feedback}\n\n另外,交接回覆不合格,本次修改已還原:${handoffError}`, "plan_fix");
556
+ return retry(run, "plan-fix", `${feedback}\n\n另外,交接回覆不合格,本次修改已還原:${handoffError}`, "plan_fix", "handoff_invalid");
361
557
  }
362
558
  acceptPlan(run, ordered);
363
559
  writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
@@ -377,7 +573,7 @@ async function arbitratePlan(run) {
377
573
  const verdicts = [];
378
574
  for (const [slot, arbiter] of panel.entries()) {
379
575
  info(run, `⚖️ ${mode}(${arbiter})`);
380
- const snap = snapshotPlan(run);
576
+ const snap = snapshotPlan(run, PLAN_REPLY_FILES);
381
577
  rmSync(flowFile(run, "plan-arbiter.json"), { force: true });
382
578
  const outcome = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
383
579
  kind: "review", slot, blind: true,
@@ -403,6 +599,7 @@ async function arbitratePlan(run) {
403
599
  verdicts.push({ arbiter, verdict, notes, markdownNotes });
404
600
  }
405
601
  rmSync(flowFile(run, "dispute.md"), { force: true });
602
+ rmSync(planArbitrationPath(run.id), { force: true });
406
603
  const approvals = verdicts.filter((v) => v.verdict === "approve").length;
407
604
  const unanimous = approvals === verdicts.length;
408
605
  const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak);
@@ -418,6 +615,8 @@ async function arbitratePlan(run) {
418
615
  const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
419
616
  delete attempts["plan-review"];
420
617
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
618
+ rmSync(planReviewStatePath(run.id), { force: true });
619
+ addRetry(run.id, { key: "plan-arbitration", backTo: "plan_fix", category: "arbitration_revise", attempt: arbitrationRound, final: false }, run.updatedAt);
421
620
  writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${feedbackRecord}\n`);
422
621
  info(run, ` → ${summary}`);
423
622
  return { ...run, attempts, stage: "plan_fix" };
@@ -427,7 +626,7 @@ async function arbitratePlan(run) {
427
626
  info(run, ` → ${summary}`);
428
627
  return planSettled(run, "plan-review");
429
628
  }
430
- return { ...run, stage: "failed", failedStage: "plan_review", failureReason: `${summary},需要人工決定(見 plan.md 的仲裁紀錄)` };
629
+ return { ...run, stage: "failed", failedStage: "plan_review", failureCategory: "arbitration_stop", failureReason: `${summary},需要人工決定(見 plan.md 的仲裁紀錄)` };
431
630
  }
432
631
  function planTamperedMessage(files) {
433
632
  return `計畫定案後不可修改規格與計畫檔,已還原你的變更:${files.map((f) => `.flow/${f}`).join(", ")}。若認為規格或驗收條件有誤,請寫進 .flow/handoff-response.json 的 newIssues。`;
@@ -467,29 +666,29 @@ async function implementStage(run) {
467
666
  const tampered = restorePlan(run, snap);
468
667
  if (!r.ok) {
469
668
  await resetTo(repo, before);
470
- return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
669
+ return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement", "agent_error");
471
670
  }
472
671
  if (tampered.length) {
473
672
  await resetTo(repo, before);
474
- return retry(run, key, planTamperedMessage(tampered), "implement");
673
+ return retry(run, key, planTamperedMessage(tampered), "implement", "plan_tampered");
475
674
  }
476
675
  const commit = await commitAll(repo, `test(${task.id}): ${task.title} [${testsAuthor}]`);
477
676
  if (!commit)
478
- return retry(run, key, "沒有任何檔案變更,這個階段必須撰寫測試。", "implement");
677
+ return retry(run, key, "沒有任何檔案變更,這個階段必須撰寫測試。", "implement", "tests_not_written");
479
678
  const changed = await changedFiles(repo, before, commit);
480
679
  if (!changed.some((f) => testRe.test(f))) {
481
680
  await resetTo(repo, before);
482
- return retry(run, key, `沒有新增或修改任何符合 /${cfg.testPattern}/ 的測試檔。`, "implement");
681
+ return retry(run, key, `沒有新增或修改任何符合 /${cfg.testPattern}/ 的測試檔。`, "implement", "tests_not_written");
483
682
  }
484
683
  const red = await runCommand(target(run, `${task.id}-red`, CMD_AGENT), testCmd);
485
684
  if (red.ok) {
486
685
  await resetTo(repo, before);
487
- return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
686
+ return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement", "tests_not_red");
488
687
  }
489
688
  const handoffError = finishHandoff(run, outcome, "writer");
490
689
  if (handoffError) {
491
690
  await resetTo(repo, before);
492
- return retry(run, key, handoffError, "implement");
691
+ return retry(run, key, handoffError, "implement", "handoff_invalid");
493
692
  }
494
693
  writeFileSync(flowFile(run, "red-output.txt"), red.output);
495
694
  info(run, `🔴 [${progress}] 測試如預期失敗`);
@@ -507,26 +706,26 @@ async function implementStage(run) {
507
706
  const { r, agent: codeAuthor } = outcome;
508
707
  const tampered = restorePlan(run, snap);
509
708
  if (!r.ok)
510
- return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
709
+ return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement", "agent_error");
511
710
  if (tampered.length) {
512
711
  await resetTo(repo, testsCommit);
513
- return retry(run, key, planTamperedMessage(tampered), "implement");
712
+ return retry(run, key, planTamperedMessage(tampered), "implement", "plan_tampered");
514
713
  }
515
714
  await commitAll(repo, `feat(${task.id}): ${task.title} [${codeAuthor}]`);
516
715
  const touched = (await changedFiles(repo, testsCommit, await headCommit(repo))).filter((f) => testRe.test(f));
517
716
  if (touched.length) {
518
717
  await resetTo(repo, testsCommit);
519
- return retry(run, key, `實作階段不可修改測試檔,已還原你的變更:${touched.join(", ")}`, "implement");
718
+ return retry(run, key, `實作階段不可修改測試檔,已還原你的變更:${touched.join(", ")}`, "implement", "tests_modified");
520
719
  }
521
720
  const green = await runCommand(target(run, `${task.id}-green`, CMD_AGENT), testCmd);
522
721
  if (!green.ok) {
523
722
  info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
524
- return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
723
+ return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement", "tests_not_green");
525
724
  }
526
725
  const handoffError = finishHandoff(run, outcome, "writer");
527
726
  if (handoffError) {
528
727
  await resetTo(repo, testsCommit);
529
- return retry(run, key, handoffError, "implement");
728
+ return retry(run, key, handoffError, "implement", "handoff_invalid");
530
729
  }
531
730
  info(run, `🟢 [${progress}] 測試通過`);
532
731
  return { ...succeed(run, key, "implement"), taskPhase: "review", lastWriter: codeAuthor };
@@ -558,7 +757,7 @@ async function taskReviewStep(run, task, progress, taskJson, acceptanceJson) {
558
757
  if (!result.objector)
559
758
  return { ...succeed(reviewed, key, "implement"), taskPhase: "verify" };
560
759
  return {
561
- ...retry(reviewed, key, `任務審查要求修改:\n\n${result.issues.join("\n\n")}`, "implement"),
760
+ ...retry(reviewed, key, `任務審查要求修改:\n\n${result.issues.join("\n\n")}`, "implement", "review_changes"),
562
761
  taskPhase: "fix",
563
762
  fixSource: "review",
564
763
  lastReviewer: result.objector,
@@ -570,7 +769,7 @@ async function taskVerifyStep(run, task, progress) {
570
769
  info(run, `🔍 [${progress}] 執行驗證`);
571
770
  const report = await runChecks(run, `${task.id}-`);
572
771
  if (report)
573
- return { ...retry(run, key, report, "implement"), taskPhase: "fix", fixSource: "verify" };
772
+ return { ...retry(run, key, report, "implement", "checks_failed"), taskPhase: "fix", fixSource: "verify" };
574
773
  info(run, `✅ [${progress}] 完成`);
575
774
  return {
576
775
  ...succeed(run, key, "implement"),
@@ -622,7 +821,7 @@ async function verifyStage(run) {
622
821
  const report = await runChecks(run);
623
822
  if (!report)
624
823
  return succeed(run, "verify", "review");
625
- return { ...retry(run, "verify", report, "fix"), fixSource: "verify" };
824
+ return { ...retry(run, "verify", report, "fix", "checks_failed"), fixSource: "verify" };
626
825
  }
627
826
  /** 修正驗證錯誤或審查意見;成功時回傳實際修正者,未通過時回傳重試後的 run */
628
827
  async function applyFix(run, opts) {
@@ -647,24 +846,24 @@ async function applyFix(run, opts) {
647
846
  });
648
847
  const { r, agent: actual } = outcome;
649
848
  const tampered = restorePlan(run, snap);
650
- const again = (reason) => ({ run: retry(run, opts.key, reason, opts.backTo) });
849
+ const again = (reason, category) => ({ run: retry(run, opts.key, reason, opts.backTo, category) });
651
850
  if (!r.ok)
652
- return again(`${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`);
851
+ return again(`${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`, "agent_error");
653
852
  if (tampered.length) {
654
853
  await resetTo(repo, before);
655
- return again(`${feedback}\n\n另外:${planTamperedMessage(tampered)}`);
854
+ return again(`${feedback}\n\n另外:${planTamperedMessage(tampered)}`, "plan_tampered");
656
855
  }
657
856
  await commitAll(repo, `${opts.commitScope}: ${why} [${actual}]`);
658
857
  const testRe = new RegExp(cfg.testPattern);
659
858
  const deleted = (await changedFiles(repo, before, await headCommit(repo), "D")).filter((f) => testRe.test(f));
660
859
  if (deleted.length) {
661
860
  await resetTo(repo, before);
662
- return again(`${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`);
861
+ return again(`${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`, "tests_deleted");
663
862
  }
664
863
  const handoffError = finishHandoff(run, outcome, "writer");
665
864
  if (handoffError) {
666
865
  await resetTo(repo, before);
667
- return again(`${feedback}\n\n另外,交接回覆不合格,本次修正已還原:${handoffError}`);
866
+ return again(`${feedback}\n\n另外,交接回覆不合格,本次修正已還原:${handoffError}`, "handoff_invalid");
668
867
  }
669
868
  return { agent: actual };
670
869
  }
@@ -709,14 +908,14 @@ async function codeReview(run, opts) {
709
908
  if (tampered.length)
710
909
  info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
711
910
  if (!r.ok)
712
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, `Agent 執行失敗:${r.summary}`, opts.backTo) };
911
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, `Agent 執行失敗:${r.summary}`, opts.backTo, "agent_error") };
713
912
  const review = readJsonFile(flowFile(run, "review.json"), ConsistentReviewResult);
714
913
  if (!review.ok)
715
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, review.error, opts.backTo) };
914
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, review.error, opts.backTo, "format_invalid") };
716
915
  const gate = opts.gate ? { target: "code", verdict: review.data.verdict } : undefined;
717
916
  const handoffError = finishHandoff(run, outcome, "reviewer", gate);
718
917
  if (handoffError)
719
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, handoffError, opts.backTo) };
918
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, handoffError, opts.backTo, "handoff_invalid") };
720
919
  if (opts.step === "review")
721
920
  run = clearModelReviewFailure(run, "review", reviewer);
722
921
  renameSync(flowFile(run, "review.json"), flowFile(run, opts.saveAs(reviewer)));
@@ -753,7 +952,7 @@ async function reviewStage(run) {
753
952
  if (!result.objector)
754
953
  return succeed(result.state, "review", "pr");
755
954
  return {
756
- ...retry(result.state, "review", `程式碼審查要求修改:\n\n${result.issues.join("\n\n")}`, "fix"),
955
+ ...retry(result.state, "review", `程式碼審查要求修改:\n\n${result.issues.join("\n\n")}`, "fix", "review_changes"),
757
956
  fixSource: "review",
758
957
  lastReviewer: result.objector,
759
958
  };
@@ -761,7 +960,7 @@ async function reviewStage(run) {
761
960
  async function prStage(run) {
762
961
  const pending = openActions(readHandoff(run.id));
763
962
  if (pending.length)
764
- return { ...run, stage: "failed", failedStage: "pr", failureReason: `仍有未結交接事項:${pending.map((item) => item.id).join("、")}` };
963
+ return { ...run, stage: "failed", failedStage: "pr", failureCategory: "open_handoff", failureReason: `仍有未結交接事項:${pending.map((item) => item.id).join("、")}` };
765
964
  const repo = worktreeDir(run.id);
766
965
  const remotes = (await git(repo, "remote")).split("\n").filter(Boolean);
767
966
  if (!remotes.includes("origin")) {
@@ -806,6 +1005,7 @@ export async function advance(initial) {
806
1005
  ...run,
807
1006
  stage: "failed",
808
1007
  failedStage: stage,
1008
+ failureCategory: "agent_budget",
809
1009
  failureReason: `已執行 agent ${runs} 次,達到上限 ${run.maxAgentRuns}(可用 resume --max-agent-runs 調高)`,
810
1010
  });
811
1011
  }
@@ -817,7 +1017,7 @@ export async function advance(initial) {
817
1017
  info(run, `⏸️ 暫停:${err.message}`);
818
1018
  return saveRun({ ...run, stage: "paused", pausedStage: stage, pauseReason: err.message });
819
1019
  }
820
- return saveRun({ ...run, stage: "failed", failedStage: stage, failureReason: err.message });
1020
+ return saveRun({ ...run, stage: "failed", failedStage: stage, failureCategory: "error", failureReason: err.message });
821
1021
  }
822
1022
  }
823
1023
  }