@zhushanwen/pi-subagent-workflow 7.4.0 → 8.1.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.
Files changed (166) hide show
  1. package/README.md +15 -0
  2. package/package.json +7 -4
  3. package/scripts/rfl.mjs +308 -0
  4. package/skills/workflow-script-format/SKILL.md +1 -1
  5. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  7. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  8. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  9. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  10. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  11. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  12. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  13. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  14. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  15. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  16. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  17. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  18. package/src/execution/__tests__/execution-record.test.ts +199 -0
  19. package/src/execution/__tests__/finalize-record.test.ts +197 -15
  20. package/src/execution/__tests__/format.test.ts +131 -7
  21. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  22. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
  24. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  26. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  27. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  28. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  29. package/src/execution/__tests__/list-component.test.ts +59 -5
  30. package/src/execution/__tests__/list-fields.test.ts +109 -0
  31. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  32. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  33. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  34. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  35. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  36. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  37. package/src/execution/__tests__/record-store.test.ts +442 -54
  38. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  39. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  40. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  41. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  42. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  43. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  44. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  45. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  46. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  47. package/src/execution/__tests__/session-pending.test.ts +61 -2
  48. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  49. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  50. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  51. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  52. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  53. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  54. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  55. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  56. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  57. package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  59. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  60. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  61. package/src/execution/__tests__/tool-action.test.ts +12 -10
  62. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  63. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  64. package/src/execution/__tests__/worktree-manager.test.ts +300 -90
  65. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
  66. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  67. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  68. package/src/execution/agent-result-mapper.ts +4 -1
  69. package/src/execution/argv-mirror.ts +21 -2
  70. package/src/execution/channel-registry-access.ts +3 -1
  71. package/src/execution/execution-record.ts +126 -9
  72. package/src/execution/finalize-record.ts +99 -13
  73. package/src/execution/idle-gc.ts +47 -0
  74. package/src/execution/lifecycle-manager.ts +491 -0
  75. package/src/execution/lifecycle-predicates.ts +65 -0
  76. package/src/execution/manifest-store.ts +61 -16
  77. package/src/execution/model-resolver.ts +26 -5
  78. package/src/execution/notifier.ts +69 -12
  79. package/src/execution/pi-invocation.ts +21 -1
  80. package/src/execution/record-entry.ts +118 -0
  81. package/src/execution/record-store.ts +844 -108
  82. package/src/execution/session-pending.ts +121 -49
  83. package/src/execution/session-reconstructor.ts +224 -7
  84. package/src/execution/session-runner.ts +713 -316
  85. package/src/execution/sessions-index.ts +304 -0
  86. package/src/execution/stdin-writer.ts +93 -7
  87. package/src/execution/stream-sink.ts +20 -3
  88. package/src/execution/subagent-service.ts +917 -138
  89. package/src/execution/temp-prompt.ts +8 -3
  90. package/src/execution/turn-limiter.ts +14 -0
  91. package/src/execution/types.ts +218 -19
  92. package/src/execution/worktree-manager.ts +449 -59
  93. package/src/execution/worktree-registry.ts +97 -29
  94. package/src/index.ts +318 -20
  95. package/src/injectors/subagent-list-injector.ts +26 -8
  96. package/src/injectors/workflow-list-injector.ts +25 -8
  97. package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
  98. package/src/interface/__tests__/tool-render.test.ts +10 -8
  99. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  100. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  101. package/src/interface/bg-notify-render.ts +32 -8
  102. package/src/interface/command-actions.ts +26 -7
  103. package/src/interface/commands.ts +21 -22
  104. package/src/interface/format.ts +53 -20
  105. package/src/interface/gui-mappers.ts +6 -8
  106. package/src/interface/helpers.ts +170 -10
  107. package/src/interface/list-component.ts +53 -14
  108. package/src/interface/subagent-actions.ts +235 -17
  109. package/src/interface/subagent-tool.ts +81 -16
  110. package/src/interface/subagents.ts +2 -1
  111. package/src/interface/tool-render.ts +21 -27
  112. package/src/interface/tool-workflow-script.ts +29 -33
  113. package/src/interface/tool-workflow.ts +67 -100
  114. package/src/interface/views/WorkflowsView.ts +89 -32
  115. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  116. package/src/interface/views/detail-content.ts +1 -1
  117. package/src/interface/views/format.ts +3 -3
  118. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
  119. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  120. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  121. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  122. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  123. package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
  124. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  128. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  129. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  130. package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
  131. package/src/orchestration/__tests__/test-mocks.ts +197 -0
  132. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  133. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
  134. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  135. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  136. package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
  137. package/src/orchestration/agent-opts-resolver.ts +4 -1
  138. package/src/orchestration/args-validator.ts +2 -2
  139. package/src/orchestration/config-loader.ts +30 -1
  140. package/src/orchestration/error-recovery.ts +137 -30
  141. package/src/orchestration/execute-agent-call.ts +44 -10
  142. package/src/orchestration/jsonl-run-store.ts +397 -71
  143. package/src/orchestration/launcher.ts +7 -1
  144. package/src/orchestration/lifecycle.ts +145 -133
  145. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  146. package/src/orchestration/models/budget.ts +1 -1
  147. package/src/orchestration/models/run-runtime.ts +15 -17
  148. package/src/orchestration/models/run-spec.ts +2 -2
  149. package/src/orchestration/models/run-state.ts +3 -3
  150. package/src/orchestration/models/trace.ts +95 -15
  151. package/src/orchestration/models/types.ts +8 -9
  152. package/src/orchestration/models/workflow-run.ts +50 -71
  153. package/src/orchestration/models/workflow-script.ts +32 -1
  154. package/src/orchestration/skill-discovery.ts +30 -0
  155. package/src/orchestration/worker-handle.ts +1 -1
  156. package/src/orchestration/worker-host.ts +1 -1
  157. package/src/orchestration/worker-script-builder.ts +38 -11
  158. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  159. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  160. package/src/shared/__tests__/resource-discovery.test.ts +79 -0
  161. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  162. package/src/shared/agent-ref.ts +22 -1
  163. package/src/shared/resource-discovery.ts +162 -59
  164. package/src/shared/schema-jsonify.ts +56 -0
  165. package/workflows/review-fix-loop-utils.cjs +542 -32
  166. package/workflows/review-fix-loop.js +462 -109
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
- import { dirname, join } from "node:path";
24
+ import { basename, dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
27
27
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -68,7 +68,7 @@ const MOCK_USAGE: AgentUsage = {
68
68
  };
69
69
 
70
70
  type JsonSchema = {
71
- type?: string;
71
+ type?: string | string[];
72
72
  properties?: Record<string, JsonSchema>;
73
73
  items?: JsonSchema;
74
74
  oneOf?: JsonSchema[];
@@ -97,14 +97,17 @@ function miniValidator(schema: unknown, value: unknown, path: string): void {
97
97
  return;
98
98
  }
99
99
  if (s.type) {
100
- const ok =
101
- (s.type === "string" && typeof value === "string") ||
102
- (s.type === "number" && typeof value === "number") ||
103
- (s.type === "integer" && typeof value === "number" && Number.isInteger(value)) ||
104
- (s.type === "boolean" && typeof value === "boolean") ||
105
- (s.type === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) ||
106
- (s.type === "array" && Array.isArray(value));
107
- if (!ok) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path} (expected ${s.type})`);
100
+ // rfl C5:JSON Schema union type(如 total: ["number", "null"])——任一分支匹配即过
101
+ const types = Array.isArray(s.type) ? s.type : [s.type];
102
+ const ok = types.some((t) =>
103
+ (t === "string" && typeof value === "string") ||
104
+ (t === "number" && typeof value === "number") ||
105
+ (t === "integer" && typeof value === "number" && Number.isInteger(value)) ||
106
+ (t === "boolean" && typeof value === "boolean") ||
107
+ (t === "null" && value === null) ||
108
+ (t === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) ||
109
+ (t === "array" && Array.isArray(value)));
110
+ if (!ok) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path} (expected ${JSON.stringify(s.type)})`);
108
111
  }
109
112
  const rec = value as Record<string, unknown> | null;
