Skip to content
6 changes: 3 additions & 3 deletions ops/agent-v2/SHADOW-RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ spec 定案 2 安全轨的运维面:旧管线原地不动,v2 在 `ops/agent-v2/`
```bash
# 对拍闸门(罐装世界,零网络,~1min):旧 replay vs v2 checkpoint 任务
node ops/agent-v2/shadow-diff.mjs <YYYY-MM-DD-HHMM-health> # 退出 0=全 SAME
# 全量测试套(46 测,含 parity + cron-launchers + E2E + SIGKILL resume + 重试/跨库/exit 码面)
# 全量测试套(52 测/51 绿+1 设计 SKIP,含 parity + cron-launchers + E2E + SIGKILL resume + 重试/跨库/exit 码面 + golden 三面)
cd ops/agent-v2 && node --test 'tests/*.test.mjs'
```

Expand All @@ -16,8 +16,8 @@ cd ops/agent-v2 && node --test 'tests/*.test.mjs'
## SAME 门槛

- `shadow-diff.mjs` 退出 0(可比工件闭集 + 台账行,归一化窄面见其头注)。
- 全量测试套 46/46 绿。
- **golden 全集新旧逐条一致(票 #232,切流硬门槛)**:当前分支覆盖 1 例(discover full faux)+罐装健康截段(shadow-diff);全集(neg/boundary/health_full_faux 等 ~10 例)补齐前不得切流。
- 全量测试套 52 测 51 绿 + 1 设计 SKIP(smoke 凭证门)。
- **golden 对拍(#232 已收口,切流硬门槛)**:管线面 2/2 位等价对拍(`pipeline_health_full_faux`+`pipeline_discover_full_faux`,tests/golden-health-parity/golden-discover-parity,归一化窄面同 shadow-diff)+ legacy 判断器 8 例回归钉(golden-judge-regression:判断器例驱动 legacy agent.mjs runRound 面,spec T3 明示不迁 v2——钉的是 v2 依赖世界保持 GOLDENPASS,非管线对拍;切流删旧时随 legacy 面退役)+ smoke 例凭证门(golden-smoke-gated:选层门恒钉,OLD 面双门手跑,NEW=N/A)。
- 连续一个完整月轮周期内:零 infra 信封(v2 侧)、零对拍 DIFF、SIGKILL/断电恢复演练至少一次通过(`tests/resume.test.mjs` + 人工 kill 面板)。

## 已知差异面(声明,非缺陷)
Expand Down
4 changes: 3 additions & 1 deletion ops/agent-v2/shadow-diff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ async function runSide(cmd, args, env, label) {
}

// —— 归一化(窄面:时间/时长/tmp 路径;语义字段零归一)——
function normalizeText(t, base) {
// 导出面(#232):golden 全集对拍测试复用同一归一化(勿 fork;基座=TS/dur_ms/tmp/-rN,
// usage 三计数等 JSON 数值面由调用方按票面窄面在其上叠一层)。
export function normalizeText(t, base) {
return t
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, "<TS>")
.replace(/"generated_at":\s*"<TS>"/g, '"generated_at":"<TS>"')
Expand Down
165 changes: 165 additions & 0 deletions ops/agent-v2/tests/golden-health-parity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// golden-health-parity.test.mjs — golden 对拍补齐(票 #232;ops/agent/golden/cases.json
// 健康轮管线例 pipeline_health_full_faux):旧引擎(ops/agent/pipeline.mjs runPipeline,
// in-proc)vs 新任务面(ops/agent-v2 run-health.mjs 全链 PROBE→CLOSE,子进程)逐工件
// 位等价对拍。与 golden-discover-parity.test.mjs(#227 T3)合成「管线面 golden 2/2」。
// 世界=f run-golden.mjs runCase 同构(fixture 判例树 + curl/OPS_EVAL_PY stub + 抑制规则
// fixture,出处:run-golden.mjs writePrecedents/stubCurl/stubPy;用例数据单源=cases.json
// 运行时读,不手抄)。归一化=shadow-diff.mjs normalizeText(导出面,#232 复用勿 fork)
// + 票面窄面叠层(usage 三计数/wall_s:ENVELOPE.md §usage 计量边界合法差,cost 逐字)。
// 口径裁决(父会话 2026-10-28,A+C):管线面=位等价对拍;判断器 8 例=legacy 面回归钉
// (golden-judge-regression.test.mjs),不在此文件。
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import { GOLDEN_SWEEP_ANCHORS, anchorRow } from "../../agent/anchor-mirror.mjs"; // #194 C3-2 单源(run-golden.mjs stubPy 同引)
import { normalizeText } from "../shadow-diff.mjs";

const execFile = promisify(execFileCb);
const V2 = path.resolve(import.meta.dirname, "..");
const CASE = JSON.parse(fs.readFileSync(path.resolve(V2, "..", "agent", "golden", "cases.json"), "utf8"))
.cases.find((c) => c.id === "pipeline_health_full_faux");
const ROUND = "2026-06-01-0400-health"; // run-golden.mjs roundType 分支同字面(#208 T2:健康例原字面)
const FIXTURES = path.resolve(V2, "..", "agent", "fixtures", "pipeline");

// fixture 判例树(run-golden.mjs writePrecedents 逐字副本:A 命中 health+discover 检索词;
// B 在库不命中任何管线例检索词——闭集负例载体,tree 同构保金样世界零漂移)
const PREC_A = "2026-06-01-golden-aggregator.md";
const PREC_B = "2026-06-02-golden-matrix-nolayer.md";
const writePrecedents = (dir) => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, PREC_A),
`---\nid: 2026-06-01-golden-aggregator\ndate: 2026-06-01\nsubject: fixture — 聚合器候选必须实测排他率\ncontext: >\n 聚合器形状候选,上游映射推断重叠不可信,\n 排他率必须实测(冻结门线外另议)。\nhuman_ruling: >\n 实测排他率 50.0%,推翻 REJECT,立项。\nreward:\n hard_gates:\n exclusive_rate: "50.0%"\n ruling_agreement: overruled\n---\n\n正文检索词:聚合器 排他率 回声 冻结 违例。\n`);
fs.writeFileSync(path.join(dir, PREC_B),
`---\nid: 2026-06-02-golden-matrix-nolayer\ndate: 2026-06-02\nsubject: fixture — 语义矩阵 NO-LAYER 层读数约定\ncontext: >\n truth 层无取样资产时读数记 NO-LAYER,\n 摘要员如实提及不计入异常。\nhuman_ruling: >\n 认可约定。\nreward:\n hard_gates: {}\n ruling_agreement: agreed\n---\n\n正文:语义矩阵 NO-LAYER 层读数约定(零检索词命中样本)。\n`);
fs.writeFileSync(path.join(dir, "_schema.md"), "schema 说明(注入排除面)。\n");
};

