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/README.md +34 -4
- package/dist/agents/codex.js +70 -30
- package/dist/agents/gemini.js +11 -4
- package/dist/cli.js +114 -4
- package/dist/engine.js +291 -91
- package/dist/insights.js +85 -0
- package/dist/logs.js +2 -0
- package/dist/modelProbe.js +11 -4
- package/dist/modelSelection.js +19 -11
- package/dist/paths.js +4 -0
- package/dist/planReview.js +333 -0
- package/dist/proc.js +35 -2
- package/dist/schemas.js +14 -0
- package/dist/stats.js +26 -0
- package/dist/store.js +62 -17
- package/dist/usageInsights.js +156 -0
- package/dist/util.js +12 -0
- package/examples/flow.config.json +1 -0
- package/package.json +1 -1
- package/prompts/plan-arbiter.md +1 -1
- package/prompts/plan-fix.md +5 -2
- package/prompts/plan-review-group.md +92 -0
- package/prompts/plan-review-index.md +93 -0
- package/prompts/plan-review.md +1 -0
- package/prompts/plan.md +3 -3
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
|
-
|
|
116
|
-
|
|
120
|
+
/** 讀 .flow/ 下的文字檔,沒有檔就當空字串 */
|
|
121
|
+
function flowText(run, name) {
|
|
122
|
+
const p = flowFile(run, name);
|
|
117
123
|
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
118
124
|
}
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|