diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e8a4e7..21d4b5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ ### 新增 Added + - **Assessment-track rc machine-checking + month-round leg separation (issue #193, umbrella #191 arch-review C5)**: `scripts/fusion_replay_gate.py` grows `gate_rc` aggregation — 0 = all legs PASS with sufficient corpus, 1 = any true FAIL (C2 strict/loose mismatch stays a hard gate), 2 = insufficient corpus (empty-corpus vacuous PASS no longer masquerades as green); `main()` returns it instead of unconditionally `0` (the dead gate is alive again; reviewer-D P1-1). `scripts/monthly_eval.sh` separates per-leg rc: all eight legs (LEG_ORDER: model/all/dsem/audit/temporal/readout/anchors/agg-share) capture rc explicitly and never abort siblings — the F4 anchors special case is honestly generalized to a whole-round contract; final exit = first non-zero in leg order; `check_family -maxdepth 1` closes the old find-recursion hole where a same-shape artifact in a subdirectory could substitute for the family verdict; leg-status artifacts (`leg-status-*.json`) dual-write (in-flight snapshot with readout=null + final) and feed the monthly report's leg-status section (missing = section omitted, never fabricated). STANDARD.md D-1 gate hook 47→48. + - **评估轨 rc 机检化+月轮全腿分离(issue #193,伞 #191 审审整改 C5)**:`scripts/fusion_replay_gate.py` 新增 `gate_rc` 聚合——0=全腿 PASS 且语料充分,1=任一真 FAIL(C2 strict/loose 失配仍硬门),2=语料不足(空语料的空真 PASS 不再冒充绿);`main()` 返回真实 rc(死门复活,评审D P1-1)。`scripts/monthly_eval.sh` 全腿 rc 分离:八腿(LEG_ORDER)显式捕获不互断——F4 anchors 特例诚实推广为全轮契约;最终退出码=腿序首非零;`check_family -maxdepth 1` 补 find 递归旧洞(子目录同形状件不再能顶替族 verdict 件);腿级状态件(leg-status-*.json)双写(在飞快照+定稿件)入月报腿况节(缺省不出节,不虚构)。STANDARD.md D-1 挂点 47→48。 + - **Gate-contract single source + dual cross-language sentinels (issue #194, umbrella #191 arch-review C3)**: `GATE_SEQUENCE`/`GATE_COUNT`/`GATE_NAMES` derive from the gates.mjs registry itself (7 hardcoded `records.length !== 10` sites zeroed; adding/removing/reordering a gate trips the registry-order↔execution-order bijection invariant test); the 19-anchor JS mirror is unified into `ops/agent/anchor-mirror.mjs` (single definition, `anchorRow` constructor throws on unknown/missing keys — the #189 stub-missing-conflict precedent); two new §14 sentinels: SHAPE (anchor row key-set cross-language check) and FOLD (fold-family full-value-domain check: python `_error_family` golden constants AST-extracted vs JS `foldErrorFamily` — previously only net_timeout→timeout was covered). Acceptance replayed by the merger: tampering with gate list / one fold value / one anchor / one row key each trips the sentinels red while the JS-internal tests still look green (the canned-self-consistency blind spot the sentinels exist to catch). + - **门契约单源+跨语言双哨(issue #194,伞 #191 审审整改 C3)**:`GATE_SEQUENCE`/`GATE_COUNT`/`GATE_NAMES` 全部从 gates.mjs 注册表本体派生(7 处 `records.length !== 10` 硬编码清零;加门/删门/改序即触发注册键序↔执行序双射不变式红);19 锚 JS 镜像合一到 `ops/agent/anchor-mirror.mjs`(唯一定义点,`anchorRow` 构造器多写/漏写键即 throw,#189 桩漏 conflict 判例);§14 新增双哨:SHAPE(锚行键集跨语言对拍)+FOLD(折叠族全值域对拍:python `_error_family` 金常量 AST 抽取 vs JS `foldErrorFamily`,旧盖面仅 net_timeout→timeout 一例)。merger 篡改重放:改门清单/折叠族一值/锚一员/行键一枚→哨齐红而 JS 内部测试仍绿(哨存在的意义=罐内自洽假绿面)。 + - **tools.mjs layering, behavior-preserving (issue #195, umbrella #191 arch-review C6)**: the 1345-line monolith splits into a 132-line facade (ToolHost host + registration orchestration + semanticGate + prototype mixin mount) plus six single-purpose faces `tools/{shared,envelope,registry,precedents,measurement,discovery}.mjs`; all existing imports keep working through the facade re-export; behavior conservation pinned three ways — the 29-name public export set equals the bde7f061 frozen monolith name-for-name, constants byte-equal (GH_WRITE_SUBCOMMANDS/FROZEN_DAYS/RSS_FRESH_DAYS/RG_TIMEOUT_MS), and a line-multiset audit showing zero unplanned logic-line changes. Exit-safety single sources (PREDICATES/GH_WRITE_SUBCOMMANDS/DENY_*) live exactly once each in registry.mjs; STANDARD.md anchors P1/P6/D-3/D-6 repointed to the face files; contract SRCS and selftest.sh node --check extended to the six faces. + - **tools.mjs 分层,行为守恒(issue #195,伞 #191 审审整改 C6)**:1345 行单体拆为 132 行门面(ToolHost 宿主+注册编排+semanticGate+prototype mixin 挂载)+六面 `tools/{shared,envelope,registry,precedents,measurement,discovery}.mjs`;既有 import 面经门面 re-export 全不断;行为守恒三重钉死——公开导出面 29 名与 bde7f061 冻结单体逐名相等、常量字节等(GH_WRITE_SUBCOMMANDS/FROZEN_DAYS/RSS_FRESH_DAYS/RG_TIMEOUT_MS)、行多重集审计零计划外逻辑行变更。出口安全单源(PREDICATES/GH_WRITE_SUBCOMMANDS/DENY_*)全仓各恰一处(registry.mjs);STANDARD.md 四锚(P1/P6/D-3/D-6)改指面文件;contract SRCS+selftest.sh node --check 扩六面。 +- **物化谓词根修:harvest 版本戳腿(issue #192,伞 #191 架审整改 C2;ADR 0009;收口 #188 部署注记的「harvest 代码演进不触发重物化」架构缺口)**:`needs_convert` 从 mtime 单腿(代理「内容+代码版本」,代码演进不动 csv mtime → 新纪元永不建 → 静默休眠,2026-10-10 / 2026-10-25 两起生产事故同形)升三腿——ptr 缺失 ∨ `.rev` 版本戳 ≠ 当前 `HARVEST_REV`(代码代腿;`_sources/_lmdb.py` 单调递增常量,任何 harvest 语义改动必须 bump,判例注记在常量旁)∨ ptr mtime < raw mtime(数据腿,增量跳写语义保住);`rebuild_lmdb` staged 段在 ptr 前盖 `.rev` 章(ptr-LAST 无裸奔不变式不动);`_needs_rebuild_of` 比较对象从 `_mmdb_path` 换 `_lmdb_base` 族(`_mmdb_path` 留作 scheduler 观测契约);存量族 `.rev` 缺失=不匹配 → 首跑一次全量重建自愈;门①位等价=代码未变连续 rebuild 零副作用逐字节同(sidecar 字节/纪元清单/逐文件 sha256+mtime_ns);门②消融 3 真实源(myip_ms 1563/56、ziyadnz 123106/0、cps 32715/0)touch 旧仪式 vs `HARVEST_REV` 前进(进程内模拟代码演进)——计数相等+排除/告警日志 multiset 相等(ziyadnz 46 行)+csv mtime 全程不动 → touch 不再必要;rev 前进后收敛无循环;GLOSSARY「物化谓词」「harvest 版本戳」同批铸词;**部署注记**:首跑触发存量族全量迁移重建(票面口径 41 源;开发机 data 面 50/59 raw 在场,以生产面为准)——计数不变已机检钉死,属预期自愈非故障;#188 部署注记的显式 `POST /api/sources/infra_services/update` 仪式随本修失效(版本戳腿自动触发),infra_services 无条件重物化机制保留、职责转为投影一致性自愈(ADR 0009) + - **Materialization-predicate root fix: the harvest version-stamp leg (issue #192, umbrella #191 arch-review C2; ADR 0009; closes the "harvest-code evolution never re-materializes" architecture gap left in #188's deploy note)**: `needs_convert` goes from the single mtime leg (which proxied "content + code version" — code evolution leaves the csv mtime unmoved, so a new epoch is never built and projections go dormant; the 2026-10-10 and 2026-10-25 production incidents share this shape) to three legs — ptr missing ∨ `.rev` stamp ≠ current `HARVEST_REV` (the code-generation leg; a monotonically increasing constant in `_sources/_lmdb.py` that any harvest-semantics change must bump, with the incident precedents annotated at the constant) ∨ ptr mtime < raw mtime (the data leg; incremental skip-write semantics preserved); `rebuild_lmdb` stamps `.rev` in the staged section before the ptr swap (the ptr-LAST no-naked-commit invariant untouched); `_needs_rebuild_of` compares against the `_lmdb_base` family instead of `_mmdb_path` (`_mmdb_path` stays as the scheduler's observability contract); legacy families without `.rev` read as mismatch → one-time full self-heal rebuild on first run; gate ① bit-equivalence = with code unchanged, consecutive rebuilds have zero side effects, byte-identical (sidecar bytes / epoch listing / per-file sha256 + mtime_ns); gate ② ablation across 3 real sources (myip_ms 1563/56, ziyadnz 123106/0, cps 32715/0): the old touch ceremony vs advancing `HARVEST_REV` (in-process code-evolution simulation) — equal counts + equal exclusion/alert-log multisets (ziyadnz 46 rows) + csv mtime untouched throughout → touch no longer necessary; convergence after a rev advance loops no further; GLOSSARY coins "物化谓词" (materialization predicate) and "harvest 版本戳" (harvest version stamp) in the same batch; **deploy note**: the first run triggers a one-time full migration rebuild of all existing families (ticket estimate 41 sources; the dev-machine data face shows 50/59 raws present — production's face is authoritative) — counts unchanged is machine-pinned, this is expected self-healing, not a fault; #188's deploy-note ceremony of an explicit `POST /api/sources/infra_services/update` is superseded (the version-stamp leg triggers automatically), while infra_services' unconditional re-materialization mechanism stays, its duty shifting to projection-consistency healing (ADR 0009) + - **benign② 良性断言源接入(issue #188,spec #180 首批租户 Q5 承接;ADR 0008 决策4 定向整改)**:infra_services 升格为首个良性断言源——harvest 同 payload 双面(service 资产槽照旧 + classification_type=服务角色(dns/ntp)+verdict="benign" 进分类面;弃权拼写 "" 期作废);_registry.lookup 分组注入:良性断言 type-agnostic 注入每个指控组同场(任意指控类型×良性断言 = verdict_conflict=True 真对立出海,如 223.5.5.5 = tweetfeed 指控×infra_services 良性断言;固定 ctype 方案否决:blacklist 型指控会漏接);宁少算不变式测试钉死:良性证人不投票(组 conf/顶层重融合逐位不变)、不压制指控 verdict(precedence 恒指控胜)、不产 STIX Indicator;无指控组时以自身 ctype 成纯良性面(verdict=benign conf≈r,区别于①无证据弃权 benign/0);_classification 注册 dns/ntp 断言域标签+i18n 双语;三锚点(2026-09-06 PR #61 终审建议4 挂账)同批收口:①STIX verdict_conflict=True 往返断言 ②回放 check_directional 合法成因清单加 benign-abstention ③_types/_api_models/api.ts 三处「今天恒 False,占位」注释改口径;conflict 留痕人裁链:anchor_sweep 工件逐锚 conflict 投影(留痕不持门,锚仍 finding——宁少算不自动压制)+G_ANCHOR_SWEEP 门 conflict 布尔形状校验+finding 出口 issue 模板对立行标记;GLOSSARY「benign(两层,勿混)」②随实做改口径(占位句作废,铸词同批律);新增 tests/lookup/test_benign_assertion.py(6 例:真对立/多指控组各自对峙/conf 位等价/纯良性 v4+v6/无 Indicator)+锚巡检 conflict 形状固化例;**部署注记(评审A P1)**:本批 `_DATA` 字节不变→csv mtime 不动→`needs_convert` 恒 False,存量 epoch 不含良性断言=部署后静默休眠(2026-10-10 事故同形状:那次靠 docker rm csv+restart 外科治愈);部署时须显式触发重建——`POST /api/sources/infra_services/update`,良性面才物化出海;另「harvest 代码演进不触发重物化」为架构缺口,已留票缓议;**回放闸门判例(评审C)**:C3 同面双跑 ρ 第五位/直方图 ±10/3.28M 桶移=now 基准时间衰减抖动非回归(`_merge.py asof_to_now`),硬门=C2 strict 失配 0 - **benign② benign-assertion source onboarding (issue #188, carrying spec #180 first-batch tenant Q5; ADR 0008 decision 4's targeted remediation)**: infra_services is upgraded into the first benign-assertion source — harvest yields one payload on two faces (the service asset slot unchanged, plus classification_type=service role (dns/ntp) + verdict="benign" entering the classification face; the old abstention spelling "" is retired); _registry.lookup group injection: benign assertions inject type-agnostically into every accusing group in the same scene (any accusation type × benign assertion = verdict_conflict=True true opposition going live, e.g. 223.5.5.5 = tweetfeed accusation × infra_services benign assertion; the fixed-ctype design was rejected: blacklist-type accusations would miss); 宁少算 (err on under-counting) invariants pinned by tests: the benign witness never votes (group conf / top-level re-fusion bit-identical), never suppresses an accusation verdict (precedence always favors the accusation), never yields a STIX Indicator; with no accusing group the assertion forms a pure-benign face on its own ctype (verdict=benign conf≈r, distinct from ① no-evidence abstention benign/0); _classification registers the dns/ntp assertion-domain labels + bilingual i18n; the three anchor points (2026-09-06 PR #61 final-review recommendation 4 carryover) close in the same batch: ① STIX verdict_conflict=True roundtrip assertion ② replay check_directional legal-cause list gains benign-abstention ③ the three "constant False today, placeholder" comments in _types/_api_models/api.ts re-worded; conflict mark-for-human-adjudication chain: anchor_sweep artifacts carry a per-anchor conflict projection (recorded, never gating — the anchor stays a finding, 宁少算 means no auto-suppression) + G_ANCHOR_SWEEP gate boolean shape check for conflict + the finding-exit issue template marks opposition rows; GLOSSARY "benign (two layers, never conflate)" ② re-worded to match the landed reality (the placeholder sentence retired, coinage-in-same-batch rule); adds tests/lookup/test_benign_assertion.py (6 cases: true opposition / per-group confrontation across multiple accusing groups / conf bit-equivalence / pure-benign v4+v6 / no Indicator) plus the anchor-sweep conflict-shape pinning case; **deploy note (review A P1)**: this batch's `_DATA` is byte-unchanged → csv mtime unmoved → `needs_convert` stays False, so the existing epoch carries no benign assertion and the feature lies dormant after deploy (same shape as the 2026-10-10 incident, then cured surgically via docker rm csv+restart); at deploy time explicitly trigger the rebuild — `POST /api/sources/infra_services/update` — before the benign face materializes; "harvest-code evolution without `_DATA` drift never re-materializes" is an architecture gap deferred to a follow-up ticket; **replay-gate judgment (review C)**: C3 same-face double-run drift (ρ at the 5th decimal / histogram ±10 / 3.28M bucket moves) is now-anchor time-decay jitter, not a regression (`_merge.py asof_to_now`); the hard gate is C2 strict with 0 mismatches diff --git a/GLOSSARY.md b/GLOSSARY.md index f586c7a6..df357267 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -8,6 +8,8 @@ ## 存储 - **epoch**:一次 rebuild 产出的不可变数据目录(`.`),建后永不原地写。锚:backend/ipdb/_sources/_lmdb.py(epoch helpers) +- **物化谓词**:族是否必须重物化的判定 `needs_convert(raw, base)`,三腿:ptr 缺失 ∨ harvest 版本戳不匹配 ∨ ptr mtime < raw mtime;数据未变+代码未变 = False(增量跳写),任一腿触发 = True(真重建)。锚:_lmdb.py:372(needs_convert,ADR 0009) +- **harvest 版本戳**:`.rev` sidecar 里存的 harvest 代码代号(rebuild 原子盖章),对照 `HARVEST_REV` 常量;任何 harvest 语义改动必须 bump 常量,否则代码演进静默休眠(2026-10-10/2026-10-25 两起事故判例;存量族缺 .rev = 不匹配 → 首跑自愈重建)。锚:_lmdb.py:337(HARVEST_REV)、:325(rev_path,ADR 0009) - **ptr**:指向当前 live epoch 的指针文件,唯一事实源;写侧换 ptr 即原子切换,读侧跟随,读者永不见半状态。锚:_lmdb.py(read_ptr) - **族**:v4/v6 双套平行 LMDB 存储(base/ptr/reader/disjoint 全套独立);族必须显式声明——小 v6 整数数值上落在 v4 范围,按数值分派会错编 key。锚:_lmdb.py(lookup)、_source_base.py - **disjoint 快路径**:排序区间两两不相交的库可走 O(1) 回扫;前提经 detect_disjoint O(n) 验证才许走,否则保守退到逐前缀 LPM 探测(嵌套库)。算法按数据形状选择,前提显式验证。锚:_lmdb.py(lookup/detect_disjoint) @@ -114,19 +116,22 @@ - **金轨迹**:空转保活裁定(三小裁①)——零 need_deep 轮 DEEP 仍跑一条 canned-假设 faux 回路(hid=golden-track-canned),零成本保回路活性;真 bundle 轮不得混入(G_DEEP 双向机检)。锚:pipeline/steps.mjs(deepFinalize) - **折叠族**:error_signature 的族映射收敛(http_401/403→http_4xx→http_5xx→network→download 兜底),P1 探测器与管线 ITEMIZE 同 dedup_key 空间的同因拼写;条目摘要镜像钉=selftest 对拍 Python 金常量防两语言漂移。锚:pipeline/ops-items.mjs、backend/ipdb/_ops_detectors.py(_error_family) - **轮次状态文件**:rounds//state.json——状态机持久面:每步边界原子写(temp+rename,沿 _ops_store 判例),boot 恢复与 replay 的单一事实源;轮次目录 rounds//(round_id=YYYY-MM-DD-HHMM-health),replay 产物写 -r/ 原件零改写。锚:ops/agent/pipeline.mjs(writeState/runReplay) +- **门契约**:十门的清单/序/计数单源——`GATE_SEQUENCE`(=GATES 注册键序)/`GATE_COUNT`(=GATE_SEQUENCE.length,禁手写)/`GATE_NAMES` 全部派生自 pipeline/gates.mjs 注册表本体,消费方(run-golden/selftest-pipeline)一律 import;契约即注册表,分居两文件=伪单源。哨=注册键序↔HEALTH_PIPELINE 执行序双射不变式测试(临时加门/改序即红)。锚:ops/agent/pipeline/gates.mjs(GATE_SEQUENCE) +- **锚镜像**:python 锚集(backend/ipdb/_eval/anchors.py ANCHORS)在 JS 侧的唯一镜像定义点 ops/agent/anchor-mirror.mjs(GOLDEN_SWEEP_ANCHORS 19 员+行键集 ANCHOR_ROW_KEYS 8 键{ip,expect,ok,reason,verdict,confidence,accusers,conflict}+anchorRow 构造器多写/漏写键即 throw=行键 fail-closed);镜像对拍哨(MIRROR)逐成员对拍 python 真源,行键哨(SHAPE)跨语言对拍键集——罐内自洽不算数,#189 桩漏 conflict 判例。锚:ops/agent/anchor-mirror.mjs +- **腿级状态件**:月轮八腿(model/all/dsem/audit/temporal/readout/anchors/agg-share)的逐腿执行状态工件 leg-status-*.json(腿名/rc/时间戳),月报「进行中/腿红」节的唯一事实源;双写=render 前件(readout=null 在飞快照)+定稿件;缺省不出节(缺读数不虚构,schema-stable)。锚:scripts/monthly_eval.sh(write_leg_status)、backend/ipdb/_eval/monthly.py(load_leg_status/render_leg_status) - **降级(轮次语义)**:失败语义三值(重试/降级/终态)之一——跳过可选步或标 unresolved 转人裁,轮仍成功(信封 ok=true+stages 标 degraded,如 DISPATCH 预算截尾/DEEP 单假设 unresolved/RETRO 跳过);降级不是失败,与终态(九词闭集错误码)两回事。Avoid:「自愈闭环」「自动修复」(有人裁闸门,叫自愈是夸大)、「重试到底」(重试有上限,尽即终态或降级)。锚:ops/agent/pipeline.mjs(runStages 降级分支) - **召回地板**:判例库 _INDEX.md(派生可再生缓存:概念判例一行一件,文件名+概念词+一行主张+关键词+状态;留痕判例不进)——库内任何概念至少一行 gist 常驻在场,检索 miss 的根治:rg 是精度工具,索引是召回保底;「索引是地图不是存储」(ADR 0007),判例文件才是唯一真源,spawn 前可再生。锚:ops/agent/build-index.mjs、docs/ops/precedents/_INDEX.md(B稿 §2.2①) - **锚判例域词表**:anchors-health.json——按高频域(github 重定向假红/限流假面/abuse.ch 迁移/sunset/矩阵 park 残差/聚合器回声)预置域词→判例文件闭集,宿主从旗标读数确定性选锚(零模型),命中并入注入闭集且不越 HIT_CAP;锚指向缺件=编造路径,build-index --check 拒。锚:ops/agent/anchors-health.json、ops/agent/pipeline/anchors.mjs(B稿 §2.2②) - **判例升格通道**:同因人裁留痕 ≥2 且 ruling_agreement 一致(键并集:文本归一全等 ∨ 源身份词相交;superseded 链不计数防谱系回声)→ retro/promotion-candidates/ 候选件(仅立项:三门槛=可机检/可证伪/非环境特异性以 pending 起步待人裁逐项核);人裁采纳(adopt_rule)才入概念判例,lineage 必列来源留痕(谱系可回算);检测器永不写判例区。锚:ops/agent/promote-detect.mjs(B稿 §3.2) -- **通道信封四码**:外部通道(gh/rg)失败的结构化分类码——channel_missing(spawn ENOENT/rc=127,通道不在运行环境)/channel_auth(凭证缺失失效:not logged in/401/403)/quota(rate limit 限流)/transient(超时/网络,唯一可重试族,同参数 ≤1 次);判定优先级 channel_missing>channel_auth>quota>transient>原兑底(gh_error/rg_infra 兼容保留);每码文案自带「下一步合法动作」(对齐 MCP isError 语义 2026-07-28:错误要能引导模型自纠或升级)——前三码属基础设施/部署面不可轮内自纠,直接人裁区;trace.rc 恒 Number(ENOENT→127/超时杀→124)。判例:2026-10-24-discover-dual-channel-dead(容器无 gh 双通道全灭,失败不可诊断面)。锚:ops/agent/tools.mjs(classifyChannelError)、prompts/discover-system.md(信封处置)、GLOSSARY 同批录入律 -- **发现通道**:discover 轮的读向补全三薄工具 web_read(r.jina.ai markdown 正文,恒无鉴权头——实测 2026-10-24:裸调用正常,带欠费 key 反 402)/ web_search(pi-web-access 多 provider 网络搜索,2026-10-24 用户令;缺省路由 keyless duckduckgo 零配置一发即活,jina 经 agent-config tool_keys 扩槽配 key 后入回退链;路由配置单源 = web-search.json 物化进 ops 树 + PI_CODING_AGENT_DIR 指路,auth.json 同型判例)/ rss_parse(本地零依赖 RSS2.0/Atom 结构解析,新鲜门 RSS_FRESH_DAYS=30);与测量工具(fetch_url/gh_search/exclusive_probe/matrix)的边界=数据形状天然隔离:产出发现线索,不进谓词硬门数字与 MEASUREMENT_TOOLS(evidence 语义门不因此放宽);agent-reach 整只不进镜像(异构故障面违 few, sharp tools)。锚:ops/agent/tools.mjs(normalizeJinaRead/parseMcpSearchText/parseRss)、prompts/discover-system.md(动作面表)、DESIGN §6(2026-10-24 裁定) +- **通道信封四码**:外部通道(gh/rg)失败的结构化分类码——channel_missing(spawn ENOENT/rc=127,通道不在运行环境)/channel_auth(凭证缺失失效:not logged in/401/403)/quota(rate limit 限流)/transient(超时/网络,唯一可重试族,同参数 ≤1 次);判定优先级 channel_missing>channel_auth>quota>transient>原兑底(gh_error/rg_infra 兼容保留);每码文案自带「下一步合法动作」(对齐 MCP isError 语义 2026-07-28:错误要能引导模型自纠或升级)——前三码属基础设施/部署面不可轮内自纠,直接人裁区;trace.rc 恒 Number(ENOENT→127/超时杀→124)。判例:2026-10-24-discover-dual-channel-dead(容器无 gh 双通道全灭,失败不可诊断面)。锚:ops/agent/tools/envelope.mjs(classifyChannelError)、prompts/discover-system.md(信封处置)、GLOSSARY 同批录入律 +- **发现通道**:discover 轮的读向补全三薄工具 web_read(r.jina.ai markdown 正文,恒无鉴权头——实测 2026-10-24:裸调用正常,带欠费 key 反 402)/ web_search(pi-web-access 多 provider 网络搜索,2026-10-24 用户令;缺省路由 keyless duckduckgo 零配置一发即活,jina 经 agent-config tool_keys 扩槽配 key 后入回退链;路由配置单源 = web-search.json 物化进 ops 树 + PI_CODING_AGENT_DIR 指路,auth.json 同型判例)/ rss_parse(本地零依赖 RSS2.0/Atom 结构解析,新鲜门 RSS_FRESH_DAYS=30);与测量工具(fetch_url/gh_search/exclusive_probe/matrix)的边界=数据形状天然隔离:产出发现线索,不进谓词硬门数字与 MEASUREMENT_TOOLS(evidence 语义门不因此放宽);agent-reach 整只不进镜像(异构故障面违 few, sharp tools)。锚:ops/agent/tools/discovery.mjs(normalizeJinaRead/parseMcpSearchText/parseRss)、prompts/discover-system.md(动作面表)、DESIGN §6(2026-10-24 裁定) - **黄金标准(四层)**:情报运营 agent 开发验收典章(ADR 0008,2026-10-24 用户确认;真源 STANDARD.md v0 = #181)——原语层(22 词,条文必须声明由哪些原语组成)→条文层(编号+判例链,EDD 开发环=头条)→门挂点层(CI/ops 轮/selftest 机检挂口)→金标层(回归语料);**挂不上验证手段的条目只能称建议,不得称标准**(反面教材=锚门死件;机检面=standard-check.mjs,挂 selftest 链)。结构词:条文五件套=编号+主张+原语组成+门挂点+判例链(缺一=伪标准形态);挂口族=门挂点层注册表(CI 腿/selftest 伞/golden 回归/ops 轮门四族,挂点须落族内载体,嵌套 docs 仓路径禁作挂点)。外部依据只采 2026 源。Avoid:与金族他词混用(金轨迹=空转保活、golden fixtures=字节等价基线、裁决级锚=anchors.py 锚集,均非本词)。锚:ops/agent/STANDARD.md(典章真源)、ops/agent/standard-check.mjs(门挂点核对器)、docs/adr/0008(嵌套 docs 仓)、notes/ops-agent-golden-standard-research-2026-10-24.md - **回归轨 / 能力轨**:评测双轨语义(ADR 0008)——回归轨=已知答案金标+行为探针,≈100% 通过才放行,持 CI+运营轮巡检(回答「agent 是否还能做会的事」);能力轨=北星指标(整体情报更准)爬坡观测,不持门;两轨阈值语义不同,不得互相原谅。出处:Anthropic evals 2026-01。锚:docs/adr/0008、backend/ipdb/_eval/anchors.py(回归轨既有件,接线=租户批) - **安静失败**:监控面板全绿而系统行为已错——标准可观测性(存活/延迟/错误率)抓不住语义级退化,须行为探针(活路径已知答案查询)代查(IEEE Spectrum 2026-04)。判例:锚门死件(8.8.8.8 被打恶而门不响)。锚:notes/ops-agent-golden-standard-research-2026-10-24.md(工业腿③) - **发现固化**:生产事故与运营 finding 当天转可执行回归金标,人验签核(agent 提名、人裁终局)——学术判例 Agentic Auditing 2026-10(发现→可执行测试→人验,$2-3/轮)。首批金标=8.8.8.8/114DNS 判例。STANDARD.md 原语层 P13 词条=本词,同概念同判例(2026-10-24 终审修单统一拼写,典章内不再用「事故固化」拼法)。锚:docs/adr/0008 决策3、notes 研究学术腿①、ops/agent/STANDARD.md 原语层(P13) - **预扣结算**:预算在动作前检查的原语——reserve 后动作、settle 时对账;事后 dashboard 不构成预算控制(Latentism 2026-10)。轮内现状=步前检查实报累计(budget_truncate 同语义),月度 kill switch=补课清单。锚:ops/agent/pipeline.mjs(budget_truncate) - **谱系混淆**:变量不对齐产生的优势/印证是伪影——源派生重复计票(回声当印证)与算力不对齐的多 agent 优势(swarm tax:同 thinking-token 预算下多 agent 优势消失)同族;工业 eval 框架均不建模源谱系,本仓独有标准条目。判例:114DNS cps+ziyadnz 假印证。锚:backend/ipdb/_logodds.py(dedup_lineage)、notes 研究(三向对撞表) -- **信封纪律**:agent 与外部通道的失败表达契约(STANDARD 原语 P1)——失败不编造:通道失败走四码结构化分类(每码自带下一步合法动作),无证据≠清白,弃权/缺口不冒充零命中;机检面=classifyChannelError(词条「通道信封四码」=码表细则)。锚:ops/agent/STANDARD.md 原语层(P1)、ops/agent/tools.mjs +- **信封纪律**:agent 与外部通道的失败表达契约(STANDARD 原语 P1)——失败不编造:通道失败走四码结构化分类(每码自带下一步合法动作),无证据≠清白,弃权/缺口不冒充零命中;机检面=classifyChannelError(词条「通道信封四码」=码表细则,实体在 tools/envelope.mjs)。锚:ops/agent/STANDARD.md 原语层(P1)、ops/agent/tools/envelope.mjs - **状态机骨架**:运营 agent 宿主形态(STANDARD 原语 P2)——宿主=显式状态机编排全轮,模型只在 TRIAGE/DEEP/SYNTH 三步,门全机检、任意步可 replay;单 agent 优化穷尽前不建多 agent(反多 agent 化默认)。词条「轮次状态机」=其实现细则。锚:ops/agent/STANDARD.md 原语层(P2)、ops/agent/pipeline.mjs(HEALTH_PIPELINE) - **三层职责线**:宿主只编排、提示词只判断、脚本只测量的职责三分(STANDARD 原语 P5;词条「提示词层/装配层/宿主层」=分层细则);调用权在宿主不在模型。锚:ops/agent/STANDARD.md 原语层(P5)、ops/agent/DESIGN.md - **轮内预算步前检查**:预算控制在动作前(STANDARD 原语 P7)——轮内=每步调用前检查预算并实报累计(budget_truncate 与 reserve-then-settle 同语义),事后 dashboard 不构成预算控制;跨轮面=「预扣结算」词条。锚:ops/agent/STANDARD.md 原语层(P7)、ops/agent/pipeline.mjs(budget_truncate) @@ -138,7 +143,7 @@ - **OTel投影**:轮信封按 OTel GenAI semconv 的外发投影(STANDARD 原语 P20,缺;另票)——内部契约不动,投影=纯读派生。锚:ops/agent/STANDARD.md 原语层(P20) - **跨轮级评测**:Run/Trace/Thread 三级跨轮评测(STANDARD 原语 P21,缺;LangChain 2026-06)——单轮评测之上补轮间轨迹连续性读数。锚:ops/agent/STANDARD.md 原语层(P21) - **失败归因图**:DeFA 式失败传播依赖图(STANDARD 原语 P22,缺)——多步 agent 误差沿依赖图分离定位到步/工具。锚:ops/agent/STANDARD.md 原语层(P22) -- **finding 出口三件套**:巡检 finding(ANCHOR_SWEEP 读数 ok 且 finding>0)的三个出口(#184,spec #180 Q6)——gh issue(needs-triage 标签人裁终局,标准模板四件:来源步/锚:期望vs实得/证据指针/处置面)/告警 webhook(apprise CLI,env IP_RADAR_ALERT_URLS 与 backend _alerts.push 同 env 同形=双消费面;未配置=skip 留痕轮仍成功)/RETRO 摘要行(retro 工件 anchor_sweep 字段,G_RETRO 机检与 08 工件回算一致)。出口永不败轮(失败=失败信封记入 08-anchor-sweep-exits.json+信封 pipeline.finding_exits);dry-run 开关 OPS_SWEEP_DRY_RUN(默认关=真发;测试/golden 恒 1);replay 恒零外发(回声律同款)。**「出口只写不动作」红线**(票面钉死):从 finding 出发仅两写口——gh issue create 与 apprise 推送;无任何调权/停源/写数据面/源管理 API/规则库写口可达,由依赖面闭集结构性断言钉死(export 闭集恰三函数/spawn 目标恰 apprise/禁面符号零在场)。锚:ops/agent/pipeline/finding-exit.mjs、tools.mjs(ghIssueCreate/GH_WRITE_SUBCOMMANDS)、票 #184 +- **finding 出口三件套**:巡检 finding(ANCHOR_SWEEP 读数 ok 且 finding>0)的三个出口(#184,spec #180 Q6)——gh issue(needs-triage 标签人裁终局,标准模板四件:来源步/锚:期望vs实得/证据指针/处置面)/告警 webhook(apprise CLI,env IP_RADAR_ALERT_URLS 与 backend _alerts.push 同 env 同形=双消费面;未配置=skip 留痕轮仍成功)/RETRO 摘要行(retro 工件 anchor_sweep 字段,G_RETRO 机检与 08 工件回算一致)。出口永不败轮(失败=失败信封记入 08-anchor-sweep-exits.json+信封 pipeline.finding_exits);dry-run 开关 OPS_SWEEP_DRY_RUN(默认关=真发;测试/golden 恒 1);replay 恒零外发(回声律同款)。**「出口只写不动作」红线**(票面钉死):从 finding 出发仅两写口——gh issue create 与 apprise 推送;无任何调权/停源/写数据面/源管理 API/规则库写口可达,由依赖面闭集结构性断言钉死(export 闭集恰三函数/spawn 目标恰 apprise/禁面符号零在场)。锚:ops/agent/pipeline/finding-exit.mjs、tools/measurement.mjs(ghIssueCreate)、tools/registry.mjs(GH_WRITE_SUBCOMMANDS)、票 #184 - **emit 守卫**:外发崩溃窗防重发机制(#184)——出口工件先记 phase=emitting 再外发、终记 emitted;同轮重入见 emitting/emitted 一律不重发(崩溃窗内外发状态不可知,宁少算交人工核对:issue 重复开可人关,漏发下轮补);工件缺席=首程照发。与「回声防线」的分工:回声防线管轮间重放不产新产物,emit 守卫管同轮恢复不重复外发。锚:ops/agent/pipeline/finding-exit.mjs(runFindingExits) ## It's working if diff --git a/backend/ipdb/_eval/__main__.py b/backend/ipdb/_eval/__main__.py index aee054bd..ff505f54 100644 --- a/backend/ipdb/_eval/__main__.py +++ b/backend/ipdb/_eval/__main__.py @@ -363,8 +363,9 @@ def run_readout(registry, out_dir, *, assets_dir=None, keysets_dir=None, from .layered import assemble_four_layers, epsilon_sensitivity, layered_readout from .monthly import (compose_action_block, compose_monthly_md, load_anchor_green, load_dispositions, - load_prev_northstar, northstar_record, - render_anchor_oc, render_consensus_diag, + load_leg_status, load_prev_northstar, + northstar_record, render_anchor_oc, + render_consensus_diag, render_leg_status, render_northstar, render_scorecard, render_steal_parking_diag) @@ -448,6 +449,10 @@ def run_readout(registry, out_dir, *, assets_dir=None, keysets_dir=None, (out_dir / f"northstar-{run_id}.json").write_text( json.dumps(ns, indent=1, ensure_ascii=False), encoding="utf-8") north_star_lines = render_northstar(ns) + # 腿级状态行(#193 C5): 月轮壳在 readout 腿前写 leg-status-*.json, + # 此处渲染进月报 —— 腿级红随工件可见(_alerts 只认心跳 mtime, 红态 + # 必须长在工件里); 无件(首轮/手工跑)不出该节, schema-stable + leg_status_lines = render_leg_status(load_leg_status(out_dir)) or None items = compose_action_block(readout, prev_items, dispositions, today) model_dir = out_dir / "model" @@ -476,7 +481,8 @@ def run_readout(registry, out_dir, *, assets_dir=None, keysets_dir=None, "annual": dispositions.get("last_annual_review"), "today": today, "epsilon": eps}, steal_parking_lines=render_steal_parking_diag(keysets_root), - north_star_lines=north_star_lines) + north_star_lines=north_star_lines, + leg_status_lines=leg_status_lines) md_path = out_dir / f"monthly-{run_id}.md" md_path.write_text(md, encoding="utf-8") (out_dir / "action-block.json").write_text(json.dumps( diff --git a/backend/ipdb/_eval/monthly.py b/backend/ipdb/_eval/monthly.py index 94a150e4..140a55a4 100644 --- a/backend/ipdb/_eval/monthly.py +++ b/backend/ipdb/_eval/monthly.py @@ -294,6 +294,43 @@ def load_prev_northstar(out_dir: Path, run_id: str) -> dict | None: return prev +def load_leg_status(out_dir: Path) -> dict | None: + """腿级状态件(#193 C5): /leg-status-*.json 最新一件; + 月轮壳在 readout 腿前写入(腿名→rc, readout=null=本腿在飞), + 接线方渲染进月报头块 —— 腿级红随工件可见(_alerts 只认心跳 + mtime, 红态必须长在工件里, 评审D P1-3)。无件 → None(首轮/ + 手工跑不虚构); 坏件跳过不炸(同 load_prev_northstar 宽容律)。""" + latest = None + for p in sorted(Path(out_dir).glob("leg-status-*.json")): + try: + d = json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, ValueError): + continue + if isinstance(d, dict): + latest = d # 文件名升序扫描 → 末次赋值即最新 + return latest + + +def render_leg_status(leg: dict | None) -> list[str]: + """月报腿级行(#193 C5): 全景一行(腿名=rcN, null→渲染中在飞) + + 红腿单独点名行(仅有红时出)。只观测不持门 —— 红态外显靠月轮壳 + 结尾聚合退出码, 本行只长在工件里供人/机读。""" + if not leg: + return [] + legs = leg.get("legs") or {} + if not legs: + return [] + cells = " | ".join( + f"{name}=" + ("进行中(在飞)" if rc is None else f"rc{rc}") + for name, rc in legs.items()) + lines = [f"- 腿级状态(#193 C5): {cells}"] + red = [f"{k}(rc{v})" for k, v in legs.items() if v not in (0, None)] + if red: + lines.append(f"- 腿级红: {', '.join(red)} —— 红态记录≠管线中止" + "(机器可读件 leg-status-*.json)") + return lines + + def northstar_record(readout: dict, anchor_hist: list[dict], prev_ns: dict | None, *, run_id: str, eval_env: str, today: date) -> dict: @@ -390,7 +427,8 @@ def compose_monthly_md(readout: dict, disclosures_lines: list[str], consensus_diag_lines: list[str], header_meta: dict, steal_parking_lines: list[str] | None = None, - north_star_lines: list[str] | None = None) -> str: + north_star_lines: list[str] | None = None, + leg_status_lines: list[str] | None = None) -> str: """组装月轮报告 v2 markdown。头块 = 环境指纹 + 语料版本/指纹逐层 + S12/S17 换版注记(仅稳定层指纹实变才出, F2 趋势纪律; 共识代理层= 滚动语料豁免, 声明行交代)+ S6 年度递减计数 + @@ -399,7 +437,9 @@ def compose_monthly_md(readout: dict, disclosures_lines: list[str], 接线方并入 consensus_diag_lines;S34 偷泊行经 steal_parking_lines 并入同节 Q22 行之后, 缺省 None 不出, schema-stable)/能力轨北星行 (#185 E-5, north_star_lines 缺省 None 不出, schema-stable; 只观测 - 不持门 —— 无阈值无门语义, 行文自带声明)。F1: 无任何总分列。""" + 不持门 —— 无阈值无门语义, 行文自带声明)/腿级状态行(#193 C5, + leg_status_lines 缺省 None 不出, schema-stable; 腿级红随工件可见, + 红态外显靠月轮壳结尾聚合退出码)。F1: 无任何总分列。""" lines: list[str] = ["# 月轮读数报告 v2", ""] lines.append(f"- 环境指纹(eval_env): {header_meta.get('eval_env', '—')}") corpus = header_meta.get("corpus") or {} @@ -445,6 +485,8 @@ def compose_monthly_md(readout: dict, disclosures_lines: list[str], if north_star_lines: # 能力轨北星行(#185): 头块尾, 只观测不持门 lines += north_star_lines + if leg_status_lines: # 腿级状态行(#193 C5): 北星行后, 同律只观测 + lines += leg_status_lines layers = readout.get("layers", {}) lines += ["", "## 分层读数(T8)", "", diff --git a/backend/ipdb/_registry.py b/backend/ipdb/_registry.py index 160790fe..06b9be27 100644 --- a/backend/ipdb/_registry.py +++ b/backend/ipdb/_registry.py @@ -415,11 +415,12 @@ def enabled_offline_sources() -> list: def _needs_rebuild_of(source) -> bool: - """Per-source: True if the MMDB (or its v6 ptr sidecar) is missing or - older than the raw file. + """Per-source: True if any family env (v4/v6) is missing, older than + the raw file, or stamped with a stale harvest code revision (.rev, + ADR 0009). Single-source form of sources_needing_rebuild, using the same - needs_convert check. Returns False for sources lacking _path/_mmdb_path + needs_convert check. Returns False for sources lacking _path/_lmdb_base (defensive; real offline sources always set them) and for by-design LMDB-less sources (lmdb_backed=False; ptr 永不存在 ≠ 需重建).""" if not getattr(source, "lmdb_backed", True): @@ -428,20 +429,22 @@ def _needs_rebuild_of(source) -> bool: return False from ._sources._lmdb import needs_convert raw_path = getattr(source, "_path", None) - mmdb_path = getattr(source, "_mmdb_path", None) - if raw_path is None or mmdb_path is None: + lmdb_base = getattr(source, "_lmdb_base", None) + if raw_path is None or lmdb_base is None: return False raw = Path(raw_path) - mmdb = Path(mmdb_path) + base = Path(lmdb_base) if not raw.exists(): return False - v6_ptr = getattr(source, "_mmdb6_path", None) - if v6_ptr is not None: + v6_base = getattr(source, "_lmdb6_base", None) + if v6_base is not None: # Q3(spec §8): v6 ptr 缺失 ⇒ v6-aware 代码从未重建过此源 → 触发。 # rebuild 必写 v6 ptr(空数据=空 env),故不会对 C 类源反复触发。 - if needs_convert(raw, Path(v6_ptr)): + # (C2/ADR 0009:比较对象从 ptr 改为族 base —— ptr mtime + .rev + # harvest 版本戳双腿,与 _converged_count 跳过判定恒一致不变。) + if needs_convert(raw, Path(v6_base)): return True - return needs_convert(raw, mmdb) + return needs_convert(raw, base) def set_source_enabled(name: str, enabled: bool) -> dict: diff --git a/backend/ipdb/_source_base.py b/backend/ipdb/_source_base.py index 6e339453..5e773a64 100644 --- a/backend/ipdb/_source_base.py +++ b/backend/ipdb/_source_base.py @@ -67,8 +67,8 @@ def __init__(self, data_dir: Path): self._data_dir = data_dir self._path = data_dir / self.filename self._lmdb_base = data_dir / f"{self.filename}.lmdb" - # registry/scheduler 的 needs_convert 比较对象(名保留):ptr 文件 - # (mtime 随重建刷新,与旧 .mmdb 同语义) + # scheduler 快照契约名(名保留):ptr 文件;C2 后 needs_convert 的 + # 比较对象是 _lmdb_base 族(ptr mtime + .rev harvest 版本戳,ADR 0009) from ._sources._lmdb import ptr_path as _ptr_path self._mmdb_path = _ptr_path(self._lmdb_base) self._reader = None # LMDB env (readonly, lock=False) diff --git a/backend/ipdb/_sources/_base.py b/backend/ipdb/_sources/_base.py index bacde8fd..fe3297be 100644 --- a/backend/ipdb/_sources/_base.py +++ b/backend/ipdb/_sources/_base.py @@ -47,8 +47,8 @@ def __init__(self, data_dir: Path): self._data_dir = data_dir self._path = data_dir / self.filename self._lmdb_base = data_dir / f"{self.filename}.lmdb" - # registry/scheduler 的 needs_convert 比较对象(名保留):ptr 文件 - # (mtime 随重建刷新,与旧 .mmdb 同语义) + # scheduler 快照契约名(名保留):ptr 文件;C2 后 needs_convert 的 + # 比较对象是 _lmdb_base 族(ptr mtime + .rev harvest 版本戳,ADR 0009) from ._lmdb import ptr_path as _ptr_path self._mmdb_path = _ptr_path(self._lmdb_base) self._reader = None # LMDB env (readonly, lock=False) diff --git a/backend/ipdb/_sources/_lmdb.py b/backend/ipdb/_sources/_lmdb.py index a7120ff3..9b850d85 100644 --- a/backend/ipdb/_sources/_lmdb.py +++ b/backend/ipdb/_sources/_lmdb.py @@ -11,6 +11,8 @@ entries 回写,非流式行数——重复行/同起点 CIDR 碰撞下行数 > 键数,旧行为 UI record_count 虚高) .disjoint epoch-bound disjoint flag ( <0|1>) + .rev harvest 版本戳 = HARVEST_REV(ADR 0009,C2; + rebuild 必写,needs_convert 代码代腿比较对象) key = start_ip 4-byte big-endian; value = JSON [end_ip_int, ref_id] — evidence payloads are interned into a "payloads" sub-db (write side drops @@ -320,6 +322,21 @@ def disjoint_path(base: Path) -> Path: return Path(f"{base}.disjoint") # 与 count_path/cov_path 同构,STRING concat +def rev_path(base: Path) -> Path: + return base.parent / (base.name + ".rev") # 同上,STRING concat + + +# ── harvest 版本戳(ADR 0009 / issue #192,2026-10-25)── +# mtime 只代理「数据内容」,代理不了「代码版本」:harvest 代码演进(滤码/ +# 良性断言等)不动 csv mtime → 旧谓词(ptr mtime ≥ raw 即整族跳写)恒 +# False → 新纪元永不建 → 投影静默休眠(2026-10-10 csv 删改仪式 / 2026- +# 10-25 benign② 两起生产事故同形)。任何改变 harvest 语义的改动(parse/ +# 过滤/提交面,backend/ipdb/_sources/** 与 _base/_source_base 的 rebuild +# 路径)必须 bump 此常量(单调 +1);存量族 .rev 缺失 = 不匹配 → 首跑 +# 一次全量重建自愈(部署注记见 CHANGELOG)。详见 docs/adr/0009。 +HARVEST_REV = 1 + + def env_dir(base: Path, epoch: int) -> Path: return base.parent / f"{base.name}.{epoch}" @@ -352,11 +369,23 @@ def read_disjoint_flag(base: Path, current_epoch: int) -> bool: return parse_disjoint_sidecar(base, current_epoch) is True -def needs_convert(raw_path: Path, ptr_like_path: Path) -> bool: - """True if the ptr is missing or older than the raw file.""" - if not ptr_like_path.exists(): +def needs_convert(raw_path: Path, base: Path) -> bool: + """物化谓词(ADR 0009):True = 该族必须重物化。 + + 三触发腿:ptr 缺失 ∨ .rev harvest 版本戳 ≠ 当前代码代(HARVEST_REV; + 代码变)∨ ptr mtime < raw mtime(数据变)。前两腿治「代码演进不 + bump mtime 的静默休眠」;mtime 腿保数据增量语义。存量族无 .rev = + 不匹配 → 一次重建自愈(#192 部署注记:首跑全量重建属预期)。 + """ + ptr = ptr_path(base) + if not ptr.exists(): + return True + try: + if rev_path(base).read_text().strip() != str(HARVEST_REV): + return True + except OSError: # 缺失/不可读 = 不匹配,重建自愈 return True - return ptr_like_path.stat().st_mtime < raw_path.stat().st_mtime + return ptr.stat().st_mtime < raw_path.stat().st_mtime def next_epoch(base: Path) -> int: @@ -756,6 +785,8 @@ def _flush(): staged.append((_write_staged( disjoint_path(base), f"{epoch} {1 if disjoint else 0}"), disjoint_path(base))) + staged.append((_write_staged(rev_path(base), str(HARVEST_REV)), + rev_path(base))) # ADR 0009:rebuild 必盖章 for s, final in staged: # sidecars commit first os.replace(s, final) p_staged = _write_staged(ptr_path(base), str(epoch)) @@ -792,12 +823,13 @@ def _flush(): def _converged_count(base: Path, raw: Path | None) -> int | None: """族级增量的跳过判定 + 跳过族返回计数(2026-10-14 收敛批)。 - None(不跳过,照建)= raw 未给出 / 族 ptr 落后于 raw / count sidecar - 缺失或不可解析。sidecar 缺失不跳:rebuild 即自愈(既有契约, + None(不跳过,照建)= raw 未给出 / 族 ptr 落后于 raw / .rev 版本戳 + 不匹配(ADR 0009,代码代腿) / count sidecar 缺失或不可解析。sidecar + 缺失不跳:rebuild 即自愈(既有契约, test_base_*_reconverts_when_count_sidecar_missing)—— ptr 在而计数 丢失时跳过会把 owner._count 静默归 0。ptr 存在 ⇒ sidecar 先于 ptr 提交(见 rebuild_lmdb 提交序),理论恒在。""" - if raw is None or needs_convert(raw, ptr_path(base)): + if raw is None or needs_convert(raw, base): return None try: return int(count_path(base).read_text().strip()) @@ -829,9 +861,10 @@ def rebuild_dual_family(records, v4_base: Path, v6_base: Path, *, 返回 (n4, n6) = 各族提交后的主库键数(DQ-2:键数即 .count 真值,供 owner._count 直采;CsvSource 旧 count4/count6 证据数覆写通道已删)。 raw: 原始数据路径(族级增量,2026-10-14 收敛批)。给出时每族先判 - needs_convert(raw, 族ptr):ptr ≥ raw mtime(该族已收敛)→ 跳过 —— 不建 - 新纪元不动 reader,返回计数取现有 count sidecar。重试场景(v6 被 - memory valve 中止)只重做未收敛族,v4 不再每次全量重做;与 registry + needs_convert(raw, 族 base):ptr ≥ raw mtime 且 .rev 版本戳匹配 + (ADR 0009;该族已收敛)→ 跳过 —— 不建新纪元不动 reader,返回计数取 + 现有 count sidecar。重试场景(v6 被 memory valve 中止)只重做未收敛族, + v4 不再每次全量重做;与 registry _needs_rebuild_of 同谓词,触发车道与跳过判定恒一致。v6 ptr 缺失 照建(needs_convert 对缺 ptr 恒 True,Q3 不变量不破)。 """ diff --git a/backend/ipdb/_sources/infra_services.py b/backend/ipdb/_sources/infra_services.py index 5594099b..b72f14d7 100644 --- a/backend/ipdb/_sources/infra_services.py +++ b/backend/ipdb/_sources/infra_services.py @@ -112,7 +112,10 @@ def _sync_projection(self) -> None: rebuild 后 ptr 更新即收敛;同内容不再写,无循环。epoch 损坏遇内容 变更亦借此自愈(bump 直接入重建队列,不再 load_error 永久瘫痪)。 (e) 零记录守卫交互:_DATA 若退化到零行,_lmdb 零记录守卫拒提交保旧 - epoch,needs_convert 维持 True 走退避重试——可见失败非静默。""" + epoch,needs_convert 维持 True 走退避重试——可见失败非静默。 + (f) C2 后(ADR 0009,issue #192)harvest 代码演进已由 .rev 版本戳腿 + 统一触发重建,本投影同步机制保留:职责是 _DATA→csv 真相源一致性, + 与版本戳正交。""" if not self._path.exists() or self._path.read_text() != _DATA: self.download() diff --git a/backend/ipdb/_sources/ipinfo_lite.py b/backend/ipdb/_sources/ipinfo_lite.py index c3534bc7..7c1f8f1a 100644 --- a/backend/ipdb/_sources/ipinfo_lite.py +++ b/backend/ipdb/_sources/ipinfo_lite.py @@ -38,7 +38,7 @@ def __init__(self, data_dir: Path): self._gz_path = data_dir / "ipinfo_lite.csv.gz" self._data_dir = data_dir self._lmdb_base = data_dir / "ipinfo_lite.csv.lmdb" - # registry 的 needs_convert 比较对象:ptr 文件(mtime 随重建刷新) + # scheduler 快照契约名(名保留);needs_convert 比较对象 = _lmdb_base 族(C2/ADR 0009) self._mmdb_path = ptr_path_for(self._lmdb_base) # _reader 实为 lmdb readonly env(open_env_read 产物),非 maxminddb.Reader self._reader: Optional[object] = None diff --git a/backend/tests/core/test_fusion_replay_gate_rc.py b/backend/tests/core/test_fusion_replay_gate_rc.py new file mode 100644 index 00000000..eda992d1 --- /dev/null +++ b/backend/tests/core/test_fusion_replay_gate_rc.py @@ -0,0 +1,85 @@ +# backend/tests/core/test_fusion_replay_gate_rc.py +"""fusion_replay_gate rc 机检化守卫(#193 C5,评审D P1-1:回放门=死门)。 + +契约(TDD 先行):main() 退出码按判据聚合—— + rc 0 = 全 PASS 且语料充分; + rc 1 = 任一腿真 FAIL(C2/C3/C4);真 FAIL 优先于语料不足(带病≠没测); + rc 2 = 无真 FAIL 但语料不足/空语料(vacuous PASS 不得冒充绿,空语料 + 下限护栏;与真 FAIL 区分——消费者可分「红」与「没测成」)。 + +seam 两层:gate_rc 纯函数 = rc 聚合单源(判据输入→rc);空目录端到端 +subprocess = main() 接线 seam(真实形状:0 指控源 → n_probe=0 → C2 空 +vacuous PASS / C3、C4 pass=None → rc 2;修复前恒 0 = 死门本证)。 +全仓复核(#193):无既有 rc 消费方依赖恒 0(仅 import smoke 测试与 +GLOSSARY 锚),rc 语义变更安全。 +""" +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +SCRIPTS = REPO / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import fusion_replay_gate # noqa: E402 + + +def test_gate_rc_all_pass_with_sufficient_corpus_is_zero(): + rc, fails, insuff = fusion_replay_gate.gate_rc( + c2_pass=True, c2_strict_n=2361, c2_loose_n=2500, + c3_pass=True, c4_pass=True) + assert rc == 0 + assert fails == [] and insuff == [] + + +def test_gate_rc_any_leg_fail_returns_one(): + # 真 FAIL 优先:即使语料同时不足,带病不折衷成「没测成」 + for kw in ({"c2_pass": False}, + {"c3_pass": False}, + {"c4_pass": False}): + args = dict(c2_pass=True, c2_strict_n=100, c2_loose_n=100, + c3_pass=True, c4_pass=True) + args.update(kw) + rc, fails, _ = fusion_replay_gate.gate_rc(**args) + assert rc == 1, f"{kw} 应 rc=1(真 FAIL 优先于语料不足)" + assert fails, f"{kw} 应点名 FAIL 腿" + + +def test_gate_rc_empty_corpus_returns_two_not_zero(): + # 空语料护栏:0 探测 IP 时 C2 的 PASS 是 vacuous —— 不得冒充绿 + rc, fails, insuff = fusion_replay_gate.gate_rc( + c2_pass=True, c2_strict_n=0, c2_loose_n=0, + c3_pass=True, c4_pass=True) + assert rc == 2 + assert "C2" in insuff + + +def test_gate_rc_partial_c2_corpus_counts_as_insufficient(): + # 严格口径语料在而宽口径空(或反之)= 判据半 vacuous,仍 rc 2 + rc, _, insuff = fusion_replay_gate.gate_rc( + c2_pass=True, c2_strict_n=50, c2_loose_n=0, + c3_pass=True, c4_pass=True) + assert rc == 2 and "C2" in insuff + + +def test_gate_rc_na_legs_are_insufficient_not_fail(): + # C3 benign 语料空 / C4 ρ NaN → pass=None = 「没测成」,rc 2 非 1 + rc, fails, insuff = fusion_replay_gate.gate_rc( + c2_pass=True, c2_strict_n=100, c2_loose_n=100, + c3_pass=None, c4_pass=None) + assert rc == 2 + assert fails == [] and set(insuff) == {"C3", "C4"} + + +def test_main_empty_data_dir_end_to_end_returns_two(tmp_path): + """接线 seam:空数据目录跑真 main()(7s 级)——0 指控源/0 探测 IP, + 今日(修复前)恒 return 0 = 死门;契约后 rc 2(语料不足≠绿)。""" + data = tmp_path / "empty-data" + data.mkdir() + r = subprocess.run( + [sys.executable, str(SCRIPTS / "fusion_replay_gate.py"), + "--data", str(data), "--report", str(tmp_path / "r.md")], + capture_output=True, text=True, timeout=180) + assert r.returncode == 2, ( + f"空语料须 rc=2(语料不足,与真 FAIL 区分),实得 {r.returncode}\n" + f"stderr 尾: {r.stderr[-400:]}") diff --git a/backend/tests/core/test_lmdb_codec.py b/backend/tests/core/test_lmdb_codec.py index b6796792..8038306e 100644 --- a/backend/tests/core/test_lmdb_codec.py +++ b/backend/tests/core/test_lmdb_codec.py @@ -234,6 +234,7 @@ def factory(): assert made == [1] # 跳过的族不白跑 harvest(PEP 289 延迟) for _, e in calls: e.close() + # 场景 B:两族 ptr 均 ≥ raw → 双跳,零 reader 回调零新纪元零 harvest os.utime(ptr_path(v6), (FUT, FUT)) calls = [] @@ -263,6 +264,52 @@ def factory(): for _, e in calls: e.close() +def test_rev_sidecar_stamp_and_drift_rebuilds(tmp_path): + """C2(ADR 0009,issue #192):rebuild 必给两族盖章 .rev=HARVEST_REV; + 戳匹配 + mtime 收敛 → 双跳(harvest 零调用);仅 v4 戳漂移(模拟 + harvest 代码演进,mtime 全程不动)→ 仅 v4 重建,v6 不白跑。""" + import os + from ipdb._sources._lmdb import (HARVEST_REV, rebuild_dual_family, + read_ptr, ptr_path, rev_path) + raw = tmp_path / "s.txt" + raw.write_text("x") + v4, v6 = tmp_path / "s.lmdb", tmp_path / "s.v6.lmdb" + envs = [] + rebuild_dual_family( + [("10.0.0.0/24", [{}]), ("2001:db8::/32", [{}])], + v4, v6, reader_setter4=envs.append, reader_setter6=envs.append, + raw=raw) + for e in envs: e.close() + assert rev_path(v4).read_text().strip() == str(HARVEST_REV) # 两族均盖章 + assert rev_path(v6).read_text().strip() == str(HARVEST_REV) + FUT = 2e9 + os.utime(ptr_path(v4), (FUT, FUT)) # mtime 收敛,后续场景不再动 mtime + os.utime(ptr_path(v6), (FUT, FUT)) + + made = [] + + def factory(): + made.append(1) + return iter([("10.9.0.0/24", [{}]), ("2001:db9::/32", [{}])]) + + calls = [] + rebuild_dual_family(factory, v4, v6, + reader_setter4=lambda e: calls.append(("r4", e)), + reader_setter6=lambda e: calls.append(("r6", e)), + raw=raw) + assert made == [] and calls == [] # 双收敛:双跳,零 harvest + + rev_path(v4).write_text(str(HARVEST_REV - 1)) # 仅 v4 代码代漂移 + rebuild_dual_family(factory, v4, v6, + reader_setter4=lambda e: calls.append(("r4", e)), + reader_setter6=lambda e: calls.append(("r6", e)), + raw=raw) + assert made == [1] # 只跑了 v4 的 harvest + assert [c[0] for c in calls] == ["r4"] # 只有 v4 reader 换代 + assert read_ptr(v4) == 2 and read_ptr(v6) == 1 + for _, e in calls: e.close() + + def test_rebuild_dual_family_v4_only_feed_zeroes_v6_phantom(tmp_path): """T4(2026-10-14 收敛批)sfs v6 幻影:历史 v6 count=1 的库遇到 v4-only feed → n4>0(主族健康)时空 v6 重建允许提交,count 归 0;修复前此处被 diff --git a/backend/tests/core/test_registry_valve.py b/backend/tests/core/test_registry_valve.py index e27cefa8..c89d8563 100644 --- a/backend/tests/core/test_registry_valve.py +++ b/backend/tests/core/test_registry_valve.py @@ -7,13 +7,12 @@ def test_sources_needing_rebuild_uses_needs_convert(tmp_path, monkeypatch): import ipdb._registry as reg # 构造一个 needs_convert=True 的源 (tmp_path / "raw.txt").write_text("1.2.3.0/24\n") - (tmp_path / "raw.txt.mmdb").write_bytes(b"") # 空/旧 mmdb import ipdb._sources._lmdb as lmdb_storage - monkeypatch.setattr(lmdb_storage, "needs_convert", lambda r, m: True) - # monkeypatch _enabled_sources 返回一个假源 + monkeypatch.setattr(lmdb_storage, "needs_convert", lambda r, b: True) + # monkeypatch _enabled_sources 返回一个假源(_lmdb_base 族契约,C2/ADR 0009) class _S: name = "t"; _path = tmp_path / "raw.txt" - _mmdb_path = tmp_path / "raw.txt.mmdb" + _lmdb_base = tmp_path / "raw.txt.lmdb" monkeypatch.setattr(reg, "_enabled_sources", lambda: [_S()]) assert "t" in reg.sources_needing_rebuild() diff --git a/backend/tests/core/test_scheduler.py b/backend/tests/core/test_scheduler.py index b7ed42cf..febe9d26 100644 --- a/backend/tests/core/test_scheduler.py +++ b/backend/tests/core/test_scheduler.py @@ -97,27 +97,34 @@ def test_needs_rebuild_of_detects_stale_mmdb(tmp_path): class _FakeOffline: def __init__(self, p): self._path = p - self._mmdb_path = Path(str(p) + ".mmdb") + self._lmdb_base = Path(str(p) + ".lmdb") # C2/ADR 0009:族契约属性 raw = tmp_path / "raw.txt" - mmdb = tmp_path / "raw.txt.mmdb" + from ipdb._sources._lmdb import HARVEST_REV, ptr_path, rev_path + base = Path(str(raw) + ".lmdb") - # raw exists, mmdb missing -> needs rebuild + # raw exists, 族 ptr 缺失 -> needs rebuild raw.write_text("x") f = _FakeOffline(raw) assert _needs_rebuild_of(f) is True - # mmdb newer than raw -> does not need rebuild - mmdb.write_text("x") + # ptr 新于 raw + .rev 戳匹配 -> does not need rebuild + ptr_path(base).write_text("1") + rev_path(base).write_text(str(HARVEST_REV)) fut = time.time() + 100 import os - os.utime(mmdb, (fut, fut)) + os.utime(ptr_path(base), (fut, fut)) assert _needs_rebuild_of(f) is False - # raw newer than mmdb -> needs rebuild + # raw newer than ptr -> needs rebuild(mtime 腿) os.utime(raw, (fut + 200, fut + 200)) assert _needs_rebuild_of(f) is True + # .rev 戳漂移(代码代演进,mtime 收敛)-> needs rev-leg rebuild + os.utime(ptr_path(base), (fut + 400, fut + 400)) + rev_path(base).write_text(str(HARVEST_REV - 1)) + assert _needs_rebuild_of(f) is True + # ── Scheduler-logic tests (no threads; scan() called directly) ── @@ -137,6 +144,7 @@ def __init__(self, name, path, is_stale=False, mtime=None, mmdb_path=None, from pathlib import Path self._path = Path(path) self._mmdb_path = mmdb_path or Path(str(path) + ".mmdb") + self._lmdb_base = Path(str(path) + ".lmdb") # C2:族契约(needs_convert 对象) if mtime is not None: self.set_mtime(mtime) diff --git a/backend/tests/core/test_storage_helpers.py b/backend/tests/core/test_storage_helpers.py index e4804732..c168912a 100644 --- a/backend/tests/core/test_storage_helpers.py +++ b/backend/tests/core/test_storage_helpers.py @@ -16,12 +16,67 @@ def test_needs_convert_respects_mtime(tmp_path): raw.write_text("8.8.8.0/24\n") base = tmp_path / "raw.csv.lmdb" ptr = tmp_path / "raw.csv.lmdb.ptr" - assert needs_convert(raw, ptr) is True # no ptr yet + assert needs_convert(raw, base) is True # no ptr yet build_lmdb([("8.8.8.0/24", {"v": 1})], base) os.utime(ptr, (raw.stat().st_mtime + 100,) * 2) # ptr strictly newer (deterministic) - assert needs_convert(raw, ptr) is False + assert needs_convert(raw, base) is False os.utime(raw, (ptr.stat().st_mtime + 100,) * 2) # raw strictly newer - assert needs_convert(raw, ptr) is True + assert needs_convert(raw, base) is True + + +def test_needs_convert_respects_harvest_rev(tmp_path): + """C2 物化谓词代码代腿(ADR 0009,issue #192)。 + + 判例两起同形:harvest 代码演进(滤码/良性断言)不动 csv mtime, + 旧谓词(ptr mtime≥raw 即跳)恒 False → 投影静默休眠 + (2026-10-10 csv 删改仪式 / 2026-10-25 benign②)。 + """ + from ipdb._sources._lmdb import HARVEST_REV, rev_path + + raw = tmp_path / "raw.csv" + raw.write_text("8.8.8.0/24\n") + base = tmp_path / "raw.csv.lmdb" + ptr = tmp_path / "raw.csv.lmdb.ptr" + build_lmdb([("8.8.8.0/24", {"v": 1})], base) + os.utime(ptr, (raw.stat().st_mtime + 100,) * 2) # mtime 全程收敛 + assert rev_path(base).read_text().strip() == str(HARVEST_REV) # rebuild 必盖章 + assert needs_convert(raw, base) is False # 戳匹配 + 收敛 + + rev_path(base).unlink() # 存量族形态:无 .rev + assert needs_convert(raw, base) is True # → 不匹配,重建自愈 + + rev_path(base).write_text(str(HARVEST_REV - 1)) # 代码代落后于常量 + assert needs_convert(raw, base) is True + + rev_path(base).write_text(str(HARVEST_REV)) # 恢复匹配 → 不触发 + assert needs_convert(raw, base) is False + + +def test_registry_rebuild_ignition_on_rev_drift(tmp_path): + """_needs_rebuild_of 触发车道必须与跳过判定同谓词:版本戳漂移单独 + 即可点火(mtime 零变化),恢复匹配即熄火。""" + from ipdb._registry import _needs_rebuild_of + from ipdb._sources._lmdb import HARVEST_REV, rev_path + + raw = tmp_path / "raw.csv" + raw.write_text("8.8.8.0/24\n") + base = tmp_path / "raw.csv.lmdb" + ptr = tmp_path / "raw.csv.lmdb.ptr" + build_lmdb([("8.8.8.0/24", {"v": 1})], base) + os.utime(ptr, (raw.stat().st_mtime + 100,) * 2) + + class _Src: # 最小桩:_needs_rebuild_of 契约属性 + _path = raw + _lmdb_base = base + lmdb_backed = True + + assert _needs_rebuild_of(_Src()) is False # 戳匹配 + 收敛 + rev_path(base).write_text(str(HARVEST_REV - 1)) # 仅代码代漂移 + assert _needs_rebuild_of(_Src()) is True # 点火:零 mtime 变化 + rev_path(base).unlink() # 存量族无 .rev + assert _needs_rebuild_of(_Src()) is True + rev_path(base).write_text(str(HARVEST_REV)) + assert _needs_rebuild_of(_Src()) is False # 熄火 def test_reload_leaves_prior_reader_to_refcount(tmp_path, monkeypatch): diff --git a/backend/tests/eval/test_eval_monthly.py b/backend/tests/eval/test_eval_monthly.py index a17da262..0a78dd20 100644 --- a/backend/tests/eval/test_eval_monthly.py +++ b/backend/tests/eval/test_eval_monthly.py @@ -20,8 +20,10 @@ compose_action_block, compose_monthly_md, load_dispositions, + load_leg_status, render_anchor_oc, render_consensus_diag, + render_leg_status, render_scorecard, render_steal_parking_diag, ) @@ -404,3 +406,51 @@ def test_md_steal_parking_lines_after_consensus_default_none(): default = compose_monthly_md(_readout(), [], [], [], ["- 池大小: 570"], _header_meta()) assert "偷泊" not in default # 缺省 None → 节不变(schema-stable) + + +# ── #193 C5: 腿级状态件读法 + 月报腿级行 ─────────────────────── + +def test_load_leg_status_picks_latest_and_skips_bad(tmp_path): + """月轮壳写 leg-status-.json(readout 渲染前件);读法同 + load_prev_northstar 形制: 排序 glob 取最新, 坏件跳过不炸(宽容律)。""" + (tmp_path / "leg-status-20261024-010101.json").write_text( + "{ bad json", encoding="utf-8") + (tmp_path / "leg-status-20261024-010102.json").write_text( + json.dumps({"schema": "leg-status/1", + "legs": {"model": 0, "anchors": 1, "readout": None}}), + encoding="utf-8") + leg = load_leg_status(tmp_path) + assert leg is not None + assert leg["legs"]["anchors"] == 1 and leg["legs"]["readout"] is None + + +def test_load_leg_status_missing_returns_none(tmp_path): + assert load_leg_status(tmp_path) is None # 首轮无件 → 不虚构 + + +def test_render_leg_status_red_named_null_in_flight(): + lines = render_leg_status({"schema": "leg-status/1", + "legs": {"model": 0, "anchors": 1, + "readout": None}}) + joined = "\n".join(lines) + assert "anchors=rc1" in joined # 红腿带 rc 点名 + assert "进行中" in joined # readout null=渲染时本腿在飞 + assert "model=rc0" in joined # 绿腿全景一行(rcN 紧凑形) + assert any("腿级红" in l for l in lines) # 红腿单独点名一行 + + +def test_render_leg_status_all_green_no_red_callout(): + lines = render_leg_status({"schema": "leg-status/1", + "legs": {"model": 0, "readout": None}}) + assert not any("腿级红" in l for l in lines) + + +def test_md_leg_status_lines_position_and_default_none(): + md = compose_monthly_md( + _readout(), [], [], [], ["- 池大小: 570"], _header_meta(), + north_star_lines=["- 北星: x"], + leg_status_lines=["- 腿级状态: model=0 | anchors=1"]) + assert md.index("北星") < md.index("腿级状态") < md.index("## 分层读数") + default = compose_monthly_md(_readout(), [], [], [], ["- 池大小: 570"], + _header_meta()) + assert "腿级状态" not in default # 缺省 None 不出(schema-stable) diff --git a/backend/tests/eval/test_eval_readout_cli.py b/backend/tests/eval/test_eval_readout_cli.py index 8912562e..d50d5055 100644 --- a/backend/tests/eval/test_eval_readout_cli.py +++ b/backend/tests/eval/test_eval_readout_cli.py @@ -276,3 +276,34 @@ def test_readout_northstar_second_round_mom(tmp_path): assert "corroborated 精度 100.0%" in text assert "环比 -10.5pp" in text assert "环比 前轮 N/A" in text + + +def test_readout_embeds_leg_status_lines(tmp_path): + """#193 C5 接线: 月轮壳在 readout 腿前写 leg-status-*.json(腿级 rc, + readout=null=在飞), run_readout 须把它渲染进月报头块——腿级红随 + 月报工件可见(_alerts 只认心跳 mtime, 红态必须长在工件里)。""" + assets, kdir, rep = tmp_path / "assets", tmp_path / "keysets", tmp_path / "rep" + rep.mkdir() + (rep / "leg-status-20261025-010101.json").write_text( + json.dumps({"schema": "leg-status/1", "generated_utc": "2026-10-25T01:01:01+00:00", + "legs": {"model": 0, "all": 0, "anchors": 1, "dsem": 0, + "agg-share": 0, "audit": 0, "temporal": 0, + "readout": None}}), + encoding="utf-8") + reg = _FakeRegistry(_build_sources(tmp_path)) + md, _, _ = run_readout(reg, rep, assets_dir=assets, keysets_dir=kdir, + run_seed=1, run_id="r9") + text = md.read_text(encoding="utf-8") + assert "腿级状态" in text + assert "anchors" in text and "1" in text.split("腿级状态")[1].splitlines()[0] + assert "进行中" in text # readout=null → 渲染时在飞 + + +def test_readout_without_leg_status_file_omits_section(tmp_path): + """旧轮/手工跑无 leg-status 件 → 头块不出该节(schema-stable, + 同 steal_parking/north_star 缺省律)。""" + assets, kdir, rep = tmp_path / "assets", tmp_path / "keysets", tmp_path / "rep" + reg = _FakeRegistry(_build_sources(tmp_path)) + md, _, _ = run_readout(reg, rep, assets_dir=assets, keysets_dir=kdir, + run_seed=1, run_id="r10") + assert "腿级状态" not in md.read_text(encoding="utf-8") diff --git a/backend/tests/eval/test_monthly_eval_cascade.py b/backend/tests/eval/test_monthly_eval_cascade.py index b5b99f09..3ec511d6 100644 --- a/backend/tests/eval/test_monthly_eval_cascade.py +++ b/backend/tests/eval/test_monthly_eval_cascade.py @@ -1,11 +1,15 @@ """monthly_eval.sh 锚腿级联语义守卫(2026-10-24 终审修单 F4)。 -契约(TDD 先行):--anchors 腿=回归轨判定门,失败 rc=1 时—— - ① 工件照写(anchors-*.json 落盘留证,证据不灭); - ② 后续腿照跑(dsem/audit/temporal/readout),月报+北星照产; - ③ 红态可见:运行日志红态行 + 脚本以捕获 rc 退出(红态外显不吞); - ④ 其余腿 set -e 语义不变:非 anchors 腿失败仍立即中止(缺员即死, - 不推心跳,「回归红态记录不等于管线中止」只对 anchors 判定腿生效)。 +契约(TDD 先行,#193 C5 全腿版,承 2026-10-24 F4 anchors 判例推广): + ① 任一腿红(model/all/anchors/dsem/audit/temporal/readout)不中止 + 后续腿,工件照写留证(证据不灭); + ② 红态可见三面:运行日志红态行 + 腿级状态件 leg-status-*.json + (机器可读,报告根)+ 结尾以腿序首个非零 rc 退出(红态外显不吞); + ③ 心跳照推:腿级红≠缺员,报告族齐全(工件在场)即活;缺员即死 + 不变(工件缺席的腿=半途中止=死,不推心跳); + ④ leg-status-*.json 不得顶替 all 腿工件(报告族 all 模式须排除 + 本件,观测件永不触活族门); + ⑤ mirror-check/rsync 守卫不变(数据面先于读数面,塔缩即止)。 守护对象是 bash 级联形状,不是 eval 数值——数值面归 test_eval_anchors.py。 harness 全密闭:临时 git 仓(master+clean 过守卫)+ PATH 桩(python3/rsync/ @@ -26,19 +30,35 @@ # cascade-test stub:按 argv 模拟 ipdb._eval 各腿(工件照写;rc 按 *_MODE) # mirror_check 步真脚本不带 IP_RADAR_EVAL_DIR → 该分支不展开工件路径 if [[ "$*" == *"ipdb._eval.mirror_check"* ]]; then exit 0; fi + # #193 腿级状态件写手桩:shell 侧 write_leg_status 经 python3 heredoc + # 调用(无 -m ipdb._eval);模拟真写手 argv 形状(path 腿 rc ...)落 JSON + # python3 - args 调用形:$1="-" $2=path —— 守卫查 $2 + if [ "$#" -ge 2 ] && [[ "$2" == *leg-status-*.json ]]; then + out_doc="$2"; shift 2 + { printf '{"schema":"leg-status/1","legs":{' + sep="" + while [ "$#" -ge 2 ]; do + printf '%s"%s":%s' "$sep" "$1" "$2" + sep="," + shift 2 + done + printf '}}' + } > "$out_doc" + exit 0 + fi ts="$(date -u +%Y%m%d-%H%M%S)" out="${IP_RADAR_EVAL_DIR:?}/monthly" mkdir -p "$out/model" ARGS="$*" # 腿判定基准=脚本级 argv(函数内 $* 会遮蔽) leg() { case " $ARGS " in *" --$1 "*) return 0;; *) return 1;; esac; } - if leg model; then printf '{"kind":"model"}' > "$out/model/model-$ts.json"; fi - if leg all; then printf '{"kind":"all"}' > "$out/all-$ts.json"; fi + if leg model; then printf '{"kind":"model"}' > "$out/model/model-$ts.json"; MODEL_RC="${MODEL_MODE:-0}"; fi + if leg all; then if [ "${ALL_SKIP:-0}" != 1 ]; then printf '{"kind":"all"}' > "$out/all-$ts.json"; ALL_RC="${ALL_MODE:-0}"; fi; fi if leg agg-share; then printf '{"kind":"agg-share"}' > "$out/model/agg-share-$ts.json"; fi if leg dsem; then printf '{"kind":"dsem"}' > "$out/model/dsem-$ts.json"; DSEM_RC="${DSEM_MODE:-0}"; fi - if leg audit; then printf '{"kind":"audit"}' > "$out/model/audit-$ts.json"; fi - if leg temporal; then printf '{"kind":"temporal"}' > "$out/temporal-$ts.json"; fi + if leg audit; then printf '{"kind":"audit"}' > "$out/model/audit-$ts.json"; AUDIT_RC="${AUDIT_MODE:-0}"; fi + if leg temporal; then printf '{"kind":"temporal"}' > "$out/temporal-$ts.json"; TEMPORAL_RC="${TEMPORAL_MODE:-0}"; fi if leg readout; then printf '# monthly\\n' > "$out/monthly-$ts.md" - printf '{"kind":"northstar"}' > "$out/northstar-$ts.json"; fi + printf '{"kind":"northstar"}' > "$out/northstar-$ts.json"; READOUT_RC="${READOUT_MODE:-0}"; fi if leg anchors; then # 18/19 红形态:判定门失败也落盘留证(anchors_report 真实形状) printf '{"kind":"anchors","n_fail":%s}' "${ANCHORS_MODE:-0}" > "$out/model/anchors-$ts.json" @@ -46,8 +66,10 @@ fi # future-stamp:stub 秒级跑完,同秒 mtime 会假阴 -newermt(真月轮=分钟级无此界) find "$out" -type f -exec touch -d "@$(( $(date +%s) + 2 ))" {} + 2>/dev/null || true - [ "${ANCHORS_RC:-0}" = 0 ] || exit 1 - [ "${DSEM_RC:-0}" = 0 ] || exit 1 + # 腿 rc 聚合退出(红腿外显自身 rc;#193 各腿不再互断) + for v in MODEL_RC ALL_RC ANCHORS_RC DSEM_RC AUDIT_RC TEMPORAL_RC READOUT_RC; do + [ "${!v:-0}" = 0 ] || exit "${!v}" + done exit 0 """ ) @@ -82,13 +104,23 @@ def _make_stubs(tmp_path: Path) -> Path: def _run_monthly(repo: Path, stubs: Path, tmp_path: Path, *args: str, - anchors_mode: str = "0", dsem_mode: str = "0") -> tuple[int, str]: + anchors_mode: str = "0", dsem_mode: str = "0", + model_mode: str = "0", all_mode: str = "0", + audit_mode: str = "0", temporal_mode: str = "0", + readout_mode: str = "0", all_skip: bool = False, + ) -> tuple[int, str]: env = dict(os.environ) env["PATH"] = f"{stubs}:{env['PATH']}" env["EVAL_DATA_DIR"] = str(tmp_path / "data") env["HEARTBEAT_MARKER"] = str(tmp_path / "heartbeat-marker") env["ANCHORS_MODE"] = anchors_mode env["DSEM_MODE"] = dsem_mode + env["MODEL_MODE"] = model_mode + env["ALL_MODE"] = all_mode + env["AUDIT_MODE"] = audit_mode + env["TEMPORAL_MODE"] = temporal_mode + env["READOUT_MODE"] = readout_mode + env["ALL_SKIP"] = "1" if all_skip else "0" r = subprocess.run(["bash", "scripts/monthly_eval.sh", "stub-host", *args], cwd=repo, env=env, capture_output=True, text=True, timeout=120) return r.returncode, r.stdout + r.stderr @@ -141,16 +173,82 @@ def test_anchors_green_round_exits_zero(tmp_path): assert list(_report_root(repo).glob("monthly-*.md")) -def test_other_leg_failure_still_aborts(tmp_path): - """set -e 对其余腿不变:dsem 失败仍立即中止,readout/心跳不发生。""" +def _latest_leg_status(root: Path) -> dict: + import json as _json + files = sorted(root.glob("leg-status-*.json")) + assert files, "leg-status 工件缺席" + return _json.loads(files[-1].read_text(encoding="utf-8")) + + +def test_other_leg_failure_does_not_abort_round(tmp_path): + """#193 C5(F4 判例推广到全腿):dsem 腿红不再断整轮——后续腿照跑、 + 月报/北星照产、心跳照推(腿级红≠缺员)、结尾外显 dsem rc、 + leg-status 工件留红。""" repo = _make_repo(tmp_path) stubs = _make_stubs(tmp_path) - rc, out = _run_monthly(repo, stubs, tmp_path, anchors_mode="0", dsem_mode="1") + rc, out = _run_monthly(repo, stubs, tmp_path, anchors_mode="0", dsem_mode="3") + assert rc == 3, f"腿红须外显自身 rc(3),实得 {rc}\n{out}" + assert "红态" in out, out + for leg in ("audit", "temporal", "readout"): + assert f"==> {leg}:" in out, f"dsem 红中止了后续腿 {leg}\n{out}" + assert "完成" in out, out + root = _report_root(repo) + assert list(root.glob("model/dsem-*.json")), "红腿工件须照写留证" + assert list(root.glob("monthly-*.md")), "月报须照产" + leg = _latest_leg_status(root) + assert leg["legs"]["dsem"] == 3, leg + assert (tmp_path / "heartbeat-marker").exists(), "腿级红≠缺员,须照推心跳" + + +def test_multi_red_aggregates_first_nonzero_in_leg_order(tmp_path): + """双腿红:结尾 rc=腿序(model→all→anchors→…)首个非零; + leg-status 工件两红全留(机器可读面不丢腿)。""" + repo = _make_repo(tmp_path) + stubs = _make_stubs(tmp_path) + rc, out = _run_monthly(repo, stubs, tmp_path, + anchors_mode="1", dsem_mode="2") + assert rc == 1, f"腿序首个非零=anchors(1),实得 {rc}\n{out}" + leg = _latest_leg_status(_report_root(repo)) + assert leg["legs"]["anchors"] == 1 and leg["legs"]["dsem"] == 2, leg + assert (tmp_path / "heartbeat-marker").exists(), out + + +def test_green_round_writes_leg_status_with_all_legs(tmp_path): + """全绿轮:leg-status 件在(8 腿全 rc=0,readout 定稿件非 null)。 + 渲染前件(readout=null 供月报嵌「在飞」)被定稿件覆盖或共存, + 最新件=定稿件。""" + repo = _make_repo(tmp_path) + stubs = _make_stubs(tmp_path) + rc, out = _run_monthly(repo, stubs, tmp_path) + assert rc == 0, out + leg = _latest_leg_status(_report_root(repo)) + for name in ("model", "all", "anchors", "dsem", "agg-share", + "audit", "temporal", "readout"): + assert leg["legs"][name] == 0, f"{name} 腿绿轮须 rc=0: {leg}" + + +def test_leg_status_does_not_substitute_all_family(tmp_path): + """报告族 all 模式须排除 leg-status-*:all 腿工件缺席时,同形状的 + leg-status-.json 不得顶替逐源 verdict 件满足校验(观测件永不 + 触活族门,缺员即死不推心跳)。""" + repo = _make_repo(tmp_path) + stubs = _make_stubs(tmp_path) + rc, out = _run_monthly(repo, stubs, tmp_path, all_skip=True) assert rc != 0, out - assert "==> temporal:" not in out, "非锚腿失败不得级联后续腿" - assert "==> readout:" not in out - assert "完成" not in out - assert not (tmp_path / "heartbeat-marker").exists(), "半途中止不得推心跳" + assert "报告族不齐" in out, out + assert not (tmp_path / "heartbeat-marker").exists(), "缺员即死不推心跳" + + +def test_readout_red_still_exits_nonzero_with_artifacts(tmp_path): + """readout 腿自身红(工件已落):心跳照推、定稿 leg-status 记 + readout rc、结尾外显(单腿红不再静默 35 天,评审D P1-3)。""" + repo = _make_repo(tmp_path) + stubs = _make_stubs(tmp_path) + rc, out = _run_monthly(repo, stubs, tmp_path, readout_mode="4") + assert rc == 4, f"readout 红须外显 rc=4,实得 {rc}\n{out}" + leg = _latest_leg_status(_report_root(repo)) + assert leg["legs"]["readout"] == 4, leg + assert (tmp_path / "heartbeat-marker").exists(), out def test_agg_share_leg_in_report_family(tmp_path): diff --git a/backend/tests/sources/test_infra_services.py b/backend/tests/sources/test_infra_services.py index b7b2ebe4..e9679a18 100644 --- a/backend/tests/sources/test_infra_services.py +++ b/backend/tests/sources/test_infra_services.py @@ -65,12 +65,12 @@ def test_projection_rematerializes_on_content_drift(tmp_path, monkeypatch): assert s.rebuild() == 1 # 活纪元建于旧 _DATA new_data = "8.8.8.8,dns,Google Public DNS\n203.0.113.9,dns,New Row\n" monkeypatch.setattr(infra_mod, "_DATA", new_data) # 模拟新版代码 - assert not needs_convert(csv_path, s._mmdb_path) # 死锁态:csv 老 ptr 新 + assert not needs_convert(csv_path, s._lmdb_base) # 死锁态:csv 老 ptr 新 m0 = csv_path.stat().st_mtime_ns assert s.load() == 1 # 旧 epoch 照常 mmap(重建交给调度器) assert csv_path.read_text() == new_data # 投影已追平真相 assert csv_path.stat().st_mtime_ns > m0 # mtime bump = rebuild 入队信号 - assert needs_convert(csv_path, s._mmdb_path) # → 一次 rebuild 在队 + assert needs_convert(csv_path, s._lmdb_base) # → 一次 rebuild 在队 def test_projection_no_rewrite_when_content_matches(tmp_path): @@ -115,8 +115,8 @@ def test_projection_converges_after_one_rebuild(tmp_path, monkeypatch): # 重建路径同样同步投影(未经 load 直入 rebuild 也不采到旧 csv) assert s.rebuild() == 32 assert s.query("203.0.113.9")[0]["service"] == "dns" - assert not needs_convert(csv_path, s._mmdb_path) # ptr 已新 → 收敛 + assert not needs_convert(csv_path, s._lmdb_base) # ptr+戳已新 → 收敛 m1 = csv_path.stat().st_mtime_ns s.load() # 收敛后零写,无循环 assert csv_path.stat().st_mtime_ns == m1 - assert not needs_convert(csv_path, s._mmdb_path) + assert not needs_convert(csv_path, s._lmdb_base) diff --git a/backend/tests/sources/test_ipinfo_lite_rebuild.py b/backend/tests/sources/test_ipinfo_lite_rebuild.py index 2cac1e29..9db2b435 100644 --- a/backend/tests/sources/test_ipinfo_lite_rebuild.py +++ b/backend/tests/sources/test_ipinfo_lite_rebuild.py @@ -49,7 +49,7 @@ def test_ipinfo_lite_mmdb_path_points_at_ptr(tmp_path): s = IPinfoLiteSource(tmp_path) assert s._mmdb_path.name == "ipinfo_lite.csv.lmdb.ptr" (tmp_path / "ipinfo_lite.csv").write_text("network\n1.0.0.0/24\n") - assert needs_convert(tmp_path / "ipinfo_lite.csv", s._mmdb_path) is True + assert needs_convert(tmp_path / "ipinfo_lite.csv", s._lmdb_base) is True def test_ipinfo_lite_query_returns_three_new_keys(tmp_path): diff --git a/backend/tests/test_ops_review.py b/backend/tests/test_ops_review.py index c80e0437..4b925828 100644 --- a/backend/tests/test_ops_review.py +++ b/backend/tests/test_ops_review.py @@ -23,10 +23,11 @@ import json import re -from datetime import date +from datetime import date, timedelta from pathlib import Path import pytest +from ipdb._ops_review import _RULE_REVIEW_DAYS import yaml # ── 独立真源字面(spec #152 §3 / 判例固定形同 test_ops_ruling)── @@ -561,8 +562,8 @@ def test_adopt_rule_agree_writes_suppression_rule(admin_env, client_as_admin, rule = doc["rules"][0] assert rule["id"] == "R1" assert rule["adopted_via"] == f"item:{iid}" - assert rule["adopted"] == "2026-10-24" - assert rule["review_at"] == "2027-01-22" # 采纳+90 天(10-24 → 次年 01-22) + assert rule["adopted"] == date.today().isoformat() + assert rule["review_at"] == (date.today() + timedelta(days=_RULE_REVIEW_DAYS)).isoformat() # 采纳+90 天 assert rule["match"] == {"bucket": "unreachable", "source": "ciarm", "error_family": "http_404"} assert rule["action"] == "suppress" diff --git a/notes/c2-predicate-ablation-2026-10-25.md b/notes/c2-predicate-ablation-2026-10-25.md new file mode 100644 index 00000000..3e75d20c --- /dev/null +++ b/notes/c2-predicate-ablation-2026-10-25.md @@ -0,0 +1,37 @@ +# C2 物化谓词消融记录(2026-10-25,#192 / ADR 0009) + +门脚本:`scripts/c2_predicate_gates.py`(worktree 内运行,数据面从主仓 +`backend/data` 只读 `cp -a` 到 worktree `backend/data/gate/`,绝不写主仓)。 +两次连跑全绿;首跑曾出现一次「rev 前进后收敛」FAIL(cps 段),隔离复跑 +与后续两轮全跑均绿,判定为文件系统 mtime 一次性抖动,非谓词语义(位等价 +逐字节比对含 mtime_ns,敏感面最大)。 + +## 门① 位等价(代码未变 → 连续 rebuild 零副作用) + +对三源各自:迁移重建(存量族无 `.rev`,版本戳腿触发,纪元 3→4,计数 +不变)→ 快照(sidecar 字节 + 纪元目录清单 + 逐文件 sha256 + mtime_ns) +→ 再 rebuild → 快照逐位相等。**结论:增量跳写语义保住——数据未变+代码 +未变时,不重建、不重开 reader、不碰任何 sidecar mtime。** + +## 门② 消融(touch 前后 × 版本戳机制) + +对照两路径(同 csv 字节、同代码): +- **旧仪式**:touch csv(mtime 腿)→ 强制重建; +- **C2 机制**:进程内 `HARVEST_REV +1`(模拟 harvest 代码演进),csv + mtime 全程不动 → 版本戳腿强制重建。 + +| 源 | v4/v6 计数(touch 路径) | 计数(rev 路径) | 排除/告警日志 | mtime 不动 | +|---|---|---|---|---| +| myip_ms | 1563 / 56 | 1563 / 56(相等) | 0 行 = 0 行 | ✓ | +| ziyadnz | 123106 / 0 | 123106 / 0(相等) | 46 行 multiset 相等 | ✓ | +| cps | 32715 / 0 | 32715 / 0(相等) | 0 行 = 0 行 | ✓ | + +**结论:版本戳机制产出与 touch 强制重建逐计数相等、日志 multiset 相等 +——touch 不再必要;代码演进无需动数据文件即触发真重建。**rev 前进后 +`.rev` 已= 新代,再 rebuild 零副作用,无循环。 + +## 迁移注记(部署预期) + +存量族(41 源)无 `.rev` = 版本戳不匹配 → 首跑一次全量重建自愈(计数 +不变已由上表「迁移重建计数不变」钉死);此后仅在 HARVEST_REV 前进或 +raw 更新时重建。ziyadnz 46 行日志 = 既有解析排除日志,两路径一致。 diff --git a/ops/agent/STANDARD.md b/ops/agent/STANDARD.md index e2769604..b09a1874 100644 --- a/ops/agent/STANDARD.md +++ b/ops/agent/STANDARD.md @@ -12,12 +12,12 @@ | # | 原语 | 2026 出处(研究底座) | 内部状态 | 内部锚 / 补课 | |---|---|---|---|---| -| P1 | 信封纪律 | FlowGuard 2026-07;EpiSEM-Bench 2026-10 | 已有 | ops/agent/tools.mjs(classifyChannelError 四码) | +| P1 | 信封纪律 | FlowGuard 2026-07;EpiSEM-Bench 2026-10 | 已有 | ops/agent/tools/envelope.mjs(classifyChannelError 四码) | | P2 | 状态机骨架 | Gartner 2026-10;swarm tax 2026-05 | 已有 | ops/agent/pipeline.mjs(HEALTH_PIPELINE,模型只在三步) | | P3 | 谱系去重 | swarm tax 2026-05(算力对齐同构);对撞表独有列 | 已有 | backend/ipdb/_logodds.py(dedup_lineage) | | P4 | 回放闸门 | Braintrust 2026-09;promptfoo 2026-10 | 已有 | ops/agent/golden(字节等价)+PROBE 位等价判例(#169) | | P5 | 三层职责线 | Microsoft 2026-06;ACM Queue 2026-10 | 已有 | ops/agent/DESIGN.md(ADR 0007) | -| P6 | 工具白名单 | MCP-ITP 2026-01;NSA CSI 2026-06;MCP spec 2026-07 | 已有 | ops/agent/tools.mjs(MEASUREMENT_TOOLS 闭集) | +| P6 | 工具白名单 | MCP-ITP 2026-01;NSA CSI 2026-06;MCP spec 2026-07 | 已有 | ops/agent/tools/registry.mjs(MEASUREMENT_TOOLS 闭集) | | P7 | 轮内预算步前检查 | Latentism 2026-10(调用前检查印证) | 已有 | ops/agent/pipeline.mjs(budget_truncate) | | P8 | 阶段门 | promptfoo 2026-10(CI fail-PR) | 已有 | ops/agent/pipeline/gates.mjs(gates.json 留痕) | | P9 | 判例升格通道 | Agentic Auditing 2026-10(人验签核平行;外部无直接对应) | 已有 | ops/agent/promote-detect.mjs | @@ -77,7 +77,8 @@ #### D-1 对照对打门 - 主张:候选与生产变更在固定数据集上对照对打,过 release criteria 才放行;字节等价是下限不是上限——评分实现改动必过回放闸门(融合层位等价对照) - 原语组成:回放闸门;对照对打 -- 门挂点:ops/agent/golden/run-golden.mjs;ops/agent/selftest-t4.mjs#golden +- 门挂点:ops/agent/golden/run-golden.mjs;ops/agent/selftest-t4.mjs#golden;scripts/fusion_replay_gate.py#gate_rc +- 判例补:#193 C5 回放门 rc 机检化(rc 0=全绿/1=真 FAIL/2=语料不足,空语料 vacuous PASS 不冒充绿) - 判例链:Braintrust 2026-09-26;promptfoo 2026-10-02(golden 数据集+断言不达标 fail PR);PROBE 位等价判例(PR #169) #### D-2 最简架构默认 @@ -89,7 +90,7 @@ #### D-3 工具白名单+证据最小权 - 主张:测量面=闭集白名单,发现产物不进谓词硬门数字(evidence 语义门不因发现通道放宽);模型可调工具最小权——借道合法高权工具作恶是实证攻击面(MCP-ITP 84.2% ASR/0.3% 检出) - 原语组成:工具白名单 -- 门挂点:ops/agent/tools.mjs#MEASUREMENT_TOOLS +- 门挂点:ops/agent/tools/registry.mjs#MEASUREMENT_TOOLS - 判例链:MCP-ITP 2026-01(隐式投毒);NSA CSI 2026-06;MCP Specification 2026-07-28;PR #179 判例(发现通道三件不入 MEASUREMENT_TOOLS) #### D-4 谱系去重 @@ -107,7 +108,7 @@ #### D-6 信封纪律 - 主张:失败不编造——通道失败走四码结构化分类(每码自带下一步合法动作),无证据≠清白,弃权/缺口不冒充零命中;wire 漂移哨钉语法面 - 原语组成:信封纪律;弃权校准 -- 门挂点:ops/agent/tools.mjs#classifyChannelError;ops/agent/error-codes.mjs +- 门挂点:ops/agent/tools/envelope.mjs#classifyChannelError;ops/agent/error-codes.mjs - 判例链:FlowGuard 2026-07(语义信号≠证据);EpiSEM-Bench 2026-10(每句都真也可能不安全);判例 2026-10-24-discover-dual-channel-dead;PR #179 wire 漂移哨(非空无标记文本归 io_error 不冒充零命中) ## 三、门挂点层 diff --git a/ops/agent/anchor-mirror.mjs b/ops/agent/anchor-mirror.mjs new file mode 100644 index 00000000..d2de0db0 --- /dev/null +++ b/ops/agent/anchor-mirror.mjs @@ -0,0 +1,31 @@ +// anchor-mirror.mjs — #182 锚集/锚行形状 JS 单源镜像(#194 C3-2 合一)。 +// 锚集真源=backend/ipdb/_eval/anchors.py 的 ANCHORS(扩员只改 python 一处); +// 此前 run-golden.mjs GOLDEN_SWEEP_ANCHORS 与 selftest-pipeline.mjs SWEEP_ANCHORS +// 两份手抄互不同步——anchors.py 扩员而 stub 不改→门只验工件自洽仍全绿(评审D +// 漂移面)。本模块是唯一 JS 定义点:selftest.sh §14 对拍哨只对拍此一处; +// selftest-pipeline 另有镜像测试钉「两消费文件源文本零手抄副本」。 +// 约束:GOLDEN_SWEEP_ANCHORS/ANCHOR_ROW_KEYS 保持数组字面量形态(哨用正则抽 +// 取,禁改成计算表达式);本模块纯常量+纯函数,env 零依赖——消费方可在 env +// 落位前静态 import(与 tools.mjs 面「env 先于 import」纪律不冲突)。 +export const GOLDEN_SWEEP_ANCHORS = [["8.8.8.8", "clean"], ["8.8.4.4", "clean"], ["1.1.1.1", "clean"], ["1.0.0.1", "clean"], + ["9.9.9.9", "clean"], ["208.67.222.222", "clean"], ["114.114.114.114", "clean"], ["223.5.5.5", "clean"], + ["9.9.9.4", "clean"], ["10.0.0.1", "reserved"], ["127.0.0.1", "reserved"], ["192.168.1.1", "reserved"], + ["172.16.0.1", "reserved"], ["185.220.101.1", "tor"], ["171.25.193.78", "tor"], ["185.220.101.34", "tor"], + ["52.95.116.115", "hosting"], ["20.36.253.52", "hosting"], ["34.64.121.1", "hosting"]]; +// 锚巡检工件行键集(=anchors.py anchor_sweep results.append 行键的 JS 侧声明; +// §14 形状哨 py↔JS 集合对拍,C3-4)。G_ANCHOR_SWEEP 门逐字段机检以此为准的同词面。 +export const ANCHOR_ROW_KEYS = ["ip", "expect", "ok", "reason", "verdict", "confidence", "accusers", "conflict"]; +// 罐装行构造器:键集钉死——多写/漏写字段即 throw(防罐装面新行键静默漂移)。 +// 基行=clean 形(ok:true/reason:null/verdict:benign/confidence:0/accusers:[]/conflict:false), +// 覆盖参数按需传(事故形 sweepIncident 等)。 +export function anchorRow(fields) { + const row = { ip: fields.ip, expect: fields.expect, ok: fields.ok ?? true, reason: fields.reason ?? null, + verdict: fields.verdict ?? "benign", confidence: fields.confidence ?? 0, accusers: fields.accusers ?? [], + conflict: fields.conflict ?? false }; + const extra = Object.keys(fields).filter((k) => !(k in row)); + if (extra.length) throw new Error(`anchorRow 键集漂移:未知字段 ${extra.join(",")}(允许=${ANCHOR_ROW_KEYS.join(",")})`); + const got = Object.keys(row).sort().join(","); + const want = [...ANCHOR_ROW_KEYS].sort().join(","); + if (got !== want) throw new Error(`anchorRow 键集漂移:${got}≠${want}`); + return row; +} diff --git a/ops/agent/golden/run-golden.mjs b/ops/agent/golden/run-golden.mjs index 8c7e7abc..1a6358eb 100644 --- a/ops/agent/golden/run-golden.mjs +++ b/ops/agent/golden/run-golden.mjs @@ -15,6 +15,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { GOLDEN_SWEEP_ANCHORS, anchorRow } from "../anchor-mirror.mjs"; // #194 C3-2 锚镜像单源(纯常量模块,env 零依赖,可先于 env 静态 import) const HERE = path.dirname(fileURLToPath(import.meta.url)); const CASES = JSON.parse(fs.readFileSync(path.join(HERE, "cases.json"), "utf8")).cases; @@ -22,14 +23,9 @@ const CASES = JSON.parse(fs.readFileSync(path.join(HERE, "cases.json"), "utf8")) // fixture 判例树(cases.json $comment 契约):A 命中 health+discover 检索词;B 在库但零命中(负例③) const PREC_A = "2026-06-01-golden-aggregator.md"; const PREC_B = "2026-06-02-golden-matrix-nolayer.md"; -// #182 锚巡检 stub 面:罐装零 finding 全集投影(19 员镜像 anchors.py 扩员后形状; -// 形状钉,锚集真源=python,门只验工件内自洽)。golden 基线=零 finding(事故判例双 finding 面 -// 在 selftest-pipeline SWEEP_CANNED,两处分工=golden 钉基线/selftest 钉 finding 不持门)。 -const GOLDEN_SWEEP_ANCHORS = [["8.8.8.8", "clean"], ["8.8.4.4", "clean"], ["1.1.1.1", "clean"], ["1.0.0.1", "clean"], - ["9.9.9.9", "clean"], ["208.67.222.222", "clean"], ["114.114.114.114", "clean"], ["223.5.5.5", "clean"], - ["9.9.9.4", "clean"], ["10.0.0.1", "reserved"], ["127.0.0.1", "reserved"], ["192.168.1.1", "reserved"], - ["172.16.0.1", "reserved"], ["185.220.101.1", "tor"], ["171.25.193.78", "tor"], ["185.220.101.34", "tor"], - ["52.95.116.115", "hosting"], ["20.36.253.52", "hosting"], ["34.64.121.1", "hosting"]]; +// #182 锚巡检 stub 面:罐装零 finding 全集投影(19 员锚集=#194 C3-2 单源 +// anchor-mirror.mjs,锚集真源=python,门只验工件自洽)。golden 基线=零 finding(事故 +// 判例双 finding 面在 selftest-pipeline SWEEP_CANNED,两处分工=golden 钉基线/selftest 钉 finding 不持门)。 const writePrecedents = (dir) => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, PREC_A), @@ -69,7 +65,7 @@ const stubPy = (matrix) => `#!/usr/bin/env bash # conflict 布尔——G_ANCHOR_SWEEP 门形状面必查字段,缺席即门败(golden 桩漏带即 CI 红,判例 # 2026-10-25 #189 首跑),其余→罐装矩阵 JSON。 SWEEP_OK='${JSON.stringify({ kind: "anchor_sweep", anchors: GOLDEN_SWEEP_ANCHORS, n_total: GOLDEN_SWEEP_ANCHORS.length, n_fail: 0, failures: [], - results: GOLDEN_SWEEP_ANCHORS.map(([ip, expect]) => ({ ip, expect, ok: true, reason: null, verdict: "benign", confidence: 0, accusers: [], conflict: false })) }).replace(/'/g, "'\\''")}' + results: GOLDEN_SWEEP_ANCHORS.map(([ip, expect]) => anchorRow({ ip, expect })) }).replace(/'/g, "'\\''")}' case " $* " in *--anchor-sweep*) printf '%s\\n' "$SWEEP_OK" ;; *) printf '%s\\n' '${JSON.stringify(matrix).replace(/'/g, "'\\''")}' ;; @@ -97,6 +93,7 @@ async function runCase(c, allowSmoke) { const { runRound } = await import("../agent.mjs"); const { resolveField } = await import("../tools.mjs"); + const { GATE_COUNT } = await import("../pipeline/gates.mjs"); // 门数单源(#194 C3):禁手抄 10(评审D P2 硬编码判例);env 后动态 import 同上两行 let result = null, err = null; if (c.kind === "pipeline") { // P0 轮次状态机例(spec #161):真引擎跑全 faux 轮(模型步=faux provider+仓内 fixtures/pipeline, @@ -214,7 +211,7 @@ async function runCase(c, allowSmoke) { } case "pipeline_gates": { const g = JSON.parse(fs.readFileSync(path.join(result.dir, "gates.json"), "utf8")); - if (g.records.length !== 10) { fail(a, `门记录 ${g.records.length}≠10(9 可跑步门含 G_ANCHOR_SWEEP #182+G_CLOSE)`); break; } + if (g.records.length !== GATE_COUNT) { fail(a, `门记录 ${g.records.length}≠${GATE_COUNT}(可跑步门含 G_ANCHOR_SWEEP #182+G_CLOSE;单源 GATE_COUNT)`); break; } if (!g.records.every((r) => r.pass === true)) { fail(a, `门败:${g.records.filter((r) => !r.pass).map((r) => r.name).join(",")}`); break; } break; } diff --git a/ops/agent/pipeline/gates.mjs b/ops/agent/pipeline/gates.mjs index b5a0b7c7..0d4ecf2f 100644 --- a/ops/agent/pipeline/gates.mjs +++ b/ops/agent/pipeline/gates.mjs @@ -314,6 +314,18 @@ export const GATES = { }, }; +// 门契约单源(#194 C3,评审D P2):门清单/门数/门序一律从上方 GATES 注册键序派生——加门/删门/ +// 调序只改 GATES 一处,消费方(run-golden.mjs 门数断言、selftest-pipeline.mjs 门数与序断言) +// import 派生值,手抄数字/名单残留=漂移面(门记录数字面量硬编码三处判例:漏改一处=旧门 +// 面全绿假象)。键序即管线执行序(pipeline.mjs HEALTH_PIPELINE 的 def.gate 序);selftest-pipeline +// 另有双射不变式测试钉住「注册键序 ↔ 执行序」一致——任一侧单改即红。不另建 +// gates-contract.mjs:契约即注册表本体,单源分居两文件=伪单源(dispatch 两个选一,并入)。 +export const GATE_SEQUENCE = Object.keys(GATES); // 十门有序名单(禁手抄) +export const GATE_COUNT = GATE_SEQUENCE.length; // 门数派生(禁手写数字) +export const GATE_NAMES = Object.freeze(Object.fromEntries( + GATE_SEQUENCE.map((g) => [g.replace(/^G_/, ""), g]), // 每门名常量 {PROBE:"G_PROBE",…}(派生) +)); + // 求值 + 留痕(gates.json 追加;门自身可审计——哲学:门的判断也能被人审) export function evalStageGate(def, ctx) { const t0 = performance.now(); diff --git a/ops/agent/selftest-contract.mjs b/ops/agent/selftest-contract.mjs index 8054b112..6b4ca14c 100644 --- a/ops/agent/selftest-contract.mjs +++ b/ops/agent/selftest-contract.mjs @@ -30,7 +30,13 @@ const SRCS = { agent: src("agent.mjs"), tools: src("tools.mjs"), models: src("mo pipeline: src("pipeline.mjs"), pipelineSteps: src("pipeline/steps.mjs"), pipelineGates: src("pipeline/gates.mjs"), pipelineFaux: src("pipeline/faux.mjs"), // 票 #184(finding 出口三件套):出口模块入扫描——OPS_SWEEP_DRY_RUN/IP_RADAR_ALERT_URLS 真读方 - pipelineFindingExit: src("pipeline/finding-exit.mjs") }; + pipelineFindingExit: src("pipeline/finding-exit.mjs"), + // 票 #195(C6 tools.mjs 分层):面文件入扫描——env 读取面(DOCS_ROOT/OPS_EVAL_PY/ + // OPS_DATA_DIR/OPS_FETCH_MAX_BYTES/OPS_PROBE_* 等)真读方自 tools.mjs 移入 + // tools/shared.mjs;对拍不变式(表内每项真被读/读取∈表)随面延展覆盖 + toolsShared: src("tools/shared.mjs"), toolsEnvelope: src("tools/envelope.mjs"), + toolsRegistry: src("tools/registry.mjs"), toolsPrecedents: src("tools/precedents.mjs"), + toolsMeasurement: src("tools/measurement.mjs"), toolsDiscovery: src("tools/discovery.mjs") }; // ── fixture(in-process 用;golden writePrecedents 同型:env 先于 agent.mjs import)── const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "contract-selftest-")); @@ -61,7 +67,7 @@ const ERROR_CODES = mod.ERROR_CODES; const computeReads = () => { const reads = new Set(); let unknownTemplate = null; - for (const s of [SRCS.agent, SRCS.tools, SRCS.models, SRCS.pipeline, SRCS.pipelineSteps, SRCS.pipelineGates, SRCS.pipelineFaux, SRCS.pipelineFindingExit]) { + for (const s of [SRCS.agent, SRCS.tools, SRCS.models, SRCS.pipeline, SRCS.pipelineSteps, SRCS.pipelineGates, SRCS.pipelineFaux, SRCS.pipelineFindingExit, SRCS.toolsShared, SRCS.toolsEnvelope, SRCS.toolsRegistry, SRCS.toolsPrecedents, SRCS.toolsMeasurement, SRCS.toolsDiscovery]) { for (const m of s.matchAll(/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g)) reads.add(m[1]); // 方括号字符串读形(reviewer Minor-2,T2):process.env["QUOTED_NAME"]/['X']——与点形 // 同名集并入对拍;引号族与反引号族(下方)互不重叠。动态下标读形 process.env[expr] @@ -97,7 +103,7 @@ await t("表对拍:扫描器收录的 env 读取面(计算模板无未知形)", assert.equal(READS.unknownTemplate, null, `未收录的 process.env[··] 计算读取模板,对拍器需同步`); }); -await t("表:SPAWN_CONTRACT_ENV 名集=规格 16 项+OPS_PROBE_MAX_KEYS(tools.mjs 实读,对拍闭包补入)+OPS_PIPELINE/OPS_PIPELINE_BUDGET_CNY(P2 P0 增量,pipeline.mjs 实读)+OPS_ANCHOR_SWEEP_TIMEOUT_MS(#182 anchorSweepCli 实读)+JINA_API_KEY/PI_CODING_AGENT_DIR(PR3a pi-web-access 外部自读,同 GH_TOKEN 豁免型)+OPS_SWEEP_DRY_RUN/IP_RADAR_ALERT_URLS(#184 finding 出口,pipeline/finding-exit.mjs 实读)", () => { +await t("表:SPAWN_CONTRACT_ENV 名集=规格 16 项+OPS_PROBE_MAX_KEYS(tools 面实读,C6 分层后 env 读取单源 tools/shared.mjs;对拍闭包补入)+OPS_PIPELINE/OPS_PIPELINE_BUDGET_CNY(P2 P0 增量,pipeline.mjs 实读)+OPS_ANCHOR_SWEEP_TIMEOUT_MS(#182 anchorSweepCli 实读)+JINA_API_KEY/PI_CODING_AGENT_DIR(PR3a pi-web-access 外部自读,同 GH_TOKEN 豁免型)+OPS_SWEEP_DRY_RUN/IP_RADAR_ALERT_URLS(#184 finding 出口,pipeline/finding-exit.mjs 实读)", () => { assert.deepEqual([...tableNames()].sort(), ["DOCS_ROOT", "GH_TOKEN", "IP_RADAR_ALERT_URLS", "JINA_API_KEY", "LEDGER_FILE", "OPS_ANCHOR_SWEEP_TIMEOUT_MS", "OPS_AUTH", "OPS_DATA_DIR", "OPS_EVAL_PY", "OPS_FETCH_MAX_BYTES", "OPS_MATRIX_TIMEOUT_MS", "OPS_MODELS", "OPS_MODEL_DISCOVER", "OPS_MODEL_HEALTH", "OPS_MODELS_STORE", "OPS_PIPELINE", "OPS_PIPELINE_BUDGET_CNY", "OPS_PROBE_CLI_TIMEOUT_MS", diff --git a/ops/agent/selftest-pipeline.mjs b/ops/agent/selftest-pipeline.mjs index ae941411..2b10ad2a 100644 --- a/ops/agent/selftest-pipeline.mjs +++ b/ops/agent/selftest-pipeline.mjs @@ -16,6 +16,7 @@ import os from "node:os"; import path from "node:path"; import { execFile as execFileCb } from "node:child_process"; import { promisify } from "node:util"; +import { GOLDEN_SWEEP_ANCHORS, ANCHOR_ROW_KEYS, anchorRow } from "./anchor-mirror.mjs"; // #194 C3-2 锚镜像单源(纯常量模块,env 零依赖可静态 import) import { fileURLToPath } from "node:url"; const execFileP = promisify(execFileCb); @@ -50,20 +51,32 @@ const MATRIX_CANNED = { exit_code: 0, counts: { violation: 0, pending: 0, agreem buckets: { violation: [], pending: [], agreement: [] } }; // 罐装锚巡检(#182 faux 查询面):事故判例形——8.8.8.8=myip_ms 单源指控/114DNS= // cps+ziyadnz 假印证双 finding(#183 整改前形状,票 #182 明示 fixture 不依赖整改); -// 锚集镜像 anchors.py 扩员后 19 员(形状钉,门只验工件内自洽——锚集真源=python 勿在此改)。 -const SWEEP_ANCHORS = [["8.8.8.8", "clean"], ["8.8.4.4", "clean"], ["1.1.1.1", "clean"], ["1.0.0.1", "clean"], - ["9.9.9.9", "clean"], ["208.67.222.222", "clean"], ["114.114.114.114", "clean"], ["223.5.5.5", "clean"], - ["9.9.9.4", "clean"], ["10.0.0.1", "reserved"], ["127.0.0.1", "reserved"], ["192.168.1.1", "reserved"], - ["172.16.0.1", "reserved"], ["185.220.101.1", "tor"], ["171.25.193.78", "tor"], ["185.220.101.34", "tor"], - ["52.95.116.115", "hosting"], ["20.36.253.52", "hosting"], ["34.64.121.1", "hosting"]]; -const sweepIncident = (ip) => ({ ip, expect: "clean", ok: false, reason: "expected clean, got malicious", +// 锚集+行键集+罐装行构造器单源=#194 C3-2 合一至 anchor-mirror.mjs(锚集真源仍= +// python anchors.py,§14 哨对拍单源;此文件/run-golden 禁再手抄副本,镜像测试钉零副本)。 +const sweepIncident = (ip) => anchorRow({ ip, expect: "clean", ok: false, reason: "expected clean, got malicious", verdict: "malicious", confidence: 55, conflict: false, accusers: ip === "8.8.8.8" ? ["myip_ms"] : ["cps_collected_iocs", "ziyadnz"] }); -const SWEEP_CANNED = { kind: "anchor_sweep", anchors: SWEEP_ANCHORS, n_total: SWEEP_ANCHORS.length, n_fail: 2, +const SWEEP_CANNED = { kind: "anchor_sweep", anchors: GOLDEN_SWEEP_ANCHORS, n_total: GOLDEN_SWEEP_ANCHORS.length, n_fail: 2, failures: ["8.8.8.8", "114.114.114.114"].map((ip) => ({ ip, expect: "clean", reason: "expected clean, got malicious" })), - results: SWEEP_ANCHORS.map(([ip, expect]) => ip === "8.8.8.8" || ip === "114.114.114.114" ? sweepIncident(ip) - : { ip, expect, ok: true, reason: null, verdict: "benign", confidence: 0, accusers: [], conflict: false }) }; + results: GOLDEN_SWEEP_ANCHORS.map(([ip, expect]) => ip === "8.8.8.8" || ip === "114.114.114.114" ? sweepIncident(ip) + : anchorRow({ ip, expect })) }; const SHELL_JSON = (o) => `'${JSON.stringify(o).replace(/'/g, "'\\''")}'`; +// 折叠族全值域金表(#194 C3-3,伞票 #191 C3 ③):行=[输入码,期望族],覆盖 foldErrorFamily +// 全词面(含兜底);§14 哨抽本表末列与 _ops_detectors._error_family 返回词汇集合对拍 +// (py 输入域=(error_code,error_text) 文本面、JS=error_code 直入,输入域不同——对拍面= +// 输出词汇集合相等,同因同签两侧同词面)。加族先改 python 真源,哨红后再跟本表+词面断言。 +const FOLD_GOLDEN = [ + ["http_401", "http_401"], + ["http_403", "http_403"], + ["http_404", "http_4xx"], + ["http_429", "http_4xx"], + ["http_500", "http_5xx"], + ["http_503", "http_5xx"], + ["net_fail", "network"], + ["net_timeout", "timeout"], + ["", "download"], + ["weird_code", "download"] +]; fs.writeFileSync(path.join(STUBS, "py-matrix"), `#!/usr/bin/env bash # selftest stub:OPS_EVAL_PY 替身(golden stubPy 同型);--anchor-sweep→罐装锚巡检 JSON(#182),其余→罐装矩阵 JSON case " $* " in @@ -78,6 +91,10 @@ process.env.LEDGER_FILE = path.join(DOCS, "ops", "ledger.md"); process.env.OPS_EVAL_PY = path.join(STUBS, "py-matrix"); // import-time 面:先于 tools.mjs 装载生效 process.env.OPS_SWEEP_DRY_RUN = "1"; // #184:测试恒 dry-run(gh/webhook 出口零外发,只记 cmd/payload;票面默认关=生产真发) process.env.PATH = `${STUBS}:${process.env.PATH}`; +// 门契约单源(#194 C3):门清单/门数/门序一律 import pipeline/gates.mjs 派生值,禁手抄 +// 数字/名单(评审D P2:门记录数硬编码三处判例,漏改=旧门面全绿假象); +// env 后动态 import(同 tools.mjs 面:先落位 env 再装载)。 +const { GATE_SEQUENCE, GATE_COUNT } = await import("./pipeline/gates.mjs"); const { runPipeline, HEALTH_PIPELINE, DEFAULT_FIXTURES_DIR } = await import("./pipeline.mjs"); const { runModelStep, selectModelFace, STEP_ROUND } = await import("./pipeline/faux.mjs"); // #170 面选择/真面注入缝直调单测面 @@ -147,6 +164,14 @@ await t("全 faux 轮:十执行态到 CLOSE,信封 ok+形状只增 pipeline 扩 assert.equal(env.verify.cited, 1, "canned 报告引 1 fixture 判例"); }); +await t("门契约单源(#194 C3):GATES 注册键序=HEALTH_PIPELINE 执行序双射;GATE_COUNT 派生(单侧加/删门即红=门面假绿判例)", async () => { + const { GATES } = await import("./pipeline/gates.mjs"); + const { HEALTH_PIPELINE } = await import("./pipeline.mjs"); + assert.deepEqual(GATE_SEQUENCE, HEALTH_PIPELINE.map((d) => d.gate).filter(Boolean), "注册键序=执行序"); + assert.equal(GATE_COUNT, GATE_SEQUENCE.length, "门数=名单长(禁手写数字)"); + assert.equal(Object.keys(GATES).length, GATE_COUNT); +}); + await t("工件清单全落盘+gates.json 十记录全 PASS+state.json 终态 done/CLOSE+原子写无 .tmp 残留", async () => { const d = roundDir(R1); for (const f of ["01-probe-freshness.tsv", "01-probe-matrix.json", "01-probe-summary.json", "02-verify-survivors.json", @@ -158,10 +183,10 @@ await t("工件清单全落盘+gates.json 十记录全 PASS+state.json 终态 do assert.ok(fs.readdirSync(d).some((f) => /^05-deep-.*-transcript\.jsonl$/.test(f)), "逐假设 transcript"); const g = j(path.join(d, "gates.json")); assert.equal(g.round_id, R1); - assert.equal(g.records.length, 10, "9 可跑步门(含 #182 ANCHOR_SWEEP)+G_CLOSE"); + assert.equal(g.records.length, GATE_COUNT, "9 可跑步门(含 #182 ANCHOR_SWEEP)+G_CLOSE"); assert.ok(g.records.every((r) => r.pass === true), `门败:${g.records.filter((r) => !r.pass).map((r) => r.name).join(",")}`); assert.ok(g.records.every((r) => typeof r.reason === "string" && /\d/.test(r.reason)), "reason 带数字可回算"); - assert.deepEqual(g.records.map((r) => r.name), ["G_PROBE", "G_VERIFY", "G_TRIAGE", "G_DISPATCH", "G_DEEP", "G_ITEMIZE", "G_SYNTH", "G_ANCHOR_SWEEP", "G_RETRO", "G_CLOSE"]); + assert.deepEqual(g.records.map((r) => r.name), GATE_SEQUENCE); const st = j(path.join(d, "state.json")); assert.equal(st.status, "done"); assert.equal(st.stage, "CLOSE"); @@ -172,14 +197,32 @@ await t("工件清单全落盘+gates.json 十记录全 PASS+state.json 终态 do assert.ok(fs.existsSync(path.join(DOCS, "ops", "retro", `${R1}.json`)), "retro 工件(ops/retro/)"); }); +await t("锚镜像单源(#194 C3-2):JS 侧锚集唯一定义点=anchor-mirror.mjs;run-golden/selftest-pipeline 源文本零手抄副本+行键集闭合(副本再起即红)", async () => { + const srcSelf = fs.readFileSync(new URL(import.meta.url), "utf8"); + const srcGolden = fs.readFileSync(new URL("./golden/run-golden.mjs", import.meta.url), "utf8"); + const srcMirror = fs.readFileSync(new URL("./anchor-mirror.mjs", import.meta.url), "utf8"); + const reAnchorArr = /(?:GOLDEN_SWEEP_ANCHORS|SWEEP_ANCHORS)\s*=\s*\[\[/; // 数组字面量形态(§14 哨同正则面) + assert.ok(!reAnchorArr.test(srcSelf), "selftest-pipeline 不再自带锚数组手抄副本"); + assert.ok(!reAnchorArr.test(srcGolden), "run-golden 不再自带锚数组手抄副本"); + assert.equal((srcMirror.match(/GOLDEN_SWEEP_ANCHORS\s*=\s*\[\[/g) ?? []).length, 1, "唯一定义点=anchor-mirror.mjs(恰一处)"); + assert.equal(GOLDEN_SWEEP_ANCHORS.length, new Set(GOLDEN_SWEEP_ANCHORS.map(([ip]) => ip)).size, "锚 ip 去重(全集成员性)"); + for (const [ip, expect] of GOLDEN_SWEEP_ANCHORS) { + assert.ok(/^\d+\.\d+\.\d+\.\d+$/.test(ip ?? ""), `锚 ip 形状:${ip}`); + assert.ok(typeof expect === "string" && expect.length > 0, `锚 expect 非空串:${ip}`); + } + // 行键集闭合:罐装行构造器产出键集=声明键集(多写/漏写即 throw 或红;C3-4 与 python 行键对拍在 §14 形状哨) + assert.deepEqual(Object.keys(anchorRow({ ip: "0.0.0.0", expect: "clean" })).sort(), [...ANCHOR_ROW_KEYS].sort()); + assert.throws(() => anchorRow({ ip: "0.0.0.0", expect: "clean", rogue_key: 1 }), /锚集漂移|键集/); +}); + await t("ANCHOR_SWEEP 巡检步(#182):事故判例 finding 在场门仍过——工件=全集投影逐锚读数+G 门不持门;步序 SYNTH 后 RETRO 前(finding 先于台账收口)", async () => { const d = roundDir(R1); const s = j(path.join(d, "08-anchor-sweep.json")); // 工件可查(票验收) assert.equal(s.kind, "anchor_sweep"); - assert.equal(s.n_total, SWEEP_ANCHORS.length); + assert.equal(s.n_total, GOLDEN_SWEEP_ANCHORS.length); assert.equal(s.anchors.length, s.results.length, "锚集=结果集同长(全集投影)"); // 投影穷尽:每锚恰一结果(ip|expect 双键) - const want = SWEEP_ANCHORS.map(([ip, exp]) => `${ip}|${exp}`).sort().join(","); + const want = GOLDEN_SWEEP_ANCHORS.map(([ip, exp]) => `${ip}|${exp}`).sort().join(","); const got = s.results.map((r) => `${r.ip}|${r.expect}`).sort().join(","); assert.equal(got, want, "逐锚恰一结果(丢锚/重锚即门败面)"); for (const r of s.results) { @@ -187,6 +230,7 @@ await t("ANCHOR_SWEEP 巡检步(#182):事故判例 finding 在场门仍过—— assert.equal(typeof r.confidence, "number", `${r.ip} confidence 数字`); assert.ok(Array.isArray(r.accusers) && r.accusers.every((a) => typeof a === "string"), `${r.ip} accusers 字符串数组`); assert.equal(typeof r.ok, "boolean"); + assert.deepEqual(Object.keys(r).sort(), [...ANCHOR_ROW_KEYS].sort(), `${r.ip} 行键集=ANCHOR_ROW_KEYS(#194 C3-4;§14 哨与 anchors.py 行键对拍)`); } assert.equal(s.n_fail, 2, "罐装事故判例:8.8.8.8+114DNS 双 finding(#183 整改前形状)"); assert.equal(s.failures.length, 2, "failures 与 n_fail 回算一致"); @@ -590,7 +634,7 @@ await t("空转金轨迹轮(#162 审 P2-2 裁定①):零 need_deep → DISPATCH const m = j(path.join(d, "06-itemize-manifest.json")); assert.deepEqual([m.created, m.reopened, m.deduped, m.suppressed], [2, 0, 0, 1], "watch 条目 2 + 抑制 1(P4:真树落盘)"); const g = j(path.join(d, "gates.json")); - assert.equal(g.records.length, 9, "skipped 步无门记录(PROBE..CLOSE 除 DISPATCH;含 #182 G_ANCHOR_SWEEP)"); + assert.equal(g.records.length, GATE_COUNT - 1, "skipped 步无门记录(PROBE..CLOSE 除 DISPATCH;含 #182 G_ANCHOR_SWEEP)"); assert.ok(g.records.every((x) => x.pass), "G_DEEP 机检金轨迹在场面"); assert.ok(g.records.find((x) => x.name === "G_DEEP").reason.includes("golden=1"), "门 reason 带数字可回算"); }); @@ -855,7 +899,7 @@ await t("matrix 裸失败负例(#163 review P2-1):信封件落 01-probe-matrix.j assert.equal(s.matrix.exit_code, null); assert.equal(s.matrix.violation_n, null); const g = j(path.join(d, "gates.json")); - assert.equal(g.records.length, 10); + assert.equal(g.records.length, GATE_COUNT); assert.ok(g.records.every((x) => x.pass), "门不受 matrix 裸失败牵连(legacy 失败只回嵌套信封,不阻轮语义两侧一致)"); // 该 stub 无旗标分发→sweep 面同死:#182 降级旁路(stage degraded+门旁路留痕,轮仍成功) const envD = JSON.parse(r.stdout.trim().split("\n").pop()); @@ -1066,10 +1110,10 @@ await t("replay --dry 全程可跑(CLI 子进程):门全重算 PASS,产物写 -r assert.equal(env.ok, true); assert.equal(env.pipeline.replayed_of, R1); assert.equal(env.pipeline.round_id, `${R1}-r1`); - assert.equal(env.pipeline.stages.length, 9, "dry 段=PROBE..RETRO(CLOSE 不入重放;含 #182 ANCHOR_SWEEP)"); + assert.equal(env.pipeline.stages.length, GATE_COUNT - 1, "dry 段=PROBE..RETRO(CLOSE 不入重放;含 #182 ANCHOR_SWEEP)"); assert.ok(env.pipeline.stages.every((s) => s.status === "dry"), "dry 状态词"); const g = j(path.join(roundDir(`${R1}-r1`), "gates.json")); - assert.equal(g.records.length, 9, "9 可跑步门全重算(含 G_ANCHOR_SWEEP;CLOSE 不入重放)"); + assert.equal(g.records.length, GATE_COUNT - 1, "可跑步门全重算(含 G_ANCHOR_SWEEP;CLOSE 不入重放)"); assert.ok(g.records.every((x) => x.pass), "原件工件上全门重算 PASS(自愈:以工件+门为准)"); assert.equal(treeMd5(roundDir(R1)), before, "原件零改写"); }); @@ -1280,6 +1324,11 @@ await t("retroDiff 单元:review_at 到点报复审+两通道矛盾(被抑制信 assert.deepEqual(dd.candidates[0].evidence.dismissed_items, ["d-aa"], "候选证据挂 dismissed 条目(可回算)"); }); +await t("折叠族全值域对拍(#194 C3-3):foldErrorFamily 逐行=FOLD_GOLDEN 金表;输出词汇=py _error_family 返回词汇(§14 哨同面,单侧加族双哨齐红)", async () => { + const { foldErrorFamily } = await import("./pipeline/ops-items.mjs"); + for (const [input, want] of FOLD_GOLDEN) assert.equal(foldErrorFamily(input), want, `fold(${JSON.stringify(input)})=${want}`); +}); + await t("超时分腿镜像(#166 审 P2-1):fold net_timeout→timeout;lib.sh _ff_probe curl rc=28 → 第4列 net_timeout", async () => { const { foldErrorFamily } = await import("./pipeline/ops-items.mjs"); assert.equal(foldErrorFamily("net_timeout"), "timeout", "对拍 _ops_detectors._error_family timeout 族"); diff --git a/ops/agent/selftest-t4.mjs b/ops/agent/selftest-t4.mjs index 21a3edd7..c12dafc8 100644 --- a/ops/agent/selftest-t4.mjs +++ b/ops/agent/selftest-t4.mjs @@ -857,6 +857,49 @@ await t("修正④:smoke 层 fixture 树保全(台账/包=记账工件不灭失) assert.ok(!fs.existsSync(fauxTmp), "faux 一次性树照旧清理"); }); +// ── 票 #195(C6 tools.mjs 分层)结构断言:门面 re-export 闭集 + 单向依赖 ────── +// 纯结构重构零行为:红期先钉公共 API 面(冻结字面 = bde7f061 拆分前 export 面实测, +// bde7f061..ed84145a 间未动)——拆分后门面漂移(漏 re-export/误删/多导)即红。 +const C6_EXPORT_FACE = [ + "DOCS_ROOT", "FROZEN_DAYS", "GH_WRITE_SUBCOMMANDS", "RG_TIMEOUT_MS", "ROOT", "RSS_FRESH_DAYS", + "ResultRegistry", "ToolHost", "anchorSweepCli", "checkPrivateIp", "checkReadablePath", + "classifyChannelError", "classifyMcpSearchError", "classifyWebStatus", "closureCheck", + "countFeedKeys", "fmField", "ghIssueCreate", "matrixArtifactText", "matrixScanCli", + "normalizeJinaRead", "parseMcpSearchText", "parseRss", "parseSerp", "pinnedLookup", + "resolveField", "resolvePrecedentsDir", "safeFetch", "searchPrecedentsCore", +]; + +await t("C6-1:门面 re-export 闭集 = 拆分前公共 API 面(29 名逐名相等,bde7f061 冻结字面)", async () => { + const face = await import("./tools.mjs"); + assert.deepEqual(Object.keys(face).sort(), [...C6_EXPORT_FACE].sort()); +}); + +await t("C6-2:tools.mjs 只剩门面——零 outputSchema 工具实现,ToolHost 宿主+注册编排留驻", () => { + const src = fs.readFileSync(path.join(AGENT, "tools.mjs"), "utf8"); + assert.ok(!/outputSchema\s*:/.test(src), "具体工具实现须移面文件(tools/*.mjs)"); + assert.ok(/class ToolHost/.test(src) && /this\.tools\s*=/.test(src), "ToolHost 宿主与注册编排留门面"); + assert.ok(/from "\.\/tools\//.test(src), "门面 re-export 自 ./tools/ 面文件"); +}); + +await t("C6-3:面文件单向依赖——六面在场、职责分层(shared/envelope 叶,面不互指不回导入门面)", () => { + const DEPS = { + shared: [], + envelope: [], + registry: ["shared", "envelope"], + precedents: ["shared", "envelope"], + measurement: ["shared", "envelope"], + discovery: ["shared", "envelope"], + }; + for (const [f, allowed] of Object.entries(DEPS)) { + const p = path.join(AGENT, "tools", `${f}.mjs`); + assert.ok(fs.existsSync(p), `面文件缺席:${p}`); + const s = fs.readFileSync(p, "utf8"); + assert.ok(!/from\s+"\.\.\/tools(\.mjs)?"/.test(s), `${f}.mjs 不得回导入门面`); + const imports = [...s.matchAll(new RegExp('from\\s+"\\./(\\w+)\\.mjs"', "g"))].map((m) => m[1]).filter((x) => x in DEPS); + assert.deepEqual([...new Set(imports)].sort(), [...allowed].sort(), `${f}.mjs 面内依赖漂移(许 ${allowed.join("/") || "无"})`); + } +}); + fs.rmSync(TMP, { recursive: true, force: true }); console.log(`t4-selftest: PASS=${pass} FAIL=${fail}`); process.exit(fail ? 1 : 0); diff --git a/ops/agent/selftest.sh b/ops/agent/selftest.sh index 166e7916..ae0bf426 100755 --- a/ops/agent/selftest.sh +++ b/ops/agent/selftest.sh @@ -380,9 +380,12 @@ fi && ok "t4:npm install 已落位" || bad "t4:npm install 已落位" git -C "$ROOT" check-ignore -q "$AGENT/node_modules" \ && ok "t4:node_modules 已入 .gitignore" || bad "t4:node_modules 已入 .gitignore" -node --check "$AGENT/agent.mjs" 2>/dev/null && node --check "$AGENT/tools.mjs" 2>/dev/null \ - && node --check "$AGENT/models.mjs" 2>/dev/null \ - && ok "t4:agent/tools/models 三模块 node 语法通过" || bad "t4:agent/tools/models 三模块 node 语法通过" +toolsSyntax=0 +for f in "$AGENT/agent.mjs" "$AGENT/tools.mjs" "$AGENT"/tools/*.mjs "$AGENT/models.mjs"; do + node --check "$f" 2>/dev/null || toolsSyntax=1 +done +[[ $toolsSyntax -eq 0 ]] \ + && ok "t4:agent/tools(含 tools/ 六面)/models node 语法通过" || bad "t4:agent/tools(含 tools/ 六面)/models node 语法通过" # ---- 9. T4: 提示词条款钉子(谓词表三条/自动入队/信封处置/验证条款;health 信封条款) ---- DISC="$AGENT/prompts/discover-system.md" @@ -436,6 +439,8 @@ if command -v node >/dev/null 2>&1 && [[ -d "$AGENT/node_modules/@earendil-works && ok "golden:smoke 真模型层零触发(冒烟层隔离面)" || bad "golden:smoke 真模型层零触发(冒烟层隔离面)" node --check "$AGENT/golden/run-golden.mjs" 2>/dev/null \ && ok "golden:runner node 语法通过" || bad "golden:runner node 语法通过" + node --check "$AGENT/anchor-mirror.mjs" 2>/dev/null \ + && ok "golden:锚镜像单源模块 node 语法通过(#194 C3-2)" || bad "golden:锚镜像单源模块 node 语法通过(#194 C3-2)" node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$AGENT/golden/cases.json" 2>/dev/null \ && ok "golden:用例集 json 可解析" || bad "golden:用例集 json 可解析" else @@ -555,11 +560,11 @@ fi [[ -f "$AGENT/STANDARD.md" ]] \ && ok "standard:典章真源 STANDARD.md 在案" || bad "standard:典章真源 STANDARD.md 在案" -# ---- 14. 锚集三源对拍哨(终审修单 F8):python ANCHORS 真源 vs 双 JS 镜像集合 ---- -# 锚集真源=backend/ipdb/_eval/anchors.py(扩员只改此一处);selftest-pipeline.mjs -# 的 SWEEP_ANCHORS 与 golden/run-golden.mjs 的 GOLDEN_SWEEP_ANCHORS 是形状钉 -# 镜像(罐装工件面)。扩员不跟(任一镜像落后真源)=红:双向集合相等逐成员 -# 对拍。python 面用 ast 文本解析零 import(CI frontend job 无 backend 依赖面)。 +# ---- 14. 锚集对拍哨(终审修单 F8;#194 C3-2 改单源)+行键形状哨(C3-4)+折叠族全值域对拍哨(C3-3) ---- +# 锚集真源=backend/ipdb/_eval/anchors.py(扩员只改此一处);JS 侧唯一定义点= +# ops/agent/anchor-mirror.mjs GOLDEN_SWEEP_ANCHORS(#194 C3-2 合一:此前双 JS 手抄 +# 副本互不同步,漂移即门面假绿;selftest-pipeline 另有镜像测试钉两消费文件零手抄副本)。 +# python 面用 ast 文本解析零 import(CI frontend job 无 backend 依赖面)。 if command -v python3 >/dev/null 2>&1; then MIRLOG="$TMP/anchor-mirror-check.log" if ROOT="$ROOT" python3 - > "$MIRLOG" 2>&1 <<'PYEOF'; then MIRRC=0; else MIRRC=$?; fi @@ -580,23 +585,71 @@ def js_mirror(rel, var): assert m, f"{rel} 找不到 {var} 数组字面量" return sorted(tuple(x) for x in json.loads(m.group(1))) bad_mirrors = [] -for rel, var in (("ops/agent/selftest-pipeline.mjs", "SWEEP_ANCHORS"), - ("ops/agent/golden/run-golden.mjs", "GOLDEN_SWEEP_ANCHORS")): +# #194 C3-2:JS 侧单源=anchor-mirror.mjs 一处(消费文件零手抄副本由 selftest-pipeline 镜像测试钉) +for rel, var in (("ops/agent/anchor-mirror.mjs", "GOLDEN_SWEEP_ANCHORS"),): mirror = js_mirror(rel, var) if mirror != py_anchors: miss = ["|".join(a) for a in py_anchors if a not in mirror] extra = ["|".join(a) for a in mirror if a not in py_anchors] bad_mirrors.append(f"{rel} {var} 缺={miss} 多={extra}") if bad_mirrors: - print("MIRRORFAIL 锚集镜像与 anchors.py 不一致(扩员须同改双 JS 镜像): " + "; ".join(bad_mirrors)) + print("MIRRORFAIL 锚集镜像与 anchors.py 不一致(扩员须同改 JS 单源 anchor-mirror.mjs): " + "; ".join(bad_mirrors)) + raise SystemExit(1) +print(f"MIRRORPASS 锚集对拍一致({len(py_anchors)} 员;python 真源 vs anchor-mirror.mjs 单源)") + +# ---- C3-4 行键形状哨:anchors.py anchor_sweep 行键 vs JS ANCHOR_ROW_KEYS 集合对拍 ---- +# 锚巡检工件行键跨语言同词面(G_ANCHOR_SWEEP 门逐字段机检面;#189 判例=桩漏 conflict 门败)。 +py_keys = None +for node in ast.walk(ast.parse(src)): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "append": + f = node.func.value + if isinstance(f, ast.Name) and f.id == "results" and node.args and isinstance(node.args[0], ast.Dict): + keys = {k.value if isinstance(k, ast.Constant) else ast.dump(k) for k in node.args[0].keys} + if all(isinstance(k, ast.Constant) for k in node.args[0].keys): + py_keys = keys + break +assert py_keys, "anchors.py anchor_sweep results.append 行键不可解析(ast 文本面)" +m = re.search(r"ANCHOR_ROW_KEYS\s*=\s*(\[[^\]]*\])\s*;", (root / "ops/agent/anchor-mirror.mjs").read_text(encoding="utf-8")) +assert m, "anchor-mirror.mjs 找不到 ANCHOR_ROW_KEYS 数组字面量" +js_keys = set(json.loads(m.group(1))) +if js_keys != py_keys: + print(f"SHAPEFAIL 锚行键集漂移:py={sorted(py_keys)} vs js={sorted(js_keys)}(行键跨语言同词面,单侧加键即红)") + raise SystemExit(1) +print(f"SHAPEPASS 锚行键集一致({len(py_keys)} 键:python anchor_sweep vs JS ANCHOR_ROW_KEYS)") + +# ---- C3-3 折叠族全值域对拍哨:py _error_family 返回词汇 vs JS 金表输出词汇 ---- +# py 侧=backend/ipdb/_ops_detectors.py _error_family(金常量);JS 侧=ops-items.mjs +# foldErrorFamily(输入域不同:py=(error_code,error_text)文本面,JS=error_code 直入)—— +# 全值域对拍=输出词汇集合相等(两侧同因同签;伞票 #191 C3 ③)。 +py_fam_vocab, fam_fn = set(), None +for node in ast.walk(ast.parse((root / "backend/ipdb/_ops_detectors.py").read_text(encoding="utf-8"))): + if isinstance(node, ast.FunctionDef) and node.name == "_error_family": + fam_fn = node + break +assert fam_fn, "_ops_detectors.py 找不到 _error_family" +for node in ast.walk(fam_fn): + if isinstance(node, ast.Return) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + py_fam_vocab.add(node.value.value) # 字面返回族(http_4xx/http_5xx/timeout/network) + if isinstance(node, ast.Return) and isinstance(node.value, ast.JoinedStr): # f-string:http_{status} 且 status in (401,403) + if re.search(r"status\s+in\s+\(\s*401\s*,\s*403\s*\)", ast.unparse(fam_fn)): + py_fam_vocab.update(("http_401", "http_403")) +for node in ast.walk(ast.parse((root / "backend/ipdb/_ops_detectors.py").read_text(encoding="utf-8"))): + if isinstance(node, ast.Assign) and any(getattr(t, "id", None) == "_FALLBACK_FAMILY" for t in node.targets): + py_fam_vocab.add(node.value.value) # 兜底族 download +assert py_fam_vocab, "_error_family 返回词汇不可解析" +m = re.search(r"FOLD_GOLDEN\s*=\s*(\[.*?\])\s*;\n", (root / "ops/agent/selftest-pipeline.mjs").read_text(encoding="utf-8"), re.S) +assert m, "selftest-pipeline.mjs 找不到 FOLD_GOLDEN 金表字面量" +js_fam_vocab = {row[-1] for row in json.loads(re.sub(r",(\s*[\\]}])", r"\1", m.group(1)))} # 金表每行末列=期望族(全值域;容忍尾逗号) +if js_fam_vocab != py_fam_vocab: + print(f"FOLDFAIL 折叠族词汇漂移:py={sorted(py_fam_vocab)} vs js={sorted(js_fam_vocab)}(同因同签两侧同词面,单侧加族即红)") raise SystemExit(1) -print(f"MIRRORPASS 锚集三源对拍一致({len(py_anchors)} 员;扩员须同改双 JS 镜像)") +print(f"FOLDPASS 折叠族词汇一致({len(py_fam_vocab)} 族:py _error_family vs JS FOLD_GOLDEN 全值域)") PYEOF - if (( MIRRC == 0 )) && grep -q "^MIRRORPASS " "$MIRLOG"; then - ok "mirror:锚集三源对拍一致(anchors.py vs SWEEP_ANCHORS/GOLDEN_SWEEP_ANCHORS,$(grep -o '[0-9]* 员' "$MIRLOG" | head -1))" + if (( MIRRC == 0 )) && grep -q "^MIRRORPASS " "$MIRLOG" && grep -q "^SHAPEPASS " "$MIRLOG" && grep -q "^FOLDPASS " "$MIRLOG"; then + ok "mirror:锚集对拍一致(anchors.py vs anchor-mirror.mjs 单源)+行键集一致+折叠族词汇一致,$(grep -o '[0-9]* 员' "$MIRLOG" | head -1)" else - bad "mirror:锚集三源对拍不一致(扩员未跟镜像)" - grep -E 'MIRRORFAIL|Error|assert' "$MIRLOG" || true + bad "mirror:锚集/行键/折叠族对拍不一致(单源镜像漂移或键集/词汇漂移)" + grep -E 'MIRRORFAIL|SHAPEFAIL|FOLDFAIL|Error|assert' "$MIRLOG" || true fi else bad "mirror:python3 不可用(对拍哨前置)" diff --git a/ops/agent/tools.mjs b/ops/agent/tools.mjs index 9a4d7a82..0a4eb018 100644 --- a/ops/agent/tools.mjs +++ b/ops/agent/tools.mjs @@ -1,697 +1,22 @@ -// tools.mjs — 融合领域工具面(11 工具 = discover 10 + health 1;PR3a 发现通道三件并入, -// 2026-10-24 裁定)+ 写前语义门(计划书 Task 4 终稿;融合必保 6 条 -// = tool-critic-1/2 并集;铁律①-⑦ 见各段注释)。 +// tools.mjs — 融合领域工具面门面+注册编排(票 #195 C6:单体按面分层至 tools/*.mjs, +// 行为零变化)。本文件=ToolHost 宿主(注册表/预算/写前语义门/收口);具体工具实现与 +// 闭集常量单源在各面文件,公共 API 面(29 名)经本门面 re-export 逐名不变 +// (selftest-t4 C6-1 钉死)。11 工具 = discover 10 + health 1;PR3a 发现通道三件并入, +// 2026-10-24 裁定;融合必保 6 条 = tool-critic-1/2 并集;铁律①-⑦ 见各面文件注释。 // -// 结构性红线(铁律⑥):零内建工具——本文件只注册下列领域工具,agent 无 bash/write/edit; +// 结构性红线(铁律⑥):零内建工具——本面只注册下列领域工具,agent 无 bash/write/edit; // 唯一写口 = queue_action(append-only,恒 vetoable,幂等去重),包/台账/判例写回全在 host。 // 警察函数(校验/沙箱/台账)不注册为工具(被审计者不持审计工具)。 // // 失败面统一(铁律②):一切失败 = 结构化信封 {ok:false,code,reason},不抛裸栈、不静默; // 信封禁止入 evidence(语义门拒绝),降级人裁区。硬门数字只认 structuredContent, // 测量工具恒带 trace{cmd,rc,artifact}(铁律③)。 -import { execFile, spawn } from "node:child_process"; -import { promisify } from "node:util"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; import path from "node:path"; -import dns from "node:dns/promises"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import { fetch as undiciFetch, Agent } from "undici"; -import { Type } from "@earendil-works/pi-ai"; - -const execFileP = promisify(execFile); -const nodeRequire = createRequire(import.meta.url); // ESM 下解析包内文件真路径(pi-web-access MCP CLI) -const AGENT_DIR = path.dirname(fileURLToPath(import.meta.url)); -export const ROOT = path.resolve(AGENT_DIR, "..", ".."); // worktree 根(沙箱红线不变式:落位推导) -export const DOCS_ROOT = process.env.DOCS_ROOT ?? path.join(ROOT, "docs"); // 符号链接 → docs 仓 -const LIBSH = path.join(AGENT_DIR, "lib.sh"); - -// ── 边界常量(env 可覆盖,selftest 用)──────────────────────────────────────── -const B = { - fetchBytes: +(process.env.OPS_FETCH_MAX_BYTES ?? 512 * 1024), - fetchPerRound: 20, - fetchTimeoutMs: 15_000, - ghPerRound: 15, - ghTimeoutMs: 60_000, - queuePerRound: 10, - readBytes: 50 * 1024, - readLines: 2000, - probeBytes: +(process.env.OPS_PROBE_MAX_BYTES ?? 256 * 1024 * 1024), - probeMaxKeys: +(process.env.OPS_PROBE_MAX_KEYS ?? 1_000_000), // bitwire 4.58M OOM 判例:过线体量人裁 - probeDlTimeoutMs: 300_000, - probeCliTimeoutMs: +(process.env.OPS_PROBE_CLI_TIMEOUT_MS ?? 1_800_000), - matrixTimeoutMs: +(process.env.OPS_MATRIX_TIMEOUT_MS ?? 1_200_000), - anchorSweepTimeoutMs: +(process.env.OPS_ANCHOR_SWEEP_TIMEOUT_MS ?? 300_000), // #182:19 锚直调 lookup 远轻于 matrix 全扫(20min);5min 兑底 - hitCap: 8, // 铁律⑦:判例注入与检索命中上限 8(检索深度伤害决策) - zeroAssetKb: 32, - webReadPerRound: 8, // 发现通道三件预算行:防失控非省钱(2026-10-24 裁定,DESIGN §6;预算四界另批) - webSearchPerRound: 6, - rssParsePerRound: 6, - webReadTimeoutMs: 20_000, // r.jina.ai 正文面 20s(票面;上游可 10s+) - webSearchTimeoutMs: 40_000, // pi-web-access MCP:握手 ~1s + DDG 内部 30s 上限(pi-web-access 0.44.0) -}; -const EVAL_PY = process.env.OPS_EVAL_PY ?? "/home/huxiao/dev/pi-ip-lookup-tool/backend/.venv/bin/python"; -const DATA_DIR = process.env.OPS_DATA_DIR ?? "/home/huxiao/dev/pi-ip-lookup-tool/backend/data"; -const UA = "ip-radar-ops-agent/1.0"; -export const FROZEN_DAYS = 180; // threatview 判例:sunset 门线 -export const RSS_FRESH_DAYS = 30; // rss 新鲜门(feed 活性):独立常量勿复用 FROZEN_DAYS——门线语义不同(活性 vs sunset 冻结);DESIGN §6 2026-10-24 裁定 -const PREDICATES = ["sunset_frozen", "mirror_echo", "zero_asset"]; // discover 谓词表闭集 -const MEASUREMENT_TOOLS = new Set(["fetch_url", "gh_search", "exclusive_probe", "matrix_scan_summary"]); -// 发现通道三件(web_read/web_search/rss_parse,PR3a)不进 MEASUREMENT_TOOLS:与测量工具的边界 -// =数据形状天然隔离(发现线索非硬门数字),evidence 语义门不因发现通道放宽(DESIGN §6 2026-10-24 裁定)。 - -// ── SSRF:私网/环回/链路本地(逐跳,fetch_url 与 probe 下载共用)──────────────── -const BL = new net.BlockList(); -for (const [net4, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], - ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16], ["198.18.0.0", 15], - ["224.0.0.0", 4], ["240.0.0.0", 4]]) BL.addSubnet(net4, bits); -for (const [net6, bits] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) BL.addSubnet(net6, bits, "ipv6"); -export function checkPrivateIp(ip) { - if (ip.toLowerCase().startsWith("::ffff:") && net.isIPv4(ip.slice(7))) ip = ip.slice(7); // v4 映射地址 - return BL.check(ip, ip.includes(":") ? "ipv6" : "ipv4"); -} - -class SsrfError extends Error { constructor(h) { super(`SSRF 拒绝:${h} 解析到私网/环回/链路本地地址`); this.code = "ssrf"; } } - -// 返回本跳已验证的安全地址集(供连接层钉死);IP 字面量已验则返回空数组 -// (net.connect 对字面量不走 lookup,直连即已验地址)。 -async function guardHop(rawUrl) { - const u = new URL(rawUrl); - if (u.protocol !== "http:" && u.protocol !== "https:") throw Object.assign(new Error(`仅许 http(s):${rawUrl}`), { code: "bad_url" }); - const h = u.hostname.replace(/^\[|\]$/g, ""); - if (net.isIP(h)) { if (checkPrivateIp(h)) throw new SsrfError(h); return []; } - const addrs = await dns.lookup(h, { all: true }).catch(() => { throw Object.assign(new Error(`DNS 解析失败:${h}`), { code: "network" }); }); - const safe = []; - for (const { address } of addrs) { - if (checkPrivateIp(address)) throw new SsrfError(`${h}→${address}`); - safe.push(address); - } - return safe; -} - -// 连接层钉死:lookup 只吐 guardHop 已验证的地址——验证与连接同一地址集, -// 堵 TTL-0 DNS 重绑定的双解析 TOCTOU(评审 Important-2,2026-10-19)。 -// 选 undici Agent + undici.fetch(而非全局 fetch):全局 fetch 不保证消费自定义 dispatcher; -// hostname/SNI/证书校验仍按域名,仅连接地址被钉。导出供单测钉住机制。 -export function pinnedLookup(validatedAddrs) { - return (hostname, options, callback) => { - callback(null, validatedAddrs.map((a) => ({ address: a, family: net.isIPv6(a) ? 6 : 4 }))); - }; -} - -// 手动逐跳重定向:每一跳都过 SSRF 门并钉地址(融合必保:D1 逐跳复检)。 -// headers/allowStatus 注入面(#173 微探针专用,既有调用面零改):headers 合并覆盖(Range/UA 变体); -// allowStatus=true 时 ≥400 不抛错照常返回——状态码本身是读数(UA 拦截/限流/死键的判别信号, -// 同 lib.sh 把 http_/TOKEN_REQUIRED 当读数的口径),网络/超时/跳数上限才是失败信封。 -export async function safeFetch(rawUrl, { method = "GET", maxBytes, timeoutMs, headers = {}, allowStatus = false } = {}) { - let url = rawUrl, hops = 0, finalRes, active = null; - try { - for (;;) { - const validated = await guardHop(url); - const hop = new Agent({ connections: 1, connect: { lookup: pinnedLookup(validated) } }); - let r; - try { - r = await undiciFetch(url, { - method, redirect: "manual", signal: AbortSignal.timeout(timeoutMs), - headers: { "user-agent": UA, ...headers }, dispatcher: hop, - }); - } catch (e) { - await hop.close().catch(() => { /* 本跳失败即弃 */ }); - const why = e?.cause?.message ?? e?.message ?? String(e); - throw Object.assign(new Error(`网络失败:${why}`), { code: /timeout|abort/i.test(why) ? "timeout" : "network" }); - } - if (active) await active.close().catch(() => { /* 前一跳体已 cancel */ }); - active = hop; - finalRes = r; - if ([301, 302, 303, 307, 308].includes(finalRes.status)) { - const loc = finalRes.headers.get("location"); - await finalRes.body?.cancel().catch(() => { /* 重定向体不消费,显式 cancel 释放在飞请求 */ }); - if (!loc) throw Object.assign(new Error(`${finalRes.status} 无 Location`), { code: "http_error" }); - if (++hops > 5) throw Object.assign(new Error("重定向 >5 跳"), { code: "too_many_hops" }); - url = new URL(loc, url).toString(); - continue; - } - break; - } - // GET 体必须在本跳 dispatcher 开着时消费(复审 Important,2026-10-19):undici fetch - // 头部即 resolve,体流是消费者驱动带背压;先 close() 会等在飞请求,体永不被喡 → - // 卡到超时。实测复现:30MB 体 headers 20ms,close() 4s+ 仍挂起;177KB 则侥幸过。 - let buf = null; - if (method === "GET") { - const reader = finalRes.body.getReader(); - const chunks = []; let n = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - n += value.byteLength; - if (n > maxBytes) { await reader.cancel(); throw Object.assign(new Error(`响应超过 ${maxBytes}B 上限`), { code: "too_large" }); } - chunks.push(value); - } - buf = Buffer.concat(chunks); - } else { - await finalRes.body?.cancel().catch(() => { /* HEAD 等无体请求:显式释放 */ }); - } - if (finalRes.status >= 400 && !allowStatus) throw Object.assign(new Error(`HTTP ${finalRes.status}`), { code: "http_error", status: finalRes.status }); - // 通道复验轮修复(2026-10-24,轮包 2026-10-24-discover-0634 实锤):原函数体 `const headers` - // 遮蔽参数解构的 headers(#173 微探针注入面)——函数体 const 全作用域 TDZ,构造请求头处即抛 - // Cannot access 'headers' before initialization,fetch_url/exclusive_probe/微探针 100% 必死。 - // 1412 轮「DEEP 探针网络失败(peeringdb 不通)」归因实为本虫(transcript 在案),非网络。 - const resHeaders = Object.fromEntries(finalRes.headers.entries()); - return { finalUrl: url, status: finalRes.status, headers: resHeaders, body: buf, bytes: buf?.byteLength ?? 0 }; - } finally { - await active?.close().catch(() => { /* 消费/取消/异常后统一收口(钉址 agent 单跳即弃) */ }); - } -} - -// ── 通用助手 ────────────────────────────────────────────────────────────── -const envelope = (code, reason) => ({ ok: false, code, reason }); -const ts = () => { - const d = new Date(), p = (x) => String(x).padStart(2, "0"); - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; -}; -const runLib = (fn, args = [], timeoutMs = 600_000) => - execFileP("bash", ["-c", `. "$0"; ${fn} "$@"`, LIBSH, ...args], - { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, env: { ...process.env } }); -const artifactPath = (dir, slug, ext) => { - fs.mkdirSync(dir, { recursive: true }); - const p = path.join(dir, `${ts()}-${slug}.${ext}`); - return p; -}; - -// ── matrix 扫描 CLI 单源(P1,#163)─────────────────────────────────────────── -// tools.mjs health_snapshot(matrix 腿)与 pipeline/steps.mjs PROBE 共用同一调用路径: -// 位等价=同 EVAL_PY 命令+同解析+同工件序列化(matrixArtifactText);legacy 侧零行为改动 -// (runMatrixScan 只剩三分支收编的薄包装)。返回分支: -// {ok:true, d, rc, cmd} 读数(rc 0/1/2 都是读数,round.sh T3 判例) -// {ok:false, env, cmd, trace:null} stdout 带 error 键的失败信封(无 trace,同原语义) -// {ok:false, env, cmd, trace:{cmd,rc}} 裸失败信封(带 trace) -export async function matrixScanCli() { - const cmd = `cd backend && IP_RADAR_DATA_DIR=${DATA_DIR} ${EVAL_PY} -m ipdb._eval --matrix-scan --json --assets-dir ${DATA_DIR}/eval/assets`; - try { - const { stdout } = await execFileP(EVAL_PY, - ["-m", "ipdb._eval", "--matrix-scan", "--json", "--assets-dir", `${DATA_DIR}/eval/assets`], - { cwd: path.join(ROOT, "backend"), env: { ...process.env, IP_RADAR_DATA_DIR: DATA_DIR }, - timeout: B.matrixTimeoutMs, maxBuffer: 32 * 1024 * 1024 }); - const d = JSON.parse(stdout); - if (d.error) return { ok: false, env: envelope(d.error.code ?? "matrix_failed", d.error.message ?? ""), cmd, trace: null }; - return { ok: true, d, rc: 0, cmd }; - } catch (e) { - // rc 0/1/2 = 读数(干净/pending/violation,round.sh T3 判例);execFile 对非零退出 reject, - // 但 stdout 携带带 exit_code 的有效 JSON 时仍是读数不是故障 - try { - const d = JSON.parse(String(e.stdout ?? "")); - if (typeof d.exit_code === "number") return { ok: true, d, rc: e.code ?? 0, cmd }; - if (d.error) return { ok: false, env: envelope(d.error.code ?? "matrix_failed", d.error.message ?? ""), cmd, trace: null }; - } catch { /* 裸失败走信封 */ } - return { ok: false, env: envelope("matrix_failed", String(e.stderr ?? e.message ?? "").slice(0, 300)), cmd, trace: { cmd, rc: e.code ?? 1 } }; - } -} -export const matrixArtifactText = (d) => JSON.stringify(d, null, 1); // matrix 工件序列化单源:legacy -matrix-scan.json 与管线 01-probe-matrix.json 逐字节同文(#163 位等价) - -// ── ops 轮锚巡检 CLI 单源(票 #182;matrixScanCli 同型先例)────────────── -// 面向 python -m ipdb._eval --anchor-sweep --json(求值核=ipdb/_eval/anchors.py -// anchor_sweep;OPS_EVAL_PY/OPS_DATA_DIR 桥=backend _round_env 注入,容器内 -// 解释器与数据面=后端查询进程同一套——读数即生产读数,零认证零网络)。 -// 返回分支同 matrixScanCli 三形: -// {ok:true, d, rc:0, cmd} 读数(锚 finding 在场仍 rc 0=读数非故障, -// 不持门语义在 CLI 面) -// {ok:false, env, cmd, trace:null} stdout 带 error 键的失败信封(load_db 等) -// {ok:false, env, cmd, trace:{cmd,rc}} 裸失败(通道四码兼容:ENOENT/超时/限流/ -// 鉴权族过 classifyChannelError 四码; -// gh_error/rg_infra 兑底码是 gh/rg 专属 -// 不复用于 python 面,兑底归 infra) -export async function anchorSweepCli({ evalPy = EVAL_PY, dataDir = DATA_DIR, timeoutMs = B.anchorSweepTimeoutMs } = {}) { - const cmd = `cd backend && IP_RADAR_DATA_DIR=${dataDir} ${evalPy} -m ipdb._eval --anchor-sweep --json`; - try { - const { stdout } = await execFileP(evalPy, - ["-m", "ipdb._eval", "--anchor-sweep", "--json"], - { cwd: path.join(ROOT, "backend"), env: { ...process.env, IP_RADAR_DATA_DIR: dataDir }, - timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 }); - const d = JSON.parse(stdout); - if (d.error) return { ok: false, env: envelope(d.error.code ?? "anchor_sweep_failed", d.error.message ?? ""), cmd, trace: null }; - return { ok: true, d, rc: 0, cmd }; - } catch (e) { - try { - const d = JSON.parse(String(e.stdout ?? "")); - if (d.error) return { ok: false, env: envelope(d.error.code ?? "anchor_sweep_failed", d.error.message ?? ""), cmd, trace: null }; - } catch { /* 裸失败走信封 */ } - const c = classifyChannelError(e, "python"); - if (["channel_missing", "channel_auth", "quota", "transient"].includes(c.code)) - return { ok: false, env: envelope(c.code, c.reason), cmd, trace: { cmd, rc: c.rc } }; - return { ok: false, env: envelope("infra", String(e.stderr ?? e.message ?? "").slice(0, 300)), cmd, trace: { cmd, rc: c.rc } }; - } -} - -// ── gh 面子命令白名单(票 #184;spec #180 首批租户 Q6 授权写通道)────────────────── -// search=只读模型工具(ghSearch,每轮≤15);issue create=gh 面唯一写面,仅 finding 出口脚本步 -// (pipeline/finding-exit.mjs)消费——模型工具面零新增写口(MEASUREMENT_TOOLS 不动,evidence -// 语义门不受污染);写面闭集扩张=spec 变更。标签缺省 needs-triage(triage-labels 五默认角色,人裁终局)。 -export const GH_WRITE_SUBCOMMANDS = ["issue create"]; - -// ghIssueCreate——开 gh issue(出口只写不动作两写口之一;失败永不抛,分类信封同 gh_search 四码)。 -// dryRun=只记将发命令零 spawn(OPS_SWEEP_DRY_RUN 面的执行侧;测试恒 dry-run)。 -// 返回:{ok,dry_run,cmd,issue_url(真发成功=gh stdout 末行 URL;否则 null),error?:{code,reason}}。 -export async function ghIssueCreate({ title, body, labels = ["needs-triage"] } = {}, { dryRun = false, exec = execFileP, timeoutMs = B.ghTimeoutMs } = {}) { - const argv = ["issue", "create", "--title", String(title ?? ""), "--body", String(body ?? ""), - ...labels.flatMap((l) => ["--label", String(l)])]; - const cmd = `gh ${argv.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}`; - if (dryRun) return { ok: true, dry_run: true, cmd, issue_url: null }; - try { - const { stdout } = await exec("gh", argv, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }); - return { ok: true, dry_run: false, cmd, issue_url: String(stdout ?? "").trim().split("\n").pop() || null }; - } catch (e) { - const cls = classifyChannelError(e, "gh"); - return { ok: false, dry_run: false, cmd, issue_url: null, error: { code: cls.code, reason: cls.reason.slice(0, 300) } }; - } -} - -export function countFeedKeys(text) { // probe 键量:IPv4 去重(exclusive.py _IP_RE 同口径的 v4 面) - return new Set(text.match(/(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?/g)?.map((t) => t.split("/")[0]) ?? []).size; -} - -// ── 通道错误分类(信封四码;PR #177 工具通道修复;判例 docs/ops/precedents/2026-10-24-discover-dual-channel-dead)── -// 2026-10-24 01:59 生产 discover 轮双通道全灭的事故面修复:旧路径 gh_search 失败一律 gh_error -// 兑底(容器无 gh 也不可辨),且 trace.rc 在 ENOENT 时塞字符串违反 outputSchema Number 槽。 -// code additive 四码 + 原兑底(gh_error/rg_infra)兼容保留;判定优先级(2026-10-24 裁定; -// 审后修 P2-3 调序并注明 why):channel_missing(spawn ENOENT/rc=127)> quota(stderr 含 -// rate limit;GitHub 次级限流形态=403+rate limit 文案,归此才给对「窗口恢复」指引)> -// channel_auth(not logged in/Authentication required/gh auth login 免凭据实测报文/ -// GH_TOKEN/401/403,词边界)> transient(超时/网络)> 原兑底。 -// 每码文案带「下一步合法动作」(对齐 MCP isError 语义 2026-07-28:错误要能引导模型自纠或 -// 升级,出处注明);不可重试三码(channel_missing/channel_auth/quota)由 prompts/discover-system.md -// 信封处置条款消费(基础设施面直接人裁区)。GLOSSARY「通道信封四码」同批录入。 -// trace.rc 恒 Number:ENOENT→127;超时被 SIGTERM 杀(execFile code=null)→124(GNU -// timeout(1) 惯例);其余取 execFile 退出码,兑底 1。 -const CHANNEL_HINTS = { - channel_missing: "下一步:该通道本轮不可用——候选直接移人裁区并注明信封内容;修复属部署动作(镜像装配该二进制),勿轮内重试", - channel_auth: "下一步:凭证属部署面(GH_TOKEN 配置见 deploy/ops-cron.md)——候选移人裁区注明,勿轮内重试、勿改写查询绕行", - quota: "下一步:账户/配额面——充值或窗口恢复后自然可用;候选移人裁区注明,勿轮内重试", // 措辞兼余额形态(s.jina.ai 402;PR3a),窗口性限流同码 - transient: "下一步:可同参数重试 ≤1 次;再失败即人裁区注明信封内容,勿猜数字", - gh_error: "下一步:候选移人裁区注明信封原文;勿替失败补数(无证据≠清白)", - rg_infra: "下一步:基础设施故障非零命中(Minor-3 判例)——人裁区注明;勿当作「无判例」继续推理", -}; -export function classifyChannelError(e, bin) { - const stderr = String(e?.stderr ?? ""); - const msg = String(e?.message ?? e ?? ""); - const exitCode = typeof e?.code === "number" ? e.code : null; - // 审后修 P2-2/P2-3(2026-10-24,PR #177):timedOut 判据只认 execFile 信号面(killed/ - // SIGTERM/ETIMEDOUT 错误码)——旧 /timed out/i 扫 message 首行含命令行,查询词恰含该 - // 短语会误归 transient;quota 提到 channel_auth 之前(次级限流 403+rate limit 归窗口恢复 - // 指引);channel_auth 补真免凭据报文模式(gh auth login/GH_TOKEN 提示,2026-10-24 - // GH_CONFIG_DIR 隔离实测)并加词边界。 - const timedOut = e?.killed === true || e?.signal === "SIGTERM" || e?.code === "ETIMEDOUT"; - const rc = e?.code === "ENOENT" ? 127 : exitCode ?? (timedOut ? 124 : 1); - let code; - if (e?.code === "ENOENT" || exitCode === 127) code = "channel_missing"; - else if (/rate limit|balance|insufficient|\b402\b/i.test(stderr)) code = "quota"; // 402/balance/insufficient=s.jina.ai 余额形态(PR3a):402 显式归 quota 不归 channel_auth——quota 分支先判 - else if (/not logged in|Authentication required|gh auth login|\bGH_TOKEN\b|\b40[13]\b/.test(stderr)) code = "channel_auth"; - else if (timedOut) code = "transient"; - else code = bin === "gh" ? "gh_error" : "rg_infra"; - const detail = `${bin} 通道失败(rc=${rc}):${(stderr || msg).slice(0, 200)}`; - return { code, rc, reason: `${detail}——${CHANNEL_HINTS[code]}` }; -} - -// ── 判例检索单源(PR #177;判例 2026-10-24-discover-dual-channel-dead)──────────── -// rg spawn argv 逐字对齐 backend/ipdb/_ops_store.rg_search_precedents(Python 参考实现, -// Python 面零改动):["rg","-i","-l","-g","*.md","-g","!_*",...每词 "-e",kw,dir]。 -// 成功路径注入块与 lib.sh inject_precedents 逐字节等价(golden/inject-block.* 钉死; -// fmField 重实现 fm_block 拍平语义);失败面走 classifyChannelError 分类信封 -// (rc=1 = 真零命中;rc≥2/ENOENT/超时 ≠ 零命中)。agent.mjs 宿主预注入同调本函数(单源); -// lib.sh inject_precedents 保留仅供 pipeline/steps.mjs(pipeline 面本批冻结不触; -// 两路输出 golden 字节等价由 selftest-t4 对拍钉死)。 -export const RG_TIMEOUT_MS = 60_000; // _ops_store._RG_TIMEOUT_S 同值(两侧同签,勿单边改) - -export function resolvePrecedentsDir(env = process.env) { - // call-time 解析(契约表 PRECEDENTS_DIR phase=call-time;DOCS_ROOT 用模块顶 import-time 值兑底) - return env.PRECEDENTS_DIR ?? path.join(env.DOCS_ROOT ?? DOCS_ROOT, "ops", "precedents"); -} - -// fmField — lib.sh fm_block 的 JS 逐语义重实现(frontmatter 顶层字段拍平摘要,非 YAML 解析)。 -// 字节等价的隐藏细节(golden 钉死,以现行为准不顺手修正): -// - key 行内联值记入,块标示符(> / |)起头不记:折叠字段首个续行拍平后带前导空格 → -// 「context: 」双空格是现行输出真形状; -// - 字段块内任何非缩进行(含空行)= 块终止(awk exit 语义:单引号多行值 key 行后空行即截断); -// - 续行/嵌套子键(reward 映射)两侧 [ \t] 裁剪后以单空格连接,空续行跳过。 -export function fmField(text, field) { - const lines = text.split("\n"); - if (lines[0] !== "---") return ""; // NR==1 非 ---:无 frontmatter - let out = "", active = false; - for (let i = 1; i < lines.length; i++) { - const l = lines[i]; - if (/^---/.test(l)) break; // frontmatter 终线 - const m = l.match(/^([A-Za-z_][A-Za-z0-9_]*):/); // 顶层键(列 0;缩进子键不命中) - if (m) { - if (m[1] === field) { - active = true; - const rest = l.slice(m[0].length).replace(/^[ \t]+/, "").replace(/[ \t]+$/, ""); - if (rest !== "" && !/^[|>]/.test(rest)) out += (out === "" ? "" : " ") + rest; - } else if (active) break; // 下一顶层键 = 本字段块结束 - continue; - } - if (active && /^[ \t]/.test(l)) { - const line = l.replace(/^[ \t]+/, "").replace(/[ \t]+$/, ""); - if (line !== "") out += " " + line; // 首续行前导空格 = 双空格细节的来源 - } else if (active) break; // 非缩进行(含空行)= 字段块终止 - } - return out; -} - -// 返回 { block, files, trace }:block=注入块文本(逐字节等价 lib.sh;含尾换行);files= -// 排序去重命中路径(sort -u 语义;词法序——判例文件名全 ASCII 日期前缀,locale 无关); -// trace={cmd:真 argv,rc:真 rc}。失败抛 Error(.envelope={code,rc,reason} 分类信封 + .trace)。 -export async function searchPrecedentsCore(keywords, { precedentsDir, env = process.env, timeoutMs = RG_TIMEOUT_MS } = {}) { - const dir = path.resolve(precedentsDir ?? resolvePrecedentsDir(env)); - // execFile(file,args) 的 args 不含二进制名(Python subprocess 全 argv 含 "rg" ——两侧逻辑 - // argv 逐字对齐,此处拼接点不同);trace.cmd 展示含 rg 的完整逻辑 argv - const args = ["-i", "-l", "-g", "*.md", "-g", "!_*"]; - for (const kw of keywords) args.push("-e", kw); - args.push(dir); - const cmd = ["rg", ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" "); - let stdout = ""; - if (keywords.length) { // 零词守卫(lib.sh 同形:不跑 rg——rg 无 pattern 会 rc=2;Python 面由 ValueError 拒词,契约对拍不覆盖零词) - try { - ({ stdout } = await execFileP("rg", args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, env })); - } catch (e) { - if (e.code === 1) stdout = String(e.stdout ?? ""); // rc=1 = 真零命中(Python 参考同语义;execFile 对非零退出 reject,此处置回成功面) - else { - const cls = classifyChannelError(e, "rg"); - throw Object.assign(new Error(cls.reason), { envelope: cls, trace: { cmd, rc: cls.rc } }); - } - } - } - const files = [...new Set(stdout.split("\n").filter((l) => l !== ""))].sort(); - const kwsLine = keywords.join(" "); - if (!files.length) { - return { block: `### 注入判例(检索词: ${kwsLine})\n无判例(检索词: ${kwsLine})\n`, files, trace: { cmd, rc: 0 } }; - } - const blockLines = [`### 注入判例(检索词: ${kwsLine})`]; - for (const f of files) { - const text = fs.readFileSync(f, "utf8"); // rg 输出行即路径(与 lib.sh fm_block "$f" 同读法) - blockLines.push(`- ${path.basename(f)}`); - for (const field of ["context", "human_ruling", "reward"]) blockLines.push(` - ${field}: ${fmField(text, field)}`); - } - return { block: `${blockLines.join("\n")}\n`, files, trace: { cmd, rc: 0 } }; -} - -// ── 发现通道三件纯函数核(PR3a 薄工具批;DESIGN §6 2026-10-24 修订裁定)────────── -// web_read/web_search/rss_parse 的归一/解析核(离线单测面:selftest-t4 钉归一/截断/ -// 畸形行;网络层部署段真网验)。实测事实(2026-10-24,两行为勿写反):r.jina.ai 裸调用 -// (无 Authorization)正常,带欠费 key 反 402 → web_read 恒不带 key。 -// web_search 引擎 = pi-web-access(2026-10-24 用户令「用 pi-web-access 实现网络搜索」, -// https://pi.dev/packages/pi-web-access;pin 0.44.0):多 provider 网络搜索 + 智能回退 -// 链。载体 = 包的 MCP CLI(dist/mcp-cli.js,stdio JSON-RPC)——程序化 dist/index.js -// 只导出 pi extension 面,TS 源在 node_modules 下被 Node 拒绝类型剥离,MCP 是唯一稳定 -// 程序化口;每次调用 spawn 一发会话(无守护进程生命周期;预算 ≤6/轮,启动 ~0.3s 可 -// 接受)。provider 路由不在代码里选:配置单源 web-search.json(PI_CODING_AGENT_DIR -// 指路,auth.json 同型判例——_ops_rounds._round_env 物化进 ops 树)的 searchRouting -// 回退链 duckduckgo(keyless,isDuckDuckGoAvailable()≡true,零配置一发即活,离线 -// 确定性优先)→ jina(可用性 = jinaApiKey/JINA_API_KEY,充值/配置后自然入链);零配置 -// Exa MCP 需运行时拉起外部进程,不入链(确定性优先,2026-10-24 裁定)。 -export function normalizeJinaRead(rawText, { maxChars = 4000 } = {}) { - // Jina r 缺省文本面:元数据前缀行(Title:/URL Source:/Markdown Content:,空行分隔); - // 无前缀(上游形变)则 title=null、整文为正文(宁少算不猜)。 - const lines = String(rawText ?? "").split("\n"); - const skipBlank = (i) => { while (lines[i] !== undefined && lines[i].trim() === "") i++; return i; }; - let title = null, i = 0; - if (lines[0]?.startsWith("Title:")) { - title = lines[0].slice("Title:".length).trim() || null; - i = skipBlank(1); - } - // 前缀族逐行可选剥离(审后修 P2:Published Time 行——真 fixture 首发遗漏,带日期页 - // 面该元数据行会漏入正文;同 URL Source 型,出现才吃) - if (lines[i]?.startsWith("URL Source:")) i = skipBlank(i + 1); - if (lines[i]?.startsWith("Published Time:")) i = skipBlank(i + 1); - if (lines[i]?.startsWith("Markdown Content:")) i += 1; - const body = lines.slice(i).join("\n").trim(); - const content = body.slice(0, maxChars); - return { title, content, chars_n: content.length, truncated: body.length > content.length }; -} - -export function parseSerp(rawJson, { num = 5 } = {}) { - // SERP JSON 归一(通用形状,离线钉解析语义):顶层数组或 {data:[…]} 两形;畸形行 - // (非对象/缺 url)跳过不冒充结果(宁少算);num 本地截断(truncated=服务端多回)。 - // 注:web_search 引擎已换 pi-web-access MCP(PR3a 中程裁定),本画数不再被工具面 - // 调用——保留为 SERP 归一语义钉(零死面:仅 selftest 引)。 - let rows; - try { - rows = JSON.parse(String(rawJson ?? "")); - } catch { - throw Object.assign(new Error("SERP 响应非 JSON(本地解析失败)"), { code: "io_error" }); - } - if (rows && typeof rows === "object" && !Array.isArray(rows) && Array.isArray(rows.data)) rows = rows.data; - if (!Array.isArray(rows)) throw Object.assign(new Error("SERP 形状坏(非数组/data 数组)"), { code: "io_error" }); - const results = []; - for (const r of rows) { - if (r == null || typeof r !== "object" || typeof r.url !== "string" || !r.url) continue; - results.push({ - url: r.url, - title: String(r.title ?? "").replace(/\s+/g, " ").trim(), - snippet: String(r.snippet ?? r.description ?? "").replace(/\s+/g, " ").trim().slice(0, 300), - }); - } - return { results: results.slice(0, num), n: Math.min(results.length, num), truncated: results.length > num }; -} - -export function parseRss(rawText, { freshDays = RSS_FRESH_DAYS, now = Date.now() } = {}) { - // 零依赖最小解析(正则/字符串扫描,勿新增 npm 依赖——薄工具裁定):RSS2.0 - // /<link>/<pubDate> + Atom <entry><title>/<link href>/<updated>。 - // 零 item/entry = io_error(feed 结构验证失败,不伪装空读数);无可解析时间 → - // latest_pubdate/fresh 双 null(fetch_url age_days NO-LM 同型:缺读数≠过期)。 - const text = String(rawText ?? ""); - const strip = (s) => String(s ?? "").replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); - const pick = (blk, re) => blk.match(re)?.[1] ?? ""; - const items = []; - let m; - const itemRe = /<item\b[^>]*>([\s\S]*?)<\/item\s*>/gi; - while ((m = itemRe.exec(text)) !== null) { - items.push({ - title: strip(pick(m[1], /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)), - link: pick(m[1], /<link\b[^>]*>([\s\S]*?)<\/link\s*>/i).trim(), - pubdate: pick(m[1], /<pubDate\b[^>]*>([\s\S]*?)<\/pubDate\s*>/i).trim(), - }); - } - const entryRe = /<entry\b[^>]*>([\s\S]*?)<\/entry\s*>/gi; - while ((m = entryRe.exec(text)) !== null) { - items.push({ - title: strip(pick(m[1], /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)), - link: (pick(m[1], /<link\b[^>]*\/?>/i).match(/href\s*=\s*"([^"]*)"/i)?.[1] - ?? pick(m[1], /<link\b[^>]*>([\s\S]*?)<\/link\s*>/i)).trim(), - pubdate: pick(m[1], /<updated\b[^>]*>([\s\S]*?)<\/updated\s*>/i).trim(), - }); - } - if (!items.length) throw Object.assign(new Error("非 RSS2.0/Atom 形状:零 <item>/<entry>(feed 结构验证失败)"), { code: "io_error" }); - let latest = null, latestStr = null; - for (const it of items) { - if (!it.pubdate) continue; - const t = Date.parse(it.pubdate); - if (Number.isNaN(t)) continue; - if (latest === null || t > latest) { latest = t; latestStr = it.pubdate; } - } - const ageDays = latest === null ? null : Math.max(0, Math.floor((now - latest) / 86_400_000)); // 时钟偏移铳 0(fetch_url 同口径) - return { - items_n: items.length, - latest_pubdate: latestStr, - fresh: ageDays === null ? null : ageDays <= freshDays, - sample: items.slice(0, 3).map((it) => it.title || "(无题)"), - }; -} - -// HTTP 状态面通道分类(web_read/web_search 共用;四码语义与 classifyChannelError -// 同族:quota(429/402/余额文案)先于 channel_auth(401/403)——402 归 quota 不归 -// channel_auth;文案复用 CHANNEL_HINTS(下一步合法动作,MCP isError 语义);状态面 -// 无 spawn,rc 恒 HTTP status;http_error 无 hint(提示词暂态条款辖,同 fetch_url)。 -function classifyWebStatus(status, bodyText, channel) { - const body = String(bodyText ?? "").replace(/\s+/g, " ").trim().slice(0, 160); - let code; - if (status === 429 || status === 402 || /balance|insufficient/i.test(body)) code = "quota"; - else if (status === 401 || status === 403) code = "channel_auth"; - else code = "http_error"; - const detail = `${channel} HTTP ${status}${body ? `:${body}` : ""}`; - const hint = CHANNEL_HINTS[code]; - return { code, rc: status, reason: hint ? `${detail}——${hint}` : detail }; -} -export { classifyWebStatus }; // 导出供 selftest-t4 状态面单测(四码语义与 classifyChannelError 同族对齐) - -// ── web_search 引擎:pi-web-access MCP 单发会话(2026-10-24 用户令;见上节选型注)── -const MCP_SEARCH_CMD = "pi-web-access-mcp web_search"; // trace 展示名(真 CLI = resolveMcpCliPath) - -function resolveMcpCliPath() { - // 包无 exports 字段,子路径走 legacy 解析到真实文件;包 pin 0.44.0(package.json) - return nodeRequire.resolve("pi-web-access/dist/mcp-cli.js"); -} - -// 单发 MCP 会话:initialize → initialized → tools/call → 收应答即退。stderr 首个 -// Error: 行即时捕获(坏配置时 CLI 启动即崩,栈很长,首行是唯一真因面;尾窗只作兑底)。 -async function mcpCallToolOnce(toolName, args, timeoutMs) { - const cli = resolveMcpCliPath(); - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [cli], { stdio: ["pipe", "pipe", "pipe"] }); - let buf = ""; - let stderrTail = ""; - let stderrErrLine = null; - let settled = false; - const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); child.kill(); fn(v); } }; - const timer = setTimeout(() => finish(reject, Object.assign(new Error(`MCP 会话超时(>${timeoutMs}ms)`), { killed: true })), timeoutMs); - const send = (o) => child.stdin.write(JSON.stringify(o) + "\n"); - // stdin EPIPE 守卫(审后修 P2):child 在应答后、下条 send 前死亡时 write 触发 - // EPIPE——未挂监听会让流错误裸崩 agent 进程(违铁律②);此处静默,结局由既有 - // exit/error 事件携带(MCP CLI 提前退出信封),无信息丢失。 - child.stdin.on("error", () => {}); - child.on("error", (e) => finish(reject, e)); // ENOENT(spawn 面)走 classifyMcpSearchError → channel_missing - child.stderr?.on("data", (d) => { - const s = String(d); - stderrTail = `${stderrTail}${s}`.slice(-400); - if (!stderrErrLine) stderrErrLine = /(?:^|\n)(Error:[^\n]+)/.exec(s)?.[1] ?? /(Failed to parse[^\n]+)/.exec(s)?.[1] ?? null; - }); - child.on("exit", (code) => finish(reject, Object.assign( - new Error(`MCP CLI 提前退出(rc=${code ?? "null"})${(stderrErrLine ?? stderrTail) ? `:${String(stderrErrLine ?? stderrTail).replace(/\s+/g, " ").trim().slice(0, 200)}` : ""}`), - { code: typeof code === "number" ? code : undefined }))); - child.stdout.on("data", (d) => { - buf += d; - let i; - while ((i = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, i); buf = buf.slice(i + 1); - if (!line.trim()) continue; - let msg; - try { msg = JSON.parse(line); } catch { continue; } - if (msg.id === 1) { - send({ jsonrpc: "2.0", method: "notifications/initialized" }); - send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } }); - } else if (msg.id === 2) { - if (msg.error) finish(resolve, { isError: true, text: `MCP 协议错误:${msg.error.message ?? ""}` }); - else { - const r = msg.result ?? {}; - finish(resolve, { - isError: r.isError === true, - text: (Array.isArray(r.content) ? r.content : []).map((c) => (c && typeof c.text === "string" ? c.text : "")).join("\n"), - }); - } - } - } - }); - send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ip-radar-ops-agent", version: "1.0" } } }); - }); -} - -// pi-web-access 的 MCP wire 面只吐文本(structuredContent 被剥,formatSearchSummary -// 语法):`**Provider:** <p>` 头 + Sources 块(`N. title\n url`) + 段内机会式 -// `Source: t (u)` 尾行(前文=该源摘要,duckduckgo 形;无此形则 snippet 留空——宁少算)。 -// 错误面:provider 失败以 `Error: …` 行内嵌(isError:false!);真 fixture 钉死语法。 -export function parseMcpSearchText(text) { - const t = String(text ?? ""); - const provider = t.match(/^\*\*Provider:\*\*\s*(.+)$/m)?.[1]?.trim() ?? null; - const srcIdx = t.indexOf("**Sources:**"); - const results = []; - if (srcIdx >= 0) { - const end = t.indexOf("\n---", srcIdx); - const block = t.slice(srcIdx, end >= 0 ? end : undefined); - const re = /^(\d+)\.\s+(.*?)\r?\n\s+(\S+)\s*$/gm; - let m; - while ((m = re.exec(block)) !== null) results.push({ url: m[3].trim(), title: m[2].trim(), snippet: "" }); - } - if (results.length) { - const byUrl = new Map(results.map((r) => [r.url, r])); - for (const para of t.slice(0, srcIdx >= 0 ? srcIdx : t.length).split(/\n{2,}/)) { - const sm = para.match(/^(?:.|\n)*?Source:\s+.*?\((https?:\/\/[^\s)]+)\)\s*$/); - if (!sm) continue; - const r = byUrl.get(sm[1]); - if (r && !r.snippet) r.snippet = para.replace(/Source:\s+.*$/, "").replace(/\s+/g, " ").trim().slice(0, 300); - } - } - const error = t.match(/^Error:\s*(.+)$/m)?.[1]?.trim() ?? null; - // wire 漂移哨(审后修 P2:升级后语法漂移会伪装成「零命中」):0.44.0 成功面必有 - // **Provider:** 头(真网实拍 fixture 钉);三无(provider/Sources/Error)且文本非空 - // = wire 变形 → io_error 信封,宁报通道异常不报假零。**包升级必须复跑真网 fixture** - // (selftest 钉的是 0.44.0 语法)。 - if (!provider && !results.length && !error && t.trim()) { - throw Object.assign(new Error("MCP wire 文本无 Provider/Sources/Error 标记(语法漂移?pi-web-access 版本升级须复验)"), { code: "io_error" }); - } - return { provider, results, error }; -} - -// MCP 面失败分类(四码同族;SearchProviderError.kind 词汇入判定): -// channel_missing(spawn ENOENT/rc127、config 形坏=部署面)> quota(402/429/balance/ -// insufficient)> channel_auth(credential/auth/401/403)> transient(超时/network/aborted) -// > io_error 兑底(解析/协议面,文案带下一步:人裁区注明勿补数)。 -const MCP_SEARCH_KINDS = new Set(["transient", "quota", "network", "credential", "config", "auth", "invalid-request", "invalid-response", "unsupported", "aborted", "unknown"]); -export function classifyMcpSearchError(failure, { rc } = {}) { - const msg = String(failure?.message ?? failure ?? ""); - let code; let rcr = typeof rc === "number" ? rc : 1; - if (failure?.code === "ENOENT" || rcr === 127) { code = "channel_missing"; rcr = 127; } - else if (failure?.killed === true || failure?.signal === "SIGTERM" || failure?.code === "ETIMEDOUT" || /timeout|超时|aborted/i.test(msg)) { code = "transient"; rcr = 124; } - else { - const kinds = [...msg.matchAll(/(?:failed \(|\[)(\w+)(?:\)|\])/g)].map((m) => m[1]).filter((k) => MCP_SEARCH_KINDS.has(k)); - if (kinds.includes("quota") || /\b40[29]\b|balance|insufficient/i.test(msg)) code = "quota"; - else if (kinds.includes("credential") || kinds.includes("auth") || /credential|not logged in|\b40[13]\b/i.test(msg)) code = "channel_auth"; - else if (kinds.includes("network")) code = "transient"; - else if (kinds.includes("config") || /Failed to parse .*web-search\.json|Invalid config/i.test(msg)) code = "channel_missing"; - else code = "io_error"; - } - const detail = `web_search 通道失败(pi-web-access MCP;rc=${rcr}):${msg.replace(/\s+/g, " ").slice(0, 200)}`; - const hint = CHANNEL_HINTS[code] ?? "下一步:候选移人裁区注明信封原文;勿替失败补数(无证据≠清白)"; - return { code, rc: rcr, reason: `${detail}——${hint}` }; -} - -// ── 读面白名单(融合必保 4:显式拒 .env/密钥模式/.venv/data;门=实现双层之实现层)── -const DENY_NAME = [/\.env(\.|$)/i, /\.pem$/i, /\.p12$/i, /\.key$/i, /(^|[-_.])(priv|secret|credential)s?([-_.]|$)/i]; -const DENY_SEG = [".git", ".venv", "node_modules", "backend/data"]; -export function checkReadablePath(absPath) { - // 不存在的路径不 realpath(ENOENT 会抢在拒读判定前炸):词法归一化即可支撑白名单/拒读判定 - const real = fs.existsSync(absPath) ? fs.realpathSync(absPath) : path.resolve(absPath); - const rootOf = (r) => (fs.existsSync(r) ? fs.realpathSync(r) : path.resolve(r)); - const roots = [rootOf(ROOT), rootOf(DOCS_ROOT)]; - if (!roots.some((r) => real === r || real.startsWith(r + path.sep))) { - throw new Error(`路径越界(白名单=仓根+docs 根):${absPath}`); - } - const rel = path.relative(ROOT, real); - if (DENY_SEG.some((s) => rel.split(path.sep).includes(s) || rel.includes(s + path.sep))) { - throw new Error(`路径禁读(${DENY_SEG.join("/")}):${absPath}`); - } - const base = path.basename(real).toLowerCase(); - if (DENY_NAME.some((re) => re.test(base))) throw new Error(`疑似密钥/凭证文件拒读(.env/密钥模式):${absPath}`); - return real; -} - -// evidence 字段点路径解析:"results.0.size_kb" / "results[0].size_kb" → 嵌套取值;无命中 undefined。 -// 导出供金轨迹 runner 复用同一解析语义(T6,避免断言侧另起炉灶)。 -export function resolveField(obj, field) { - const parts = String(field).replace(/\[(\d+)\]/g, ".$1").split("."); - let cur = obj; - for (const p of parts) { - if (cur == null || typeof cur !== "object" || !(p in cur)) return undefined; - cur = cur[p]; - } - return cur; -} - -// ── 判例引用闭合校验(铁律④:路径 ∈ 预注入 ∪ 本会话检索命中;存在性不够)──────── -export function closureCheck(pkgText, allowed /* Set<string> 文件名 */) { - const lines = pkgText.split("\n"); - const sec = []; let inSec = false; - for (const l of lines) { - if (/^#+\s.*注入判例/.test(l)) { inSec = true; continue; } - if (inSec && /^#+\s/.test(l)) break; - if (inSec) sec.push(l); - } - if (!sec.length) throw new Error("闭合校验:包缺少「注入判例」节"); - const toks = [...new Set(sec.flatMap((l) => (l.match(/[A-Za-z0-9_\-./]+\.md/g) ?? []).map((t) => path.basename(t))))]; - if (!toks.length) { - if (sec.some((l) => l.includes("无判例"))) return { cited: 0 }; - throw new Error("闭合校验:注入判例节既无路径也无「无判例」"); - } - const bad = toks.filter((t) => !allowed.has(t)); - if (bad.length) throw new Error(`闭合校验:判例路径不在(预注入∪本会话检索命中)闭集:${bad.join(", ")}(防真实文件假引用)`); - return { cited: toks.length }; -} - -// ── 本会话 structuredContent 登记 + 写前语义门(铁律①)──────────────────────── -export class ResultRegistry { - constructor() { this.byCall = new Map(); } - register(callId, toolName, sc) { this.byCall.set(callId, { tool: toolName, sc }); } - get(callId) { return this.byCall.get(callId); } -} +import { B, DOCS_ROOT, FROZEN_DAYS } from "./tools/shared.mjs"; +import { PREDICATES, MEASUREMENT_TOOLS, resolveField, ResultRegistry, registryMethods } from "./tools/registry.mjs"; +import { precedentsMethods } from "./tools/precedents.mjs"; +import { measurementMethods } from "./tools/measurement.mjs"; +import { discoveryMethods } from "./tools/discovery.mjs"; export class ToolHost { constructor(round, opts = {}) { @@ -715,7 +40,6 @@ export class ToolHost { } budgetSpend(name) { this.spent[name] = (this.spent[name] ?? 0) + 1; } - async beforeToolCall({ toolCall, args }) { const max = this.budgets[toolCall.name]; if (max !== undefined && (this.spent[toolCall.name] ?? 0) >= max) { @@ -724,7 +48,6 @@ export class ToolHost { if (toolCall.name === "queue_action") return this.semanticGate(args); return undefined; } - // 写前语义门:host 从本会话 structuredContent 重算谓词,不信 rationale/evidence 复述。 semanticGate(args) { const block = (reason) => ({ block: true, reason }); @@ -784,7 +107,6 @@ export class ToolHost { } return block("action 必须在闭集内:ENQUEUE/REJECT"); } - // 工具返回统一走这里:登记 structuredContent(信封也登记——被引时门内识别 ok:false) // + 预算计数 + 工件记录。content 前缀 call_id:实测(2026-10-19 discover 重放)模型 // 看不到运行时生成的调用 id,evidence{call_id} 无从逐字回引——必须把 id 上翻给模型。 @@ -795,551 +117,16 @@ export class ToolHost { this.budgetSpend(name); return { content: [{ type: "text", text: `[call_id=${callId}] ${contentText}` }], details, structuredContent: structured }; } +} - // ── 健康轮唯一工具:零参快照(旗标+计数进上下文,全量落工件;flash 无 search/read)── - healthSnapshot() { - return { - name: "health_snapshot", - label: "健康快照", - description: "零参。本地实测:全部源 feed 冻结检测(HEAD/Last-Modified)+ 语义矩阵违例扫描。返回旗标与计数(全量原始读数落工件文件);matrix 失败以嵌套信封呈现,不算轮次失败。", - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, - parameters: Type.Object({}), - outputSchema: Type.Object({ - ok: Type.Boolean(), urls_n: Type.Number(), frozen_n: Type.Number(), near_band_n: Type.Number(), - unreachable_n: Type.Number(), no_lm_n: Type.Number(), token_required_n: Type.Number(), - matrix_ok: Type.Boolean(), matrix_exit_code: Type.Number(), - artifacts: Type.Array(Type.String()), - }), - execute: async (callId) => { - const arts = []; - try { - const { stdout: urlOut } = await runLib("extract_feed_urls", [], 120_000); - const urls = urlOut.split("\n").map((s) => s.trim()).filter(Boolean); - if (!urls.length) throw Object.assign(new Error("extract_feed_urls 零 URL"), { code: "extract_failed" }); - const { stdout: tsv } = await runLib("feed_freshness", urls, 900_000); - // TSV 四类读数(P1 T2 修约,spec #152 §2a:401/403 → TOKEN_REQUIRED - // 需令牌·正常——不算不可达不进 unreachable,与 NO-LM 同待遇只计数; - // 契约修约判例 2026-10-20:消费方未现=修约非违约) - const frozen = [], near = [], unreachable = [], noLm = [], tokenReq = []; - for (const line of tsv.trimEnd().split("\n")) { - const [url, lm, age] = line.split("\t"); - if (age === "UNREACHABLE") unreachable.push(url); - else if (age === "TOKEN_REQUIRED") tokenReq.push(url); - else if (age === "NO-LM") noLm.push(url); - else { - const a = +age; - if (a >= FROZEN_DAYS) frozen.push({ url, last_modified: lm, age_days: a }); - else if (a >= 90) near.push({ url, age_days: a }); - } - } - const tsvArt = artifactPath(this.artifactDir, "health-freshness", "tsv"); - fs.writeFileSync(tsvArt, tsv); - arts.push(tsvArt); - const matrix = await this.runMatrixScan(arts); - const st = { - ok: true, urls_n: urls.length, frozen, frozen_n: frozen.length, near_band: near, near_band_n: near.length, - unreachable, unreachable_n: unreachable.length, no_lm_n: noLm.length, - token_required: tokenReq, token_required_n: tokenReq.length, - matrix_ok: matrix.ok, matrix_exit_code: matrix.exit_code ?? -1, - matrix: matrix.ok ? matrix : { envelope: matrix }, artifacts: arts, - }; - const c = [`源 URL ${urls.length};冻结≥${FROZEN_DAYS}天 ${frozen.length};近带(90-179) ${near.length};不可达 ${unreachable.length};NO-LM ${noLm.length};需令牌(401/403) ${tokenReq.length}`, - ...frozen.map((f) => `- 死键嫌疑·建议复核: ${f.url}(冻结 ${f.age_days} 天;Last-Modified: ${f.last_modified})`), - ...unreachable.map((u) => `- 不可达·建议复核: ${u}(不可达≠死键)`), - matrix.ok - ? [`matrix 读数: exit_code=${matrix.exit_code}(0 干净/1 pending/2 violation) violation=${matrix.counts.violation} pending=${matrix.counts.pending} agreement=${matrix.counts.agreement}`, - ...Object.entries(matrix.layers).map(([k, v]) => `layer ${k}: ${v.status}${v.notes.length ? `(${v.notes.join(",")})` : ""}`), - ...matrix.violation_cells.slice(0, 12).map((x) => `violation 格: ${x.cell}${x.note ? `(note: ${x.note})` : ""}`)] - : [`matrix 测量信封: ${matrix.code}: ${matrix.reason}`].join("\n"), - `工件: ${arts.join(", ")}`].flat().join("\n"); - return this.finish(callId, "health_snapshot", st, c); - } catch (e) { - const env = envelope(e.code ?? "snapshot_failed", e.message); - return this.finish(callId, "health_snapshot", { ...env, artifacts: arts }, `健康快照失败信封:${env.code}: ${env.reason}`); - } - }, - }; - } - - // ── discover 工具 ──────────────────────────────────────────────────────── - searchPrecedents() { - return { - name: "search_precedents", - label: "判例检索", - description: "关键词词法检索(rg)判例库 docs/ops/precedents/(唯一跨轮记忆;无嵌入)。命中≤8 条(铁律⑦);零命中显式返回 none。包内引用判例只许引注入块或本工具命中的文件。", - annotations: { readOnlyHint: true, idempotentHint: true }, - parameters: Type.Object({ - keywords: Type.Array(Type.String({ minLength: 1, maxLength: 64 }), { minItems: 1, maxItems: 5 }), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), hits: Type.Array(Type.String()), hit_n: Type.Number(), - truncated: Type.Boolean(), rg_rc: Type.Number(), trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), - }), - execute: async (callId, p) => { - // PR #177 单源改造:原生 rg spawn(searchPrecedentsCore),失败面分类信封 - // (判例 2026-10-24-discover-dual-channel-dead:旧路径硬编码 trace rc:0 连失败 - // 路径也带、lib.sh 吞 stderr 2>/dev/null、ENOENT 塞字符串违反 Number 槽)。 - // 成功路径输出与旧 lib.sh 路径逐字节等价(golden 钉死);结构化字段只增不删。 - try { - const { block, files, trace } = await searchPrecedentsCore(p.keywords); - const all = files.map((f) => path.basename(f)); - const truncated = all.length > B.hitCap; - const hits = all.slice(0, B.hitCap); - hits.forEach((h) => this.searchHits.add(h)); - const st = { ok: true, hits, hit_n: hits.length, truncated, rg_rc: 0, trace }; - return this.finish(callId, "search_precedents", st, - hits.length ? `${block.trim()}\n(命中 ${all.length} 条${truncated ? `,注入前 ${B.hitCap}(铁律⑦)` : ""})` : block.trim()); - } catch (e) { - const env = envelope(e.envelope?.code ?? "rg_infra", e.envelope?.reason ?? e.message); - return this.finish(callId, "search_precedents", { ...env, trace: e.trace ?? { cmd: "(未起跑)", rc: 1 } }, - `判例检索失败信封:${env.code}: ${env.reason}`); - } - }, - }; - } - - readFile() { - return { - name: "read_file", - label: "白名单读文件", - description: "只读。白名单=本仓根+docs 根;拒 .env/密钥模式/.venv/backend-data/node_modules。≤50KB/2000 行,支持 pattern 过滤(grep -E 语义)与 offset/limit。", - annotations: { readOnlyHint: true, idempotentHint: true }, - parameters: Type.Object({ - path: Type.String({ minLength: 1, maxLength: 512 }), - pattern: Type.Optional(Type.String({ maxLength: 128, description: "正则过滤,仅保留命中行" })), - offset: Type.Optional(Type.Number({ minimum: 1, maximum: 100000 })), - limit: Type.Optional(Type.Number({ minimum: 1, maximum: 2000 })), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), path: Type.String(), lines_n: Type.Number(), - bytes_n: Type.Number(), truncated: Type.Boolean(), content: Type.String(), - }), - execute: async (callId, p) => { - let real; - try { - real = checkReadablePath(path.resolve(p.path)); - const stat = fs.statSync(real); - if (!stat.isFile()) throw Object.assign(new Error("不是普通文件"), { code: "denied" }); - const truncatedBytes = stat.size > B.readBytes; - const text = fs.readFileSync(real, "utf8").slice(0, B.readBytes); - let lines = text.split("\n"); - if (p.pattern) { - let re; try { re = new RegExp(p.pattern); } catch { throw Object.assign(new Error("pattern 非法正则"), { code: "bad_pattern" }); } - lines = lines.filter((l) => re.test(l)); - } - const off = (p.offset ?? 1) - 1; - const lim = p.limit ?? B.readLines; - const sliced = lines.slice(off, off + lim); - const st = { - ok: true, path: real, lines_n: sliced.length, bytes_n: stat.size, - truncated: truncatedBytes || off + lim < lines.length, content: sliced.join("\n"), - }; - return this.finish(callId, "read_file", st, sliced.join("\n") || "(空)"); - } catch (e) { - const env = envelope(e.code === "denied" || /白名单|禁读|密钥/.test(e.message) ? "denied" : (e.code ?? "io_error"), e.message); - return this.finish(callId, "read_file", { ...env, path: p.path }, `读文件失败信封:${env.code}: ${env.reason}`); - } - }, - }; - } - - fetchUrl() { - return { - name: "fetch_url", - label: "URL 探测", - description: "只读 GET/HEAD。逐跳 SSRF 防护(私网/环回拒);512KB/15s/每轮≤20 次。返回 status/last_modified/age_days(GET 附样本≤5 行)。age_days 是 sunset 谓词的唯一数据源;HTTP≥400/超时=失败信封(不可达≠死键)。", - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, - parameters: Type.Object({ - url: Type.String({ pattern: "^https?://", maxLength: 500 }), - method: Type.Optional(Type.Union([Type.Literal("GET"), Type.Literal("HEAD")], { description: "默认 HEAD;GET 取样本" })), - max_bytes: Type.Optional(Type.Number({ minimum: 256, maximum: 524288 })), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), url: Type.String(), final_url: Type.String(), status: Type.Number(), - last_modified: Type.Union([Type.String(), Type.Null()]), age_days: Type.Union([Type.Number(), Type.Null()]), - bytes: Type.Number(), sample: Type.Array(Type.String()), - }), - execute: async (callId, p) => { - const method = p.method ?? "HEAD"; - try { - const res = await safeFetch(p.url, { method, maxBytes: p.max_bytes ?? B.fetchBytes, timeoutMs: B.fetchTimeoutMs }); - const lm = res.headers["last-modified"] ?? null; - const age = lm ? Math.max(0, Math.floor((Date.now() - Date.parse(lm)) / 86_400_000)) : null; - const sample = method === "GET" && res.body - ? res.body.toString("utf8").split("\n").map((l) => l.trim()).filter(Boolean).slice(0, 5).map((l) => l.slice(0, 200)) : []; - const st = { - ok: true, url: p.url, final_url: res.finalUrl, status: res.status, last_modified: lm, - age_days: age, bytes: res.bytes, sample, trace: { method, status: res.status }, - }; - const c = `${method} ${p.url} → ${res.status};Last-Modified: ${lm ?? "(无)"};age_days=${age ?? "null"};bytes=${res.bytes}` - + (sample.length ? `\n样本:\n${sample.join("\n")}` : ""); - return this.finish(callId, "fetch_url", st, c); - } catch (e) { - const env = envelope(e.code ?? "network", e.message); - return this.finish(callId, "fetch_url", { ...env, url: p.url, method }, `URL 探测失败信封:${env.code}: ${env.reason}`); - } - }, - }; - } - - ghSearch() { - const FIELDS = { - repos: ["fullName", "description", "pushedAt", "updatedAt", "stargazersCount", "language", "isArchived", "size", "url"], - code: ["path", "repository"], - commits: ["commit", "repository"], - }; - const project = (kind, rows) => rows.map((r) => kind === "repos" ? ({ - full_name: r.fullName, description: String(r.description ?? "").slice(0, 120), - pushed_at: r.pushedAt, updated_at: r.updatedAt, stars: r.stargazersCount, - language: r.language, archived: r.isArchived, size_kb: r.size, url: r.url, - }) : kind === "code" ? ({ repo: r.repository?.nameWithOwner, path: r.path }) - : ({ repo: r.repository?.nameWithOwner, sha: String(r.sha ?? "").slice(0, 12), date: r.commit?.committer?.date ?? r.commit?.author?.date ?? null, message: String(r.commit?.message ?? "").split("\n")[0].slice(0, 120) })); - return { - name: "gh_search", - label: "GitHub 检索", - description: "只读 gh search(子命令白名单仅 search;禁裸 stdout,固定字段投影)。kind=repos(候选仓元数据/size_kb)/code(仓内代码命中;零命中=零资产信号)/commits(出版方活性;注意 commits 检索必须带文本词,纯 qualifier 会报错)。每轮≤15 次。", - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, - parameters: Type.Object({ - query: Type.String({ minLength: 1, maxLength: 256 }), - kind: Type.Union([Type.Literal("repos"), Type.Literal("code"), Type.Literal("commits")]), - limit: Type.Optional(Type.Number({ minimum: 1, maximum: 30 })), - // page 已移除(通道复验轮修复 2026-10-24):gh 2.103.x search 无 --page 旗(repos/code - // 实测均拒 unknown flag)——旧 argv 恒拼 --page 故 gh_search 100% 必死,仅因前置通道先死从未暴露。 - // 分页需求由 limit ≤30(预算面)承担。 - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), kind: Type.String(), query: Type.String(), total: Type.Number(), - results: Type.Array(Type.Object({})), trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), - }), - execute: async (callId, p) => { - const argv = ["search", p.kind, p.query, "--json", FIELDS[p.kind].join(","), - "--limit", String(p.limit ?? 10)]; - const cmd = `gh ${argv.map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ")}`; - try { - const { stdout } = await execFileP("gh", argv, { timeout: B.ghTimeoutMs, maxBuffer: 8 * 1024 * 1024 }); - const rows = JSON.parse(stdout); - const results = project(p.kind, Array.isArray(rows) ? rows : []); - const st = { ok: true, kind: p.kind, query: p.query, total: results.length, results, trace: { cmd, rc: 0 } }; - const c = `${p.kind} "${p.query}" → ${results.length} 条(字段投影)\n` + - results.slice(0, 30).map((r) => `- ${JSON.stringify(r)}`).join("\n"); - return this.finish(callId, "gh_search", st, c); - } catch (e) { - // PR #177 分类落地(判例 2026-10-24-discover-dual-channel-dead):容器无 gh/ - // 凭证缺失/限流/暂态分四码信封,每码带下一步合法动作;trace.rc 恒 Number - // (旧 e.code ?? 1 在 ENOENT 时塞字符串违反 outputSchema Number 槽);原 gh_error - // 兑底兼容保留;成功路径零改动。 - const cls = classifyChannelError(e, "gh"); - const env = envelope(cls.code, cls.reason); - return this.finish(callId, "gh_search", { ...env, kind: p.kind, query: p.query, trace: { cmd, rc: cls.rc } }, - `gh 检索失败信封:${env.code}:${env.reason}`); - } - }, - }; - } - - // ── 发现通道三件(PR3a;DESIGN §6 2026-10-24 裁定;不进 MEASUREMENT_TOOLS——发现 - // 线索非硬门数字,evidence 语义门不因此放宽)────────────────────────────── - webRead() { - return { - name: "web_read", - label: "网页正文读", - description: "只读。GET r.jina.ai/<url> 取 markdown 正文(awesome 列表/厂商页取证)。恒无鉴权头(实测 2026-10-24:裸调用可用,带欠费 key 反 402);目标 url 先过 SSRF 私网面,逐跳防护同 fetch_url;正文截断头 max_chars(≤8000 缺省 4000);20s;每轮≤8 次。", - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, - parameters: Type.Object({ - url: Type.String({ pattern: "^https?://", maxLength: 500 }), - max_chars: Type.Optional(Type.Number({ minimum: 200, maximum: 8000, description: "正文保留头 N 字,缺省 4000" })), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), url: Type.String(), - title: Type.Union([Type.String(), Type.Null()]), - content: Type.String(), chars_n: Type.Number(), truncated: Type.Boolean(), - trace: Type.Object({ channel: Type.String(), status: Type.Number() }), - }), - execute: async (callId, p) => { - try { - await guardHop(p.url); // 目标 url 先过 checkPrivateIp 面(SSRF 同 fetch_url 规;Jina 中继不当内网探针) - const res = await safeFetch(`https://r.jina.ai/${p.url}`, { - method: "GET", maxBytes: B.fetchBytes, timeoutMs: B.webReadTimeoutMs, - allowStatus: true, // ≥400 状态码走 classifyWebStatus 四码(Jina r 429/403→quota/channel_auth 文案自然衔接) - }); - const text = res.body.toString("utf8"); - if (res.status >= 400) { - const cls = classifyWebStatus(res.status, text, "web_read"); - return this.finish(callId, "web_read", - { ok: false, code: cls.code, reason: cls.reason, url: p.url, trace: { channel: "r.jina.ai", status: res.status } }, - `web_read 失败信封:${cls.code}: ${cls.reason}`); - } - const norm = normalizeJinaRead(text, { maxChars: p.max_chars ?? 4000 }); - const st = { ok: true, url: p.url, ...norm, trace: { channel: "r.jina.ai", status: res.status } }; - return this.finish(callId, "web_read", st, - `读 ${p.url} → ${res.status};title=${norm.title ?? "(无)"};chars=${norm.chars_n}${norm.truncated ? "(截断)" : ""}\n${norm.content}`); - } catch (e) { - // 网络错/超时/SSRF 同 fetch_url 语义(暂态族,提示词可重试 ≤1 次) - const env = envelope(e.code ?? "network", e.message); - return this.finish(callId, "web_read", { ...env, url: p.url, trace: { channel: "r.jina.ai", status: 0 } }, - `web_read 失败信封:${env.code}: ${env.reason}`); - } - }, - }; - } - - webSearch() { - // 引擎 = pi-web-access MCP(2026-10-24 用户令;选型注见纯函数节);provider 路由 - // 走物化 web-search.json 的 searchRouting 链(duckduckgo keyless 起步,jina 配 key - // 后入链)——零配置一发即活,取代 Jina 充值前置。契约面与本工具原规格一致。 - return { - name: "web_search", - label: "通用搜索", - description: "只读。pi-web-access 多 provider 网络搜索(缺省路由 duckduckgo,零配置可用;jina 配 key 后入回退链):SERP 归一 {url,title,snippet}。失败四码信封(channel_missing/quota/channel_auth/transient,部署/账户面人裁区注明);num≤8 缺省 5(本地截断+truncated);每轮≤6 次。", - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, - parameters: Type.Object({ - query: Type.String({ minLength: 1, maxLength: 256 }), - num: Type.Optional(Type.Number({ minimum: 1, maximum: 8 })), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), query: Type.String(), n: Type.Number(), truncated: Type.Boolean(), - provider: Type.Union([Type.String(), Type.Null()]), - results: Type.Array(Type.Object({ url: Type.String(), title: Type.String(), snippet: Type.String() })), - trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), - }), - execute: async (callId, p) => { - const num = p.num ?? 5; - try { - const r = await mcpCallToolOnce("web_search", { query: p.query, numResults: num }, B.webSearchTimeoutMs); - const parsed = parseMcpSearchText(r.text); - if (r.isError || (!parsed.results.length && parsed.error)) { - const cls = classifyMcpSearchError(parsed.error ?? r.text); - return this.finish(callId, "web_search", - { ok: false, code: cls.code, reason: cls.reason, query: p.query, provider: parsed.provider, trace: { cmd: MCP_SEARCH_CMD, rc: cls.rc } }, - `web_search 失败信封:${cls.code}: ${cls.reason}`); - } - const truncated = parsed.results.length > num; - const results = parsed.results.slice(0, num).map((x) => ({ url: x.url, title: x.title, snippet: x.snippet.slice(0, 300) })); - const st = { ok: true, query: p.query, n: results.length, truncated, provider: parsed.provider, results, trace: { cmd: MCP_SEARCH_CMD, rc: 0 } }; - const c = `搜索 "${p.query}" → ${results.length} 条(provider=${parsed.provider ?? "?"}${truncated ? ";服务端多回本地截断" : ""})\n` - + results.map((x) => `- ${x.title || x.url}\n ${x.url}${x.snippet ? `\n ${x.snippet}` : ""}`).join("\n"); - return this.finish(callId, "web_search", st, c); - } catch (e) { - // spawn 面(ENOENT/超时/CLI 启动崩——坏配置 stderr 在消息里) - const cls = classifyMcpSearchError(e); - return this.finish(callId, "web_search", - { ok: false, code: cls.code, reason: cls.reason, query: p.query, provider: null, trace: { cmd: MCP_SEARCH_CMD, rc: cls.rc } }, - `web_search 失败信封:${cls.code}: ${cls.reason}`); - } - }, - }; - } - - rssParse() { - return { - name: "rss_parse", - label: "feed 结构解析", - description: "只读。GET 原始 feed URL(SSRF 逐跳同 fetch_url)→ 本地零依赖最小解析(RSS2.0 <item>/<pubDate> + Atom <entry>/<updated>):条数/最新发布时间/新鲜门≤30 天(可调 stale_days)/前 3 条样本。非 RSS/Atom 形状=io_error 信封;每轮≤6 次。", - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, - parameters: Type.Object({ - url: Type.String({ pattern: "^https?://", maxLength: 500 }), - stale_days: Type.Optional(Type.Number({ minimum: 1, maximum: 365, description: "新鲜门天数,缺省 30(RSS_FRESH_DAYS)" })), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), url: Type.String(), items_n: Type.Number(), - latest_pubdate: Type.Union([Type.String(), Type.Null()]), - fresh: Type.Union([Type.Boolean(), Type.Null()]), - sample: Type.Array(Type.String()), trace: Type.Object({ method: Type.String(), status: Type.Number() }), - }), - execute: async (callId, p) => { - try { - const res = await safeFetch(p.url, { method: "GET", maxBytes: B.fetchBytes, timeoutMs: B.fetchTimeoutMs }); - const parsed = parseRss(res.body.toString("utf8"), { freshDays: p.stale_days ?? RSS_FRESH_DAYS }); - const st = { ok: true, url: p.url, ...parsed, trace: { method: "GET", status: res.status } }; - const c = `feed ${p.url} → ${res.status};items=${parsed.items_n};最新发布 ${parsed.latest_pubdate ?? "(无可解析时间)"};fresh=${String(parsed.fresh)};样本:${parsed.sample.join(" | ")}`; - return this.finish(callId, "rss_parse", st, c); - } catch (e) { - // parseRss 零 item/entry → io_error(feed 结构验证失败);网络/SSRF 同 fetch_url 语义 - const env = envelope(e.code ?? "io_error", e.message); - return this.finish(callId, "rss_parse", { ...env, url: p.url, trace: { method: "GET", status: 0 } }, - `rss_parse 失败信封:${env.code}: ${env.reason}`); - } - }, - }; - } - - exclusiveProbe() { - return { - name: "exclusive_probe", - label: "排他率探针", - description: "候选 feed 实测排他率(唯一硬门数字生产线;阳性对照闸 20 中 ≥18)。仅收 URL(禁文件路径);内部下载(SSRF 逐跳,≤256MB)→ 键量>1M 走体量信封(bitwire OOM 判例,人裁)→ 子进程跑 python -m ipdb._eval --exclusive --json(seed 缺省 20261018)。串行执行;数据面缺失=结构化信封。ENQUEUE 自动入队条件=本工具 exclusive_rate≥0.25 且 control_pass。", - annotations: { readOnlyHint: true, openWorldHint: true }, - parameters: Type.Object({ - url: Type.String({ pattern: "^https?://", maxLength: 500 }), - seed: Type.Optional(Type.Number({ minimum: 0, maximum: 99999999 })), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), url: Type.String(), seed: Type.Number(), - exclusive_rate: Type.Number(), exclusive: Type.Number(), sampled: Type.Number(), - feed_unique_keys: Type.Number(), control_pass: Type.Boolean(), - covering_sources: Type.Array(Type.Object({})), artifact: Type.String(), - trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), - }), - // 串行一层(评审 P1 修正):SDK 每工具执行模式 pi-agent-core 1.3.0 AgentTool. - // executionMode(agent-loop executeToolCalls:批次含 sequential 工具即整批逐个执行, - // 防 SDK 缺省 parallel 下并发 ≤256MB 下载 + _eval CLI/LMDB 读压)。 - executionMode: "sequential", - execute: async (callId, p) => this.probeQueue(this.runExclusiveProbe(callId, p)), - }; - } - - // 串行二层(实例级 promise 链 this.probeChain,非模块级;只序落定,起跑由 execute 立即发生) - // ——并发防护以一层 executionMode 为准,本链兜底 SDK 批层之外的调用路径(LMDB 读压)。 - probeQueue(task) { - const run = this.probeChain ?? Promise.resolve(); - this.probeChain = run.then(() => task, () => task); - return this.probeChain; - } - - async runExclusiveProbe(callId, p) { - const seed = p.seed ?? 20261018; - const tmp = path.join(os.tmpdir(), `ops-probe-${process.pid}-${Date.now()}.txt`); - try { - const dl = await safeFetch(p.url, { method: "GET", maxBytes: B.probeBytes, timeoutMs: B.probeDlTimeoutMs }); - const text = dl.body.toString("utf8"); - const keys = countFeedKeys(text); - if (keys === 0) return this.finish(callId, "exclusive_probe", - envelope("bad_feed", `feed 提取零个 IPv4 键:${p.url}(空分母不冒充 0)`), - `探针失败信封:bad_feed: feed 零键`); - if (keys > B.probeMaxKeys) return this.finish(callId, "exclusive_probe", - envelope("key_cap", `feed 键量 ${keys} > 上限 ${B.probeMaxKeys}(bitwire 4.58M OOM 判例:过线体量人裁)`), - `探针失败信封:key_cap: 键量 ${keys} 超限,升级人裁区`); - fs.writeFileSync(tmp, text); - const cmd = `cd backend && IP_RADAR_DATA_DIR=${DATA_DIR} ${EVAL_PY} -m ipdb._eval --exclusive <tmp> --json --seed ${seed}`; - const { stdout, stderr } = await execFileP(EVAL_PY, - ["-m", "ipdb._eval", "--exclusive", tmp, "--json", "--seed", String(seed)], - { cwd: path.join(ROOT, "backend"), env: { ...process.env, IP_RADAR_DATA_DIR: DATA_DIR }, - timeout: B.probeCliTimeoutMs, maxBuffer: 32 * 1024 * 1024 }); - const res = JSON.parse(stdout); - const controlPass = res.control.hits >= res.control.required; - const art = artifactPath(this.artifactDir, `probe-${new URL(p.url).hostname.replace(/\W+/g, "-")}`, "json"); - fs.writeFileSync(art, JSON.stringify({ trace: { cmd, rc: 0 }, result: res }, null, 1)); - const st = { - ok: true, url: p.url, seed: res.seed, feed_unique_keys: res.feed_unique_keys, - sampled: res.sampled, exclusive: res.exclusive, covered: res.covered, - exclusive_rate: res.exclusive_rate, control: res.control, control_pass: controlPass, - covering_sources: Object.entries(res.coverage_sources).slice(0, 8).map(([source, n]) => ({ source, n })), - artifact: art, trace: { cmd, rc: 0 }, - }; - const c = `排他率实测:${res.exclusive}/${res.sampled} = ${(res.exclusive_rate * 100).toFixed(1)}%(seed=${res.seed},feed uniq=${res.feed_unique_keys});` - + `对照闸 binarydefense ${res.control.hits}/${res.control.n}(需≥${res.control.required},${controlPass ? "过" : "未过"});` - + `覆盖源:${Object.entries(res.coverage_sources).slice(0, 8).map(([s, n]) => `${s}:${n}`).join(",")};工件:${art}`; - return this.finish(callId, "exclusive_probe", st, c); - } catch (e) { - let env; - try { // CLI --json 失败信封(control_failed/bad_feed/internal)解析优先 - const j = JSON.parse(String(e.stdout ?? "")); - env = envelope(j.error?.code ?? "probe_failed", j.error?.message ?? String(e.message)); - } catch { env = envelope(e.code === "ENOENT" ? "data_plane_missing" : "probe_failed", String(e.stderr ?? e.message).slice(0, 300)); } - return this.finish(callId, "exclusive_probe", { ...env, url: p.url, seed }, `探针失败信封:${env.code}: ${env.reason}`); - } finally { - try { fs.rmSync(tmp, { force: true }); } catch { /* 轮末清理兜底 */ } - } - } - - async runMatrixScan(arts) { // 单源薄包装(P1,#163):真路径在 matrixScanCli,legacy 返回形状零改动 - const r = await matrixScanCli(); - if (r.ok) return this.matrixFromResult(r.d, r.cmd, arts, r.rc); - return r.trace ? { ...r.env, trace: r.trace } : r.env; - } - - matrixFromResult(d, cmd, arts, rc) { - const art = artifactPath(this.artifactDir, "matrix-scan", "json"); - fs.writeFileSync(art, matrixArtifactText(d)); - arts?.push(art); - return { - ok: true, exit_code: d.exit_code, counts: d.counts, - layers: Object.fromEntries(Object.entries(d.layers).map(([k, v]) => [k, { status: v.status, notes: v.notes ?? [] }])), - violation_cells: (d.buckets?.violation ?? []).map((c) => ({ cell: c.cell, note: c.note ?? null })), - artifact: art, trace: { cmd, rc }, - }; - } - - matrixScanSummary() { - return { - name: "matrix_scan_summary", - label: "语义矩阵扫描", - description: "零参。语义契约矩阵现状扫描(四层取样):exit_code 0/1/2=干净/pending/violation + 逐格违例与层状态。数据面缺失=结构化信封不裸栈。", - annotations: { readOnlyHint: true, idempotentHint: true }, - parameters: Type.Object({}), - outputSchema: Type.Object({ - ok: Type.Boolean(), exit_code: Type.Number(), violation_n: Type.Number(), - counts: Type.Object({}), layers: Type.Object({}), trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), - }), - execute: async (callId) => { - const r = await this.runMatrixScan(); - if (!r.ok) return this.finish(callId, "matrix_scan_summary", r, `matrix 扫描失败信封:${r.code}: ${r.reason}`); - this.artifacts.push(r.artifact); - const c = `exit_code=${r.exit_code}(0 干净/1 pending/2 violation) violation=${r.counts.violation} pending=${r.counts.pending} agreement=${r.counts.agreement}\n` - + Object.entries(r.layers).map(([k, v]) => `layer ${k}: ${v.status}${v.notes.length ? `(${v.notes.join(",")})` : ""}`).join("\n") - + (r.violation_cells.length ? `\n违例格:${r.violation_cells.map((x) => x.cell).join(",")}` : ""); - return this.finish(callId, "matrix_scan_summary", { ...r, violation_n: r.counts.violation }, c); - }, - }; - } +// 各面工具方法挂 prototype(实现零改动;宿主态 this.finish/registry/artifacts/spent/ +// searchHits/round/queueFile/artifactDir 经 this 跨面共享——面文件不互相导入): +Object.assign(ToolHost.prototype, measurementMethods, discoveryMethods, precedentsMethods, registryMethods); - queueAction() { - return { - name: "queue_action", - label: "动作入队(唯一写)", - description: "唯一写工具:把 ENQUEUE(自动入队,须本会话 exclusive_probe 实测 rate≥0.25 且对照闸过)/REJECT(谓词闭集 sunset_frozen|mirror_echo|zero_asset,写前语义门从 structuredContent 重算谓词)追加到 docs/ops/queue.md。append-only、幂等去重、恒可否决(veto_cmd);rationale 只落工件包不进行列。evidence[]={call_id,field,value} 必须逐字对应本会话测量工具的结构化返回。", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, - parameters: Type.Object({ - source: Type.String({ pattern: "^[a-z0-9_]{1,32}$" }), - action: Type.Union([Type.Literal("ENQUEUE"), Type.Literal("REJECT")]), - predicate: Type.Optional(Type.Union([Type.Literal("sunset_frozen"), Type.Literal("mirror_echo"), Type.Literal("zero_asset")])), - rationale: Type.String({ minLength: 1, maxLength: 2000 }), - evidence: Type.Array(Type.Object({ - call_id: Type.String({ minLength: 1 }), - field: Type.String({ minLength: 1 }), - value: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]), - }), { minItems: 1, maxItems: 16 }), - }), - outputSchema: Type.Object({ - ok: Type.Boolean(), appended: Type.Boolean(), dedup: Type.Boolean(), - action: Type.String(), source: Type.String(), vetoable: Type.Boolean(), veto_cmd: Type.String(), - }), - execute: async (callId, p) => { - // 预算计数走真实路径:queue_action 不经 finish()(非测量工具,不进登记表), - // 若不在此计数 spent 永不增长、10/轮上限永不触发(评审 Important-1,2026-10-19) - this.budgetSpend("queue_action"); - // 幂等去重:同 action+source 已在队列 → 返回既有事实,不双写 - let existing = ""; - try { existing = fs.readFileSync(this.queueFile, "utf8"); } catch { /* 首建 */ } - const dedupRe = new RegExp(`\\| \\d{4}-\\d{2}-\\d{2} \\| [^|]+ \\| ${p.action} \\| ${p.source} \\|`); - const dedup = dedupRe.test(existing); - let lineN = existing ? existing.trimEnd().split("\n").length : 0; - const rel = path.relative(ROOT, this.queueFile); - const vetoCmd = `sed -i '/| ${p.action} | ${p.source} |/d' ${rel}`; - if (!dedup) { - if (!existing) { - fs.mkdirSync(path.dirname(this.queueFile), { recursive: true }); - fs.writeFileSync(this.queueFile, - "# 源生命周期动作队列(queue_action 唯一写口;行由 host 模板渲染,rationale 只落工件)\n\n" - + "| 日期 | 轮次 | action | source | predicate | evidence | vetoable |\n|---|---|---|---|---|---|---|\n"); - lineN = 4; - } - const ev = p.evidence.map((e) => `${e.call_id}:${e.field}=${JSON.stringify(e.value)}`).join(";"); - const row = `| ${new Date().toISOString().slice(0, 10)} | ${this.round} | ${p.action} | ${p.source} | ${p.predicate ?? "—"} | ${ev} | 是(否决=删除本行) |`; - fs.appendFileSync(this.queueFile, `${row}\n`); - lineN += 1; - const rpath = artifactPath(this.artifactDir, "queue-rationale", "md"); - fs.appendFileSync(rpath, `## ${ts()} ${p.action} ${p.source}\n- predicate: ${p.predicate ?? "(无)"}\n- evidence: ${JSON.stringify(p.evidence)}\n- rationale(模型原话,仅人裁阅读,不进队列行):\n\n${p.rationale}\n\n`); - this.artifacts.push(rpath); - } - const st = { - ok: true, appended: !dedup, dedup, action: p.action, source: p.source, - predicate: p.predicate ?? null, vetoable: true, veto_cmd: vetoCmd, queue_file: rel, - }; - this.queueWrites.push(st); - const c = `${p.action} ${p.source}${dedup ? "(已在队列,幂等去重,未双写)" : " 已入队"};恒可否决:veto_cmd = \`${vetoCmd}\`;rationale 已落工件(包脚注)。`; - return { content: [{ type: "text", text: c }], details: {}, structuredContent: st }; - }, - }; - } -} +// 公共 API 面 re-export(拆分前 29 名逐名不变,bde7f061 冻结字面;C6-1 钉死): +export { ROOT, DOCS_ROOT, FROZEN_DAYS, RSS_FRESH_DAYS, checkPrivateIp, pinnedLookup, safeFetch } from "./tools/shared.mjs"; +export { classifyChannelError, classifyWebStatus, classifyMcpSearchError } from "./tools/envelope.mjs"; +export { GH_WRITE_SUBCOMMANDS, checkReadablePath, resolveField, closureCheck, ResultRegistry } from "./tools/registry.mjs"; +export { RG_TIMEOUT_MS, resolvePrecedentsDir, fmField, searchPrecedentsCore } from "./tools/precedents.mjs"; +export { matrixScanCli, matrixArtifactText, anchorSweepCli, ghIssueCreate, countFeedKeys } from "./tools/measurement.mjs"; +export { normalizeJinaRead, parseSerp, parseRss, parseMcpSearchText } from "./tools/discovery.mjs"; diff --git a/ops/agent/tools/discovery.mjs b/ops/agent/tools/discovery.mjs new file mode 100644 index 00000000..bbcfd5a0 --- /dev/null +++ b/ops/agent/tools/discovery.mjs @@ -0,0 +1,339 @@ +// tools/discovery.mjs — 发现通道三件(web_read/web_search/rss_parse,PR3a 薄工具批) +// 的归一/解析纯函数核 + pi-web-access MCP 单发会话。不进 MEASUREMENT_TOOLS:发现线索 +// 非硬门数字,evidence 语义门不因此放宽(DESIGN §6 2026-10-24 裁定)。 +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { Type } from "@earendil-works/pi-ai"; +import { B, guardHop, safeFetch, RSS_FRESH_DAYS } from "./shared.mjs"; +import { envelope, classifyWebStatus, classifyMcpSearchError } from "./envelope.mjs"; + +const nodeRequire = createRequire(import.meta.url); // ESM 下解析包内文件真路径(pi-web-access MCP CLI) + +// ── 发现通道三件纯函数核(PR3a 薄工具批;DESIGN §6 2026-10-24 修订裁定)────────── +// web_read/web_search/rss_parse 的归一/解析核(离线单测面:selftest-t4 钉归一/截断/ +// 畸形行;网络层部署段真网验)。实测事实(2026-10-24,两行为勿写反):r.jina.ai 裸调用 +// (无 Authorization)正常,带欠费 key 反 402 → web_read 恒不带 key。 +// web_search 引擎 = pi-web-access(2026-10-24 用户令「用 pi-web-access 实现网络搜索」, +// https://pi.dev/packages/pi-web-access;pin 0.44.0):多 provider 网络搜索 + 智能回退 +// 链。载体 = 包的 MCP CLI(dist/mcp-cli.js,stdio JSON-RPC)——程序化 dist/index.js +// 只导出 pi extension 面,TS 源在 node_modules 下被 Node 拒绝类型剥离,MCP 是唯一稳定 +// 程序化口;每次调用 spawn 一发会话(无守护进程生命周期;预算 ≤6/轮,启动 ~0.3s 可 +// 接受)。provider 路由不在代码里选:配置单源 web-search.json(PI_CODING_AGENT_DIR +// 指路,auth.json 同型判例——_ops_rounds._round_env 物化进 ops 树)的 searchRouting +// 回退链 duckduckgo(keyless,isDuckDuckGoAvailable()≡true,零配置一发即活,离线 +// 确定性优先)→ jina(可用性 = jinaApiKey/JINA_API_KEY,充值/配置后自然入链);零配置 +// Exa MCP 需运行时拉起外部进程,不入链(确定性优先,2026-10-24 裁定)。 +export function normalizeJinaRead(rawText, { maxChars = 4000 } = {}) { + // Jina r 缺省文本面:元数据前缀行(Title:/URL Source:/Markdown Content:,空行分隔); + // 无前缀(上游形变)则 title=null、整文为正文(宁少算不猜)。 + const lines = String(rawText ?? "").split("\n"); + const skipBlank = (i) => { while (lines[i] !== undefined && lines[i].trim() === "") i++; return i; }; + let title = null, i = 0; + if (lines[0]?.startsWith("Title:")) { + title = lines[0].slice("Title:".length).trim() || null; + i = skipBlank(1); + } + // 前缀族逐行可选剥离(审后修 P2:Published Time 行——真 fixture 首发遗漏,带日期页 + // 面该元数据行会漏入正文;同 URL Source 型,出现才吃) + if (lines[i]?.startsWith("URL Source:")) i = skipBlank(i + 1); + if (lines[i]?.startsWith("Published Time:")) i = skipBlank(i + 1); + if (lines[i]?.startsWith("Markdown Content:")) i += 1; + const body = lines.slice(i).join("\n").trim(); + const content = body.slice(0, maxChars); + return { title, content, chars_n: content.length, truncated: body.length > content.length }; +} + +export function parseSerp(rawJson, { num = 5 } = {}) { + // SERP JSON 归一(通用形状,离线钉解析语义):顶层数组或 {data:[…]} 两形;畸形行 + // (非对象/缺 url)跳过不冒充结果(宁少算);num 本地截断(truncated=服务端多回)。 + // 注:web_search 引擎已换 pi-web-access MCP(PR3a 中程裁定),本画数不再被工具面 + // 调用——保留为 SERP 归一语义钉(零死面:仅 selftest 引)。 + let rows; + try { + rows = JSON.parse(String(rawJson ?? "")); + } catch { + throw Object.assign(new Error("SERP 响应非 JSON(本地解析失败)"), { code: "io_error" }); + } + if (rows && typeof rows === "object" && !Array.isArray(rows) && Array.isArray(rows.data)) rows = rows.data; + if (!Array.isArray(rows)) throw Object.assign(new Error("SERP 形状坏(非数组/data 数组)"), { code: "io_error" }); + const results = []; + for (const r of rows) { + if (r == null || typeof r !== "object" || typeof r.url !== "string" || !r.url) continue; + results.push({ + url: r.url, + title: String(r.title ?? "").replace(/\s+/g, " ").trim(), + snippet: String(r.snippet ?? r.description ?? "").replace(/\s+/g, " ").trim().slice(0, 300), + }); + } + return { results: results.slice(0, num), n: Math.min(results.length, num), truncated: results.length > num }; +} +export function parseRss(rawText, { freshDays = RSS_FRESH_DAYS, now = Date.now() } = {}) { + // 零依赖最小解析(正则/字符串扫描,勿新增 npm 依赖——薄工具裁定):RSS2.0 + // <item><title>/<link>/<pubDate> + Atom <entry><title>/<link href>/<updated>。 + // 零 item/entry = io_error(feed 结构验证失败,不伪装空读数);无可解析时间 → + // latest_pubdate/fresh 双 null(fetch_url age_days NO-LM 同型:缺读数≠过期)。 + const text = String(rawText ?? ""); + const strip = (s) => String(s ?? "").replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); + const pick = (blk, re) => blk.match(re)?.[1] ?? ""; + const items = []; + let m; + const itemRe = /<item\b[^>]*>([\s\S]*?)<\/item\s*>/gi; + while ((m = itemRe.exec(text)) !== null) { + items.push({ + title: strip(pick(m[1], /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)), + link: pick(m[1], /<link\b[^>]*>([\s\S]*?)<\/link\s*>/i).trim(), + pubdate: pick(m[1], /<pubDate\b[^>]*>([\s\S]*?)<\/pubDate\s*>/i).trim(), + }); + } + const entryRe = /<entry\b[^>]*>([\s\S]*?)<\/entry\s*>/gi; + while ((m = entryRe.exec(text)) !== null) { + items.push({ + title: strip(pick(m[1], /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)), + link: (pick(m[1], /<link\b[^>]*\/?>/i).match(/href\s*=\s*"([^"]*)"/i)?.[1] + ?? pick(m[1], /<link\b[^>]*>([\s\S]*?)<\/link\s*>/i)).trim(), + pubdate: pick(m[1], /<updated\b[^>]*>([\s\S]*?)<\/updated\s*>/i).trim(), + }); + } + if (!items.length) throw Object.assign(new Error("非 RSS2.0/Atom 形状:零 <item>/<entry>(feed 结构验证失败)"), { code: "io_error" }); + let latest = null, latestStr = null; + for (const it of items) { + if (!it.pubdate) continue; + const t = Date.parse(it.pubdate); + if (Number.isNaN(t)) continue; + if (latest === null || t > latest) { latest = t; latestStr = it.pubdate; } + } + const ageDays = latest === null ? null : Math.max(0, Math.floor((now - latest) / 86_400_000)); // 时钟偏移铳 0(fetch_url 同口径) + return { + items_n: items.length, + latest_pubdate: latestStr, + fresh: ageDays === null ? null : ageDays <= freshDays, + sample: items.slice(0, 3).map((it) => it.title || "(无题)"), + }; +} + +// HTTP 状态面通道分类(web_read/web_search 共用;四码语义与 classifyChannelError +// 同族:quota(429/402/余额文案)先于 channel_auth(401/403)——402 归 quota 不归 +// channel_auth;文案复用 CHANNEL_HINTS(下一步合法动作,MCP isError 语义);状态面 +// ── web_search 引擎:pi-web-access MCP 单发会话(2026-10-24 用户令;见上节选型注)── +const MCP_SEARCH_CMD = "pi-web-access-mcp web_search"; // trace 展示名(真 CLI = resolveMcpCliPath) + +function resolveMcpCliPath() { + // 包无 exports 字段,子路径走 legacy 解析到真实文件;包 pin 0.44.0(package.json) + return nodeRequire.resolve("pi-web-access/dist/mcp-cli.js"); +} + +// 单发 MCP 会话:initialize → initialized → tools/call → 收应答即退。stderr 首个 +// Error: 行即时捕获(坏配置时 CLI 启动即崩,栈很长,首行是唯一真因面;尾窗只作兑底)。 +async function mcpCallToolOnce(toolName, args, timeoutMs) { + const cli = resolveMcpCliPath(); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli], { stdio: ["pipe", "pipe", "pipe"] }); + let buf = ""; + let stderrTail = ""; + let stderrErrLine = null; + let settled = false; + const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); child.kill(); fn(v); } }; + const timer = setTimeout(() => finish(reject, Object.assign(new Error(`MCP 会话超时(>${timeoutMs}ms)`), { killed: true })), timeoutMs); + const send = (o) => child.stdin.write(JSON.stringify(o) + "\n"); + // stdin EPIPE 守卫(审后修 P2):child 在应答后、下条 send 前死亡时 write 触发 + // EPIPE——未挂监听会让流错误裸崩 agent 进程(违铁律②);此处静默,结局由既有 + // exit/error 事件携带(MCP CLI 提前退出信封),无信息丢失。 + child.stdin.on("error", () => {}); + child.on("error", (e) => finish(reject, e)); // ENOENT(spawn 面)走 classifyMcpSearchError → channel_missing + child.stderr?.on("data", (d) => { + const s = String(d); + stderrTail = `${stderrTail}${s}`.slice(-400); + if (!stderrErrLine) stderrErrLine = /(?:^|\n)(Error:[^\n]+)/.exec(s)?.[1] ?? /(Failed to parse[^\n]+)/.exec(s)?.[1] ?? null; + }); + child.on("exit", (code) => finish(reject, Object.assign( + new Error(`MCP CLI 提前退出(rc=${code ?? "null"})${(stderrErrLine ?? stderrTail) ? `:${String(stderrErrLine ?? stderrTail).replace(/\s+/g, " ").trim().slice(0, 200)}` : ""}`), + { code: typeof code === "number" ? code : undefined }))); + child.stdout.on("data", (d) => { + buf += d; + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id === 1) { + send({ jsonrpc: "2.0", method: "notifications/initialized" }); + send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } }); + } else if (msg.id === 2) { + if (msg.error) finish(resolve, { isError: true, text: `MCP 协议错误:${msg.error.message ?? ""}` }); + else { + const r = msg.result ?? {}; + finish(resolve, { + isError: r.isError === true, + text: (Array.isArray(r.content) ? r.content : []).map((c) => (c && typeof c.text === "string" ? c.text : "")).join("\n"), + }); + } + } + } + }); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ip-radar-ops-agent", version: "1.0" } } }); + }); +} + +// pi-web-access 的 MCP wire 面只吐文本(structuredContent 被剥,formatSearchSummary +// 语法):`**Provider:** <p>` 头 + Sources 块(`N. title\n url`) + 段内机会式 +// `Source: t (u)` 尾行(前文=该源摘要,duckduckgo 形;无此形则 snippet 留空——宁少算)。 +// 错误面:provider 失败以 `Error: …` 行内嵌(isError:false!);真 fixture 钉死语法。 +export function parseMcpSearchText(text) { + const t = String(text ?? ""); + const provider = t.match(/^\*\*Provider:\*\*\s*(.+)$/m)?.[1]?.trim() ?? null; + const srcIdx = t.indexOf("**Sources:**"); + const results = []; + if (srcIdx >= 0) { + const end = t.indexOf("\n---", srcIdx); + const block = t.slice(srcIdx, end >= 0 ? end : undefined); + const re = /^(\d+)\.\s+(.*?)\r?\n\s+(\S+)\s*$/gm; + let m; + while ((m = re.exec(block)) !== null) results.push({ url: m[3].trim(), title: m[2].trim(), snippet: "" }); + } + if (results.length) { + const byUrl = new Map(results.map((r) => [r.url, r])); + for (const para of t.slice(0, srcIdx >= 0 ? srcIdx : t.length).split(/\n{2,}/)) { + const sm = para.match(/^(?:.|\n)*?Source:\s+.*?\((https?:\/\/[^\s)]+)\)\s*$/); + if (!sm) continue; + const r = byUrl.get(sm[1]); + if (r && !r.snippet) r.snippet = para.replace(/Source:\s+.*$/, "").replace(/\s+/g, " ").trim().slice(0, 300); + } + } + const error = t.match(/^Error:\s*(.+)$/m)?.[1]?.trim() ?? null; + // wire 漂移哨(审后修 P2:升级后语法漂移会伪装成「零命中」):0.44.0 成功面必有 + // **Provider:** 头(真网实拍 fixture 钉);三无(provider/Sources/Error)且文本非空 + // = wire 变形 → io_error 信封,宁报通道异常不报假零。**包升级必须复跑真网 fixture** + // (selftest 钉的是 0.44.0 语法)。 + if (!provider && !results.length && !error && t.trim()) { + throw Object.assign(new Error("MCP wire 文本无 Provider/Sources/Error 标记(语法漂移?pi-web-access 版本升级须复验)"), { code: "io_error" }); + } + return { provider, results, error }; +} + +// ── 工具方法(实现零改动,经门面 Object.assign 挂 ToolHost.prototype)──────── +export const discoveryMethods = { + // ── 发现通道三件(PR3a;DESIGN §6 2026-10-24 裁定;不进 MEASUREMENT_TOOLS——发现 + // 线索非硬门数字,evidence 语义门不因此放宽)────────────────────────────── + webRead() { + return { + name: "web_read", + label: "网页正文读", + description: "只读。GET r.jina.ai/<url> 取 markdown 正文(awesome 列表/厂商页取证)。恒无鉴权头(实测 2026-10-24:裸调用可用,带欠费 key 反 402);目标 url 先过 SSRF 私网面,逐跳防护同 fetch_url;正文截断头 max_chars(≤8000 缺省 4000);20s;每轮≤8 次。", + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + parameters: Type.Object({ + url: Type.String({ pattern: "^https?://", maxLength: 500 }), + max_chars: Type.Optional(Type.Number({ minimum: 200, maximum: 8000, description: "正文保留头 N 字,缺省 4000" })), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), url: Type.String(), + title: Type.Union([Type.String(), Type.Null()]), + content: Type.String(), chars_n: Type.Number(), truncated: Type.Boolean(), + trace: Type.Object({ channel: Type.String(), status: Type.Number() }), + }), + execute: async (callId, p) => { + try { + await guardHop(p.url); // 目标 url 先过 checkPrivateIp 面(SSRF 同 fetch_url 规;Jina 中继不当内网探针) + const res = await safeFetch(`https://r.jina.ai/${p.url}`, { + method: "GET", maxBytes: B.fetchBytes, timeoutMs: B.webReadTimeoutMs, + allowStatus: true, // ≥400 状态码走 classifyWebStatus 四码(Jina r 429/403→quota/channel_auth 文案自然衔接) + }); + const text = res.body.toString("utf8"); + if (res.status >= 400) { + const cls = classifyWebStatus(res.status, text, "web_read"); + return this.finish(callId, "web_read", + { ok: false, code: cls.code, reason: cls.reason, url: p.url, trace: { channel: "r.jina.ai", status: res.status } }, + `web_read 失败信封:${cls.code}: ${cls.reason}`); + } + const norm = normalizeJinaRead(text, { maxChars: p.max_chars ?? 4000 }); + const st = { ok: true, url: p.url, ...norm, trace: { channel: "r.jina.ai", status: res.status } }; + return this.finish(callId, "web_read", st, + `读 ${p.url} → ${res.status};title=${norm.title ?? "(无)"};chars=${norm.chars_n}${norm.truncated ? "(截断)" : ""}\n${norm.content}`); + } catch (e) { + // 网络错/超时/SSRF 同 fetch_url 语义(暂态族,提示词可重试 ≤1 次) + const env = envelope(e.code ?? "network", e.message); + return this.finish(callId, "web_read", { ...env, url: p.url, trace: { channel: "r.jina.ai", status: 0 } }, + `web_read 失败信封:${env.code}: ${env.reason}`); + } + }, + }; + }, + webSearch() { + // 引擎 = pi-web-access MCP(2026-10-24 用户令;选型注见纯函数节);provider 路由 + // 走物化 web-search.json 的 searchRouting 链(duckduckgo keyless 起步,jina 配 key + // 后入链)——零配置一发即活,取代 Jina 充值前置。契约面与本工具原规格一致。 + return { + name: "web_search", + label: "通用搜索", + description: "只读。pi-web-access 多 provider 网络搜索(缺省路由 duckduckgo,零配置可用;jina 配 key 后入回退链):SERP 归一 {url,title,snippet}。失败四码信封(channel_missing/quota/channel_auth/transient,部署/账户面人裁区注明);num≤8 缺省 5(本地截断+truncated);每轮≤6 次。", + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + parameters: Type.Object({ + query: Type.String({ minLength: 1, maxLength: 256 }), + num: Type.Optional(Type.Number({ minimum: 1, maximum: 8 })), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), query: Type.String(), n: Type.Number(), truncated: Type.Boolean(), + provider: Type.Union([Type.String(), Type.Null()]), + results: Type.Array(Type.Object({ url: Type.String(), title: Type.String(), snippet: Type.String() })), + trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), + }), + execute: async (callId, p) => { + const num = p.num ?? 5; + try { + const r = await mcpCallToolOnce("web_search", { query: p.query, numResults: num }, B.webSearchTimeoutMs); + const parsed = parseMcpSearchText(r.text); + if (r.isError || (!parsed.results.length && parsed.error)) { + const cls = classifyMcpSearchError(parsed.error ?? r.text); + return this.finish(callId, "web_search", + { ok: false, code: cls.code, reason: cls.reason, query: p.query, provider: parsed.provider, trace: { cmd: MCP_SEARCH_CMD, rc: cls.rc } }, + `web_search 失败信封:${cls.code}: ${cls.reason}`); + } + const truncated = parsed.results.length > num; + const results = parsed.results.slice(0, num).map((x) => ({ url: x.url, title: x.title, snippet: x.snippet.slice(0, 300) })); + const st = { ok: true, query: p.query, n: results.length, truncated, provider: parsed.provider, results, trace: { cmd: MCP_SEARCH_CMD, rc: 0 } }; + const c = `搜索 "${p.query}" → ${results.length} 条(provider=${parsed.provider ?? "?"}${truncated ? ";服务端多回本地截断" : ""})\n` + + results.map((x) => `- ${x.title || x.url}\n ${x.url}${x.snippet ? `\n ${x.snippet}` : ""}`).join("\n"); + return this.finish(callId, "web_search", st, c); + } catch (e) { + // spawn 面(ENOENT/超时/CLI 启动崩——坏配置 stderr 在消息里) + const cls = classifyMcpSearchError(e); + return this.finish(callId, "web_search", + { ok: false, code: cls.code, reason: cls.reason, query: p.query, provider: null, trace: { cmd: MCP_SEARCH_CMD, rc: cls.rc } }, + `web_search 失败信封:${cls.code}: ${cls.reason}`); + } + }, + }; + }, + rssParse() { + return { + name: "rss_parse", + label: "feed 结构解析", + description: "只读。GET 原始 feed URL(SSRF 逐跳同 fetch_url)→ 本地零依赖最小解析(RSS2.0 <item>/<pubDate> + Atom <entry>/<updated>):条数/最新发布时间/新鲜门≤30 天(可调 stale_days)/前 3 条样本。非 RSS/Atom 形状=io_error 信封;每轮≤6 次。", + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + parameters: Type.Object({ + url: Type.String({ pattern: "^https?://", maxLength: 500 }), + stale_days: Type.Optional(Type.Number({ minimum: 1, maximum: 365, description: "新鲜门天数,缺省 30(RSS_FRESH_DAYS)" })), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), url: Type.String(), items_n: Type.Number(), + latest_pubdate: Type.Union([Type.String(), Type.Null()]), + fresh: Type.Union([Type.Boolean(), Type.Null()]), + sample: Type.Array(Type.String()), trace: Type.Object({ method: Type.String(), status: Type.Number() }), + }), + execute: async (callId, p) => { + try { + const res = await safeFetch(p.url, { method: "GET", maxBytes: B.fetchBytes, timeoutMs: B.fetchTimeoutMs }); + const parsed = parseRss(res.body.toString("utf8"), { freshDays: p.stale_days ?? RSS_FRESH_DAYS }); + const st = { ok: true, url: p.url, ...parsed, trace: { method: "GET", status: res.status } }; + const c = `feed ${p.url} → ${res.status};items=${parsed.items_n};最新发布 ${parsed.latest_pubdate ?? "(无可解析时间)"};fresh=${String(parsed.fresh)};样本:${parsed.sample.join(" | ")}`; + return this.finish(callId, "rss_parse", st, c); + } catch (e) { + // parseRss 零 item/entry → io_error(feed 结构验证失败);网络/SSRF 同 fetch_url 语义 + const env = envelope(e.code ?? "io_error", e.message); + return this.finish(callId, "rss_parse", { ...env, url: p.url, trace: { method: "GET", status: 0 } }, + `rss_parse 失败信封:${env.code}: ${env.reason}`); + } + }, + }; + }, +}; diff --git a/ops/agent/tools/envelope.mjs b/ops/agent/tools/envelope.mjs new file mode 100644 index 00000000..7a7e0eb5 --- /dev/null +++ b/ops/agent/tools/envelope.mjs @@ -0,0 +1,81 @@ +// tools/envelope.mjs — 信封纪律(铁律②:一切失败=结构化信封 {ok:false,code,reason}, +// 不抛裸栈不静默;信封禁止入 evidence,降级人裁区)。三分类器单源:spawn 面 +// (classifyChannelError)/HTTP 状态面(classifyWebStatus)/MCP 面(classifyMcpSearchError), +// 四码语义同族,文案共读本文件 CHANNEL_HINTS(每码带「下一步合法动作」)。 +export const envelope = (code, reason) => ({ ok: false, code, reason }); +// ── 通道错误分类(信封四码;PR #177 工具通道修复;判例 docs/ops/precedents/2026-10-24-discover-dual-channel-dead)── +// 2026-10-24 01:59 生产 discover 轮双通道全灭的事故面修复:旧路径 gh_search 失败一律 gh_error +// 兑底(容器无 gh 也不可辨),且 trace.rc 在 ENOENT 时塞字符串违反 outputSchema Number 槽。 +// code additive 四码 + 原兑底(gh_error/rg_infra)兼容保留;判定优先级(2026-10-24 裁定; +// 审后修 P2-3 调序并注明 why):channel_missing(spawn ENOENT/rc=127)> quota(stderr 含 +// rate limit;GitHub 次级限流形态=403+rate limit 文案,归此才给对「窗口恢复」指引)> +// channel_auth(not logged in/Authentication required/gh auth login 免凭据实测报文/ +// GH_TOKEN/401/403,词边界)> transient(超时/网络)> 原兑底。 +// 每码文案带「下一步合法动作」(对齐 MCP isError 语义 2026-07-28:错误要能引导模型自纠或 +// 升级,出处注明);不可重试三码(channel_missing/channel_auth/quota)由 prompts/discover-system.md +// 信封处置条款消费(基础设施面直接人裁区)。GLOSSARY「通道信封四码」同批录入。 +// trace.rc 恒 Number:ENOENT→127;超时被 SIGTERM 杀(execFile code=null)→124(GNU +// timeout(1) 惯例);其余取 execFile 退出码,兑底 1。 +const CHANNEL_HINTS = { + channel_missing: "下一步:该通道本轮不可用——候选直接移人裁区并注明信封内容;修复属部署动作(镜像装配该二进制),勿轮内重试", + channel_auth: "下一步:凭证属部署面(GH_TOKEN 配置见 deploy/ops-cron.md)——候选移人裁区注明,勿轮内重试、勿改写查询绕行", + quota: "下一步:账户/配额面——充值或窗口恢复后自然可用;候选移人裁区注明,勿轮内重试", // 措辞兼余额形态(s.jina.ai 402;PR3a),窗口性限流同码 + transient: "下一步:可同参数重试 ≤1 次;再失败即人裁区注明信封内容,勿猜数字", + gh_error: "下一步:候选移人裁区注明信封原文;勿替失败补数(无证据≠清白)", + rg_infra: "下一步:基础设施故障非零命中(Minor-3 判例)——人裁区注明;勿当作「无判例」继续推理", +}; +export function classifyChannelError(e, bin) { + const stderr = String(e?.stderr ?? ""); + const msg = String(e?.message ?? e ?? ""); + const exitCode = typeof e?.code === "number" ? e.code : null; + // 审后修 P2-2/P2-3(2026-10-24,PR #177):timedOut 判据只认 execFile 信号面(killed/ + // SIGTERM/ETIMEDOUT 错误码)——旧 /timed out/i 扫 message 首行含命令行,查询词恰含该 + // 短语会误归 transient;quota 提到 channel_auth 之前(次级限流 403+rate limit 归窗口恢复 + // 指引);channel_auth 补真免凭据报文模式(gh auth login/GH_TOKEN 提示,2026-10-24 + // GH_CONFIG_DIR 隔离实测)并加词边界。 + const timedOut = e?.killed === true || e?.signal === "SIGTERM" || e?.code === "ETIMEDOUT"; + const rc = e?.code === "ENOENT" ? 127 : exitCode ?? (timedOut ? 124 : 1); + let code; + if (e?.code === "ENOENT" || exitCode === 127) code = "channel_missing"; + else if (/rate limit|balance|insufficient|\b402\b/i.test(stderr)) code = "quota"; // 402/balance/insufficient=s.jina.ai 余额形态(PR3a):402 显式归 quota 不归 channel_auth——quota 分支先判 + else if (/not logged in|Authentication required|gh auth login|\bGH_TOKEN\b|\b40[13]\b/.test(stderr)) code = "channel_auth"; + else if (timedOut) code = "transient"; + else code = bin === "gh" ? "gh_error" : "rg_infra"; + const detail = `${bin} 通道失败(rc=${rc}):${(stderr || msg).slice(0, 200)}`; + return { code, rc, reason: `${detail}——${CHANNEL_HINTS[code]}` }; +} +// 同族:quota(429/402/余额文案)先于 channel_auth(401/403)——402 归 quota 不归 +// channel_auth;文案复用 CHANNEL_HINTS(下一步合法动作,MCP isError 语义);状态面 +// 无 spawn,rc 恒 HTTP status;http_error 无 hint(提示词暂态条款辖,同 fetch_url)。 +export function classifyWebStatus(status, bodyText, channel) { + const body = String(bodyText ?? "").replace(/\s+/g, " ").trim().slice(0, 160); + let code; + if (status === 429 || status === 402 || /balance|insufficient/i.test(body)) code = "quota"; + else if (status === 401 || status === 403) code = "channel_auth"; + else code = "http_error"; + const detail = `${channel} HTTP ${status}${body ? `:${body}` : ""}`; + const hint = CHANNEL_HINTS[code]; + return { code, rc: status, reason: hint ? `${detail}——${hint}` : detail }; +} +// MCP 面失败分类(四码同族;SearchProviderError.kind 词汇入判定): +// channel_missing(spawn ENOENT/rc127、config 形坏=部署面)> quota(402/429/balance/ +// insufficient)> channel_auth(credential/auth/401/403)> transient(超时/network/aborted) +// > io_error 兑底(解析/协议面,文案带下一步:人裁区注明勿补数)。 +const MCP_SEARCH_KINDS = new Set(["transient", "quota", "network", "credential", "config", "auth", "invalid-request", "invalid-response", "unsupported", "aborted", "unknown"]); +export function classifyMcpSearchError(failure, { rc } = {}) { + const msg = String(failure?.message ?? failure ?? ""); + let code; let rcr = typeof rc === "number" ? rc : 1; + if (failure?.code === "ENOENT" || rcr === 127) { code = "channel_missing"; rcr = 127; } + else if (failure?.killed === true || failure?.signal === "SIGTERM" || failure?.code === "ETIMEDOUT" || /timeout|超时|aborted/i.test(msg)) { code = "transient"; rcr = 124; } + else { + const kinds = [...msg.matchAll(/(?:failed \(|\[)(\w+)(?:\)|\])/g)].map((m) => m[1]).filter((k) => MCP_SEARCH_KINDS.has(k)); + if (kinds.includes("quota") || /\b40[29]\b|balance|insufficient/i.test(msg)) code = "quota"; + else if (kinds.includes("credential") || kinds.includes("auth") || /credential|not logged in|\b40[13]\b/i.test(msg)) code = "channel_auth"; + else if (kinds.includes("network")) code = "transient"; + else if (kinds.includes("config") || /Failed to parse .*web-search\.json|Invalid config/i.test(msg)) code = "channel_missing"; + else code = "io_error"; + } + const detail = `web_search 通道失败(pi-web-access MCP;rc=${rcr}):${msg.replace(/\s+/g, " ").slice(0, 200)}`; + const hint = CHANNEL_HINTS[code] ?? "下一步:候选移人裁区注明信封原文;勿替失败补数(无证据≠清白)"; + return { code, rc: rcr, reason: `${detail}——${hint}` }; +} diff --git a/ops/agent/tools/measurement.mjs b/ops/agent/tools/measurement.mjs new file mode 100644 index 00000000..64f14890 --- /dev/null +++ b/ops/agent/tools/measurement.mjs @@ -0,0 +1,369 @@ +// tools/measurement.mjs — 测量类工具(硬门数字唯一生产线;铁律③:测量工具恒带 +// trace{cmd,rc,artifact})。health_snapshot/fetch_url/gh_search/exclusive_probe/ +// matrix_scan_summary = MEASUREMENT_TOOLS 闭集(evidence 语义门只认这五个的结构化 +// 返回;闭集常量在 registry.mjs 单源)。CLI 单源(matrixScanCli/anchorSweepCli)与 +// 工件序列化(matrixArtifactText)供管线面共用(#163/#182 位等价)。 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Type } from "@earendil-works/pi-ai"; +import { B, DATA_DIR, EVAL_PY, ROOT, FROZEN_DAYS, safeFetch, runLib, artifactPath, execFileP } from "./shared.mjs"; +import { envelope, classifyChannelError } from "./envelope.mjs"; + +// ── matrix 扫描 CLI 单源(P1,#163)─────────────────────────────────────────── +// tools.mjs health_snapshot(matrix 腿)与 pipeline/steps.mjs PROBE 共用同一调用路径: +// 位等价=同 EVAL_PY 命令+同解析+同工件序列化(matrixArtifactText);legacy 侧零行为改动 +// (runMatrixScan 只剩三分支收编的薄包装)。返回分支: +// {ok:true, d, rc, cmd} 读数(rc 0/1/2 都是读数,round.sh T3 判例) +// {ok:false, env, cmd, trace:null} stdout 带 error 键的失败信封(无 trace,同原语义) +// {ok:false, env, cmd, trace:{cmd,rc}} 裸失败信封(带 trace) +export async function matrixScanCli() { + const cmd = `cd backend && IP_RADAR_DATA_DIR=${DATA_DIR} ${EVAL_PY} -m ipdb._eval --matrix-scan --json --assets-dir ${DATA_DIR}/eval/assets`; + try { + const { stdout } = await execFileP(EVAL_PY, + ["-m", "ipdb._eval", "--matrix-scan", "--json", "--assets-dir", `${DATA_DIR}/eval/assets`], + { cwd: path.join(ROOT, "backend"), env: { ...process.env, IP_RADAR_DATA_DIR: DATA_DIR }, + timeout: B.matrixTimeoutMs, maxBuffer: 32 * 1024 * 1024 }); + const d = JSON.parse(stdout); + if (d.error) return { ok: false, env: envelope(d.error.code ?? "matrix_failed", d.error.message ?? ""), cmd, trace: null }; + return { ok: true, d, rc: 0, cmd }; + } catch (e) { + // rc 0/1/2 = 读数(干净/pending/violation,round.sh T3 判例);execFile 对非零退出 reject, + // 但 stdout 携带带 exit_code 的有效 JSON 时仍是读数不是故障 + try { + const d = JSON.parse(String(e.stdout ?? "")); + if (typeof d.exit_code === "number") return { ok: true, d, rc: e.code ?? 0, cmd }; + if (d.error) return { ok: false, env: envelope(d.error.code ?? "matrix_failed", d.error.message ?? ""), cmd, trace: null }; + } catch { /* 裸失败走信封 */ } + return { ok: false, env: envelope("matrix_failed", String(e.stderr ?? e.message ?? "").slice(0, 300)), cmd, trace: { cmd, rc: e.code ?? 1 } }; + } +} +export const matrixArtifactText = (d) => JSON.stringify(d, null, 1); // matrix 工件序列化单源:legacy <ts>-matrix-scan.json 与管线 01-probe-matrix.json 逐字节同文(#163 位等价) +// ── ops 轮锚巡检 CLI 单源(票 #182;matrixScanCli 同型先例)────────────── +// 面向 python -m ipdb._eval --anchor-sweep --json(求值核=ipdb/_eval/anchors.py +// anchor_sweep;OPS_EVAL_PY/OPS_DATA_DIR 桥=backend _round_env 注入,容器内 +// 解释器与数据面=后端查询进程同一套——读数即生产读数,零认证零网络)。 +// 返回分支同 matrixScanCli 三形: +// {ok:true, d, rc:0, cmd} 读数(锚 finding 在场仍 rc 0=读数非故障, +// 不持门语义在 CLI 面) +// {ok:false, env, cmd, trace:null} stdout 带 error 键的失败信封(load_db 等) +// {ok:false, env, cmd, trace:{cmd,rc}} 裸失败(通道四码兼容:ENOENT/超时/限流/ +// 鉴权族过 classifyChannelError 四码; +// gh_error/rg_infra 兑底码是 gh/rg 专属 +// 不复用于 python 面,兑底归 infra) +export async function anchorSweepCli({ evalPy = EVAL_PY, dataDir = DATA_DIR, timeoutMs = B.anchorSweepTimeoutMs } = {}) { + const cmd = `cd backend && IP_RADAR_DATA_DIR=${dataDir} ${evalPy} -m ipdb._eval --anchor-sweep --json`; + try { + const { stdout } = await execFileP(evalPy, + ["-m", "ipdb._eval", "--anchor-sweep", "--json"], + { cwd: path.join(ROOT, "backend"), env: { ...process.env, IP_RADAR_DATA_DIR: dataDir }, + timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 }); + const d = JSON.parse(stdout); + if (d.error) return { ok: false, env: envelope(d.error.code ?? "anchor_sweep_failed", d.error.message ?? ""), cmd, trace: null }; + return { ok: true, d, rc: 0, cmd }; + } catch (e) { + try { + const d = JSON.parse(String(e.stdout ?? "")); + if (d.error) return { ok: false, env: envelope(d.error.code ?? "anchor_sweep_failed", d.error.message ?? ""), cmd, trace: null }; + } catch { /* 裸失败走信封 */ } + const c = classifyChannelError(e, "python"); + if (["channel_missing", "channel_auth", "quota", "transient"].includes(c.code)) + return { ok: false, env: envelope(c.code, c.reason), cmd, trace: { cmd, rc: c.rc } }; + return { ok: false, env: envelope("infra", String(e.stderr ?? e.message ?? "").slice(0, 300)), cmd, trace: { cmd, rc: c.rc } }; + } +} +// ghIssueCreate——开 gh issue(出口只写不动作两写口之一;失败永不抛,分类信封同 gh_search 四码)。 +// dryRun=只记将发命令零 spawn(OPS_SWEEP_DRY_RUN 面的执行侧;测试恒 dry-run)。 +// 返回:{ok,dry_run,cmd,issue_url(真发成功=gh stdout 末行 URL;否则 null),error?:{code,reason}}。 +export async function ghIssueCreate({ title, body, labels = ["needs-triage"] } = {}, { dryRun = false, exec = execFileP, timeoutMs = B.ghTimeoutMs } = {}) { + const argv = ["issue", "create", "--title", String(title ?? ""), "--body", String(body ?? ""), + ...labels.flatMap((l) => ["--label", String(l)])]; + const cmd = `gh ${argv.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}`; + if (dryRun) return { ok: true, dry_run: true, cmd, issue_url: null }; + try { + const { stdout } = await exec("gh", argv, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }); + return { ok: true, dry_run: false, cmd, issue_url: String(stdout ?? "").trim().split("\n").pop() || null }; + } catch (e) { + const cls = classifyChannelError(e, "gh"); + return { ok: false, dry_run: false, cmd, issue_url: null, error: { code: cls.code, reason: cls.reason.slice(0, 300) } }; + } +} +export function countFeedKeys(text) { // probe 键量:IPv4 去重(exclusive.py _IP_RE 同口径的 v4 面) + return new Set(text.match(/(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?/g)?.map((t) => t.split("/")[0]) ?? []).size; +} + +// ── 工具方法(实现零改动,经门面 Object.assign 挂 ToolHost.prototype;方法网 +// healthSnapshot→runMatrixScan/matrixFromResult、exclusiveProbe→probeQueue→ +// runExclusiveProbe 同文件闭合;宿主态经 this 共享)───────────────────────── +export const measurementMethods = { + // ── 健康轮唯一工具:零参快照(旗标+计数进上下文,全量落工件;flash 无 search/read)── + healthSnapshot() { + return { + name: "health_snapshot", + label: "健康快照", + description: "零参。本地实测:全部源 feed 冻结检测(HEAD/Last-Modified)+ 语义矩阵违例扫描。返回旗标与计数(全量原始读数落工件文件);matrix 失败以嵌套信封呈现,不算轮次失败。", + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + parameters: Type.Object({}), + outputSchema: Type.Object({ + ok: Type.Boolean(), urls_n: Type.Number(), frozen_n: Type.Number(), near_band_n: Type.Number(), + unreachable_n: Type.Number(), no_lm_n: Type.Number(), token_required_n: Type.Number(), + matrix_ok: Type.Boolean(), matrix_exit_code: Type.Number(), + artifacts: Type.Array(Type.String()), + }), + execute: async (callId) => { + const arts = []; + try { + const { stdout: urlOut } = await runLib("extract_feed_urls", [], 120_000); + const urls = urlOut.split("\n").map((s) => s.trim()).filter(Boolean); + if (!urls.length) throw Object.assign(new Error("extract_feed_urls 零 URL"), { code: "extract_failed" }); + const { stdout: tsv } = await runLib("feed_freshness", urls, 900_000); + // TSV 四类读数(P1 T2 修约,spec #152 §2a:401/403 → TOKEN_REQUIRED + // 需令牌·正常——不算不可达不进 unreachable,与 NO-LM 同待遇只计数; + // 契约修约判例 2026-10-20:消费方未现=修约非违约) + const frozen = [], near = [], unreachable = [], noLm = [], tokenReq = []; + for (const line of tsv.trimEnd().split("\n")) { + const [url, lm, age] = line.split("\t"); + if (age === "UNREACHABLE") unreachable.push(url); + else if (age === "TOKEN_REQUIRED") tokenReq.push(url); + else if (age === "NO-LM") noLm.push(url); + else { + const a = +age; + if (a >= FROZEN_DAYS) frozen.push({ url, last_modified: lm, age_days: a }); + else if (a >= 90) near.push({ url, age_days: a }); + } + } + const tsvArt = artifactPath(this.artifactDir, "health-freshness", "tsv"); + fs.writeFileSync(tsvArt, tsv); + arts.push(tsvArt); + const matrix = await this.runMatrixScan(arts); + const st = { + ok: true, urls_n: urls.length, frozen, frozen_n: frozen.length, near_band: near, near_band_n: near.length, + unreachable, unreachable_n: unreachable.length, no_lm_n: noLm.length, + token_required: tokenReq, token_required_n: tokenReq.length, + matrix_ok: matrix.ok, matrix_exit_code: matrix.exit_code ?? -1, + matrix: matrix.ok ? matrix : { envelope: matrix }, artifacts: arts, + }; + const c = [`源 URL ${urls.length};冻结≥${FROZEN_DAYS}天 ${frozen.length};近带(90-179) ${near.length};不可达 ${unreachable.length};NO-LM ${noLm.length};需令牌(401/403) ${tokenReq.length}`, + ...frozen.map((f) => `- 死键嫌疑·建议复核: ${f.url}(冻结 ${f.age_days} 天;Last-Modified: ${f.last_modified})`), + ...unreachable.map((u) => `- 不可达·建议复核: ${u}(不可达≠死键)`), + matrix.ok + ? [`matrix 读数: exit_code=${matrix.exit_code}(0 干净/1 pending/2 violation) violation=${matrix.counts.violation} pending=${matrix.counts.pending} agreement=${matrix.counts.agreement}`, + ...Object.entries(matrix.layers).map(([k, v]) => `layer ${k}: ${v.status}${v.notes.length ? `(${v.notes.join(",")})` : ""}`), + ...matrix.violation_cells.slice(0, 12).map((x) => `violation 格: ${x.cell}${x.note ? `(note: ${x.note})` : ""}`)] + : [`matrix 测量信封: ${matrix.code}: ${matrix.reason}`].join("\n"), + `工件: ${arts.join(", ")}`].flat().join("\n"); + return this.finish(callId, "health_snapshot", st, c); + } catch (e) { + const env = envelope(e.code ?? "snapshot_failed", e.message); + return this.finish(callId, "health_snapshot", { ...env, artifacts: arts }, `健康快照失败信封:${env.code}: ${env.reason}`); + } + }, + }; + }, + fetchUrl() { + return { + name: "fetch_url", + label: "URL 探测", + description: "只读 GET/HEAD。逐跳 SSRF 防护(私网/环回拒);512KB/15s/每轮≤20 次。返回 status/last_modified/age_days(GET 附样本≤5 行)。age_days 是 sunset 谓词的唯一数据源;HTTP≥400/超时=失败信封(不可达≠死键)。", + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + parameters: Type.Object({ + url: Type.String({ pattern: "^https?://", maxLength: 500 }), + method: Type.Optional(Type.Union([Type.Literal("GET"), Type.Literal("HEAD")], { description: "默认 HEAD;GET 取样本" })), + max_bytes: Type.Optional(Type.Number({ minimum: 256, maximum: 524288 })), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), url: Type.String(), final_url: Type.String(), status: Type.Number(), + last_modified: Type.Union([Type.String(), Type.Null()]), age_days: Type.Union([Type.Number(), Type.Null()]), + bytes: Type.Number(), sample: Type.Array(Type.String()), + }), + execute: async (callId, p) => { + const method = p.method ?? "HEAD"; + try { + const res = await safeFetch(p.url, { method, maxBytes: p.max_bytes ?? B.fetchBytes, timeoutMs: B.fetchTimeoutMs }); + const lm = res.headers["last-modified"] ?? null; + const age = lm ? Math.max(0, Math.floor((Date.now() - Date.parse(lm)) / 86_400_000)) : null; + const sample = method === "GET" && res.body + ? res.body.toString("utf8").split("\n").map((l) => l.trim()).filter(Boolean).slice(0, 5).map((l) => l.slice(0, 200)) : []; + const st = { + ok: true, url: p.url, final_url: res.finalUrl, status: res.status, last_modified: lm, + age_days: age, bytes: res.bytes, sample, trace: { method, status: res.status }, + }; + const c = `${method} ${p.url} → ${res.status};Last-Modified: ${lm ?? "(无)"};age_days=${age ?? "null"};bytes=${res.bytes}` + + (sample.length ? `\n样本:\n${sample.join("\n")}` : ""); + return this.finish(callId, "fetch_url", st, c); + } catch (e) { + const env = envelope(e.code ?? "network", e.message); + return this.finish(callId, "fetch_url", { ...env, url: p.url, method }, `URL 探测失败信封:${env.code}: ${env.reason}`); + } + }, + }; + }, + ghSearch() { + const FIELDS = { + repos: ["fullName", "description", "pushedAt", "updatedAt", "stargazersCount", "language", "isArchived", "size", "url"], + code: ["path", "repository"], + commits: ["commit", "repository"], + }; + const project = (kind, rows) => rows.map((r) => kind === "repos" ? ({ + full_name: r.fullName, description: String(r.description ?? "").slice(0, 120), + pushed_at: r.pushedAt, updated_at: r.updatedAt, stars: r.stargazersCount, + language: r.language, archived: r.isArchived, size_kb: r.size, url: r.url, + }) : kind === "code" ? ({ repo: r.repository?.nameWithOwner, path: r.path }) + : ({ repo: r.repository?.nameWithOwner, sha: String(r.sha ?? "").slice(0, 12), date: r.commit?.committer?.date ?? r.commit?.author?.date ?? null, message: String(r.commit?.message ?? "").split("\n")[0].slice(0, 120) })); + return { + name: "gh_search", + label: "GitHub 检索", + description: "只读 gh search(子命令白名单仅 search;禁裸 stdout,固定字段投影)。kind=repos(候选仓元数据/size_kb)/code(仓内代码命中;零命中=零资产信号)/commits(出版方活性;注意 commits 检索必须带文本词,纯 qualifier 会报错)。每轮≤15 次。", + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, + parameters: Type.Object({ + query: Type.String({ minLength: 1, maxLength: 256 }), + kind: Type.Union([Type.Literal("repos"), Type.Literal("code"), Type.Literal("commits")]), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 30 })), + // page 已移除(通道复验轮修复 2026-10-24):gh 2.103.x search 无 --page 旗(repos/code + // 实测均拒 unknown flag)——旧 argv 恒拼 --page 故 gh_search 100% 必死,仅因前置通道先死从未暴露。 + // 分页需求由 limit ≤30(预算面)承担。 + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), kind: Type.String(), query: Type.String(), total: Type.Number(), + results: Type.Array(Type.Object({})), trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), + }), + execute: async (callId, p) => { + const argv = ["search", p.kind, p.query, "--json", FIELDS[p.kind].join(","), + "--limit", String(p.limit ?? 10)]; + const cmd = `gh ${argv.map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ")}`; + try { + const { stdout } = await execFileP("gh", argv, { timeout: B.ghTimeoutMs, maxBuffer: 8 * 1024 * 1024 }); + const rows = JSON.parse(stdout); + const results = project(p.kind, Array.isArray(rows) ? rows : []); + const st = { ok: true, kind: p.kind, query: p.query, total: results.length, results, trace: { cmd, rc: 0 } }; + const c = `${p.kind} "${p.query}" → ${results.length} 条(字段投影)\n` + + results.slice(0, 30).map((r) => `- ${JSON.stringify(r)}`).join("\n"); + return this.finish(callId, "gh_search", st, c); + } catch (e) { + // PR #177 分类落地(判例 2026-10-24-discover-dual-channel-dead):容器无 gh/ + // 凭证缺失/限流/暂态分四码信封,每码带下一步合法动作;trace.rc 恒 Number + // (旧 e.code ?? 1 在 ENOENT 时塞字符串违反 outputSchema Number 槽);原 gh_error + // 兑底兼容保留;成功路径零改动。 + const cls = classifyChannelError(e, "gh"); + const env = envelope(cls.code, cls.reason); + return this.finish(callId, "gh_search", { ...env, kind: p.kind, query: p.query, trace: { cmd, rc: cls.rc } }, + `gh 检索失败信封:${env.code}:${env.reason}`); + } + }, + }; + }, + exclusiveProbe() { + return { + name: "exclusive_probe", + label: "排他率探针", + description: "候选 feed 实测排他率(唯一硬门数字生产线;阳性对照闸 20 中 ≥18)。仅收 URL(禁文件路径);内部下载(SSRF 逐跳,≤256MB)→ 键量>1M 走体量信封(bitwire OOM 判例,人裁)→ 子进程跑 python -m ipdb._eval --exclusive --json(seed 缺省 20261018)。串行执行;数据面缺失=结构化信封。ENQUEUE 自动入队条件=本工具 exclusive_rate≥0.25 且 control_pass。", + annotations: { readOnlyHint: true, openWorldHint: true }, + parameters: Type.Object({ + url: Type.String({ pattern: "^https?://", maxLength: 500 }), + seed: Type.Optional(Type.Number({ minimum: 0, maximum: 99999999 })), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), url: Type.String(), seed: Type.Number(), + exclusive_rate: Type.Number(), exclusive: Type.Number(), sampled: Type.Number(), + feed_unique_keys: Type.Number(), control_pass: Type.Boolean(), + covering_sources: Type.Array(Type.Object({})), artifact: Type.String(), + trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), + }), + // 串行一层(评审 P1 修正):SDK 每工具执行模式 pi-agent-core 1.3.0 AgentTool. + // executionMode(agent-loop executeToolCalls:批次含 sequential 工具即整批逐个执行, + // 防 SDK 缺省 parallel 下并发 ≤256MB 下载 + _eval CLI/LMDB 读压)。 + executionMode: "sequential", + execute: async (callId, p) => this.probeQueue(this.runExclusiveProbe(callId, p)), + }; + }, + // 串行二层(实例级 promise 链 this.probeChain,非模块级;只序落定,起跑由 execute 立即发生) + // ——并发防护以一层 executionMode 为准,本链兜底 SDK 批层之外的调用路径(LMDB 读压)。 + probeQueue(task) { + const run = this.probeChain ?? Promise.resolve(); + this.probeChain = run.then(() => task, () => task); + return this.probeChain; + }, + async runExclusiveProbe(callId, p) { + const seed = p.seed ?? 20261018; + const tmp = path.join(os.tmpdir(), `ops-probe-${process.pid}-${Date.now()}.txt`); + try { + const dl = await safeFetch(p.url, { method: "GET", maxBytes: B.probeBytes, timeoutMs: B.probeDlTimeoutMs }); + const text = dl.body.toString("utf8"); + const keys = countFeedKeys(text); + if (keys === 0) return this.finish(callId, "exclusive_probe", + envelope("bad_feed", `feed 提取零个 IPv4 键:${p.url}(空分母不冒充 0)`), + `探针失败信封:bad_feed: feed 零键`); + if (keys > B.probeMaxKeys) return this.finish(callId, "exclusive_probe", + envelope("key_cap", `feed 键量 ${keys} > 上限 ${B.probeMaxKeys}(bitwire 4.58M OOM 判例:过线体量人裁)`), + `探针失败信封:key_cap: 键量 ${keys} 超限,升级人裁区`); + fs.writeFileSync(tmp, text); + const cmd = `cd backend && IP_RADAR_DATA_DIR=${DATA_DIR} ${EVAL_PY} -m ipdb._eval --exclusive <tmp> --json --seed ${seed}`; + const { stdout, stderr } = await execFileP(EVAL_PY, + ["-m", "ipdb._eval", "--exclusive", tmp, "--json", "--seed", String(seed)], + { cwd: path.join(ROOT, "backend"), env: { ...process.env, IP_RADAR_DATA_DIR: DATA_DIR }, + timeout: B.probeCliTimeoutMs, maxBuffer: 32 * 1024 * 1024 }); + const res = JSON.parse(stdout); + const controlPass = res.control.hits >= res.control.required; + const art = artifactPath(this.artifactDir, `probe-${new URL(p.url).hostname.replace(/\W+/g, "-")}`, "json"); + fs.writeFileSync(art, JSON.stringify({ trace: { cmd, rc: 0 }, result: res }, null, 1)); + const st = { + ok: true, url: p.url, seed: res.seed, feed_unique_keys: res.feed_unique_keys, + sampled: res.sampled, exclusive: res.exclusive, covered: res.covered, + exclusive_rate: res.exclusive_rate, control: res.control, control_pass: controlPass, + covering_sources: Object.entries(res.coverage_sources).slice(0, 8).map(([source, n]) => ({ source, n })), + artifact: art, trace: { cmd, rc: 0 }, + }; + const c = `排他率实测:${res.exclusive}/${res.sampled} = ${(res.exclusive_rate * 100).toFixed(1)}%(seed=${res.seed},feed uniq=${res.feed_unique_keys});` + + `对照闸 binarydefense ${res.control.hits}/${res.control.n}(需≥${res.control.required},${controlPass ? "过" : "未过"});` + + `覆盖源:${Object.entries(res.coverage_sources).slice(0, 8).map(([s, n]) => `${s}:${n}`).join(",")};工件:${art}`; + return this.finish(callId, "exclusive_probe", st, c); + } catch (e) { + let env; + try { // CLI --json 失败信封(control_failed/bad_feed/internal)解析优先 + const j = JSON.parse(String(e.stdout ?? "")); + env = envelope(j.error?.code ?? "probe_failed", j.error?.message ?? String(e.message)); + } catch { env = envelope(e.code === "ENOENT" ? "data_plane_missing" : "probe_failed", String(e.stderr ?? e.message).slice(0, 300)); } + return this.finish(callId, "exclusive_probe", { ...env, url: p.url, seed }, `探针失败信封:${env.code}: ${env.reason}`); + } finally { + try { fs.rmSync(tmp, { force: true }); } catch { /* 轮末清理兜底 */ } + } + }, + async runMatrixScan(arts) { // 单源薄包装(P1,#163):真路径在 matrixScanCli,legacy 返回形状零改动 + const r = await matrixScanCli(); + if (r.ok) return this.matrixFromResult(r.d, r.cmd, arts, r.rc); + return r.trace ? { ...r.env, trace: r.trace } : r.env; + }, + matrixFromResult(d, cmd, arts, rc) { + const art = artifactPath(this.artifactDir, "matrix-scan", "json"); + fs.writeFileSync(art, matrixArtifactText(d)); + arts?.push(art); + return { + ok: true, exit_code: d.exit_code, counts: d.counts, + layers: Object.fromEntries(Object.entries(d.layers).map(([k, v]) => [k, { status: v.status, notes: v.notes ?? [] }])), + violation_cells: (d.buckets?.violation ?? []).map((c) => ({ cell: c.cell, note: c.note ?? null })), + artifact: art, trace: { cmd, rc }, + }; + }, + matrixScanSummary() { + return { + name: "matrix_scan_summary", + label: "语义矩阵扫描", + description: "零参。语义契约矩阵现状扫描(四层取样):exit_code 0/1/2=干净/pending/violation + 逐格违例与层状态。数据面缺失=结构化信封不裸栈。", + annotations: { readOnlyHint: true, idempotentHint: true }, + parameters: Type.Object({}), + outputSchema: Type.Object({ + ok: Type.Boolean(), exit_code: Type.Number(), violation_n: Type.Number(), + counts: Type.Object({}), layers: Type.Object({}), trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), + }), + execute: async (callId) => { + const r = await this.runMatrixScan(); + if (!r.ok) return this.finish(callId, "matrix_scan_summary", r, `matrix 扫描失败信封:${r.code}: ${r.reason}`); + this.artifacts.push(r.artifact); + const c = `exit_code=${r.exit_code}(0 干净/1 pending/2 violation) violation=${r.counts.violation} pending=${r.counts.pending} agreement=${r.counts.agreement}\n` + + Object.entries(r.layers).map(([k, v]) => `layer ${k}: ${v.status}${v.notes.length ? `(${v.notes.join(",")})` : ""}`).join("\n") + + (r.violation_cells.length ? `\n违例格:${r.violation_cells.map((x) => x.cell).join(",")}` : ""); + return this.finish(callId, "matrix_scan_summary", { ...r, violation_n: r.counts.violation }, c); + }, + }; + }, +}; diff --git a/ops/agent/tools/precedents.mjs b/ops/agent/tools/precedents.mjs new file mode 100644 index 00000000..7b591eb9 --- /dev/null +++ b/ops/agent/tools/precedents.mjs @@ -0,0 +1,128 @@ +// tools/precedents.mjs — 判例检索单源(PR #177;判例 2026-10-24-discover-dual-channel-dead)。 +// rg spawn argv 逐字对齐 python 参考实现;agent.mjs 宿主预注入同调 searchPrecedentsCore(单源)。 +import fs from "node:fs"; +import path from "node:path"; +import { Type } from "@earendil-works/pi-ai"; +import { B, DOCS_ROOT, execFileP } from "./shared.mjs"; +import { envelope, classifyChannelError } from "./envelope.mjs"; + +// ── 判例检索单源(PR #177;判例 2026-10-24-discover-dual-channel-dead)──────────── +// rg spawn argv 逐字对齐 backend/ipdb/_ops_store.rg_search_precedents(Python 参考实现, +// Python 面零改动):["rg","-i","-l","-g","*.md","-g","!_*",...每词 "-e",kw,dir]。 +// 成功路径注入块与 lib.sh inject_precedents 逐字节等价(golden/inject-block.* 钉死; +// fmField 重实现 fm_block 拍平语义);失败面走 classifyChannelError 分类信封 +// (rc=1 = 真零命中;rc≥2/ENOENT/超时 ≠ 零命中)。agent.mjs 宿主预注入同调本函数(单源); +// lib.sh inject_precedents 保留仅供 pipeline/steps.mjs(pipeline 面本批冻结不触; +// 两路输出 golden 字节等价由 selftest-t4 对拍钉死)。 +export const RG_TIMEOUT_MS = 60_000; // _ops_store._RG_TIMEOUT_S 同值(两侧同签,勿单边改) + +export function resolvePrecedentsDir(env = process.env) { + // call-time 解析(契约表 PRECEDENTS_DIR phase=call-time;DOCS_ROOT 用模块顶 import-time 值兑底) + return env.PRECEDENTS_DIR ?? path.join(env.DOCS_ROOT ?? DOCS_ROOT, "ops", "precedents"); +} + +// fmField — lib.sh fm_block 的 JS 逐语义重实现(frontmatter 顶层字段拍平摘要,非 YAML 解析)。 +// 字节等价的隐藏细节(golden 钉死,以现行为准不顺手修正): +// - key 行内联值记入,块标示符(> / |)起头不记:折叠字段首个续行拍平后带前导空格 → +// 「context: 」双空格是现行输出真形状; +// - 字段块内任何非缩进行(含空行)= 块终止(awk exit 语义:单引号多行值 key 行后空行即截断); +// - 续行/嵌套子键(reward 映射)两侧 [ \t] 裁剪后以单空格连接,空续行跳过。 +export function fmField(text, field) { + const lines = text.split("\n"); + if (lines[0] !== "---") return ""; // NR==1 非 ---:无 frontmatter + let out = "", active = false; + for (let i = 1; i < lines.length; i++) { + const l = lines[i]; + if (/^---/.test(l)) break; // frontmatter 终线 + const m = l.match(/^([A-Za-z_][A-Za-z0-9_]*):/); // 顶层键(列 0;缩进子键不命中) + if (m) { + if (m[1] === field) { + active = true; + const rest = l.slice(m[0].length).replace(/^[ \t]+/, "").replace(/[ \t]+$/, ""); + if (rest !== "" && !/^[|>]/.test(rest)) out += (out === "" ? "" : " ") + rest; + } else if (active) break; // 下一顶层键 = 本字段块结束 + continue; + } + if (active && /^[ \t]/.test(l)) { + const line = l.replace(/^[ \t]+/, "").replace(/[ \t]+$/, ""); + if (line !== "") out += " " + line; // 首续行前导空格 = 双空格细节的来源 + } else if (active) break; // 非缩进行(含空行)= 字段块终止 + } + return out; +} + +// 返回 { block, files, trace }:block=注入块文本(逐字节等价 lib.sh;含尾换行);files= +// 排序去重命中路径(sort -u 语义;词法序——判例文件名全 ASCII 日期前缀,locale 无关); +// trace={cmd:真 argv,rc:真 rc}。失败抛 Error(.envelope={code,rc,reason} 分类信封 + .trace)。 +export async function searchPrecedentsCore(keywords, { precedentsDir, env = process.env, timeoutMs = RG_TIMEOUT_MS } = {}) { + const dir = path.resolve(precedentsDir ?? resolvePrecedentsDir(env)); + // execFile(file,args) 的 args 不含二进制名(Python subprocess 全 argv 含 "rg" ——两侧逻辑 + // argv 逐字对齐,此处拼接点不同);trace.cmd 展示含 rg 的完整逻辑 argv + const args = ["-i", "-l", "-g", "*.md", "-g", "!_*"]; + for (const kw of keywords) args.push("-e", kw); + args.push(dir); + const cmd = ["rg", ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" "); + let stdout = ""; + if (keywords.length) { // 零词守卫(lib.sh 同形:不跑 rg——rg 无 pattern 会 rc=2;Python 面由 ValueError 拒词,契约对拍不覆盖零词) + try { + ({ stdout } = await execFileP("rg", args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, env })); + } catch (e) { + if (e.code === 1) stdout = String(e.stdout ?? ""); // rc=1 = 真零命中(Python 参考同语义;execFile 对非零退出 reject,此处置回成功面) + else { + const cls = classifyChannelError(e, "rg"); + throw Object.assign(new Error(cls.reason), { envelope: cls, trace: { cmd, rc: cls.rc } }); + } + } + } + const files = [...new Set(stdout.split("\n").filter((l) => l !== ""))].sort(); + const kwsLine = keywords.join(" "); + if (!files.length) { + return { block: `### 注入判例(检索词: ${kwsLine})\n无判例(检索词: ${kwsLine})\n`, files, trace: { cmd, rc: 0 } }; + } + const blockLines = [`### 注入判例(检索词: ${kwsLine})`]; + for (const f of files) { + const text = fs.readFileSync(f, "utf8"); // rg 输出行即路径(与 lib.sh fm_block "$f" 同读法) + blockLines.push(`- ${path.basename(f)}`); + for (const field of ["context", "human_ruling", "reward"]) blockLines.push(` - ${field}: ${fmField(text, field)}`); + } + return { block: `${blockLines.join("\n")}\n`, files, trace: { cmd, rc: 0 } }; +} + +// ── 工具方法(实现零改动,经门面挂 ToolHost.prototype)───────────────────── +export const precedentsMethods = { + searchPrecedents() { + return { + name: "search_precedents", + label: "判例检索", + description: "关键词词法检索(rg)判例库 docs/ops/precedents/(唯一跨轮记忆;无嵌入)。命中≤8 条(铁律⑦);零命中显式返回 none。包内引用判例只许引注入块或本工具命中的文件。", + annotations: { readOnlyHint: true, idempotentHint: true }, + parameters: Type.Object({ + keywords: Type.Array(Type.String({ minLength: 1, maxLength: 64 }), { minItems: 1, maxItems: 5 }), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), hits: Type.Array(Type.String()), hit_n: Type.Number(), + truncated: Type.Boolean(), rg_rc: Type.Number(), trace: Type.Object({ cmd: Type.String(), rc: Type.Number() }), + }), + execute: async (callId, p) => { + // PR #177 单源改造:原生 rg spawn(searchPrecedentsCore),失败面分类信封 + // (判例 2026-10-24-discover-dual-channel-dead:旧路径硬编码 trace rc:0 连失败 + // 路径也带、lib.sh 吞 stderr 2>/dev/null、ENOENT 塞字符串违反 Number 槽)。 + // 成功路径输出与旧 lib.sh 路径逐字节等价(golden 钉死);结构化字段只增不删。 + try { + const { block, files, trace } = await searchPrecedentsCore(p.keywords); + const all = files.map((f) => path.basename(f)); + const truncated = all.length > B.hitCap; + const hits = all.slice(0, B.hitCap); + hits.forEach((h) => this.searchHits.add(h)); + const st = { ok: true, hits, hit_n: hits.length, truncated, rg_rc: 0, trace }; + return this.finish(callId, "search_precedents", st, + hits.length ? `${block.trim()}\n(命中 ${all.length} 条${truncated ? `,注入前 ${B.hitCap}(铁律⑦)` : ""})` : block.trim()); + } catch (e) { + const env = envelope(e.envelope?.code ?? "rg_infra", e.envelope?.reason ?? e.message); + return this.finish(callId, "search_precedents", { ...env, trace: e.trace ?? { cmd: "(未起跑)", rc: 1 } }, + `判例检索失败信封:${env.code}: ${env.reason}`); + } + }, + }; + }, +}; diff --git a/ops/agent/tools/registry.mjs b/ops/agent/tools/registry.mjs new file mode 100644 index 00000000..7cd5e834 --- /dev/null +++ b/ops/agent/tools/registry.mjs @@ -0,0 +1,184 @@ +// tools/registry.mjs — 工具面闭集/白名单单源(票 #195 C6 分层):MEASUREMENT_TOOLS +// (evidence 只认测量工具)/PREDICATES(REJECT 谓词闭集)/GH_WRITE_SUBCOMMANDS(gh 面 +// 唯一写面闭集,票 #184)+ 读面白名单执法(checkReadablePath/DENY_*)+ 证据解析 +// (resolveField)/判例引用闭合(closureCheck)/structuredContent 登记表(ResultRegistry) +// 与两个执法工具方法(read_file 白名单读 / queue_action 唯一写;经门面挂 ToolHost)。 +import fs from "node:fs"; +import path from "node:path"; +import { Type } from "@earendil-works/pi-ai"; +import { B, ROOT, DOCS_ROOT, ts, artifactPath } from "./shared.mjs"; +import { envelope } from "./envelope.mjs"; + +export const PREDICATES = ["sunset_frozen", "mirror_echo", "zero_asset"]; // discover 谓词表闭集 +export const MEASUREMENT_TOOLS = new Set(["fetch_url", "gh_search", "exclusive_probe", "matrix_scan_summary"]); +// 发现通道三件(web_read/web_search/rss_parse,PR3a)不进 MEASUREMENT_TOOLS:与测量工具的边界 +// =数据形状天然隔离(发现线索非硬门数字),evidence 语义门不因发现通道放宽(DESIGN §6 2026-10-24 裁定)。 +// ── gh 面子命令白名单(票 #184;spec #180 首批租户 Q6 授权写通道)────────────────── +// search=只读模型工具(ghSearch,每轮≤15);issue create=gh 面唯一写面,仅 finding 出口脚本步 +// (pipeline/finding-exit.mjs)消费——模型工具面零新增写口(MEASUREMENT_TOOLS 不动,evidence +// 语义门不受污染);写面闭集扩张=spec 变更。标签缺省 needs-triage(triage-labels 五默认角色,人裁终局)。 +export const GH_WRITE_SUBCOMMANDS = ["issue create"]; +// ── 读面白名单(融合必保 4:显式拒 .env/密钥模式/.venv/data;门=实现双层之实现层)── +const DENY_NAME = [/\.env(\.|$)/i, /\.pem$/i, /\.p12$/i, /\.key$/i, /(^|[-_.])(priv|secret|credential)s?([-_.]|$)/i]; +const DENY_SEG = [".git", ".venv", "node_modules", "backend/data"]; +export function checkReadablePath(absPath) { + // 不存在的路径不 realpath(ENOENT 会抢在拒读判定前炸):词法归一化即可支撑白名单/拒读判定 + const real = fs.existsSync(absPath) ? fs.realpathSync(absPath) : path.resolve(absPath); + const rootOf = (r) => (fs.existsSync(r) ? fs.realpathSync(r) : path.resolve(r)); + const roots = [rootOf(ROOT), rootOf(DOCS_ROOT)]; + if (!roots.some((r) => real === r || real.startsWith(r + path.sep))) { + throw new Error(`路径越界(白名单=仓根+docs 根):${absPath}`); + } + const rel = path.relative(ROOT, real); + if (DENY_SEG.some((s) => rel.split(path.sep).includes(s) || rel.includes(s + path.sep))) { + throw new Error(`路径禁读(${DENY_SEG.join("/")}):${absPath}`); + } + const base = path.basename(real).toLowerCase(); + if (DENY_NAME.some((re) => re.test(base))) throw new Error(`疑似密钥/凭证文件拒读(.env/密钥模式):${absPath}`); + return real; +} + +// evidence 字段点路径解析:"results.0.size_kb" / "results[0].size_kb" → 嵌套取值;无命中 undefined。 +// 导出供金轨迹 runner 复用同一解析语义(T6,避免断言侧另起炉灶)。 +export function resolveField(obj, field) { + const parts = String(field).replace(/\[(\d+)\]/g, ".$1").split("."); + let cur = obj; + for (const p of parts) { + if (cur == null || typeof cur !== "object" || !(p in cur)) return undefined; + cur = cur[p]; + } + return cur; +} + +// ── 判例引用闭合校验(铁律④:路径 ∈ 预注入 ∪ 本会话检索命中;存在性不够)──────── +export function closureCheck(pkgText, allowed /* Set<string> 文件名 */) { + const lines = pkgText.split("\n"); + const sec = []; let inSec = false; + for (const l of lines) { + if (/^#+\s.*注入判例/.test(l)) { inSec = true; continue; } + if (inSec && /^#+\s/.test(l)) break; + if (inSec) sec.push(l); + } + if (!sec.length) throw new Error("闭合校验:包缺少「注入判例」节"); + const toks = [...new Set(sec.flatMap((l) => (l.match(/[A-Za-z0-9_\-./]+\.md/g) ?? []).map((t) => path.basename(t))))]; + if (!toks.length) { + if (sec.some((l) => l.includes("无判例"))) return { cited: 0 }; + throw new Error("闭合校验:注入判例节既无路径也无「无判例」"); + } + const bad = toks.filter((t) => !allowed.has(t)); + if (bad.length) throw new Error(`闭合校验:判例路径不在(预注入∪本会话检索命中)闭集:${bad.join(", ")}(防真实文件假引用)`); + return { cited: toks.length }; +} +// ── 本会话 structuredContent 登记 + 写前语义门(铁律①)──────────────────────── +export class ResultRegistry { + constructor() { this.byCall = new Map(); } + register(callId, toolName, sc) { this.byCall.set(callId, { tool: toolName, sc }); } + get(callId) { return this.byCall.get(callId); } +} + +// ── 执法工具方法(实现零改动,经门面 Object.assign 挂 ToolHost.prototype)──── +export const registryMethods = { + readFile() { + return { + name: "read_file", + label: "白名单读文件", + description: "只读。白名单=本仓根+docs 根;拒 .env/密钥模式/.venv/backend-data/node_modules。≤50KB/2000 行,支持 pattern 过滤(grep -E 语义)与 offset/limit。", + annotations: { readOnlyHint: true, idempotentHint: true }, + parameters: Type.Object({ + path: Type.String({ minLength: 1, maxLength: 512 }), + pattern: Type.Optional(Type.String({ maxLength: 128, description: "正则过滤,仅保留命中行" })), + offset: Type.Optional(Type.Number({ minimum: 1, maximum: 100000 })), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 2000 })), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), path: Type.String(), lines_n: Type.Number(), + bytes_n: Type.Number(), truncated: Type.Boolean(), content: Type.String(), + }), + execute: async (callId, p) => { + let real; + try { + real = checkReadablePath(path.resolve(p.path)); + const stat = fs.statSync(real); + if (!stat.isFile()) throw Object.assign(new Error("不是普通文件"), { code: "denied" }); + const truncatedBytes = stat.size > B.readBytes; + const text = fs.readFileSync(real, "utf8").slice(0, B.readBytes); + let lines = text.split("\n"); + if (p.pattern) { + let re; try { re = new RegExp(p.pattern); } catch { throw Object.assign(new Error("pattern 非法正则"), { code: "bad_pattern" }); } + lines = lines.filter((l) => re.test(l)); + } + const off = (p.offset ?? 1) - 1; + const lim = p.limit ?? B.readLines; + const sliced = lines.slice(off, off + lim); + const st = { + ok: true, path: real, lines_n: sliced.length, bytes_n: stat.size, + truncated: truncatedBytes || off + lim < lines.length, content: sliced.join("\n"), + }; + return this.finish(callId, "read_file", st, sliced.join("\n") || "(空)"); + } catch (e) { + const env = envelope(e.code === "denied" || /白名单|禁读|密钥/.test(e.message) ? "denied" : (e.code ?? "io_error"), e.message); + return this.finish(callId, "read_file", { ...env, path: p.path }, `读文件失败信封:${env.code}: ${env.reason}`); + } + }, + }; + }, + queueAction() { + return { + name: "queue_action", + label: "动作入队(唯一写)", + description: "唯一写工具:把 ENQUEUE(自动入队,须本会话 exclusive_probe 实测 rate≥0.25 且对照闸过)/REJECT(谓词闭集 sunset_frozen|mirror_echo|zero_asset,写前语义门从 structuredContent 重算谓词)追加到 docs/ops/queue.md。append-only、幂等去重、恒可否决(veto_cmd);rationale 只落工件包不进行列。evidence[]={call_id,field,value} 必须逐字对应本会话测量工具的结构化返回。", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + parameters: Type.Object({ + source: Type.String({ pattern: "^[a-z0-9_]{1,32}$" }), + action: Type.Union([Type.Literal("ENQUEUE"), Type.Literal("REJECT")]), + predicate: Type.Optional(Type.Union([Type.Literal("sunset_frozen"), Type.Literal("mirror_echo"), Type.Literal("zero_asset")])), + rationale: Type.String({ minLength: 1, maxLength: 2000 }), + evidence: Type.Array(Type.Object({ + call_id: Type.String({ minLength: 1 }), + field: Type.String({ minLength: 1 }), + value: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]), + }), { minItems: 1, maxItems: 16 }), + }), + outputSchema: Type.Object({ + ok: Type.Boolean(), appended: Type.Boolean(), dedup: Type.Boolean(), + action: Type.String(), source: Type.String(), vetoable: Type.Boolean(), veto_cmd: Type.String(), + }), + execute: async (callId, p) => { + // 预算计数走真实路径:queue_action 不经 finish()(非测量工具,不进登记表), + // 若不在此计数 spent 永不增长、10/轮上限永不触发(评审 Important-1,2026-10-19) + this.budgetSpend("queue_action"); + // 幂等去重:同 action+source 已在队列 → 返回既有事实,不双写 + let existing = ""; + try { existing = fs.readFileSync(this.queueFile, "utf8"); } catch { /* 首建 */ } + const dedupRe = new RegExp(`\\| \\d{4}-\\d{2}-\\d{2} \\| [^|]+ \\| ${p.action} \\| ${p.source} \\|`); + const dedup = dedupRe.test(existing); + let lineN = existing ? existing.trimEnd().split("\n").length : 0; + const rel = path.relative(ROOT, this.queueFile); + const vetoCmd = `sed -i '/| ${p.action} | ${p.source} |/d' ${rel}`; + if (!dedup) { + if (!existing) { + fs.mkdirSync(path.dirname(this.queueFile), { recursive: true }); + fs.writeFileSync(this.queueFile, + "# 源生命周期动作队列(queue_action 唯一写口;行由 host 模板渲染,rationale 只落工件)\n\n" + + "| 日期 | 轮次 | action | source | predicate | evidence | vetoable |\n|---|---|---|---|---|---|---|\n"); + lineN = 4; + } + const ev = p.evidence.map((e) => `${e.call_id}:${e.field}=${JSON.stringify(e.value)}`).join(";"); + const row = `| ${new Date().toISOString().slice(0, 10)} | ${this.round} | ${p.action} | ${p.source} | ${p.predicate ?? "—"} | ${ev} | 是(否决=删除本行) |`; + fs.appendFileSync(this.queueFile, `${row}\n`); + lineN += 1; + const rpath = artifactPath(this.artifactDir, "queue-rationale", "md"); + fs.appendFileSync(rpath, `## ${ts()} ${p.action} ${p.source}\n- predicate: ${p.predicate ?? "(无)"}\n- evidence: ${JSON.stringify(p.evidence)}\n- rationale(模型原话,仅人裁阅读,不进队列行):\n\n${p.rationale}\n\n`); + this.artifacts.push(rpath); + } + const st = { + ok: true, appended: !dedup, dedup, action: p.action, source: p.source, + predicate: p.predicate ?? null, vetoable: true, veto_cmd: vetoCmd, queue_file: rel, + }; + this.queueWrites.push(st); + const c = `${p.action} ${p.source}${dedup ? "(已在队列,幂等去重,未双写)" : " 已入队"};恒可否决:veto_cmd = \`${vetoCmd}\`;rationale 已落工件(包脚注)。`; + return { content: [{ type: "text", text: c }], details: {}, structuredContent: st }; + }, + }; + }, +}; diff --git a/ops/agent/tools/shared.mjs b/ops/agent/tools/shared.mjs new file mode 100644 index 00000000..4b491bbe --- /dev/null +++ b/ops/agent/tools/shared.mjs @@ -0,0 +1,162 @@ +// tools/shared.mjs — 跨面基建(票 #195 C6:tools.mjs 单体按面分层,行为零变化)。 +// 落位/边界常量/SSRF 安全取数/lib 助手:被各面文件与门面单向共享(面不互指、不回导入 +// 门面)。env 读取面(OPSEVAL_PY/DATA_DIR 等)全部集中本文件(selftest-contract 扫描集 +// 随 SRCS 扩至 tools/*.mjs)。 +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import dns from "node:dns/promises"; +import { fileURLToPath } from "node:url"; +import { fetch as undiciFetch, Agent } from "undici"; + +export const execFileP = promisify(execFile); +const AGENT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); // 面文件居 tools/ 子层:上移一层=ops/agent,ROOT/LIBSH 推导与拆分前同值(C6) +export const ROOT = path.resolve(AGENT_DIR, "..", ".."); // worktree 根(沙箱红线不变式:落位推导) +export const DOCS_ROOT = process.env.DOCS_ROOT ?? path.join(ROOT, "docs"); // 符号链接 → docs 仓 +const LIBSH = path.join(AGENT_DIR, "lib.sh"); +// ── 边界常量(env 可覆盖,selftest 用)──────────────────────────────────────── +export const B = { + fetchBytes: +(process.env.OPS_FETCH_MAX_BYTES ?? 512 * 1024), + fetchPerRound: 20, + fetchTimeoutMs: 15_000, + ghPerRound: 15, + ghTimeoutMs: 60_000, + queuePerRound: 10, + readBytes: 50 * 1024, + readLines: 2000, + probeBytes: +(process.env.OPS_PROBE_MAX_BYTES ?? 256 * 1024 * 1024), + probeMaxKeys: +(process.env.OPS_PROBE_MAX_KEYS ?? 1_000_000), // bitwire 4.58M OOM 判例:过线体量人裁 + probeDlTimeoutMs: 300_000, + probeCliTimeoutMs: +(process.env.OPS_PROBE_CLI_TIMEOUT_MS ?? 1_800_000), + matrixTimeoutMs: +(process.env.OPS_MATRIX_TIMEOUT_MS ?? 1_200_000), + anchorSweepTimeoutMs: +(process.env.OPS_ANCHOR_SWEEP_TIMEOUT_MS ?? 300_000), // #182:19 锚直调 lookup 远轻于 matrix 全扫(20min);5min 兑底 + hitCap: 8, // 铁律⑦:判例注入与检索命中上限 8(检索深度伤害决策) + zeroAssetKb: 32, + webReadPerRound: 8, // 发现通道三件预算行:防失控非省钱(2026-10-24 裁定,DESIGN §6;预算四界另批) + webSearchPerRound: 6, + rssParsePerRound: 6, + webReadTimeoutMs: 20_000, // r.jina.ai 正文面 20s(票面;上游可 10s+) + webSearchTimeoutMs: 40_000, // pi-web-access MCP:握手 ~1s + DDG 内部 30s 上限(pi-web-access 0.44.0) +}; +export const EVAL_PY = process.env.OPS_EVAL_PY ?? "/home/huxiao/dev/pi-ip-lookup-tool/backend/.venv/bin/python"; +export const DATA_DIR = process.env.OPS_DATA_DIR ?? "/home/huxiao/dev/pi-ip-lookup-tool/backend/data"; +const UA = "ip-radar-ops-agent/1.0"; +export const FROZEN_DAYS = 180; // threatview 判例:sunset 门线 +export const RSS_FRESH_DAYS = 30; // rss 新鲜门(feed 活性):独立常量勿复用 FROZEN_DAYS——门线语义不同(活性 vs sunset 冻结);DESIGN §6 2026-10-24 裁定 +// ── SSRF:私网/环回/链路本地(逐跳,fetch_url 与 probe 下载共用)──────────────── +const BL = new net.BlockList(); +for (const [net4, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], + ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16], ["198.18.0.0", 15], + ["224.0.0.0", 4], ["240.0.0.0", 4]]) BL.addSubnet(net4, bits); +for (const [net6, bits] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) BL.addSubnet(net6, bits, "ipv6"); +export function checkPrivateIp(ip) { + if (ip.toLowerCase().startsWith("::ffff:") && net.isIPv4(ip.slice(7))) ip = ip.slice(7); // v4 映射地址 + return BL.check(ip, ip.includes(":") ? "ipv6" : "ipv4"); +} + +class SsrfError extends Error { constructor(h) { super(`SSRF 拒绝:${h} 解析到私网/环回/链路本地地址`); this.code = "ssrf"; } } + +// 返回本跳已验证的安全地址集(供连接层钉死);IP 字面量已验则返回空数组 +// (net.connect 对字面量不走 lookup,直连即已验地址)。 +export async function guardHop(rawUrl) { + const u = new URL(rawUrl); + if (u.protocol !== "http:" && u.protocol !== "https:") throw Object.assign(new Error(`仅许 http(s):${rawUrl}`), { code: "bad_url" }); + const h = u.hostname.replace(/^\[|\]$/g, ""); + if (net.isIP(h)) { if (checkPrivateIp(h)) throw new SsrfError(h); return []; } + const addrs = await dns.lookup(h, { all: true }).catch(() => { throw Object.assign(new Error(`DNS 解析失败:${h}`), { code: "network" }); }); + const safe = []; + for (const { address } of addrs) { + if (checkPrivateIp(address)) throw new SsrfError(`${h}→${address}`); + safe.push(address); + } + return safe; +} + +// 连接层钉死:lookup 只吐 guardHop 已验证的地址——验证与连接同一地址集, +// 堵 TTL-0 DNS 重绑定的双解析 TOCTOU(评审 Important-2,2026-10-19)。 +// 选 undici Agent + undici.fetch(而非全局 fetch):全局 fetch 不保证消费自定义 dispatcher; +// hostname/SNI/证书校验仍按域名,仅连接地址被钉。导出供单测钉住机制。 +export function pinnedLookup(validatedAddrs) { + return (hostname, options, callback) => { + callback(null, validatedAddrs.map((a) => ({ address: a, family: net.isIPv6(a) ? 6 : 4 }))); + }; +} + +// 手动逐跳重定向:每一跳都过 SSRF 门并钉地址(融合必保:D1 逐跳复检)。 +// headers/allowStatus 注入面(#173 微探针专用,既有调用面零改):headers 合并覆盖(Range/UA 变体); +// allowStatus=true 时 ≥400 不抛错照常返回——状态码本身是读数(UA 拦截/限流/死键的判别信号, +// 同 lib.sh 把 http_<code>/TOKEN_REQUIRED 当读数的口径),网络/超时/跳数上限才是失败信封。 +export async function safeFetch(rawUrl, { method = "GET", maxBytes, timeoutMs, headers = {}, allowStatus = false } = {}) { + let url = rawUrl, hops = 0, finalRes, active = null; + try { + for (;;) { + const validated = await guardHop(url); + const hop = new Agent({ connections: 1, connect: { lookup: pinnedLookup(validated) } }); + let r; + try { + r = await undiciFetch(url, { + method, redirect: "manual", signal: AbortSignal.timeout(timeoutMs), + headers: { "user-agent": UA, ...headers }, dispatcher: hop, + }); + } catch (e) { + await hop.close().catch(() => { /* 本跳失败即弃 */ }); + const why = e?.cause?.message ?? e?.message ?? String(e); + throw Object.assign(new Error(`网络失败:${why}`), { code: /timeout|abort/i.test(why) ? "timeout" : "network" }); + } + if (active) await active.close().catch(() => { /* 前一跳体已 cancel */ }); + active = hop; + finalRes = r; + if ([301, 302, 303, 307, 308].includes(finalRes.status)) { + const loc = finalRes.headers.get("location"); + await finalRes.body?.cancel().catch(() => { /* 重定向体不消费,显式 cancel 释放在飞请求 */ }); + if (!loc) throw Object.assign(new Error(`${finalRes.status} 无 Location`), { code: "http_error" }); + if (++hops > 5) throw Object.assign(new Error("重定向 >5 跳"), { code: "too_many_hops" }); + url = new URL(loc, url).toString(); + continue; + } + break; + } + // GET 体必须在本跳 dispatcher 开着时消费(复审 Important,2026-10-19):undici fetch + // 头部即 resolve,体流是消费者驱动带背压;先 close() 会等在飞请求,体永不被喡 → + // 卡到超时。实测复现:30MB 体 headers 20ms,close() 4s+ 仍挂起;177KB 则侥幸过。 + let buf = null; + if (method === "GET") { + const reader = finalRes.body.getReader(); + const chunks = []; let n = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + n += value.byteLength; + if (n > maxBytes) { await reader.cancel(); throw Object.assign(new Error(`响应超过 ${maxBytes}B 上限`), { code: "too_large" }); } + chunks.push(value); + } + buf = Buffer.concat(chunks); + } else { + await finalRes.body?.cancel().catch(() => { /* HEAD 等无体请求:显式释放 */ }); + } + if (finalRes.status >= 400 && !allowStatus) throw Object.assign(new Error(`HTTP ${finalRes.status}`), { code: "http_error", status: finalRes.status }); + // 通道复验轮修复(2026-10-24,轮包 2026-10-24-discover-0634 实锤):原函数体 `const headers` + // 遮蔽参数解构的 headers(#173 微探针注入面)——函数体 const 全作用域 TDZ,构造请求头处即抛 + // Cannot access 'headers' before initialization,fetch_url/exclusive_probe/微探针 100% 必死。 + // 1412 轮「DEEP 探针网络失败(peeringdb 不通)」归因实为本虫(transcript 在案),非网络。 + const resHeaders = Object.fromEntries(finalRes.headers.entries()); + return { finalUrl: url, status: finalRes.status, headers: resHeaders, body: buf, bytes: buf?.byteLength ?? 0 }; + } finally { + await active?.close().catch(() => { /* 消费/取消/异常后统一收口(钉址 agent 单跳即弃) */ }); + } +} +export const ts = () => { + const d = new Date(), p = (x) => String(x).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +}; +export const runLib = (fn, args = [], timeoutMs = 600_000) => + execFileP("bash", ["-c", `. "$0"; ${fn} "$@"`, LIBSH, ...args], + { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, env: { ...process.env } }); +export const artifactPath = (dir, slug, ext) => { + fs.mkdirSync(dir, { recursive: true }); + const p = path.join(dir, `${ts()}-${slug}.${ext}`); + return p; +}; + diff --git a/scripts/c2_predicate_gates.py b/scripts/c2_predicate_gates.py new file mode 100644 index 00000000..dbea916a --- /dev/null +++ b/scripts/c2_predicate_gates.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""C2 物化谓词双门(#192 / ADR 0009):位等价 + 版本戳消融。 + +门①位等价:代码未变时连续两次 rebuild,第二次必须零副作用—— + sidecar(ptr/count/cov/disjoint/.rev)/纪元目录逐字节同、mtime 同、 + 纪元号不动。首跑(拷入的存量族无 .rev)先做一次迁移重建,再验证跳写。 +门②消融(3 真实源 myip_ms/ziyadnz/cps):对照「touch csv 强制重建」 + (旧仪式,依赖 mtime 腿)与「HARVEST_REV 前进强制重建」(C2 代码代腿, + csv mtime 全程不动)——两路径的计数 sidecar 与排除/告警日志必须一致, + 即版本戳机制下 touch 不再必要。 + +用法(在 worktree 根): + backend/../../backend/.venv/bin/python scripts/c2_predicate_gates.py +数据隔离:从主仓 backend/data 只读 cp -a 到 worktree backend/data/gate/, +绝不写主仓。退出码 0 = 双门全过。 + +产物:notes/c2-predicate-ablation-2026-10-25.md 由本脚本打印的表格手工 +汇编(脚本只输出机检结论,人读叙述在 notes)。 +""" +from __future__ import annotations + +import hashlib +import logging +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent # worktree 根 +MAIN_DATA = Path("/home/huxiao/dev/pi-ip-lookup-tool/backend/data") # 只读真相源 +GATE_DATA = ROOT / "backend" / "data" / "gate" # worktree 内隔离数据面 + +sys.path.insert(0, str(ROOT / "backend")) + +import ipdb._sources._lmdb as lm # noqa: E402 + +SOURCES = [ # (模块, 类, raw 文件名) + ("ipdb._sources.myip_ms", "MyipMsSource", "myip_ms"), + ("ipdb._sources.ziyadnz", "ZiyadnzSource", "ziyadnz.csv"), + ("ipdb._sources.cps_collected_iocs", "CpsCollectedIocsSource", + "cps_collected_iocs.txt"), +] + +failures: list[str] = [] + + +def check(cond: bool, label: str) -> None: + print(f" [{'OK' if cond else 'FAIL'}] {label}") + if not cond: + failures.append(label) + + +def family_state(base: Path) -> dict: + """族完整指纹:sidecar 字节 + 纪元目录清单 + 逐文件 sha256 + mtime_ns。""" + def sha(p: Path) -> str: + return hashlib.sha256(p.read_bytes()).hexdigest() + + side_names = [".ptr", ".count", ".cov", ".disjoint", ".rev"] + st: dict = {"sidecars": {}, "epochs": {}} + for suf in side_names: + p = base.parent / (base.name + suf) + st["sidecars"][suf] = (p.exists(), sha(p) if p.exists() else None, + p.stat().st_mtime_ns if p.exists() else None) + if base.parent.exists(): + for child in sorted(base.parent.iterdir()): + if child.is_dir() and child.name.startswith(base.name + ".") \ + and child.name[len(base.name) + 1:].split(".")[0].isdigit(): + st["epochs"][child.name] = { + f.name: (sha(f), f.stat().st_mtime_ns) + for f in sorted(child.iterdir())} + return st + + +class LogCap(logging.Handler): + """rebuild 期间排除/告警日志捕手(同格式三元组,可多重集比对)。""" + + def __init__(self): + super().__init__(level=logging.INFO) + self.rows: list[tuple] = [] + + def emit(self, rec): + self.rows.append((rec.levelname, rec.name, rec.getMessage())) + + def __enter__(self): + logging.getLogger().addHandler(self) + return self + + def __exit__(self, *a): + logging.getLogger().removeHandler(self) + + +def snapshot(data_dir: Path, raw: str) -> dict: + stem = raw.rsplit(".csv", 1)[0] if raw.endswith(".csv") else raw + v4 = data_dir / f"{raw}.lmdb" + v6 = data_dir / f"{raw}.v6.lmdb" + return {"v4": family_state(v4), "v6": family_state(v6), + "raw_mtime_ns": (data_dir / raw).stat().st_mtime_ns} + + +def counts(data_dir: Path, raw: str) -> dict: + stem = raw + out = {} + for fam in (f"{raw}.lmdb", f"{raw}.v6.lmdb"): + cp = data_dir / (fam + ".count") + pp = data_dir / (fam + ".ptr") + out[fam] = ((cp.read_text().strip() if cp.exists() else None), + (pp.read_text().strip() if pp.exists() else None)) + return out + + +def prep_source(name: str, raw: str) -> Path: + """cp -a 主仓该源全套(raw+两族 sidecar+纪元)到隔离目录,保 mtime。""" + d = GATE_DATA / name + if d.exists(): + shutil.rmtree(d) + d.mkdir(parents=True) + for f in MAIN_DATA.iterdir(): + if f.name == raw or f.name.startswith(raw + "."): + shutil.copy2(f, d / f.name) if f.is_file() else shutil.copytree(f, d / f.name) + assert (d / raw).exists() + return d + + +def run_source(cls_path: str, cls_name: str, raw: str) -> None: + import importlib + print(f"\n== {cls_name} ({raw}) ==") + d = prep_source(cls_name.lower(), raw) + cls = getattr(importlib.import_module(cls_path), cls_name) + src = cls(data_dir=d) + + pre = counts(d, raw) + print(f" 拷入存量族:{pre}") + + # ── 迁移重建(存量无 .rev → 版本戳腿触发;#192 部署注记的预期行为)── + with LogCap() as cap_mig: + src.rebuild() + post = counts(d, raw) + check(post[f"{raw}.lmdb"][0] == pre[f"{raw}.lmdb"][0], + f"迁移重建 v4 计数不变 {pre[f'{raw}.lmdb'][0]} → {post[f'{raw}.lmdb'][0]}") + check(post[f"{raw}.v6.lmdb"][0] == pre[f"{raw}.v6.lmdb"][0], + f"迁移重建 v6 计数不变 {pre[f'{raw}.v6.lmdb'][0]} → {post[f'{raw}.v6.lmdb'][0]}") + rev_v4 = (d / f"{raw}.lmdb.rev").read_text().strip() + rev_v6 = (d / f"{raw}.v6.lmdb.rev").read_text().strip() + check(rev_v4 == str(lm.HARVEST_REV) == rev_v6, + f"两族盖章 .rev={rev_v4}/{rev_v6} == HARVEST_REV={lm.HARVEST_REV}") + check(post[f"{raw}.lmdb"][1] != pre[f"{raw}.lmdb"][1], + "迁移重建 v4 纪元前进(真重建非跳写)") + + # ── 门①:代码未变连续 rebuild → 零副作用逐字节同 ── + s1 = snapshot(d, raw) + src.rebuild() + s2 = snapshot(d, raw) + check(s1 == s2, "门① 位等价:第二次 rebuild 零副作用(sidecar/纪元/mtime 逐字节同)") + check(s1["raw_mtime_ns"] == s2["raw_mtime_ns"], "门① raw mtime 未被触碰") + + # ── 门②a 旧仪式:touch csv(mtime 腿)强制重建 ── + import os + (d / raw).touch() + m_before = (d / raw).stat().st_mtime_ns + with LogCap() as cap_touch: + src.rebuild() + c_touch = counts(d, raw) + check(c_touch[f"{raw}.lmdb"][0] == pre[f"{raw}.lmdb"][0], "touch 路径 v4 计数不变") + rows_touch = sorted(cap_touch.rows) + # 排除/过滤日志对照(touch vs 后续 rev 路径,同 multiset) + print(f" touch 路径日志 {len(rows_touch)} 行") + + # ── 门②b C2:harvest 代码代前进(HARVEST_REV+1),csv mtime 全程不动 ── + lm.HARVEST_REV = lm.HARVEST_REV + 1 + try: + m_b = (d / raw).stat().st_mtime_ns + with LogCap() as cap_rev: + src.rebuild() + c_rev = counts(d, raw) + m_a = (d / raw).stat().st_mtime_ns + check(m_a == m_b == m_before, "门② rev 路径 csv mtime 全程不动(touch 不再必要)") + # 计数对照:count sidecar 必相等;纪元号必然各自前进(机制便如此) + cnt_touch = {k: v[0] for k, v in c_touch.items()} + cnt_rev = {k: v[0] for k, v in c_rev.items()} + check(cnt_rev == cnt_touch, f"门② 计数对照 rev路径==touch路径 {cnt_rev}") + check(int(c_rev[f"{raw}.lmdb"][1]) > int(c_touch[f"{raw}.lmdb"][1]), + f"rev 路径真重建纪元前进 {c_touch[f'{raw}.lmdb'][1]} → {c_rev[f'{raw}.lmdb'][1]}") + rows_rev = sorted(cap_rev.rows) + check(rows_rev == rows_touch, + f"门② 排除/告警日志对照一致({len(rows_rev)} 行 multiset 相等)") + + # 收敛不循环:rev 前进后 .rev 已=新代,再 rebuild 必须跳写 + s3 = snapshot(d, raw) + src.rebuild() + check(snapshot(d, raw) == s3, "rev 前进后收敛:再 rebuild 零副作用(无循环)") + finally: + lm.HARVEST_REV = lm.HARVEST_REV - 1 + + +def main() -> int: + assert GATE_DATA.resolve() != MAIN_DATA.resolve() + GATE_DATA.mkdir(parents=True, exist_ok=True) + for cls_path, cls_name, raw in SOURCES: + run_source(cls_path, cls_name, raw) + print("\n" + ("=" * 60)) + if failures: + print(f"双门未过:{len(failures)} 项失败") + for f in failures: + print(f" - {f}") + return 1 + print("双门全过:门① 位等价 OK / 门② 消融(计数+日志对照,touch 不再必要)OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fusion_replay_gate.py b/scripts/fusion_replay_gate.py index 1991a3be..dad07231 100644 --- a/scripts/fusion_replay_gate.py +++ b/scripts/fusion_replay_gate.py @@ -216,6 +216,40 @@ def fmt_ip(v: int) -> str: return str(ipaddress.IPv4Address(v)) +def gate_rc(c2_pass, c2_strict_n, c2_loose_n, c3_pass, c4_pass): + """rc 聚合单源(#193 C5,评审D P1-1「回放门=死门」修): + + rc 0 = 全 PASS 且语料充分;rc 1 = 任一腿真 FAIL(真 FAIL 优先于 + 语料不足——带病≠没测);rc 2 = 无真 FAIL 但语料不足/空语料 + (vacuous PASS 不冒充绿;与真 FAIL 区分,消费者可分「红」与 + 「没测成」)。返回 (rc, fail_legs, insufficient_legs);调用方 + main() 末尾照单返回(报告与机器可读摘要同步落 gate 块)。 + + 语料下限:C2 严格/宽两口径各需 N≥1(任一口径空 = 判据半 vacuous + = 没测成);C3/C4 的 pass=None(benign 语料空/ρ NaN)同律归 + insufficient。既有早退 rc:数据目录缺失=2、benign 对拍失配=3, + 与本函数分立(入口面 vs 判据面)。""" + fail_legs: list[str] = [] + insufficient_legs: list[str] = [] + if c2_pass is False: + fail_legs.append("C2") + if c2_strict_n < 1 or c2_loose_n < 1: + insufficient_legs.append("C2") + if c3_pass is False: + fail_legs.append("C3") + elif c3_pass is None: + insufficient_legs.append("C3") + if c4_pass is False: + fail_legs.append("C4") + elif c4_pass is None: + insufficient_legs.append("C4") + if fail_legs: + return 1, fail_legs, insufficient_legs + if insufficient_legs: + return 2, fail_legs, insufficient_legs + return 0, fail_legs, insufficient_legs + + def main() -> int: # 内存红线(R3-F3):4GiB 匿名堆硬顶在 main() 首行设置 —— CLI 跑语义 # 不变,而 import 本模块零副作用(测试/工具可安全 import,不钳调用方 @@ -447,6 +481,11 @@ def main() -> int: c3_rate_new = benign_ge70_new / benign_n if benign_n else None c3_pass = None if benign_n == 0 else (c3_rate_new <= c3_rate_old) c4_pass = None if (rho_old != rho_old or rho_new != rho_new) else (rho_new >= rho_old) + # rc 聚合(#193 C5):判据→退出码单源(0=全绿/1=真 FAIL/2=语料不足); + # 空/半空语料=vacuous PASS 不得冒充绿(评审D P1-1 空语料下限护栏) + rc, fail_legs, insufficient_legs = gate_rc( + c2_pass=c2_pass, c2_strict_n=c2_strict_n, c2_loose_n=c2_loose_n, + c3_pass=c3_pass, c4_pass=c4_pass) def bucket(h: list[int]) -> list[int]: return [sum(h[b * 10:(b + 1) * 10]) for b in range(11)] # 0-9,...,90-99,100 @@ -481,6 +520,8 @@ def bucket(h: list[int]) -> list[int]: "movers70": {"n": n_movers70, "n_down": n_movers_down, "dump": args.dump_movers or None}, "k_hist_top": dict(sorted(k_hist.items(), key=lambda kv: -kv[1])[:8]), + "gate": {"rc": rc, "fail_legs": fail_legs, + "insufficient_legs": insufficient_legs}, } # ── Markdown 报告 ── @@ -534,6 +575,12 @@ def pct(x): f"vs 新 {summary['C4']['rho_all_new']}") lines.append(f"- k 分布 top: {summary['k_hist_top']}") lines.append("") + lines.append("### 门裁决(#193 C5 rc 机检化)") + lines.append("") + lines.append(f"- **rc={rc}**(0=全 PASS 且语料充分 / 1=真 FAIL / 2=语料不足)" + f";FAIL 腿: {fail_legs or '无'};语料不足腿: {insufficient_legs or '无'}" + ";真 FAIL 优先于语料不足") + lines.append("") lines.append("## 分数直方图(10 分桶,指控并集)") lines.append("") lines.append("| 分数段 | 旧(选角) | 新(融合) |") @@ -571,12 +618,17 @@ def pct(x): log(f"报告已写: {args.report}") else: print(report) + why = ";".join(x for x in ( + ("真 FAIL: " + ",".join(fail_legs)) if fail_legs else "", + ("语料不足: " + ",".join(insufficient_legs)) if insufficient_legs else "", + ) if x) log(f"完成: {elapsed:,.0f}s | VmHWM {hwm_kb/1e6:.2f}GB | " f"C2={'PASS' if c2_pass else 'FAIL'} " f"C3={'PASS' if c3_pass else 'FAIL' if c3_pass is not None else 'N/A'} " - f"C4={'PASS' if c4_pass else 'FAIL' if c4_pass is not None else 'N/A'}") - return 0 - + f"C4={'PASS' if c4_pass else 'FAIL' if c4_pass is not None else 'N/A'} " + f"| 门裁决 rc={rc}" + + (f"({why})" if why else "")) + return rc if __name__ == "__main__": diff --git a/scripts/monthly_eval.sh b/scripts/monthly_eval.sh index 07db2989..0c28fac2 100755 --- a/scripts/monthly_eval.sh +++ b/scripts/monthly_eval.sh @@ -103,23 +103,66 @@ if [ "$DRY_RUN" != 1 ]; then python3 -m ipdb._eval.mirror_check "$DATA_DIR" "$MIRROR_MANIFEST" ) fi -run_step model --model # 舰队模型 + 验收套件(落盘本轮 model-*.json) -run_step all --all # 逐源 verdict 表 -# anchors 腿:失败不阻断后续腿(终审修单 F4)——回归红态记录≠管线中止: -# 锚点红时工件照写(anchors_report 失败也落盘 anchors-*.json,证据不灭), -# rc 捕获进运行日志红态行,readout/月报/北星与心跳照常,结尾以此 rc 退出 -# (红态外显不吞);其余腿 set -e 语义不变(仍缺员即死不推心跳)。 -# 守卫=tests/eval/test_monthly_eval_cascade.py(密闭 harness:桩腿红绿双态)。 -ANCHORS_RC=0 -run_step anchors --anchors || ANCHORS_RC=$? # 锚点回归(判定门:失败 rc=1 照写工件) -if [ "$DRY_RUN" != 1 ] && [ "$ANCHORS_RC" != 0 ]; then - echo "!! anchors 腿 rc=$ANCHORS_RC:回归红态(anchors-*.json 已照写留证)——红态记录≠管线中止,后续腿照跑,结尾外显此 rc" -fi -run_step dsem --dsem # DS-EM 公平对决(advisory) -run_step agg-share --agg-share # 印证对聚合器占比披露(#183;只测量不持门,exit 恒 0) -run_step audit --audit # 谱系前向流审计(读 model 历史) -run_step temporal --temporal # λ_s 确认率(读 model 历史,须 ≥2 轮) -run_step readout --readout # 月轮四层读数(§4.2/S26: 资产+四层读数+行动区块+月报) +REPORT_ROOT="$REPO/backend/data/eval/monthly" +# ── 腿级 rc 分离(#193 C5,承 F4 anchors 判例推广到全腿):任一腿红 +# 不中止后续腿(红态记录≠管线中止),工件照写留证;缺员即死不变 +# (工件缺席=半途中止=死,不推心跳)。腿级状态落 leg-status-*.json +# (报告根,机器可读:render 前件 readout=null 供月报嵌「在飞」,结尾 +# 定稿;_alerts 只认心跳 mtime,腿级红必须长在工件里,评审D P1-3)。 +# 结尾退出码=腿序首个非零(红态外显不吞)。 +LEG_ORDER=(model all anchors dsem agg-share audit temporal readout) +declare -A LEG_RC=() +for _leg in "${LEG_ORDER[@]}"; do LEG_RC[$_leg]=0; done +run_step model --model || LEG_RC[model]=$? # 舰队模型+验收套件(model-*.json) +run_step all --all || LEG_RC[all]=$? # 逐源 verdict 表 +run_step anchors --anchors || LEG_RC[anchors]=$? # 锚点回归(判定门:失败 rc=1 照写工件) +run_step dsem --dsem || LEG_RC[dsem]=$? # DS-EM 公平对决(advisory) +run_step agg-share --agg-share || LEG_RC[agg-share]=$? # 印证对聚合器占比披露(#183;只测量不持门,exit 恒 0) +run_step audit --audit || LEG_RC[audit]=$? # 谱系前向流审计(读 model 历史) +run_step temporal --temporal || LEG_RC[temporal]=$? # λ_s 确认率(读 model 历史,须 ≥2 轮) + +write_leg_status() { # write_leg_status <readout_rc|null> — 腿级状态件(#193 C5) + # 落报告根 leg-status-<ts>.json(ts 形制同族);all 腿校验须排除本件 + # (同形状不得顶替逐源 verdict 件,观测件永不触活族门)。 + [ "$DRY_RUN" = 1 ] && return 0 + local path="$REPORT_ROOT/leg-status-$(date -u +%Y%m%d-%H%M%S).json" + local argv=("$path") + local leg rc="readout=$1" # readout 位由调用方显式给(null=在飞) + for leg in "${LEG_ORDER[@]}"; do + if [ "$leg" = readout ]; then argv+=("$leg" "$1"); else argv+=("$leg" "${LEG_RC[$leg]}"); fi + done + python3 - "${argv[@]}" <<'PYEOF' +import json, sys +from datetime import datetime, timezone +from pathlib import Path +path = Path(sys.argv[1]) +legs = {} +for i in range(2, len(sys.argv), 2): + name, rc = sys.argv[i], sys.argv[i + 1] + legs[name] = None if rc == "null" else int(rc) +doc = { + "schema": "leg-status/1", + "generated_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "legs": legs, + "red_legs": sorted(k for k, v in legs.items() if v not in (0, None)), +} +path.write_text(json.dumps(doc, ensure_ascii=False, indent=1) + "\n", + encoding="utf-8") +PYEOF + echo "==> 腿级状态件: $path" +} + +# render 前件:readout 腿在飞(null)——月报 render_leg_status 读最新件嵌行 +write_leg_status null +run_step readout --readout || LEG_RC[readout]=$? # 月轮四层读数(§4.2/S26)月报+北星 +write_leg_status "${LEG_RC[readout]}" # 定稿件(机器可读面完整) + +# 红态行(红腿逐个点名,输出面可见;证据在工件,此处外显不吞) +for _leg in "${LEG_ORDER[@]}"; do + if [ "$DRY_RUN" != 1 ] && [ "${LEG_RC[$_leg]}" != 0 ]; then + echo "!! $_leg 腿 rc=${LEG_RC[$_leg]}:红态记录(工件照写留证)——红态记录≠管线中止,腿级状态入 leg-status 工件,结尾外显此 rc" + fi +done # ── S5(裁决 A / spec §4.1):报告族齐全校验 → 心跳推送 ── # 七命令齐全=活:七写者均查本轮工件(mtime 晚于脚本启动)。audit/anchors @@ -131,7 +174,6 @@ run_step readout --readout # 月轮四层读数(§4.2/S26: 资产+四层读数 # 缺员即死(半途中止=死,不推心跳)。心跳落生产 volume,生产侧 # _alerts 只认文件 mtime(IP_RADAR_EVAL_HEARTBEAT,>35d 告警;时基 # 独立 S6,不信管线自报)。 -REPORT_ROOT="$REPO/backend/data/eval/monthly" HEARTBEAT_DST="/var/lib/docker/volumes/ip-lookup-tool_ipradar-data/_data/eval-heartbeat" check_family() { # check_family <label> <subdir> <name-pattern> [排除pattern...] local label=$1 sub=$2 pat=$3; shift 3 @@ -139,7 +181,11 @@ check_family() { # check_family <label> <subdir> <name-pattern> [排除pattern. echo " [dry-run] 报告族: $label -> ${sub:+$sub/}$pat${*:+ 排除 $*}" return 0 fi - local args=(-name "$pat") + # #193: -maxdepth 1 —— find 默认递归会让 model/ 子目录同形状件(如 + # agg-share-<ts>.json)顶替根 all 腿校验;各腿工件只落自身层, + # 限深=逐腿只在自身层验在场(腿级状态件同形状防顶替,观测件永不 + # 触活族门)。 + local args=("-maxdepth" "1" -name "$pat") local excl for excl in "$@"; do args+=(! -name "$excl"); done local f @@ -159,7 +205,7 @@ check_family model model 'model-*.json' # all 腿排除: temporal-*/northstar-* 同带 ts 后缀形状但不属逐源 verdict # 件 —— northstar(#185 能力轨观测件)若不排除, 会顶替 all 腿满足校验 # → 回归族判定被观测件稀释(观测件永不持门, 亦不得反向触活报告族门)。 -check_family all '' '*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9].json' 'temporal-*' 'northstar-*' +check_family all '' '*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9].json' 'temporal-*' 'northstar-*' 'leg-status-*' check_family anchors model 'anchors-*.json' check_family dsem model 'dsem-*.json' check_family agg-share model 'agg-share-*.json' @@ -176,7 +222,12 @@ else fi echo + echo "完成。报告目录: $REPORT_ROOT(model/ 逐轮累积)" -# 结尾退出码:anchors 红态外显(完成≠全绿;cron/操作员以非零退出码感知 -# 回归轨红,详见 CHANGELOG「回归红态记录不等于管线中止」句)。 -exit "$ANCHORS_RC" +# 结尾退出码(#193 C5):腿序首个非零外显(完成≠全绿;cron/操作员以 +# 非零退出码感知腿级红;红腿明细在 leg-status 工件与月报腿级行)。 +AGG_RC=0 +for _leg in "${LEG_ORDER[@]}"; do + if [ "${LEG_RC[$_leg]}" != 0 ]; then AGG_RC=${LEG_RC[$_leg]}; break; fi +done +exit "$AGG_RC"