// stub 脚本(run-golden.mjs stubCurl/stubPy 同型:CLI 边界替身,工具实现层全真;零网络)
const stubCurl = (rules) => `#!/usr/bin/env bash
url="${'${@: -1}'}"
d=180; s=0
case "$url" in
${rules.map((r) => ` *${r.match}*) d=${r.days ?? 180}; s=${r.status ?? 0} ;;`).join("\n")}
esac
if (( s >= 400 )); then
printf 'HTTP/1.1 %s stub\\r\\nContent-Length: 0\\r\\n\\r\\n' "$s"
exit 0
fi
printf 'HTTP/1.1 200 OK\\r\\nLast-Modified: %s\\r\\nContent-Length: 4\\r\\n\\r\\n' \\
"$(date -u -d "@$(( $(date +%s) - d * 86400 - 600 ))" '+%a, %d %b %Y %H:%M:%S GMT')"
exit 0
`;
const stubPy = (matrix) => `#!/usr/bin/env bash
SWEEP_OK='${JSON.stringify({ kind: "anchor_sweep", anchors: GOLDEN_SWEEP_ANCHORS, n_total: GOLDEN_SWEEP_ANCHORS.length, n_fail: 0, failures: [],
results: GOLDEN_SWEEP_ANCHORS.map(([ip, expect]) => anchorRow({ ip, expect })) }).replace(/'/g, "'\\''")}'
case " $* " in
*--anchor-sweep*) printf '%s\\n' "$SWEEP_OK" ;;
*) printf '%s\\n' '${JSON.stringify(matrix).replace(/'/g, "'\\''")}' ;;
esac
`;

