agentflowctl 0.16.0 → 0.17.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 CHANGED
@@ -198,6 +198,7 @@ Codex 另有幾點差異:
198
198
  | `tddSplit` | `true` | 有多位 agent 時,`true` 會把同一任務的測試與實作分給不同 agent |
199
199
  | `reviewQuorum` | `1` | 任務與最終程式碼審查需要幾位不同審查者核准 |
200
200
  | `planReviewQuorum` | `1` | 計畫需要幾位不同審查者核准 |
201
+ | `reviewConcurrency` | 不限 | 同一輪審查最多幾位審查者同時執行;`1` 為一次一位;說明見表格下方,`doctor` 會顯示目前的設定 |
201
202
  | `planArbiter` | `true` | 計畫審查僵持,或修訂一次後仍被要求修改時是否啟用仲裁 |
202
203
  | `planReviewLayers` | `{ "enabled": true, "minTasks": 7, "maxGroups": 5, "tasksPerGroup": 3 }` | 任務夠多時把計畫審查拆成索引與任務群;說明見表格下方 |
203
204
  | `tieBreak` | `"proceed"` | 兩位仲裁者意見分歧時,`"proceed"` 繼續、`"stop"` 停止 |
@@ -210,6 +211,34 @@ Codex 另有幾點差異:
210
211
 
211
212
  計畫審查會依任務規模選做法。同時符合下列條件時,每輪先做一次索引審查,再只審查有變動的任務群:任務達到 `planReviewLayers.minTasks` 個;依 description 寫的檔案路徑能分成至少兩群,而且最大一群不超過三分之二;`plan.md` 每個任務都有 `## T-<數字>` 標題。索引審查讀規格、全部任務描述、驗收條件與整體做法,人數是 `planReviewQuorum`。群數最多 `maxGroups`,也不超過任務數除以 `tasksPerGroup`;每群一位審查者,含 `high` 任務的群改由 `planReviewQuorum` 位審查。改了 `plan.md` 的整體做法時所有群都重審;某一次審查失敗時只重跑還沒完成的部分。已達門檻卻不符其他條件時,終端機會印出原因並改由審查者讀完整份規格與計畫。`"planReviewLayers": { "enabled": false }` 可以關閉,`doctor` 會顯示目前的設定。
212
213
 
214
+ #### 平行審查
215
+
216
+ 同一輪的審查者(整份計畫審查、分層計畫審查的索引與各群、程式碼審查)預設同時執行,可用 `reviewConcurrency` 限制同時數量(`1` 為一次一位)。
217
+
218
+ **審查者的環境**
219
+
220
+ - 每位審查者在自己的臨時 git worktree 裡工作,固定放在 `.agentflowctl/runs/<id>/tmp-review/slot-<N>/`;只有該路徑被占用(例如上次中斷的殘骸)時才改用唯一的子目錄。
221
+ - 看到的是 run 目前的 HEAD、`.flow/` 的複本,以及指向 run worktree 頂層 `node_modules` 的 symlink(有才建);不含其他未 commit 或被 gitignore 的檔案(例如 `dist/`)。所以審查者改了什麼都不會影響 run 的 worktree 或其他審查者。
222
+ - 唯一的例外是 `node_modules`:它是共用的 symlink,審查者若在臨時 worktree 裡跑安裝,會寫到 run 真正的 `node_modules`。
223
+ - 程式碼審查開始前,會先把 run 的 worktree 還原成 HEAD(清掉驗證階段留下的未 commit 修改與未追蹤產物)。
224
+
225
+ **結果如何套用**
226
+
227
+ - 審查結果一完成就存進 `.agentflowctl/runs/<id>/parallel-review/`,全部審查者跑完後才依固定順序逐一套用結果與交接事項。
228
+ - 因此即使 `reviewConcurrency` 為 `1`,同一輪的審查者也看不到彼此本輪新增或結掉的交接事項,核准與否也依它開始時看到的帳本判斷。例如一位審查者結掉了某個未結事項,另一位核准卻沒有結掉它,後者會被判交接不合格而多重跑一次(重跑時就看得到該事項已結)。
229
+ - 同時執行時,引擎印出的訊息會加上 `[審查者]` 前綴;agent 自己的輸出(`-v` 時印出的內容、回報的疑慮)不加。
230
+
231
+ **中斷與 resume**
232
+
233
+ - 任何時候 Ctrl-C 或被中斷,之後 `resume` 只會補跑沒完成的審查者,已完成的不重跑。
234
+ - 已存檔的審查只在同一輪、計畫或程式碼沒變,而且交接帳本沒有被這一輪以外的呼叫(例如修正者)動過時沿用,否則重審。
235
+ - 中斷留下的臨時 worktree(`.agentflowctl/runs/<id>/tmp-review/`)在該 run 下次 `resume`(或 `clean`)時自動清掉。
236
+
237
+ **額度與預算**
238
+
239
+ - 若有審查者的額度用完,其他審查者仍會跑完並存檔,全部結束後才暫停;但同一家 agent 還沒啟動的審查者也會略過(這家在這次執行中已確認額度用完),`resume` 時再和額度用完的那位一起補跑。
240
+ - 審查只在 `maxAgentRuns` 剩餘的次數內啟動:預算不夠整輪時只啟動預算內的審查者(結果照樣存檔),run 以 `agent_budget` 失敗,用 `resume <id> --max-agent-runs <次數>` 調高後只補跑沒跑的。
241
+
213
242
  審查意見的處理寫在 `.flow/plan-replies.md`,每輪覆寫,不寫進 `plan.md` 文末。下一輪索引會看到整份回應;任務群只看到自己的 `## T-<數字>` 節。
214
243
 
