From 6e50476bd9fcc81a2b42a599c1cbf96943afe81d Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 20:52:09 +0800 Subject: [PATCH 01/10] =?UTF-8?q?chore(c2c3):=20placeholder=20=E2=80=94=20?= =?UTF-8?q?40min=20wall=20guard,=20resume=20anchor=20(tickets=20#203=20#20?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 44e60a4e79fdf68d59bbb720f88d2483b3fb877b Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 20:52:41 +0800 Subject: [PATCH 02/10] =?UTF-8?q?wip(#202):=20C1=20=E5=BC=80=E5=B7=A5?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E2=80=94=E2=80=94=E5=B7=A5=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E6=BA=90=20artifacts.mjs(=E9=98=B2=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E4=B8=A2=E5=B7=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 7813d893979f03fe2dc79fc770a09cd5a5a01158 Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:00:38 +0800 Subject: [PATCH 03/10] =?UTF-8?q?#202=20C1:=E5=B7=A5=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E6=BA=90=20ops/agent/artifacts.mjs+selftest?= =?UTF-8?q?=20=E6=B8=85=E5=8D=95=E9=9D=A2=E6=94=B9=E5=8D=95=E6=BA=90+?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E9=97=AD=E5=8C=85=E5=93=A8(=E6=B8=85?= =?UTF-8?q?=E5=8D=9518=E5=9B=BA=E5=AE=9A=E5=90=8D=E2=88=AADEEP=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=97=8F;=E5=90=8D=E8=A1=A8=E5=8A=A0=E9=A1=B9?= =?UTF-8?q?=E4=B8=8D=E6=9B=B4=E6=96=B0=E5=86=99=E5=87=BA=E9=9D=A2=3D?= =?UTF-8?q?=E7=BA=A2,=E6=AD=A5=E9=9D=A2=E6=96=B0=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=E4=B8=8D=E5=85=A5=E8=A1=A8=3D=E7=BA=A2);78/0=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/agent/artifacts.mjs | 39 +++++++++++++++++++++++++++++++++ ops/agent/selftest-pipeline.mjs | 15 +++++++------ 2 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 ops/agent/artifacts.mjs diff --git a/ops/agent/artifacts.mjs b/ops/agent/artifacts.mjs new file mode 100644 index 00000000..776caa4f --- /dev/null +++ b/ops/agent/artifacts.mjs @@ -0,0 +1,39 @@ +// 工件名表单源(#202 C1 / spec C1):健康轮轮目录工件名的唯一真源。 +// 为什么:gates.mjs(inputs 声明/读面)、steps.mjs(写出面)、selftest-pipeline.mjs(清单面)三层 +// 曾各持手抄字符串(同名字符串 ~70 处),漂移即静默错位;单源后漂移由闭包哨 +// (selftest「工件名表单源」测)双向拦截:名表加项不更新写出面=红;步面新写工件不入表=红。 +// 红线:磁盘工件文件名逐字节冻结(位等价判例)——本表改值=改磁盘名,须过重放/位等价双门。 +// 范围:轮目录(ctx.dir)固定名 + 逐假设 DEEP 参数族;retro/.json(ctx.opsRoot 级)、 +// breaker.json(opsRoot 级)、state.json 之外的 opsRoot 面不在本表(见 #201 C1-C3 分工)。 + +// 18 个固定工件名 = 一个完整跑毕的健康轮(VERIFY 两分支都写 02-verify-rules.json)必有的固定面。 +export const ARTIFACTS = Object.freeze({ + PROBE_FRESHNESS_TSV: "01-probe-freshness.tsv", + PROBE_MATRIX: "01-probe-matrix.json", + PROBE_SUMMARY: "01-probe-summary.json", + VERIFY_RULES: "02-verify-rules.json", + VERIFY_SURVIVORS: "02-verify-survivors.json", + VERIFY_SUPPRESSED: "02-verify-suppressed.json", + TRIAGE_HYPOTHESES: "03-triage-hypotheses.json", + TRIAGE_TRANSCRIPT: "03-triage-transcript.jsonl", + DISPATCH_BUNDLES: "04-dispatch-bundles.json", + DEEP_VERDICTS: "05-deep-verdicts.json", + ITEMIZE_MANIFEST: "06-itemize-manifest.json", + SYNTH_REPORT: "07-synth-report.md", + SYNTH_TRANSCRIPT: "07-synth-transcript.jsonl", + ANCHOR_SWEEP: "08-anchor-sweep.json", + ANCHOR_SWEEP_EXITS: "08-anchor-sweep-exits.json", + BUDGET: "09-budget.json", + GATES: "gates.json", + STATE: "state.json", +}); + +// 名表闭包面(清单遍历/闭包哨差集都用它;禁止 Object.keys 当名字用)。 +export const ARTIFACT_NAMES = Object.freeze(Object.values(ARTIFACTS)); + +// 逐假设 DEEP 参数族:名字由假设 slug 拼出(slug=hidSlug(url)),不入固定表。 +export const deepArtifactName = (slug) => `05-deep-${slug}.json`; +export const deepTranscriptName = (slug) => `05-deep-${slug}-transcript.jsonl`; +// 参数族枚举/匹配面(gates G_DEEP 巡检、steps deepSeeds 过滤、闭包哨差集判定)。 +export const DEEP_ARTIFACT_RE = /^05-deep-.*\.json$/; // 注意:05-deep-verdicts.json 也命中(固定表先行扣除) +export const DEEP_TRANSCRIPT_RE = /^05-deep-.*-transcript\.jsonl$/; diff --git a/ops/agent/selftest-pipeline.mjs b/ops/agent/selftest-pipeline.mjs index 133a6cec..84349331 100644 --- a/ops/agent/selftest-pipeline.mjs +++ b/ops/agent/selftest-pipeline.mjs @@ -172,15 +172,16 @@ await t("门契约单源(#194 C3):GATES 注册键序=HEALTH_PIPELINE 执行序 assert.equal(Object.keys(GATES).length, GATE_COUNT); }); -await t("工件清单全落盘+gates.json 十记录全 PASS+state.json 终态 done/CLOSE+原子写无 .tmp 残留", async () => { +await t("工件名表单源(#202 C1):ARTIFACT_NAMES 双向闭包——清单全落盘∧轮目录实际工件集合=名表∪DEEP 参数族(名表加项不更新写出面=红;步面新写工件不入表=红;09-budget.json 手抄三层判例收口)+gates.json 十记录全 PASS+state.json 终态 done/CLOSE+原子写无 .tmp 残留", async () => { + const { ARTIFACTS, ARTIFACT_NAMES, DEEP_ARTIFACT_RE, DEEP_TRANSCRIPT_RE } = await import("./artifacts.mjs"); // 工件名单一真源(禁手抄清单) const d = roundDir(R1); - for (const f of ["01-probe-freshness.tsv", "01-probe-matrix.json", "01-probe-summary.json", "02-verify-survivors.json", - "02-verify-suppressed.json", "03-triage-hypotheses.json", "03-triage-transcript.jsonl", "04-dispatch-bundles.json", - "05-deep-verdicts.json", "06-itemize-manifest.json", "07-synth-report.md", "07-synth-transcript.jsonl", - "08-anchor-sweep.json", "08-anchor-sweep-exits.json", "09-budget.json", "gates.json", "state.json"]) + for (const f of ARTIFACT_NAMES) assert.ok(fs.existsSync(path.join(d, f)), `工件缺:${f}`); - assert.ok(fs.readdirSync(d).some((f) => /^05-deep-.*\.json$/.test(f) && f !== "05-deep-verdicts.json"), "逐假设工件"); - assert.ok(fs.readdirSync(d).some((f) => /^05-deep-.*-transcript\.jsonl$/.test(f)), "逐假设 transcript"); + const parametric = fs.readdirSync(d).filter((f) => !ARTIFACT_NAMES.includes(f)); // 名表外=逐假设参数族闭包面 + for (const f of parametric) + assert.ok(DEEP_ARTIFACT_RE.test(f) || DEEP_TRANSCRIPT_RE.test(f), `轮目录工件不在名表∪参数族闭包:${f}(新工件须先入 artifacts.mjs——名表与写出面双向哨)`); + assert.ok(parametric.some((f) => DEEP_ARTIFACT_RE.test(f) && f !== ARTIFACTS.DEEP_VERDICTS), "逐假设工件"); + assert.ok(parametric.some((f) => DEEP_TRANSCRIPT_RE.test(f)), "逐假设 transcript"); const g = j(path.join(d, "gates.json")); assert.equal(g.round_id, R1); assert.equal(g.records.length, GATE_COUNT, "9 可跑步门(含 #182 ANCHOR_SWEEP)+G_CLOSE"); From 86f57d2781e5e9a01bb5bde74c42b96283cdaef0 Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:02:37 +0800 Subject: [PATCH 04/10] =?UTF-8?q?#202=20C1:gates.mjs=20=E9=97=A8=E8=AF=BB?= =?UTF-8?q?=E9=9D=A2/inputs=20=E5=A3=B0=E6=98=8E=E5=85=A8=E6=94=B9?= =?UTF-8?q?=E5=B7=A5=E4=BB=B6=E5=90=8D=E8=A1=A8=E5=8F=96=E5=80=BC(~45=20?= =?UTF-8?q?=E5=A4=84=E8=A3=B8=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=B8=85=E9=9B=B6?= =?UTF-8?q?,inputs/reason=20=E5=AD=97=E8=8A=82=E4=B8=8D=E5=8F=98;DEEP=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=97=8F=E9=80=9A=E9=85=8D=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=8D=95=E6=BA=90=20DEEP=5FTRANSCRIPT=5FGLOB);78/0=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/agent/artifacts.mjs | 1 + ops/agent/pipeline/gates.mjs | 95 ++++++++++++++++++------------------ 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/ops/agent/artifacts.mjs b/ops/agent/artifacts.mjs index 776caa4f..84786194 100644 --- a/ops/agent/artifacts.mjs +++ b/ops/agent/artifacts.mjs @@ -37,3 +37,4 @@ export const deepTranscriptName = (slug) => `05-deep-${slug}-transcript.jsonl`; // 参数族枚举/匹配面(gates G_DEEP 巡检、steps deepSeeds 过滤、闭包哨差集判定)。 export const DEEP_ARTIFACT_RE = /^05-deep-.*\.json$/; // 注意:05-deep-verdicts.json 也命中(固定表先行扣除) export const DEEP_TRANSCRIPT_RE = /^05-deep-.*-transcript\.jsonl$/; +export const DEEP_TRANSCRIPT_GLOB = "05-deep-*-transcript.jsonl"; // 参数族通配标签(仅 gates inputs 声明面;非单文件名) diff --git a/ops/agent/pipeline/gates.mjs b/ops/agent/pipeline/gates.mjs index 88862e23..cb0999f2 100644 --- a/ops/agent/pipeline/gates.mjs +++ b/ops/agent/pipeline/gates.mjs @@ -10,6 +10,7 @@ import { resolveField, closureCheck } from "../tools.mjs"; import { normalizeRules, readInFlightItems, inFlightKeys, deepSeeds, RULE_PREDICATES, RULE_ACTIONS } from "./steps.mjs"; import { fullDedupKey } from "./ops-items.mjs"; // 全签同因面(P4 #166;步/门同源) import { parseItem } from "./ops-items.mjs"; // 条目树读回(单一解析语义) +import { ARTIFACTS as A, DEEP_TRANSCRIPT_RE, DEEP_TRANSCRIPT_GLOB } from "../artifacts.mjs"; // 工件名表单源(#202 C1):门读面/inputs 声明禁手抄工件名(磁盘名字节冻结,位等价判例) export const CLOSED_SETS = { cause_class: ["frozen_feed", "dead_endpoint", "auth_wall", "rate_limit", "probe_artifact", "matrix_violation", "unknown"], // C稿 §3.2 草案 1 @@ -30,11 +31,11 @@ const pass = (reason, inputs) => ({ pass: true, reason, inputs }); export const GATES = { // G_PROBE:summary 可解析 ∧ urls_n≥30(现 101,清单事实层 feeds.txt)∧ attempted/urls≥0.9 ∧ TSV 行数=attempted_n(C稿 §2.2) G_PROBE: (ctx) => { - const inputs = ["01-probe-summary.json", "01-probe-freshness.tsv"]; - const s = j(path.join(ctx.dir, "01-probe-summary.json")); - if (!s) return fail("01-probe-summary.json 不可解析", inputs); - const tsv = txt(path.join(ctx.dir, "01-probe-freshness.tsv")); - if (tsv === null) return fail("01-probe-freshness.tsv 缺失", inputs); + const inputs = [A.PROBE_SUMMARY, A.PROBE_FRESHNESS_TSV]; + const s = j(path.join(ctx.dir, A.PROBE_SUMMARY)); + if (!s) return fail(`${A.PROBE_SUMMARY} 不可解析`, inputs); + const tsv = txt(path.join(ctx.dir, A.PROBE_FRESHNESS_TSV)); + if (tsv === null) return fail(`${A.PROBE_FRESHNESS_TSV} 缺失`, inputs); const rows = tsv.split("\n").filter(Boolean).length; const ratio = s.urls_n ? s.attempted_n / s.urls_n : 0; if (!(s.urls_n >= 30)) return fail(`urls_n=${s.urls_n}<30`, inputs); @@ -44,15 +45,15 @@ export const GATES = { }, // G_VERIFY:survivors∪suppressed 的 signal_id = probe 旗标全集(穷尽一条不丢)∧ 每条抑制引用规则 id ∈ 规则文件闭集 G_VERIFY: (ctx) => { - const inputs = ["01-probe-summary.json", "02-verify-survivors.json", "02-verify-suppressed.json", "02-verify-rules.json|rules/suppressions.json", "items/*.md"]; - const s = j(path.join(ctx.dir, "01-probe-summary.json")); - const sv = j(path.join(ctx.dir, "02-verify-survivors.json")); - const sp = j(path.join(ctx.dir, "02-verify-suppressed.json")); + const inputs = [A.PROBE_SUMMARY, A.VERIFY_SURVIVORS, A.VERIFY_SUPPRESSED, `${A.VERIFY_RULES}|rules/suppressions.json`, "items/*.md"]; + const s = j(path.join(ctx.dir, A.PROBE_SUMMARY)); + const sv = j(path.join(ctx.dir, A.VERIFY_SURVIVORS)); + const sp = j(path.join(ctx.dir, A.VERIFY_SUPPRESSED)); if (!s || !sv || !sp) return fail("verify 工件不可解析", inputs); // #164 审 P2-6(#167 收口):门读轮次时点版规则——VERIFY 步落 02-verify-rules.json // 快照;replay --dry(复制原件工件)即审轮次时点版,规则库事后演进不改历史判定。 // 快照缺席(P5 前旧轮/过渡面)回落现行文件,reason 注记版本漂移风险(宁显式勿哑)。 - const snap = path.join(ctx.dir, "02-verify-rules.json"); + const snap = path.join(ctx.dir, A.VERIFY_RULES); const src = fs.existsSync(snap) ? snap : (fs.existsSync(ctx.rulesFile) ? ctx.rulesFile : null); let rules; if (!src) rules = { version: 0, rules: [] }; // 缺席=合法零规则态(步侧/后端同口径;审后修 P1 bootstrap 死锁;坏文件仍终态) @@ -78,10 +79,10 @@ export const GATES = { }, // G_TRIAGE:schema 过 ∧ 闭集 ∧ 假设覆盖全部幸存 signal_id(一条一假设,吞并即门败);幸存集空=门直过 G_TRIAGE: (ctx) => { - const inputs = ["02-verify-survivors.json", "03-triage-hypotheses.json"]; - const sv = j(path.join(ctx.dir, "02-verify-survivors.json")); - const h = j(path.join(ctx.dir, "03-triage-hypotheses.json")); - if (!h || !Array.isArray(h.hypotheses)) return fail("03-triage-hypotheses.json 不可解析/缺 hypotheses 数组", inputs); + const inputs = [A.VERIFY_SURVIVORS, A.TRIAGE_HYPOTHESES]; + const sv = j(path.join(ctx.dir, A.VERIFY_SURVIVORS)); + const h = j(path.join(ctx.dir, A.TRIAGE_HYPOTHESES)); + if (!h || !Array.isArray(h.hypotheses)) return fail(`${A.TRIAGE_HYPOTHESES} 不可解析/缺 hypotheses 数组`, inputs); const svIds = (sv?.survivors ?? []).map((x) => x.signal_id); const hIds = h.hypotheses.map((x) => x.signal_id); if (!eqSet(svIds, hIds)) return fail(`假设不穷尽幸存集:survivors=${svIds.length}≠假设=${hIds.length}(一条一假设,吞并/漏签即门败)`, inputs); @@ -99,10 +100,10 @@ export const GATES = { }, // G_DISPATCH:每个 need_deep 假设有 bundle(或入截尾)∧ 预算合计 ≤ 轮预算门 G_DISPATCH: (ctx) => { - const inputs = ["03-triage-hypotheses.json", "04-dispatch-bundles.json"]; - const h = j(path.join(ctx.dir, "03-triage-hypotheses.json")); - const d = j(path.join(ctx.dir, "04-dispatch-bundles.json")); - if (!d || !Array.isArray(d.bundles)) return fail("04-dispatch-bundles.json 不可解析", inputs); + const inputs = [A.TRIAGE_HYPOTHESES, A.DISPATCH_BUNDLES]; + const h = j(path.join(ctx.dir, A.TRIAGE_HYPOTHESES)); + const d = j(path.join(ctx.dir, A.DISPATCH_BUNDLES)); + if (!d || !Array.isArray(d.bundles)) return fail(`${A.DISPATCH_BUNDLES} 不可解析`, inputs); const need = (h?.hypotheses ?? []).filter((x) => x.need_deep).map((x) => x.signal_id); const got = [...d.bundles.map((b) => b.hid), ...(d.truncated ?? []).map((t) => t.hid)]; if (!eqSet(need, got)) return fail(`need_deep ${need.length} 条≠bundles∪truncated ${got.length}(截尾也须留痕)`, inputs); @@ -112,16 +113,16 @@ export const GATES = { }, // G_DEEP:每假设 verdict ∈ 闭集 ∧ evidence 三元组逐字回引本步登记表(A 一律:call_id+field 选择对,宿主回填值) G_DEEP: (ctx) => { - const inputs = ["04-dispatch-bundles.json", "05-deep-verdicts.json", "05-deep-*-transcript.jsonl"]; - const d = j(path.join(ctx.dir, "04-dispatch-bundles.json")); - const v = j(path.join(ctx.dir, "05-deep-verdicts.json")); - if (!v || !Array.isArray(v.verdicts)) return fail("05-deep-verdicts.json 不可解析", inputs); + const inputs = [A.DISPATCH_BUNDLES, A.DEEP_VERDICTS, DEEP_TRANSCRIPT_GLOB]; + const d = j(path.join(ctx.dir, A.DISPATCH_BUNDLES)); + const v = j(path.join(ctx.dir, A.DEEP_VERDICTS)); + if (!v || !Array.isArray(v.verdicts)) return fail(`${A.DEEP_VERDICTS} 不可解析`, inputs); const need = (d?.bundles ?? []).map((b) => b.hid); const got = [...v.verdicts.map((x) => x.hid), ...(v.degraded ?? []).map((x) => x.hid)]; if (!eqSet(need, got)) return fail(`深验不穷尽 bundle:${need.length}≠${got.length}`, inputs); const reg = deepSeeds(ctx.dir); // 登记表重建:各假设 transcript 的 registry_seed 行(P4 faux 工具面;deepConsume 宿主回填同源单一解析) const seedN = new Map(); // 微探针计数:transcript → seed 行数(≤3/假设,deep.md 预算段机检面) - for (const f of fs.readdirSync(ctx.dir).filter((f) => /^05-deep-.*-transcript\.jsonl$/.test(f))) + for (const f of fs.readdirSync(ctx.dir).filter((f) => DEEP_TRANSCRIPT_RE.test(f))) seedN.set(f, jsonl(path.join(ctx.dir, f)).filter((r) => r.type === "registry_seed").length); for (const [f, n] of seedN) if (n > 3) return fail(`微探针超 3 次/假设:${f} seed=${n}(预算段硬约束)`, inputs); for (const r of reg.values()) if (!CLOSED_SETS.deep_tool.includes(r.tool)) @@ -155,9 +156,9 @@ export const GATES = { // G_ITEMIZE:manifest 计数=rows 对账 ∧ 行穷尽(verdicts∪degraded∪truncated∪未深验)∧ 真条目树 // 读回一致(文件在场/dedup_key 同签/复开链指)∧ 动作/severity 闭集 ∧ refuted 恒 low(三小裁②) G_ITEMIZE: (ctx) => { - const inputs = ["06-itemize-manifest.json", "02-verify-suppressed.json", "ops-items/*.md"]; - const m = j(path.join(ctx.dir, "06-itemize-manifest.json")); - if (!m || !Array.isArray(m.rows)) return fail("06-itemize-manifest.json 不可解析/缺 rows(P4 #166:rows=轮内条目面)", inputs); + const inputs = [A.ITEMIZE_MANIFEST, A.VERIFY_SUPPRESSED, "ops-items/*.md"]; + const m = j(path.join(ctx.dir, A.ITEMIZE_MANIFEST)); + if (!m || !Array.isArray(m.rows)) return fail(`${A.ITEMIZE_MANIFEST} 不可解析/缺 rows(P4 #166:rows=轮内条目面)`, inputs); for (const k of ["created", "reopened", "deduped", "suppressed"]) if (typeof m[k] !== "number") return fail(`manifest.${k} 非数字`, inputs); const tally = { created: 0, reopened: 0, deduped: 0 }; for (const r of m.rows) { @@ -168,10 +169,10 @@ export const GATES = { } if (tally.created !== m.created || tally.reopened !== m.reopened || tally.deduped !== m.deduped) return fail(`计数≠rows 对账:created ${m.created}/${tally.created} reopened ${m.reopened}/${tally.reopened} deduped ${m.deduped}/${tally.deduped}`, inputs); - const hyps = j(path.join(ctx.dir, "03-triage-hypotheses.json"))?.hypotheses ?? []; - const dv = j(path.join(ctx.dir, "05-deep-verdicts.json")) ?? { verdicts: [], degraded: [] }; + const hyps = j(path.join(ctx.dir, A.TRIAGE_HYPOTHESES))?.hypotheses ?? []; + const dv = j(path.join(ctx.dir, A.DEEP_VERDICTS)) ?? { verdicts: [], degraded: [] }; const want = [...(dv.verdicts ?? []).map((x) => x.hid), ...(dv.degraded ?? []).map((x) => x.hid), - ...(j(path.join(ctx.dir, "04-dispatch-bundles.json"))?.truncated ?? []).map((x) => x.hid), + ...(j(path.join(ctx.dir, A.DISPATCH_BUNDLES))?.truncated ?? []).map((x) => x.hid), ...hyps.filter((x) => !x.need_deep).map((x) => x.signal_id)]; if (!eqSet(want, m.rows.map((r) => r.hid))) return fail(`条目行不穷尽:want ${want.length}≠rows ${m.rows.length}(verdicts∪degraded∪truncated∪未深验)`, inputs); const seen = new Set(); @@ -191,17 +192,17 @@ export const GATES = { if (r.outcome === "reopened" && (it.supersedes ?? null) !== (r.supersedes ?? null)) return fail(`复开链指不符:${r.item_id} 树=${it.supersedes} 行=${r.supersedes}`, inputs); if (r.outcome === "created" && it.supersedes && !r.echo) return fail(`created 行携带链指(形状漂移):${r.item_id} supersedes=${it.supersedes}`, inputs); } - const sp = j(path.join(ctx.dir, "02-verify-suppressed.json")); + const sp = j(path.join(ctx.dir, A.VERIFY_SUPPRESSED)); if ((sp?.suppressed ?? []).length !== m.suppressed) return fail(`suppressed=${m.suppressed}≠抑制工件 ${(sp?.suppressed ?? []).length}`, inputs); return pass(`rows=${m.rows.length} created=${m.created} reopened=${m.reopened} deduped=${m.deduped} suppressed=${m.suppressed};真条目树读回同签,复开链指齐,零同轮双开`, inputs); }, // G_SYNTH:三节齐 ∧ 计数与工件逐字一致(四计数+深验三计数,#162 审 P2-5 抽检面)∧ 判例引用闭合(复用 closureCheck) G_SYNTH: (ctx) => { - const inputs = ["07-synth-report.md", "07-synth-transcript.jsonl", "06-itemize-manifest.json", "05-deep-verdicts.json", "04-dispatch-bundles.json"]; - const report = txt(path.join(ctx.dir, "07-synth-report.md")); - const m = j(path.join(ctx.dir, "06-itemize-manifest.json")); - if (report === null) return fail("07-synth-report.md 缺失", inputs); - if (!m) return fail("06-itemize-manifest.json 不可解析", inputs); + const inputs = [A.SYNTH_REPORT, A.SYNTH_TRANSCRIPT, A.ITEMIZE_MANIFEST, A.DEEP_VERDICTS, A.DISPATCH_BUNDLES]; + const report = txt(path.join(ctx.dir, A.SYNTH_REPORT)); + const m = j(path.join(ctx.dir, A.ITEMIZE_MANIFEST)); + if (report === null) return fail(`${A.SYNTH_REPORT} 缺失`, inputs); + if (!m) return fail(`${A.ITEMIZE_MANIFEST} 不可解析`, inputs); for (const sec of ["## 注入判例", "## 异常清单", "## 摘要"]) if (!report.includes(sec)) return fail(`三节缺:${sec}`, inputs); { // 摘要行数门(审后补 P2:提示词声明「≤10 行(门机检)」,门必须真检) const idx = report.indexOf("## 摘要"); @@ -214,15 +215,15 @@ export const GATES = { if (occ.some((n) => n !== m[k])) return fail(`报告计数不符 manifest:${k} 报告=${[...new Set(occ)].join("/")}(工件=${m[k]})`, inputs); } // 深验三计数抽检(#162 审 P2-5):报告数字 = verdicts ∪ degraded(重试尽降级)∪ truncated(预算截尾)回算 - const dv = j(path.join(ctx.dir, "05-deep-verdicts.json")) ?? { verdicts: [], degraded: [] }; - const truth = { confirmed: 0, refuted: 0, unresolved: (dv.degraded ?? []).length + (j(path.join(ctx.dir, "04-dispatch-bundles.json"))?.truncated ?? []).length }; + const dv = j(path.join(ctx.dir, A.DEEP_VERDICTS)) ?? { verdicts: [], degraded: [] }; + const truth = { confirmed: 0, refuted: 0, unresolved: (dv.degraded ?? []).length + (j(path.join(ctx.dir, A.DISPATCH_BUNDLES))?.truncated ?? []).length }; for (const x of dv.verdicts ?? []) if (truth[x.verdict] !== undefined) truth[x.verdict] += 1; for (const k of ["confirmed", "refuted", "unresolved"]) { const occ = [...report.matchAll(new RegExp(`\\b${k}=(\\d+)`, "g"))].map((x) => +x[1]); if (!occ.length) return fail(`报告缺深验计数 ${k}=N(契约:摘要必含深验计数行)`, inputs); if (occ.some((n) => n !== truth[k])) return fail(`深验计数不符工件:${k} 报告=${[...new Set(occ)].join("/")}(回算=${truth[k]};verdicts∪degraded∪truncated)`, inputs); } - const head = jsonl(path.join(ctx.dir, "07-synth-transcript.jsonl")).find((l) => l.type === "input"); + const head = jsonl(path.join(ctx.dir, A.SYNTH_TRANSCRIPT)).find((l) => l.type === "input"); const allowed = new Set((head?.meta?.injected ?? []).map((n) => path.basename(n))); // 闭集=预注入(锚选∪检索命中;P0 无轮中检索工具) try { closureCheck(report, allowed); } catch (e) { return fail(`判例闭合违例:${e.message}`, inputs, "closure_check"); } return pass(`三节齐;四计数+深验三计数与工件逐字一致(confirmed=${truth.confirmed} refuted=${truth.refuted} unresolved=${truth.unresolved});判例引用闭合(注入 ${allowed.size})`, inputs); @@ -234,9 +235,9 @@ export const GATES = { // 工件 anchors 字段即其投影,门验工件内自洽不复制真源(谱系单一真源律)。求值面死走 // 降级旁路(appendGateBypass,RETRO 同款),不进本门。 G_ANCHOR_SWEEP: (ctx) => { - const inputs = ["08-anchor-sweep.json"]; - const s = j(path.join(ctx.dir, "08-anchor-sweep.json")); - if (!s || !Array.isArray(s.anchors) || !Array.isArray(s.results)) return fail("08-anchor-sweep.json 不可解析(缺 anchors/results 数组)", inputs); + const inputs = [A.ANCHOR_SWEEP]; + const s = j(path.join(ctx.dir, A.ANCHOR_SWEEP)); + if (!s || !Array.isArray(s.anchors) || !Array.isArray(s.results)) return fail(`${A.ANCHOR_SWEEP} 不可解析(缺 anchors/results 数组)`, inputs); if (s.anchors.length !== s.results.length) return fail(`anchors=${s.anchors.length}≠results=${s.results.length}(全集投影破)`, inputs); const want = s.anchors.map((a) => `${a[0]}|${a[1]}`).sort().join(","); const got = s.results.map((r) => `${r.ip}|${r.expect}`).sort().join(","); @@ -261,7 +262,7 @@ export const GATES = { G_RETRO: (ctx) => { const key = ctx.retroAuditKey; // replay --dry 审原件;重执行审重放自产 const retroFile = path.join(ctx.opsRoot, "retro", `${key}.json`); - const inputs = [`retro/${key}.json`, "08-anchor-sweep.json(#184 出口③摘要源)", "(ledger)"]; + const inputs = [`retro/${key}.json`, `${A.ANCHOR_SWEEP}(#184 出口③摘要源)`, "(ledger)"]; const r = j(retroFile); if (!r) return fail(`retro/${key}.json 不可解析`, inputs); if (r.round_id !== key) return fail(`retro.round_id=${r.round_id}≠${key}`, inputs); @@ -285,7 +286,7 @@ export const GATES = { if (r.sandbox_assert_pass !== true) return fail(`sandbox_assert rc≠0(${r.sandbox_assert_rc})`, inputs); // #184 出口③机检:08 巡检工件在场(读数或降级信封)→ retro.anchor_sweep 摘要必须在场且与工件回算一致 // (无验证手段不得称出口;降级轮摘要须记 degraded;缺席/不符=门败) - const sweepArt = j(path.join(ctx.dir, "08-anchor-sweep.json")); + const sweepArt = j(path.join(ctx.dir, A.ANCHOR_SWEEP)); if (sweepArt && Array.isArray(sweepArt.results)) { if (!r.anchor_sweep || r.anchor_sweep.status !== "ok" || r.anchor_sweep.n_fail !== sweepArt.n_fail) return fail(`retro.anchor_sweep 摘要缺席/n_fail 不符(工件=${sweepArt.n_fail},retro=${r.anchor_sweep?.n_fail ?? "(缺)"}——出口③机检)`, inputs); @@ -297,7 +298,7 @@ export const GATES = { }, // G_CLOSE:信封单行 JSON 落 stdout(书挡门;重放不入 CLOSE) G_CLOSE: (ctx) => { - const inputs = ["(stdout 轮次信封)", "09-budget.json"]; + const inputs = ["(stdout 轮次信封)", A.BUDGET]; if (!ctx.envelope) return fail("信封未组装", inputs); const s = JSON.stringify(ctx.envelope); if (s.includes("\n")) return fail("信封非单行", inputs); @@ -306,8 +307,8 @@ export const GATES = { // 门只对拍 09-budget.json 读数 ≤ 生效帽(防工件漂移/篡改;非二次执法)。 const BUDGET_ENV_KEYS = ["OPS_PIPELINE_BUDGET_CNY", "OPS_PIPELINE_MAX_MODEL_CALLS", "OPS_PIPELINE_TOKEN_BUDGET", "OPS_PIPELINE_WALL_CLOCK_S", "OPS_PIPELINE_SPEND_CAP_CNY", "OPS_PIPELINE_MONTHLY_CAP_CNY", "OPS_PIPELINE_DUP_CALL_LIMIT"]; - const b = j(path.join(ctx.dir, "09-budget.json")); - if (!b || !b.env || !b.readings) return fail("09-budget.json 缺席/形状坏(env+readings 必在场)", inputs); + const b = j(path.join(ctx.dir, A.BUDGET)); + if (!b || !b.env || !b.readings) return fail(`${A.BUDGET} 缺席/形状坏(env+readings 必在场)`, inputs); const missEnv = BUDGET_ENV_KEYS.filter((k) => !(k in b.env) || !Number.isFinite(b.env[k])); if (missEnv.length) return fail(`预算旋钮生效值缺席:${missEnv.join(",")}`, inputs); const r = b.readings; @@ -356,7 +357,7 @@ export function evalStageGate(def, ctx) { return { stage: def.name, name: def.gate, pass: !!r.pass, reason: r.reason, dur_ms: Math.round(performance.now() - t0), inputs: r.inputs ?? [], ...(r.code ? { code: r.code } : {}) }; // code=门指定终态码(闭集内) } export function appendGate(dir, roundId, rec) { - const p = path.join(dir, "gates.json"); + const p = path.join(dir, A.GATES); const cur = j(p) ?? { round_id: roundId, records: [] }; cur.round_id = roundId; cur.records.push(rec); From 326fd2ff4c5ec658588153415ef4bdbb62c70c2b Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:04:16 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor(#203):=20C2=20=E6=9C=88=E5=BA=A6?= =?UTF-8?q?=E5=87=86=E5=85=A5=E6=8B=92=E8=B7=91=E5=9D=97=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=20budget.mjs=20admitOrRefuse=E2=80=94=E2=80=94=E5=8F=8C?= =?UTF-8?q?=E9=9D=A2(agent.mjs=20runRound/pipeline.mjs=20runPipeline)?= =?UTF-8?q?=E6=8B=92=E8=B7=91=E6=96=87=E6=A1=88/[admission]=20log/?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E7=95=99=E7=97=95=E8=A1=8C=E5=8D=95=E6=BA=90?= =?UTF-8?q?,=E4=B8=A4=E9=9D=A2=E8=A1=8C=E4=B8=BA=E9=80=90=E5=AD=97?= =?UTF-8?q?=E4=B8=8D=E5=8F=98(=E5=87=BA=E5=8F=A3=E5=90=84=E6=98=A0?= =?UTF-8?q?=E5=B0=84:legacy=20throw=20code=E2=86=92=E4=BF=A1=E5=B0=81=20ex?= =?UTF-8?q?it1/pipeline=20emitFail=20=E4=BF=A1=E5=B0=81=20exit1);admitMont?= =?UTF-8?q?hly=20=E5=AE=B9=E5=B7=AE=E5=8F=A3=E5=BE=84=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E9=9B=86=E4=B8=AD=E4=B8=80=E5=A4=84(P2=20=E9=99=84=E6=B3=A8:?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E6=AF=94=E8=BE=83=E6=97=A0=20=CE=B5,+1e-9=20?= =?UTF-8?q?=E7=81=B0=E5=B0=98=E5=AE=B9=E5=B7=AE=E5=85=88=E4=BE=8B=E5=9C=A8?= =?UTF-8?q?=E8=BD=AE=E9=A2=84=E7=AE=97=E9=9D=A2=E4=B8=8D=E5=85=A5=E6=9C=AC?= =?UTF-8?q?=E9=9D=A2);=E6=96=B0=E5=93=A8=20selftest-pipeline=20A2-?= =?UTF-8?q?=E6=8B=92=E8=B7=91=E5=9D=97=E5=8D=95=E6=BA=90=E9=92=89=E6=AD=BB?= =?UTF-8?q?=E6=96=87=E6=A1=88/log/=E5=8F=B0=E8=B4=A6=E8=A1=8C=E9=80=90?= =?UTF-8?q?=E5=AD=97=E5=9F=BA=E7=BA=BF=E3=80=82=E7=BA=A2=E8=AF=81:?= =?UTF-8?q?=E6=9C=AA=E5=AE=9E=E7=8E=B0=E6=9C=9F=2078/1(admitOrRefuse=20is?= =?UTF-8?q?=20not=20a=20function);=E5=AE=B9=E5=B7=AE=E7=BF=BB=E8=BD=AC=20 (opts.stderr ?? process.stderr).write(a.join(" ") + "\n"); // #199 A2 月度 kill switch 准入(与 pipeline.mjs runPipeline 同面共享 budget.mjs,双面同裁决): // 先于模型装载/工具构建——拒跑零花费。est=OPS_PIPELINE_BUDGET_CNY(与 mkCtx 同读同校验)。 + // #203 C2:拒跑块(文案/[admission] log/台账留痕行)收口 budget.mjs admitOrRefuse 留痕外壳 + // (与 pipeline 面同壳同文案);本面只映射出口=throw {code}(main 兜底信封 exit 1)。 const budgetKnobs = budget.readBudgetKnobs(childEnv); const estRaw = childEnv.OPS_PIPELINE_BUDGET_CNY ?? process.env.OPS_PIPELINE_BUDGET_CNY ?? "0.05"; const estCny = +estRaw; if (!Number.isFinite(estCny) || estCny < 0) throw Object.assign(new Error(`OPS_PIPELINE_BUDGET_CNY 须为非负数,得到:${estRaw}`), { usage: true }); const ledgerFile = childEnv.LEDGER_FILE ?? process.env.LEDGER_FILE ?? path.join(DOCS_ROOT, "ops", "ledger.md"); - const admission = await budget.monthlyAdmission({ ledgerFile, capCny: budgetKnobs.monthlyCapCny, estCny }); - if (!admission.ok) { - log(`[admission] budget_monthly refused month=${admission.month} spend=${admission.spend} est=${admission.est} cap=${admission.cap} skipped=${admission.skipped}`); - try { await budget.appendRefusalTrace(ledgerFile, { est: estCny, cap: budgetKnobs.monthlyCapCny }, childEnv); } - catch (e) { log(`[admission] 拒跑留痕行写败(不翻转裁决):${e.message}`); } - throw Object.assign(new Error(`月度预算拒跑:当月已花 ¥${admission.spend} + 本轮预算 ¥${admission.est} > 月帽 ¥${admission.cap}(OPS_PIPELINE_MONTHLY_CAP_CNY;当月 ${admission.counted} 行计入,${admission.skipped} 行缺 cost 跳过不猜)`), { code: "budget_monthly" }); - } + const admission = await budget.admitOrRefuse({ ledgerFile, capCny: budgetKnobs.monthlyCapCny, estCny, env: childEnv, log }); + if (admission.refused) throw Object.assign(new Error(admission.message), { code: admission.code }); const host = new ToolHost(round); const { models, resolve } = opts.models ?? await loadModels(); for (const seed of opts.registrySeed ?? []) host.registry.register(seed.id, seed.tool, seed.sc); diff --git a/ops/agent/pipeline.mjs b/ops/agent/pipeline.mjs index 550b8d83..81965095 100644 --- a/ops/agent/pipeline.mjs +++ b/ops/agent/pipeline.mjs @@ -335,14 +335,11 @@ export async function runPipeline(opts = {}) { io.stdout.write(JSON.stringify(envelope) + "\n"); return { ok: false, envelope, roundId: ctx.roundId, dir: ctx.dir }; }; // #199 A2 月度 kill switch 准入:mkCtx 后、建轮目录前(拒跑零侧写:无目录无 state); // 读数入 state(A5 CLOSE 工件面)。est=本轮预算(BUDGET_CNY 准告警线上界,宁少算口径)。 - const admission = await budget.monthlyAdmission({ ledgerFile: ctx.ledgerFile, capCny: ctx.budgetKnobs.monthlyCapCny, estCny: ctx.budgetCny }); - if (!admission.ok) { - ctx.log(`[admission] budget_monthly refused month=${admission.month} spend=${admission.spend} est=${admission.est} cap=${admission.cap} skipped=${admission.skipped}`); - try { await budget.appendRefusalTrace(ctx.ledgerFile, { est: ctx.budgetCny, cap: ctx.budgetKnobs.monthlyCapCny }, io.env); } - catch (e) { ctx.log(`[admission] 拒跑留痕行写败(不翻转裁决):${e.message}`); } // 台账写败不吞拒跑:宁拒不跑 - return emitFail("budget_monthly", `月度预算拒跑:当月已花 ¥${admission.spend} + 本轮预算 ¥${admission.est} > 月帽 ¥${admission.cap}(OPS_PIPELINE_MONTHLY_CAP_CNY;当月 ${admission.counted} 行计入,${admission.skipped} 行缺 cost 跳过不猜)`); - } - ctx.state.monthly_admission = admission; // A5 CLOSE 工件读数面(观察非裁决) + // #203 C2:拒跑块(文案/[admission] log/台账留痕行)收口 budget.mjs admitOrRefuse 留痕外壳 + // (与 agent.mjs legacy 面同壳同文案);本面只映射出口=emitFail 信封(exit 1)。 + const admission = await budget.admitOrRefuse({ ledgerFile: ctx.ledgerFile, capCny: ctx.budgetKnobs.monthlyCapCny, estCny: ctx.budgetCny, env: io.env, log: ctx.log }); + if (admission.refused) return emitFail(admission.code, admission.message); + ctx.state.monthly_admission = admission.admission; // A5 CLOSE 工件读数面(观察非裁决) if (fs.existsSync(path.join(ctx.dir, "state.json"))) return emitFail("bad_round", `rounds/${ctx.roundId} 已存在(同 id 重跑走 replay)`); fs.mkdirSync(ctx.dir, { recursive: true }); writeState(ctx); // 轮次目录 + state.json 建立 diff --git a/ops/agent/pipeline/budget.mjs b/ops/agent/pipeline/budget.mjs index 75933155..b1a139db 100644 --- a/ops/agent/pipeline/budget.mjs +++ b/ops/agent/pipeline/budget.mjs @@ -82,6 +82,9 @@ export function monthSpendOfLedgerText(text, month) { } // 纯函数:准入裁决(等号=帽内放行;est=本轮预算上界,宁少算口径)。 +// 容差口径唯一真源集中此一处(#203 C2 P2 附注):现行=精确比较无 ε(spend+est>cap 才拒, +// spec 钉「等号放行」);轮预算面 gates.mjs/steps.mjs 的 +1e-9 灰尘容差是其各自面的先例, +// 不入本面;若将来月帽要加 ε,只许加在此处(admitOrRefuse 外壳与双面不得另立容差)。 export const admitMonthly = ({ spendCny, estCny, capCny }) => { const projected = spendCny + estCny; return { ok: projected <= capCny, projected }; @@ -112,6 +115,28 @@ export async function appendRefusalTrace(ledgerFile, { est, cap }, env = process `budget_monthly refused est=${est} cap=${cap}`], { timeout: 120_000, maxBuffer: 32 * 1024 * 1024, env: { ...env, LEDGER_FILE: ledgerFile } }); } +// —— 拒跑块单源(#203 C2):admitOrRefuse 留痕外壳 + refusalOf 纯文案 —— +// 收口判例:拒跑块(文案/台账留痕行/[admission] log)曾双面逐字复制(agent.mjs runRound ↔ +// pipeline.mjs runPipeline,#200 Spec 轴核),漂移靠人记;单源后两面只映射出口——agent 面 +// throw {code}(main 兜底信封 exit 1)、pipeline 面 emitFail(code, message)(信封 exit 1), +// 出口形状不属本壳(信封纪律:两面行为/日志/台账行/exit code 逐字不变)。 +// 纯函数面:裁决真源仍是 admitMonthly(容差语义见其注);refusalOf 只做文案单源。 +export const refusalOf = (a) => ({ + code: "budget_monthly", + message: `月度预算拒跑:当月已花 ¥${a.spend} + 本轮预算 ¥${a.est} > 月帽 ¥${a.cap}(OPS_PIPELINE_MONTHLY_CAP_CNY;当月 ${a.counted} 行计入,${a.skipped} 行缺 cost 跳过不猜)`, +}); +// 留痕外壳:monthlyAdmission 读→裁;拒跑则 [admission] log + 台账留痕行(写败只 log 不 +// 翻转裁决——宁拒不跑),返回 {refused:true, code, message};放行返回 {refused:false, +// admission}(读数对象原样回传,state.monthly_admission/09-budget.json 观察面字节不变)。 +export async function admitOrRefuse({ ledgerFile, capCny, estCny, env, log }) { + const admission = await monthlyAdmission({ ledgerFile, capCny, estCny }); + if (admission.ok) return { refused: false, admission }; + log(`[admission] budget_monthly refused month=${admission.month} spend=${admission.spend} est=${admission.est} cap=${admission.cap} skipped=${admission.skipped}`); + try { await appendRefusalTrace(ledgerFile, { est: estCny, cap: capCny }, env); } + catch (e) { log(`[admission] 拒跑留痕行写败(不翻转裁决):${e.message}`); } // 台账写败不吞拒跑:宁拒不跑 + return { refused: true, ...refusalOf(admission) }; +} + // (调用哈希面见 A4 段——同文件追加,不另起炉灶) import { createHash } from "node:crypto"; diff --git a/ops/agent/selftest-pipeline.mjs b/ops/agent/selftest-pipeline.mjs index 133a6cec..0bcd98a4 100644 --- a/ops/agent/selftest-pipeline.mjs +++ b/ops/agent/selftest-pipeline.mjs @@ -1815,6 +1815,27 @@ await t("A2-双面共享:legacy 面 runRound 同一拒跑码+台账留痕(准入 assert.ok(led.includes("budget_monthly refused est=0.05 cap=0.05"), `legacy 面同留痕:${led}`); }); +await t("A2-拒跑块单源(#203 C2):admitOrRefuse 外壳——文案/[admission] log/台账行逐字钉死+放行面原样回传", async () => { + const { admitOrRefuse, refusalOf, monthNow } = await import(path.join(AGENT, "pipeline/budget.mjs")); + const tree = mkTree("budget-admit-shell"); + const m = monthNow(); + fs.mkdirSync(path.dirname(tree.env.LEDGER_FILE), { recursive: true }); + fs.writeFileSync(tree.env.LEDGER_FILE, `# 运营轮次台账\n\n| 日期 | 轮次 | model | 用量 | 备注 |\n|---|---|---|---|---|\n| ${m}-01 | health | pipeline-real | in=1 out=1 total=2 cost=0.04 | pipeline:old |\n`); + const logs = []; + const r = await admitOrRefuse({ ledgerFile: tree.env.LEDGER_FILE, capCny: 0.05, estCny: 0.05, env: tree.env, log: (l) => logs.push(l) }); + assert.equal(r.refused, true); + assert.equal(r.code, "budget_monthly"); + assert.equal(r.message, "月度预算拒跑:当月已花 ¥0.04 + 本轮预算 ¥0.05 > 月帽 ¥0.05(OPS_PIPELINE_MONTHLY_CAP_CNY;当月 1 行计入,0 行缺 cost 跳过不猜)", "拒跑文案=收口前双面逐字基线(改文案=改契约,须过票)"); + assert.deepEqual(logs, [`[admission] budget_monthly refused month=${m} spend=0.04 est=0.05 cap=0.05 skipped=0`], "[admission] log 行单源逐字"); + const led = fs.readFileSync(tree.env.LEDGER_FILE, "utf8"); + assert.ok(led.includes("budget_monthly refused est=0.05 cap=0.05"), `台账留痕行:${led}`); + const ok = await admitOrRefuse({ ledgerFile: tree.env.LEDGER_FILE, capCny: 1, estCny: 0.05, env: tree.env, log: (l) => logs.push(l) }); + assert.equal(ok.refused, false, "帽内放行"); + assert.equal(ok.admission.spend, 0.04, "读数原样回传(state.monthly_admission/09-budget.json 面字节不变)"); + assert.equal(logs.length, 1, "放行零日志(拒跑才留痕)"); + assert.equal(refusalOf({ spend: 0.04, est: 0.05, cap: 0.05, counted: 1, skipped: 0 }).code, "budget_monthly", "纯文案函数 code 面"); +}); + // ── A4(#199):重复调用哈希 ── await t("A4-纯函数:canonical 键序无关+dupKey 同参同键/异参异键+计数第 limit 次拦/limit=0 停用+dupTop 排序", async () => { const { canonicalJson, dupKey, dupRecord, dupTop } = await import(path.join(AGENT, "pipeline/budget.mjs")); From 48826c9ce847eee92565880cd92a50d83dedfb82 Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:05:38 +0800 Subject: [PATCH 06/10] =?UTF-8?q?#202=20C1:steps.mjs=20=E5=86=99=E5=87=BA?= =?UTF-8?q?=E9=9D=A2/=E8=AF=BB=E9=9D=A2=E5=85=A8=E6=94=B9=E5=B7=A5?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E8=A1=A8=E5=8F=96=E5=80=BC(~31=20=E5=A4=84?= =?UTF-8?q?=E8=A3=B8=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=B8=85=E9=9B=B6;DEEP=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=97=8F=E8=B5=B0=20deepArtifactName/deepTra?= =?UTF-8?q?nscriptName=20=E5=B7=A5=E5=8E=82,=E7=A3=81=E7=9B=98=E5=90=8D?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E4=B8=8D=E5=8F=98);78/0=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/agent/pipeline/steps.mjs | 89 ++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/ops/agent/pipeline/steps.mjs b/ops/agent/pipeline/steps.mjs index 95ca9170..449f51b0 100644 --- a/ops/agent/pipeline/steps.mjs +++ b/ops/agent/pipeline/steps.mjs @@ -18,6 +18,7 @@ import { runFindingExits } from "./finding-exit.mjs"; // #184 finding 出口三 import * as budget from "./budget.mjs"; // #199 A5:09-budget.json 读数面(零环:budget 只依赖 node 内置) import { mkBreaker, readBreakerKnobs } from "../tools/breaker.mjs"; // #199 B:breaker 观察快照(只读零写;零环:breaker 只依赖 node 内置+lib.sh) import { itemSignature, fullDedupKey, createOpsItem, parseItemsTree, CAUSE_GROUP_BY_BUCKET } from "./ops-items.mjs"; // 条目库桥(P4 #166:接 _ops_items 同空间) +import { ARTIFACTS as A, deepArtifactName, deepTranscriptName, DEEP_TRANSCRIPT_RE } from "../artifacts.mjs"; // 工件名表单源(#202 C1):步写出/读面禁手抄工件名(磁盘名字节冻结,位等价判例) const execFileP = promisify(execFileCb); const AGENT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -99,14 +100,14 @@ async function PROBE(def, ctx) { const srcOf = new Map(pairs); const urls = [...new Set(pairs.map(([u]) => u))].sort(); const tsv = (await sh("feed_freshness", urls, 1200_000, ctx.env)).stdout; - fs.writeFileSync(path.join(ctx.dir, "01-probe-freshness.tsv"), tsv); + fs.writeFileSync(path.join(ctx.dir, A.PROBE_FRESHNESS_TSV), tsv); // matrix(P1,#163)=真扫描单源 tools.mjs matrixScanCli(legacy health_snapshot 同路径同解析;位等价门=同 stub // 下两腿产物逐字节同文,自测 selftest-pipeline 位等价例)。产物路径对照:legacy=-matrix-scan.json // (artifactPath ts-slug 族,ops/artifacts/);管线=01-probe-matrix.json(§4.1 序号前缀族)。 // 失败=嵌套信封不阻轮(C稿 §2.2,legacy 同语义);差异仅落盘面:legacy 失败不落件只回 sc 嵌套信封, // 管线按 §4.1 工件契约落信封进件(证据不灭失)。 const m = await matrixScanCli(); - fs.writeFileSync(path.join(ctx.dir, "01-probe-matrix.json"), m.ok ? matrixArtifactText(m.d) : JSON.stringify(m.env, null, 1)); + fs.writeFileSync(path.join(ctx.dir, A.PROBE_MATRIX), m.ok ? matrixArtifactText(m.d) : JSON.stringify(m.env, null, 1)); const matrix = m.ok ? { ok: true, exit_code: m.d.exit_code, counts: m.d.counts } : m.env; const buckets = { fresh: 0, frozen: 0, unreachable: 0, token_required: 0, no_lm: 0 }; const flags = []; @@ -123,7 +124,7 @@ async function PROBE(def, ctx) { ...(bucket === "unreachable" && fam ? { error_family: fam } : {}) }); // http_/net_fail(lib.sh _ff_probe 四列,P2 消费) } } - writeJson(path.join(ctx.dir, "01-probe-summary.json"), { ok: true, round_id: ctx.roundId, urls_n: urls.length, + writeJson(path.join(ctx.dir, A.PROBE_SUMMARY), { ok: true, round_id: ctx.roundId, urls_n: urls.length, attempted_n: tsv.split("\n").filter(Boolean).length, buckets, flags, matrix: { ok: matrix.ok === true, exit_code: matrix.exit_code ?? null, violation_n: matrix.counts?.violation ?? null } }); } @@ -140,16 +141,16 @@ function ruleMatch(match, f) { return true; } async function VERIFY(def, ctx) { - const summary = j(path.join(ctx.dir, "01-probe-summary.json")); - if (!summary) throw infra("01-probe-summary.json 不可解析"); + const summary = j(path.join(ctx.dir, A.PROBE_SUMMARY)); + if (!summary) throw infra(`${A.PROBE_SUMMARY} 不可解析`); const rulesExists = fs.existsSync(ctx.rulesFile); const raw = rulesExists ? j(ctx.rulesFile) : null; if (rulesExists && !raw) throw infra(`抑制规则库不可解析:${ctx.rulesFile}(规则零命中=空集照常过;文件坏=终态)`); const rules = raw ? normalizeRules(raw) : { version: 0, rules: [] }; // 缺席=合法零规则态(与 _ops_review 零规则起步同口径;审后修 P1:缺席 infra 会致生产 bootstrap 死锁——init_ops_tree 不建此文件,首轮 VERIFY 必死则 adopt_rule 永不可达) // 轮次时点版规则快照(#164 审 P2-6 收口,#167):replay --dry 审计读快照而非现行文件 // ——规则库事后演进(采纳/删除)不得改写历史轮的门判定;快照=工件面证据不灭失。 - if (rulesExists) fs.copyFileSync(ctx.rulesFile, path.join(ctx.dir, "02-verify-rules.json")); - else writeJson(path.join(ctx.dir, "02-verify-rules.json"), { version: 0, rules: [] }); // 缺席时快照仍落空库形(门读快照单一口径,不留「快照缺席」分叉) + if (rulesExists) fs.copyFileSync(ctx.rulesFile, path.join(ctx.dir, A.VERIFY_RULES)); + else writeJson(path.join(ctx.dir, A.VERIFY_RULES), { version: 0, rules: [] }); // 缺席时快照仍落空库形(门读快照单一口径,不留「快照缺席」分叉) const flight = inFlightKeys(readInFlightItems(ctx.itemsDir, ctx.state?.replay_of)); // 同因在飞不重问:全签(source|probe_class|error_signature,P4 #166);replay 排除原轮自己的产物(回声防线) const survivors = [], suppressed = []; for (const f of summary.flags) { @@ -164,19 +165,19 @@ async function VERIFY(def, ctx) { } // known_noise_digest:已知误判模式机器格式(C稿 §3.5——TRIAGE 不注入判例全文,只吃本 digest; // 含 review_at 供 RETRO P5 到点复审)。 - writeJson(path.join(ctx.dir, "02-verify-survivors.json"), { survivors, + writeJson(path.join(ctx.dir, A.VERIFY_SURVIVORS), { survivors, known_noise_digest: rules.rules.map(({ id, action, match, note, review_at }) => ({ id, action, match, note, review_at })), rules_version: rules.version, in_flight_n: flight.size }); - writeJson(path.join(ctx.dir, "02-verify-suppressed.json"), { suppressed, rules_version: rules.version }); + writeJson(path.join(ctx.dir, A.VERIFY_SUPPRESSED), { suppressed, rules_version: rules.version }); } async function DISPATCH(def, ctx) { - const h = j(path.join(ctx.dir, "03-triage-hypotheses.json")) ?? { hypotheses: [] }; + const h = j(path.join(ctx.dir, A.TRIAGE_HYPOTHESES)) ?? { hypotheses: [] }; const sorted = [...(h.hypotheses ?? []).filter((x) => x.need_deep)] .sort((a, b) => (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9)); // 每假设新上下文的 bundle 装配面(C稿 §2.4-1):hid/假设全文/≤3 相关判例命中(锚域词表×该 // 假设旗标确定性选锚,缺件锄宁少算不注入)/预算余额;TRIAGE→DEEP 隔真脚本步,门永远有件可查。 - const surv = new Map((j(path.join(ctx.dir, "02-verify-survivors.json"))?.survivors ?? []).map((s) => [s.signal_id, s])); + const surv = new Map((j(path.join(ctx.dir, A.VERIFY_SURVIVORS))?.survivors ?? []).map((s) => [s.signal_id, s])); const anchors = loadAnchors(); const precDir = ctx.env.PRECEDENTS_DIR ?? path.join(ctx.env.DOCS_ROOT ?? process.env.DOCS_ROOT ?? AGENT_DIR, "ops", "precedents"); const hitsOf = (x) => { @@ -191,7 +192,7 @@ async function DISPATCH(def, ctx) { } const bundles = sorted.map((x) => ({ hid: x.signal_id, budget_cny: Math.floor((ctx.budgetCny / sorted.length) * 1e4) / 1e4, // 审后修 P1-1:toFixed 四舍五入 n=3 时各 0.0167 合计 0.0501>0.05 必砸 G_DISPATCH;改 floor 下取 prompt_pkg: { hypothesis: x.hypothesis, cause_class: x.cause_class, severity: x.severity, probe_hint: x.probe_hint, url: x.signal_id, precedent_hits: hitsOf(x) } })); - writeJson(path.join(ctx.dir, "04-dispatch-bundles.json"), { budget_cny: ctx.budgetCny, est_per_hyp_cny: EST_PER_HYP_CNY, max_hypotheses: MAX_DEEP_HYPOTHESES, + writeJson(path.join(ctx.dir, A.DISPATCH_BUNDLES), { budget_cny: ctx.budgetCny, est_per_hyp_cny: EST_PER_HYP_CNY, max_hypotheses: MAX_DEEP_HYPOTHESES, bundles, truncated: truncated.map(({ x, reason }) => ({ hid: x.signal_id, severity: x.severity, reason })), degraded: truncated.length > 0 }); return truncated.length ? { degraded: `dispatch_truncate:${truncated.length} 条按 severity 截尾(${truncated.filter((t) => t.reason === "unresolved(budget)").length} 预算/${truncated.filter((t) => t.reason === "unresolved(dispatch_cap)").length} 上限;预算 ${ctx.budgetCny}¥/上限 ${MAX_DEEP_HYPOTHESES})` } : undefined; @@ -205,11 +206,11 @@ const IMPACT_BY_BUCKET = { frozen: (f) => `${f.source} 情报源冻结(Last-Modified ${f.age_days} 天无更新),威胁情报新鲜度受损`, unreachable: (f) => `${f.source} 情报源不可达(${f.error_family ?? "未知族"}),威胁覆盖面收缩`, }; async function ITEMIZE(def, ctx) { - const hyps = j(path.join(ctx.dir, "03-triage-hypotheses.json"))?.hypotheses ?? []; - const verdictsFile = j(path.join(ctx.dir, "05-deep-verdicts.json")) ?? { verdicts: [], degraded: [] }; + const hyps = j(path.join(ctx.dir, A.TRIAGE_HYPOTHESES))?.hypotheses ?? []; + const verdictsFile = j(path.join(ctx.dir, A.DEEP_VERDICTS)) ?? { verdicts: [], degraded: [] }; const verdicts = verdictsFile.verdicts, degradedVerdicts = verdictsFile.degraded; - const truncated = j(path.join(ctx.dir, "04-dispatch-bundles.json"))?.truncated ?? []; - const surv = new Map((j(path.join(ctx.dir, "02-verify-survivors.json"))?.survivors ?? []).map((s) => [s.signal_id, s])); + const truncated = j(path.join(ctx.dir, A.DISPATCH_BUNDLES))?.truncated ?? []; + const surv = new Map((j(path.join(ctx.dir, A.VERIFY_SURVIVORS))?.survivors ?? []).map((s) => [s.signal_id, s])); const rows = []; for (const v of verdicts) rows.push({ hid: v.hid, hyp: hyps.find((h) => h.signal_id === v.hid) ?? {}, outcome: v.verdict, one_line: v.one_line, deep_evidence: v.evidence ?? [] }); for (const v of degradedVerdicts) rows.push({ hid: v.hid, hyp: hyps.find((h) => h.signal_id === v.hid) ?? {}, outcome: "unresolved", one_line: v.one_line, deep_evidence: [] }); // 重试尽降级 unresolved 同入清单(证据不灭失) @@ -217,7 +218,7 @@ async function ITEMIZE(def, ctx) { for (const h of hyps.filter((x) => !x.need_deep)) rows.push({ hid: h.signal_id, hyp: h, outcome: "watch", one_line: h.one_line, deep_evidence: [] }); // 未深验假设直入清单 // replay 确定性:原轮 manifest 在场时逐 hid 回声原 outcome(审计重放不改生产条目面); // 缺席(原轮 ITEMIZE 半程崩溃重放)走桥内文件回声救回部分写入。 - const origByHid = new Map((ctx.state?.replay_of ? j(path.join(ctx.opsRoot, "rounds", ctx.state.replay_of, "06-itemize-manifest.json"))?.rows ?? [] : []).map((r) => [r.hid, r])); + const origByHid = new Map((ctx.state?.replay_of ? j(path.join(ctx.opsRoot, "rounds", ctx.state.replay_of, A.ITEMIZE_MANIFEST))?.rows ?? [] : []).map((r) => [r.hid, r])); let created = 0, reopened = 0, deduped = 0; const out = []; for (const r of rows) { @@ -243,8 +244,8 @@ async function ITEMIZE(def, ctx) { one_line: r.one_line ?? r.hyp.one_line ?? "", dedup_key: { source: f.source, ...sig }, item_id: res.item, outcome: res.outcome, supersedes: res.supersedes, ...(res.echo ? { echo: true } : {}) }); } - const suppressed = j(path.join(ctx.dir, "02-verify-suppressed.json"))?.suppressed?.length ?? 0; - writeJson(path.join(ctx.dir, "06-itemize-manifest.json"), { created, reopened, deduped, suppressed, rows: out }); + const suppressed = j(path.join(ctx.dir, A.VERIFY_SUPPRESSED))?.suppressed?.length ?? 0; + writeJson(path.join(ctx.dir, A.ITEMIZE_MANIFEST), { created, reopened, deduped, suppressed, rows: out }); } // ── RETRO 候选推导(P5 #167;C稿 §2.2/§6-6)───────────────────────────────── @@ -325,15 +326,15 @@ async function RETRO(def, ctx) { catch (e) { throw Object.assign(new Error("sandbox_assert rc≠0(backend/frontend 污染)"), { code: "sandbox_violation" }); } let degraded; try { // 候选产出失败=降级(跳过 retro,轮仍成功);台账/sandbox 失败已先行终态(C稿 §2.2 语义序) - const s = j(path.join(ctx.dir, "01-probe-summary.json")); - const survivors = j(path.join(ctx.dir, "02-verify-survivors.json"))?.survivors ?? []; - const suppressedF = j(path.join(ctx.dir, "02-verify-suppressed.json")); + const s = j(path.join(ctx.dir, A.PROBE_SUMMARY)); + const survivors = j(path.join(ctx.dir, A.VERIFY_SURVIVORS))?.survivors ?? []; + const suppressedF = j(path.join(ctx.dir, A.VERIFY_SUPPRESSED)); const rawRules = j(ctx.rulesFile); const rules = rawRules ? normalizeRules(rawRules) : { version: 0, rules: [] }; // 步侧已过门,坏文件不会到这;缺席=零规则照常 const items = parseItemsTree(ctx.itemsDir); // #184 出口③:finding 计入 RETRO 摘要工件(读 08 工件只计数零出口动作;G_RETRO 机检与工件回算一致)。 // 降级轮(08=失败信封)记 status=degraded(诚实面:求值面死非零 finding);resume 旧轮防御:出口记录缺席则补初始化。 - const sweepArt = j(path.join(ctx.dir, "08-anchor-sweep.json")); + const sweepArt = j(path.join(ctx.dir, A.ANCHOR_SWEEP)); const anchorSweep = sweepArt && Array.isArray(sweepArt.results) ? { status: "ok", n_total: sweepArt.results.length, n_fail: sweepArt.n_fail, findings: sweepArt.results.filter((x) => !x.ok).map(({ ip, expect, verdict, confidence, accusers }) => ({ ip, expect, verdict, confidence, accusers })) } @@ -358,7 +359,7 @@ async function RETRO(def, ctx) { fs.mkdirSync(path.dirname(ctx.retroFile), { recursive: true }); writeJson(ctx.retroFile, { round_id: ctx.roundId, generated_at: new Date().toISOString(), flags_n: (s?.flags ?? []).length, survivors_n: survivors.length, suppressed_n: suppressedF?.suppressed?.length ?? 0, - verdicts_n: j(path.join(ctx.dir, "05-deep-verdicts.json"))?.verdicts?.length ?? 0, + verdicts_n: j(path.join(ctx.dir, A.DEEP_VERDICTS))?.verdicts?.length ?? 0, anchor_sweep: anchorSweep, // #184 出口③:巡检 finding 摘要行(G_RETRO 机检:与 08 工件回算一致) rules_version: rules.version, ...diff, // candidates(P5 #167:机械误判→adopt_rule 候选,仅产条目永不自动生效)/rules_due_review/rule_hits/channel_conflicts @@ -381,7 +382,7 @@ async function CLOSE(def, ctx) { // 书挡步:存在性校验(闭合双层的外 file: path.join(ctx.opsRoot ?? path.dirname(ctx.dir), "breaker.json"), env: ctx.env ?? {}, round: ctx.roundId ?? "health", knobs: ctx.breakerKnobs ?? readBreakerKnobs(ctx.env ?? {}), }); - writeJson(path.join(ctx.dir, "09-budget.json"), { + writeJson(path.join(ctx.dir, A.BUDGET), { round_id: ctx.roundId, generated_at: new Date().toISOString(), env: { // 生效值(含 OPS_PIPELINE_BUDGET_CNY:语义=DISPATCH 准告警线,真执法=A1 四界) @@ -402,7 +403,7 @@ async function CLOSE(def, ctx) { // 书挡步:存在性校验(闭合双层的外 breaker: breakerFace.snapshot(), // #199 B:五通道闭集态+生效旋钮(观察不裁决,G_CLOSE 不对拍帽——无帽可拍) }, }); - const report = path.join(ctx.dir, "07-synth-report.md"); + const report = path.join(ctx.dir, A.SYNTH_REPORT); ctx.pkg = fs.existsSync(report) ? report : null; if (!ctx.pkg) return; try { @@ -420,7 +421,7 @@ async function CLOSE(def, ctx) { // 书挡步:存在性校验(闭合双层的外 // replay 恒零外发),RETRO 摘要行由 RETRO 步读本工件计数(出口③)。 async function ANCHOR_SWEEP(def, ctx) { const r = await anchorSweepCli(); - fs.writeFileSync(path.join(ctx.dir, "08-anchor-sweep.json"), + fs.writeFileSync(path.join(ctx.dir, A.ANCHOR_SWEEP), r.ok ? `${JSON.stringify(r.d, null, 1)}\n` : `${JSON.stringify(r.env, null, 1)}\n`); ctx.state.finding_exits = await runFindingExits({ roundId: ctx.roundId, sweepD: r.ok ? r.d : null, dir: ctx.dir, env: ctx.env, replayOf: ctx.state?.replay_of ?? null }); // #184:信封 pipeline.finding_exits 留痕(skip/failed 同合法态) @@ -434,7 +435,7 @@ async function ANCHOR_SWEEP(def, ctx) { const GOLDEN_BUNDLE = { hid: "golden-track-canned", golden: true, budget_cny: 0, prompt_pkg: { hypothesis: "金轨迹保活 canned 假设:若冻结读数为真,HEAD 复测 age_days 应保持不变", cause_class: "frozen_feed", severity: "low", probe_hint: "reprobe_get", url: "(canned)", precedent_hits: [] } }; function triageInput(ctx) { - const sv = j(path.join(ctx.dir, "02-verify-survivors.json")); + const sv = j(path.join(ctx.dir, A.VERIFY_SURVIVORS)); const rows = (sv?.survivors ?? []).map(({ signal_id, source, bucket, age_days, url, error_family, annotated_rule }) => ({ signal_id, source, bucket, ...(age_days != null ? { age_days } : {}), url, ...(error_family ? { error_family } : {}), ...(annotated_rule ? { annotated_rule } : {}) })); @@ -446,18 +447,18 @@ function triageConsume(ctx, _item, { text }) { let h; try { h = JSON.parse(text); } catch { throw Object.assign(new Error("TRIAGE 输出非 JSON(schema 违例)"), { code: "model_face" }); } if (!h || !Array.isArray(h.hypotheses)) throw Object.assign(new Error("TRIAGE 输出缺 hypotheses 数组"), { code: "model_face" }); - writeJson(path.join(ctx.dir, "03-triage-hypotheses.json"), h); + writeJson(path.join(ctx.dir, A.TRIAGE_HYPOTHESES), h); } const deepItems = (ctx) => { - const bundles = j(path.join(ctx.dir, "04-dispatch-bundles.json"))?.bundles ?? []; + const bundles = j(path.join(ctx.dir, A.DISPATCH_BUNDLES))?.bundles ?? []; return bundles.length ? bundles : [GOLDEN_BUNDLE]; // 零 bundle=金轨迹回路(#162 审 P2-2,裁定①) }; // DEEP 登记表重建(P4 #166):逐假设 transcript 的 registry_seed 行 → Map(call_id → {tool, sc}); // 宿主回填(G_DEEP 写前语义门同源重算)与门重算共用本面(单一解析语义,不另起炉灶)。 export function deepSeeds(dir, hid = null) { const m = new Map(); - const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^05-deep-.*-transcript\.jsonl$/.test(f) - && (hid === null || f === `05-deep-${hidSlug(hid)}-transcript.jsonl`)) : []; + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => DEEP_TRANSCRIPT_RE.test(f) + && (hid === null || f === deepTranscriptName(hidSlug(hid)))) : []; for (const f of files) for (const line of fs.readFileSync(path.join(dir, f), "utf8").split("\n").filter(Boolean)) { let r; try { r = JSON.parse(line); } catch { continue; } @@ -483,23 +484,23 @@ function deepConsume(ctx, item, { text }) { const seeds = deepSeeds(ctx.dir, item.hid); const evidence = (v.evidence ?? []).map((e) => ({ call_id: e.call_id, field: e.field, value: e.value !== undefined ? e.value : resolveField(seeds.get(e.call_id)?.sc, e.field) })); - writeJson(path.join(ctx.dir, `05-deep-${hidSlug(item.hid)}.json`), { hid: item.hid, ...(item.golden ? { golden: true } : {}), ...v, evidence }); + writeJson(path.join(ctx.dir, deepArtifactName(hidSlug(item.hid))), { hid: item.hid, ...(item.golden ? { golden: true } : {}), ...v, evidence }); } function deepFinalize(ctx, { degradedItems }) { // 收编尾:逐假设工件 → 单 verdicts 集(降级项入 degraded;金轨迹单收 golden_track) const verdicts = [], golden = []; for (const b of deepItems(ctx)) { - const f = j(path.join(ctx.dir, `05-deep-${hidSlug(b.hid)}.json`)); + const f = j(path.join(ctx.dir, deepArtifactName(hidSlug(b.hid)))); if (!f) continue; const row = { hid: f.hid, verdict: f.verdict, evidence: f.evidence ?? [], one_line: f.one_line ?? "" }; (f.golden || b.golden ? golden : verdicts).push(row); } - writeJson(path.join(ctx.dir, "05-deep-verdicts.json"), { verdicts, degraded: degradedItems, golden_track: golden }); + writeJson(path.join(ctx.dir, A.DEEP_VERDICTS), { verdicts, degraded: degradedItems, golden_track: golden }); } async function synthInput(ctx) { // 判例注入只在 SYNTH(闭合校验面集中,C稿 §3.5);检索词=锚域词表(B 三件②,P3 接线: // agent.mjs KWS.health 硬编码退役,词表单一真源=anchors-health.json)+ 锚词表×幸存旗标确定性选锚。 const anchors = loadAnchors(); - const flags = j(path.join(ctx.dir, "02-verify-survivors.json"))?.survivors ?? []; + const flags = j(path.join(ctx.dir, A.VERIFY_SURVIVORS))?.survivors ?? []; const docsRoot = ctx.env.DOCS_ROOT ?? path.resolve(AGENT_DIR, "..", "..", "docs"); // 同 lib.sh 派生链 const precDir = ctx.env.PRECEDENTS_DIR ?? path.join(docsRoot, "ops", "precedents"); const onDisk = (n) => fs.existsSync(path.join(precDir, n)); // 锚选目标须在真源判例库在场(缺件锚宁少算不注入) @@ -510,11 +511,11 @@ async function synthInput(ctx) { const injected = [...new Set([...anchored, ...hitNames])].slice(0, HIT_CAP); // 条目明细(ADR 0006:报告=条目集只读渲染;异常清单每行对应真实条目)——直读本轮 manifest rows // (P4 #166:真条目在 ops/items 树,轮内产物面=manifest 行,item_id 供四键走查对号) - const rows = j(path.join(ctx.dir, "06-itemize-manifest.json"))?.rows ?? []; + const rows = j(path.join(ctx.dir, A.ITEMIZE_MANIFEST))?.rows ?? []; const itemsDetail = rows.map(({ source, verdict, severity, suggested_action, one_line, item_id }) => ({ source, verdict, severity, suggested_action, one_line, item_id })); - const input = { injected, manifest: j(path.join(ctx.dir, "06-itemize-manifest.json")), items: itemsDetail, - verdicts_digest: (j(path.join(ctx.dir, "05-deep-verdicts.json"))?.verdicts ?? []).map(({ hid, verdict }) => ({ hid, verdict })), - probe: (({ urls_n, buckets, matrix }) => ({ urls_n, buckets, matrix }))(j(path.join(ctx.dir, "01-probe-summary.json")) ?? {}) }; + const input = { injected, manifest: j(path.join(ctx.dir, A.ITEMIZE_MANIFEST)), items: itemsDetail, + verdicts_digest: (j(path.join(ctx.dir, A.DEEP_VERDICTS))?.verdicts ?? []).map(({ hid, verdict }) => ({ hid, verdict })), + probe: (({ urls_n, buckets, matrix }) => ({ urls_n, buckets, matrix }))(j(path.join(ctx.dir, A.PROBE_SUMMARY)) ?? {}) }; // 锚选不在检索命中内的,以锚域 note 补一行 gist(具名引用禁裸 id,研究 #71);超帽沿铁律⑦截前 8 const anchoredExtra = anchored.filter((n) => !hitNames.includes(n)).map((n) => { const a = anchors.anchors.find((x) => x.precedents.includes(n)); @@ -538,16 +539,16 @@ export function synthInjectionBlock(injStdout, anchoredExtra, hitNames, injected } function synthConsume(ctx, _item, { text }) { if (!text.trim()) throw Object.assign(new Error("SYNTH 最终文本为空"), { code: "empty_text" }); - fs.writeFileSync(path.join(ctx.dir, "07-synth-report.md"), text); + fs.writeFileSync(path.join(ctx.dir, A.SYNTH_REPORT), text); } // 模型步工件完备判定(#167 resume 三规则②):「有 transcript(=本步工件集齐)→ 只补门 // 评估,不重花 token」。单发步=def.out 全在场;逐假设步(DEEP)=该假设 05-deep-.json // + transcript 在场(聚合 verdicts 由 finalize 从逐假设工件重建,不算完备判据)。 export function modelItemComplete(def, ctx, item) { - if (def.forEach) { // forEach 今日仅 DEEP;命名面=deepConsume 同源(05-deep- 族) - const base = path.join(ctx.dir, `05-deep-${hidSlug(item.hid)}`); - return fs.existsSync(`${base}.json`) && fs.existsSync(`${base}-transcript.jsonl`); + if (def.forEach) { // forEach 今日仅 DEEP;命名面=deepConsume 同源(05-deep- 族,名表参数族工厂) + return fs.existsSync(path.join(ctx.dir, deepArtifactName(hidSlug(item.hid)))) + && fs.existsSync(path.join(ctx.dir, deepTranscriptName(hidSlug(item.hid)))); } return def.out.every((f) => fs.existsSync(path.join(ctx.dir, f))); } From 0e74eab242969e0baca1f0b2a11adf4cfc7db96f Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:10:43 +0800 Subject: [PATCH 07/10] =?UTF-8?q?#202=20C1:C1C3-C1-REPORT.md=20=E8=90=BD?= =?UTF-8?q?=20worktree=20=E6=A0=B9(=E9=AA=8C=E6=94=B6=E9=97=A8=E5=85=A8?= =?UTF-8?q?=E7=BB=BF=E6=95=B0=E5=AD=97+commit=20=E9=93=BE+=E8=87=AA?= =?UTF-8?q?=E5=86=B3=E6=B8=85=E5=8D=95+=E6=AE=8B=E4=BD=99=E5=9B=9E?= =?UTF-8?q?=E5=A3=B0=E9=9D=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- C1C3-C1-REPORT.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 C1C3-C1-REPORT.md diff --git a/C1C3-C1-REPORT.md b/C1C3-C1-REPORT.md new file mode 100644 index 00000000..1a497df1 --- /dev/null +++ b/C1C3-C1-REPORT.md @@ -0,0 +1,60 @@ +# C1C3-C1-REPORT — 工件名表单源(#202 C1,伞票 #201) + +工位:`.worktrees/c1c3-c1`(分支 `feature/c1c3-c1`,基 a7c0f150)。零 push;只动本 worktree。 + +## 交付物 + +1. **`ops/agent/artifacts.mjs`(新)** — 轮目录工件名单一真源: + - `ARTIFACTS`(Object.freeze,18 固定名:01-…/02-…×3/03-…×2/04-…/05-deep-verdicts/06-…/07-…×2/08-…×2/09-budget/gates.json/state.json)+ `ARTIFACT_NAMES` 派生名单(禁手抄) + - DEEP 逐假设参数族:`deepArtifactName(slug)` / `deepTranscriptName(slug)` 工厂 + `DEEP_ARTIFACT_RE` / `DEEP_TRANSCRIPT_RE` 匹配面 + `DEEP_TRANSCRIPT_GLOB`(仅 gates inputs 声明标签面) + - 范围注释:retro/.json(opsRoot 级)、breaker.json(opsRoot 级)不在表;磁盘名改值=改磁盘名,须过重放/位等价门 +2. **`ops/agent/pipeline/gates.mjs`** — 门读面/inputs 声明/reason 字符串全改 `A.*` 取值(十门 ~45 处裸字符串清零;G_DEEP 巡检 regex 改 `DEEP_TRANSCRIPT_RE`,appendGate 写 gates.json 改 `A.GATES`)。gates.json 落盘 inputs/reason 字节不变。 +3. **`ops/agent/pipeline/steps.mjs`** — 写出面/读面全改 `A.*` 取值(31 处;DEEP 族走工厂,`modelItemComplete` 的 `${base}.json`/`${base}-transcript.jsonl` 拼接改工厂两查;ITEMIZE replay 回声跨轮读 `06-itemize-manifest.json` 亦改表值)。 +4. **`ops/agent/selftest-pipeline.mjs:177`** — 17 名手抄清单退役,改 `ARTIFACT_NAMES` 遍历 + 双向闭包差集断言(名表外文件必须 ∈ DEEP 参数族;名表加项不更新写出面=红;步面新写工件不入表=红)。 + +## 验收门(全绿,实测数字) + +| 门 | 基线 | 实测 | +|---|---|---| +| selftest-pipeline | 78/0 | **78/0** | +| selftest-t4 | 票面 58/0(集成分支已含 PR3b 增 5) | **63/0**(FAIL=0,零回归) | +| selftest-contract | 18/0 | **18/0** | +| golden(run-golden.mjs) | 9/0 | **9/0** | +| standard-check | 47-48 OK | **OK=47 FAIL=0**(条文14 金标3 建议10 挂点31) | +| pytest ops 子集(`tests/ -k "ops or store"`,cwd=worktree/backend,主仓 venv) | 271 passed | **271 passed / 1 skipped / 0 failed** | +| 罐装面工件集合位等价 | — | 见下 | + +**位等价抽验**(驱动 `/tmp/c1-equiv-driver.mjs` = selftest R1 同型罐装 faux 轮,child 真跑 `pipeline.mjs health`,重构前/后各一次): +- 轮目录 23 文件名集合**逐字节同**(18 固定 + 2 假设 × (.json/-transcript.jsonl));20/23 md5 直接全等 +- 3 异 = 09-budget.json / gates.json / state.json:差异**仅** generated_at/started_at 时戳、dur_ms、wall_s、驱动 TMP 根路径串(mkdtemp 后缀);gates.json 时戳规范化后逐字节同,budget/state 再 TMP 路径规范化后逐字节同 → 零语义漂移(inputs/reason/读数全同) +- `git diff a7c0f150 -- ops/agent/golden/ ops/agent/fixtures/` 空(cases.json 与 fixtures 逐字节未动) + +**闭包哨真红验证**(TDD 红步):名表临时加 `TAMPER_PROBE: "10-c1-tamper.json"` → selftest **FAIL=1**(`工件缺:10-c1-tamper.json`)→ 还原 → 78/0 绿。 + +## Commit 链 + +``` +44e60a4e wip(#202): C1 开工占位(防超时丢工) +7813d893 #202 C1:工件名表单源 artifacts.mjs + selftest 清单面改单源 + 双向闭包哨(78/0) +86f57d27 #202 C1:gates.mjs 门读面/inputs 声明全改名表取值(~45 处清零,字节不变;78/0) +48826c9c #202 C1:steps.mjs 写出/读面全改名表取值(~31 处清零;DEEP 参数族工厂;78/0) +<本报告 commit> +``` + +## 自决清单(实现者裁量,均留痕) + +1. `DEEP_TRANSCRIPT_GLOB` 补进 artifacts.mjs:gates G_DEEP inputs 里的 `"05-deep-*-transcript.jsonl"` 标签族与工厂/正则同文件单源(标签漂移面收口)。 +2. 表键名风格 `PROBE_SUMMARY` 等(值派生自文件名,不带扩展名后缀);`02-verify-rules.json` 入表键 `VERIFY_RULES`。 +3. opsRoot 级文件(retro/.json、breaker.json)不入表——非轮目录面,gates 里 retro 标签 `retro/${key}.json` 维持字面量(非 ctx.dir 工件);已在 artifacts.mjs 头注释声明边界。 +4. ticket 写 t4 基线 58/0,实测集成分支基线 63/0(PR3b 已加 5 例);以 FAIL=0 + 实测数为准报告。 +5. CHANGELOG 双语条:spec 验收门 5 是**整批一条覆盖 C1-C3**,留给伞收口(C2/C3 合入后一次写),不在 C1 单票内提前写半条。 +6. pytest 运行法:cwd=worktree/backend(`python -m pytest` 把 cwd 入 sys.path;直接 cwd=worktree 会 `ModuleNotFoundError: No module named 'ipdb'/'main'`——venv 属主仓,无 editable 指向 worktree)。 + +## 残余回声面(spec 三层措辞之外,未动;闭包哨兜底) + +- `ops/agent/pipeline/faux.mjs:37-39` `transcriptName`:3 处 DEEP/SYNTH/TRIAGE transcript 名字面量(faux 面,写出与 steps 同名;哨管磁盘集合,名字漂移会即时红,但字面量本身未单源)。 +- `ops/agent/pipeline.mjs` HEALTH_PIPELINE def `out` 数组(~10 处工件名)、SKIP_WHEN 面 `04-dispatch-bundles.json`/`03-triage-hypotheses.json` 字面量、STAGE_PREFIX:spec C1 三层消费面=gates/steps/selftest,pipeline.mjs 定义表面不在票面;若收口建议随 C2/C3 或伞票顺手(等价改法已验证:`import { ARTIFACTS as A } from "./artifacts.mjs"` + `out: [A.X]`)。 + +## 余项 + +无阻塞项。C2(budget.mjs admitOrRefuse 提取)/C3(ctxShape 提取)由同批其他实现者承担;本分支 `feature/c1c3-c1` 待任务评审后并入 `feature/c1c3-artifacts-ctx`。 From f89fd08b1be790e0e208ebdf241f9b715cd2c704 Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:12:05 +0800 Subject: [PATCH 08/10] =?UTF-8?q?refactor(#204):=20C3=20ctx=20=E5=BD=A2?= =?UTF-8?q?=E7=8A=B6=E6=94=B6=E5=8F=A3=20pipeline.mjs=20ctxShape=E2=80=94?= =?UTF-8?q?=E2=80=94mkCtx(=E6=A0=A1=E9=AA=8C=E5=90=8E=E6=96=B0=E5=80=BC)?= =?UTF-8?q?=E4=B8=8E=20runResume(state=20=E5=A4=8D=E5=8E=9F+=E7=BC=BA?= =?UTF-8?q?=E6=97=8B=E9=92=AE=E8=A1=A5=20env)=E5=85=B1=E7=94=A8=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E5=BD=A2=E7=8A=B6=E6=9E=84=E9=80=A0,runResume=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=89=8B=E5=86=99=E5=AD=97=E6=AE=B5=E6=B8=85?= =?UTF-8?q?=E5=8D=95;=E6=97=8B=E9=92=AE=E6=A0=A1=E9=AA=8C=E7=95=99=20mkCtx?= =?UTF-8?q?=20=E4=B8=8D=E5=89=8D=E7=A7=BB=E4=B8=8D=E6=94=BE=E6=9D=BE(resum?= =?UTF-8?q?e=20=E7=9A=84=20budget=5Fcny=3Dundefined=20=E5=8E=9F=E6=A0=B7?= =?UTF-8?q?=E8=BF=87);modelCalls/dupRegistry/usage=20=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E6=96=B0=E5=BB=BA,state=20=E4=BC=A0=E5=BC=95=E7=94=A8=E4=B8=8D?= =?UTF-8?q?=E5=85=8B=E9=9A=86,dir=20=E7=94=B1=20ctxShape=20=E8=87=AA?= =?UTF-8?q?=E7=AE=97(String=20=E9=9D=A2);=E6=96=B0=E5=93=A8=C3=972:ctxShap?= =?UTF-8?q?e=20=E9=97=AD=E9=9B=86=E9=94=AE=E9=92=89=E6=AD=BB+=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E6=9E=84=E9=80=A0=E7=82=B9=E5=AD=97=E9=9D=A2=E6=81=B0?= =?UTF-8?q?=E4=B8=80=E6=AC=A1(dupRegistry:=20new=20Map()/modelCalls:=200/u?= =?UTF-8?q?sage:=20{=20input:=200=20=E6=89=8B=E6=8A=84=E5=9B=9E=E6=BD=AE?= =?UTF-8?q?=E5=8D=B3=E7=BA=A2)=E3=80=82=E7=BA=A2=E8=AF=81:=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=88=A0=20ctxShape=20=E7=9A=84=20breakerKnobs=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E2=86=92=E9=97=AD=E9=9B=86=E5=93=A8=E7=9C=9F?= =?UTF-8?q?=E7=BA=A2=2080/1,=E5=B7=B2=E8=BF=98=E5=8E=9F=E3=80=82=E9=97=A8:?= =?UTF-8?q?pipeline=2081/0=C2=B7t4=2063/0=C2=B7contract=2018/0=C2=B7golden?= =?UTF-8?q?=209/0=C2=B7standard-check=2047(=E6=8C=82=E7=82=B931/=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE10)=C2=B7backend=20ops=20=E5=AD=90=E9=9B=86=20271=20pa?= =?UTF-8?q?ssed=201=20skipped=C2=B7legacy=20smoke=20exit=202=20=E5=8F=8C?= =?UTF-8?q?=E8=B7=AF=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ops/agent/pipeline.mjs | 35 ++++++++++++++++++++++----------- ops/agent/selftest-pipeline.mjs | 17 ++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/ops/agent/pipeline.mjs b/ops/agent/pipeline.mjs index 81965095..12b416c5 100644 --- a/ops/agent/pipeline.mjs +++ b/ops/agent/pipeline.mjs @@ -33,6 +33,23 @@ const ctxPaths = (OPS_ROOT, roundId) => ({ retroFile: path.join(OPS_ROOT, "retro", `${roundId}.json`), retroAuditKey: roundId, }); +// ctx 形状唯一真源(#204 C3):mkCtx(校验后的新值)与 runResume(state 复原+缺旋钮补 env) +// 共用本构造——此前 runResume 手工重建 ctx 与 mkCtx 形状漂移靠人记(终审 smell:加字段两处改)。 +// 职责边界:旋钮值校验留 mkCtx 进本函数前(不放松不前移);本函数零校验零补齐——resume 的 +// state.budget_cny 可为 undefined(旧轮)须原样过。state 传引用不克隆;modelCalls/dupRegistry/ +// usage 每次新建(resume 重开=usage 从零计,断点前已花量不可回收,宁少算口径)。 +export const ctxShape = ({ type, roundId, opsRoot, env, log, fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, state }) => ({ + type, roundId, + dir: path.join(opsRoot, "rounds", String(roundId)), // String():resume argv 面与 mkCtx 字符串面同算 + env, log, fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, + modelCalls: 0, dupRegistry: new Map(), opsRoot, + ...ctxPaths(opsRoot, roundId), // 派生路径单源(含 retroAuditKey;replay 面事后覆写不受影响) + ledgerFile: env.LEDGER_FILE ?? process.env.LEDGER_FILE ?? path.join(DOCS_ROOT, "ops", "ledger.md"), + pkg: null, verify: null, envelope: null, + usage: { input: 0, output: 0, totalTokens: 0, cost: 0 }, + state, +}); + // ── 轮次定义表(数据非代码;C稿 §2.2 十状态;加状态须过 spec 变更+三元组铁律:无工件/无门/无失败语义=不配当状态)── // 每态:name/kind(script|model|bookend|terminal)/model(提示词键;执行面选择=faux.mjs selectModelFace,#170)/工件清单(out)/门/失败语义三元组 // fail={retry,terminal,degrade?,perItem?}(重试上限/终态错误码∈九词闭集/降级路径);skipWhen=命名谓词闭集。 @@ -116,10 +133,7 @@ function mkCtx(opts, io) { status: "in_flight", stage: null, stages: [], budget_cny: budgetCny, budget_knobs: budgetKnobs, fixtures_dir: fixturesDir, model_face: modelFace, prompt_hashes: {}, started_at: nowIso(), updated_at: nowIso(), resume_count: 0, replay_of: opts.replayOf ?? null }; const OPS_ROOT = opsRootOf(io.env); // 本轮 ops 树(rounds/rules/retro/items 同根;items=条目库真写面 P4 #166) - return { type, roundId, dir: path.join(OPS_ROOT, "rounds", roundId), env: io.env, log: io.log, - fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, modelCalls: 0, dupRegistry: new Map(), opsRoot: OPS_ROOT, ...ctxPaths(OPS_ROOT, roundId), - ledgerFile: io.env.LEDGER_FILE ?? process.env.LEDGER_FILE ?? path.join(DOCS_ROOT, "ops", "ledger.md"), - pkg: null, verify: null, envelope: null, usage: { input: 0, output: 0, totalTokens: 0, cost: 0 }, state }; + return ctxShape({ type, roundId, opsRoot: OPS_ROOT, env: io.env, log: io.log, fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, state }); // 形状单源(#204 C3);旋钮校验全部在上,不前移不放松 } const writeState = (ctx) => { ctx.state.updated_at = nowIso(); atomicWrite(path.join(ctx.dir, "state.json"), JSON.stringify(ctx.state, null, 2) + "\n"); }; const addUsage = (to, from) => { for (const k of ["input", "output", "totalTokens", "cost"]) to[k] += from?.[k] ?? 0; }; @@ -424,13 +438,12 @@ export async function runResume(opts = {}) { if ((state.resume_count ?? 0) >= 1) throw infra(`自动恢复已用尽(resume_count=${state.resume_count};每轮至多一次,二次崩溃=FAILED(infra) 交人,replay 提示见台账)`); // ctx 重建:state.json 是唯一真源(budget/fixtures 等从崩溃现场复原;usage 从零计—— - // 崩溃尝试的已花量不可回收,恢复轮 usage=下界,宁少算) - const ctx = { type: state.type, roundId, dir, env: io.env, log: io.log, - // resume 重开调用登记表(观察面从零起;断点前已花量不可回收,同 usage 下界口径) - budgetCny: state.budget_cny, budgetKnobs: state.budget_knobs ?? budget.readBudgetKnobs(io.env), breakerKnobs: readBreakerKnobs(io.env), modelCalls: 0, dupRegistry: new Map(), fixturesDir: state.fixtures_dir, modelFace: state.model_face ?? "faux", opsRoot: OPS_ROOT, - ...ctxPaths(OPS_ROOT, roundId), // 单一形状源(mkCtx 同;终审 smell 收口) - ledgerFile: io.env.LEDGER_FILE ?? process.env.LEDGER_FILE ?? path.join(DOCS_ROOT, "ops", "ledger.md"), - pkg: null, verify: null, envelope: null, usage: { input: 0, output: 0, totalTokens: 0, cost: 0 }, state }; + // 崩溃尝试的已花量不可回收,恢复轮 usage=下界,宁少算;调用登记表同理重开从零起)。 + // 形状=ctxShape 单源(#204 C3,与 mkCtx 同构造);值面=state 复原+缺旋钮补 env 读取 + // (budget_knobs/model_face 缺省回读零漂移;budget_cny 不校验不补——旧轮 undefined 保持 undefined)。 + const ctx = ctxShape({ type: state.type, roundId, opsRoot: OPS_ROOT, env: io.env, log: io.log, + budgetCny: state.budget_cny, budgetKnobs: state.budget_knobs ?? budget.readBudgetKnobs(io.env), breakerKnobs: readBreakerKnobs(io.env), + fixturesDir: state.fixtures_dir, modelFace: state.model_face ?? "faux", state }); const pending = RUNNABLE.filter((n) => !state.stages.some((r) => r.name === n && ["done", "skipped", "degraded", "dry"].includes(r.status))); ctx.resumedFrom = pending[0] ?? null; // 信封 resumed_from(恢复起点;全记录齐=仅 CLOSE 收口) ctx.resuming = true; // 引擎旗:模型步工件齐者补门不重跑(runModelStage/runStages 消费) diff --git a/ops/agent/selftest-pipeline.mjs b/ops/agent/selftest-pipeline.mjs index 0bcd98a4..8b34dc51 100644 --- a/ops/agent/selftest-pipeline.mjs +++ b/ops/agent/selftest-pipeline.mjs @@ -1836,6 +1836,23 @@ await t("A2-拒跑块单源(#203 C2):admitOrRefuse 外壳——文案/[admission assert.equal(refusalOf({ spend: 0.04, est: 0.05, cap: 0.05, counted: 1, skipped: 0 }).code, "budget_monthly", "纯文案函数 code 面"); }); +await t("C3-ctx 形状单源(#204):ctxShape 闭集键钉死(mkCtx 与 runResume 同构造;缺/多字段即红)", async () => { + const { ctxShape } = await import("./pipeline.mjs"); + const state = { schema: 1 }; + const c = ctxShape({ type: "health", roundId: "2026-10-25-2110-health", opsRoot: "/ops", env: {}, log: () => {}, fixturesDir: "/fx", modelFace: "faux", budgetCny: 0.05, budgetKnobs: { monthlyCapCny: 30 }, breakerKnobs: {}, state }); + assert.deepEqual(Object.keys(c).sort(), ["breakerKnobs", "budgetCny", "budgetKnobs", "dir", "dupRegistry", "envelope", "env", "fixturesDir", "itemsDir", "ledgerFile", "log", "modelCalls", "modelFace", "opsRoot", "pkg", "retroAuditKey", "retroFile", "roundId", "rulesFile", "state", "type", "usage", "verify"].sort(), "键集=两消费面(gates/steps)所读闭集+ctxPaths 四键;手抄重建回潮=键漂移即红"); + assert.ok(c.dir === path.join("/ops", "rounds", "2026-10-25-2110-health"), "dir 自算(resume argv String 面与 mkCtx 字符串面同算)"); + assert.equal(c.modelCalls, 0); assert.ok(c.dupRegistry instanceof Map); + assert.deepEqual(c.usage, { input: 0, output: 0, totalTokens: 0, cost: 0 }, "调用登记/用量每次新建"); + assert.equal(c.state, state, "state 传引用不克隆"); +}); + +await t("C3-ctx 唯一构造点(#204):dupRegistry/modelCalls/usage 零字面在 pipeline.mjs 恰一处(绕 ctxShape 手抄回潮=第二处即红)", async () => { + const src = fs.readFileSync(path.join(AGENT, "pipeline.mjs"), "utf8"); + for (const lit of ["dupRegistry: new Map()", "modelCalls: 0", "usage: { input: 0"]) + assert.equal(src.split(lit).length - 1, 1, `「${lit}」须恰出现 1 次(仅 ctxShape;第 2 处=手抄重建回潮)`); +}); + // ── A4(#199):重复调用哈希 ── await t("A4-纯函数:canonical 键序无关+dupKey 同参同键/异参异键+计数第 limit 次拦/limit=0 停用+dupTop 排序", async () => { const { canonicalJson, dupKey, dupRecord, dupTop } = await import(path.join(AGENT, "pipeline/budget.mjs")); From 41c11410a9ec448c2144d1effa85481df80b4f84 Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:13:10 +0800 Subject: [PATCH 09/10] =?UTF-8?q?docs(c2c3):=20C1C3-C23-REPORT=20=E4=BA=A4?= =?UTF-8?q?=E5=8D=B7=E6=8A=A5=E5=91=8A+C1C3=20=E6=89=B9=20CHANGELOG=20?= =?UTF-8?q?=E5=8F=8C=E8=AF=AD=E6=9D=A1(C2+C3=20=E9=9D=A2;C1=20=E8=AF=8D?= =?UTF-8?q?=E6=9D=A1=E7=95=99=E5=85=B6=E5=B7=A5=E4=BD=8D)=E2=80=94?= =?UTF-8?q?=E2=80=94=E9=97=A8=E6=95=B0=E5=AD=97:81/0=C2=B763/0=C2=B718/0?= =?UTF-8?q?=C2=B79/0=C2=B747=C2=B7pytest=20271+1skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- C1C3-C23-REPORT.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 ++ 2 files changed, 68 insertions(+) create mode 100644 C1C3-C23-REPORT.md diff --git a/C1C3-C23-REPORT.md b/C1C3-C23-REPORT.md new file mode 100644 index 00000000..da06f83b --- /dev/null +++ b/C1C3-C23-REPORT.md @@ -0,0 +1,66 @@ +# C1C3-C23-REPORT — C2(#203)+C3(#204) 实现者交卷报告 + +工位:`.worktrees/c1c3-c2c3`(分支 `feature/c1c3-c2c3`,基 = 集成分支 `feature/c1c3-artifacts-ctx` @ a7c0f150)。零 push。 + +## 交付 C2(#203):月度准入拒跑块单源化 ✅ + +**收口前现状**:拒跑块在 `agent.mjs runRound`(legacy 面)与 `pipeline.mjs runPipeline`(管线面)逐字复制——文案/`[admission]` log 行/台账留痕行三件全同(#200 Spec 轴已核),漂移只靠人记。 + +**落地**(`ops/agent/pipeline/budget.mjs`): +- `export const refusalOf(a)` — 纯文案面,单源产出 `code="budget_monthly"` + 拒跑 message(counted/skipped 读数嵌文)。 +- `export async function admitOrRefuse({ledgerFile, capCny, estCny, env, log})` — 留痕外壳:`monthlyAdmission` 读 → 放行返回 `{refused:false, admission}`(读数对象原样回传,`state.monthly_admission`/09-budget.json 观察面字节不变);拒跑则 `[admission] budget_monthly refused month=… spend=… est=… cap=… skipped=…` log + `appendRefusalTrace` 台账行(写败只 log 不翻转裁决,宁拒不跑)→ `{refused:true, ...refusalOf(admission)}`。 +- 双面改调用,只映射各自出口:agent.mjs `throw Object.assign(new Error(msg), {code})`(main 兜底信封 exit 1);pipeline.mjs `emitFail(code, message)`(信封 exit 1)。**两面行为/日志/台账行/exit code 逐字不变**。 +- 容差口径(spec P2 附注项):`admitMonthly` 上方集中单点注释——精确比较无 ε(`spend+est>cap` 才拒,等号放行);gates.mjs/steps.mjs 轮预算面的 +1e-9 灰尘容差先例不入本面;将来若加月帽 ε,admitMonthly 是唯一合法落点。 + +**哨**(selftest-pipeline.mjs 新增 1 条,"A2-拒跑块单源"):真台账树(mkTree)断言 message 逐字=收口前文案、logs 逐字=[admission] 行、台账含 `budget_monthly refused est=0.05 cap=0.05`、放行面 `refused:false + admission.spend=0.04` 原样回传+零日志。 + +**红证**(真红,已录):①未实现期 `admitOrRefuse is not a function` → 78/1;②容差翻转 `<=`→`<` → 恰「A2-纯函数…等号(0.04+0.06=0.10=帽)放行」真红 78/1,还原后绿。 + +## 交付 C3(#204):ctx 形状单源化 ✅ + +**收口前现状**:`runResume`(pipeline.mjs ~:435-444)手工重建 ctx,与 `mkCtx`(~:101-125)字段清单两处维护,形状漂移靠人记(终审 smell)。 + +**落地**(`ops/agent/pipeline.mjs`): +- 新增 `export const ctxShape({type, roundId, opsRoot, env, log, fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, state})` — ctx 字段清单唯一构造点(模块内函数,择浅):dir 自算 `path.join(opsRoot,"rounds",String(roundId))`、展开 `ctxPaths`(四键含 retroAuditKey)、ledgerFile 公式、`modelCalls:0 / dupRegistry:new Map() / usage:{input:0,output:0,totalTokens:0,cost:0}` 每次新建、state 传引用不克隆。 +- `mkCtx` 尾部改 `return ctxShape({...})` — **旋钮校验(budgetCny 非负有限/budgetKnobs/breakerKnobs)全部留在 mkCtx 进 ctxShape 前,不前移不放松**;ctxShape 刻意零校验零补齐。 +- `runResume` 改 `const ctx = ctxShape({...})` — 值面 = state 复原 + 缺旋钮补 env 读取(`state.budget_knobs ?? readBudgetKnobs(io.env)`、`state.model_face ?? "faux"` 不变;`budgetCny = state.budget_cny` 不校验不补,旧轮 undefined 原样保持)。前置读 state.json 的局部 dir 保留(ctxShape 自算等值)。runReplay 走 mkCtx 自动获益(未动)。 + +**哨**(selftest-pipeline.mjs 新增 2 条): +- "C3-ctx 形状单源:ctxShape 闭集键钉死" — 构造后 `Object.keys().sort()` deepEqual 23 键金集(含 ctxPaths 四键 rulesFile/itemsDir/retroFile/retroAuditKey;= gates/steps 两消费面所读闭集);附 dir 自算、usage/dupRegistry/modelCalls 新建、state 引用不克隆断言。 +- "C3-ctx 唯一构造点" — 读 pipeline.mjs 源文本,`dupRegistry: new Map()`、`modelCalls: 0`、`usage: { input: 0` 各恰出现 1 次(手抄字段清单回潮=第 2 处出现即红)。 + +**红证**(真红,已录):临时删 ctxShape 返回体的 `breakerKnobs,` 字段 → 闭集哨真红 80/1,还原后绿。(期间自修一例:哨金键集手写序 "envelope"/"env" 与 JS 字典序相反致哨假红——两侧同 .sort() 修复,属测试自bug非产品面。) + +## 验收门(全绿;除注明外均为 C3 完成态实测) + +| 门 | 数字 | 判定 | +|---|---|---| +| selftest-pipeline | **81/0**(基线 78/0,+1 C2 哨+2 C3 哨) | ✅ 基线不降 | +| selftest-t4 | **63/0**(任务文 58/0 系旧数;改动前基线实测 63/0,不降) | ✅ | +| selftest-contract | **18/0** | ✅ | +| golden | **9/0**(罐装面 = 验收门④ 位等价抽验:工件集合与文件名不变) | ✅ | +| standard-check | **47 OK**(条文14/金标3/建议10/挂点31/原语22,已有12/半缺7/缺3) | ✅ | +| backend pytest ops 子集 | **271 passed, 1 skipped**(主仓 venv;`-k "ops or store"`) | ✅ | +| legacy smoke | `node agent.mjs` 无参 exit 2;`OPS_PIPELINE_BUDGET_CNY=abc node agent.mjs health x` exit 2 | ✅ 不炸 | + +注:上表数字在 C3 代码完成态实测;其后仅改 markdown(CHANGELOG.md、本报告),零代码面重叠,门不受影响。基线对照(改动前,/tmp/c23-baseline.log):pipeline 78/0 · t4 63/0 · contract 18/0 · golden 9/0 · standard-check 47。 + +## 自决清单 + +1. CHANGELOG 双语条只写 C2+C3(本工位两票);C1(工件名表)词条留给其工位自落——不代写未验证工作。 +2. C2 哨放 selftest-pipeline(A2 系旁,同台账树 fixture 复用);C3 择浅=模块内函数(pipeline.mjs export),未开独立模块。 +3. t4 门数字以实测基线 63/0 为准(任务文 58/0 为旧基线,PR3b 后已 63),不降不追旧数。 +4. C3 哨金键集手写序初版与 JS 字典序冲突("env"<"envelope")致假红——两侧同 .sort() 收口(哨自身修复,非放松)。 + +## Commit 链 + +``` +f89fd08b refactor(#204): C3 ctx 形状收口 pipeline.mjs ctxShape——mkCtx/runResume 共用单一形状构造…(门:81/0 等) +326fd2ff refactor(#203): C2 月度准入拒跑块收口 budget.mjs admitOrRefuse——双面文案/log/台账行单源…(门:79/0 等) +6e50476b chore(c2c3): placeholder — 40min wall guard, resume anchor (tickets #203 #204) +``` + +## 残留风险 + +- resume 面行为等价靠既有 resume 测试组(三规则+负例)+ 全 faux 轮钉住;旧轮 `state.budget_cny === undefined` 字面路径无专属历史 fixture(行为「不补不校验」由 ctxShape 零校验设计+注释钉死),如需可后续补历史形状 fixture。 +- `runModelStage` 的防御补 `ctx.budgetKnobs ??= …` 保留未动(非本次票面)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e8fd5f..a3192cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### 新增 Added + - **Monthly-admission refusal block + ctx shape consolidated to single sources (issues #203 #204; C1-C3 refactor batch, umbrella #201; pure refactor, zero behavior change — C2+C3 scope; C1's artifacts-name table lands with its own worker)**: C2 — the monthly kill-switch refusal block was verbatim-duplicated across both faces (agent.mjs runRound legacy face ↔ pipeline.mjs runPipeline, the #200 spec-axis finding); `ops/agent/pipeline/budget.mjs` now exports the single source: `refusalOf` (pure text face) + `admitOrRefuse` (stateful shell: monthlyAdmission reads → on refusal the `[admission]` log line + the ledger trace row via appendRefusalTrace, write failure logged never flipping the verdict) ; both faces rewire to it and only map their exits (legacy throws {code} → failure envelope exit 1; pipeline emits emitFail envelope exit 1) — refusal message / [admission] log / ledger row / exit codes byte-identical, pinned by a new selftest-pipeline sentinel against a real ledger tree; the monthly-cap tolerance doctrine is centralized as the single annotation at admitMonthly (exact comparison, no ε — equality passes, spec-pinned "only > cap rejects"; the +1e-9 dust-tolerance precedent stays on the round-budget face and must never leak here; if a monthly ε is ever added, admitMonthly is the only legal site). C3 — runResume's hand-rebuilt ctx had drifted from mkCtx's shape with only human memory holding them together; pipeline.mjs now exports `ctxShape` — the ctx field-list single constructor; mkCtx passes validated fresh values, runResume passes state.json-recovered values with missing knobs re-read from env (budget_knobs/model_face fallbacks unchanged; budget_cny stays unvalidated and un-filled — an old round's undefined passes through verbatim); knob validation stays in mkCtx exactly as-is (not moved, not loosened — ctxShape does zero validation by design), state passes by reference, modelCalls/dupRegistry/usage are fresh per construction; two new sentinels: the ctxShape closed key-set (23 keys incl. the ctxPaths four) and the unique-construction-site check ("dupRegistry: new Map()" / "modelCalls: 0" / "usage: { input: 0" each appear exactly once in pipeline.mjs — a hand-rebuilt field list recurring is red). Gates: selftest-pipeline 81/0 (baseline 78; red proofs: unimplemented shell 78/1 "admitOrRefuse is not a function", tolerance flip <=→< reddened the equality-admits pin, removing ctxShape's breakerKnobs reddened the closed-set sentinel), t4 63/0, contract 18/0, golden 9/0 (canned face doubles as the bit-equivalence spot check), standard-check 47 OK (挂点31/建议10), backend ops subset 271 passed 1 skipped, legacy smoke exit-2 paths unchanged. + - **月度准入拒跑块+ctx 形状收单源(issue #203 #204;C1-C3 重构批,伞 #201;纯重构零行为变更——C2+C3 面;C1 工件名表随其工位自落)**:C2——月度 kill switch 拒跑块双面逐字复制(agent.mjs runRound legacy 面↔pipeline.mjs runPipeline,#200 Spec 轴发现),`ops/agent/pipeline/budget.mjs` 收口单源:`refusalOf`(纯文案面)+`admitOrRefuse`(留痕外壳:monthlyAdmission 读→拒跑则 [admission] log 行+appendRefusalTrace 台账留痕行,写败只 log 不翻转裁决);双面改调只映射出口(legacy throw {code}→failure 信封 exit 1,pipeline emitFail 信封 exit 1)——拒跑文案/[admission] log/台账行/exit code 逐字不变,新哨真台账树钉死;月帽容差口径集中为 admitMonthly 单点注释(精确比较无 ε,等号放行,spec 钉「> 帽才拒」;+1e-9 灰尘容差先例只属轮预算面永不入此;将来若加月帽 ε,admitMonthly 是唯一合法落点)。C3——runResume 手工重建 ctx 与 mkCtx 形状漂移只靠人记,pipeline.mjs 新增导出 `ctxShape`=ctx 字段清单单一构造点:mkCtx 传校验后新值,runResume 传 state.json 复原值+缺旋钮补 env 读取(budget_knobs/model_face 回退不变;budget_cny 不校验不补——旧轮 undefined 原样过);旋钮校验留 mkCtx 原样(不前移不放松,ctxShape 刻意零校验),state 传引用不克隆,modelCalls/dupRegistry/usage 每次新建;新哨×2:ctxShape 闭集键(23 键含 ctxPaths 四键)+唯一构造点字面恰一次(「dupRegistry: new Map()」「modelCalls: 0」「usage: { input: 0」手抄回潮即红)。门:selftest-pipeline 81/0(基线 78;红证:未实现期 78/1 "admitOrRefuse is not a function"、容差翻转 <=→< 等号放行例真红、删 ctxShape 的 breakerKnobs 闭集哨真红)、t4 63/0、contract 18/0、golden 9/0(罐装面兼位等价抽验)、standard-check 47 OK(挂点31/建议10)、backend ops 子集 271 passed 1 skipped、legacy smoke exit 2 双路不变。 - **Cross-round channel circuit breaker (issue #199 deliverable B, STANDARD.md P16 catch-up; PR3b batch, umbrella: the "cross-round state absent" half-miss from arch-review)**: new `ops/agent/tools/breaker.mjs` — pure core (BREAKER_CHANNELS exactly five {gh_search,web_read,web_search,rss_parse,fetch_url}; BREAKER_COUNTED_CODES exactly three {channel_missing,channel_auth,quota} — 宁少算: transient/infra-shaped codes never count; parseBreakerState rejects bad JSON / wrong version / rogue channels / rogue state words → null → fail-closed reset to closed, never crashes the round) plus a stateful shell mkBreaker (state persists atomically in DOCS_ROOT/ops/breaker.json via tmp+renameSync, the _ops_store precedent; per-operation re-read so cross-round and cross-host instances share the file as truth); semantics: 3 consecutive counted failures (OPS_BREAKER_THRESHOLD, 0=disable like DUP_CALL_LIMIT=0) opens the circuit for 21600s (OPS_BREAKER_COOLDOWN_S, 6h); after cooldown verdict() grants exactly one probe by flipping to half_open and persisting synchronously before allowing (probe quota is durable — a crash mid-probe cannot double-probe); half-open success → recovered/closed with consec reset, half-open failure → reopened with a fresh full cooldown (no exponential backoff, spec-pinned); half-open non-counted failure reverts to the original open window (no deadlock, 宁少算); a success in closed state with consec=0 is zero-write. While open, calls are refused at the single wiring point — tools.mjs ToolHost (the legacy face hosts the five channel tools; microprobe tools are not channel-shaped so the pipeline face needs no wiring) — with a structured envelope `code=channel_open` (ERROR_CODES closed set 13→14) naming the remaining window and the next legal action, zero spawn; guard order in beforeToolCall is dup→breaker→cap (self-decision, recorded); finish() settles breaker state from the structured envelope code (channel tools only, structured.ok boolean face), so the envelope exit stays the single source. mkCtx validates the breaker knobs at round start (budget.mjs readKnob precedent: non-negative finite else usage-error); knobs read in literal dot-shape on purpose (the computeReads scanner only recognizes `process.env.NAME` literals — the scan face is the contract, A's reverted-table lesson applied); SPAWN_CONTRACT_ENV gains both OPS_BREAKER_* entries and selftest-contract's SRCS/aggregate extends to tools/breaker.mjs (dead-table-entry cross-check stays green). CLOSE snapshot: 09-budget.json gains `readings.breaker` (five-channel state face + effective knobs) — read-only, never creates breaker.json (replay zero-write, echo discipline; G_CLOSE adds no breaker assertion: observation face has no cap to cross-check — self-decision). Transitions append a ledger row via lib.sh ledger_append (same flock domain; ledger failure is logged, never thrown — appendRefusalTrace precedent) and an in-file events ring (≤20). Charter faces close in this same batch per spec: STANDARD P15/P16 flip 半缺→已有, D-5's "补课腿" wording lands and its gate-mount row gains `tools/breaker.mjs#mkBreaker` (挂点 30→31), the two 建议 lines (monthly kill switch / cross-round breaker) are deleted now that both landed (建议 12→10), DESIGN.md:74's 【状态:未实现】marker is flipped (deliverable A's row unmarked by B per same-batch rule), DESIGN 熔断器 row gains its landed note, GLOSSARY coins 预算四界 (one-line form) and rewrites 跨轮熔断 to the landed semantics (P16 no longer "cross-round state absent"). Deploy note: ops/breaker.json is auto-created on the first counted channel result (snapshot/verdict faces never write; a fresh deploy has no file until a channel fails). Gates: selftest-t4 63/0 (red phase 55 passes then module-load crash — breaker.mjs absent), selftest-contract 18/0 (red 17/1 — 14-word closed set), selftest-pipeline 78/0 (red 77/1 — readings.breaker absent), golden 9/0, standard-check 47/0 with 挂点=31/建议=10/原语 22 (已有12/半缺7/缺3), selftest.sh full umbrella green. - **跨轮通道熔断器(issue #199 交付B,STANDARD.md P16 补课;PR3b 批,伞=架审「跨轮态缺」半缺)**:新增 `ops/agent/tools/breaker.mjs`——纯核(通道闭集恰五员 {gh_search,web_read,web_search,rss_parse,fetch_url};计数码恰三员 {channel_missing,channel_auth,quota}——宁少算:transient/infra 形码永不计数;parseBreakerState 对坏 JSON/错版本/野通道/野状态词一概 →null→fail-closed 重置闭路,绝不炸轮)+有态壳 mkBreaker(态落 DOCS_ROOT/ops/breaker.json,tmp+renameSync 原子写=_ops_store 判例;每操作重读文件,跨轮跨壳同文件为真源);语义:连续 3 次计数码失败(OPS_BREAKER_THRESHOLD,0=禁用同 DUP_CALL_LIMIT=0 判例)开断 21600s(OPS_BREAKER_COOLDOWN_S,6h);冷却期满 verdict() 先同步翻转半开落盘再放行恰一次探活(探活名额持久——探活中崩溃不会双探);半开成功→恢复闭路 consec 清零,半开失败→重开+新整窗(spec 钉:无指数退避);半开非计数失败→退回原开断窗(不困死,宁少算);closed 态 consec=0 的成功=零写。开断中调用在唯一接线点拦——tools.mjs ToolHost(五通道工具只活在 legacy 面;微探针工具非通道形,pipeline 面无需接线)——结构化信封 `code=channel_open`(ERROR_CODES 闭集 13→14)带剩窗秒+下一步合法动作,零 spawn;beforeToolCall 守卫序 dup→breaker→cap(自决,已录);finish() 从结构化信封码结算熔断态(仅通道工具、structured.ok 布尔面),信封出口保持单源。mkCtx 轮始校验熔断旋钮(budget.mjs readKnob 判例:非负有限否则用法错);旋钮刻意字面点形读(computeReads 扫描器只认 `process.env.NAME` 字面——扫描面即契约面,沿 A 的表驱动回退教训);SPAWN_CONTRACT_ENV 收两枚 OPS_BREAKER_* 表项,selftest-contract 的 SRCS/聚合面扩 tools/breaker.mjs(死表项对拍保绿)。CLOSE 快照:09-budget.json 增 `readings.breaker`(五通道态面+生效旋钮)——只读,永不造 breaker.json(回放零写,回声律;G_CLOSE 不加熔断对拍:观察面无帽可拍——自决)。转移事件经 lib.sh ledger_append 追台账行(同 flock 锁域;台账写败 log 不抛——appendRefusalTrace 判例)+文件内 events 环(≤20)。典章面按 spec 同批收口:STANDARD P15/P16 翻 半缺→已有,D-5「补课腿」措辞落地、门挂点行增收 `tools/breaker.mjs#mkBreaker`(挂点 30→31),建议区两行(月度 kill switch/跨轮熔断器)落地即删(建议 12→10),DESIGN.md:74【状态:未实现】标注翻正(交付A 的行由 B 按同批规则翻正),DESIGN 熔断器行补已落地注记,GLOSSARY 铸词 预算四界(一行式)+跨轮熔断词条改落地口径(P16 不再「跨轮态缺」)。部署注记:ops/breaker.json 由首个计数码通道结果自动创建(快照/verdict 面永不写;新部署在通道首次失败前无此文件)。门:selftest-t4 63/0(红期 55 过后模块装载崩——breaker.mjs 缺)、selftest-contract 18/0(红期 17/1——14 词闭集)、selftest-pipeline 78/0(红期 77/1——readings.breaker 缺)、golden 9/0、standard-check 47/0 挂点=31/建议=10/原语 22(已有12/半缺7/缺3)、selftest.sh 全伞绿。 - **Budget four-frontier in-round enforcement + monthly kill switch (issue #199 deliverable A, STANDARD.md P15 catch-up; PR3b batch, umbrella: the "only G_DISPATCH single real gate" half-miss from arch-review)**: new `ops/agent/pipeline/budget.mjs` single-source knob face — six knobs with production defaults `OPS_PIPELINE_MAX_MODEL_CALLS=12` / `OPS_PIPELINE_TOKEN_BUDGET=200000` / `OPS_PIPELINE_WALL_CLOCK_S=1800` / `OPS_PIPELINE_SPEND_CAP_CNY=3` / `OPS_PIPELINE_MONTHLY_CAP_CNY=30` / `OPS_PIPELINE_DUP_CALL_LIMIT=3` (knob names read in literal dot-shape on purpose — the computeReads cross-check scanner only recognizes `process.env.NAME` literals, so a BUDGET_KNOBS table-driven refactor was tried and reverted: the scan face is the contract); knob validation follows the mkCtx precedent (non-negative finite, else usage-error exit 2, no sick rounds); four frontiers enforce in-round at engine checkpoints (pipeline.mjs runModelStage/runStages): ① model-call cap checked before each attempt (retries count, faux face enforced equally), ② token-trajectory budget and ④ spend cap checked after settlement (equality passes — spec pins "only > monthly cap rejects"), ③ wall clock from started_at with degenerate-unparseable→0 (never false-kill; cap=0 means zero budget — refused even at t=0, deliberately distinct from the DUP/THRESHOLD 0=disable interception lines; determinism precedent: the 2026-10-26 CI same-millisecond first-boundary race with a 0.0001 cap); enforcement is 宁少算: a breach stops further model calls (no retry burn, no degrade-and-continue), artifacts/ledger already written stay (evidence never lost, failStage lands the terminal state); monthly kill switch admission (A2) is shared across both faces (pipeline.mjs runPipeline + agent.mjs runRound legacy face): reads the current month's Σcost from the ledger (in-lock, same domain as the ledger lock below), Σ+round-estimate > monthly cap → refuse with envelope `code=budget_monthly`, exit 1, plus a ledger trace row; missing/empty ledger = 0-baseline, rows lacking cost are skipped not guessed; ledger dual-write face gains locking (A3, #139 shape): python `append_ledger` holds `fcntl.flock` and `lib.sh ledger_append` goes through `flock(1)` — same lock domain, concurrent appends lose zero rows; duplicate-call hash (A4): the 3rd same-parameters tool call is refused with `code=repeat_storm` (0 disables), injected on both faces (tools.mjs ToolHost.beforeToolCall + microprobes.mjs mkMicroprobeHost), counts exported into the budget artifact; new CLOSE artifact `09-budget.json` (knob readings + per-frontier verdicts + dup-call counts) with the G_CLOSE echo sentinel extended to cross-check artifact readings ≤ effective caps (golden stub = cases.json, #189 shape); ERROR_CODES closed set 9→13 (`budget_exceeded` / `wall_clock_exceeded` / `budget_monthly` / `repeat_storm`; `channel_open` belongs to deliverable B); gates: selftest-pipeline 77/0 (red batches 63/5→68/4→72/3→74/3 all genuinely red first), contract 18/0, t4 58/0, golden 9/0, standard-check 48, selftest.sh 115/0, backend ops subset 65 passed (A3 concurrency red-phase: 2×40 appends land exactly 40 rows unpatched); charter faces (STANDARD P15/P16 promotion, DESIGN.md:74 unmarking, GLOSSARY coinages) intentionally deferred to deliverable B's same-batch closeout per spec — P16 landmine: breaker lands with B, flipping P15 prose before B would misdescribe the charter. Merger independently recomputed all gates and replayed all three red tests (TOKEN_BUDGET=1 → budget_exceeded envelope with attempts=1, no idle burn; tampered 09-budget.json → G_CLOSE真 FAIL; 3rd same-params call → repeat_storm真拦). From 066120ea63ec20f7d53881fb97b23ed6b9546a11 Mon Sep 17 00:00:00 2001 From: steponeerror Date: Sun, 25 Oct 2026 21:42:17 +0800 Subject: [PATCH 10/10] =?UTF-8?q?docs(#201=20=E4=BC=9E=E6=94=B6=E5=8F=A3):?= =?UTF-8?q?=20C1=20CHANGELOG=20=E5=8F=8C=E8=AF=AD=E6=9D=A1=E8=A1=A5?= =?UTF-8?q?=E8=B4=A6(=E5=B7=A5=E4=BD=8D=E6=9C=AA=E8=90=BD,merger=20?= =?UTF-8?q?=E7=A7=BB=E4=BA=A4)+=E7=AC=AC=E5=9B=9B=E5=86=99=E9=9D=A2?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E5=90=8D=E8=A1=A8(finding-exit=20EXITS=5FART?= =?UTF-8?q?IFACT=20=E6=94=B9=E5=8F=96=20ARTIFACTS.ANCHOR=5FSWEEP=5FEXITS,C?= =?UTF-8?q?1=20merger=20=E5=8F=91=E7=8E=B0=20P2)=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=97=A8:pipeline=2081/0=C2=B7t4=2063/0=C2=B7contract=2018/0?= =?UTF-8?q?=C2=B7golden=209/0=C2=B7standard-check=2047(=E6=8C=82=E7=82=B93?= =?UTF-8?q?1)=C2=B7pytest=20ops=20260+1w=C2=B7selftest.sh=20116/0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ ops/agent/pipeline/finding-exit.mjs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3192cbe..56416a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### 新增 Added + - **Artifact-name table single source (issue #202; C1-C3 refactor batch, umbrella #201; pure refactor, disk names byte-identical)**: artifact filenames were raw string literals in three consumption layers (gates.mjs ~45 sites, steps.mjs ~31, selftest-pipeline's 17-name hand-copied list) — adding 09-budget.json in #199 meant hand-syncing three layers (live example); new `ops/agent/artifacts.mjs` exports `ARTIFACTS` (18 fixed names, frozen) + `ARTIFACT_NAMES` + the DEEP parameterized families (`deepArtifactName`/`deepTranscriptName` factories + `DEEP_ARTIFACT_RE`/`DEEP_TRANSCRIPT_RE` matchers); all three layers now take names from the table; a two-way closure sentinel pins table<->disk-set coherence (table gains an entry without a writer = red; a step emits a new artifact not in the table = red); golden/cases.json and the canned-faux artifact-name set are bit-identical pre/post (md5-equal); umbrella closure folds in the fourth write surface — finding-exit.mjs's EXITS_ARTIFACT now takes `ARTIFACTS.ANCHOR_SWEEP_EXITS` (C1-merger find). Known non-swept literals by spec design: faux.mjs transcriptName x3 and pipeline.mjs HEALTH_PIPELINE out/SKIP_WHEN (outside the spec's three layers; the disk-level sentinel backstops). Gates: selftest-pipeline 78/0 (tamper proof: injected fake table entry -> closure sentinel FAIL "工件缺:10-c1-tamper.json" -> restored green), t4 63/0, contract 18/0, golden 9/0, standard-check 47 OK, backend ops subset 271 passed 1 skipped. + - **工件名表单源(issue #202;C1-C3 重构批,伞 #201;纯重构,磁盘工件名字节不变)**:工件文件名原是三层裸字符串(gates.mjs ~45 处/steps.mjs ~31 处/selftest-pipeline 17 名手抄清单)——#199 加 09-budget.json 时手抄三层就是活例;新 `ops/agent/artifacts.mjs` 导出 `ARTIFACTS`(18 固定名 frozen)+`ARTIFACT_NAMES`+DEEP 参数族(deepArtifactName/deepTranscriptName 工厂+DEEP_ARTIFACT_RE/DEEP_TRANSCRIPT_RE 匹配面);三层全改从名表取值;双向闭包哨钉名表↔磁盘集合自洽(名表加项无写出面=红,步面新工件不入表=红);golden/cases.json 与罐装 faux 工件名集合重构前后位等价(md5 同);伞收口补入第四写面——finding-exit.mjs 的 EXITS_ARTIFACT 改取 `ARTIFACTS.ANCHOR_SWEEP_EXITS`(C1 merger 发现)。spec 设计内留白:faux.mjs transcriptName ×3+pipeline.mjs HEALTH_PIPELINE out/SKIP_WHEN 字面量(三层措辞之外;磁盘级哨兜底)。门:selftest-pipeline 78/0(红证:名表注入假名→闭包哨 FAIL "工件缺:10-c1-tamper.json"→还原回绿)、t4 63/0、contract 18/0、golden 9/0、standard-check 47 OK、backend ops 子集 271 passed 1 skipped。 - **Monthly-admission refusal block + ctx shape consolidated to single sources (issues #203 #204; C1-C3 refactor batch, umbrella #201; pure refactor, zero behavior change — C2+C3 scope; C1's artifacts-name table lands with its own worker)**: C2 — the monthly kill-switch refusal block was verbatim-duplicated across both faces (agent.mjs runRound legacy face ↔ pipeline.mjs runPipeline, the #200 spec-axis finding); `ops/agent/pipeline/budget.mjs` now exports the single source: `refusalOf` (pure text face) + `admitOrRefuse` (stateful shell: monthlyAdmission reads → on refusal the `[admission]` log line + the ledger trace row via appendRefusalTrace, write failure logged never flipping the verdict) ; both faces rewire to it and only map their exits (legacy throws {code} → failure envelope exit 1; pipeline emits emitFail envelope exit 1) — refusal message / [admission] log / ledger row / exit codes byte-identical, pinned by a new selftest-pipeline sentinel against a real ledger tree; the monthly-cap tolerance doctrine is centralized as the single annotation at admitMonthly (exact comparison, no ε — equality passes, spec-pinned "only > cap rejects"; the +1e-9 dust-tolerance precedent stays on the round-budget face and must never leak here; if a monthly ε is ever added, admitMonthly is the only legal site). C3 — runResume's hand-rebuilt ctx had drifted from mkCtx's shape with only human memory holding them together; pipeline.mjs now exports `ctxShape` — the ctx field-list single constructor; mkCtx passes validated fresh values, runResume passes state.json-recovered values with missing knobs re-read from env (budget_knobs/model_face fallbacks unchanged; budget_cny stays unvalidated and un-filled — an old round's undefined passes through verbatim); knob validation stays in mkCtx exactly as-is (not moved, not loosened — ctxShape does zero validation by design), state passes by reference, modelCalls/dupRegistry/usage are fresh per construction; two new sentinels: the ctxShape closed key-set (23 keys incl. the ctxPaths four) and the unique-construction-site check ("dupRegistry: new Map()" / "modelCalls: 0" / "usage: { input: 0" each appear exactly once in pipeline.mjs — a hand-rebuilt field list recurring is red). Gates: selftest-pipeline 81/0 (baseline 78; red proofs: unimplemented shell 78/1 "admitOrRefuse is not a function", tolerance flip <=→< reddened the equality-admits pin, removing ctxShape's breakerKnobs reddened the closed-set sentinel), t4 63/0, contract 18/0, golden 9/0 (canned face doubles as the bit-equivalence spot check), standard-check 47 OK (挂点31/建议10), backend ops subset 271 passed 1 skipped, legacy smoke exit-2 paths unchanged. - **月度准入拒跑块+ctx 形状收单源(issue #203 #204;C1-C3 重构批,伞 #201;纯重构零行为变更——C2+C3 面;C1 工件名表随其工位自落)**:C2——月度 kill switch 拒跑块双面逐字复制(agent.mjs runRound legacy 面↔pipeline.mjs runPipeline,#200 Spec 轴发现),`ops/agent/pipeline/budget.mjs` 收口单源:`refusalOf`(纯文案面)+`admitOrRefuse`(留痕外壳:monthlyAdmission 读→拒跑则 [admission] log 行+appendRefusalTrace 台账留痕行,写败只 log 不翻转裁决);双面改调只映射出口(legacy throw {code}→failure 信封 exit 1,pipeline emitFail 信封 exit 1)——拒跑文案/[admission] log/台账行/exit code 逐字不变,新哨真台账树钉死;月帽容差口径集中为 admitMonthly 单点注释(精确比较无 ε,等号放行,spec 钉「> 帽才拒」;+1e-9 灰尘容差先例只属轮预算面永不入此;将来若加月帽 ε,admitMonthly 是唯一合法落点)。C3——runResume 手工重建 ctx 与 mkCtx 形状漂移只靠人记,pipeline.mjs 新增导出 `ctxShape`=ctx 字段清单单一构造点:mkCtx 传校验后新值,runResume 传 state.json 复原值+缺旋钮补 env 读取(budget_knobs/model_face 回退不变;budget_cny 不校验不补——旧轮 undefined 原样过);旋钮校验留 mkCtx 原样(不前移不放松,ctxShape 刻意零校验),state 传引用不克隆,modelCalls/dupRegistry/usage 每次新建;新哨×2:ctxShape 闭集键(23 键含 ctxPaths 四键)+唯一构造点字面恰一次(「dupRegistry: new Map()」「modelCalls: 0」「usage: { input: 0」手抄回潮即红)。门:selftest-pipeline 81/0(基线 78;红证:未实现期 78/1 "admitOrRefuse is not a function"、容差翻转 <=→< 等号放行例真红、删 ctxShape 的 breakerKnobs 闭集哨真红)、t4 63/0、contract 18/0、golden 9/0(罐装面兼位等价抽验)、standard-check 47 OK(挂点31/建议10)、backend ops 子集 271 passed 1 skipped、legacy smoke exit 2 双路不变。 - **Cross-round channel circuit breaker (issue #199 deliverable B, STANDARD.md P16 catch-up; PR3b batch, umbrella: the "cross-round state absent" half-miss from arch-review)**: new `ops/agent/tools/breaker.mjs` — pure core (BREAKER_CHANNELS exactly five {gh_search,web_read,web_search,rss_parse,fetch_url}; BREAKER_COUNTED_CODES exactly three {channel_missing,channel_auth,quota} — 宁少算: transient/infra-shaped codes never count; parseBreakerState rejects bad JSON / wrong version / rogue channels / rogue state words → null → fail-closed reset to closed, never crashes the round) plus a stateful shell mkBreaker (state persists atomically in DOCS_ROOT/ops/breaker.json via tmp+renameSync, the _ops_store precedent; per-operation re-read so cross-round and cross-host instances share the file as truth); semantics: 3 consecutive counted failures (OPS_BREAKER_THRESHOLD, 0=disable like DUP_CALL_LIMIT=0) opens the circuit for 21600s (OPS_BREAKER_COOLDOWN_S, 6h); after cooldown verdict() grants exactly one probe by flipping to half_open and persisting synchronously before allowing (probe quota is durable — a crash mid-probe cannot double-probe); half-open success → recovered/closed with consec reset, half-open failure → reopened with a fresh full cooldown (no exponential backoff, spec-pinned); half-open non-counted failure reverts to the original open window (no deadlock, 宁少算); a success in closed state with consec=0 is zero-write. While open, calls are refused at the single wiring point — tools.mjs ToolHost (the legacy face hosts the five channel tools; microprobe tools are not channel-shaped so the pipeline face needs no wiring) — with a structured envelope `code=channel_open` (ERROR_CODES closed set 13→14) naming the remaining window and the next legal action, zero spawn; guard order in beforeToolCall is dup→breaker→cap (self-decision, recorded); finish() settles breaker state from the structured envelope code (channel tools only, structured.ok boolean face), so the envelope exit stays the single source. mkCtx validates the breaker knobs at round start (budget.mjs readKnob precedent: non-negative finite else usage-error); knobs read in literal dot-shape on purpose (the computeReads scanner only recognizes `process.env.NAME` literals — the scan face is the contract, A's reverted-table lesson applied); SPAWN_CONTRACT_ENV gains both OPS_BREAKER_* entries and selftest-contract's SRCS/aggregate extends to tools/breaker.mjs (dead-table-entry cross-check stays green). CLOSE snapshot: 09-budget.json gains `readings.breaker` (five-channel state face + effective knobs) — read-only, never creates breaker.json (replay zero-write, echo discipline; G_CLOSE adds no breaker assertion: observation face has no cap to cross-check — self-decision). Transitions append a ledger row via lib.sh ledger_append (same flock domain; ledger failure is logged, never thrown — appendRefusalTrace precedent) and an in-file events ring (≤20). Charter faces close in this same batch per spec: STANDARD P15/P16 flip 半缺→已有, D-5's "补课腿" wording lands and its gate-mount row gains `tools/breaker.mjs#mkBreaker` (挂点 30→31), the two 建议 lines (monthly kill switch / cross-round breaker) are deleted now that both landed (建议 12→10), DESIGN.md:74's 【状态:未实现】marker is flipped (deliverable A's row unmarked by B per same-batch rule), DESIGN 熔断器 row gains its landed note, GLOSSARY coins 预算四界 (one-line form) and rewrites 跨轮熔断 to the landed semantics (P16 no longer "cross-round state absent"). Deploy note: ops/breaker.json is auto-created on the first counted channel result (snapshot/verdict faces never write; a fresh deploy has no file until a channel fails). Gates: selftest-t4 63/0 (red phase 55 passes then module-load crash — breaker.mjs absent), selftest-contract 18/0 (red 17/1 — 14-word closed set), selftest-pipeline 78/0 (red 77/1 — readings.breaker absent), golden 9/0, standard-check 47/0 with 挂点=31/建议=10/原语 22 (已有12/半缺7/缺3), selftest.sh full umbrella green. diff --git a/ops/agent/pipeline/finding-exit.mjs b/ops/agent/pipeline/finding-exit.mjs index 91312f1c..6e93a2f8 100644 --- a/ops/agent/pipeline/finding-exit.mjs +++ b/ops/agent/pipeline/finding-exit.mjs @@ -22,10 +22,11 @@ import path from "node:path"; import { execFile as execFileCb } from "node:child_process"; import { promisify } from "node:util"; import { ghIssueCreate } from "../tools.mjs"; +import { ARTIFACTS } from "../artifacts.mjs"; const execFileP = promisify(execFileCb); const APPRISE_TIMEOUT_MS = 15_000; // apprise CLI 单批推送超时(防挂轮;库面 _alerts 无此面,CLI 面必须有) -const EXITS_ARTIFACT = "08-anchor-sweep-exits.json"; +const EXITS_ARTIFACT = ARTIFACTS.ANCHOR_SWEEP_EXITS; // 工件名表单源(#202 伞收口:第四写面并入;C1 merger 发现) // URL 脱敏(_alerts._redact_url 同律):只留 scheme+尾 4 字符;余段 ≤8 只留 *** const redactUrl = (u) => {