// 世界置备(双侧同构:tmp/docs + 判例树 + stub + 抑制规则 fixture[cases.json 单源])
function seedSide(tag) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `v2-hpar-${tag}-`));
const docsRoot = path.join(tmp, "docs");
const ops = path.join(docsRoot, "ops");
writePrecedents(path.join(ops, "precedents"));
fs.mkdirSync(path.join(ops, "rules"), { recursive: true });
fs.writeFileSync(path.join(ops, "rules", "suppressions.json"), JSON.stringify(CASE.pipeline.rules, null, 2) + "\n");
const stubDir = path.join(tmp, "stubs");
fs.mkdirSync(stubDir, { recursive: true });
const writeStub = (name, text) => { const p = path.join(stubDir, name); fs.writeFileSync(p, text); fs.chmodSync(p, 0o755); return p; };
writeStub("curl", stubCurl(CASE.stubs.curl_age_days));
const py = writeStub("py-matrix", stubPy(CASE.stubs.matrix));
const env = {
...process.env,
DOCS_ROOT: docsRoot, PRECEDENTS_DIR: path.join(ops, "precedents"), LEDGER_FILE: path.join(ops, "ledger.md"),
PATH: `${stubDir}:${process.env.PATH}`, OPS_EVAL_PY: py, OPS_SWEEP_DRY_RUN: "1",
};
return { tmp, docsRoot, ops, env };
}

// 可比工件闭集(shadow-diff COMPARABLE 同源 + 01/02/09:全链双侧均在——shadow 载具停笔
// RETRO 才排除 09,本对拍双侧跑全链到 CLOSE,09-budget 双侧落件)。NEW 侧白名单
// .v2-claim.json(跨库撞轮认领件,shadow-diff isUndeclaredNewFile 同声明)。
const COMPARABLE = ["01-", "02-", "03-", "04-", "05-", "06-", "07-", "08-", "09-budget.json", "gates.json"];