110
113
  if (Array.isArray(s.required) && rec !== null && typeof rec === "object") {
@@ -153,9 +156,17 @@ function assertScriptOutcome(scriptResult: unknown): {
153
156
  }
154
157
 
155
158
  interface Scenario {
156
- /** review 调用序 → 返回数据生成器;R2+ 回调收到 prompt 文本(可用于对账/传递断言)。 */
159
+ /** review 调用序 → 返回数据生成器;R2+ 回调收到 prompt 文本(可用于对账/传递断言)。
160
+ * 返回 { __invalidOutput: value } 哨兵 = 模拟「reviewer 输出无效」(F3 锁定用例,
161
+ * 语义同 aggregate 哨兵)。 */
157
162
  review: Array<(prompt: string) => Record<string, unknown>>;
158
- aggregate: () => Record<string, unknown>;
163
+ /**
164
+ * aggregate 剧本(回调收到 prompt 文本——可从中解析 roundDir 预写 fallback 依赖的
165
+ * aggregated.md)。返回 { __invalidOutput: value } 哨兵 = 模拟「aggregator 输出无效」
166
+ * (真实 LLM 违约场景,F2 e2e):runner 解包后直接返回 value 并跳过 miniValidator
167
+ * ——契约校验防的是 mock 无意脱节,刻意违约剧本必须绕过。
168
+ */
169
+ aggregate: (prompt: string) => Record<string, unknown> | { __invalidOutput: unknown };
159
170
  fix: () => Record<string, unknown>;
160
171
  }
161
172
 
@@ -165,33 +176,53 @@ interface Scenario {
165
176
  */
166
177
  function makeScenarioRunner(scenario: Scenario) {
167
178
  const reviewCalls: Array<{ prompt: string; result: Record<string, unknown> }> = [];
168
- const calls: Array<{ kind: "review" | "aggregate" | "fix"; prompt: string; agent?: string }> = [];
169
- const run = vi.fn(async (opts: { prompt?: string; schema?: unknown; agent?: string }): Promise<AgentResult> => {
179
+ const calls: Array<{ kind: "review" | "aggregate" | "fix"; prompt: string; agent?: string; schema?: unknown; model?: string }> = [];
180
+ const run = vi.fn(async (opts: { prompt?: string; schema?: unknown; agent?: string; model?: string }): Promise<AgentResult> => {
170
181
  const kind = classifyCall(opts);
171
182
  const prompt = opts.prompt ?? "";
172
183
  // m5:记录 agent 字段(review/fix 派发验证)。内置名(reviewer/doc-reviewer)走
173
184
  // def.name,自定义 .md agent 为 undefined——只断言 fix 调用的(fixAgent 派发)。
174
- calls.push({ kind, prompt, agent: opts.agent });
185
+ // rfl B6:记录 opts.schema(aggregatorSchema 扩展断言——防 schema 与 prompt 脱节)。
186
+ // rfl C5:记录 opts.model(aggregatorModel 降档断言)。
187
+ calls.push({ kind, prompt, agent: opts.agent, schema: opts.schema, model: opts.model });
175
188
  let parsed: unknown = null;
189
+ // F2 e2e:__invalidOutput 哨兵 = 刻意模拟 aggregator 违约输出(缺 must_fix 等),
190
+ // miniValidator 对故意无效无校验意义,跳过(防契约校验拦截违约剧本)。
191
+ let skipContractCheck = false;
176
192
  if (kind === "review") {
177
193
  const idx = reviewCalls.length;
178
194
  const gen = scenario.review[Math.min(idx, scenario.review.length - 1)];
179
195
  const result = gen(prompt);
180
196
  reviewCalls.push({ prompt, result });
181
- parsed = result;
197
+ // F3 锁定用例:review 剧本同样支持 __invalidOutput 哨兵——模拟 reviewer 违约
198
+ // 输出(缺 must_fix),绕过 miniValidator(契约校验防的是 mock 无意脱节)。
199
+ if (result && typeof result === "object" && "__invalidOutput" in result) {
200
+ parsed = (result as { __invalidOutput: unknown }).__invalidOutput;
201
+ skipContractCheck = true;
202
+ } else {
203
+ parsed = result;
204
+ }
182
205
  } else if (kind === "aggregate") {
183
- parsed = scenario.aggregate();
206
+ const raw = scenario.aggregate(prompt);
207
+ if (raw && typeof raw === "object" && "__invalidOutput" in raw) {
208
+ parsed = (raw as { __invalidOutput: unknown }).__invalidOutput;
209
+ skipContractCheck = true;
210
+ } else {
211
+ parsed = raw;
212
+ }
184
213
  } else {
185
214
  parsed = scenario.fix();
186
215
  }
187
216
  // m8:schema 契约校验——mock 返回的 parsedOutput 必须符合 workflow 声明的权威 schema
188
217
  // (防止未来 schema 收紧时 E2E 仍绿)。校验失败抛错让测试立即失败。
189
- miniValidator(opts.schema, parsed, "parsedOutput");
218
+ if (!skipContractCheck) miniValidator(opts.schema, parsed, "parsedOutput");
219
+ // rfl 仪表(T1):sessionId 逐调用编号——A6 断言 calls[].sessionId 与调用序对应
190
220
  return {
191
221
  content: "mock",
192
222
  parsedOutput: parsed,
193
223
  usage: MOCK_USAGE,
194
224
  durationMs: 1,
225
+ sessionId: "sess-e2e-" + (calls.length - 1),
195
226
  error: undefined,
196
227
  };
197
228
  });
@@ -202,6 +233,8 @@ function makeScenarioRunner(scenario: Scenario) {
202
233
  kinds: calls.map((c) => c.kind),
203
234
  prompts: calls.map((c) => c.prompt),
204
235
  agents: calls.map((c) => c.agent),
236
+ schemas: calls.map((c) => c.schema),
237
+ models: calls.map((c) => c.model),
205
238
  }),
206
239
  };
207
240
  }
@@ -285,9 +318,16 @@ function makeDeps(runner: AgentRunner): LauncherDeps {
285
318
  return { ...base, registry };
286
319
  }
287
320
 
321
+ let rflHomeDir: string;
322
+
288
323
  beforeEach(() => {
289
324
  sessionDir = mkdtempSync(join(tmpdir(), "rfl-e2e-"));
290
325
  fixtureDir = mkdtempSync(join(tmpdir(), "rfl-e2e-agents-"));
326
+ // rfl 仪表(tier-1 T3):state.json 现落 ~/.review-fix-loop/——HOME 重定向到
327
+ // 临时目录,防测试写真实 home(worker_threads 在 new Worker 时拷贝主线程 env
328
+ // 快照,stubEnv 先于 runAndWait 即可让 worker 侧 os.homedir() 读到隔离值)。
329
+ rflHomeDir = mkdtempSync(join(tmpdir(), "rfl-e2e-home-"));
330
+ vi.stubEnv("HOME", rflHomeDir);
291
331
  createdStores = [];
292
332
  });
293
333
 
@@ -295,12 +335,15 @@ afterEach(() => {
295
335
  try {
296
336
  rmSync(sessionDir, { recursive: true, force: true });
297
337
  rmSync(fixtureDir, { recursive: true, force: true });
338
+ rmSync(rflHomeDir, { recursive: true, force: true });
298
339
  } catch {
299
340
  // 临时目录清理失败不影响测试结论
300
341
  }
301
342
  sessionDir = "";
302
343
  fixtureDir = "";
344
+ rflHomeDir = "";
303
345
  createdStores = [];
346
+ vi.unstubAllEnvs();
304
347
  vi.restoreAllMocks();
305
348
  });
306
349
 
@@ -439,10 +482,17 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
439
482
  expect(outcome.totalFixed).toBe(2);
440
483
 
441
484
  // skipCleanAgents:R1 两 review + R2 一 review = 3;skip 失效则为 4
442
- const { kinds, reviewCalls, agents } = runner.stats();
485
+ const { kinds, reviewCalls, agents, prompts } = runner.stats();
443
486
  expect(reviewCalls.length).toBe(3);
444
487
  expect(kinds.filter((k) => k === "fix").length).toBe(1);
445
488
  expect(kinds.filter((k) => k === "aggregate").length).toBe(1);
489
+ // W6:schema-only reviewer 的 report_content 已落盘(report_file 回填),聚合
490
+ // prompt 只内嵌计数与路径清单——正文不得随 reviewResults JSON 再嵌一份
491
+ //(同一内容双份付费,实测 ~48% 重复);修复前 sub_reviews JSON 含 report_content
492
+ const aggPrompt = prompts[kinds.indexOf("aggregate")];
493
+ expect(aggPrompt).toContain('<untrusted source="sub_reviews">'); // reviewResults JSON 仍在
494
+ expect(aggPrompt).not.toContain("Pass 1 完成"); // 正文不在(已落盘,read 指引可取)
495
+ expect(aggPrompt).toContain("doc-reviewer.md"); // 路径清单在(READ FIRST 段数据源)
446
496
  // S4:fixAgent 派发验证——fix 调用带 agentRef 路径(<available_subagents> location 语义)
447
497
  const fixIdx = kinds.indexOf("fix");
448
498
  expect(agents[fixIdx]).toBe(agentMd("reviewer"));
@@ -879,4 +929,1043 @@ describe("startup fail-fast (ADR-0003 D6)", () => {
879
929
  expect(String(result.error ?? "")).toContain("/nonexistent/fix.md");
880
930
  expect(runner.stats().kinds.length).toBe(0);
881
931
  }, RUN_TIMEOUT_MS);
932
+
933
+ // ── A6: rfl 仪表 e2e(tier-1 T3+T4:存储迁移 + calls[] 采集) ──
934
+
935
+ it(
936
+ "A6: state.json 落 ~/.review-fix-loop/<slug>/<runId>/ 且 calls[] 十字段全集 + phaseTimings 采集(默认剧本)",
937
+ async () => {
938
+ // 剧本:R1 1 must-fix → fix → R2 clean(two-round clean 终止)
939
+ const runner = makeScenarioRunner({
940
+ review: [
941
+ () => ({ report_file: "/tmp/a6-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
942
+ () => ({
943
+ report_file: "/tmp/a6-r2.md", must_fix: 0, suggestion: 0,
944
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
945
+ }),
946
+ ],
947
+ aggregate: () => ({
948
+ report_file: "/tmp/a6-agg.md", must_fix: 1, suggestion: 0,
949
+ must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [],
950
+ }),
951
+ fix: () => ({
952
+ fixed_count: 1,
953
+ fixes: [{ issue_id: "MF-1", description: "mock fix", self_check: "grep: 1 hit", affected_files: ["src/a.ts"] }],
954
+ deferred: [],
955
+ }),
956
+ });
957
+ const deps = makeDeps(runner);
958
+ const userRunId = RUN_ID();
959
+ const result = await runAndWait(
960
+ wf("review-fix-loop"),
961
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: userRunId },
962
+ deps, undefined, RUN_TIMEOUT_MS,
963
+ );
964
+ expect(result.reason).toBe("completed");
965
+ const outcome = assertScriptOutcome(result.scriptResult);
966
+ expect(outcome.terminated).toBe("clean");
967
+
968
+ // T3 存储迁移:runDir 位于 HOME(已 stub 到 rflHomeDir)下 .review-fix-loop/<slug>/<runId>
969
+ expect(typeof outcome.runDir).toBe("string");
970
+ expect(outcome.runDir).toContain(join(rflHomeDir, ".review-fix-loop"));
971
+ // 引擎注入的 _runId 覆盖用户预传值(A3 语义)——state 目录名 = 引擎 runId
972
+ expect(outcome.runDir).not.toContain(userRunId);
973
+ expect(result.runId).toBeTruthy();
974
+ expect(outcome.runDir!.endsWith(result.runId)).toBe(true);
975
+
976
+ const stateFile = join(outcome.runDir!, "state.json");
977
+ expect(existsSync(stateFile)).toBe(true);
978
+ const st = JSON.parse(readFileSync(stateFile, "utf8")) as {
979
+ meta: { terminated: string };
980
+ calls: Array<Record<string, unknown>>;
981
+ batches: Array<{ rounds: Array<{ round: number; phaseTimings: Record<string, unknown> }> }>;
982
+ };
983
+
984
+ // terminated 快照(saveState 落盘)
985
+ expect(st.meta.terminated).toBe("clean");
986
+
987
+ // calls[]:R1 review + R1 aggregate + R1 fix + R2 review = 4 条,十字段全集
988
+ expect(st.calls.length).toBe(4);
989
+ const roles = st.calls.map((c) => c.role as string);
990
+ expect(roles.filter((r) => r === "reviewer").length).toBe(2);
991
+ expect(roles.filter((r) => r === "aggregator").length).toBe(1);
992
+ expect(roles.filter((r) => r === "fixer").length).toBe(1);
993
+ for (const c of st.calls) {
994
+ expect(typeof c.batch).toBe("number");
995
+ expect(typeof c.round).toBe("number");
996
+ expect(typeof c.name).toBe("string");
997
+ expect(c.name.length).toBeGreaterThan(0);
998
+ expect(typeof c.model).toBe("string");
999
+ expect(typeof c.durationMs).toBe("number");
1000
+ expect(c.usage).toMatchObject({ input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0 });
1001
+ // A12 语义精确化:promptMode = review prompt 模式(reviewer full/scoped);
1002
+ // 非 reviewer 角色(aggregator/fixer)该字段无意义,显式落 null
1003
+ if (c.role === "reviewer") {
1004
+ expect(c.promptMode).toBe("full");
1005
+ } else {
1006
+ expect(c.promptMode).toBeNull();
1007
+ }
1008
+ expect(typeof c.promptBytes).toBe("number");
1009
+ expect(c.promptBytes).toBeGreaterThan(0);
1010
+ expect(c.sessionId).toMatch(/^sess-e2e-\d+$/);
1011
+ }
1012
+ // sessionId 与 mock runner 调用序一一对应(0..3 各出现一次)
1013
+ const sessionIds = st.calls.map((c) => c.sessionId as string).sort();
1014
+ expect(sessionIds).toEqual(["sess-e2e-0", "sess-e2e-1", "sess-e2e-2", "sess-e2e-3"]);
1015
+
1016
+ // phaseTimings:R1 三键齐全(number 对),R2 clean 轮仅 review、aggregate/fix null
1017
+ const rounds = st.batches[0].rounds;
1018
+ expect(rounds.length).toBe(2);
1019
+ const [r1, r2] = rounds;
1020
+ const pair = [expect.any(Number), expect.any(Number)];
1021
+ expect(r1.phaseTimings.review).toEqual(pair);
1022
+ expect(r1.phaseTimings.aggregate).toEqual(pair);
1023
+ expect(r1.phaseTimings.fix).toEqual(pair);
1024
+ expect(r2.phaseTimings.review).toEqual(pair);
1025
+ expect(r2.phaseTimings.aggregate).toBeNull();
1026
+ expect(r2.phaseTimings.fix).toBeNull();
1027
+ },
1028
+ RUN_TIMEOUT_MS,
1029
+ );
1030
+
1031
+ it(
1032
+ "A6: recheckAfterFix=true 剧本 → calls[] 出现 promptMode=\"scoped\"(scoped 分支采集)",
1033
+ async () => {
1034
+ // 剧本同 E2E-5:R1 reviewer dirty + doc-reviewer clean → fix → R2 全批重派,
1035
+ // doc-reviewer 走 scoped 限定分支。
1036
+ const runner = makeScenarioRunner({
1037
+ review: [
1038
+ (prompt) => prompt.includes("doc-reviewer.md")
1039
+ ? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] }
1040
+ : { report_file: "/tmp/a6s-r1a.md", must_fix: 2, suggestion: 0, reconciliation: [] },
1041
+ (prompt) => prompt.includes("doc-reviewer.md")
1042
+ ? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] }
1043
+ : { report_file: "/tmp/a6s-r1b.md", must_fix: 2, suggestion: 0, reconciliation: [] },
1044
+ () => ({
1045
+ report_file: "/tmp/a6s-r2a.md", must_fix: 0, suggestion: 0,
1046
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read" }, { prev_id: "MF-2", status: "fixed", evidence: "read" }],
1047
+ }),
1048
+ () => ({ report_file: "/tmp/a6s-r2b.md", must_fix: 0, suggestion: 0, reconciliation: [] }),
1049
+ ],
1050
+ aggregate: () => ({
1051
+ report_file: "/tmp/a6s-agg.md", must_fix: 2, suggestion: 0,
1052
+ must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [],
1053
+ }),
1054
+ fix: () => ({
1055
+ fixed_count: 2,
1056
+ fixes: [
1057
+ { issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: ["src/x.ts"] },
1058
+ { issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit", affected_files: [] },
1059
+ ],
1060
+ deferred: [],
1061
+ }),
1062
+ });
1063
+ const deps = makeDeps(runner);
1064
+ const result = await runAndWait(
1065
+ wf("review-fix-loop"),
1066
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer") + "," + agentMd("doc-reviewer"), recheckAfterFix: true, _runId: RUN_ID() },
1067
+ deps, undefined, RUN_TIMEOUT_MS,
1068
+ );
1069
+ expect(result.reason).toBe("completed");
1070
+ const outcome = assertScriptOutcome(result.scriptResult);
1071
+ expect(outcome.terminated).toBe("clean");
1072
+
1073
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1074
+ calls: Array<Record<string, unknown>>;
1075
+ };
1076
+ // 4 review(R1 ×2 + R2 ×2)+ 1 aggregate + 1 fix
1077
+ expect(st.calls.length).toBe(6);
1078
+ const reviewCalls2 = st.calls.filter((c) => c.role === "reviewer");
1079
+ const modes = reviewCalls2.map((c) => c.promptMode as string).sort();
1080
+ // R1 两调用 full;R2 全批重派:reviewer 走 R2+ full、doc-reviewer(上轮 clean)走 scoped
1081
+ expect(modes).toEqual(["full", "full", "full", "scoped"]);
1082
+ },
1083
+ RUN_TIMEOUT_MS,
1084
+ );
1085
+
1086
+ // ── B6: rfl 数据链 e2e(tier-1 M1:origin/dormant/复活/消费侧过滤/schema) ──
1087
+
1088
+ it(
1089
+ "B6: issues 带 origin/guidance/evidence + dormant 落盘/复活 + 消费侧过滤 + aggregatorSchema 扩展",
1090
+ async () => {
1091
+ // 剧本(三轮到 clean):R1 聚合 2 条目(MF-1 活跃带扩展字段 + MF-D1 降级),
1092
+ // fix 只修 MF-1(降级条目被 filterActiveIds 过滤出修复队列);R2 聚合 3 条目
1093
+ //(N-1 files 与 fix affected_files 相交 → regression;N-2 不相交 → new;
1094
+ // MF-D1 复活重新上报)fix 修 3 条;R3 全 clean 终止。
1095
+ // aggregate/fix 按调用序分流(闭包计数,不改 runner 接口)。
1096
+ let aggN = 0;
1097
+ let fixN = 0;
1098
+ const runner = makeScenarioRunner({
1099
+ review: [
1100
+ // R1:1 must-fix(触发聚合)
1101
+ () => ({ report_file: "/tmp/b6-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1102
+ // R2:3 must-fix(N-1/N-2/MF-D1 复活——聚合结构化源),对账 MF-1 fixed
1103
+ () => ({
1104
+ report_file: "/tmp/b6-r2.md", must_fix: 3, suggestion: 0,
1105
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
1106
+ }),
1107
+ // R3:全 clean(对账 3 条 fixed)
1108
+ () => ({
1109
+ report_file: "/tmp/b6-r3.md", must_fix: 0, suggestion: 0,
1110
+ reconciliation: [
1111
+ { prev_id: "N-1", status: "fixed", evidence: "read" },
1112
+ { prev_id: "N-2", status: "fixed", evidence: "read" },
1113
+ { prev_id: "MF-D1", status: "fixed", evidence: "read" },
1114
+ ],
1115
+ }),
1116
+ ],
1117
+ aggregate: () => {
1118
+ aggN++;
1119
+ if (aggN === 1) {
1120
+ return {
1121
+ report_file: "/tmp/b6-agg1.md", must_fix: 1, suggestion: 0,
1122
+ must_fix_ids: [
1123
+ { id: "MF-1", severity: "major", adjudication: "evidence",
1124
+ files: ["src/feature.ts"], evidence: "line 42 off-by-one", guidance: "fix boundary in parser" },
1125
+ { id: "MF-D1", severity: "major", adjudication: "downgraded", note: "claim contradicts known facts" },
1126
+ ],
1127
+ fixes_caution: [],
1128
+ };
1129
+ }
1130
+ return {
1131
+ report_file: "/tmp/b6-agg2.md", must_fix: 3, suggestion: 0,
1132
+ must_fix_ids: [
1133
+ { id: "N-1", severity: "major", adjudication: "evidence",
1134
+ files: ["src/touched.ts"], evidence: "regression found", guidance: "restore guard" },
1135
+ { id: "N-2", severity: "major", adjudication: "evidence",
1136
+ files: ["docs/guide.md"], evidence: "stale doc", guidance: "update doc" },
1137
+ { id: "MF-D1", severity: "major", adjudication: "evidence",
1138
+ files: ["src/touched.ts"], evidence: "revived with concrete repro" },
1139
+ ],
1140
+ fixes_caution: [],
1141
+ };
1142
+ },
1143
+ fix: () => {
1144
+ fixN++;
1145
+ if (fixN === 1) {
1146
+ return {
1147
+ fixed_count: 1,
1148
+ fixes: [{ issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: ["src/touched.ts"] }],
1149
+ deferred: [],
1150
+ };
1151
+ }
1152
+ return {
1153
+ fixed_count: 3,
1154
+ fixes: [
1155
+ { issue_id: "N-1", description: "fix n1", self_check: "grep: 1 hit", affected_files: ["src/touched.ts"] },
1156
+ { issue_id: "N-2", description: "fix n2", self_check: "grep: 1 hit", affected_files: ["docs/guide.md"] },
1157
+ { issue_id: "MF-D1", description: "fix revived", self_check: "grep: 1 hit", affected_files: [] },
1158
+ ],
1159
+ deferred: [],
1160
+ };
1161
+ },
1162
+ });
1163
+ const deps = makeDeps(runner);
1164
+
1165
+ const result = await runAndWait(
1166
+ wf("review-fix-loop"),
1167
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
1168
+ deps, undefined, RUN_TIMEOUT_MS,
1169
+ );
1170
+ expect(result.reason).toBe("completed");
1171
+ const outcome = assertScriptOutcome(result.scriptResult);
1172
+ // 到达 clean = ES3 未拦截 = R1 fix 只修 MF-1 而聚合 must_fix=1(非降级计数)口径一致;
1173
+ // 若降级条目未过滤,mustFixIds 含 MF-D1、fix 剧本不修它 → fix-failure 终止(R3 不可达)。
1174
+ expect(outcome.terminated).toBe("clean");
1175
+
1176
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1177
+ issues: Record<string, { origin?: string; guidance?: string; evidence?: string; status: string }>;
1178
+ dormant: Array<{ id: string; reason: string; detail: string; round: number; revived: boolean }>;
1179
+ };
1180
+
1181
+ // ① 消费侧过滤(间接+直接双证):run 到达 clean = R1 fix 只修 MF-1 而
1182
+ // mustFixIds=filterActiveIds=[MF-1](若降级条目未过滤,ES3 判 MF-D1 漏修 →
1183
+ // fix-failure,R3 不可达);直接证 = dormant 有 MF-D1 落盘(见④)。
1184
+ // ⑥(旧格式回归由现有 e2e 用例守护——本用例全部新格式)
1185
+ expect(st.issues["MF-1"]).toMatchObject({
1186
+ guidance: "fix boundary in parser",
1187
+ evidence: "line 42 off-by-one",
1188
+ });
1189
+ expect(st.issues["MF-1"].origin).toBeUndefined(); // R1 条目无 origin(设计 6.1)
1190
+
1191
+ // ② R2 新发现 origin:N-1 regression(files 与 fix 剧本 affected_files 相交——
1192
+ // mock fixer 不写文件,regression 经 fixImpactFiles 触发);N-2 new
1193
+ expect(st.issues["N-1"]).toMatchObject({ origin: "regression", guidance: "restore guard" });
1194
+ expect(st.issues["N-2"]).toMatchObject({ origin: "new", evidence: "stale doc" });
1195
+
1196
+ // ④ 复活闭环:MF-D1 revived=true 且回修复队列(issues 有它)
1197
+ const dormantD1 = st.dormant.find((d) => d.id === "MF-D1");
1198
+ expect(dormantD1).toBeDefined();
1199
+ expect(dormantD1!.reason).toBe("adjudication-downgraded");
1200
+ expect(dormantD1!.detail).toBe("claim contradicts known facts"); // detail=note(裁决理由优先)
1201
+ expect(dormantD1!.revived).toBe(true);
1202
+ expect(st.issues["MF-D1"]).toBeDefined(); // 复活回修复队列
1203
+
1204
+ // ③ R2 review prompt 含 dormant 清单;R3 prompt 不再注入(revived=true 过滤)
1205
+ const { prompts, kinds, schemas } = runner.stats();
1206
+ const reviewPrompts = prompts.filter((_, i) => kinds[i] === "review");
1207
+ expect(reviewPrompts[1]).toContain("DORMANT ISSUES");
1208
+ expect(reviewPrompts[1]).toContain("MF-D1");
1209
+ expect(reviewPrompts[1]).toContain("adjudication-downgraded");
1210
+ expect(reviewPrompts[2]).not.toContain("DORMANT ISSUES");
1211
+
1212
+ // ⑤ aggregatorSchema 扩展:条目 oneOf object 分支 properties 含五扩展字段
1213
+ const aggSchemaIdx = kinds.findIndex((k) => k === "aggregate");
1214
+ const aggSchema = schemas[aggSchemaIdx] as {
1215
+ properties?: { must_fix_ids?: { items?: { oneOf?: Array<{ properties?: Record<string, unknown> }> } }; scores?: unknown };
1216
+ };
1217
+ const objBranch = aggSchema.properties?.must_fix_ids?.items?.oneOf?.find(
1218
+ (b) => b.properties && "files" in b.properties,
1219
+ );
1220
+ expect(objBranch).toBeDefined();
1221
+ for (const field of ["files", "evidence", "guidance", "adjudication", "note"]) {
1222
+ expect(objBranch!.properties).toHaveProperty(field);
1223
+ }
1224
+ expect(aggSchema.properties).toHaveProperty("scores");
1225
+ },
1226
+ RUN_TIMEOUT_MS,
1227
+ );
1228
+
1229
+ // ── C5: rfl 打分 + clean 轮回填 + aggregatorModel(tier-1 M2) ──
1230
+
1231
+ it(
1232
+ "C5: scores 落盘 + regression 确定性回填 + clean 轮对账回填 + aggregatorModel 降档",
1233
+ async () => {
1234
+ // 剧本(三轮到 clean):R1 聚合带 reviewer scores;R2 聚合带 R2 reviewer 分 +
1235
+ // R1 fix LLM 三维度分 + 1 条新 issue;R2 reconciliation 全 fixed(R1 fix
1236
+ // regression 回填 = 10);R3 全 clean → 确定性对账(N-1 fix-attempted → fixed)
1237
+ // + R2 fix 的 regression 回填(clean 轮 entry,LLM 维度 null)。
1238
+ let aggN = 0;
1239
+ const runner = makeScenarioRunner({
1240
+ review: [
1241
+ () => ({ report_file: "/tmp/c5-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1242
+ () => ({
1243
+ report_file: "/tmp/c5-r2.md", must_fix: 1, suggestion: 0,
1244
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
1245
+ }),
1246
+ () => ({
1247
+ report_file: "/tmp/c5-r3.md", must_fix: 0, suggestion: 0,
1248
+ reconciliation: [{ prev_id: "N-1", status: "fixed", evidence: "read confirmed" }],
1249
+ }),
1250
+ ],
1251
+ aggregate: () => {
1252
+ aggN++;
1253
+ if (aggN === 1) {
1254
+ return {
1255
+ report_file: "/tmp/c5-agg1.md", must_fix: 1, suggestion: 0,
1256
+ must_fix_ids: [{ id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"], evidence: "off-by-one", guidance: "fix boundary" }],
1257
+ fixes_caution: [],
1258
+ scores: [{
1259
+ round: 1, targetKind: "reviewer", targetName: "reviewer",
1260
+ dimensions: { evidence: 9, severity: 7, actionability: 8, reconciliation: 9 },
1261
+ total: 8.2,
1262
+ }],
1263
+ };
1264
+ }
1265
+ return {
1266
+ report_file: "/tmp/c5-agg2.md", must_fix: 1, suggestion: 0,
1267
+ must_fix_ids: [{ id: "N-1", severity: "major", adjudication: "evidence", files: ["src/b.ts"], evidence: "new issue", guidance: "patch it" }],
1268
+ fixes_caution: [],
1269
+ scores: [
1270
+ { round: 2, targetKind: "reviewer", targetName: "reviewer",
1271
+ dimensions: { evidence: 8, severity: 8, actionability: 9, reconciliation: 10 }, total: 8.6 },
1272
+ { round: 1, targetKind: "fix", targetName: "fix",
1273
+ dimensions: { coverage: 8, selfCheck: 9, minimality: 7 }, total: 8 },
1274
+ ],
1275
+ };
1276
+ },
1277
+ fix: () => ({
1278
+ // R1 修 MF-1、R2 修 N-1(按调用序内容差异不影响断言——issue_id 由 reconcile 驱动)
1279
+ fixed_count: 1,
1280
+ fixes: [{ issue_id: aggN === 1 ? "MF-1" : "N-1", description: "fix", self_check: "grep: 1 hit", affected_files: ["src/c.ts"] }],
1281
+ deferred: [],
1282
+ }),
1283
+ });
1284
+ const deps = makeDeps(runner);
1285
+
1286
+ const result = await runAndWait(
1287
+ wf("review-fix-loop"),
1288
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"),
1289
+ aggregatorModel: "mock-model-x", _runId: RUN_ID() },
1290
+ deps, undefined, RUN_TIMEOUT_MS,
1291
+ );
1292
+ expect(result.reason).toBe("completed");
1293
+ const outcome = assertScriptOutcome(result.scriptResult);
1294
+ expect(outcome.terminated).toBe("clean");
1295
+
1296
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1297
+ issues: Record<string, { status: string; history: Array<{ round: number; status: string }> }>;
1298
+ scores: Array<{ round: number; targetKind: string; targetName: string; dimensions: Record<string, number | null>; total: number | null; note?: string }>;
1299
+ };
1300
+
1301
+ // ① R1 reviewer entry(4 维度 + total)与 R1 fix entry(LLM 三维度 + regression 由
1302
+ // R2 reconcile 确定性回填 = 10:R2 reconciliation 全 fixed,无 regressed)
1303
+ const r1Reviewer = st.scores.find((s) => s.targetKind === "reviewer" && s.round === 1);
1304
+ expect(r1Reviewer).toMatchObject({ targetName: "reviewer", total: 8.2 });
1305
+ expect(r1Reviewer!.dimensions).toEqual({ evidence: 9, severity: 7, actionability: 8, reconciliation: 9 });
1306
+
1307
+ const r1Fix = st.scores.find((s) => s.targetKind === "fix" && s.round === 1);
1308
+ expect(r1Fix).toBeDefined();
1309
+ expect(r1Fix!.dimensions).toEqual({ coverage: 8, selfCheck: 9, minimality: 7, regression: 10 });
1310
+ expect(r1Fix!.total).toBe(8); // LLM entry 的 total 保持,regression 只补维度
1311
+
1312
+ // ②③ clean 轮黑洞修复:R2 fix 的 N-1 从 fix-attempted → fixed(history 有 R3
1313
+ // fixed 记录);R2 fix 的确定性 entry(round=2,LLM 三维度 null + regression=10 +
1314
+ // total null + note 标注)
1315
+ expect(st.issues["N-1"].status).toBe("fixed");
1316
+ expect(st.issues["N-1"].history).toContainEqual({ round: 3, status: "fixed" });
1317
+ const r2Fix = st.scores.find((s) => s.targetKind === "fix" && s.round === 2);
1318
+ expect(r2Fix).toBeDefined();
1319
+ expect(r2Fix!.dimensions).toEqual({ coverage: null, selfCheck: null, minimality: null, regression: 10 });
1320
+ expect(r2Fix!.total).toBeNull();
1321
+ expect(r2Fix!.note).toContain("clean-round deterministic backfill");
1322
+
1323
+ // ④ aggregatorModel 降档:aggregate 调用 model=mock-model-x;review/fix 不受影响
1324
+ const { kinds, models } = runner.stats();
1325
+ const aggModels = kinds.map((k, i) => (k === "aggregate" ? models[i] : null)).filter((m) => m !== null);
1326
+ expect(aggModels).toEqual(["mock-model-x", "mock-model-x"]);
1327
+ const reviewModels = kinds.map((k, i) => (k === "review" ? models[i] : null)).filter((m) => m !== null);
1328
+ expect(reviewModels.every((m) => m !== "mock-model-x")).toBe(true);
1329
+
1330
+ // ⑤ usage 提示载体:pi-meta parameters 的 aggregatorModel description(用户可见层)
1331
+ const wfSource = readFileSync(wf("review-fix-loop"), "utf-8");
1332
+ expect(wfSource).toContain("aggregatorModel");
1333
+ expect(wfSource).toContain("AGENTS.md");
1334
+ expect(wfSource).toContain("confirm with the owner");
1335
+ },
1336
+ RUN_TIMEOUT_MS,
1337
+ );
1338
+
1339
+ it(
1340
+ "B6: exec-review 修复回归——对账通道不绕过 dormant 分区 + 已追踪条目降级重报不翻 regressed/不落 dormant",
1341
+ async () => {
1342
+ // 剧本:R1 聚合 2 条目(MF-1 活跃 + MF-D1 降级)→ fix(MF-1);R2 reconciliation
1343
+ // 声明 MF-D1 not-fixed(绕过尝试)+ MF-1 fixed;R2 聚合把 MF-1 降级重报
1344
+ //(已追踪条目降级)+ N-1 活跃 → fix(N-1);R3 clean。
1345
+ let aggN = 0;
1346
+ let fixN = 0;
1347
+ const runner = makeScenarioRunner({
1348
+ review: [
1349
+ () => ({ report_file: "/tmp/b6b-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1350
+ () => ({
1351
+ report_file: "/tmp/b6b-r2.md", must_fix: 1, suggestion: 0,
1352
+ reconciliation: [
1353
+ { prev_id: "MF-1", status: "fixed", evidence: "read confirmed" },
1354
+ { prev_id: "MF-D1", status: "not-fixed", evidence: "still present" },
1355
+ ],
1356
+ }),
1357
+ () => ({
1358
+ report_file: "/tmp/b6b-r3.md", must_fix: 0, suggestion: 0,
1359
+ reconciliation: [{ prev_id: "N-1", status: "fixed", evidence: "read confirmed" }],
1360
+ }),
1361
+ ],
1362
+ aggregate: () => {
1363
+ aggN++;
1364
+ if (aggN === 1) {
1365
+ return {
1366
+ report_file: "/tmp/b6b-agg1.md", must_fix: 1, suggestion: 0,
1367
+ must_fix_ids: [
1368
+ { id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"] },
1369
+ { id: "MF-D1", severity: "major", adjudication: "downgraded", note: "no evidence" },
1370
+ ],
1371
+ fixes_caution: [],
1372
+ };
1373
+ }
1374
+ return {
1375
+ report_file: "/tmp/b6b-agg2.md", must_fix: 1, suggestion: 0,
1376
+ must_fix_ids: [
1377
+ { id: "MF-1", severity: "major", adjudication: "downgraded", note: "re-adjudicated down" },
1378
+ { id: "N-1", severity: "major", adjudication: "evidence", files: ["src/b.ts"] },
1379
+ ],
1380
+ fixes_caution: [],
1381
+ };
1382
+ },
1383
+ fix: () => {
1384
+ fixN++;
1385
+ return {
1386
+ fixed_count: 1,
1387
+ fixes: [{ issue_id: fixN === 1 ? "MF-1" : "N-1", description: "fix", self_check: "grep: 1 hit", affected_files: ["src/c.ts"] }],
1388
+ deferred: [],
1389
+ };
1390
+ },
1391
+ });
1392
+ const deps = makeDeps(runner);
1393
+
1394
+ const result = await runAndWait(
1395
+ wf("review-fix-loop"),
1396
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
1397
+ deps, undefined, RUN_TIMEOUT_MS,
1398
+ );
1399
+ expect(result.reason).toBe("completed");
1400
+ const outcome = assertScriptOutcome(result.scriptResult);
1401
+ expect(outcome.terminated).toBe("clean");
1402
+
1403
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1404
+ issues: Record<string, { status: string; history: Array<{ round: number; status: string }> }>;
1405
+ dormant: Array<{ id: string; revived: boolean }>;
1406
+ };
1407
+
1408
+ // major-1 回归:reviewer 对 dormant id(MF-D1)声明 not-fixed 不经对账通道建
1409
+ // issue(复活唯一入口 = 聚合活跃重报)——issues 无 MF-D1,dormant 记录保持
1410
+ expect(st.issues["MF-D1"]).toBeUndefined();
1411
+ const dormantD1 = st.dormant.find((d) => d.id === "MF-D1");
1412
+ expect(dormantD1).toBeDefined();
1413
+ expect(dormantD1!.revived).toBe(false);
1414
+
1415
+ // major-2a 回归:已追踪条目(MF-1)被 R2 聚合降级重报 → 不翻 regressed
1416
+ //(对账 fixed 声明是权威转换:fix-attempted → fixed,history R2 fixed)
1417
+ expect(st.issues["MF-1"].status).toBe("fixed");
1418
+ expect(st.issues["MF-1"].history).toContainEqual({ round: 2, status: "fixed" });
1419
+ expect(st.issues["MF-1"].history).not.toContainEqual({ round: 2, status: "regressed" });
1420
+
1421
+ // major-2b 回归:MF-1 在 issues 活跃追踪(recordDormant excludeIds)→ 不落 dormant
1422
+ expect(st.dormant.find((d) => d.id === "MF-1")).toBeUndefined();
1423
+
1424
+ // N-1 正常链路 + R3 clean 轮对账回填(fix-attempted → fixed)
1425
+ expect(st.issues["N-1"].status).toBe("fixed");
1426
+ expect(st.issues["N-1"].history).toContainEqual({ round: 3, status: "fixed" });
1427
+ },
1428
+ RUN_TIMEOUT_MS,
1429
+ );
1430
+
1431
+ // ── D1/D4: T9 前缀稳定化(schema 跨轮统一 + R1 空数组下游无异常) ──
1432
+
1433
+ it(
1434
+ "D1/D4: 同一 run 内 R1 与 R2 review 调用 schema 逐字节相同 + 无 per-round spread + R1 空数组下游无异常",
1435
+ async () => {
1436
+ // 剧本同 B6b 骨架(R1 带 reconciliation: [] 显式输出 → fix → R2 → clean)
1437
+ let aggN = 0;
1438
+ let fixN = 0;
1439
+ const runner = makeScenarioRunner({
1440
+ review: [
1441
+ // R1:显式 reconciliation: [](required 统一后的合规输出)
1442
+ () => ({ report_file: "/tmp/d-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1443
+ () => ({
1444
+ report_file: "/tmp/d-r2.md", must_fix: 0, suggestion: 0,
1445
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
1446
+ }),
1447
+ ],
1448
+ aggregate: () => {
1449
+ aggN++;
1450
+ return {
1451
+ report_file: "/tmp/d-agg.md", must_fix: 1, suggestion: 0,
1452
+ must_fix_ids: [{ id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"] }],
1453
+ fixes_caution: [],
1454
+ };
1455
+ },
1456
+ fix: () => {
1457
+ fixN++;
1458
+ return {
1459
+ fixed_count: 1,
1460
+ fixes: [{ issue_id: "MF-1", description: "fix", self_check: "grep: 1 hit", affected_files: ["src/b.ts"] }],
1461
+ deferred: [],
1462
+ };
1463
+ },
1464
+ });
1465
+ const deps = makeDeps(runner);
1466
+
1467
+ const result = await runAndWait(
1468
+ wf("review-fix-loop"),
1469
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
1470
+ deps, undefined, RUN_TIMEOUT_MS,
1471
+ );
1472
+ expect(result.reason).toBe("completed");
1473
+ const outcome = assertScriptOutcome(result.scriptResult);
1474
+ // R1 空数组下游无异常:run 到达 clean(reconcile 门控 reconCount===0 行为不变)
1475
+ expect(outcome.terminated).toBe("clean");
1476
+
1477
+ // schema 跨轮逐字节相同(缓存前缀稳定化的 system 段前提)
1478
+ const { kinds, schemas } = runner.stats();
1479
+ const reviewSchemas = kinds
1480
+ .map((k, i) => (k === "review" ? JSON.stringify(schemas[i]) : null))
1481
+ .filter((s) => s !== null);
1482
+ expect(reviewSchemas.length).toBe(2);
1483
+ expect(reviewSchemas[0]).toBe(reviewSchemas[1]);
1484
+ const parsed = JSON.parse(reviewSchemas[0]) as { required?: string[] };
1485
+ expect(parsed.required).toEqual(["report_file", "must_fix", "suggestion", "reconciliation"]);
1486
+ expect(JSON.stringify(parsed)).not.toContain("optional for R1"); // stale description 已更新
1487
+
1488
+ // 脚本内不再存在 per-round required spread
1489
+ const wfSource = readFileSync(wf("review-fix-loop"), "utf-8");
1490
+ expect(wfSource).not.toContain("...reviewerSchema, required:");
1491
+ },
1492
+ RUN_TIMEOUT_MS,
1493
+ );
1494
+
1495
+ // ── 实施后对抗式审查修复(v7.1):A1/A2/A3/A4 ──────────────────────
1496
+
1497
+ it(
1498
+ "A4: 全降级轮(reviewer 报 must-fix、aggregator 全部降级)→ 不派 fixer,批推进 + 跨批 skip(W5)",
1499
+ async () => {
1500
+ // 剧本:批 1 R1 reviewer-a 报 1 must-fix,aggregator 裁决全部降级
1501
+ //(must_fix=0、唯一条目 downgraded)→ A4 守卫生效:批 1 视同 clean 终止本轮,
1502
+ // 不空转派发 fixer(修复前:mustFix===0 混过 all-clean 判定(用的是 reviewer
1503
+ // 原始计数口径)→ 照常进 Fix 阶段 → fixCount++ 白跑);批 2 = reviewer-a +
1504
+ // reviewer-b:W5 修复后 reviewer-a 在 A4 break 前被补记 recordAgentClean
1505
+ //(裁决降级=噪声,语义等价 clean;A4 轮无 fix → fixCount 快照不变)→ 批 2
1506
+ // cross-batch skip,仅 reviewer-b 派发且直接 clean。
1507
+ const runner = makeScenarioRunner({
1508
+ review: [
1509
+ () => ({ report_file: "/tmp/a4-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1510
+ () => ({ report_file: "/tmp/a4-r2.md", must_fix: 0, suggestion: 0, reconciliation: [] }),
1511
+ ],
1512
+ aggregate: () => ({
1513
+ report_file: "/tmp/a4-agg.md", must_fix: 0, suggestion: 0,
1514
+ must_fix_ids: [{ id: "MF-1", severity: "major", adjudication: "downgraded", note: "no reproducible evidence" }],
1515
+ fixes_caution: [],
1516
+ }),
1517
+ fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
1518
+ });
1519
+ const deps = makeDeps(runner);
1520
+
1521
+ const result = await runAndWait(
1522
+ wf("review-fix-loop"),
1523
+ {
1524
+ targetType: "file", target: "README.md",
1525
+ batch1: agentMd("reviewer-a"),
1526
+ batch2: agentMd("reviewer-a") + "," + agentMd("reviewer-b"), _runId: RUN_ID(),
1527
+ },
1528
+ deps, undefined, RUN_TIMEOUT_MS,
1529
+ );
1530
+
1531
+ expect(result.reason).toBe("completed");
1532
+ expect(result.error).toBeUndefined();
1533
+ const outcome = assertScriptOutcome(result.scriptResult);
1534
+ expect(outcome.terminated).toBe("clean");
1535
+ // 无 fixer 调用(守卫生效);聚合 1 次(批 1),批 2 全 clean 不聚合
1536
+ const { kinds } = runner.stats();
1537
+ expect(kinds.filter((k) => k === "fix").length).toBe(0);
1538
+ expect(kinds.filter((k) => k === "aggregate").length).toBe(1);
1539
+ // W5:review 总数 2 = 批 1 reviewer-a + 批 2 仅 reviewer-b(reviewer-a 被跨批
1540
+ // skip);修复前 reviewer-a 未记 clean → 批 2 派 2 个 agent → 总 3,本断言拦截
1541
+ expect(kinds.filter((k) => k === "review").length).toBe(2);
1542
+ // 批推进到批 2(守卫视同 clean,不 fail-fast)+ fixCount 不虚增
1543
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1544
+ batches: unknown[];
1545
+ fixCount: number;
1546
+ dormant: Array<{ id: string }>;
1547
+ agentStatus: Record<string, { lastCleanBatch: number; lastCleanFixCount: number }>;
1548
+ };
1549
+ expect(st.batches).toHaveLength(2);
1550
+ expect(st.fixCount).toBe(0);
1551
+ // W5:A4 break 前对 must_fix>0 的 reviewer 补记 clean 快照(batch=1、当时
1552
+ // fixCount=0)——shouldSkipAgent 的判定数据源
1553
+ expect(st.agentStatus["reviewer-a"]).toMatchObject({ lastCleanBatch: 1, lastCleanFixCount: 0 });
1554
+ // 批 1 的降级条目在批 1 期间落过 dormant(中间 saveState 可查);批 2 开始时
1555
+ // dormant 批作用域重置(A2)且批 2 无降级 → 最终落盘为空
1556
+ expect(st.dormant).toEqual([]);
1557
+ },
1558
+ RUN_TIMEOUT_MS,
1559
+ );
1560
+
1561
+ it(
1562
+ "F2: aggregator numeric-fallback(无 must_fix_ids)不授予跨批 clean-skip——批 2 仍派该 agent",
1563
+ async () => {
1564
+ // F2 场景(第 3 轮复审 minor):批 1 R1 reviewer-a 报 1 must-fix;aggregator
1565
+ // JSON 无效(缺 must_fix,__invalidOutput 哨兵模拟真实违约)→ parseAggregatedMd
1566
+ // 从预写的 aggregated.md 兜底解析 must_fix=0。兜底 agg 无 must_fix_ids 键——
1567
+ // A4 第三条件对 undefined 恒真仍 break(保留),但 W5 补记循环若无
1568
+ // agg.must_fix_ids 前置,会凭「md 里一行 Must-fix: 0」的弱证据给 reviewer-a 记
1569
+ // clean → 批 2 cross-batch skip →「跳一轮」被 shouldSkipAgent 放大为「跳到底」
1570
+ // 且无条目级裁决证据。修复后:批 2 reviewer-a 照常全价重扫。
1571
+ const runner = makeScenarioRunner({
1572
+ review: [
1573
+ // 批 1 R1(reviewer-a):1 must-fix(dirty)
1574
+ () => ({ report_file: "/tmp/f2-b1r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1575
+ // 批 2 R1(reviewer-a / reviewer-b 顺序不定,两 generator 同结果):全 clean
1576
+ () => ({ report_file: "/tmp/f2-b2r1a.md", must_fix: 0, suggestion: 0, reconciliation: [] }),
1577
+ () => ({ report_file: "/tmp/f2-b2r1b.md", must_fix: 0, suggestion: 0, reconciliation: [] }),
1578
+ ],
1579
+ aggregate: (prompt: string) => {
1580
+ // 真实失败形态:aggregator 的 JSON 无效,但 PART 1 的 aggregated.md 已写出。
1581
+ // 从 prompt 解析 roundDir 预写 fallback 依赖的固定格式("- Must-fix: 0")。
1582
+ const m = /^outputDir: (.+)$/m.exec(prompt);
1583
+ if (!m) throw new Error("F2 e2e: outputDir not found in aggregator prompt");
1584
+ writeFileSync(join(m[1].trim(), "aggregated.md"), "## Summary\n- Must-fix: 0\n- Suggestions: 0\n", "utf-8");
1585
+ // 无 must_fix → normalizeAggregatorResult 判 null → fallback 走 md 兜底
1586
+ return { __invalidOutput: { fixes_caution: [] } };
1587
+ },
1588
+ fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
1589
+ });
1590
+ const deps = makeDeps(runner);
1591
+
1592
+ const result = await runAndWait(
1593
+ wf("review-fix-loop"),
1594
+ {
1595
+ targetType: "file", target: "README.md",
1596
+ batch1: agentMd("reviewer-a"),
1597
+ batch2: agentMd("reviewer-a") + "," + agentMd("reviewer-b"), _runId: RUN_ID(),
1598
+ },
1599
+ deps, undefined, RUN_TIMEOUT_MS,
1600
+ );
1601
+
1602
+ expect(result.reason).toBe("completed");
1603
+ expect(result.error).toBeUndefined();
1604
+ const outcome = assertScriptOutcome(result.scriptResult);
1605
+ expect(outcome.terminated).toBe("clean");
1606
+
1607
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1608
+ batches: Array<{ rounds: Array<{ round: number; mustFix: number | null }> }>;
1609
+ fixCount: number;
1610
+ agentStatus: Record<string, { lastCleanBatch?: number; lastCleanFixCount?: number }>;
1611
+ };
1612
+ expect(st.batches).toHaveLength(2);
1613
+ expect(st.fixCount).toBe(0);
1614
+ // fallback 生效旁证:批 1 的轮条目 mustFix=0(md 兜底解析值),A4 break 路径
1615
+ expect(st.batches[0].rounds[0].mustFix).toBe(0);
1616
+
1617
+ // 核心判别:批 2 仍派 reviewer-a(3 次 review = 批 1 ×1 + 批 2 ×2)。
1618
+ // 修复前 W5 凭弱证据补记 clean(batch=1、fixCount=0)→ 批 2 skip → 仅 2 次。
1619
+ const { kinds } = runner.stats();
1620
+ expect(kinds.filter((k) => k === "review").length).toBe(3);
1621
+ expect(kinds.filter((k) => k === "aggregate").length).toBe(1); // 批 2 全 clean 不聚合
1622
+ expect(kinds.filter((k) => k === "fix").length).toBe(0);
1623
+ // agentStatus 佐证:reviewer-a 的 clean 记录来自批 2 真实 clean 轮(=2),
1624
+ // 而非批 1 fallback 弱证据补记(修复前 =1)
1625
+ expect(st.agentStatus["reviewer-a"]).toMatchObject({ lastCleanBatch: 2, lastCleanFixCount: 0 });
1626
+ },
1627
+ RUN_TIMEOUT_MS,
1628
+ );
1629
+
1630
+ it(
1631
+ "A4/W4: R2 全降级 break 不二次对账——open 条目同轮 openStreak 不双计",
1632
+ async () => {
1633
+ // 剧本:R1 reviewer 报 2 must-fix(MF-1/MF-2 聚合均活跃)→ fix 修两个
1634
+ //(fix-attempted);R2 reviewer 原始报 1 must-fix + 对账 MF-1 fixed /
1635
+ // MF-2 not-fixed;R2 聚合全部降级(mustFix=0、唯一条目 MF-2 downgraded)→
1636
+ // A4 break 终止批 1。修复前:A4 break 前的 applyCleanRoundBackfill 内部第二次
1637
+ // reconcileIssues 把 MF-2(regressed 且 seen)同轮 openStreak 再 +1(1→2 双计,
1638
+ // 无谓吃掉 stuckThreshold 余量);修复后(W4)A4 仅补 regression 维度,对账
1639
+ // 只发生一次(stuck 检测处)。
1640
+ let aggN = 0;
1641
+ const runner = makeScenarioRunner({
1642
+ review: [
1643
+ () => ({ report_file: "/tmp/w4-r1.md", must_fix: 2, suggestion: 0, reconciliation: [] }),
1644
+ () => ({
1645
+ report_file: "/tmp/w4-r2.md", must_fix: 1, suggestion: 0,
1646
+ reconciliation: [
1647
+ { prev_id: "MF-1", status: "fixed", evidence: "read confirmed" },
1648
+ { prev_id: "MF-2", status: "not-fixed", evidence: "still wrong" },
1649
+ ],
1650
+ }),
1651
+ ],
1652
+ aggregate: () => {
1653
+ aggN++;
1654
+ if (aggN === 1) {
1655
+ return {
1656
+ report_file: "/tmp/w4-agg1.md", must_fix: 2, suggestion: 0,
1657
+ must_fix_ids: [
1658
+ { id: "MF-1", severity: "major", adjudication: "evidence" },
1659
+ { id: "MF-2", severity: "major", adjudication: "evidence" },
1660
+ ],
1661
+ fixes_caution: [],
1662
+ };
1663
+ }
1664
+ return {
1665
+ report_file: "/tmp/w4-agg2.md", must_fix: 0, suggestion: 0,
1666
+ must_fix_ids: [{ id: "MF-2", severity: "major", adjudication: "downgraded", note: "weak evidence" }],
1667
+ fixes_caution: [],
1668
+ };
1669
+ },
1670
+ fix: () => ({
1671
+ fixed_count: 2,
1672
+ fixes: [
1673
+ { issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit; synced", affected_files: [] },
1674
+ { issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit; synced", affected_files: [] },
1675
+ ],
1676
+ deferred: [],
1677
+ }),
1678
+ });
1679
+ const deps = makeDeps(runner);
1680
+
1681
+ const result = await runAndWait(
1682
+ wf("review-fix-loop"),
1683
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
1684
+ deps, undefined, RUN_TIMEOUT_MS,
1685
+ );
1686
+
1687
+ expect(result.reason).toBe("completed");
1688
+ expect(result.error).toBeUndefined();
1689
+ const outcome = assertScriptOutcome(result.scriptResult);
1690
+ // A4 break → batchClean → 单批 run 以 clean 终止
1691
+ expect(outcome.terminated).toBe("clean");
1692
+
1693
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1694
+ issues: Record<string, { status: string; openStreak?: number; fixAttempts?: number; history: Array<{ round: number; status: string }> }>;
1695
+ };
1696
+ // R2 对账(唯一一次):MF-1 fixed;MF-2 regressed(fixAttempts=1)且
1697
+ // openStreak=1(修复前第二次 reconcile 双计 → openStreak=2,本断言拦截)
1698
+ expect(st.issues["MF-1"].status).toBe("fixed");
1699
+ expect(st.issues["MF-2"].status).toBe("regressed");
1700
+ expect(st.issues["MF-2"].openStreak).toBe(1);
1701
+ expect(st.issues["MF-2"].fixAttempts).toBe(1);
1702
+ // history 旁证:{round:2, regressed} 只一条(双计的本质是 openStreak 数值,
1703
+ // history 不重复 push——openStreak 断言才是判别点)
1704
+ expect(st.issues["MF-2"].history.filter((h) => h.round === 2 && h.status === "regressed")).toHaveLength(1);
1705
+
1706
+ // 调用数:review 2 + aggregate 2 + fix 1(R2 A4 不派 fixer)
1707
+ const { kinds } = runner.stats();
1708
+ expect(kinds.filter((k) => k === "review").length).toBe(2);
1709
+ expect(kinds.filter((k) => k === "aggregate").length).toBe(2);
1710
+ expect(kinds.filter((k) => k === "fix").length).toBe(1);
1711
+ },
1712
+ RUN_TIMEOUT_MS,
1713
+ );
1714
+
1715
+ it(
1716
+ "F3: review-failure 轮的 batchRounds 条目 mustFix/suggestion 为 null(未知非 0)",
1717
+ async () => {
1718
+ // 第 3 轮终审 info 锁定:失败轮(review-failure)聚合未发生,mustFix 是未知
1719
+ // 而非 0——W8 曾落 0,时间线行会被误读为 clean 轮(消费侧 rfl.mjs `?? "-"`)。
1720
+ // 剧本:reviewer 返回缺 must_fix 的违约输出(__invalidOutput 哨兵绕过契约校验)
1721
+ // → 脚本结构化终止 review-failure,断言该轮 rounds 条目 mustFix/suggestion 为 null。
1722
+ const runner = makeScenarioRunner({
1723
+ review: [() => ({ __invalidOutput: { report_file: "/tmp/f3-invalid.md", suggestion: 0 } })],
1724
+ aggregate: () => {
1725
+ throw new Error("aggregate must not run after review-failure");
1726
+ },
1727
+ fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
1728
+ });
1729
+ const deps = makeDeps(runner);
1730
+
1731
+ const result = await runAndWait(
1732
+ wf("review-fix-loop"),
1733
+ {
1734
+ targetType: "file", target: "README.md",
1735
+ batch1: agentMd("reviewer-a"), _runId: RUN_ID(),
1736
+ },
1737
+ deps, undefined, RUN_TIMEOUT_MS,
1738
+ );
1739
+
1740
+ expect(result.reason).toBe("completed");
1741
+ expect(result.error).toBeUndefined();
1742
+ const outcome = assertScriptOutcome(result.scriptResult);
1743
+ expect(outcome.terminated).toBe("review-failure");
1744
+
1745
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1746
+ batches: Array<{ rounds: Array<{ round: number; mustFix: number | null; suggestion: number | null }> }>;
1747
+ };
1748
+ expect(st.batches).toHaveLength(1);
1749
+ expect(st.batches[0].rounds).toHaveLength(1);
1750
+ // 判别点:失败轮 mustFix/suggestion = null(未知),非 0(clean 语义)——
1751
+ // 回退为 0 时时间线 "mustFix 0" 与真实 clean 轮无法区分,本断言拦截。
1752
+ expect(st.batches[0].rounds[0].mustFix).toBeNull();
1753
+ expect(st.batches[0].rounds[0].suggestion).toBeNull();
1754
+ // 聚合/修复未发生(review-failure 在聚合前终止)
1755
+ const { kinds } = runner.stats();
1756
+ expect(kinds.filter((k) => k === "aggregate").length).toBe(0);
1757
+ expect(kinds.filter((k) => k === "fix").length).toBe(0);
1758
+ },
1759
+ RUN_TIMEOUT_MS,
1760
+ );
1761
+
1762
+ it(
1763
+ "A2+A3: dormant 批作用域重置(跨批同 id 不污染对账)+ fix prompt 含 guidance 通道",
1764
+ async () => {
1765
+ // A2 剧本(跨批 id 冲突):批 1 R1 聚合 2 条目(MF-1 活跃带 guidance / MF-3 降级
1766
+ // 落 dormant)→ fix 修 MF-1;批 1 R2 clean(对账 MF-1 fixed)→ 批 1 终止,
1767
+ // dormant=[批1的 MF-3]。批 2 R1 reviewer-b 报 1 must-fix,聚合返回活跃 MF-3
1768
+ //(aggregator id 空间每批从 MF-1 重新编号,批 2 的 MF-3 是新问题)→ fix 修它;
1769
+ // 批 2 R2 对账声明 MF-3 not-fixed(未修好)→ 聚合仍报活跃 MF-3 → fix 再修。
1770
+ // 修复前:批 1 残留 dormant MF-3 被 filterDormantFromRecon 从 reconSeen 剥离 →
1771
+ // 批 2 的 MF-3 fix-attempted 被误反转 fixed → converged 提前终止(掩盖未修复);
1772
+ // 修复后:dormant 批作用域重置 → reconcile 正常转 regressed → 继续 fix → max-rounds。
1773
+ let aggN = 0;
1774
+ let fixN = 0;
1775
+ const runner = makeScenarioRunner({
1776
+ review: [
1777
+ // 批 1 R1(reviewer-a):2 must-fix
1778
+ () => ({ report_file: "/tmp/a2-b1r1.md", must_fix: 2, suggestion: 0, reconciliation: [] }),
1779
+ // 批 1 R2(reviewer-a):clean + 对账 MF-1 fixed → all-clean 终止批 1
1780
+ () => ({
1781
+ report_file: "/tmp/a2-b1r2.md", must_fix: 0, suggestion: 0,
1782
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
1783
+ }),
1784
+ // 批 2 R1(reviewer-b):1 must-fix(批 2 的 MF-3)
1785
+ () => ({ report_file: "/tmp/a2-b2r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1786
+ // 批 2 R2(reviewer-b):MF-3 not-fixed(未修好)
1787
+ () => ({
1788
+ report_file: "/tmp/a2-b2r2.md", must_fix: 1, suggestion: 0,
1789
+ reconciliation: [{ prev_id: "MF-3", status: "not-fixed", evidence: "still wrong" }],
1790
+ }),
1791
+ ],
1792
+ aggregate: () => {
1793
+ aggN++;
1794
+ if (aggN === 1) {
1795
+ return {
1796
+ report_file: "/tmp/a2-agg1.md", must_fix: 1, suggestion: 0,
1797
+ must_fix_ids: [
1798
+ { id: "MF-1", severity: "major", adjudication: "evidence",
1799
+ files: ["src/a.ts"], evidence: "off-by-one", guidance: "fix the boundary check in parser" },
1800
+ // W2:降级条目刻意带 guidance——修复前 fixGuidance 从 must_fix_ids 全量
1801
+ // 提取,降级条目的 guidance 会以 MUST-FIX GUIDANCE 标题混给 fixer
1802
+ { id: "MF-3", severity: "major", adjudication: "downgraded", note: "weak evidence at the time", guidance: "downgraded-noise guidance" },
1803
+ ],
1804
+ fixes_caution: [],
1805
+ };
1806
+ }
1807
+ if (aggN === 2) {
1808
+ return {
1809
+ report_file: "/tmp/a2-agg2.md", must_fix: 1, suggestion: 0,
1810
+ must_fix_ids: [{ id: "MF-3", severity: "major", adjudication: "evidence",
1811
+ files: ["src/z.ts"], evidence: "new issue in batch 2", guidance: "patch the z guard" }],
1812
+ fixes_caution: [],
1813
+ };
1814
+ }
1815
+ return {
1816
+ report_file: "/tmp/a2-agg3.md", must_fix: 1, suggestion: 0,
1817
+ must_fix_ids: [{ id: "MF-3", severity: "major", adjudication: "evidence",
1818
+ files: ["src/z.ts"], evidence: "still broken" }],
1819
+ fixes_caution: [],
1820
+ };
1821
+ },
1822
+ fix: () => {
1823
+ fixN++;
1824
+ return {
1825
+ fixed_count: 1,
1826
+ fixes: [{
1827
+ issue_id: fixN === 1 ? "MF-1" : "MF-3",
1828
+ description: "fix " + fixN, self_check: "grep: 1 hit; synced", affected_files: [],
1829
+ }],
1830
+ deferred: [],
1831
+ };
1832
+ },
1833
+ });
1834
+ const deps = makeDeps(runner);
1835
+
1836
+ const result = await runAndWait(
1837
+ wf("review-fix-loop"),
1838
+ {
1839
+ targetType: "file", target: "README.md", maxRounds: 2,
1840
+ batch1: agentMd("reviewer-a"), batch2: agentMd("reviewer-b"), _runId: RUN_ID(),
1841
+ },
1842
+ deps, undefined, RUN_TIMEOUT_MS,
1843
+ );
1844
+
1845
+ expect(result.reason).toBe("completed");
1846
+ expect(result.error).toBeUndefined();
1847
+ const outcome = assertScriptOutcome(result.scriptResult);
1848
+ // 判别点:修复前批 2 R2 的 MF-3 被误反转 fixed → converged 提前终止;
1849
+ // 修复后 regressed → 不收敛 → R2 再修一轮 → maxRounds=2 到顶 → max-rounds。
1850
+ expect(outcome.terminated).toBe("max-rounds");
1851
+
1852
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1853
+ issues: Record<string, { status: string; history: Array<{ round: number; status: string }> }>;
1854
+ dormant: Array<{ id: string }>;
1855
+ };
1856
+ // 核心断言:批 2 的 MF-3 走 regressed(对账 not-fixed 生效)而非被批 1 dormant
1857
+ // 残留反转 fixed。批 1 的 MF-1 已被批 2 的 issues 批作用域重置清除(MF-1 批级
1858
+ // 生命周期,不跨批残留——与 dormant 同点重置)。
1859
+ expect(st.issues["MF-1"]).toBeUndefined();
1860
+ expect(st.issues["MF-3"].history).toContainEqual({ round: 2, status: "regressed" });
1861
+ expect(st.issues["MF-3"].history).not.toContainEqual({ round: 2, status: "fixed" });
1862
+ // dormant 批作用域:批 1 记录的 MF-3 在批 2 被重置;批 2 的 MF-3 活跃不落 dormant
1863
+ expect(st.dormant).toEqual([]);
1864
+
1865
+ // fix 正常派发:批 1 R1 + 批 2 R1 + 批 2 R2(regressed 后再修)= 3 次
1866
+ const { kinds, prompts } = runner.stats();
1867
+ const fixIdxs = kinds.map((k, i) => (k === "fix" ? i : -1)).filter((i) => i >= 0);
1868
+ expect(fixIdxs.length).toBe(3);
1869
+
1870
+ // A3 e2e:fix prompt 含 guidance 确定性通道(mock guidance 文本 + wrapUntrusted)
1871
+ const fixPrompts = fixIdxs.map((i) => prompts[i]);
1872
+ expect(fixPrompts[0]).toContain("MUST-FIX GUIDANCE (adjudicated, per-issue)");
1873
+ expect(fixPrompts[0]).toContain('<untrusted source="must_fix_guidance">');
1874
+ expect(fixPrompts[0]).toContain("- MF-1: fix the boundary check in parser");
1875
+ // W2:降级条目(MF-3 downgraded)的 guidance 不进 MUST-FIX GUIDANCE 段——
1876
+ // fixGuidance 与修复队列(filterActiveIds)同口径,不诱导修复已裁决噪声
1877
+ //(reportContent 在此剧本不可读,prompt 中该文本唯一来源 = guidance 通道)
1878
+ expect(fixPrompts[0]).not.toContain("downgraded-noise guidance");
1879
+ // R2+(批 2 的 fix)同样生效
1880
+ expect(fixPrompts[1]).toContain("- MF-3: patch the z guard");
1881
+ // 无 guidance 条目的轮(agg3 无 guidance)→ 无该段(prompt 形状稳定)
1882
+ expect(fixPrompts[2]).not.toContain("MUST-FIX GUIDANCE");
1883
+ },
1884
+ RUN_TIMEOUT_MS,
1885
+ );
1886
+
1887
+ it(
1888
+ "A1: engine rebuild 后同 _runId 残留 state 被重置——calls/fixResults 无双记 + previousAttempts=1",
1889
+ async () => {
1890
+ // 受控脚本故障注入(设计 §8.2 S4 方法):临时脚本副本在首次 fix 完成后写 marker
1891
+ // 文件并抛错 → engine script-error → rebuildRuntime(同 _runId / 同 RUN_ROOT 重跑,
1892
+ // 已完成调用按 callId 缓存重放)。attempt-2 读到 marker 不再抛。
1893
+ // 修复前:loadState 原样复用 attempt-1 残留 → 重放的 recordCall/fixResults/fixCount
1894
+ // 全部双记(calls=7/fixResults=2/fixCount=2);修复后(A1)重置易变字段,
1895
+ // 仅 meta.previousAttempts=1 留痕。
1896
+ const faultDir = mkdtempSync(join(tmpdir(), "rfl-e2e-fault-"));
1897
+ try {
1898
+ const original = readFileSync(wf("review-fix-loop"), "utf-8");
1899
+ const utilsSrc = readFileSync(join(WORKFLOWS_DIR, "review-fix-loop-utils.cjs"), "utf-8");
1900
+ // 注入点:R1 fix 完成后的 log 行(此刻 state.json 已 saveState 落盘一次)
1901
+ const anchor = 'log("Fixed " + fixedCount + " issue(s). Total: " + totalFixed + ". Modified " + modifiedFiles.length + " file(s). Continuing...");';
1902
+ if (!original.includes(anchor)) {
1903
+ throw new Error("A1 e2e: fault anchor not found in review-fix-loop.js");
1904
+ }
1905
+ const faultCode = anchor + "\n"
1906
+ + "// [TEST-INJECTED FAULT] attempt-1 写 marker 后抛错触发 engine rebuild;attempt-2 读到 marker 不再抛\n"
1907
+ + "try {\n"
1908
+ + " fs.accessSync(RUN_ROOT + \"/e2e-fault-marker\");\n"
1909
+ + "} catch (e) {\n"
1910
+ + " if (String(e).includes(\"e2e-fault-marker\") === false && String(e).includes(\"ENOENT\") === false) throw e;\n"
1911
+ + " fs.writeFileSync(RUN_ROOT + \"/e2e-fault-marker\", \"1\");\n"
1912
+ + " throw new Error(\"e2e-injected fault: simulate script-error after first state write (A1)\");\n"
1913
+ + "}";
1914
+ const injected = original.replace(anchor, faultCode);
1915
+ const faultScriptPath = join(faultDir, "review-fix-loop.js");
1916
+ writeFileSync(faultScriptPath, injected, "utf-8");
1917
+ // utils 经 workerData.scriptPath dirname 定位——副本目录内放同版 utils
1918
+ writeFileSync(join(faultDir, "review-fix-loop-utils.cjs"), utilsSrc, "utf-8");
1919
+
1920
+ // 剧本:R1 1 must-fix → fix(saveState 后触发注入 fault)→ rebuild 重放 R1 → R2 clean
1921
+ const runner = makeScenarioRunner({
1922
+ review: [
1923
+ () => ({ report_file: "/tmp/a1-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
1924
+ () => ({
1925
+ report_file: "/tmp/a1-r2.md", must_fix: 0, suggestion: 0,
1926
+ reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
1927
+ }),
1928
+ ],
1929
+ aggregate: () => ({
1930
+ report_file: "/tmp/a1-agg.md", must_fix: 1, suggestion: 0,
1931
+ must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [],
1932
+ }),
1933
+ fix: () => ({
1934
+ fixed_count: 1,
1935
+ fixes: [{ issue_id: "MF-1", description: "mock fix", self_check: "grep: 1 hit; synced", affected_files: [] }],
1936
+ deferred: [],
1937
+ }),
1938
+ });
1939
+ const deps = makeDeps(runner);
1940
+ const result = await runAndWait(
1941
+ faultScriptPath,
1942
+ { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
1943
+ deps, undefined, RUN_TIMEOUT_MS,
1944
+ );
1945
+
1946
+ expect(result.reason).toBe("completed");
1947
+ expect(result.error).toBeUndefined();
1948
+ const outcome = assertScriptOutcome(result.scriptResult);
1949
+ expect(outcome.terminated).toBe("clean");
1950
+ expect(outcome.totalFixed).toBe(1);
1951
+
1952
+ const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as {
1953
+ meta: { previousAttempts?: number };
1954
+ calls: Array<Record<string, unknown>>;
1955
+ fixResults: unknown[];
1956
+ fixCount: number;
1957
+ };
1958
+ // attempt-1 残留被识别并留痕
1959
+ expect(st.meta.previousAttempts).toBe(1);
1960
+ // calls 无双记:attempt-2 重放 R1(review/agg/fix)+ R2 review = 4
1961
+ //(修复前 = attempt-1 的 3 + attempt-2 的 4 = 7)
1962
+ expect(st.calls.length).toBe(4);
1963
+ expect(st.fixResults.length).toBe(1);
1964
+ expect(st.fixCount).toBe(1);
1965
+ } finally {
1966
+ rmSync(faultDir, { recursive: true, force: true });
1967
+ }
1968
+ },
1969
+ RUN_TIMEOUT_MS * 2,
1970
+ );
882
1971
  });