215
244
  `install`、`test`、`checks` 未設定時,會依 `packageManager`、lockfile 和 `package.json` scripts 偵測。完整範例見 [examples/flow.config.json](examples/flow.config.json)。專案設定每一步都會重新讀取,但已建立 run 的參與 agent 與執行次數上限會沿用建立時的值;要調高後者請用 `resume --max-agent-runs`。
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { num, str, toolDetail, tryJson } from "./types.js";
4
4
  /**
@@ -19,7 +19,13 @@ function settingsFile(o) {
19
19
  };
20
20
  mkdirSync(o.runDir, { recursive: true });
21
21
  const path = join(o.runDir, "claude-settings.json");
22
- writeFileSync(path, JSON.stringify(settings, null, 2));
22
+ const content = JSON.stringify(settings, null, 2);
23
+ // 平行審查時多個 claude 同時啟動:內容沒變就不寫;要寫時先寫暫存檔再 rename,別的行程不會讀到被截斷的檔案而在沒有 deny list 下執行
24
+ if (existsSync(path) && readFileSync(path, "utf8") === content)
25
+ return path;
26
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
27
+ writeFileSync(tmp, content);
28
+ renameSync(tmp, path);
23
29
  return path;
24
30
  }
25
31
  export const claude = {
package/dist/cleanup.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
2
2
  import { git, removeWorktree } from "./git.js";
3
3
  import { projectRoot, runDir, runsDir, worktreeDir, worktreesDir } from "./paths.js";
4
4
  import { getRun } from "./store.js";
5
+ import { cleanupTempWorktrees } from "./tempWorktree.js";
5
6
  /**
6
7
  * 移除一個 run 的 worktree 與紀錄,分支保留。
7
8
  * 不需要 state.json:中斷在建立 worktree 之後、寫入紀錄之前留下的孤兒也能清。
@@ -14,6 +15,8 @@ export async function cleanRun(id) {
14
15
  const root = projectRoot();
15
16
  const wt = worktreeDir(id);
16
17
  const found = existsSync(wt) || existsSync(runDir(id));
18
+ // 平行審查中斷留下的臨時 worktree:locked 的登記 prune 不會清,要先處理;tmp-review/ 已不在時也要查
19
+ await cleanupTempWorktrees(id, { force: true });
17
20
  if (existsSync(wt)) {
18
21
  // git 不認得這個資料夾時(登記已被 prune、或 worktree add 做到一半)改成直接刪
19
22
  await removeWorktree(root, wt).catch(() => rmSync(wt, { recursive: true, force: true }));
package/dist/cli.js CHANGED
@@ -544,6 +544,7 @@ async function doctor() {
544
544
  console.log(`\n單一 run 的 agent 執行上限:${cfg.maxAgentRuns} 次`);
545
545
  console.log(`修正策略:${cfg.fixStrategy} 測試與實作分開:${cfg.tddSplit ? "是" : "否"}`);
546
546
  console.log(`程式碼審查人數:${cfg.reviewQuorum} 計畫審查人數:${cfg.planReviewQuorum} 計畫仲裁:${cfg.planArbiter ? "開啟" : "關閉"}`);
547
+ console.log(`同一輪審查者同時執行上限:${cfg.reviewConcurrency ?? "不限"}`);
547
548
  const layers = cfg.planReviewLayers;
548
549
  console.log(`計畫分層審查:${layers.enabled ? `任務達 ${layers.minTasks} 個時開啟,最多 ${layers.maxGroups} 群,每群平均至少 ${layers.tasksPerGroup} 個任務` : "關閉"}`);
549
550
  }
package/dist/engine.js CHANGED
@@ -5,11 +5,13 @@ import { arbitrationDecision } from "./arbitration.js";
5
5
  import { detectProjectDefaults, usesTestFramework, withProjectDefaults } from "./detect.js";
6
6
  import { escapeXml, opinion, reviewIssue } from "./feedback.js";
7
7
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
8
- import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
8
+ import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, responsePath, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
9
9
  import { flowDir, logDir, planArbitrationPath, planReviewStatePath, projectRoot, runDir, worktreeDir } from "./paths.js";
10
10
  import { CMD_AGENT, nextLogFile } from "./logs.js";
11
11
  import { exec } from "./proc.js";
12
12
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
13
+ import { dropCall, loadCalls, openRound, runPool, saveCall, storedCallValid } from "./parallelReview.js";
14
+ import { cleanupTempWorktrees, withTempWorktree } from "./tempWorktree.js";
13
15
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
14
16
  import { AcceptanceList, ArbiterResult, ConsistentReviewResult, RepoConfig, TaskList, } from "./schemas.js";
15
17
  import { addRetry, addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
@@ -22,8 +24,8 @@ const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
22
24
  const logHint = (run, seq) => `(agentflowctl logs ${run.id} ${seq})`;
23
25
  const flowFile = (run, name) => join(flowDir(run.id), name);
24
26
  const to = (run, stage) => ({ ...run, stage });
25
- function target(run, step, agent) {
26
- return { runId: run.id, cwd: worktreeDir(run.id), logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
27
+ function target(run, step, agent, cwd = worktreeDir(run.id)) {
28
+ return { runId: run.id, cwd, logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
27
29
  }
28
30
  /** 額度用完而必須停下:審查類步驟,或所有 agent 的額度都用完 */