test("golden 对拍 pipeline_health_full_faux:旧 runPipeline 全链 vs v2 run-health 全链,工件闭集位等价", async () => {
const old = seedSide("old");
const neu = seedSide("new");
try {
// —— 旧侧:in-proc runPipeline(run-golden.mjs pipeline 分支同调法;env 先于 import)——
const saved = { DOCS_ROOT: process.env.DOCS_ROOT, PRECEDENTS_DIR: process.env.PRECEDENTS_DIR, LEDGER_FILE: process.env.LEDGER_FILE, OPS_EVAL_PY: process.env.OPS_EVAL_PY, OPS_SWEEP_DRY_RUN: process.env.OPS_SWEEP_DRY_RUN, PATH: process.env.PATH };
Object.assign(process.env, old.env);
let oldEnv = null;
try {
const { runPipeline, DEFAULT_FIXTURES_DIR } = await import("../../agent/pipeline.mjs");
const r = await runPipeline({ type: "health", roundId: ROUND, fixturesDir: DEFAULT_FIXTURES_DIR,
stdout: { write: () => true }, stderr: { write: () => true } });
oldEnv = r?.envelope ?? null;
} finally {
for (const k of Object.keys(saved)) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
assert.ok(oldEnv?.ok, `旧引擎例应跑出 ok 信封:${JSON.stringify(oldEnv)?.slice(0, 300)}`);

// —— 新侧:子进程 run-health.mjs 全链(PROBE→CLOSE 缺省;faux fixtures 缺省)——
const { stdout } = await execFile(process.execPath, [path.join(V2, "run-health.mjs"), ROUND,
"--fixtures", FIXTURES, "--db", path.join(neu.tmp, "v2.sqlite")], { env: neu.env, maxBuffer: 32 * 1024 * 1024 });
const newR = JSON.parse(stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop());
assert.equal(newR.ok, true, `v2 例应终态 ok(失败面可读):${JSON.stringify(newR.error ?? newR).slice(0, 300)}`);

// —— 逐工件位等价(归一化窄面:TS/dur_ms/tmp/-rN=shadow-diff 单源;usage 三计数/
// wall_s=票面合法差叠层;cost 逐字)——
const base = { oldTmp: old.tmp, newTmp: neu.tmp, roundId: ROUND };
const norm = (t) => normalizeText(t, base)
.replace(/"(input|output|totalTokens)":\s*\d+/g, '"$1": N')
.replace(/"wall_s":\s*[\d.]+/g, '"wall_s": 0')
.replace(/\d+ 字节/g, "N 字节") // G_CLOSE reason 信封字节数=usage 数位下游(golden-discover-parity 同规则;shadow-diff 停笔 RETRO 不遇此面)
.replace(/(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{1,2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT/g, "<HTTP-DATE>"); // stub curl Last-Modified 毫秒漂移(金样自身 600s 余量同设计;语义面=age_days/分桶,逐字比不变)
const dirA = path.join(old.ops, "rounds", ROUND);
const dirB = path.join(neu.ops, "rounds", ROUND);
const filesOf = (d) => fs.existsSync(d) ? fs.readdirSync(d).filter((f) => !fs.statSync(path.join(d, f)).isDirectory()) : [];
const oldFiles = filesOf(dirA).filter((f) => f !== "state.json"); // OLD=磁盘状态机;NEW 无(state 面=checkpoint,shadow-diff 同排除)
const newFiles = filesOf(dirB).filter((f) => f !== "state.json" && f !== ".v2-claim.json"); // NEW 白名单(认领件,shadow-diff 同声明)
const rows = [];
for (const f of [...new Set([...oldFiles, ...newFiles])].sort()) {
const inComparable = COMPARABLE.some((p) => f.startsWith(p));
const o = oldFiles.includes(f), n = newFiles.includes(f);
if (!inComparable) { rows.push(`UNDECLARED ${f}(闭集外${o && !n ? ",旧侧" : ""}——结构性差异面须显式声明后入集,见 shadow-diff isUndeclaredNewFile 同哨)`); continue; } // 残余哨(P1:SKIP 仅记日志不断言→闭集外新面无哨;与 shadow-diff.mjs:158 同判据)
if (o && !n) { rows.push(`ONLY-OLD ${f}`); }
else if (!o && n) { rows.push(`ONLY-NEW ${f}`); }
else {
const a = norm(fs.readFileSync(path.join(dirA, f), "utf8"));
const b = norm(fs.readFileSync(path.join(dirB, f), "utf8"));
rows.push(`${a === b ? "SAME" : "DIFF"} ${f}`);
if (a !== b) {
const la = a.split("\n"), lb = b.split("\n");
const i = la.findIndex((l, k) => l !== lb[k]);
rows.push(` L${i + 1}: OLD=${(la[i] ?? "").slice(0, 160)} | NEW=${(lb[i] ?? "").slice(0, 160)}`);
}
}
}
const bad = rows.filter((r) => /^(DIFF|ONLY-|UNDECLARED)/.test(r));
// 信封相位序列/状态(旧 runPipeline 信封 vs v2;resumed/usage 已知差异面不入)
assert.deepEqual(newR.pipeline.stages.map((s) => [s.name, s.status]), oldEnv.pipeline.stages.map((s) => [s.name, s.status]), "相位序列/状态不等价");
// 台账行(RETRO 写面;usage 计数归一,cost 逐字——shadow-diff normLedger 同法)
const normLedger = (t) => normalizeText(t, base).replace(/in=\d+ out=\d+ total=\d+/g, "in=<N> out=<N> total=<N>");
const ledA = normLedger(fs.readFileSync(path.join(old.ops, "ledger.md"), "utf8")).trim();
const ledB = normLedger(fs.readFileSync(path.join(neu.ops, "ledger.md"), "utf8")).trim();
assert.equal(ledB, ledA, "台账新旧不等价(归一化后)");
// 双侧可比集非空(空集=闸门失败,shadow-diff P1-1 同判据)
assert.ok(rows.some((r) => r.startsWith("SAME")), `可比工件集为空或全异:\n${rows.join("\n")}`);
assert.deepEqual(bad, [], `golden pipeline_health_full_faux 位等价破口:\n${rows.join("\n")}`);
console.log(`[golden-parity] pipeline_health_full_faux 逐件清单:\n ${rows.join("\n ")}`);
} finally {
fs.rmSync(old.tmp, { recursive: true, force: true });
fs.rmSync(neu.tmp, { recursive: true, force: true });
}
});
44 changes: 44 additions & 0 deletions ops/agent-v2/tests/golden-judge-regression.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// golden-judge-regression.test.mjs — legacy 判断器面回归钉(票 #232;口径裁决 A+C,父会话
// 2026-10-28)。cases.json 8 个判断器例(health_summary_format/discover_predicate_hit/
// discover_midround_search_citation/neg1-3/boundary×2)驱动的是 legacy agent.mjs runRound
// 回路(ToolHost+写前语义门+收口四件)——spec T3 明示该面不迁 v2(tools/discovery-tools.mjs
// 头注:fetch_url/gh_search 留 legacy 面),故本文件不是管线对拍,而是「v2 依赖世界
// (pi-durable@1.4.0 + ops/agent 双 node_modules 共存,scaffold 单实例钉)不破坏 legacy
// 判断器面」的回归钉:在 v2 测试套内子进程起 ops/agent/golden/run-golden.mjs(ops/agent
// 零改动),faux 层全 10 例(8 判断器 + 2 管线,= runner 缺省层)须 GOLDENPASS 全绿。
// 切流删旧时本钉随 legacy 面退役(SHADOW-RUNBOOK §SAME 门槛行同口径)。
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";

const execFile = promisify(execFileCb);
const V2 = path.resolve(import.meta.dirname, "..");
const RUNNER = path.resolve(V2, "..", "agent", "golden", "run-golden.mjs");
const CASES = JSON.parse(fs.readFileSync(path.resolve(V2, "..", "agent", "golden", "cases.json"), "utf8")).cases;
const JUDGE_IDS = CASES.filter((c) => c.tier === "faux" && c.kind !== "pipeline").map((c) => c.id); // 8 判断器例(裁决口径)

test("legacy 判断器 8 例回归钉:判断器面断言词汇表外零漂移(run-golden.mjs --case 逐例 GOLDENPASS)", async () => {
// 逐例子进程(= runner 自身隔离形态:DOCS_ROOT/PATH env 每例独立,零网络全 faux);
// 8 例复用 runner 的世界置备/断言评估零复制——本钉只派生与判绿,不 fork 判断逻辑。
for (const id of JUDGE_IDS) {
let stdout = "";
try {
({ stdout } = await execFile(process.execPath, [RUNNER, "--case", id], { timeout: 300_000, maxBuffer: 32 * 1024 * 1024 }));
} catch (e) { // runner 非零退出时 execFile 先拒——捕获后仍走 match,让 GOLDENFAIL 明细面世(P2)
stdout = e.stdout ?? String(e);
}
assert.match(stdout, new RegExp(`^GOLDENPASS ${id}$`, "m"), `判断器例 ${id} 未 GOLDENPASS:\n${stdout.slice(0, 800)}`);
}
});

test("管线例判断面回归钉:faux 层全 10 例(8 判断器+2 管线)= runner 缺省层 PASS=10 FAIL=0", async () => {
// 父进程模式逐例派生子进程(runner 缺省=faux 层;与上行互补:钉选层缺省恒 faux,
// 防 smoke 层误进 CI——修正③判例面)。smoke 例零凭证零网络永不入本钉。
const { stdout: sum } = await (async () => { try {
return await execFile(process.execPath, [RUNNER], { timeout: 600_000, maxBuffer: 32 * 1024 * 1024 });
} catch (e) { return { stdout: e.stdout ?? String(e) }; } })(); // 同上:非零退出时保 stdout 入断言消息(P2)
assert.match(sum, /^golden: PASS=10 FAIL=0$/m, `faux 层非全绿:\n${sum.slice(-1200)}`);
});
Loading
Loading