/** * review-fix-loop E2E(真实 worker thread + 场景化 mock LLM runner) * * §7.2 行为/E2E 测试(设计文档 7.2 三条): * 1. R2 prompt 对账段断言 + defer 跨轮传递 mock 剧本(E2E-1) * 2. skipCleanAgents 语义 + fixAgent 参数接受(E2E-2) * 3. 渲染 gate:非 clean 终止 message 的 [UNRESOLVED] 透出 + ES3 硬校验拦截(E2E-3) * 4. M2 回归:全 fixed + 新发现 → reconcile 门控(reconCount)+ 新发现 merge 独立执行(E2E-4) * 5. M4 回归:recheckAfterFix=true → 全批重派 + clean agent 走 scoped 分支(E2E-5) * 6. F1 回归:doc-reviewer-only 批(reconciliation 恒空)→ merge 重新报告转换 → needs-redesign(E2E-6) * * 与 workflows-e2e.test.ts 同模式:真实 runAndWait + 真实 worker thread + * 唯一 mock 是 deps.runner(AgentRunner)。runner 按调用分流: * - schema 含 must_fix_ids → aggregator * - schema 含 fixed_count → fix * - 其余 → review agent * 剧本按调用序返回固定结构化数据;R2 review 会检查 prompt 内容(defer 跨轮传递验证)。 * * 已知限制:parallel 的 review 调用顺序不保证——剧本不依赖具体 agent 顺序 * (E2E-2 只断言调用总数,R1 中先到者 dirty 后到者 clean 均可)。 */ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { JsonlRunStore } from "../jsonl-run-store.ts"; import { parseResourceMeta } from "../../shared/meta-parser.ts"; import { normalizeRef } from "../../shared/agent-ref.ts"; import { type LauncherDeps, runAndWait } from "../launcher.ts"; import type { LifecycleDeps } from "../models/ports.ts"; import type { AgentRunner } from "../models/ports.ts"; import type { AgentResult, AgentUsage } from "../models/types.ts"; import { type WorkflowMeta, WorkflowScript, type WorkflowSource, } from "../models/workflow-script.ts"; import type { WorkflowScriptRegistry } from "../models/workflow-script-registry.ts"; import { WorkerHostImpl } from "../worker-host.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const WORKFLOWS_DIR = join(__dirname, "..", "..", "..", "workflows"); const wf = (name: string): string => join(WORKFLOWS_DIR, name + ".js"); // agentMd 创建真实临时 fixture .md(e2e 自包含,不依赖包内 agents/ 清单)。 // R3 启动期 stat 校验要求路径真实存在;fixtureDir 在 beforeEach 创建。 let fixtureDir: string; const agentMd = (name: string): string => { const p = join(fixtureDir, name + ".md"); writeFileSync(p, `---\nname: ${name}\ndescription: "${name} fixture"\n---\nbody`, "utf-8"); return p; }; let sessionDir: string; let createdStores: JsonlRunStore[] = []; const MOCK_USAGE: AgentUsage = { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 15, turns: 1, }; type JsonSchema = { type?: string | string[]; properties?: Record; items?: JsonSchema; oneOf?: JsonSchema[]; required?: string[]; }; /** * 轻量 schema 契约校验(m8):递归校验 parsed 是否符合 opts.schema,防止 mock runner * 绕过权威 ajv 校验掩盖「实现与契约脱节」(severity 对象被拒、report_content 丢失等 * 事故)。支持 type/oneOf/required/properties/items;description 等无关键忽略, * 多出的属性不报错。校验失败抛 Error(测试立即失败,message 含 SCHEMA CONTRACT VIOLATION)。 */ function miniValidator(schema: unknown, value: unknown, path: string): void { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return; // 无契约不校验 const s = schema as JsonSchema; if (Array.isArray(s.oneOf) && s.oneOf.length > 0) { const anyPass = s.oneOf.some((alt) => { try { miniValidator(alt, value, path); return true; } catch { return false; } }); if (!anyPass) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path} (no oneOf branch matched)`); return; } if (s.type) { // rfl C5:JSON Schema union type(如 total: ["number", "null"])——任一分支匹配即过 const types = Array.isArray(s.type) ? s.type : [s.type]; const ok = types.some((t) => (t === "string" && typeof value === "string") || (t === "number" && typeof value === "number") || (t === "integer" && typeof value === "number" && Number.isInteger(value)) || (t === "boolean" && typeof value === "boolean") || (t === "null" && value === null) || (t === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) || (t === "array" && Array.isArray(value))); if (!ok) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path} (expected ${JSON.stringify(s.type)})`); } const rec = value as Record | null; if (Array.isArray(s.required) && rec !== null && typeof rec === "object") { for (const key of s.required) { if (!(key in rec)) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path}.${key} (missing required property)`); } } if (s.properties && rec !== null && typeof rec === "object" && !Array.isArray(rec)) { for (const [key, sub] of Object.entries(s.properties)) { if (rec[key] !== undefined) miniValidator(sub, rec[key], `${path}.${key}`); } } if (s.items && Array.isArray(value)) { value.forEach((item, i) => miniValidator(s.items, item, `${path}[${i}]`)); } } /** 按 schema 形状识别调用阶段(review / aggregator / fix)。 */ function classifyCall(opts: { schema?: unknown }): "review" | "aggregate" | "fix" { const props = (opts.schema as JsonSchema | undefined)?.properties ?? {}; if ("must_fix_ids" in props) return "aggregate"; if ("fixed_count" in props) return "fix"; return "review"; } /** * 对 worker 返回的不可信 scriptResult 做最小运行时形状校验(S-16): * workflow 脚本是 JS(无 TS 类型),scriptResult 形状不受静态约束,直接类型断言 * 会在结构漂移时掩盖真实形状。guard 校验 terminated 为 string(所有终止路径必含), * 再返回带可选字段的 view;其余字段由断言侧校验存在性。 */ function assertScriptOutcome(scriptResult: unknown): { terminated: string; totalFixed?: number; message?: string; runDir?: string; } { const raw = scriptResult as Record | null | undefined; if (raw === null || typeof raw !== "object") { throw new Error(`scriptResult 缺失或非对象(worker 输出形状漂移): ${JSON.stringify(scriptResult)}`); } if (typeof raw.terminated !== "string") { throw new Error(`scriptResult.terminated 非 string(worker 输出形状漂移): ${JSON.stringify(raw.terminated)}`); } return raw as { terminated: string; totalFixed?: number; message?: string; runDir?: string }; } interface Scenario { /** review 调用序 → 返回数据生成器;R2+ 回调收到 prompt 文本(可用于对账/传递断言)。 * 返回 { __invalidOutput: value } 哨兵 = 模拟「reviewer 输出无效」(F3 锁定用例, * 语义同 aggregate 哨兵)。 */ review: Array<(prompt: string) => Record>; /** * aggregate 剧本(回调收到 prompt 文本——可从中解析 roundDir 预写 fallback 依赖的 * aggregated.md)。返回 { __invalidOutput: value } 哨兵 = 模拟「aggregator 输出无效」 * (真实 LLM 违约场景,F2 e2e):runner 解包后直接返回 value 并跳过 miniValidator * ——契约校验防的是 mock 无意脱节,刻意违约剧本必须绕过。 */ aggregate: (prompt: string) => Record | { __invalidOutput: unknown }; fix: () => Record; } /** * 场景化 mock runner:记录每次调用的 prompt 与分类,按剧本返回。 * R2 review 调用(剧本元素)若返回含 must_fix: 9 表示"断言失败"信号(测试可见)。 */ function makeScenarioRunner(scenario: Scenario) { const reviewCalls: Array<{ prompt: string; result: Record }> = []; const calls: Array<{ kind: "review" | "aggregate" | "fix"; prompt: string; agent?: string; schema?: unknown; model?: string }> = []; const run = vi.fn(async (opts: { prompt?: string; schema?: unknown; agent?: string; model?: string }): Promise => { const kind = classifyCall(opts); const prompt = opts.prompt ?? ""; // m5:记录 agent 字段(review/fix 派发验证)。内置名(reviewer/doc-reviewer)走 // def.name,自定义 .md agent 为 undefined——只断言 fix 调用的(fixAgent 派发)。 // rfl B6:记录 opts.schema(aggregatorSchema 扩展断言——防 schema 与 prompt 脱节)。 // rfl C5:记录 opts.model(aggregatorModel 降档断言)。 calls.push({ kind, prompt, agent: opts.agent, schema: opts.schema, model: opts.model }); let parsed: unknown = null; // F2 e2e:__invalidOutput 哨兵 = 刻意模拟 aggregator 违约输出(缺 must_fix 等), // miniValidator 对故意无效无校验意义,跳过(防契约校验拦截违约剧本)。 let skipContractCheck = false; if (kind === "review") { const idx = reviewCalls.length; const gen = scenario.review[Math.min(idx, scenario.review.length - 1)]; const result = gen(prompt); reviewCalls.push({ prompt, result }); // F3 锁定用例:review 剧本同样支持 __invalidOutput 哨兵——模拟 reviewer 违约 // 输出(缺 must_fix),绕过 miniValidator(契约校验防的是 mock 无意脱节)。 if (result && typeof result === "object" && "__invalidOutput" in result) { parsed = (result as { __invalidOutput: unknown }).__invalidOutput; skipContractCheck = true; } else { parsed = result; } } else if (kind === "aggregate") { const raw = scenario.aggregate(prompt); if (raw && typeof raw === "object" && "__invalidOutput" in raw) { parsed = (raw as { __invalidOutput: unknown }).__invalidOutput; skipContractCheck = true; } else { parsed = raw; } } else { parsed = scenario.fix(); } // m8:schema 契约校验——mock 返回的 parsedOutput 必须符合 workflow 声明的权威 schema // (防止未来 schema 收紧时 E2E 仍绿)。校验失败抛错让测试立即失败。 if (!skipContractCheck) miniValidator(opts.schema, parsed, "parsedOutput"); // rfl 仪表(T1):sessionId 逐调用编号——A6 断言 calls[].sessionId 与调用序对应 return { content: "mock", parsedOutput: parsed, usage: MOCK_USAGE, durationMs: 1, sessionId: "sess-e2e-" + (calls.length - 1), error: undefined, }; }); return { run, stats: () => ({ reviewCalls, kinds: calls.map((c) => c.kind), prompts: calls.map((c) => c.prompt), agents: calls.map((c) => c.agent), schemas: calls.map((c) => c.schema), models: calls.map((c) => c.model), }), }; } // ── registry(与 workflows-e2e.test.ts 同模式:读文件构造 WorkflowScript) ── function extractMeta(source: string, fallbackName: string): WorkflowMeta { // m2 exec-review MINOR-1:旧 const meta regex + new Function 随 m2 迁移已失效, // 改调 IF1 parseResourceMeta(与 workflows-e2e.test 一致)。 const meta = parseResourceMeta(source, "workflow"); if (meta && meta.kind === "workflow") return meta; return { kind: "workflow", name: fallbackName, description: "", phases: [] }; } function loadWorkflowsFromDir(dir: string): Map { const scripts = new Map(); for (const file of readdirSync(dir)) { if (!file.endsWith(".js")) continue; const fullPath = join(dir, file); const sourceCode = readFileSync(fullPath, "utf-8"); const stem = file.replace(/\.js$/, ""); const meta = extractMeta(sourceCode, stem); const source: WorkflowSource = "saved"; scripts.set( meta.name, new WorkflowScript({ name: meta.name, source, path: fullPath, sourceCode, meta, available: true, }), ); } return scripts; } function makeRegistry(scripts: Map): WorkflowScriptRegistry { return { get: async (name: string) => scripts.get(name), // S2:按路径加载(任意路径 .js)——路径未预扫则直接读文件 getPath: async (ref: string) => { const normalized = normalizeRef(ref, ".js"); if (normalized === null) return undefined; for (const script of scripts.values()) { if (script.path === normalized) return script; } try { const sourceCode = readFileSync(normalized, "utf-8"); const stem = basename(normalized, ".js"); const meta = extractMeta(sourceCode, stem); return new WorkflowScript({ name: meta.name, source: "saved", path: normalized, sourceCode, meta, available: true, }); } catch { return undefined; } }, loadAll: async () => Array.from(scripts.values()), invalidate: () => {}, }; } function makeDeps(runner: AgentRunner): LauncherDeps { const scripts = loadWorkflowsFromDir(WORKFLOWS_DIR); const registry = makeRegistry(scripts); const store = new JsonlRunStore({ sessionDir }); createdStores.push(store); const base: LifecycleDeps = { store, workerHost: new WorkerHostImpl(), runner, runs: new Map(), }; return { ...base, registry }; } let rflHomeDir: string; beforeEach(() => { sessionDir = mkdtempSync(join(tmpdir(), "rfl-e2e-")); fixtureDir = mkdtempSync(join(tmpdir(), "rfl-e2e-agents-")); // rfl 仪表(tier-1 T3):state.json 现落 ~/.review-fix-loop/——HOME 重定向到 // 临时目录,防测试写真实 home(worker_threads 在 new Worker 时拷贝主线程 env // 快照,stubEnv 先于 runAndWait 即可让 worker 侧 os.homedir() 读到隔离值)。 rflHomeDir = mkdtempSync(join(tmpdir(), "rfl-e2e-home-")); vi.stubEnv("HOME", rflHomeDir); createdStores = []; }); afterEach(() => { try { rmSync(sessionDir, { recursive: true, force: true }); rmSync(fixtureDir, { recursive: true, force: true }); rmSync(rflHomeDir, { recursive: true, force: true }); } catch { // 临时目录清理失败不影响测试结论 } sessionDir = ""; fixtureDir = ""; rflHomeDir = ""; createdStores = []; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); const RUN_TIMEOUT_MS = 60_000; const RUN_ID = () => "rfl-e2e-" + Date.now() + "-" + Math.floor(Math.random() * 1e6); describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () => { it("sanity: chain 经本文件基础设施可跑(helper 自检)", async () => { // 返回超集对象同时满足 chain 三段 schema(analyze/transform/synthesize 均被分类为 review) const runner = makeScenarioRunner({ review: [() => ({ insights: "i", keyPoints: [], plan: "p", actions: [], summary: "s", recommendation: "r" })], aggregate: () => ({}), fix: () => ({}), }); const deps = makeDeps(runner); const result = await runAndWait(wf("chain"), { task: "x" }, deps, undefined, RUN_TIMEOUT_MS); expect(result.reason).toBe("completed"); }); it( "E2E-1:defer 跨轮传递 + R2 prompt 对账段 + clean 终止(§7.2 1/3)", async () => { const runner = makeScenarioRunner({ review: [ // R1:1 must-fix + 1 suggestion () => ({ report_file: "/tmp/r1-reviewer.md", must_fix: 1, suggestion: 1, reconciliation: [] }), // R2:known-remaining 必须含 S-1(fix 阶段 deferred 写入 → 同步 knownRemaining); // 对账 MF-1 已修。若 prompt 缺 S-1 → must_fix=9 使测试失败可见。 (prompt) => { if (!prompt.includes("S-1")) { return { report_file: "/tmp/r2-reviewer.md", must_fix: 9, suggestion: 0, reconciliation: [] }; } return { report_file: "/tmp/r2-reviewer.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }; }, ], aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 1, suggestion: 1, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [], }), fix: () => ({ fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "mock fix", self_check: "grep: 1 hit; synced", affected_files: ["src/a.ts"] }], deferred: [{ issue_id: "S-1", severity: "minor", reason: "needs new mechanism across modules, high cost" }], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); expect(outcome.totalFixed).toBe(1); expect(outcome.message).toContain("All batches clean"); // prompt 内容断言:R1 全量深挖(Round 1)→ fix 分流段 → R2 对账段 + known-remaining const { prompts, kinds, reviewCalls } = runner.stats(); const reviewPrompts = prompts.filter((_, i) => kinds[i] === "review"); expect(reviewPrompts[0]).toContain("Round 1"); expect(reviewPrompts[1]).toContain("RECONCILE PREVIOUS ROUND"); expect(reviewPrompts[1]).toContain("S-1"); // 5.3-4 deferred 跨轮继承 // m6:aggregator prompt 裁决段(5.4 ADJUDICATION)——裁决证据/降级保真/采信抽查 const aggPrompt = prompts[kinds.indexOf("aggregate")]; expect(aggPrompt).toContain("ADJUDICATION"); // m6:fix prompt 分流文案(trivial 直接修 / involved 标记 deferred)+ 自检要求 const fixPrompt = prompts[kinds.indexOf("fix")]; expect(fixPrompt).toContain("Fix scope"); expect(fixPrompt).toContain("fix trivial ones"); expect(fixPrompt).toContain("mark involved ones as deferred"); expect(fixPrompt).toContain("self_check in each fixes[] entry MUST include"); expect(fixPrompt).toContain("grep command + hit count + sync action"); expect(reviewCalls.length).toBe(2); }, RUN_TIMEOUT_MS, ); it( "E2E-2:skipCleanAgents 语义(clean agent 下轮跳过)+ fixAgent 参数接受(§7.2 2/3)", async () => { const runner = makeScenarioRunner({ review: [ // R1 两个 agent(顺序不定):reviewer → dirty(2);doc-reviewer → clean(0)。 // doc-reviewer 走 schema-only 真实形态(M3 回归):无 write 工具 → report_file="" + // report_content 返回正文,由 workflow 落盘。按 prompt 内的报告路径区分 agent // (顺序无关:两 generator 对同一 agent 返回同一结果)。 (prompt) => prompt.includes("doc-reviewer.md") ? { report_content: "# doc-reviewer report\nPass 1 完成", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] } : { report_file: "/tmp/r1a.md", must_fix: 2, suggestion: 0, reconciliation: [] }, (prompt) => prompt.includes("doc-reviewer.md") ? { report_content: "# doc-reviewer report\nPass 1 完成", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] } : { report_file: "/tmp/r1b.md", must_fix: 2, suggestion: 0, reconciliation: [] }, // R2:仅 dirty agent 被重派(clean 被 skipCleanAgents 过滤);返回 clean → 终止。 // 若 skip 失效,会出现第 4 次 review 调用(断言总数=3 拦截)。 () => ({ report_file: "/tmp/r2.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read" }, { prev_id: "MF-2", status: "fixed", evidence: "read" }], }), ], aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 2, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [], }), fix: () => ({ fixed_count: 2, fixes: [ { issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: [] }, { issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit", affected_files: [] }, ], deferred: [], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer") + "," + agentMd("doc-reviewer"), fixAgent: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); expect(outcome.totalFixed).toBe(2); // skipCleanAgents:R1 两 review + R2 一 review = 3;skip 失效则为 4 const { kinds, reviewCalls, agents, prompts } = runner.stats(); expect(reviewCalls.length).toBe(3); expect(kinds.filter((k) => k === "fix").length).toBe(1); expect(kinds.filter((k) => k === "aggregate").length).toBe(1); // W6:schema-only reviewer 的 report_content 已落盘(report_file 回填),聚合 // prompt 只内嵌计数与路径清单——正文不得随 reviewResults JSON 再嵌一份 //(同一内容双份付费,实测 ~48% 重复);修复前 sub_reviews JSON 含 report_content const aggPrompt = prompts[kinds.indexOf("aggregate")]; expect(aggPrompt).toContain(''); // reviewResults JSON 仍在 expect(aggPrompt).not.toContain("Pass 1 完成"); // 正文不在(已落盘,read 指引可取) expect(aggPrompt).toContain("doc-reviewer.md"); // 路径清单在(READ FIRST 段数据源) // S4:fixAgent 派发验证——fix 调用带 agentRef 路径( location 语义) const fixIdx = kinds.indexOf("fix"); expect(agents[fixIdx]).toBe(agentMd("reviewer")); // M3 直接证据:doc-reviewer(schema-only,report_file="")报告经 report_content // 落盘到 /batch-1/round-1/doc-reviewer.md(def.report 文件名 = 路径 basename, // resolveAgentDefs 路径解析)。修复前 normalizeReviewResult 丢弃 // report_content → 落盘内容为空文件,本断言失败。 const docReportPath = join(outcome.runDir, "batch-1", "round-1", "doc-reviewer.md"); expect(existsSync(docReportPath)).toBe(true); expect(readFileSync(docReportPath, "utf-8")).toContain("doc-reviewer report"); }, RUN_TIMEOUT_MS, ); it( "E2E-3:ES3 硬校验拦截(deferred critical → fix-failure)+ [UNRESOLVED] 渲染 gate(§7.2 3/3)", async () => { const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), ], aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "critical" }], fixes_caution: [], }), // 红线违反:must-fix 被 defer 且标 critical fix: () => ({ fixed_count: 0, fixes: [], deferred: [{ issue_id: "MF-1", severity: "critical", reason: "cannot fix in this round" }], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); // 结构化终止而非抛错 expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("fix-failure"); // 渲染 gate(5.9):非 clean 终止 message 带 [UNRESOLVED] 前缀 + 残留原因 expect(outcome.message).toContain("[UNRESOLVED]"); expect(outcome.message).toContain("must-fix 不得 defer"); expect(outcome.message).toContain("MF-1"); }, RUN_TIMEOUT_MS, ); it( "E2E-4:全 fixed + 新发现(M2:reconcile 门控 reconCount + 新发现 merge 独立执行)", async () => { // M2 回归场景:R2 所有 prev ID 声明 fixed(reconSeen 空)但仍有新发现 MF-2。 // 修复前:reconcile 分支整体跳过 → MF-1 停留 fix-attempted(永不转 fixed)、 // MF-2(新发现 merge 在分支内)不创建 → 残留清单含 MF-1 而非 MF-2。 // 修复后:reconCount>0 触发 reconcileIssues → MF-1 转 fixed;新发现 merge 独立 // 于 reconcile 执行 → MF-2 创建 → 残留仅 MF-2(max-rounds 终止)。 let aggRound = 0; let fixRound = 0; const runner = makeScenarioRunner({ review: [ // R1:1 must-fix () => ({ report_file: "/tmp/r1-reviewer.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // R2:MF-1 声明 fixed(全 fixed → reconSeen 空)+ 1 新发现;走 R2+ 分支 (prompt) => { if (!prompt.includes("RECONCILE PREVIOUS ROUND")) { return { report_file: "/tmp/r2-reviewer.md", must_fix: 9, suggestion: 0, reconciliation: [] }; } return { report_file: "/tmp/r2-reviewer.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }; }, ], aggregate: () => { aggRound++; return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, // R1 只含 MF-1;R2 中 MF-1 已被 reconciliation 声明 fixed,must_fix 只剩新发现 MF-2 must_fix_ids: aggRound === 1 ? [{ id: "MF-1", severity: "major" }] : [{ id: "MF-2", severity: "major" }], fixes_caution: [], }; }, fix: () => { fixRound++; return { fixed_count: 1, fixes: [{ issue_id: fixRound === 1 ? "MF-1" : "MF-2", description: "mock fix", self_check: "grep: 1 hit; synced", affected_files: [], }], deferred: [], }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), maxRounds: 2, _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); // R2 后仍有 must-fix(MF-2 新发现)且达到 maxRounds → max-rounds 终止 expect(outcome.terminated).toBe("max-rounds"); expect(outcome.totalFixed).toBe(2); // M2 直接可观测证据:MF-1 已被 reconcile 转 fixed → 不进残留清单;MF-2 在残留中 // (修复前:MF-1 停留 fix-attempted 会出现在残留里、MF-2 不创建 → 本断言失败) expect(outcome.message).toContain("MF-2"); expect(outcome.message).not.toContain("MF-1"); // 两轮 review 各 1 次调用(maxRounds=2 未超轮);aggregator/fix 各 2 次 const { reviewCalls, kinds } = runner.stats(); expect(reviewCalls.length).toBe(2); expect(kinds.filter((k) => k === "aggregate").length).toBe(2); expect(kinds.filter((k) => k === "fix").length).toBe(2); }, RUN_TIMEOUT_MS, ); it( "E2E-5:recheckAfterFix=true → 全批重派 + clean agent 走 scoped 分支(M4 回归)", async () => { // R1:reviewer dirty + doc-reviewer clean → fix 后 R2 全批重派(强回归模式), // doc-reviewer(上轮 clean)走 scoped 限定分支(modifiedFiles ∪ affectedFiles)。 // M4 修复目标:scoped 分支的 lastModifiedFiles 在批内可读(state.lastModifiedFiles // 即时字段)——修复前读 state.batches(批内未 push)恒空。 const runner = makeScenarioRunner({ review: [ // R1 两 agent(parallel 顺序不定):按 prompt 内报告路径区分 (prompt) => prompt.includes("doc-reviewer.md") ? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] } : { report_file: "/tmp/r1a.md", must_fix: 2, suggestion: 0, reconciliation: [] }, (prompt) => prompt.includes("doc-reviewer.md") ? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] } : { report_file: "/tmp/r1b.md", must_fix: 2, suggestion: 0, reconciliation: [] }, // R2 两个调用(reviewer 全量 R2+ / doc-reviewer scoped):全部 clean () => ({ report_file: "/tmp/r2a.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read" }, { prev_id: "MF-2", status: "fixed", evidence: "read" }] }), () => ({ report_file: "/tmp/r2b.md", must_fix: 0, suggestion: 0, reconciliation: [] }), ], aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 2, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [], }), fix: () => ({ fixed_count: 2, fixes: [ { issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: ["src/x.ts"] }, { issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit", affected_files: [] }, ], deferred: [], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer") + "," + agentMd("doc-reviewer"), recheckAfterFix: true, _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); expect(outcome.totalFixed).toBe(2); // R1 两 review + R2 两 review(全批重派)= 4;skipCleanAgents 被 recheckAfterFix 覆盖 const { prompts, kinds, reviewCalls } = runner.stats(); expect(reviewCalls.length).toBe(4); // scoped 分支被触发:prompt 含 "Scoped recheck"(clean agent 限定重审) const scopedPrompt = prompts.filter((p) => p.includes("Scoped recheck")); expect(scopedPrompt.length).toBe(1); // M4 数据通路:affectedFiles(fix 自检标注)进 scoped prompt expect(scopedPrompt[0]).toContain("src/x.ts"); // scoped prompt 含 modifiedFiles 结构行(git 实测内容取决于测试环境工作区, // 只断言结构存在——M4 修复的是数据源可读性) expect(scopedPrompt[0]).toContain("Modified files:"); // R2+ 全量分支同时被触发(reviewer) expect(prompts.some((p) => p.includes("RECONCILE PREVIOUS ROUND"))).toBe(true); }, RUN_TIMEOUT_MS, ); it( "E2E-6:doc-reviewer-only 批(reconciliation 恒空)→ 重新报告转 regressed → needs-redesign(F1 回归)", async () => { // F1 场景:全部 agent 为 doc-reviewer(§5.8 推荐配置),reconciliation 恒空 → // reconCount 恒 0。MF-1 连续 3 轮被重新报告(must_fix_ids 含 MF-1): // 修复前:merge 跳过已存在 ID → fixAttempts 恒 0 → needs-redesign 不可达; // newFindings 恒 0 → R3 触发 converged(streak 2)——提前终止掩盖未修复问题。 // 修复后:merge 把 fix-attempted 转 regressed + fixAttempts+1 → R3 时 // fixAttempts=2 → needs-redesign 终止(在 converged 之前,顺序正确)。 let aggRound = 0; let fixRound = 0; const runner = makeScenarioRunner({ review: [ // R1 () => ({ report_file: "/tmp/r1-dr.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // R2:MF-1 重新报告(未修复) () => ({ report_file: "/tmp/r2-dr.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // R3:MF-1 再次报告(第 2 次修复失败) () => ({ report_file: "/tmp/r3-dr.md", must_fix: 1, suggestion: 0, reconciliation: [] }), ], aggregate: () => { aggRound++; return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [], }; }, fix: () => { fixRound++; return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix " + fixRound, self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [], }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("doc-reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); // F1 判别:修复前此处是 converged(提前终止掩盖未修复);修复后 needs-redesign expect(outcome.terminated).toBe("needs-redesign"); expect(outcome.message).toContain("MF-1"); expect(outcome.message).toContain("2 次修复仍未收敛"); const { reviewCalls } = runner.stats(); expect(reviewCalls.length).toBe(3); // R1/R2/R3 各一次,R3 终止不再续轮 }, RUN_TIMEOUT_MS, ); it( "E2E-7:fixed 条目复发 → 不收敛 → 继续修复 → needs-redesign(MF-2 回归)", async () => { // MF-2 场景(reconciliation 驱动):R1 报 MF-1 → R2 确认 fixed + 新发现 MF-2 → // R3 MF-1 复发(reconciliation not-fixed)。修复前:fixed 条目复发不转换(停留 // fixed)→ R3 newFindings=0 收敛 streak 达 2 → terminated=converged 提前终止而 // must-fix 仍活跃;finalMessage「残留: 无 deferred」掩盖 MF-1。 // 修复后:R3 reconcile 把 MF-1 转 regressed(fixAttempts+1)→ 收敛门槛 // (无 open/regressed 活跃条目)拦截 → 继续 R4 → MF-1 第 2 次 regressed // (fixAttempts=2)→ needs-redesign 终止(在 converged 之前,顺序正确)。 let aggRound = 0; let fixRound = 0; const runner = makeScenarioRunner({ review: [ // R1:MF-1 首次发现 () => ({ report_file: "/tmp/r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // R2:MF-1 确认 fixed + 新发现 MF-2 () => ({ report_file: "/tmp/r2.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }] }), // R3:MF-1 复发(修复前此处不转换 → converged 提前终止)+ MF-2 未修 () => ({ report_file: "/tmp/r3.md", must_fix: 2, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "not-fixed", evidence: "still wrong" }, { prev_id: "MF-2", status: "not-fixed", evidence: "still wrong" }] }), // R4:MF-1 再次复发(第 2 次 regressed → needs-redesign);MF-2 已修(转 fixed) // ——MF-2 不再累计 openStreak,避免其先触达 stuckThreshold 抢先终止 () => ({ report_file: "/tmp/r4.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "not-fixed", evidence: "still wrong" }, { prev_id: "MF-2", status: "fixed", evidence: "read confirmed" }] }), ], aggregate: () => { aggRound++; if (aggRound === 1) return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [] }; if (aggRound === 2) return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-2", severity: "major" }], fixes_caution: [] }; if (aggRound === 3) return { report_file: "/tmp/agg.md", must_fix: 2, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [] }; return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [] }; }, fix: () => { fixRound++; if (fixRound === 1) { return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [] }; } if (fixRound === 2) { return { fixed_count: 1, fixes: [{ issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [] }; } if (fixRound === 3) { return { fixed_count: 2, fixes: [ { issue_id: "MF-1", description: "fix3", self_check: "grep: 1 hit; synced", affected_files: [] }, { issue_id: "MF-2", description: "fix4", self_check: "grep: 1 hit; synced", affected_files: [] }, ], deferred: [], }; } return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix5", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [] }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), maxRounds: 4, _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); // 修复前此处是 converged(fixed 停留 + 收敛 streak 2 → 提前终止掩盖活跃 must-fix); // 修复后 fixed 复发转 regressed → 收敛门槛拦截 → R4 needs-redesign expect(outcome.terminated).toBe("needs-redesign"); expect(outcome.message).toContain("MF-1"); expect(outcome.message).toContain("2 次修复仍未收敛"); const { reviewCalls } = runner.stats(); expect(reviewCalls.length).toBe(4); // R1/R2/R3/R4——修复前 R3 即 converged(3 次) }, RUN_TIMEOUT_MS, ); it( "fail-fast:未知参数名(batchl 拼错)→ workflow 失败且 error 含未知参数提示(S-19)", async () => { const runner = makeScenarioRunner({ review: [() => ({ report_file: "/tmp/r1.md", must_fix: 0, suggestion: 0, reconciliation: [] })], aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 0, suggestion: 0, must_fix_ids: [], fixes_caution: [] }), fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), batchl: "fallow-scan", _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); // 脚本顶层白名单校验 fail() 抛错 → worker type:"error" → 重试超限 → reason=failed expect(result.reason).toBe("failed"); expect(result.error).toContain("未知参数: batchl"); // 校验发生在任何 agent 调用之前(参数校验在脚本最顶部) expect(runner.stats().kinds).toEqual([]); }, RUN_TIMEOUT_MS, ); it( "fail-fast:targetType 非法枚举 / target 空串 → workflow 失败且 error 含必填提示(S-19)", async () => { const deps = makeDeps(makeScenarioRunner({ review: [() => ({ report_file: "/tmp/r1.md", must_fix: 0, suggestion: 0, reconciliation: [] })], aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 0, suggestion: 0, must_fix_ids: [], fixes_caution: [] }), fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }), })); // targetType 非法枚举(m3 TC14:chokepoint 先拦 → invalid_args + ajv 文案 + info 指引) const r1 = await runAndWait( wf("review-fix-loop"), { targetType: "nope", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(r1.reason).toBe("invalid_args"); expect(r1.error).toContain("Invalid args for workflow 'review-fix-loop'"); expect(r1.error).toContain("targetType"); expect(r1.error).toContain("Read the workflow script file"); // target 空串(m3 required 空串复查先拦 → invalid_args;脚本 !target 成不可达死代码) const r2 = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: " ", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(r2.reason).toBe("invalid_args"); // schema 驱动(minLength:1 + pattern '\\S'):chokepoint 不发明约束 expect(r2.error).toContain("target"); expect(r2.error).toContain("Read the workflow script file"); }, RUN_TIMEOUT_MS, ); }); describe("startup fail-fast (ADR-0003 D6)", () => { const emptyScenario = { review: [() => ({ issues: [], must_fix: [], stuck: false })], aggregate: () => ({ issues: [], must_fix: [], stuck: false }), fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }), }; it("TC1: batchN 不存在路径 → 启动期 fail-fast,未调 agent", async () => { const runner = makeScenarioRunner(emptyScenario); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: "/nonexistent/missing.md", _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).not.toBe("completed"); expect(String(result.error ?? "")).toContain("Agent file not found"); expect(String(result.error ?? "")).toContain("/nonexistent/missing.md"); // fail-fast 在 round 前:agent mock 未被调用 expect(runner.stats().kinds.length).toBe(0); }, RUN_TIMEOUT_MS); it("TC2: fixAgent 不存在路径 → 启动期 fail-fast", async () => { const runner = makeScenarioRunner(emptyScenario); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), fixAgent: "/nonexistent/fix.md", _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).not.toBe("completed"); expect(String(result.error ?? "")).toContain("Agent file not found"); expect(String(result.error ?? "")).toContain("/nonexistent/fix.md"); expect(runner.stats().kinds.length).toBe(0); }, RUN_TIMEOUT_MS); // ── A6: rfl 仪表 e2e(tier-1 T3+T4:存储迁移 + calls[] 采集) ── it( "A6: state.json 落 ~/.review-fix-loop/// 且 calls[] 十字段全集 + phaseTimings 采集(默认剧本)", async () => { // 剧本:R1 1 must-fix → fix → R2 clean(two-round clean 终止) const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/a6-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/a6-r2.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }), ], aggregate: () => ({ report_file: "/tmp/a6-agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [], }), fix: () => ({ fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "mock fix", self_check: "grep: 1 hit", affected_files: ["src/a.ts"] }], deferred: [], }), }); const deps = makeDeps(runner); const userRunId = RUN_ID(); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: userRunId }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); // T3 存储迁移:runDir 位于 HOME(已 stub 到 rflHomeDir)下 .review-fix-loop// expect(typeof outcome.runDir).toBe("string"); expect(outcome.runDir).toContain(join(rflHomeDir, ".review-fix-loop")); // 引擎注入的 _runId 覆盖用户预传值(A3 语义)——state 目录名 = 引擎 runId expect(outcome.runDir).not.toContain(userRunId); expect(result.runId).toBeTruthy(); expect(outcome.runDir!.endsWith(result.runId)).toBe(true); const stateFile = join(outcome.runDir!, "state.json"); expect(existsSync(stateFile)).toBe(true); const st = JSON.parse(readFileSync(stateFile, "utf8")) as { meta: { terminated: string }; calls: Array>; batches: Array<{ rounds: Array<{ round: number; phaseTimings: Record }> }>; }; // terminated 快照(saveState 落盘) expect(st.meta.terminated).toBe("clean"); // calls[]:R1 review + R1 aggregate + R1 fix + R2 review = 4 条,十字段全集 expect(st.calls.length).toBe(4); const roles = st.calls.map((c) => c.role as string); expect(roles.filter((r) => r === "reviewer").length).toBe(2); expect(roles.filter((r) => r === "aggregator").length).toBe(1); expect(roles.filter((r) => r === "fixer").length).toBe(1); for (const c of st.calls) { expect(typeof c.batch).toBe("number"); expect(typeof c.round).toBe("number"); expect(typeof c.name).toBe("string"); expect(c.name.length).toBeGreaterThan(0); expect(typeof c.model).toBe("string"); expect(typeof c.durationMs).toBe("number"); expect(c.usage).toMatchObject({ input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0 }); // A12 语义精确化:promptMode = review prompt 模式(reviewer full/scoped); // 非 reviewer 角色(aggregator/fixer)该字段无意义,显式落 null if (c.role === "reviewer") { expect(c.promptMode).toBe("full"); } else { expect(c.promptMode).toBeNull(); } expect(typeof c.promptBytes).toBe("number"); expect(c.promptBytes).toBeGreaterThan(0); expect(c.sessionId).toMatch(/^sess-e2e-\d+$/); } // sessionId 与 mock runner 调用序一一对应(0..3 各出现一次) const sessionIds = st.calls.map((c) => c.sessionId as string).sort(); expect(sessionIds).toEqual(["sess-e2e-0", "sess-e2e-1", "sess-e2e-2", "sess-e2e-3"]); // phaseTimings:R1 三键齐全(number 对),R2 clean 轮仅 review、aggregate/fix null const rounds = st.batches[0].rounds; expect(rounds.length).toBe(2); const [r1, r2] = rounds; const pair = [expect.any(Number), expect.any(Number)]; expect(r1.phaseTimings.review).toEqual(pair); expect(r1.phaseTimings.aggregate).toEqual(pair); expect(r1.phaseTimings.fix).toEqual(pair); expect(r2.phaseTimings.review).toEqual(pair); expect(r2.phaseTimings.aggregate).toBeNull(); expect(r2.phaseTimings.fix).toBeNull(); }, RUN_TIMEOUT_MS, ); it( "A6: recheckAfterFix=true 剧本 → calls[] 出现 promptMode=\"scoped\"(scoped 分支采集)", async () => { // 剧本同 E2E-5:R1 reviewer dirty + doc-reviewer clean → fix → R2 全批重派, // doc-reviewer 走 scoped 限定分支。 const runner = makeScenarioRunner({ review: [ (prompt) => prompt.includes("doc-reviewer.md") ? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] } : { report_file: "/tmp/a6s-r1a.md", must_fix: 2, suggestion: 0, reconciliation: [] }, (prompt) => prompt.includes("doc-reviewer.md") ? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] } : { report_file: "/tmp/a6s-r1b.md", must_fix: 2, suggestion: 0, reconciliation: [] }, () => ({ report_file: "/tmp/a6s-r2a.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read" }, { prev_id: "MF-2", status: "fixed", evidence: "read" }], }), () => ({ report_file: "/tmp/a6s-r2b.md", must_fix: 0, suggestion: 0, reconciliation: [] }), ], aggregate: () => ({ report_file: "/tmp/a6s-agg.md", must_fix: 2, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [], }), fix: () => ({ fixed_count: 2, fixes: [ { issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: ["src/x.ts"] }, { issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit", affected_files: [] }, ], deferred: [], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer") + "," + agentMd("doc-reviewer"), recheckAfterFix: true, _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { calls: Array>; }; // 4 review(R1 ×2 + R2 ×2)+ 1 aggregate + 1 fix expect(st.calls.length).toBe(6); const reviewCalls2 = st.calls.filter((c) => c.role === "reviewer"); const modes = reviewCalls2.map((c) => c.promptMode as string).sort(); // R1 两调用 full;R2 全批重派:reviewer 走 R2+ full、doc-reviewer(上轮 clean)走 scoped expect(modes).toEqual(["full", "full", "full", "scoped"]); }, RUN_TIMEOUT_MS, ); // ── B6: rfl 数据链 e2e(tier-1 M1:origin/dormant/复活/消费侧过滤/schema) ── it( "B6: issues 带 origin/guidance/evidence + dormant 落盘/复活 + 消费侧过滤 + aggregatorSchema 扩展", async () => { // 剧本(三轮到 clean):R1 聚合 2 条目(MF-1 活跃带扩展字段 + MF-D1 降级), // fix 只修 MF-1(降级条目被 filterActiveIds 过滤出修复队列);R2 聚合 3 条目 //(N-1 files 与 fix affected_files 相交 → regression;N-2 不相交 → new; // MF-D1 复活重新上报)fix 修 3 条;R3 全 clean 终止。 // aggregate/fix 按调用序分流(闭包计数,不改 runner 接口)。 let aggN = 0; let fixN = 0; const runner = makeScenarioRunner({ review: [ // R1:1 must-fix(触发聚合) () => ({ report_file: "/tmp/b6-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // R2:3 must-fix(N-1/N-2/MF-D1 复活——聚合结构化源),对账 MF-1 fixed () => ({ report_file: "/tmp/b6-r2.md", must_fix: 3, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }), // R3:全 clean(对账 3 条 fixed) () => ({ report_file: "/tmp/b6-r3.md", must_fix: 0, suggestion: 0, reconciliation: [ { prev_id: "N-1", status: "fixed", evidence: "read" }, { prev_id: "N-2", status: "fixed", evidence: "read" }, { prev_id: "MF-D1", status: "fixed", evidence: "read" }, ], }), ], aggregate: () => { aggN++; if (aggN === 1) { return { report_file: "/tmp/b6-agg1.md", must_fix: 1, suggestion: 0, must_fix_ids: [ { id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/feature.ts"], evidence: "line 42 off-by-one", guidance: "fix boundary in parser" }, { id: "MF-D1", severity: "major", adjudication: "downgraded", note: "claim contradicts known facts" }, ], fixes_caution: [], }; } return { report_file: "/tmp/b6-agg2.md", must_fix: 3, suggestion: 0, must_fix_ids: [ { id: "N-1", severity: "major", adjudication: "evidence", files: ["src/touched.ts"], evidence: "regression found", guidance: "restore guard" }, { id: "N-2", severity: "major", adjudication: "evidence", files: ["docs/guide.md"], evidence: "stale doc", guidance: "update doc" }, { id: "MF-D1", severity: "major", adjudication: "evidence", files: ["src/touched.ts"], evidence: "revived with concrete repro" }, ], fixes_caution: [], }; }, fix: () => { fixN++; if (fixN === 1) { return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: ["src/touched.ts"] }], deferred: [], }; } return { fixed_count: 3, fixes: [ { issue_id: "N-1", description: "fix n1", self_check: "grep: 1 hit", affected_files: ["src/touched.ts"] }, { issue_id: "N-2", description: "fix n2", self_check: "grep: 1 hit", affected_files: ["docs/guide.md"] }, { issue_id: "MF-D1", description: "fix revived", self_check: "grep: 1 hit", affected_files: [] }, ], deferred: [], }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); const outcome = assertScriptOutcome(result.scriptResult); // 到达 clean = ES3 未拦截 = R1 fix 只修 MF-1 而聚合 must_fix=1(非降级计数)口径一致; // 若降级条目未过滤,mustFixIds 含 MF-D1、fix 剧本不修它 → fix-failure 终止(R3 不可达)。 expect(outcome.terminated).toBe("clean"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { issues: Record; dormant: Array<{ id: string; reason: string; detail: string; round: number; revived: boolean }>; }; // ① 消费侧过滤(间接+直接双证):run 到达 clean = R1 fix 只修 MF-1 而 // mustFixIds=filterActiveIds=[MF-1](若降级条目未过滤,ES3 判 MF-D1 漏修 → // fix-failure,R3 不可达);直接证 = dormant 有 MF-D1 落盘(见④)。 // ⑥(旧格式回归由现有 e2e 用例守护——本用例全部新格式) expect(st.issues["MF-1"]).toMatchObject({ guidance: "fix boundary in parser", evidence: "line 42 off-by-one", }); expect(st.issues["MF-1"].origin).toBeUndefined(); // R1 条目无 origin(设计 6.1) // ② R2 新发现 origin:N-1 regression(files 与 fix 剧本 affected_files 相交—— // mock fixer 不写文件,regression 经 fixImpactFiles 触发);N-2 new expect(st.issues["N-1"]).toMatchObject({ origin: "regression", guidance: "restore guard" }); expect(st.issues["N-2"]).toMatchObject({ origin: "new", evidence: "stale doc" }); // ④ 复活闭环:MF-D1 revived=true 且回修复队列(issues 有它) const dormantD1 = st.dormant.find((d) => d.id === "MF-D1"); expect(dormantD1).toBeDefined(); expect(dormantD1!.reason).toBe("adjudication-downgraded"); expect(dormantD1!.detail).toBe("claim contradicts known facts"); // detail=note(裁决理由优先) expect(dormantD1!.revived).toBe(true); expect(st.issues["MF-D1"]).toBeDefined(); // 复活回修复队列 // ③ R2 review prompt 含 dormant 清单;R3 prompt 不再注入(revived=true 过滤) const { prompts, kinds, schemas } = runner.stats(); const reviewPrompts = prompts.filter((_, i) => kinds[i] === "review"); expect(reviewPrompts[1]).toContain("DORMANT ISSUES"); expect(reviewPrompts[1]).toContain("MF-D1"); expect(reviewPrompts[1]).toContain("adjudication-downgraded"); expect(reviewPrompts[2]).not.toContain("DORMANT ISSUES"); // ⑤ aggregatorSchema 扩展:条目 oneOf object 分支 properties 含五扩展字段 const aggSchemaIdx = kinds.findIndex((k) => k === "aggregate"); const aggSchema = schemas[aggSchemaIdx] as { properties?: { must_fix_ids?: { items?: { oneOf?: Array<{ properties?: Record }> } }; scores?: unknown }; }; const objBranch = aggSchema.properties?.must_fix_ids?.items?.oneOf?.find( (b) => b.properties && "files" in b.properties, ); expect(objBranch).toBeDefined(); for (const field of ["files", "evidence", "guidance", "adjudication", "note"]) { expect(objBranch!.properties).toHaveProperty(field); } expect(aggSchema.properties).toHaveProperty("scores"); }, RUN_TIMEOUT_MS, ); // ── C5: rfl 打分 + clean 轮回填 + aggregatorModel(tier-1 M2) ── it( "C5: scores 落盘 + regression 确定性回填 + clean 轮对账回填 + aggregatorModel 降档", async () => { // 剧本(三轮到 clean):R1 聚合带 reviewer scores;R2 聚合带 R2 reviewer 分 + // R1 fix LLM 三维度分 + 1 条新 issue;R2 reconciliation 全 fixed(R1 fix // regression 回填 = 10);R3 全 clean → 确定性对账(N-1 fix-attempted → fixed) // + R2 fix 的 regression 回填(clean 轮 entry,LLM 维度 null)。 let aggN = 0; const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/c5-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/c5-r2.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }), () => ({ report_file: "/tmp/c5-r3.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "N-1", status: "fixed", evidence: "read confirmed" }], }), ], aggregate: () => { aggN++; if (aggN === 1) { return { report_file: "/tmp/c5-agg1.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"], evidence: "off-by-one", guidance: "fix boundary" }], fixes_caution: [], scores: [{ round: 1, targetKind: "reviewer", targetName: "reviewer", dimensions: { evidence: 9, severity: 7, actionability: 8, reconciliation: 9 }, total: 8.2, }], }; } return { report_file: "/tmp/c5-agg2.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "N-1", severity: "major", adjudication: "evidence", files: ["src/b.ts"], evidence: "new issue", guidance: "patch it" }], fixes_caution: [], scores: [ { round: 2, targetKind: "reviewer", targetName: "reviewer", dimensions: { evidence: 8, severity: 8, actionability: 9, reconciliation: 10 }, total: 8.6 }, { round: 1, targetKind: "fix", targetName: "fix", dimensions: { coverage: 8, selfCheck: 9, minimality: 7 }, total: 8 }, ], }; }, fix: () => ({ // R1 修 MF-1、R2 修 N-1(按调用序内容差异不影响断言——issue_id 由 reconcile 驱动) fixed_count: 1, fixes: [{ issue_id: aggN === 1 ? "MF-1" : "N-1", description: "fix", self_check: "grep: 1 hit", affected_files: ["src/c.ts"] }], deferred: [], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), aggregatorModel: "mock-model-x", _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { issues: Record }>; scores: Array<{ round: number; targetKind: string; targetName: string; dimensions: Record; total: number | null; note?: string }>; }; // ① R1 reviewer entry(4 维度 + total)与 R1 fix entry(LLM 三维度 + regression 由 // R2 reconcile 确定性回填 = 10:R2 reconciliation 全 fixed,无 regressed) const r1Reviewer = st.scores.find((s) => s.targetKind === "reviewer" && s.round === 1); expect(r1Reviewer).toMatchObject({ targetName: "reviewer", total: 8.2 }); expect(r1Reviewer!.dimensions).toEqual({ evidence: 9, severity: 7, actionability: 8, reconciliation: 9 }); const r1Fix = st.scores.find((s) => s.targetKind === "fix" && s.round === 1); expect(r1Fix).toBeDefined(); expect(r1Fix!.dimensions).toEqual({ coverage: 8, selfCheck: 9, minimality: 7, regression: 10 }); expect(r1Fix!.total).toBe(8); // LLM entry 的 total 保持,regression 只补维度 // ②③ clean 轮黑洞修复:R2 fix 的 N-1 从 fix-attempted → fixed(history 有 R3 // fixed 记录);R2 fix 的确定性 entry(round=2,LLM 三维度 null + regression=10 + // total null + note 标注) expect(st.issues["N-1"].status).toBe("fixed"); expect(st.issues["N-1"].history).toContainEqual({ round: 3, status: "fixed" }); const r2Fix = st.scores.find((s) => s.targetKind === "fix" && s.round === 2); expect(r2Fix).toBeDefined(); expect(r2Fix!.dimensions).toEqual({ coverage: null, selfCheck: null, minimality: null, regression: 10 }); expect(r2Fix!.total).toBeNull(); expect(r2Fix!.note).toContain("clean-round deterministic backfill"); // ④ aggregatorModel 降档:aggregate 调用 model=mock-model-x;review/fix 不受影响 const { kinds, models } = runner.stats(); const aggModels = kinds.map((k, i) => (k === "aggregate" ? models[i] : null)).filter((m) => m !== null); expect(aggModels).toEqual(["mock-model-x", "mock-model-x"]); const reviewModels = kinds.map((k, i) => (k === "review" ? models[i] : null)).filter((m) => m !== null); expect(reviewModels.every((m) => m !== "mock-model-x")).toBe(true); // ⑤ usage 提示载体:pi-meta parameters 的 aggregatorModel description(用户可见层) const wfSource = readFileSync(wf("review-fix-loop"), "utf-8"); expect(wfSource).toContain("aggregatorModel"); expect(wfSource).toContain("AGENTS.md"); expect(wfSource).toContain("confirm with the owner"); }, RUN_TIMEOUT_MS, ); it( "B6: exec-review 修复回归——对账通道不绕过 dormant 分区 + 已追踪条目降级重报不翻 regressed/不落 dormant", async () => { // 剧本:R1 聚合 2 条目(MF-1 活跃 + MF-D1 降级)→ fix(MF-1);R2 reconciliation // 声明 MF-D1 not-fixed(绕过尝试)+ MF-1 fixed;R2 聚合把 MF-1 降级重报 //(已追踪条目降级)+ N-1 活跃 → fix(N-1);R3 clean。 let aggN = 0; let fixN = 0; const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/b6b-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/b6b-r2.md", must_fix: 1, suggestion: 0, reconciliation: [ { prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }, { prev_id: "MF-D1", status: "not-fixed", evidence: "still present" }, ], }), () => ({ report_file: "/tmp/b6b-r3.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "N-1", status: "fixed", evidence: "read confirmed" }], }), ], aggregate: () => { aggN++; if (aggN === 1) { return { report_file: "/tmp/b6b-agg1.md", must_fix: 1, suggestion: 0, must_fix_ids: [ { id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"] }, { id: "MF-D1", severity: "major", adjudication: "downgraded", note: "no evidence" }, ], fixes_caution: [], }; } return { report_file: "/tmp/b6b-agg2.md", must_fix: 1, suggestion: 0, must_fix_ids: [ { id: "MF-1", severity: "major", adjudication: "downgraded", note: "re-adjudicated down" }, { id: "N-1", severity: "major", adjudication: "evidence", files: ["src/b.ts"] }, ], fixes_caution: [], }; }, fix: () => { fixN++; return { fixed_count: 1, fixes: [{ issue_id: fixN === 1 ? "MF-1" : "N-1", description: "fix", self_check: "grep: 1 hit", affected_files: ["src/c.ts"] }], deferred: [], }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { issues: Record }>; dormant: Array<{ id: string; revived: boolean }>; }; // major-1 回归:reviewer 对 dormant id(MF-D1)声明 not-fixed 不经对账通道建 // issue(复活唯一入口 = 聚合活跃重报)——issues 无 MF-D1,dormant 记录保持 expect(st.issues["MF-D1"]).toBeUndefined(); const dormantD1 = st.dormant.find((d) => d.id === "MF-D1"); expect(dormantD1).toBeDefined(); expect(dormantD1!.revived).toBe(false); // major-2a 回归:已追踪条目(MF-1)被 R2 聚合降级重报 → 不翻 regressed //(对账 fixed 声明是权威转换:fix-attempted → fixed,history R2 fixed) expect(st.issues["MF-1"].status).toBe("fixed"); expect(st.issues["MF-1"].history).toContainEqual({ round: 2, status: "fixed" }); expect(st.issues["MF-1"].history).not.toContainEqual({ round: 2, status: "regressed" }); // major-2b 回归:MF-1 在 issues 活跃追踪(recordDormant excludeIds)→ 不落 dormant expect(st.dormant.find((d) => d.id === "MF-1")).toBeUndefined(); // N-1 正常链路 + R3 clean 轮对账回填(fix-attempted → fixed) expect(st.issues["N-1"].status).toBe("fixed"); expect(st.issues["N-1"].history).toContainEqual({ round: 3, status: "fixed" }); }, RUN_TIMEOUT_MS, ); // ── D1/D4: T9 前缀稳定化(schema 跨轮统一 + R1 空数组下游无异常) ── it( "D1/D4: 同一 run 内 R1 与 R2 review 调用 schema 逐字节相同 + 无 per-round spread + R1 空数组下游无异常", async () => { // 剧本同 B6b 骨架(R1 带 reconciliation: [] 显式输出 → fix → R2 → clean) let aggN = 0; let fixN = 0; const runner = makeScenarioRunner({ review: [ // R1:显式 reconciliation: [](required 统一后的合规输出) () => ({ report_file: "/tmp/d-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/d-r2.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }), ], aggregate: () => { aggN++; return { report_file: "/tmp/d-agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"] }], fixes_caution: [], }; }, fix: () => { fixN++; return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix", self_check: "grep: 1 hit", affected_files: ["src/b.ts"] }], deferred: [], }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); const outcome = assertScriptOutcome(result.scriptResult); // R1 空数组下游无异常:run 到达 clean(reconcile 门控 reconCount===0 行为不变) expect(outcome.terminated).toBe("clean"); // schema 跨轮逐字节相同(缓存前缀稳定化的 system 段前提) const { kinds, schemas } = runner.stats(); const reviewSchemas = kinds .map((k, i) => (k === "review" ? JSON.stringify(schemas[i]) : null)) .filter((s) => s !== null); expect(reviewSchemas.length).toBe(2); expect(reviewSchemas[0]).toBe(reviewSchemas[1]); const parsed = JSON.parse(reviewSchemas[0]) as { required?: string[] }; expect(parsed.required).toEqual(["report_file", "must_fix", "suggestion", "reconciliation"]); expect(JSON.stringify(parsed)).not.toContain("optional for R1"); // stale description 已更新 // 脚本内不再存在 per-round required spread const wfSource = readFileSync(wf("review-fix-loop"), "utf-8"); expect(wfSource).not.toContain("...reviewerSchema, required:"); }, RUN_TIMEOUT_MS, ); // ── 实施后对抗式审查修复(v7.1):A1/A2/A3/A4 ────────────────────── it( "A4: 全降级轮(reviewer 报 must-fix、aggregator 全部降级)→ 不派 fixer,批推进 + 跨批 skip(W5)", async () => { // 剧本:批 1 R1 reviewer-a 报 1 must-fix,aggregator 裁决全部降级 //(must_fix=0、唯一条目 downgraded)→ A4 守卫生效:批 1 视同 clean 终止本轮, // 不空转派发 fixer(修复前:mustFix===0 混过 all-clean 判定(用的是 reviewer // 原始计数口径)→ 照常进 Fix 阶段 → fixCount++ 白跑);批 2 = reviewer-a + // reviewer-b:W5 修复后 reviewer-a 在 A4 break 前被补记 recordAgentClean //(裁决降级=噪声,语义等价 clean;A4 轮无 fix → fixCount 快照不变)→ 批 2 // cross-batch skip,仅 reviewer-b 派发且直接 clean。 const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/a4-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/a4-r2.md", must_fix: 0, suggestion: 0, reconciliation: [] }), ], aggregate: () => ({ report_file: "/tmp/a4-agg.md", must_fix: 0, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major", adjudication: "downgraded", note: "no reproducible evidence" }], fixes_caution: [], }), fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", batch1: agentMd("reviewer-a"), batch2: agentMd("reviewer-a") + "," + agentMd("reviewer-b"), _runId: RUN_ID(), }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); // 无 fixer 调用(守卫生效);聚合 1 次(批 1),批 2 全 clean 不聚合 const { kinds } = runner.stats(); expect(kinds.filter((k) => k === "fix").length).toBe(0); expect(kinds.filter((k) => k === "aggregate").length).toBe(1); // W5:review 总数 2 = 批 1 reviewer-a + 批 2 仅 reviewer-b(reviewer-a 被跨批 // skip);修复前 reviewer-a 未记 clean → 批 2 派 2 个 agent → 总 3,本断言拦截 expect(kinds.filter((k) => k === "review").length).toBe(2); // 批推进到批 2(守卫视同 clean,不 fail-fast)+ fixCount 不虚增 const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { batches: unknown[]; fixCount: number; dormant: Array<{ id: string }>; agentStatus: Record; }; expect(st.batches).toHaveLength(2); expect(st.fixCount).toBe(0); // W5:A4 break 前对 must_fix>0 的 reviewer 补记 clean 快照(batch=1、当时 // fixCount=0)——shouldSkipAgent 的判定数据源 expect(st.agentStatus["reviewer-a"]).toMatchObject({ lastCleanBatch: 1, lastCleanFixCount: 0 }); // 批 1 的降级条目在批 1 期间落过 dormant(中间 saveState 可查);批 2 开始时 // dormant 批作用域重置(A2)且批 2 无降级 → 最终落盘为空 expect(st.dormant).toEqual([]); }, RUN_TIMEOUT_MS, ); it( "F2: aggregator numeric-fallback(无 must_fix_ids)不授予跨批 clean-skip——批 2 仍派该 agent", async () => { // F2 场景(第 3 轮复审 minor):批 1 R1 reviewer-a 报 1 must-fix;aggregator // JSON 无效(缺 must_fix,__invalidOutput 哨兵模拟真实违约)→ parseAggregatedMd // 从预写的 aggregated.md 兜底解析 must_fix=0。兜底 agg 无 must_fix_ids 键—— // A4 第三条件对 undefined 恒真仍 break(保留),但 W5 补记循环若无 // agg.must_fix_ids 前置,会凭「md 里一行 Must-fix: 0」的弱证据给 reviewer-a 记 // clean → 批 2 cross-batch skip →「跳一轮」被 shouldSkipAgent 放大为「跳到底」 // 且无条目级裁决证据。修复后:批 2 reviewer-a 照常全价重扫。 const runner = makeScenarioRunner({ review: [ // 批 1 R1(reviewer-a):1 must-fix(dirty) () => ({ report_file: "/tmp/f2-b1r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // 批 2 R1(reviewer-a / reviewer-b 顺序不定,两 generator 同结果):全 clean () => ({ report_file: "/tmp/f2-b2r1a.md", must_fix: 0, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/f2-b2r1b.md", must_fix: 0, suggestion: 0, reconciliation: [] }), ], aggregate: (prompt: string) => { // 真实失败形态:aggregator 的 JSON 无效,但 PART 1 的 aggregated.md 已写出。 // 从 prompt 解析 roundDir 预写 fallback 依赖的固定格式("- Must-fix: 0")。 const m = /^outputDir: (.+)$/m.exec(prompt); if (!m) throw new Error("F2 e2e: outputDir not found in aggregator prompt"); writeFileSync(join(m[1].trim(), "aggregated.md"), "## Summary\n- Must-fix: 0\n- Suggestions: 0\n", "utf-8"); // 无 must_fix → normalizeAggregatorResult 判 null → fallback 走 md 兜底 return { __invalidOutput: { fixes_caution: [] } }; }, fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", batch1: agentMd("reviewer-a"), batch2: agentMd("reviewer-a") + "," + agentMd("reviewer-b"), _runId: RUN_ID(), }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { batches: Array<{ rounds: Array<{ round: number; mustFix: number | null }> }>; fixCount: number; agentStatus: Record; }; expect(st.batches).toHaveLength(2); expect(st.fixCount).toBe(0); // fallback 生效旁证:批 1 的轮条目 mustFix=0(md 兜底解析值),A4 break 路径 expect(st.batches[0].rounds[0].mustFix).toBe(0); // 核心判别:批 2 仍派 reviewer-a(3 次 review = 批 1 ×1 + 批 2 ×2)。 // 修复前 W5 凭弱证据补记 clean(batch=1、fixCount=0)→ 批 2 skip → 仅 2 次。 const { kinds } = runner.stats(); expect(kinds.filter((k) => k === "review").length).toBe(3); expect(kinds.filter((k) => k === "aggregate").length).toBe(1); // 批 2 全 clean 不聚合 expect(kinds.filter((k) => k === "fix").length).toBe(0); // agentStatus 佐证:reviewer-a 的 clean 记录来自批 2 真实 clean 轮(=2), // 而非批 1 fallback 弱证据补记(修复前 =1) expect(st.agentStatus["reviewer-a"]).toMatchObject({ lastCleanBatch: 2, lastCleanFixCount: 0 }); }, RUN_TIMEOUT_MS, ); it( "A4/W4: R2 全降级 break 不二次对账——open 条目同轮 openStreak 不双计", async () => { // 剧本:R1 reviewer 报 2 must-fix(MF-1/MF-2 聚合均活跃)→ fix 修两个 //(fix-attempted);R2 reviewer 原始报 1 must-fix + 对账 MF-1 fixed / // MF-2 not-fixed;R2 聚合全部降级(mustFix=0、唯一条目 MF-2 downgraded)→ // A4 break 终止批 1。修复前:A4 break 前的 applyCleanRoundBackfill 内部第二次 // reconcileIssues 把 MF-2(regressed 且 seen)同轮 openStreak 再 +1(1→2 双计, // 无谓吃掉 stuckThreshold 余量);修复后(W4)A4 仅补 regression 维度,对账 // 只发生一次(stuck 检测处)。 let aggN = 0; const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/w4-r1.md", must_fix: 2, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/w4-r2.md", must_fix: 1, suggestion: 0, reconciliation: [ { prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }, { prev_id: "MF-2", status: "not-fixed", evidence: "still wrong" }, ], }), ], aggregate: () => { aggN++; if (aggN === 1) { return { report_file: "/tmp/w4-agg1.md", must_fix: 2, suggestion: 0, must_fix_ids: [ { id: "MF-1", severity: "major", adjudication: "evidence" }, { id: "MF-2", severity: "major", adjudication: "evidence" }, ], fixes_caution: [], }; } return { report_file: "/tmp/w4-agg2.md", must_fix: 0, suggestion: 0, must_fix_ids: [{ id: "MF-2", severity: "major", adjudication: "downgraded", note: "weak evidence" }], fixes_caution: [], }; }, fix: () => ({ fixed_count: 2, fixes: [ { issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit; synced", affected_files: [] }, { issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit; synced", affected_files: [] }, ], deferred: [], }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); // A4 break → batchClean → 单批 run 以 clean 终止 expect(outcome.terminated).toBe("clean"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { issues: Record }>; }; // R2 对账(唯一一次):MF-1 fixed;MF-2 regressed(fixAttempts=1)且 // openStreak=1(修复前第二次 reconcile 双计 → openStreak=2,本断言拦截) expect(st.issues["MF-1"].status).toBe("fixed"); expect(st.issues["MF-2"].status).toBe("regressed"); expect(st.issues["MF-2"].openStreak).toBe(1); expect(st.issues["MF-2"].fixAttempts).toBe(1); // history 旁证:{round:2, regressed} 只一条(双计的本质是 openStreak 数值, // history 不重复 push——openStreak 断言才是判别点) expect(st.issues["MF-2"].history.filter((h) => h.round === 2 && h.status === "regressed")).toHaveLength(1); // 调用数:review 2 + aggregate 2 + fix 1(R2 A4 不派 fixer) const { kinds } = runner.stats(); expect(kinds.filter((k) => k === "review").length).toBe(2); expect(kinds.filter((k) => k === "aggregate").length).toBe(2); expect(kinds.filter((k) => k === "fix").length).toBe(1); }, RUN_TIMEOUT_MS, ); it( "F3: review-failure 轮的 batchRounds 条目 mustFix/suggestion 为 null(未知非 0)", async () => { // 第 3 轮终审 info 锁定:失败轮(review-failure)聚合未发生,mustFix 是未知 // 而非 0——W8 曾落 0,时间线行会被误读为 clean 轮(消费侧 rfl.mjs `?? "-"`)。 // 剧本:reviewer 返回缺 must_fix 的违约输出(__invalidOutput 哨兵绕过契约校验) // → 脚本结构化终止 review-failure,断言该轮 rounds 条目 mustFix/suggestion 为 null。 const runner = makeScenarioRunner({ review: [() => ({ __invalidOutput: { report_file: "/tmp/f3-invalid.md", suggestion: 0 } })], aggregate: () => { throw new Error("aggregate must not run after review-failure"); }, fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }), }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", batch1: agentMd("reviewer-a"), _runId: RUN_ID(), }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("review-failure"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { batches: Array<{ rounds: Array<{ round: number; mustFix: number | null; suggestion: number | null }> }>; }; expect(st.batches).toHaveLength(1); expect(st.batches[0].rounds).toHaveLength(1); // 判别点:失败轮 mustFix/suggestion = null(未知),非 0(clean 语义)—— // 回退为 0 时时间线 "mustFix 0" 与真实 clean 轮无法区分,本断言拦截。 expect(st.batches[0].rounds[0].mustFix).toBeNull(); expect(st.batches[0].rounds[0].suggestion).toBeNull(); // 聚合/修复未发生(review-failure 在聚合前终止) const { kinds } = runner.stats(); expect(kinds.filter((k) => k === "aggregate").length).toBe(0); expect(kinds.filter((k) => k === "fix").length).toBe(0); }, RUN_TIMEOUT_MS, ); it( "A2+A3: dormant 批作用域重置(跨批同 id 不污染对账)+ fix prompt 含 guidance 通道", async () => { // A2 剧本(跨批 id 冲突):批 1 R1 聚合 2 条目(MF-1 活跃带 guidance / MF-3 降级 // 落 dormant)→ fix 修 MF-1;批 1 R2 clean(对账 MF-1 fixed)→ 批 1 终止, // dormant=[批1的 MF-3]。批 2 R1 reviewer-b 报 1 must-fix,聚合返回活跃 MF-3 //(aggregator id 空间每批从 MF-1 重新编号,批 2 的 MF-3 是新问题)→ fix 修它; // 批 2 R2 对账声明 MF-3 not-fixed(未修好)→ 聚合仍报活跃 MF-3 → fix 再修。 // 修复前:批 1 残留 dormant MF-3 被 filterDormantFromRecon 从 reconSeen 剥离 → // 批 2 的 MF-3 fix-attempted 被误反转 fixed → converged 提前终止(掩盖未修复); // 修复后:dormant 批作用域重置 → reconcile 正常转 regressed → 继续 fix → max-rounds。 let aggN = 0; let fixN = 0; const runner = makeScenarioRunner({ review: [ // 批 1 R1(reviewer-a):2 must-fix () => ({ report_file: "/tmp/a2-b1r1.md", must_fix: 2, suggestion: 0, reconciliation: [] }), // 批 1 R2(reviewer-a):clean + 对账 MF-1 fixed → all-clean 终止批 1 () => ({ report_file: "/tmp/a2-b1r2.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }), // 批 2 R1(reviewer-b):1 must-fix(批 2 的 MF-3) () => ({ report_file: "/tmp/a2-b2r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), // 批 2 R2(reviewer-b):MF-3 not-fixed(未修好) () => ({ report_file: "/tmp/a2-b2r2.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-3", status: "not-fixed", evidence: "still wrong" }], }), ], aggregate: () => { aggN++; if (aggN === 1) { return { report_file: "/tmp/a2-agg1.md", must_fix: 1, suggestion: 0, must_fix_ids: [ { id: "MF-1", severity: "major", adjudication: "evidence", files: ["src/a.ts"], evidence: "off-by-one", guidance: "fix the boundary check in parser" }, // W2:降级条目刻意带 guidance——修复前 fixGuidance 从 must_fix_ids 全量 // 提取,降级条目的 guidance 会以 MUST-FIX GUIDANCE 标题混给 fixer { id: "MF-3", severity: "major", adjudication: "downgraded", note: "weak evidence at the time", guidance: "downgraded-noise guidance" }, ], fixes_caution: [], }; } if (aggN === 2) { return { report_file: "/tmp/a2-agg2.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-3", severity: "major", adjudication: "evidence", files: ["src/z.ts"], evidence: "new issue in batch 2", guidance: "patch the z guard" }], fixes_caution: [], }; } return { report_file: "/tmp/a2-agg3.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-3", severity: "major", adjudication: "evidence", files: ["src/z.ts"], evidence: "still broken" }], fixes_caution: [], }; }, fix: () => { fixN++; return { fixed_count: 1, fixes: [{ issue_id: fixN === 1 ? "MF-1" : "MF-3", description: "fix " + fixN, self_check: "grep: 1 hit; synced", affected_files: [], }], deferred: [], }; }, }); const deps = makeDeps(runner); const result = await runAndWait( wf("review-fix-loop"), { targetType: "file", target: "README.md", maxRounds: 2, batch1: agentMd("reviewer-a"), batch2: agentMd("reviewer-b"), _runId: RUN_ID(), }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); // 判别点:修复前批 2 R2 的 MF-3 被误反转 fixed → converged 提前终止; // 修复后 regressed → 不收敛 → R2 再修一轮 → maxRounds=2 到顶 → max-rounds。 expect(outcome.terminated).toBe("max-rounds"); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { issues: Record }>; dormant: Array<{ id: string }>; }; // 核心断言:批 2 的 MF-3 走 regressed(对账 not-fixed 生效)而非被批 1 dormant // 残留反转 fixed。批 1 的 MF-1 已被批 2 的 issues 批作用域重置清除(MF-1 批级 // 生命周期,不跨批残留——与 dormant 同点重置)。 expect(st.issues["MF-1"]).toBeUndefined(); expect(st.issues["MF-3"].history).toContainEqual({ round: 2, status: "regressed" }); expect(st.issues["MF-3"].history).not.toContainEqual({ round: 2, status: "fixed" }); // dormant 批作用域:批 1 记录的 MF-3 在批 2 被重置;批 2 的 MF-3 活跃不落 dormant expect(st.dormant).toEqual([]); // fix 正常派发:批 1 R1 + 批 2 R1 + 批 2 R2(regressed 后再修)= 3 次 const { kinds, prompts } = runner.stats(); const fixIdxs = kinds.map((k, i) => (k === "fix" ? i : -1)).filter((i) => i >= 0); expect(fixIdxs.length).toBe(3); // A3 e2e:fix prompt 含 guidance 确定性通道(mock guidance 文本 + wrapUntrusted) const fixPrompts = fixIdxs.map((i) => prompts[i]); expect(fixPrompts[0]).toContain("MUST-FIX GUIDANCE (adjudicated, per-issue)"); expect(fixPrompts[0]).toContain(''); expect(fixPrompts[0]).toContain("- MF-1: fix the boundary check in parser"); // W2:降级条目(MF-3 downgraded)的 guidance 不进 MUST-FIX GUIDANCE 段—— // fixGuidance 与修复队列(filterActiveIds)同口径,不诱导修复已裁决噪声 //(reportContent 在此剧本不可读,prompt 中该文本唯一来源 = guidance 通道) expect(fixPrompts[0]).not.toContain("downgraded-noise guidance"); // R2+(批 2 的 fix)同样生效 expect(fixPrompts[1]).toContain("- MF-3: patch the z guard"); // 无 guidance 条目的轮(agg3 无 guidance)→ 无该段(prompt 形状稳定) expect(fixPrompts[2]).not.toContain("MUST-FIX GUIDANCE"); }, RUN_TIMEOUT_MS, ); it( "A1: engine rebuild 后同 _runId 残留 state 被重置——calls/fixResults 无双记 + previousAttempts=1", async () => { // 受控脚本故障注入(设计 §8.2 S4 方法):临时脚本副本在首次 fix 完成后写 marker // 文件并抛错 → engine script-error → rebuildRuntime(同 _runId / 同 RUN_ROOT 重跑, // 已完成调用按 callId 缓存重放)。attempt-2 读到 marker 不再抛。 // 修复前:loadState 原样复用 attempt-1 残留 → 重放的 recordCall/fixResults/fixCount // 全部双记(calls=7/fixResults=2/fixCount=2);修复后(A1)重置易变字段, // 仅 meta.previousAttempts=1 留痕。 const faultDir = mkdtempSync(join(tmpdir(), "rfl-e2e-fault-")); try { const original = readFileSync(wf("review-fix-loop"), "utf-8"); const utilsSrc = readFileSync(join(WORKFLOWS_DIR, "review-fix-loop-utils.cjs"), "utf-8"); // 注入点:R1 fix 完成后的 log 行(此刻 state.json 已 saveState 落盘一次) const anchor = 'log("Fixed " + fixedCount + " issue(s). Total: " + totalFixed + ". Modified " + modifiedFiles.length + " file(s). Continuing...");'; if (!original.includes(anchor)) { throw new Error("A1 e2e: fault anchor not found in review-fix-loop.js"); } const faultCode = anchor + "\n" + "// [TEST-INJECTED FAULT] attempt-1 写 marker 后抛错触发 engine rebuild;attempt-2 读到 marker 不再抛\n" + "try {\n" + " fs.accessSync(RUN_ROOT + \"/e2e-fault-marker\");\n" + "} catch (e) {\n" + " if (String(e).includes(\"e2e-fault-marker\") === false && String(e).includes(\"ENOENT\") === false) throw e;\n" + " fs.writeFileSync(RUN_ROOT + \"/e2e-fault-marker\", \"1\");\n" + " throw new Error(\"e2e-injected fault: simulate script-error after first state write (A1)\");\n" + "}"; const injected = original.replace(anchor, faultCode); const faultScriptPath = join(faultDir, "review-fix-loop.js"); writeFileSync(faultScriptPath, injected, "utf-8"); // utils 经 workerData.scriptPath dirname 定位——副本目录内放同版 utils writeFileSync(join(faultDir, "review-fix-loop-utils.cjs"), utilsSrc, "utf-8"); // 剧本:R1 1 must-fix → fix(saveState 后触发注入 fault)→ rebuild 重放 R1 → R2 clean const runner = makeScenarioRunner({ review: [ () => ({ report_file: "/tmp/a1-r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }), () => ({ report_file: "/tmp/a1-r2.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }], }), ], aggregate: () => ({ report_file: "/tmp/a1-agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [], }), fix: () => ({ fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "mock fix", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [], }), }); const deps = makeDeps(runner); const result = await runAndWait( faultScriptPath, { targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() }, deps, undefined, RUN_TIMEOUT_MS, ); expect(result.reason).toBe("completed"); expect(result.error).toBeUndefined(); const outcome = assertScriptOutcome(result.scriptResult); expect(outcome.terminated).toBe("clean"); expect(outcome.totalFixed).toBe(1); const st = JSON.parse(readFileSync(join(outcome.runDir!, "state.json"), "utf8")) as { meta: { previousAttempts?: number }; calls: Array>; fixResults: unknown[]; fixCount: number; }; // attempt-1 残留被识别并留痕 expect(st.meta.previousAttempts).toBe(1); // calls 无双记:attempt-2 重放 R1(review/agg/fix)+ R2 review = 4 //(修复前 = attempt-1 的 3 + attempt-2 的 4 = 7) expect(st.calls.length).toBe(4); expect(st.fixResults.length).toBe(1); expect(st.fixCount).toBe(1); } finally { rmSync(faultDir, { recursive: true, force: true }); } }, RUN_TIMEOUT_MS * 2, ); });