29
31
  export class QuotaPause extends Error {
@@ -50,7 +52,8 @@ function handoffKey(run, step, slot, agent) {
50
52
  }
51
53
  async function agentStep(run, planned, step, prompt, mode) {
52
54
  const cfg = loadRepoConfig();
53
- const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
55
+ const say = (msg) => info(run, mode.tag ? `[${mode.tag}] ${msg}` : msg);
56
+ const reset = mode.reset ?? (mode.workspace ? () => { } : () => discardChanges(worktreeDir(run.id)));
54
57
  let agent = planned;
55
58
  for (;;) {
56
59
  if (exhausted.has(agent)) {
@@ -62,16 +65,16 @@ async function agentStep(run, planned, step, prompt, mode) {
62
65
  throw new QuotaPause(`所有 agent 的額度都已用完(${[...exhausted].join("、")})`);
63
66
  const note = step.endsWith("-code") && sub === run.lastTestsAuthor ? "測試與實作由同一家負責" : undefined;
64
67
  addSubstitution(run.id, { step, planned, actual: sub, note });
65
- info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
68
+ say(`🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
66
69
  agent = sub;
67
70
  }
68
71
  const selected = selectModel(run, cfg, agent, step, /^T-\d+-/.test(step) ? loadOrderedTasks(run)[run.taskIndex]?.complexity : undefined, mode.kind === "review" ? agent : undefined, mode.modelScope);
69
- info(run, `🤖 ${step}:${agent} 使用 ${selected.name ?? "CLI 預設(名稱未知)"}${selected.insufficient ? `(低於目標 ${selected.targetStrength})` : ""}`);
72
+ say(`🤖 ${step}:${agent} 使用 ${selected.name ?? "CLI 預設(名稱未知)"}${selected.insufficient ? `(低於目標 ${selected.targetStrength})` : ""}`);
70
73
  const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
71
- prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
72
- const r = await runAgent(agent, { ...resolveAgent(cfg, agent), model: selected.name }, { ...target(run, step, agent), strength: selected.strength, targetStrength: selected.targetStrength }, prompt);
74
+ prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false, mode.workspace?.flow);
75
+ const r = await runAgent(agent, { ...resolveAgent(cfg, agent), model: selected.name }, { ...target(run, step, agent, mode.workspace?.dir), strength: selected.strength, targetStrength: selected.targetStrength }, prompt);
73
76
  if (r.resolvedModel && r.resolvedModel !== selected.name)
74
- info(run, ` ↳ CLI 回報實際模型:${r.resolvedModel}`);
77
+ say(` ↳ CLI 回報實際模型:${r.resolvedModel}`);
75
78
  addUsage(run.id, { stage: step, agent, model: selected.name, resolvedModel: r.resolvedModel,
76
79
  strength: selected.strength, targetStrength: selected.targetStrength, usageReported: r.usageReported,
77
80
  inputTokens: r.inputTokens, outputTokens: r.outputTokens, cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens });
@@ -79,14 +82,16 @@ async function agentStep(run, planned, step, prompt, mode) {
79
82
  reportMeta(run, agent, r);
80
83
  return { r, agent, step, callKey };
81
84
  }
82
- info(run, `⛽ ${agent} 的額度已用完`);
85
+ say(`⛽ ${agent} 的額度已用完`);
83
86
  exhausted.add(agent);
84
87
  await reset();
85
88
  // 迴圈回到開頭:review 會停下,write 會找代打
86
89
  }
87
90
  }
88
91
  /** 原有關卡已通過後才接受交接;失敗回覆不進入正式紀錄。 */
89
- function finishHandoff(run, outcome, role, gate) {
92
+ function finishHandoff(run, outcome, role, gate,
93
+ /** 平行審查:關卡以這個呼叫啟動時看到的帳本判斷(存檔裡的 base),套用仍落在目前的帳本 */
94
+ base) {
90
95
  const parsed = validateHandoffResponse(run.id);
91
96
  if (!parsed.ok)
92
97
  return parsed.error;
@@ -94,7 +99,7 @@ function finishHandoff(run, outcome, role, gate) {
94
99
  const source = {
95
100
  stage: run.stage, step: outcome.step, agent: outcome.agent, callKey: outcome.callKey,
96
101
  };
97
- const preview = previewHandoff(readHandoff(run.id), outcome.callKey, source, parsed.data, role);
102
+ const preview = previewHandoff(base ?? readHandoff(run.id), outcome.callKey, source, parsed.data, role);
98
103
  if (gate) {
99
104
  const error = reviewHandoffGate(preview, gate.target, gate.verdict);
100
105
  if (error)
@@ -330,38 +335,115 @@ async function planReviewStage(run) {
330
335
  const layered = loadLayeredPlan(run, cfg);
331
336
  return layered ? planReviewLayered(run, layered, cfg) : planReviewFull(run, cfg);
332
337
  }
338
+ const readIfExists = (path) => (existsSync(path) ? readFileSync(path, "utf8") : null);
333
339
  /**
334
- * 整份審查與分層審查共用的單次呼叫:還原審查者改過的計畫檔、驗證裁決與交接。
335
- * 失敗時回傳已呼叫 retry 的 run、沒有 collected,呼叫端應直接回傳這個 run。
340
+ * 平行階段:每位審查者在自己的臨時 worktree 執行,成功的結果立刻存檔。
341
+ * 這個階段不改 run、不寫交接帳本、不呼叫 retry。
342
+ * - 已有同一輪存檔、且仍有效的呼叫直接沿用(resume);帳本在它啟動後被本輪以外的呼叫(例如修正者)動過就作廢重跑
343
+ * - 預算(maxAgentRuns)不夠時只啟動預算內的,回傳 undefined,呼叫端應原樣回傳 run,由 advance 判定 agent_budget
344
+ * - 有呼叫額度用完時,其他呼叫照常跑完並存檔,最後才丟 QuotaPause
336
345
  */
337
- async function collectPlanReview(run, spec) {
346
+ async function executeReviewCalls(run, cfg, scope, fingerprint, calls) {
347
+ if (calls.length === 0)
348
+ return { dir: "", finished: [] };
349
+ // openRound、loadCalls 與有效性檢查都要在 runPool 啟動任何呼叫之前(loadCalls 會刪 .tmp)
350
+ const dir = openRound(run.id, scope, fingerprint);
351
+ const saved = loadCalls(dir);
352
+ const live = readHandoff(run.id);
353
+ const round = [...saved.values()];
354
+ for (const [key, stored] of [...saved]) {
355
+ if (storedCallValid(stored, live, round))
356
+ continue;
357
+ dropCall(dir, key);
358
+ saved.delete(key);
359
+ }
360
+ let budget = run.maxAgentRuns - agentRuns(run.id);
361
+ // 已有存檔的不花預算;其餘依序佔預算,超出的這次不啟動
362
+ const launch = calls.filter((call) => saved.has(call.key) || budget-- > 0);
363
+ const limit = cfg.reviewConcurrency ?? Infinity;
364
+ const tagged = limit > 1 && launch.filter((call) => !saved.has(call.key)).length > 1;
365
+ // 預算內能跑的照跑並存檔,之後 resume 加大預算就不必重付
366
+ const executed = await runPool(launch, limit, (call) => executeOne(run, dir, saved, call, tagged));
367
+ const quota = executed.find((item) => "quota" in item);
368
+ if (quota)
369
+ throw new QuotaPause(quota.quota);
370
+ if (launch.length < calls.length)
371
+ return undefined;
372
+ return { dir, finished: executed };
373
+ }
374
+ async function executeOne(run, dir, saved, call, tagged) {
375
+ const reused = saved.get(call.key);
376
+ if (reused) {
377
+ info(run, ` ↪ 沿用本輪已完成的審查(${call.reviewer})`);
378
+ return { stored: reused, ok: true };
379
+ }
380
+ try {
381
+ return await withTempWorktree(run.id, `slot-${call.slot}`, async (ws) => {
382
+ rmSync(join(ws.flow, call.output), { force: true });
383
+ // 平行階段不動帳本,同一批呼叫看到的都是這一份;序列收尾以它評估這個呼叫的核准門檻
384
+ const base = readHandoff(run.id);
385
+ const outcome = await agentStep(run, call.reviewer, call.step, call.prompt, {
386
+ kind: "review", slot: call.slot, modelScope: call.scope, workspace: ws, tag: tagged ? call.reviewer : undefined,
387
+ });
388
+ const stored = {
389
+ key: call.key, reviewer: call.reviewer, agent: outcome.agent, step: outcome.step, callKey: outcome.callKey,
390
+ summary: outcome.r.summary,
391
+ output: readIfExists(join(ws.flow, call.output)),
392
+ handoffResponse: readIfExists(responsePath(run.id, ws.flow)),
393
+ base,
394
+ };
395
+ if (outcome.r.ok)
396
+ saveCall(dir, stored); // 先存檔,臨時 worktree 才會被移除
397
+ return { stored, ok: outcome.r.ok };
398
+ });
399
+ }
400
+ catch (err) {
401
+ if (err instanceof QuotaPause)
402
+ return { quota: err.message };
403
+ throw err;
404
+ }
405
+ }
406
+ /** 序列收尾第一步:把存檔的內容放回共用 .flow/,之後就能沿用原有的驗證與交接程式 */
407
+ function replay(run, stored, output) {
408
+ mkdirSync(flowDir(run.id), { recursive: true });
409
+ const put = (path, text) => (text === null ? rmSync(path, { force: true }) : writeFileSync(path, text));
410
+ put(flowFile(run, output), stored.output);
411
+ put(responsePath(run.id), stored.handoffResponse);
412
+ }
413
+ /** 存檔內容還原成 finishHandoff 需要的 StepOutcome */
414
+ function storedOutcome(stored, ok) {
415
+ return {
416
+ r: { ok, quotaExhausted: false, summary: stored.summary, usageReported: false },
417
+ agent: stored.agent, step: stored.step, callKey: stored.callKey,
418
+ };
419
+ }
420
+ /**
421
+ * 整份審查與分層審查共用的序列收尾:驗證裁決與交接。
422
+ * 失敗時回傳已呼叫 retry 的 run、沒有 collected,呼叫端應直接回傳這個 run;
423
+ * 不合格的存檔結果一併刪掉,否則同一輪重跑會反覆讀到同一份。
424
+ */
425
+ function applyPlanReview(run, spec, done, dir) {
338
426
  const { reviewer, step } = spec;
339
- const snap = snapshotPlan(run, PLAN_REPLY_FILES);
340
- rmSync(flowFile(run, spec.output), { force: true });
341
- const outcome = await agentStep(run, reviewer, step, spec.prompt, {
342
- kind: "review", slot: spec.slot, modelScope: spec.scope,
343
- reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
344
- });
345
- await discardChanges(worktreeDir(run.id));
346
- const tampered = restorePlan(run, snap);
347
- if (tampered.length)
348
- info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
349
- const stop = (category, reason) => ({
350
- run: retry(recordModelReviewFailure(run, step, reviewer, spec.scope), "plan-review-run", reason, "plan_review", category),
351
- });
352
- if (!outcome.r.ok)
353
- return stop("agent_error", `Agent 執行失敗:${outcome.r.summary}`);
427
+ const stop = (category, reason) => {
428
+ dropCall(dir, spec.key);
429
+ return { run: retry(recordModelReviewFailure(run, step, reviewer, spec.scope), "plan-review-run", reason, "plan_review", category) };
430
+ };
431
+ if (!done.ok)
432
+ return stop("agent_error", `Agent 執行失敗:${done.stored.summary}`);
433
+ replay(run, done.stored, spec.output);
354
434
  const review = readJsonFile(flowFile(run, spec.output), ConsistentReviewResult);
355
435
  if (!review.ok)
356
436
  return stop("format_invalid", review.error);
357
- // 群審查不帶關卡:索引要求修改並新增事項後,群的核准不算矛盾;最後由 planSettled 檢查未結事項
358
- const handoffError = finishHandoff(run, outcome, "reviewer", spec.gated ? { target: "plan", verdict: review.data.verdict } : undefined);
437
+ // 群審查不帶關卡:索引要求修改並新增事項後,群的核准不算矛盾;最後由 planSettled 檢查未結事項。
438
+ // 門檻以這個呼叫自己看到的帳本評估:同輪其他審查者剛新增(或崩潰前已套用)的事項它沒看過,不能拿來判它矛盾
439
+ const handoffError = finishHandoff(run, storedOutcome(done.stored, true), "reviewer", spec.gated ? { target: "plan", verdict: review.data.verdict } : undefined, done.stored.base);
359
440
  if (handoffError)
360
441
  return stop("handoff_invalid", handoffError);
361
442
  run = clearModelReviewFailure(run, step, reviewer, spec.scope);
362
443
  // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
363
444
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
364
- renameSync(flowFile(run, spec.output), join(runDir(run.id), "reviews", spec.archive));
445
+ writeFileSync(join(runDir(run.id), "reviews", spec.archive), readFileSync(flowFile(run, spec.output)));
446
+ rmSync(flowFile(run, spec.output), { force: true });
365
447
  if (review.data.verdict === "approve") {
366
448
  info(run, ` ✓ ${reviewer} 核准${spec.subject}`);
367
449
  return { run, collected: { reviewer, verdict: "approve", issueLines: [] } };
@@ -414,14 +496,19 @@ async function planReviewFull(run, cfg) {
414
496
  const author = run.planWriter ?? planAgent(run.cycle, run.id);
415
497
  const round = (run.attempts["plan-review"] ?? 0) + 1;
416
498
  const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
499
+ const specs = panel.map((reviewer, slot) => ({
500
+ key: `full:${reviewer}`, reviewer, step: "plan-review", slot, gated: true, subject: "計畫",
501
+ prompt: renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }),
502
+ output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
503
+ }));
504
+ for (const spec of specs)
505
+ info(run, `🧐 計畫審查第 ${round} 輪(${spec.reviewer},作者 ${author})`);
506
+ const ran = await executeReviewCalls(run, cfg, "plan-review", `${round}:${currentPlanKey(run)}`, specs);
507
+ if (!ran)
508
+ return run;
417
509
  const calls = [];
418
- for (const [slot, reviewer] of panel.entries()) {
419
- info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
420
- const passed = await collectPlanReview(run, {
421
- reviewer, step: "plan-review", slot, gated: true, subject: "計畫",
422
- prompt: renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }),
423
- output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
424
- });
510
+ for (const [i, spec] of specs.entries()) {
511
+ const passed = applyPlanReview(run, spec, ran.finished[i], ran.dir);
425
512
  run = passed.run;
426
513
  if (!passed.collected)
427
514
  return run;
@@ -490,35 +577,17 @@ async function planReviewLayered(run, layered, cfg) {
490
577
  const calls = [];
491
578
  // 沿用的呼叫也佔一格,重跑時每個呼叫的 slot 才不會變
492
579
  let nextSlot = 0;
493
- /** 執行或沿用一次呼叫;失敗時回傳 false,run 已是 retry 後的狀態 */
494
- const runCall = async (key, taskIds, label, spec) => {
495
- const slot = nextSlot++;
580
+ const items = [];
581
+ const add = (key, taskIds, label, spec) => {
496
582
  const reused = done.get(key);
497
- if (reused) {
583
+ if (reused)
498
584
  info(run, ` ↪ 沿用本輪已完成的${label}(${reused.reviewer})`);
499
- calls.push(reused);
500
- return true;
501
- }
502
- info(run, `🧐 ${label}第 ${round} 輪(${spec.reviewer},作者 ${author})`);
503
- // run 是外層參數,刻意在閉包裡更新:後續呼叫與最後的彙總都要看到 retry、clearModelReviewFailure 之後的 run
504
- const passed = await collectPlanReview(run, { ...spec, slot });
505
- run = passed.run;
506
- if (!passed.collected)
507
- return false;
508
- // 真的執行並成功就是有進展:同一輪不同呼叫輪流失敗時,不會累計到重試上限而讓 run 失敗。
509
- // 進度寫在 round 裡、不會重跑,所以一輪最多失敗「呼叫數 × maxAttempts」次。
510
- // 整份審查每次重跑整輪,不能這樣歸零,否則同一位審查者反覆失敗會無限重試。
511
- const attempts = { ...run.attempts };
512
- delete attempts["plan-review-run"];
513
- run = { ...run, attempts };
514
- const call = { key, ...passed.collected, ...(taskIds ? { taskIds } : {}) };
515
- progress.calls.push(call);
516
- calls.push(call);
517
- saveState({ version: 1, ...(reviewed ? { reviewed } : {}), round: progress });
518
- return true;
585
+ else
586
+ info(run, `🧐 ${label}第 ${round} 輪(${spec.reviewer},作者 ${author})`);
587
+ items.push({ key, taskIds, reused, spec: { ...spec, slot: nextSlot++, key } });
519
588
  };
520
589
  for (const reviewer of panel) {
521
- const ok = await runCall(`index:${reviewer}`, undefined, "計畫索引審查", {
590
+ add(`index:${reviewer}`, undefined, "計畫索引審查", {
522
591
  reviewer, step: "plan-review", gated: true, subject: "計畫索引",
523
592
  prompt: renderPrompt("plan-review-index", {
524
593
  reviewer, author, requirement: run.requirement,
@@ -529,8 +598,6 @@ async function planReviewLayered(run, layered, cfg) {
529
598
  }),
530
599
  output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
531
600
  });
532
- if (!ok)
533
- return run;
534
601
  }
535
602
  for (const group of layered.dirty) {
536
603
  const groupTasks = layered.tasks.filter((task) => group.taskIds.includes(task.id));
@@ -538,7 +605,7 @@ async function planReviewLayered(run, layered, cfg) {
538
605
  const neighbors = neighborTasks(layered.tasks, group.taskIds).map(({ id, title, description, dependsOn }) => ({ id, title, description, dependsOn }));
539
606
  const groupPanel = reviewers(run.cycle, author, groupReviewerCount(groupTasks, cfg.planReviewQuorum), `${run.id}:plan-group:${group.id}:${round}`);
540
607
  for (const reviewer of groupPanel) {
541
- const ok = await runCall(`group:${group.id}:${group.taskIds.join(",")}:${reviewer}`, group.taskIds, `計畫群 ${group.id} 審查`, {
608
+ add(`group:${group.id}:${group.taskIds.join(",")}:${reviewer}`, group.taskIds, `計畫群 ${group.id} 審查`, {
542
609
  reviewer, step: "plan-review-group", gated: false, subject: `任務群 ${group.id}`, scope: group.id,
543
610
  prompt: renderPrompt("plan-review-group", {
544
611
  reviewer, author, groupId: group.id,
@@ -551,10 +618,32 @@ async function planReviewLayered(run, layered, cfg) {
551
618
  }),
552
619
  output: "plan-review-group.json", archive: `plan-review-${round}-${group.id}-${reviewer}.json`,
553
620
  });
554
- if (!ok)
555
- return run;
556
621
  }
557
622
  }
623
+ // 平行執行還沒套用的呼叫;沿用的(已套用、記在進度檔裡)不再跑
624
+ const pending = items.filter((item) => !item.reused);
625
+ const ran = await executeReviewCalls(run, cfg, "plan-review", `${round}:${planKey}`, pending.map((item) => item.spec));
626
+ if (!ran)
627
+ return run;
628
+ // 序列收尾,依原本的順序逐一套用;每套用一個就寫進度檔,中途被中斷也不會重複套用
629
+ const applied = new Map();
630
+ for (const [i, item] of pending.entries()) {
631
+ const passed = applyPlanReview(run, item.spec, ran.finished[i], ran.dir);
632
+ run = passed.run;
633
+ if (!passed.collected)
634
+ return run;
635
+ // 真的執行並成功就是有進展:同一輪不同呼叫輪流失敗時,不會累計到重試上限而讓 run 失敗。
636
+ // 進度寫在 round 裡、不會重跑,所以一輪最多失敗「呼叫數 × maxAttempts」次。
637
+ // 整份審查每次重跑整輪,不能這樣歸零,否則同一位審查者反覆失敗會無限重試。
638
+ const attempts = { ...run.attempts };
639
+ delete attempts["plan-review-run"];
640
+ run = { ...run, attempts };
641
+ const call = { key: item.key, ...passed.collected, ...(item.taskIds ? { taskIds: item.taskIds } : {}) };
642
+ progress.calls.push(call);
643
+ applied.set(item.key, call);
644
+ saveState({ version: 1, ...(reviewed ? { reviewed } : {}), round: progress });
645
+ }
646
+ calls.push(...items.map((item) => item.reused ?? applied.get(item.key)));
558
647
  // 只放這一輪真的審過的群(含沿用的),沒審到的任務才留得住前次 verdict
559
648
  const groupVerdicts = calls.flatMap((call) => call.taskIds ? [{ taskIds: call.taskIds, verdict: call.verdict }] : []);
560
649
  saveState({ version: 1, reviewed: applyReviewVerdicts(reviewed, layered.tasks, layered.acceptance, layered.planMd, groupVerdicts) });
@@ -956,32 +1045,42 @@ async function codeReview(run, opts) {
956
1045
  const cfg = loadRepoConfig();
957
1046
  const repo = worktreeDir(run.id);
958
1047
  const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, opts.seed, opts.testAuthor, opts.prefer);
1048
+ // 審查的是 HEAD:先清掉驗證階段留下的未 commit 修改與未追蹤產物,之後修正時的 commitAll 才不會把它們帶進去(.flow/ 在 exclude 內不受影響)
1049
+ await discardChanges(repo);
959
1050
  writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${opts.base}...HEAD`));
960
1051
  const authors = [...new Set((await git(repo, "log", "--format=%s", `${opts.base}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
961
1052
  .map((s) => s.slice(1, -1));
1053
+ const specs = panel.map((reviewer, slot) => ({
1054
+ key: `${slot}:${reviewer}`, slot, reviewer, step: opts.step,
1055
+ prompt: opts.prompt(reviewer, authors.join("、") || "未知"),
1056
+ output: "review.json",
1057
+ }));
1058
+ for (const spec of specs)
1059
+ info(run, `👀 ${opts.label}(${spec.reviewer})`);
1060
+ // 指紋含 HEAD:程式碼被修過就不沿用舊的審查結果
1061
+ const ran = await executeReviewCalls(run, cfg, opts.step, `${opts.seed}|${await headCommit(repo)}|${opts.base}`, specs);
1062
+ if (!ran)
1063
+ return { run };
962
1064
  const issues = [];
963
1065
  let objector;
964
- for (const [slot, reviewer] of panel.entries()) {
965
- info(run, `👀 ${opts.label}(${reviewer})`);
966
- rmSync(flowFile(run, "review.json"), { force: true });
967
- const snap = snapshotPlan(run, LOCKED_FILES);
968
- const outcome = await agentStep(run, reviewer, opts.step, opts.prompt(reviewer, authors.join("、") || "未知"), {
969
- kind: "review", slot, reset: async () => { await discardChanges(repo); restorePlan(run, snap); },
970
- });
971
- const { r } = outcome;
972
- await discardChanges(repo); // 審查者不可改程式碼
973
- const tampered = restorePlan(run, snap); // .flow/ 不受 git 管理,要另外還原
974
- if (tampered.length)
975
- info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
976
- if (!r.ok)
977
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, `Agent 執行失敗:${r.summary}`, opts.backTo, "agent_error") };
1066
+ for (const [i, spec] of specs.entries()) {
1067
+ const reviewer = spec.reviewer;
1068
+ const done = ran.finished[i];
1069
+ const fail = (reason, category) => {
1070
+ dropCall(ran.dir, spec.key); // 不合格的存檔不能留著,否則同一輪重跑會讀到同一份
1071
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, reason, opts.backTo, category) };
1072
+ };
1073
+ if (!done.ok)
1074
+ return fail(`Agent 執行失敗:${done.stored.summary}`, "agent_error");
1075
+ replay(run, done.stored, "review.json");
978
1076
  const review = readJsonFile(flowFile(run, "review.json"), ConsistentReviewResult);
979
1077
  if (!review.ok)
980
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, review.error, opts.backTo, "format_invalid") };
1078
+ return fail(review.error, "format_invalid");
981
1079
  const gate = opts.gate ? { target: "code", verdict: review.data.verdict } : undefined;
982
- const handoffError = finishHandoff(run, outcome, "reviewer", gate);
1080
+ // 門檻以這個呼叫自己看到的帳本評估:同輪其他審查者剛新增(或崩潰前已套用)的事項它沒看過,不能拿來判它矛盾
1081
+ const handoffError = finishHandoff(run, storedOutcome(done.stored, true), "reviewer", gate, done.stored.base);
983
1082
  if (handoffError)
984
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, handoffError, opts.backTo, "handoff_invalid") };
1083
+ return fail(handoffError, "handoff_invalid");
985
1084
  if (opts.step === "review")
986
1085
  run = clearModelReviewFailure(run, "review", reviewer);
987
1086
  renameSync(flowFile(run, "review.json"), flowFile(run, opts.saveAs(reviewer)));
@@ -1061,6 +1160,7 @@ const STAGES = {
1061
1160
  export async function advance(initial) {
1062
1161
  let run = initial;
1063
1162
  recoverHandoff(run.id);
1163
+ await cleanupTempWorktrees(run.id); // 上次被中斷(Ctrl-C、SIGTERM、當機)時留下的平行審查臨時 worktree
1064
1164
  for (;;) {
1065
1165
  if (["done", "failed", "awaiting_approval", "paused"].includes(run.stage))
1066
1166
  return run;
package/dist/handoff.js CHANGED
@@ -5,7 +5,8 @@ import { z } from "zod";
5
5
  import { flowDir, handoffPath, runDir } from "./paths.js";
6
6
  import { HandoffLedger, HandoffResponse, HandoffSource } from "./schemas.js";
7
7
  import { readJsonFile } from "./util.js";
8
- const responsePath = (id) => join(flowDir(id), "handoff-response.json");
8
+ /** agent 寫交接回覆的位置;平行審查者用自己臨時 worktree 的 .flow/ */
9
+ export const responsePath = (id, flow = flowDir(id)) => join(flow, "handoff-response.json");
9
10
  const receiptsDir = (id) => join(runDir(id), "handoff-receipts");
10
11
  const keyHash = (key) => createHash("sha256").update(key).digest("hex").slice(0, 16);
11
12
  const receiptPath = (id, key) => join(receiptsDir(id), `${keyHash(key)}.json`);
@@ -71,7 +72,7 @@ export function mergeHandoff(id, callKey, source, response, role) {
71
72
  return next;
72
73
  }
73
74
  /** 只把目前步驟需要處理的事項投影給 agent。 */
74
- export function prepareHandoff(id, _callKey, target, blind) {
75
+ export function prepareHandoff(id, _callKey, target, blind, flow = flowDir(id)) {
75
76
  const items = readHandoff(id).issues.filter((item) => item.targetStage === target && (item.kind === "info" || item.status === "open" || item.status === "proposed_resolved"));
76
77
  const render = (item) => {
77
78
  const source = blind ? "" : `\n來源:${item.source.stage}/${item.source.agent}`;
@@ -85,12 +86,12 @@ export function prepareHandoff(id, _callKey, target, blind) {
85
86
  actions.length ? `## 待處理事項(action,可在 dispositions 處置)\n\n${actions.join("\n\n")}` : "",
86
87
  infos.length ? `## 參考資訊(info,只供參考,不要放進 dispositions)\n\n${infos.join("\n\n")}` : "",
87
88
  ].filter(Boolean);
88
- mkdirSync(flowDir(id), { recursive: true });
89
- writeFileSync(join(flowDir(id), "handoff-context.md"), `# 待處理交接事項\n\n${sections.length ? sections.join("\n\n") : "目前沒有待處理事項。"}\n`);
90
- rmSync(responsePath(id), { force: true });
89
+ mkdirSync(flow, { recursive: true });
90
+ writeFileSync(join(flow, "handoff-context.md"), `# 待處理交接事項\n\n${sections.length ? sections.join("\n\n") : "目前沒有待處理事項。"}\n`);
91
+ rmSync(responsePath(id, flow), { force: true });
91
92
  }
92
- export function validateHandoffResponse(id) {
93
- return readJsonFile(responsePath(id), HandoffResponse);
93
+ export function validateHandoffResponse(id, flow = flowDir(id)) {
94
+ return readJsonFile(responsePath(id, flow), HandoffResponse);
94
95
  }
95
96
  /** 已通過原有關卡的回覆先記收據,再合併;中斷後可重播。 */
96
97
  export function acceptHandoff(id, callKey, source, response, role) {
@@ -0,0 +1,112 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { parallelReviewDir } from "./paths.js";
6
+ import { HandoffLedger } from "./schemas.js";
7
+ /**
8
+ * 依序啟動、同時最多 limit 個;結果依輸入順序回傳。
9
+ * fn 丟例外時,其餘已啟動與待啟動的項目仍會跑完(讓它們的收尾,例如移除臨時 worktree,都能完成),最後才把第一個例外丟出。
10
+ */
11
+ export async function runPool(items, limit, fn) {
12
+ const results = new Array(items.length);
13
+ let next = 0;
14
+ let failure;
15
+ const worker = async () => {
16
+ for (;;) {
17
+ const index = next++;
18
+ if (index >= items.length)
19
+ return;
20
+ try {
21
+ results[index] = await fn(items[index], index);
22
+ }
23
+ catch (error) {
24
+ failure ??= { error };
25
+ }
26
+ }
27
+ };
28
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
29
+ if (failure)
30
+ throw failure.error;
31
+ return results;
32
+ }
33
+ /**
34
+ * 一次已執行成功的審查呼叫。放在 run 目錄(worktree 外),以輪次+輸入指紋為鍵記憶化:
35
+ * 中斷後 resume、或同一輪因別的呼叫失敗而重跑時直接沿用,不重複付費。
36
+ * 只存原始檔案內容:套用時再放回共用的 .flow/,走原有的驗證與交接程式。
37
+ */
38
+ export const StoredCall = z.object({
39
+ /** 同一輪內唯一的呼叫識別 */
40
+ key: z.string(),
41
+ reviewer: z.string(),
42
+ agent: z.string(),
43
+ step: z.string(),
44
+ callKey: z.string(),
45
+ summary: z.string(),
46
+ /** 裁決檔原文;agent 沒寫時為 null */
47
+ output: z.string().nullable(),
48
+ /** handoff-response.json 原文;agent 沒寫時為 null */
49
+ handoffResponse: z.string().nullable(),
50
+ /** 這個呼叫啟動時看到的交接帳本:核准門檻以它評估,也用來判斷存檔是否仍可沿用 */
51
+ base: HandoffLedger,
52
+ });
53
+ const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
54
+ const scopeDir = (runId, scope) => join(parallelReviewDir(runId), scope);
55
+ /** 這一輪的存檔目錄;同 scope 下其他指紋(計畫或程式碼已變、輪次已換)的結果一律作廢 */
56
+ export function openRound(runId, scope, fingerprint) {
57
+ const base = scopeDir(runId, scope);
58
+ const mine = hash(fingerprint);
59
+ if (existsSync(base)) {
60
+ for (const name of readdirSync(base)) {
61
+ if (name !== mine)
62
+ rmSync(join(base, name), { recursive: true, force: true });
63
+ }
64
+ }
65
+ const dir = join(base, mine);
66
+ mkdirSync(dir, { recursive: true });
67
+ return dir;
68
+ }
69
+ const callFile = (dir, key) => join(dir, `${hash(key)}.json`);
70
+ export function saveCall(dir, call) {
71
+ const path = callFile(dir, call.key);
72
+ const tmp = `${path}.tmp`;
73
+ writeFileSync(tmp, JSON.stringify(call));
74
+ renameSync(tmp, path);
75
+ }
76
+ /**
77
+ * 讀出這一輪已存檔的結果,並刪掉寫到一半的 .tmp 與損毀的檔案。
78
+ * 呼叫順序:必須在 runPool 啟動任何呼叫之前;之後才呼叫會刪到別的呼叫正在寫的 .tmp。
79
+ */
80
+ export function loadCalls(dir) {
81
+ const calls = new Map();
82
+ if (!existsSync(dir))
83
+ return calls;
84
+ for (const name of readdirSync(dir)) {
85
+ const path = join(dir, name);
86
+ if (!name.endsWith(".json")) {
87
+ rmSync(path, { force: true }); // 寫到一半留下的 .tmp
88
+ continue;
89
+ }
90
+ try {
91
+ const call = StoredCall.parse(JSON.parse(readFileSync(path, "utf8")));
92
+ calls.set(call.key, call);
93
+ }
94
+ catch {
95
+ rmSync(path, { force: true }); // 損毀或格式不符:當作沒有,重跑
96
+ }
97
+ }
98
+ return calls;
99
+ }
100
+ export function dropCall(dir, key) {
101
+ rmSync(callFile(dir, key), { force: true });
102
+ }
103
+ /**
104
+ * 存檔是否仍可沿用:帳本在這個呼叫啟動後多出的已套用呼叫,必須全是這一輪已存檔的審查者(崩潰或重跑時已套用的 slot)。
105
+ * 多出別的呼叫(例如修正者只回覆交接、沒改計畫或程式碼,輪次與指紋都沒變)代表審查者沒看過現在的帳本,要重跑。
106
+ */
107
+ export function storedCallValid(call, live, round) {
108
+ const seen = new Set(call.base.appliedCalls ?? []);
109
+ const sameRound = new Set([...round].map((item) => item.callKey));
110
+ return (live.appliedCalls ?? []).every((key) => seen.has(key) || sameRound.has(key));
111
+ }
112
+ //# sourceMappingURL=parallelReview.js.map
package/dist/paths.js CHANGED
@@ -22,6 +22,10 @@ export const handoffPath = (id) => join(runDir(id), "handoff.json");
22
22
  export const planReviewStatePath = (id) => join(runDir(id), "plan-review-state.json");
23
23
  /** 已交付仲裁、尚未得出裁決:暫停後 resume 直接回到仲裁。放在 worktree 外,agent 無法偽造 */
24
24
  export const planArbitrationPath = (id) => join(runDir(id), "plan-arbitration.json");
25
+ /** 平行審查的臨時 worktree(每個呼叫一個);孤兒在 advance() 開頭統一清掉 */
26
+ export const tempWorktreesDir = (id) => join(runDir(id), "tmp-review");
27
+ /** 平行審查已執行成功的呼叫存檔(依輪次與輸入指紋分目錄),resume 時沿用 */
28
+ export const parallelReviewDir = (id) => join(runDir(id), "parallel-review");
25
29
  /** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
26
30
  export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
27
31
  export const worktreeDir = (id) => join(worktreesDir(), id);
package/dist/schemas.js CHANGED
@@ -143,6 +143,8 @@ export const RepoConfig = z.object({
143
143
  reviewQuorum: z.number().int().min(1).default(1),
144
144
  /** 計畫需要幾位不同的 reviewer 都核准 */
145
145
  planReviewQuorum: z.number().int().min(1).default(1),
146
+ /** 同一輪審查最多幾位審查者同時執行;沒寫=不限,1=一次一位 */
147
+ reviewConcurrency: z.number().int().min(1).optional(),
146
148
  /** 計畫審查僵持不下(達到重試上限或意見不再變化)時,交給第三方 agent 仲裁,而不是停下來等人 */
147
149
  planArbiter: z.boolean().default(true),
148
150
  /** 任務夠多、能依檔案分群時,計畫審查改成每輪一次索引加上只審有變動的任務群 */
@@ -0,0 +1,129 @@
1
+ import { cpSync, existsSync, mkdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
+ import { join, sep } from "node:path";
3
+ import { git, removeWorktree } from "./git.js";
4
+ import { flowDir, projectRoot, tempWorktreesDir, worktreeDir } from "./paths.js";
5
+ export { tempWorktreesDir };
6
+ let created = 0;
7
+ const warn = (what, err) => console.warn(`⚠️ ${what}失敗(下次執行或 clean 會再清):${err.message}`);
8
+ /**
9
+ * 建立臨時 worktree:detached、內容是 run worktree 的 HEAD,並複製目前的 .flow/(.flow/ 不受 git 管理,要另外帶),
10
+ * run worktree 頂層有 node_modules 時再建一個指向它的 symlink,讓審查者讀得到依賴。
11
+ * 路徑固定為 tmp-review/<name>:agent CLI(Claude Code、Gemini CLI)會依工作目錄記錄專案,路徑每次不同會一直累積紀錄。
12
+ * 固定路徑被占用(目錄已存在,或殘留的 locked 登記讓 git worktree add 失敗)時才改用唯一的父目錄,basename 仍是 name。
13
+ */
14
+ export async function createTempWorktree(runId, name) {
15
+ const stable = join(tempWorktreesDir(runId), name);
16
+ mkdirSync(tempWorktreesDir(runId), { recursive: true });
17
+ let ws;
18
+ if (!existsSync(stable)) {
19
+ try {
20
+ await git(worktreeDir(runId), "worktree", "add", "--detach", stable, "HEAD");
21
+ ws = { dir: stable, flow: join(stable, ".flow") };
22
+ }
23
+ catch {
24
+ // 固定路徑被占用:清掉這次可能留下的半成品目錄(原本不存在,是這次建的),改用唯一路徑
25
+ try {
26
+ rmSync(stable, { recursive: true, force: true });
27
+ }
28
+ catch (err) {
29
+ warn("清掉固定路徑的半成品 ", err);
30
+ }
31
+ }
32
+ }
33
+ if (!ws) {
34
+ const parent = join(tempWorktreesDir(runId), `${process.pid}-${Date.now()}-${created++}`);
35
+ const dir = join(parent, name);
36
+ mkdirSync(parent, { recursive: true });
37
+ await git(worktreeDir(runId), "worktree", "add", "--detach", dir, "HEAD");
38
+ ws = { dir, flow: join(dir, ".flow"), uniqueParent: parent };
39
+ }
40
+ try {
41
+ if (existsSync(flowDir(runId)))
42
+ cpSync(flowDir(runId), ws.flow, { recursive: true });
43
+ else
44
+ mkdirSync(ws.flow, { recursive: true });
45
+ const deps = join(worktreeDir(runId), "node_modules");
46
+ if (existsSync(deps))
47
+ symlinkSync(deps, join(ws.dir, "node_modules"), process.platform === "win32" ? "junction" : "dir");
48
+ }
49
+ catch (err) {
50
+ await removeTempWorktree(runId, ws); // 不會丟例外,不會蓋掉原本的錯誤
51
+ throw err;
52
+ }
53
+ return ws;
54
+ }
55
+ /**
56
+ * 移除這次建立的臨時 worktree:固定路徑只刪 dir(絕不刪 tmp-review/ 本身,裡面可能還有別的呼叫在用),唯一路徑連父目錄一起刪。
57
+ * git worktree remove 與 rmSync 都不會跟進 node_modules symlink 刪到原本的依賴。
58
+ * 永遠不丟例外:這在 withTempWorktree 的 finally 裡執行,丟出去會蓋掉審查本身的結果(已存檔,甚至可能是額度用完);
59
+ * 清不掉的只印警告,交給下次 advance 或 clean 的清理。
60
+ */
61
+ export async function removeTempWorktree(runId, ws) {
62
+ let removed = true;
63
+ try {
64
+ await removeWorktree(worktreeDir(runId), ws.dir);
65
+ }
66
+ catch {
67
+ removed = false; // 目錄可能已經不在:刪掉目錄後再讓 git 忘掉這一個登記
68
+ }
69
+ try {
70
+ rmSync(ws.uniqueParent ?? ws.dir, { recursive: true, force: true, maxRetries: 3 });
71
+ }
72
+ catch (err) {
73
+ warn("移除平行審查的臨時 worktree ", err);
74
+ return;
75
+ }
76
+ // 只處理自己這一個登記;repo 層級的 prune 可能清掉使用者其他資料夾暫時不在的 worktree
77
+ if (!removed)
78
+ await git(worktreeDir(runId), "worktree", "remove", "-f", "-f", ws.dir).catch(() => { });
79
+ }
80
+ export async function withTempWorktree(runId, name, fn) {
81
+ const ws = await createTempWorktree(runId, name);
82
+ try {
83
+ return await fn(ws);
84
+ }
85
+ finally {
86
+ await removeTempWorktree(runId, ws);
87
+ }
88
+ }
89
+ /** git worktree list 裡登記在這個 run 臨時目錄下的 worktree(含 locked 的) */
90
+ async function registeredTempWorktrees(repo, runId) {
91
+ const base = tempWorktreesDir(runId);
92
+ const prefixes = [base + sep];
93
+ try {
94
+ prefixes.push(realpathSync(base) + sep);
95
+ }
96
+ catch {
97
+ // 目錄已不在:只比對原路徑
98
+ }
99
+ return (await git(repo, "worktree", "list", "--porcelain"))
100
+ .split("\n")
101
+ .filter((line) => line.startsWith("worktree "))
102
+ .map((line) => line.slice("worktree ".length))
103
+ .filter((path) => prefixes.some((prefix) => path.startsWith(prefix)));
104
+ }
105
+ /**
106
+ * 清掉上次中斷(Ctrl-C 的 process.exit 不跑 finally、SIGTERM、kill -9、當機)留下的臨時 worktree 與 git 登記。
107
+ * git 在 worktree add 途中被強制中止會留下 locked 登記,prune 會略過它,所以先逐個 remove -f -f。
108
+ * 這個 run 沒有 tmp-review/ 就什麼都不做(不呼叫 git);prune 是 repo 層級的,只在確實找到這個 run 的登記時才執行。
109
+ * force(`clean` 用):tmp-review/ 已不在也照樣列出登記並清掉,否則只剩 locked 登記時會永遠留在 .git/worktrees/。
110
+ * 不丟例外:清不乾淨只印警告,下一次 advance 會再試;固定路徑被殘骸占用時建立會改用唯一路徑,不會被擋住。
111
+ */
112
+ export async function cleanupTempWorktrees(runId, opts = {}) {
113
+ if (!opts.force && !existsSync(tempWorktreesDir(runId)))
114
+ return;
115
+ try {
116
+ const repo = existsSync(worktreeDir(runId)) ? worktreeDir(runId) : projectRoot();
117
+ const registered = await registeredTempWorktrees(repo, runId);
118
+ for (const path of registered) {
119
+ await git(repo, "worktree", "remove", "-f", "-f", path).catch(() => { }); // 失敗交給下面的 rmSync 與 prune
120
+ }
121
+ rmSync(tempWorktreesDir(runId), { recursive: true, force: true, maxRetries: 3 });
122
+ if (registered.length)
123
+ await git(repo, "worktree", "prune");
124
+ }
125
+ catch (err) {
126
+ warn("清理平行審查的臨時 worktree ", err);
127
+ }
128
+ }
129
+ //# sourceMappingURL=tempWorktree.js.map
@@ -4,6 +4,7 @@
4
4
  "tddSplit": true,
5
5
  "reviewQuorum": 1,
6
6
  "planReviewQuorum": 1,
7
+ "reviewConcurrency": 2,
7
8
  "planArbiter": true,
8
9
  "planReviewLayers": { "enabled": true, "minTasks": 7, "maxGroups": 5, "tasksPerGroup": 3 },
9
10
  "tieBreak": "proceed",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",