diff --git a/GLOSSARY.md b/GLOSSARY.md index 277ce04d..115cb062 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -151,6 +151,13 @@ - **失败归因图**: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/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) +- **conversation**:会话——不可变 transcript 记录的宿主与提交面;运营轮宿主 = root conversation,子代理 = 自有 conversation(不共享主轮上下文)。Pi Durable 域词(ADR 0012)。锚:docs/adr/0012(嵌套 docs 仓) +- **task**:持久任务——相位化的工作单元,提交后由框架驱动至终态、进程崩溃可续;ops 管线轮(健康十态/发现六步)整体相位化为 task,失败三元组(重试/降级/终态)落 task 终态语义。Pi Durable 域词(ADR 0012)。锚:docs/adr/0012(嵌套 docs 仓) +- **checkpoint**:task 相位的持久快照——崩溃恢复的最小续跑点;v2 起替代手搓轮次状态文件(该词条记旧管线)。Pi Durable 域词(ADR 0012)。锚:docs/adr/0012(嵌套 docs 仓) +- **replay:safe**:工具崩溃恢复重放的安全声明(缺省 unsafe)——声明该工具重放不产生重复副作用,责任在工具作者;带外部写副作用的工具不得声明。Pi Durable 域词(ADR 0012)。锚:docs/adr/0012(嵌套 docs 仓) +- **轮次**:运营 agent 的一次完整任务运行(健康轮/发现轮同此基义;复合词轮次信封/轮次状态机沿用)——Pi Durable 迁移定稿(ADR 0012):一轮 = 对 root conversation 的一次 submit(发现轮例外:一轮 = 一 ownerless 会话,tasks/discover-round.mjs:16-24 决策——旧发现轮每模型步全新 Agent 上下文、跨轮零渗漏旧律保持)。锚:docs/adr/0012(嵌套 docs 仓)、docs/superpowers/specs/2026-10-28-ops-agent-pidurable-migration-design.md(术语映射) +- **台账**:运营轮历史的留痕真源——旧管线自维护 ledger.md/queue.md;Pi Durable 迁移定稿(ADR 0012)= storage 里的 transcript+task 记录,ledger.md 降级为兼容输出面,不再作真源(RETRO 兼容行照写,见 ops/agent-v2/ENVELOPE.md),对下游的兼容输出(月轮评估)由 T5 承接;UI 别名「台账→历史」(ADR 0006)不变。锚:docs/adr/0012(嵌套 docs 仓) +- **熔断**:通道失败的开断机制,细则与五通道闭集见「跨轮熔断」——Pi Durable 迁移定稿(ADR 0012):仍为自有 breaker.json 文件、读写逻辑随迁,不 hook/memo 化;与预算四界同属自持执法面。锚:docs/adr/0012(嵌套 docs 仓)、ops/agent/tools/breaker.mjs ## It's working if diff --git a/ops/agent-v2/ENVELOPE.md b/ops/agent-v2/ENVELOPE.md new file mode 100644 index 00000000..ebc47278 --- /dev/null +++ b/ops/agent-v2/ENVELOPE.md @@ -0,0 +1,33 @@ +# ENVELOPE.md — 轮次信封契约(冻结面) + +票 #229 T5 任务书②裁定。本文件冻结 cron 入口 stdout 的信封形状与 usage 口径;动此面前先过回放闸门(golden + shadow 对拍),否则视为破坏位等价红线。 + +## stdout 契约(cron 面,`run-health.mjs` / `run-discover.mjs` 同律) + +- stdout **末行 = 单行 JSON 旧形信封**,是 stdout 上唯一 JSON;wrapper 进度信息一律走 stderr(`[v2-health]` / `[v2-discover]` / `[stage]` / `[tool]` / `[admission]` 行)。 +- 旧形 = 与 `ops/agent/pipeline.mjs` 信封逐字段同族;形状由对拍测试钉死(round-ctx `composeEnvelope` 逐字副本 + discover 第二副本,golden/shadow 双闸),cron 消费方(脚本、看门狗)零改动。 + +| 面 | 成功 | 失败/拒跑 | +|---|---|---| +| 形状 | `{ok:true, round, pkg, usage, queue_writes, verify, pipeline}` | `{ok:false, round, error:{code,message}, pkg?}` | +| health | `round:"health"`,`pipeline:{round_id, resumed_from, replayed_of, stages, finding_exits?}` | 拒跑 `code:"budget_monthly"`;运行期失败码闭集同旧(ERROR_CODES) | +| discover | `round:"discover"`,六步 stages | 同上 | + +失败信封差异注:旧 pipeline 轮失败信封含 `pipeline` 字段(composeEnvelope 失败分支),v2 失败信封无——已声明差异面(见 SHADOW-RUNBOOK §已知差异);`error.code` 语义同闭集九词。 + +- exit 码表(两 launcher 同律):`0`=completed;`1`=failed/refused/parked;`2`=用法错;`3`=运行期异常(launcher 自身崩溃,非轮内失败)。 +- `--to-phase`(health 专属,旧 replay `--to` 镜像):停笔相位完成后终态 completed,信封 stages 止于该相位;默认 CLOSE 全链。 + +## usage 口径裁定(定案②) + +**信封与台账的 usage = 轮累计事件流口径**:轮内全部模型事件(含子任务、缓存命中)累加,轮结束一次性结算。取数机制按入口分面:发现轮 = 会话 UsageDoc 快照求和(`discover-round.mjs roundUsage`);健康轮 = 逐子任务 result 求和(`health-round.mjs` WAIT_MODEL 收口);entry 骨架 = `harness.usage()` 差分。三面共同点 = 轮累计事件流口径(与旧管线同义);pi-durable 的 UsageDoc 对 entry 面仅作 harness 内部观察面。 + +已知计量边界差(与旧管线对拍时,见 SHADOW-RUNBOOK §已知差异):v2 的 `in` 含 cache_read 且 `total` 再计一次缓存面,故 faux 同轮 v2 数字 ≥ 旧面;**cost 细胞不受影响**(价目表按 token 类分别计价,faux 恒 0,真实轮 cost 独立求和),台账 usage 三计数(in/out/total)在 shadow 对拍中归一,cost 逐字比。 + +## 台账兼容行(月度准入消费面) + +成功轮台账行由 RETRO 步写(与旧管线同点、同形):`| 日期 | 轮次 | model | in=N out=N total=N cost=0.00000000 | pipeline: |`。月度准入(`monthSpendOfLedgerText`)与前端 `opsLedger.ts` 解析面零改动——v2 轮天然流出兼容行,`tests/cron-launchers.test.mjs` 与 `tests/shadow-health-parity.test.mjs` 双钉。 + +## queue.md 兼容面(发现轮;终审补声明) + +spec 定案 4 裁「ledger/queue 不再自维护,对下游兼容输出由 T5 保证」:v2 发现轮 ENQUEUE 步零复制 `steps.runStage` 仍写 `queue.md` PROPOSE 行(同点同形,golden 对拍硬断言钉住)——与台账同属对下游兼容输出面,消费方(人工审阅队列)零改动。健康轮不写 queue(旧同律)。 diff --git a/ops/agent-v2/SHADOW-RUNBOOK.md b/ops/agent-v2/SHADOW-RUNBOOK.md new file mode 100644 index 00000000..3cafdf5c --- /dev/null +++ b/ops/agent-v2/SHADOW-RUNBOOK.md @@ -0,0 +1,55 @@ +# SHADOW-RUNBOOK.md — shadow 期运行手册(票 #229 T5 任务书④) + +spec 定案 2 安全轨的运维面:旧管线原地不动,v2 在 `ops/agent-v2/` shadow 跑满一月轮周期,golden + 新旧产出对拍一致才切流。**切流时点由用户裁决,本手册不自动切。** + +## 观察面(每轮 cron 后) + +```bash +# 对拍闸门(罐装世界,零网络,~1min):旧 replay vs v2 checkpoint 任务 +node ops/agent-v2/shadow-diff.mjs # 退出 0=全 SAME +# 全量测试套(46 测,含 parity + cron-launchers + E2E + SIGKILL resume + 重试/跨库/exit 码面) +cd ops/agent-v2 && node --test 'tests/*.test.mjs' +``` + +双轨 cron 形态(shadow 期):旧 `pipeline.mjs` cron 照跑(生产真源);v2 侧双轮型——健康轮 `run-health.mjs` 每日一轮、发现轮 `run-discover.mjs` 按旧发现轮节拍(周频/手触),各 `--db` 独库(缺省 `docs/ops/agent-v2.sqlite`),轮 id 独立命名,台账/queue 各自世界——**shadow 期 v2 不写生产台账与 queue、不进生产轮目录**。操作面差异:发现轮 real 面强制 `--model provider/modelId` 显式(旧管线自动 resolve;模型清单见 models.mjs),健康轮缺省自动 resolve 同旧。 + +## SAME 门槛 + +- `shadow-diff.mjs` 退出 0(可比工件闭集 + 台账行,归一化窄面见其头注)。 +- 全量测试套 46/46 绿。 +- **golden 全集新旧逐条一致(票 #232,切流硬门槛)**:当前分支覆盖 1 例(discover full faux)+罐装健康截段(shadow-diff);全集(neg/boundary/health_full_faux 等 ~10 例)补齐前不得切流。 +- 连续一个完整月轮周期内:零 infra 信封(v2 侧)、零对拍 DIFF、SIGKILL/断电恢复演练至少一次通过(`tests/resume.test.mjs` + 人工 kill 面板)。 + +## 已知差异面(声明,非缺陷) + +| 差异 | 性质 | 处置 | +|---|---|---| +| v2 健康轮无 state.json(真源=SQLite checkpoint+工件;仅发现轮落 state.json 兼容旧面) | 实现细节 | 不入对拍面 | +| `.v2-claim.json`(v2 健康轮轮目录认领件;跨库撞轮守卫) | v2 专属守卫落件 | 不入对拍面(shadow-diff NEW 侧白名单已列入) | +| 09-budget.json 旧 CLOSE 也落件(非 v2 专属);真实差异=对拍载具双侧停笔 RETRO 故均不落 | 载具结构 | CLOSE 面落件由 `tests/cron-launchers.test.mjs` 钉(09-budget 存在性断言) | +| 台账 usage 计数(in/out/total)计量边界差 | 两套 harness 合法差 | 对拍归一;cost 细胞逐字比(ENVELOPE.md §usage) | +| 对拍载具停笔 RETRO(旧 replay 不入 CLOSE) | 载具结构 | CLOSE 面由 `tests/cron-launchers.test.mjs` 钉(G_CLOSE/09-budget/信封) | +| PROBE/VERIFY 不入罐装对拍(真探测面,零网络红线) | 载具结构 | 两侧均为零复制 `steps.runStage` 同码路;生产 v2 轮真跑 | + +## 影子世界置备(shadow 期轮跑前置) + +shadow 期 v2 侧 `DOCS_ROOT` 指影子根(与生产 `docs/ops` 分置),防误指生产写脏。置备=从生产 rsync 一份再清轮次状态: + +```bash +mkdir -p <影子根>/docs && rsync -a docs/ops/ <影子根>/docs/ops/ +rm -rf <影子根>/docs/ops/rounds/ <影子根>/docs/ops/ledger.md <影子根>/docs/ops/queue.md # 轮次目录/台账/队列清空——v2 侧从零起卷 +# precedents/ rules/ items/ 保留(判例/规则/条目库是轮次输入面,随卷携带) +``` + +v2 侧 cron 起轮时显式带 `DOCS_ROOT=<影子根>/docs`(与 `--db` 影子库同理);指向生产根属配置事故——认领件/台账/queue 会写脏生产树。 + +## 回退 + +v2 无生产流量 → 回退=停 v2 cron 即可,零数据迁移。SQLite 库/轮目录留在原地供事后审计(证据永不灭失)。 + +## 切流(用户裁决后) + +1. 前置:上表 SAME 门槛全满足,用户明示切流。 +2. 切换 cron:旧 `pipeline.mjs` cron 下线,`run-health.mjs` 接同一轮 id 序列与生产台账世界(`DOCS_ROOT`/`LEDGER_FILE` 指生产)。 +3. 首两周双观察:v2 信封/台账行抽查 + 月度准入读数对拍(月帽口径 ENVELOPE.md)。 +4. 观察期满后旧管线代码退役(单独 PR,先归档后删)。 diff --git a/ops/agent-v2/breaker.mjs b/ops/agent-v2/breaker.mjs new file mode 100644 index 00000000..4125b556 --- /dev/null +++ b/ops/agent-v2/breaker.mjs @@ -0,0 +1,19 @@ +// breaker.mjs — v2 熔断面零复制 re-export(票 #227/#228 并批;两票 seam 同源不同面): +// T3(#227)持写侧——发现轮通道工具 defineTool 化后,通道调用照旧律 record verdict +// (成功/失败计通道计数,阈值触发熔断;实际接线=tools/discovery-tools.mjs +// mkDiscoveryHost 适配器 finish 内 breaker.record,host 零复制 import 旧管线); +// T4(#228)只接 CLOSE 相位快照**读面**(readBreakerKnobs→09-budget.json 观察面, +// 只读零写,回声律:不造文件),写侧不归它。 +// 零复制判例同 budget.mjs(票 #226):旧文件只依赖 node 内建,直接 re-export,单一真源, +// 执法/快照语义一字不改(五通道闭集/counted codes/阈值冷却旋钮/原子写/fail-closed +// reset/events ring ≤20 全在旧文件,升级/对拍都以它为准)。 +// 本 seam re-export 面(两票接口契约,勿另起炉灶): +// BREAKER_CHANNELS(五通道闭集:fetch_url/gh_search/rss_parse/web_read/web_search) +// BREAKER_COUNTED_CODES(channel_auth/channel_missing/quota) +// BREAKER_STATE_VOCAB、readBreakerKnobs(env)(OPS_BREAKER_THRESHOLD/OPS_BREAKER_COOLDOWN_S) +// emptyBreakerState、parseBreakerState、verdictFor、grantProbe、applyResult +// breakerSnapshot、mkBreaker({file,env,round,ledgerFile,knobs,log}) +// → { file, knobs, verdict(name), record(name,{ok,code}), snapshot() } +// 状态文件 = breakerFileOf(ctx)(discovery-host.mjs 单源)= DOCS_ROOT/ops/breaker.json。 +// 旧管线文件属主:ops/agent/ 零改动红线(票 #225/#226/#228)——本文件只 re-export,不 fork。 +export * from "../agent/tools/breaker.mjs"; diff --git a/ops/agent-v2/budget.mjs b/ops/agent-v2/budget.mjs new file mode 100644 index 00000000..c57d678c --- /dev/null +++ b/ops/agent-v2/budget.mjs @@ -0,0 +1,12 @@ +// budget.mjs — v2 预算面零复制 re-export(票 #226;spec 定案 3:budget.mjs **原样**搬进 v2 入口 +// 调用,不改 extension/hook 化;跑稳后另议)。 +// 零复制可行性已核:旧管线 ops/agent/pipeline/budget.mjs 只 import node 内建 +// (fs/path/child_process/util/url/crypto),零旧管线内部耦合 → 直接 re-export, +// 与 models.mjs(定案 7)同一模式,单一真源;执法语义一字不改(拒跑文案/容差/宁少算口径 +// 全在旧文件,升级/对拍都以它为准)。若将来旧文件长出内部耦合致无法直 import,才允许 +// 整文件拷贝并在文件头注 why+出处,票面须写明(本票未触发该兜底)。 +// 接线(budget-guard.mjs/entry.mjs,定案 3 双点):步前准入(月度 kill switch)+ 环内 +// 检查(四界 attempt 前/结算后/步边界)——不 hook/memo 化,与旧管线 pipeline.mjs 检查点 +// 同构;同参哈希面(dupKey/dupRecord)由此 seam 供 T3/T4 工具宿主注入(同旧 tools.mjs 面)。 +// 旧管线文件属主:ops/agent/ 零改动红线(票 #225/#226)——本文件只 re-export,不 fork。 +export * from "../agent/pipeline/budget.mjs"; diff --git a/ops/agent-v2/entry.mjs b/ops/agent-v2/entry.mjs new file mode 100644 index 00000000..0449791e --- /dev/null +++ b/ops/agent-v2/entry.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// entry.mjs — cron 式入口(票 #225;spec 定案 5:cron 起进程 → Harness.open(SQLite) +// → submit 轮次 → 跑完退出,无常驻进程;崩溃=下个 cron tick 起进程 resume 续跑)。 +// 薄适配层(定案 6):框架面只在本文件 + registry.mjs + tasks/ 收口。 +// +// 用法:node entry.mjs [--content TEXT] [--db PATH] [--model provider/modelId] +// [--round health|discover] [--stage NAME] [--crash-after-submit] +// roundId 轮次标识(月轮日期等;进 requestId 幂等键) +// --content 轮次输入(骨架期必填;真实轮次内容/任务由 T3/T4 的 defineTask 接管) +// --db SQLite 库路径(缺省见 resolveDbPath;测试/复跑同库同 requestId) +// --crash-after-submit submit 承认即 SIGKILL 自杀(resume 演示/测试面) +// 退出码:0=settled done;1=settled unanswered;2=用法错。 +// 契约:stdout 末行单行 JSON 轮次信封(骨架形状,T5 冻结对齐旧契约);进度走 stderr。 +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { Harness } from "@earendil-works/pi-durable"; +import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; +import { loadModels, parseModelRef } from "./models.mjs"; +import { buildRegistry } from "./registry.mjs"; +import { admitRound, postModelCall, preModelCall, stepBoundary, sumUsageState } from "./tasks/budget-guard.mjs"; // 票 #226 双点执法接线(定案 3:entry/tasks 层,不 hook 化) + +export const ROOT = path.resolve(import.meta.dirname, "..", ".."); // 仓根(与 ops/agent/tools/shared.mjs 同推导) + +// SQLite 落位(t1-notes 建议3):DOCS_ROOT/ops/ 派生面(与 rounds/items 同树规则); +// OPS_V2_DB 覆盖(测试隔离)。journal=WAL+synchronous=NORMAL 由 chord 适配器缺省 +// 设置(node_modules/@earendil-works/chord/dist/sqlite/node.js:159-161)——进程崩溃 +// commit 安全,断电可能丢最新一笔(轮次粒度可接受,api-map §0)。 +export function resolveDbPath(env = process.env) { + if (env.OPS_V2_DB) return env.OPS_V2_DB; + const docsRoot = env.DOCS_ROOT ?? path.join(ROOT, "docs"); + return path.join(docsRoot, "ops", "agent-v2.sqlite"); +} + +// requestId 纪律(t1-notes 风险12):`round-[-]` 形,cron 重启幂等 +// 靠它(同键重放返回既有 submission,零新模型调用;T0 smoke 已证)。 +export function requestIdFor(roundId, stage) { + return stage ? `round-${roundId}-${stage}` : `round-${roundId}`; +} + +// t1-notes 风险5:DEFAULT_COMPACTION_POLICY 的自动 compaction 会摘要改写上下文, +// shadow 对拍期显式关(golden 稳定后另议);retry/restartLimit 用缺省。 +export const HARNESS_SETTINGS = { compaction: { enabled: false } }; + +export async function openHarness(opts = {}) { + const dbPath = opts.dbPath ?? resolveDbPath(); + const bundle = opts.models ?? (await loadModels()); // 旧管线装载器原样(定案 7;凭证零入日志);opts.models 注入缝=faux/金轨迹面(同旧 agent.mjs opts.models 缝) + const { models, resolve } = bundle; + const storage = await openNodeSqliteStorage(dbPath); + const harness = await Harness.open(storage, { + models, + registry: opts.registry ?? buildRegistry(), + settings: HARNESS_SETTINGS, + }, BACKGROUND_CONTEXT); + return { harness, models, resolve }; +} + +const lastAssistantText = (view) => (view.entries.filter((e) => e.kind === "pi.assistant").at(-1)?.model ?? []) + .flatMap((m) => m.content ?? []).find((b) => b.type === "text")?.text ?? ""; + +// 一轮 = root conversation 一次 submit(spec 术语映射「轮次」;root 复用 +// ROOT_CONVERSATION_ID,月轮→conversation 粒度是 T3/T4 决策,t1-notes 建议4)。 +// 双点执法(票 #226;定案 3,旧 pipeline.mjs 检查点同构):步前准入(月度 kill switch, +// 拒跑零花费先于 harness 开启)+ 环内检查(步前①③、结算后②④;轮增量口径见 +// budget-guard.sumUsageDoc 注)。违界即停不重试(宁少算),已花 token 证据照留信封(哲学①)。 +export async function runRound(opts) { + const env = opts.env ?? process.env; + const roundType = opts.round ?? "health"; + const log = (l) => process.stderr.write(`${l}\n`); + const requestId = opts.requestId ?? requestIdFor(opts.roundId, opts.stage); + // —— 步前准入(月帽 ¥30 ∧ est;拒跑留痕在旧 admitOrRefuse 内,原样)—— + const admission = await admitRound({ env, log, roundType, ledgerFile: opts.ledgerFile }); + if (admission.refused) + return { round: opts.roundId, requestId, status: "refused", code: admission.code, message: admission.message, + budget: { admission: admission.admission } }; + // —— 环内·步前检查(相位边界③ + 首次 attempt ①③;轮始 elapsed≈0,零帽面拒)—— + const startedAtIso = new Date().toISOString(); // 墙钟起算点(旧 state.started_at 同位;相位级 startedAt 由 T3/T4 checkpoint 持有) + const pre = [stepBoundary({ roundType, knobs: admission.knobs, startedAtIso }), + preModelCall({ roundType, knobs: admission.knobs, modelCalls: 0, startedAtIso })].find((d) => !d.ok); + if (pre) + return { round: opts.roundId, requestId, status: "failed", code: pre.code, message: pre.message, + budget: { knobs: admission.knobs } }; + const { harness, models, resolve } = await openHarness(opts); + const context = BACKGROUND_CONTEXT; + harness.resume(); // 幂等:崩溃遗留提交/任务续跑(submit/wait 亦自动使能调度) + try { + const model = opts.modelRef + ? models.getModel(parseModelRef(opts.modelRef).provider, parseModelRef(opts.modelRef).model) + : resolve(opts.round ?? "health"); // 缺省 flash 档(OPS_MODEL_HEALTH 可覆盖,同源) + if (!model) throw new Error(`模型未装载:${opts.modelRef ?? opts.round ?? "health"}`); + // root 会话显式零选装(agent.js selectExtensions:未存名单时缺省=全部已装;registry + // 装发现轮工具扩展后不钉空=root 骨架面静默获得 web 三工具,违背「发现工具仅发现轮选装」) + const root = await harness.root(context, { agent: { model: { provider: model.provider, modelId: model.id }, extensions: [] } }); + const usageBefore = sumUsageState(await harness.usage(context)); // 轮增量基线(环内口径,预算自算保留) + process.stderr.write(`[v2] submit requestId=${requestId} model=${model.provider}/${model.id}\n`); + const sub = await root.submit({ type: "input", content: opts.content, requestId }, context); + if (opts.crashAfterSubmit) { + process.stderr.write(`[v2] submit 承认(id=${sub.id})即 SIGKILL 自杀——resume 演示面\n`); + process.kill(process.pid, "SIGKILL"); + } + const settled = await sub.wait(context); + const usageState = await harness.usage(context); + const after = sumUsageState(usageState); + const roundUsage = { totalTokens: after.totalTokens - usageBefore.totalTokens, costCny: after.costCny - usageBefore.costCny }; + // —— 环内·结算后查(②④;违界即停:status 翻 failed+code,证据照留)—— + const post = postModelCall({ roundType, knobs: admission.knobs, totalTokens: roundUsage.totalTokens, costCny: roundUsage.costCny }); + if (!post.ok) log(`[v2] budget ${post.code}: ${post.message}`); + const view = await root.context(context); + const envelope = { + round: opts.roundId, + requestId, + status: post.ok ? settled.status : "failed", + model: `${model.provider}/${model.id}`, + answer: lastAssistantText(view), + usage: usageState, + ...(post.ok ? {} : { code: post.code, message: post.message }), + budget: { round_usage: roundUsage, admission: admission.admission }, + }; + process.stderr.write(`[v2] settled status=${envelope.status}\n`); + return envelope; + } finally { + await harness.close(context); + } +} + +// ---- CLI(cron 面)---- +function parseArgs(argv) { + const [roundId, ...rest] = argv; + const opts = { roundId }; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "--content") opts.content = rest[++i]; + else if (a === "--db") opts.dbPath = rest[++i]; + else if (a === "--model") opts.modelRef = rest[++i]; + else if (a === "--round") opts.round = rest[++i]; + else if (a === "--stage") opts.stage = rest[++i]; + else if (a === "--crash-after-submit") opts.crashAfterSubmit = true; + else return { error: `未知参数:${a}` }; + } + if (!roundId) return { error: "缺 roundId" }; + if (!opts.content) return { error: "缺 --content(骨架期必填;真实轮次内容由 T3/T4 任务面接管)" }; + return { opts }; +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.error) { + process.stderr.write(`用法错:${parsed.error}\nnode entry.mjs --content TEXT [--db PATH] [--model provider/modelId] [--round health|discover] [--stage NAME] [--crash-after-submit]\n`); + process.exitCode = 2; // exitCode 而非 exit:stdout 写入异步落管,process.exit 可截断单行信封(旧 pipeline.mjs:547 判例) + return; + } + process.stderr.write(`[v2] harness open db=${parsed.opts.dbPath ?? resolveDbPath()}\n`); + let envelope; + try { + envelope = await runRound(parsed.opts); + } catch (e) { + if (e?.usage) { process.stderr.write(`${e.message}\n`); process.exitCode = 2; return; } // 旋钮/est 非法=用法错 exit 2 不带病跑轮(旧 agent.mjs main 同判例);exitCode 判例同上 + throw e; + } + process.stdout.write(`${JSON.stringify(envelope)}\n`); // stdout 末行单行信封(T5 冻结形状) + process.exitCode = envelope.status === "done" ? 0 : 1; +} + +// 直接执行(非 import)才跑 CLI;测试 import runRound/openHarness 不触发。 +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/ops/agent-v2/faux-face.mjs b/ops/agent-v2/faux-face.mjs new file mode 100644 index 00000000..e4735e1d --- /dev/null +++ b/ops/agent-v2/faux-face.mjs @@ -0,0 +1,57 @@ +// faux-face.mjs — faux 面分派接线(票 #228 修复 ⑥;审查 P2-4 最小接面:只接 faux +// provider 分派,不动 T5 台账兼容面)。旧真源 = ops/agent/pipeline/faux.mjs(零改动 +// 红线;loadFixture/transcriptName/fauxProvider 装配是私有面不 export → 本文件按旧源 +// 逐字重实现,行型/路径/判例位等价,出处逐处标注): +// fixture 路径/缺件判例 = 旧 faux.mjs loadFixture(faux.mjs:69-79) +// provider 装配 + 罐装回合 = 旧 runFauxStep(faux.mjs:76-79;models id 同式 +// `faux-${def.model 斜杠转横线}`);v2 偏差:provider 名加轮次/步/假设唯一后缀—— +// 旧每步 new Agent 自带 Responses 队列,v2 多子任务并行共用一个 harness Models 实例, +// 不加唯一名则 setProvider 同名互覆、罐装回合串台(偏差理由,行型不受影响)。 +// registry_seed 行(call_id=sd.id)= 旧 runFauxStep transcript 行(faux.mjs:91); +// 真面 call_id=sd.call_id(faux.mjs:137)归 model-round 直读宿主,不在本文件。 +// transcript 文件名 = 旧 faux.mjs transcriptName(faux.mjs:59-62;工件名常量 +// 单源 artifacts.mjs,deep 族 deepTranscriptName 同源)。 +import fs from "node:fs"; +import path from "node:path"; +import { fauxProvider } from "@earendil-works/pi-ai"; +import { hidSlug } from "../agent/pipeline/steps.mjs"; +import { ARTIFACTS as A, deepTranscriptName } from "../agent/artifacts.mjs"; + +// 旧 faux.mjs loadFixture 逐字副本(v2 面:fixturesDir 直传非 ctx;缺席=infra 配置面 +// 失败,与旧同判例——faux 面 fixture 是必件不是可选件)。 +export function loadFauxFixture(def, fixturesDir, item) { + if (typeof fixturesDir !== "string") + throw Object.assign(new Error(`模型步 fixture 缺:fixturesDir 未设置(${def.name})`), { code: "infra" }); + const p = def.name === "DEEP" ? path.join(fixturesDir, "deep", `${hidSlug(item.hid)}.json`) + : path.join(fixturesDir, `${def.name.toLowerCase()}.json`); + let fx; + try { fx = JSON.parse(fs.readFileSync(p, "utf8")); } + catch { throw Object.assign(new Error(`模型步 fixture 缺:${p}`), { code: "infra" }); } + if (!Array.isArray(fx.turns)) throw Object.assign(new Error(`模型步 fixture 缺 turns:${p}`), { code: "infra" }); + return fx; +} + +// 旧 runFauxStep registry_seed 行源(faux.mjs:91):fx.seeds 优先,fx.seed 单腿向后 +// 兼容;faux 面 call_id 键=sd.id(真面=sd.call_id,归 model-round)。 +export const fauxFixtureSeeds = (fx) => + (fx.seeds ?? (fx.seed ? [fx.seed] : [])).map((sd) => ({ call_id: sd.id, tool: sd.tool, sc: sd.sc })); + +// 旧 runFauxStep provider 装配(faux.mjs:76-79)+ v2 唯一名(见头注);返回 modelRef +// (configure 面 {provider,modelId};真源=faux.getModel()——其 .provider 即唯一名字串, +// 勿用 faux.provider(那是 provider 对象非 id 字串)。 +export function mkFauxFace(def, { roundId, stage, hid = null }) { + const faux = fauxProvider({ + provider: `faux-${hidSlug(`${roundId}-${stage}${hid != null ? `-${hid}` : ""}`)}`, + models: [{ id: `faux-${def.model.replace(/\//g, "-")}` }], + }); + const m = faux.getModel(); + return { faux, modelRef: { provider: m.provider, modelId: m.id } }; +} + +// 旧 faux.mjs transcriptName 逐字副本(健康轮三步;SHORTLIST=发现轮面不在本票范围, +// 恒 null 由调用方按闭集拒)。 +export const transcriptFile = (def, item) => + def.name === "TRIAGE" ? A.TRIAGE_TRANSCRIPT + : def.name === "DEEP" ? deepTranscriptName(hidSlug(item.hid)) + : def.name === "SYNTH" ? A.SYNTH_TRANSCRIPT + : null; diff --git a/ops/agent-v2/models.mjs b/ops/agent-v2/models.mjs new file mode 100644 index 00000000..aeb4def4 --- /dev/null +++ b/ops/agent-v2/models.mjs @@ -0,0 +1,9 @@ +// models.mjs — v2 模型装载薄适配(票 #225;spec 定案 7:模型面不动,配置与旧管线同源)。 +// 零复制:直接只读 re-export 旧管线 ops/agent/models.mjs 的 loadModels——其内部 +// `@earendil-works/pi-ai` 解析自 ops/agent/node_modules(lock 实装 1.4.0,与本包 +// 钉死的 1.4.0 同版;tests/scaffold.test.mjs 断言两份同版,防双实例凭证店分裂, +// T0 api-map.md §0)。凭证链原样:auth.json(OPS_AUTH)→ InMemoryCredentialStore; +// env 兜底 ZAI_CODING_CN_API_KEY;凭证零入码零入日志。 +// +// 旧管线文件属主:ops/agent/ 零改动红线(票 #225)——本文件只 re-export,不 fork。 +export { loadModels, parseModelRef, PROVIDER_ID } from "../agent/models.mjs"; diff --git a/ops/agent-v2/package-lock.json b/ops/agent-v2/package-lock.json new file mode 100644 index 00000000..9fdc315d --- /dev/null +++ b/ops/agent-v2/package-lock.json @@ -0,0 +1,1215 @@ +{ + "name": "ip-radar-ops-agent-v2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ip-radar-ops-agent-v2", + "dependencies": { + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-durable": "1.4.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.6", + "resolved": "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.978.6.tgz", + "integrity": "sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.37.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.77", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.77.tgz", + "integrity": "sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.79", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.79.tgz", + "integrity": "sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.22", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.22.tgz", + "integrity": "sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/credential-provider-env": "^3.972.77", + "@aws-sdk/credential-provider-http": "^3.972.79", + "@aws-sdk/credential-provider-login": "^3.972.84", + "@aws-sdk/credential-provider-process": "^3.972.77", + "@aws-sdk/credential-provider-sso": "^3.973.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.83", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.84", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.84.tgz", + "integrity": "sha512-G9Ty1uIV0laEvEp2KiMbEhsuBAjgaD3KYrwVjAM5VUxpcIuPBqQI5riA7/qzrnSgYiAKQbubbgkwcqCICKCIxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.89", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.89.tgz", + "integrity": "sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.77", + "@aws-sdk/credential-provider-http": "^3.972.79", + "@aws-sdk/credential-provider-ini": "^3.973.22", + "@aws-sdk/credential-provider-process": "^3.972.77", + "@aws-sdk/credential-provider-sso": "^3.973.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.83", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.77", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.77.tgz", + "integrity": "sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.21", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.21.tgz", + "integrity": "sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/token-providers": "3.1157.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1157.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.1157.0.tgz", + "integrity": "sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.83", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.83.tgz", + "integrity": "sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.39", + "resolved": "https://registry.npmmirror.com/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.39.tgz", + "integrity": "sha512-V+AvQ+KFWApgm9134igy78cgoCEAFfV8K3KmlVi511vjkRWAgvy0tGUePrZsER+9EvrmUWEsrA2NBQspiGVBMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.34", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.34.tgz", + "integrity": "sha512-0IUoHM9N75uMaf70Tj2l+qmOSNw8HaDCsoghPRnewMaaPJjdSxzFaCpPBFDmSWuSlc0gBgv4CsvvvPGoBAD4Bg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.59", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.59.tgz", + "integrity": "sha512-lpLmEPrmRSG1ev+LqxkOn1JfVGclnS9b5hvGaSoBgS0JxW2u3QXsJuu+KTHvhdFrRohsqzl70cuiHhyUHFSjYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.51", + "resolved": "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.997.51.tgz", + "integrity": "sha512-DJK4iGIHWU8Jh8ODMvqFYcX9yGBMXV4tSZAvqlHF5EDd8hbmF3eFO9KIWOnA7wcQjPzEkyMMW1/FgZ9RU8oimQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.49", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.49", + "resolved": "https://registry.npmmirror.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz", + "integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.975.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.975.0.tgz", + "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/chord": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@earendil-works/chord/-/chord-1.4.0.tgz", + "integrity": "sha512-dTbgyz4xgDS5KAUFx1bFENqj0Cr1N3tYxJJOYStc6g6HS78FSBZydyoCwLoddiQRF+VgRS0fpgAi+iKxjZ3BQA==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-telemetry": "1.4.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz", + "integrity": "sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "1.4.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz", + "integrity": "sha512-23CNAXpS9DuLzPMyYuvrXVVio46x4YhffCgCqaWmF7F5H8PooqjrQvX4ucF6M0BhUd+x2tV6r1J0dv8cCME4Cg==", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-durable": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@earendil-works/pi-durable/-/pi-durable-1.4.0.tgz", + "integrity": "sha512-EtIyXH1MBSXdJSkodmy+CYXuwfve3gPrD4GNCZ0XmuRWLPupwRlNQ7fdaXjRcLoKb6SWkT/yEzK+sMmrqvYK+Q==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-telemetry": "1.4.0", + "@silvia-odwyer/photon": "0.3.3", + "diff": "8.0.4", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz", + "integrity": "sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmmirror.com/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon": { + "version": "0.3.3", + "resolved": "https://registry.npmmirror.com/@silvia-odwyer/photon/-/photon-0.3.3.tgz", + "integrity": "sha512-8BhUjEch4slwRe8uXnaA4vcA5uiiOTT90UMsxulOj2gN98X1p0q9Z4Ysk4DkD05uNgbR9XoSqtZ37w+33w4QKQ==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.37.0", + "resolved": "https://registry.npmmirror.com/@smithy/core/-/core-3.37.0.tgz", + "integrity": "sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.15.0", + "resolved": "https://registry.npmmirror.com/bowser/-/bowser-2.15.0.tgz", + "integrity": "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmmirror.com/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/ops/agent-v2/package.json b/ops/agent-v2/package.json new file mode 100644 index 00000000..129fca4e --- /dev/null +++ b/ops/agent-v2/package.json @@ -0,0 +1,17 @@ +{ + "name": "ip-radar-ops-agent-v2", + "private": true, + "type": "module", + "description": "ops agent v2 骨架 — pi-durable@1.4.0 薄适配层 + SQLite + cron 式入口(票 #225;spec docs/superpowers/specs/2026-10-28-ops-agent-pidurable-migration-design.md 定案 1/5/6/7;shadow 期与旧管线 ops/agent/ 共存)", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "test": "node --test" + }, + "dependencies": { + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-durable": "1.4.0" + } +} diff --git a/ops/agent-v2/registry.mjs b/ops/agent-v2/registry.mjs new file mode 100644 index 00000000..998d981f --- /dev/null +++ b/ops/agent-v2/registry.mjs @@ -0,0 +1,33 @@ +// registry.mjs — v2 应用注册表(票 #225;spec 定案 6:框架调用面收薄适配层, +// 升级 pi-durable 只动 entry/registry/tasks 定义文件)。 +// +// ops 红线:不安装内建 CodingTools。pi-durable 的 tools 子入口 +// (`createCodingTools()`/read/write/edit/bash 扩展面)不会自动安装,本文件也 +// 一概不装——v2 是无人值守运营管线,模型不得获代码执行面。 +// +// 扩展面(T3 #227 + T4 #228 并批合并,并集解): +// "ops" — 轮次任务(T3 发现轮 + T4 健康轮)+ T4 微探针三只 +// (reprobe_get/reprobe_ua/upstream_check,均 replay:unsafe; +// 仅健康轮真 DEEP 面经 AgentChange.modelTools 选装—— +// 会话未存选装名单时缺省=全部已装,选装必须显式) +// "ops-discover-tools"— 发现轮三只只读通道工具(web_read/web_search/rss_parse; +// 仅发现轮自有会话选装(tasks/discover-round.mjs +// createConversationViaTx extensions 白名单);root/骨架 +// 会话不选装=零工具面,tests/scaffold.test.mjs 钉基线。 +// 会话未存选装名单时缺省=全部已装(agent.js selectExtensions), +// 选装必须显式,见 entry.mjs)。 +import { createRegistry, defineExtension } from "@earendil-works/pi-durable"; +import { TASKS } from "./tasks/index.mjs"; +import { OPS_PROBE_TOOLS } from "./tools/probes.mjs"; +import { discoveryTools } from "./tools/discovery-tools.mjs"; + +// 发现轮工具闭集扩展(票 #227;replay 声明见 tools/discovery-tools.mjs)。 +// 导出真身:轮启动器(run-discover.mjs)创建轮会话时经 AgentChange.extensions 选中它。 +export const DISCOVER_TOOLS_EXTENSION = defineExtension({ name: "ops-discover-tools", tools: discoveryTools() }); + +export function buildRegistry() { + const registry = createRegistry(); // 只含内建三任务(generation/tool/compaction) + registry.install(defineExtension({ name: "ops", tasks: [...TASKS], tools: [...OPS_PROBE_TOOLS] })); // T4:微探针闭集随 ops 扩展(仅真 DEEP 面选装) + registry.install(DISCOVER_TOOLS_EXTENSION); // T3:发现轮通道工具独立扩展(会话选装面) + return registry; +} diff --git a/ops/agent-v2/round-ctx.mjs b/ops/agent-v2/round-ctx.mjs new file mode 100644 index 00000000..6d3b5402 --- /dev/null +++ b/ops/agent-v2/round-ctx.mjs @@ -0,0 +1,73 @@ +// round-ctx.mjs — 健康轮 v2 ↔ 旧管线 ctx 适配层(票 #228;spec 定案 1/2:相位语义逐字 +// 保留,模型/脚本步实现零复制 import 旧 steps/gates,本文件只造旧 ctx 形状喂它们)。 +// 零改动红线约束:旧 pipeline.mjs 的 composeEnvelope/SKIP_WHEN(ctxPaths/ctxShape 同理) +// 是私有函数不 export → 本文件按旧源逐字重实现(适配副本),出处逐处标注;旧文件一动 +// 不动。升级/对拍以旧源为准,改这里须同步核旧源。 +import path from "node:path"; +import fs from "node:fs"; +import crypto from "node:crypto"; + +export const ROOT = path.resolve(import.meta.dirname, "..", ".."); // 仓根(与 entry.mjs 同推导) + +// ops 树根(旧 pipeline.mjs opsRootOf 同形;旧缺省链=tools.mjs import 时读的 DOCS_ROOT, +// v2 缺省=仓根 docs——测试/生产都显式 DOCS_ROOT,两链等价面只在双缺省时且行为一致) +export const opsRootOf = (env) => path.join(env.DOCS_ROOT ?? process.env.DOCS_ROOT ?? path.join(ROOT, "docs"), "ops"); + +// ctx 派生路径单一形状(旧 pipeline.mjs ctxPaths 逐字副本:私有不 export,适配重实现) +export const ctxPaths = (OPS_ROOT, roundId) => ({ + rulesFile: path.join(OPS_ROOT, "rules", "suppressions.json"), + itemsDir: path.join(OPS_ROOT, "items"), + retroFile: path.join(OPS_ROOT, "retro", `${roundId}.json`), retroAuditKey: roundId, +}); + +// 旧 pipeline/faux.mjs 私有 sha 逐字副本(transcript 行 prompt_sha 同源算法,16 hex) +export const sha16 = (s) => crypto.createHash("sha256").update(s).digest("hex").slice(0, 16); + +const j = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } }; + +// 轮次信封组装(旧 pipeline.mjs composeEnvelope 逐字副本:私有不 export,适配重实现; +// 现有形状只增 pipeline 扩展字段,旧消费方透传零改——v2 CLOSE 相位信封与旧 stdout 末行同形) +export function composeEnvelope(ctx, { ok, code, message }) { + const pipeline = { round_id: ctx.roundId, resumed_from: ctx.resumedFrom ?? null, replayed_of: ctx.state.replay_of, stages: ctx.state.stages }; + if (ctx.state.finding_exits) pipeline.finding_exits = ctx.state.finding_exits; + if (ok) return { ok: true, round: ctx.type, pkg: ctx.pkg, usage: { ...ctx.usage }, queue_writes: [], + verify: ctx.verify ?? { ok: true, cited: 0 }, pipeline }; + const e = { ok: false, round: ctx.type, error: { code, message } }; + if (ctx.pkg) e.pkg = ctx.pkg; + e.pipeline = pipeline; + return e; +} + +// skipWhen 命名谓词闭集(旧 pipeline.mjs SKIP_WHEN.no_need_deep 逐字副本:私有不 export,适配重实现) +export const skipWhenNoNeedDeep = (ctx) => { + const b = j(path.join(ctx.dir, "04-dispatch-bundles.json")); + if (b) return !(b.bundles ?? []).length; + const h = j(path.join(ctx.dir, "03-triage-hypotheses.json")); + return !((h?.hypotheses ?? []).some((x) => x.need_deep)); +}; + +// ── 轮次级挥态(进程内;旧 ctxShape 每轮新建 Map 的 v2 对应面)──────────────────── +// dupRegistry:微探针同参去重面(旧 ctx.dupRegistry;resume 重开=计数清零,宁少算口径, +// 旧 ctxShape 头注同语义——「resume 重开=usage 从零计,断点前已花量不可回收」)。 +const VOLATILE = new Map(); // roundId → { dupRegistry: Map } +export function roundVolatile(roundId) { + if (!VOLATILE.has(roundId)) VOLATILE.set(roundId, { dupRegistry: new Map() }); + return VOLATILE.get(roundId); +} + +// ctx 形状唯一真源的 v2 面(旧 ctxShape 逐字段镜像;state 由健康轮 checkpoint 重建, +// script 步只读 ctx.state、ANCHOR_SWEEP 会写 finding_exits → 步后由任务面同步回 checkpoint)。 +export const buildRoundCtx = ({ type, roundId, opsRoot, env, log, fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, state, usage, modelCalls }) => ({ + type, roundId, + dir: path.join(opsRoot, "rounds", String(roundId)), + env, log, fixturesDir, modelFace, budgetCny, budgetKnobs, breakerKnobs, + modelCalls: modelCalls ?? 0, dupRegistry: roundVolatile(roundId).dupRegistry, opsRoot, + ...ctxPaths(opsRoot, roundId), + ledgerFile: env.LEDGER_FILE ?? process.env.LEDGER_FILE ?? path.join(opsRoot, "ledger.md"), + pkg: null, verify: null, envelope: null, + discoverHost: null, + usage: usage ?? { input: 0, output: 0, totalTokens: 0, cost: 0 }, + state, +}); + +export const addUsage = (to, from) => { for (const k of ["input", "output", "totalTokens", "cost"]) to[k] += from?.[k] ?? 0; }; // 旧 pipeline.mjs addUsage 逐字副本(私有) diff --git a/ops/agent-v2/run-discover.mjs b/ops/agent-v2/run-discover.mjs new file mode 100644 index 00000000..3edaf36f --- /dev/null +++ b/ops/agent-v2/run-discover.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// run-discover.mjs — 发现轮启动器(票 #227 T3;cron 面与 golden 对拍面同一程序面)。 +// 职责(spec 定案 5/6 + t1-notes 建议4):开 harness → 准入(admitRound)→ 创建轮会话 +// (agent 面=modelRef+shortlist 提示词+发现工具扩展;任务内无 configure 面,会话必须 +// 在任务外带 agent 创建,见 tasks/discover-round.mjs 头注「轮会话来源」)→ 经 +// Conversation.commit(tx.createTask) 绑 ops.discover-round 任务 → waitForTask → stdout +// 末行=轮次信封 JSON(旧契约形;T5 冻结前不增删)。 +// 模型面:缺省=real(loadModels resolve("discover"));--fixtures DIR / FAUX_FIXTURES=faux +// (fauxProvider 罐装 shortlist turns,零网络零花费——同旧 pipeline.mjs 显式 fixtures 律)。 +// 轮 id 形状律(P2-5 对称面,旧 mkCtx:156 同律):YYYY-MM-DD-HHMM-discover(-rN), +// 先于准入拒跑(note-9 同序:调用方错不被月帽掩蔽)。 +// 用法:node run-discover.mjs [--fixtures DIR] [--db PATH] [--model provider/modelId] +// [--crash-after-phase PHASE](SIGKILL 自杀面=resume 测试;任务内 crashHook 消费 env) +// 退出码:0=completed;1=failed/refused;2=用法错;3=运行期异常(ENVELOPE.md 冻结面)。 +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { defineDoc } from "@earendil-works/pi-durable"; +import { DISCOVER_PIPELINE } from "../agent/pipeline.mjs"; // 零复制:SHORTLIST def(提示词键单源) +import { DISCOVER_TOOLS_EXTENSION } from "./registry.mjs"; +import { openHarness } from "./entry.mjs"; +import { admitRound } from "./tasks/budget-guard.mjs"; +import { discoverRoundTask } from "./tasks/discover-round.mjs"; + +// 轮→任务索引(session 单例 doc;Harness 无 scanTasks 面,见 1.4.0 types): +// roundId → {taskId, conversationId}。创建与登记同一 commit(原子;崩溃要么都有要么都无)。 +export const RoundsDoc = defineDoc({ kind: "ops.discover-rounds", scope: "session", version: 1, checkpointWhen: () => true, initial: () => ({ rounds: {} }) }); + +// SHORTLIST 提示词路径:与旧 steps.mjs promptFile 同式(/prompts/.md; +// def.model="discover/shortlist" 单源取自 DISCOVER_PIPELINE,仅 prompts 根目录在 v2 推导 +// 一次——drift 面由 golden 对拍 transcript system_sha 钉死)。 +export function shortlistPromptPath() { + const def = DISCOVER_PIPELINE.find((d) => d.name === "SHORTLIST"); + return path.join(path.resolve(import.meta.dirname, "..", ".."), "ops", "agent", "prompts", `${def.model}.md`); +} + +// faux 模型面(旧 faux.mjs runFauxStep 同构:faux-discover-<键> + fixtures turns 罐装) +export async function fauxBundle(fixturesDir) { + const { fauxProvider, createModels } = await import("@earendil-works/pi-ai"); + const fx = JSON.parse(fs.readFileSync(path.join(fixturesDir, "shortlist.json"), "utf8")); + const faux = fauxProvider({ models: [{ id: "faux-discover-shortlist" }] }); + const models = createModels(); + models.setProvider(faux.provider); + faux.setResponses(fx.turns); + return { models, resolve: () => faux.getModel() }; +} + +// ── 一轮(程序面;CLI 与测试共用)── +// 返回 {status:"completed",envelope} | {status:"failed",error} | {status:"refused",...}; +// envelope=outcome.result(信封形见 tasks/discover-round.mjs composeEnvelope)。 +export async function runDiscoverRound(opts) { + const env = opts.env ?? process.env; + const roundId = opts.roundId; + const fixturesDir = opts.fixturesDir ?? null; + const log = (l) => (opts.stderr ?? process.stderr).write(`${l}\n`); + // 轮 id 形状律(P2-5,旧 mkCtx:156 同律;先于准入——note-9 同序): + if (!/^\d{4}-\d{2}-\d{2}-\d{4}-discover(-r\d+)?$/.test(roundId ?? "")) + return { status: "failed", error: { code: "bad_round", message: `round_id 形状须 YYYY-MM-DD-HHMM-discover:${roundId}` } }; + // —— 步前准入(月度 kill switch;与 entry.runRound 同壳,budget-guard 单源)—— + const admission = await admitRound({ env, log, roundType: "discover", ledgerFile: opts.ledgerFile }); + if (admission.refused) + return { status: "refused", code: admission.code, message: admission.message, budget: { admission: admission.admission } }; + const modelRef = opts.modelRef ?? (fixturesDir ? { provider: "faux", modelId: "faux-discover-shortlist" } : null); + if (!modelRef) throw Object.assign(new Error("模型面缺:real 面须 --model provider/modelId(或经 opts.modelRef)"), { usage: true }); // P0-⑥ 用法错 exit 2(裸 Error 会落 exit 3,与 ENVELOPE.md 码表不符) + const { harness } = await openHarness({ + dbPath: opts.dbPath, + env, + registry: opts.registry, + models: fixturesDir ? await fauxBundle(fixturesDir) : undefined, + }); + const context = BACKGROUND_CONTEXT; + try { + harness.resume(); + // 幂等键律(v2 口径,对应旧「同 id 重跑走 replay」):同库同轮已有任务→复用续跑/重放 + // (requestId 幂等零新模型调用,零双写);跨库新任务撞已有轮目录仍被任务内建轮守卫拦 + // (bad_round,tasks/discover-round.mjs bootstrapRoundDir)。 + const index = await harness.snapshot(RoundsDoc, context); + const prior = index?.rounds?.[roundId]; + let taskId, conversationId; + if (prior && typeof prior.taskId === "number") { + taskId = prior.taskId; + conversationId = prior.conversationId; + log(`[v2-discover] round=${roundId} 复用任务 ${taskId}(checkpoint 续跑/终态重放,零新建)`); + } else { + // 轮会话:ownerless(一轮一会话;粒度决策=spec 定案 5「一轮=一次任务运行」, + // 详见 tasks/discover-round.mjs 头注)。agent 面钉死=旧模型步语义:全新上下文+ + // 提示词+工具闭集;extensions 显式=[DISCOVER_TOOLS_EXTENSION](未显式=缺省全装, + // agent.js selectExtensions——root 面零工具红线由 entry.mjs 钉 [] 对照)。 + const conv = await harness.createConversation({ + ownership: { kind: "ownerless" }, + agent: { + model: modelRef, + instructions: fs.readFileSync(shortlistPromptPath(), "utf8"), + extensions: [DISCOVER_TOOLS_EXTENSION], + }, + }, context); + // 任务绑定本会话+登记索引:同一 commit(原子;Session 级 commit 无绑定会话, + // conversationId 须显式——TaskOptions 文档律) + taskId = await harness.commit(async (tx) => { + const id = await tx.createTask(discoverRoundTask, { + roundId, + knobs: admission.knobs, // 轮始冻结(旧律:state.budget_knobs 同位) + budgetCny: admission.estCny, + modelFace: fixturesDir ? "faux" : "real", + fixturesDir, + admission: admission.admission ?? null, // 准入读数记录(ok/projected/month/…;旧 state.monthly_admission 同面,经 checkpoint 冻结) + modelRef, + }, { ownership: { kind: "conversation" }, conversationId: conv.id }); + const doc = await tx.doc(RoundsDoc); + doc.rounds[roundId] = { taskId: id, conversationId: conv.id }; + return id; + }, context); + conversationId = conv.id; + } + log(`[v2-discover] round=${roundId} task=${taskId} conv=${conversationId} model=${modelRef.provider}/${modelRef.modelId}`); + const settled = await harness.waitForTask(taskId, context); + const outcome = settled.state.outcome; + if (outcome?.status === "completed") return { status: "completed", envelope: outcome.result, taskId, conversationId }; + if (outcome) return { status: "failed", error: outcome.error, taskId, conversationId }; + return { status: "parked", taskId, conversationId, state: settled.state }; // restartLimit 面等(restartCount 连击);报告级,CLI 退 1(终审硬违修复:原引 else 块内 conv,复用路径 ReferenceError) + } finally { + await harness.close(context); + } +} + +// ── CLI ── +function usage() { + process.stderr.write(`用法:node run-discover.mjs [--fixtures DIR] [--db PATH] [--model provider/modelId] [--crash-after-phase PHASE]\n`); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const roundId = args[0] && !args[0].startsWith("--") ? args[0] : undefined // 位置首参=roundId(终审修复:flag 先于位置参时原 find 会把 flag 值误当 id); + const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; }; + if (!roundId) { usage(); process.exitCode = 2; } // exitCode 而非 exit:stdout 写入异步落管,process.exit 可截断单行信封(旧 pipeline.mjs:547 判例) + else { + if (flag("crash-after-phase")) process.env.OPS_V2_CRASH_AFTER_PHASE = flag("crash-after-phase"); + try { + // --model 校验(run-health 同律,P0-⑥):无斜杠/空段=用法错 exit 2,不带病开库 + let modelRef; + const modelRefArg = flag("model"); + if (modelRefArg) { + const i = modelRefArg.indexOf("/"); + if (i <= 0 || i >= modelRefArg.length - 1) throw Object.assign(new Error(`--model 须 provider/modelId,得到:${modelRefArg}`), { usage: true }); + modelRef = { provider: modelRefArg.slice(0, i), modelId: modelRefArg.slice(i + 1) }; + } + const r = await runDiscoverRound({ + roundId, + fixturesDir: flag("fixtures") ? path.resolve(flag("fixtures")) : null, + dbPath: flag("db"), + modelRef, + }); + // stdout 末行=旧形轮次信封(ENVELOPE.md 冻结面;wrapper 状态走 exit 码+stderr,不口 stdout)。 + // P0-①:任务域失败三元组的真码在 error.detail.code(finishFailed 的 scriptPhase 包装 + // 面),顶层 error.code 仅启动器层失败有——读序 detail.code → code → infra 兜底。 + const out = r.status === "completed" ? r.envelope + : r.status === "refused" ? { ok: false, round: "discover", error: { code: r.code, message: r.message } } + : { ok: false, round: "discover", error: { code: r.error?.detail?.code ?? r.error?.code ?? "infra", message: r.error?.message ?? "发现轮失败" } }; + process.stdout.write(`${JSON.stringify(out)}\n`); + process.exitCode = r.status === "completed" ? 0 : 1; + } catch (e) { + if (e?.usage) usage(); // 用法错=纯 stderr+exit 2,不发 JSON + else process.stderr.write(`${e.stack ?? e}\n`); + process.exitCode = e?.usage ? 2 : 3; // 3=T5 冻结(ENVELOPE.md):运行期异常与轮次未成分码,cron 层可区分 + } + } +} diff --git a/ops/agent-v2/run-health.mjs b/ops/agent-v2/run-health.mjs new file mode 100644 index 00000000..eb4ae38f --- /dev/null +++ b/ops/agent-v2/run-health.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// run-health.mjs — 健康轮启动器(票 #229 T5;cron 面驱动器,T4 遗留 4 的收口)。 +// 与 run-discover.mjs 同律(spec 定案 5/6):开 harness → 准入(admitRound)→ root.commit +// 建 OPS_HEALTH_ROUND 任务(E2E 判例:ownership={kind:"conversation"} 挂 root 会话; +// 模型子会话由任务内 spawn 自建)→ waitForTask → stdout 末行=旧形轮次信封(ENVELOPE.md 冻结)。 +// 模型面:--fixtures DIR=faux(零网络零花费;--model 缺省 faux-discover 同律不需); +// real 面=--model provider/modelId(子任务 spawn 内 loadModels)。 +// 退出码(T5 冻结表,ENVELOPE.md):0=completed;1=failed/refused/parked;2=用法错;3=运行期异常。 +// 用法:node run-health.mjs [--fixtures DIR] [--db PATH] [--model provider/modelId] +// [--from-phase PHASE](默认全轮 PROBE 起;faux 测试用 TRIAGE 起零网络) +// [--to-phase PHASE](默认 CLOSE 全链;旧 replay --to 镜像,shadow 载具停笔面) +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineDoc } from "@earendil-works/pi-durable"; +import { OPS_HEALTH_ROUND, RUNNABLE as HEALTH_PHASES } from "./tasks/health-round.mjs"; // RUNNABLE=PROBE…RETRO(旧 runnableOf 同推导;fromPhase 闭集面) +import { openHarness } from "./entry.mjs"; +import { admitRound } from "./tasks/budget-guard.mjs"; + +// 轮→任务索引(session 单例 doc;run-discover.mjs RoundsDoc 同律)。创建与登记同一 commit。 +export const HealthRoundsDoc = defineDoc({ kind: "ops.health-rounds", scope: "session", version: 1, checkpointWhen: () => true, initial: () => ({ rounds: {} }) }); + +// 旧形失败信封(ENVELOPE.md 冻结;agent.mjs failureEnvelope 同形:{ok:false,round,error}) +const failEnvelope = (code, message) => ({ ok: false, round: "health", error: { code, message } }); + +export async function runHealthRound(opts) { + const env = opts.env ?? process.env; + const roundId = opts.roundId; + const log = (l) => (opts.stderr ?? process.stderr).write(`${l}\n`); + // 轮 id 形状律(P2-5,旧 mkCtx 同律):YYYY-MM-DD-HHMM-health(-rN);note-9:用法/形状校验 + // 先于准入——非法轮次是调用方错(exit 1 bad_round),不被月帽拒跑掩蔽。 + if (!/^\d{4}-\d{2}-\d{2}-\d{4}-health(-r\d+)?$/.test(roundId ?? "")) + return { status: "failed", envelope: failEnvelope("bad_round", `round_id 形状须 YYYY-MM-DD-HHMM-health:${roundId}`) }; + const fixturesDir = opts.fixturesDir ?? null; + const fromPhase = opts.fromPhase ?? "PROBE"; + if (!HEALTH_PHASES.includes(fromPhase)) throw Object.assign(new Error(`--from-phase 须 ∈ {${HEALTH_PHASES.join("|")}},得到:${fromPhase}`), { usage: true }); + const toPhase = opts.toPhase ?? "CLOSE"; + if (![...HEALTH_PHASES, "CLOSE"].includes(toPhase)) throw Object.assign(new Error(`--to-phase 须 ∈ {${[...HEALTH_PHASES, "CLOSE"].join("|")}},得到:${toPhase}`), { usage: true }); + if (HEALTH_PHASES.includes(toPhase) && HEALTH_PHASES.indexOf(toPhase) <= HEALTH_PHASES.indexOf(fromPhase)) + throw Object.assign(new Error(`--to-phase(${toPhase}) 须晚于 --from-phase(${fromPhase})`), { usage: true }); // CLI 层早拒(exit 2);任务 initial 同闭集重复校验=深防御 + // --model(P1-3):provider/modelId → 任务 input.modelRef(real 面显式覆盖;faux 面被 spawn 内 + // faux 分派优先,同 E2E 注入面优先级) + let modelRef = null; + if (opts.model) { + const i = opts.model.indexOf("/"); + if (i <= 0 || i >= opts.model.length - 1) throw Object.assign(new Error(`--model 须 provider/modelId,得到:${opts.model}`), { usage: true }); + modelRef = { provider: opts.model.slice(0, i), modelId: opts.model.slice(i + 1) }; + } + const admission = await admitRound({ env, log, roundType: "health", ledgerFile: opts.ledgerFile }); + if (admission.refused) + return { status: "refused", envelope: failEnvelope(admission.code, admission.message) }; + const { harness } = await openHarness({ dbPath: opts.dbPath, env }); // 模型装载不在此分叉:faux 分派在任务 spawn 内(faux-face),real 走缺省 loadModels + const context = (await import("@earendil-works/chord/context")).BACKGROUND_CONTEXT; + try { + harness.resume(); + const index = await harness.snapshot(HealthRoundsDoc, context); + const prior = index?.rounds?.[roundId]; + let taskId; + if (prior && typeof prior.taskId === "number") { + taskId = prior.taskId; // 幂等:同库同轮复用任务(checkpoint 续跑/终态重放,零新建) + log(`[v2-health] round=${roundId} 复用任务 ${taskId}`); + } else { + const root = await harness.root(context); // 任务挂 root 会话(E2E 判例:root.commit tx 的 conversationId 隐式;Session 级 commit 需显式——TaskOptions 文档律) + taskId = await root.commit(async (tx) => { + const id = await tx.createTask(OPS_HEALTH_ROUND, { + roundId, fromPhase, ...(toPhase !== "CLOSE" ? { toPhase } : {}), ...(modelRef ? { modelRef } : {}), + modelFace: fixturesDir ? "faux" : "real", + fixturesDir, + monthlyAdmission: admission.admission ?? null, // P1-5 读数链:submitter→input→initial→09-budget monthly + }, { ownership: { kind: "conversation" } }); + const doc = await tx.doc(HealthRoundsDoc); + doc.rounds[roundId] = { taskId: id }; + return id; + }, context); + log(`[v2-health] round=${roundId} task=${taskId} from=${fromPhase} face=${fixturesDir ? "faux" : "real"}`); + } + const settled = await harness.waitForTask(taskId, context); + const outcome = settled.state.outcome; + if (outcome?.status === "completed") return { status: "completed", envelope: outcome.result.envelope, taskId }; + if (outcome?.status === "failed") return { status: "failed", envelope: outcome.result?.envelope ?? failEnvelope(outcome.error?.code ?? "infra", outcome.error?.message ?? "健康轮失败"), taskId }; + return { status: "parked", envelope: failEnvelope("infra", `任务未终态:${JSON.stringify(settled.state.outcome?.error ?? settled.state.summary ?? {})}`), taskId }; // P2-⑫ 带真错:outcome.error 优先(纯 summary 面在 restartLimit 停摆时无错因可查),summary 兑底 + } finally { + await harness.close(context); + } +} + +// ── CLI(cron 面;stdout 末行=旧形信封,wrapper 信息走 stderr——ENVELOPE.md 冻结)── +function usage() { + process.stderr.write(`用法:node run-health.mjs [--fixtures DIR] [--db PATH] [--model provider/modelId] [--from-phase PHASE] [--to-phase PHASE]\n`); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const roundId = args[0] && !args[0].startsWith("--") ? args[0] : undefined // 位置首参=roundId(终审修复:flag 先于位置参时原 find 会把 flag 值误当 id); + const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : undefined; }; + if (!roundId) { usage(); process.exitCode = 2; } // exitCode 而非 exit:stdout 写入异步落管,process.exit 可截断单行信封(旧 pipeline.mjs:547 判例) + else { + try { + const r = await runHealthRound({ + roundId, + fixturesDir: flag("fixtures") ? path.resolve(flag("fixtures")) : null, + dbPath: flag("db"), + fromPhase: flag("from-phase"), + toPhase: flag("to-phase"), + model: flag("model"), + }); + process.stdout.write(`${JSON.stringify(r.envelope)}\n`); // stdout 末行单行旧形信封(唯一 stdout 内容) + process.exitCode = r.status === "completed" ? 0 : 1; + } catch (e) { + if (e?.usage) usage(); // 用法错=纯 stderr+exit 2,不发 JSON + else process.stderr.write(`${e.stack ?? e}\n`); + process.exitCode = e?.usage ? 2 : 3; // 3=T5 冻结:运行期异常≠unanswered(旧 N1 缺口),cron 层可区分 + } + } +} diff --git a/ops/agent-v2/shadow-diff.mjs b/ops/agent-v2/shadow-diff.mjs new file mode 100644 index 00000000..1d7c347b --- /dev/null +++ b/ops/agent-v2/shadow-diff.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +// shadow-diff.mjs — shadow 对拍 harness(票 #229 T5;spec 定案 2 安全轨的执行面)。 +// 双面同种子同罐装世界、停笔面同构(均止于 RETRO):OLD = 旧管线 pipeline.mjs replay +// --from TRIAGE(旧 replay 契约缺省 --to RETRO;新开 -rN 目录,原件零改写);NEW = +// run-health.mjs --from-phase TRIAGE --to-phase RETRO(toPhase=旧 --to 镜像)。 +// 载具判例:全链载具不可行——旧 PROBE 天生联网(extract_feed_urls→feed_freshness 真网, +// golden 亦从罐装断点进),shadow 恒零网络红线 → 只能断点进。CLOSE 面不入载具(旧 +// replay 不入 CLOSE),其同构由 launcher 测试钉(run-health 全链信封/G_CLOSE)。 +// 对拍面 = 可比工件闭集 + 台账行;归一化只抹时间/时长/tmp 路径、replay -rN 后缀与台账 +// usage 计数(两套 harness 计量边界不同:in/out/total 合法差;cost 细胞逐字比,faux 恒 0; +// 台账语义面=行存在+日期/轮次/model/备注轮 id);语义字段一律逐字比(位等价闸门,收口 +// T3 遗留「归一化宽缝」)。 +// 结构性白名单(声明面,非归一化):两侧 state.json 各为实现细节(OLD=磁盘状态机, +// NEW=checkpoint 镜像)不入对拍面;NEW 09-budget.json 只在 CLOSE 落件=载具停笔前不存在, +// 不在闭集。SHADOW-RUNBOOK §已知差异面。 +// 用法:node shadow-diff.mjs [--fixtures DIR](缺省 ops/agent/fixtures/pipeline) +// [--keep](保留双世界目录供人工 diff) +// 退出:0=可比面全 SAME;1=存在 DIFF;2=用法错;3=任一面运行失败。 +import { execFile as execFileCb, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const execFileP = promisify(execFileCb); +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const AGENT = path.resolve(HERE, "..", "agent"); // ops/agent(旧真源) +const FIXTURES = path.join(AGENT, "fixtures", "pipeline"); +const PREC = "2026-06-01-golden-aggregator.md"; +const CINS = "https://cinsscore.com/list/ci-badguys.txt"; +const BDEF = "https://www.binarydefense.com/banlist.txt"; +const CINS_SLUG = "https-cinsscore.com-list-ci-badguys.txt"; + +// 可比工件闭集(前缀级;05-deep-* 双侧展开)。09-budget/state.json/envelope 不入(上方声明)。 +const COMPARABLE = ["03-", "04-", "05-", "06-", "07-", "08-", "gates.json"]; // 01/02=罐装种子面;09-budget=NEW CLOSE 落件,载具停笔前不存在,不入 +// 残余哨判定(导出面=哨正例单测):NEW 侧文件不在闭集且不在声明白名单 → 未声明新面。 +// 白名单=01-/02- 罐装种子 + 09-budget.json(CLOSE 落件) + .v2-claim.json(跨库撞轮认领件)。 +export const isUndeclaredNewFile = (f) => + !COMPARABLE.some((p) => f.startsWith(p)) && !/^0[12]-/.test(f) && f !== "09-budget.json" && f !== ".v2-claim.json"; + +// —— 世界种子(health-e2e.test.mjs seedWorld 同契约:判例+规则+01/02 前置工件+永败 eval stub; +// 导出面=cron-launchers 测试复用同世界形状)—— +export function seedWorld(roundId) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `v2-shadow-${roundId}-`)); + const docs = path.join(tmp, "docs"); + const ops = path.join(docs, "ops"); + const roundDir = path.join(ops, "rounds", roundId); + for (const d of [path.join(ops, "precedents"), path.join(ops, "rules"), path.join(ops, "items"), roundDir, path.join(ops, "retro")]) fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(ops, "precedents", PREC), `---\nid: 2026-06-01-golden-aggregator\ndate: 2026-06-01\nsubject: fixture — 聚合器候选必须实测排他率\ncontext: >\n 聚合器形状候选,上游映射推断重叠不可信。\nhuman_ruling: >\n 实测排他率 50.0%,推翻 REJECT,立项。\nreward:\n hard_gates:\n exclusive_rate: "50.0%"\n ruling_agreement: overruled\n---\n\n正文检索词:聚合器 排他率 回声 冻结 违例。\n`); + fs.writeFileSync(path.join(ops, "precedents", "_schema.md"), "schema 说明(注入排除面)。\n"); + fs.writeFileSync(path.join(ops, "rules", "suppressions.json"), JSON.stringify({ version: 1, rules: [ + { id: "R1", adopted: "2026-10-24", adopted_via: "item-fixture", match: { bucket: "frozen", url_shape: "*dshield.org*" }, + action: "suppress", note: "fixture:dshield 冻结读数为已知探测伪迹", review_at: "2027-01-24" } ] }, null, 2) + "\n"); + // 01/02 前置工件(fromPhase=TRIAGE;E2E ⑫ 同面):2 冻结幸存+1 抑制 + fs.writeFileSync(path.join(roundDir, "01-probe-summary.json"), JSON.stringify({ ok: true, round_id: roundId, urls_n: 3, attempted_n: 3, + buckets: { fresh: 0, frozen: 3, unreachable: 0, token_required: 0, no_lm: 0 }, + flags: [ + { signal_id: CINS, url: CINS, bucket: "frozen", source: "cinsscore", age_days: 220, last_modified: "canned-220d" }, + { signal_id: BDEF, url: BDEF, bucket: "frozen", source: "binarydefense", age_days: 210, last_modified: "canned-210d" }, + { signal_id: "https://lists.dshield.org/block.txt", url: "https://lists.dshield.org/block.txt", bucket: "frozen", source: "dshield", age_days: 200, last_modified: "canned-200d" }, + ], matrix: { ok: true, exit_code: 0, violation_n: 0 } }, null, 2) + "\n"); + fs.writeFileSync(path.join(roundDir, "02-verify-survivors.json"), JSON.stringify({ survivors: [ + { signal_id: CINS, source: "cinsscore", bucket: "frozen", age_days: 220, url: CINS }, + { signal_id: BDEF, source: "binarydefense", bucket: "frozen", age_days: 210, url: BDEF }, + ], known_noise_digest: [{ id: "R1", action: "suppress", match: { bucket: "frozen", url_shape: "*dshield.org*" }, note: "fixture", review_at: "2027-01-24" }], rules_version: 1, in_flight_n: 0 }, null, 2) + "\n"); + fs.writeFileSync(path.join(roundDir, "02-verify-suppressed.json"), JSON.stringify({ suppressed: [ + { signal_id: "https://lists.dshield.org/block.txt", source: "dshield", bucket: "frozen", age_days: 200, url: "https://lists.dshield.org/block.txt", rule_id: "R1" }, + ], rules_version: 1 }, null, 2) + "\n"); + // 永败 eval stub(锚巡检双面同降级)+ dry-run 双保险 + const stub = path.join(tmp, "stub-eval.py"); + fs.writeFileSync(stub, "#!/usr/bin/env python3\nimport sys\nsys.exit(1)\n"); + fs.chmodSync(stub, 0o755); + const env = { + ...process.env, + DOCS_ROOT: docs, PRECEDENTS_DIR: path.join(ops, "precedents"), LEDGER_FILE: path.join(ops, "ledger.md"), + OPS_EVAL_PY: `python3 ${stub}`, OPS_SWEEP_DRY_RUN: "1", + }; + return { tmp, docs, ops, roundDir, env }; +} + +// —— 子进程运行器(stdout 末行信封收集;两 launcher/旧 CLI 均单行 JSON 契约)—— +async function runSide(cmd, args, env, label) { + const r = await new Promise((resolve) => { + const p = spawn(cmd, args, { env, cwd: path.resolve(HERE, "..", "..") }); + let out = "", err = ""; + p.stdout.on("data", (d) => (out += d)); + p.stderr.on("data", (d) => (err += d)); + p.on("close", (code) => resolve({ code, out, err })); + }); + const lines = r.out.trimEnd().split("\n"); + let envelope = null; + try { envelope = lines.length ? JSON.parse(lines.at(-1)) : null; } catch { envelope = null; } // P2-6:末行非 JSON=信封缺失,不炸诊断 + if (r.code === 3) throw new Error(`${label} 运行期异常(exit 3):\n${r.err.split("\n").slice(-6).join("\n")}`); + if (r.code !== 0) process.stderr.write(`[${label}] exit=${r.code} envelope=${JSON.stringify(envelope)}\n[${label}] stderr 尾:\n${r.err.split("\n").filter(Boolean).slice(-8).join("\n")}\n`); + return { code: r.code, envelope, stderr: r.err }; +} + +// —— 归一化(窄面:时间/时长/tmp 路径;语义字段零归一)—— +function normalizeText(t, base) { + return t + .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, "") + .replace(/"generated_at":\s*""/g, '"generated_at":""') + .replace(/"updated_at":\s*""/g, '"updated_at":""') + .replace(/"started_at":\s*""/g, '"started_at":""') + .replace(/"dur_ms":\s*\d+/g, '"dur_ms":0') + .replaceAll(base.oldTmp, "").replaceAll(base.newTmp, "") + .replace(new RegExp(base.roundId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-r\\d+", "g"), base.roundId) // replay -rN 后缀(轮 id/台账备注/gates inputs/artifacts 内 round_id) + ; +} +function filesOf(dir) { return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => !fs.statSync(path.join(dir, f)).isDirectory()) : []; } + +export async function shadowDiff(opts = {}) { + const roundId = opts.roundId ?? `2026-10-28-1700-health`; + const fixtures = opts.fixturesDir ?? FIXTURES; + const old = seedWorld(roundId); + const neu = seedWorld(roundId); + const base = { oldTmp: old.tmp, newTmp: neu.tmp, roundId }; + // OLD:旧 replay(需先落 state.json 供 replay 读——in_flight 于 VERIFY 断点形;PROBE/VERIFY + // 两 rec 由罐装 01/02 面代理,与旧 selftest-pipeline ⑫ 同法) + fs.writeFileSync(path.join(old.roundDir, "state.json"), JSON.stringify({ schema: 1, round_id: roundId, type: "health", pipeline_mode: "pipeline", + status: "in_flight", stage: "VERIFY", stages: [{ name: "PROBE", status: "done", attempts: 1, dur_ms: 0, usage: null }, { name: "VERIFY", status: "done", attempts: 1, dur_ms: 0, usage: null }], + budget_knobs: null, fixtures_dir: fixtures, model_face: "faux", prompt_hashes: {}, updated_at: new Date().toISOString(), started_at: new Date().toISOString() }, null, 2) + "\n"); + const oldR = await runSide("node", [path.join(AGENT, "pipeline.mjs"), "replay", roundId, "--from", "TRIAGE", "--fixtures", fixtures], old.env, "OLD"); + const newR = await runSide("node", [path.join(HERE, "run-health.mjs"), roundId, "--from-phase", "TRIAGE", "--to-phase", "RETRO", "--fixtures", fixtures, "--db", path.join(neu.tmp, "s.sqlite")], neu.env, "NEW"); + // 双面目录:OLD=rounds/-rN(replay 新目录);NEW=rounds/(任务原目录) + const rounds = path.join(old.ops, "rounds"); + const oldReplayDir = fs.readdirSync(rounds).filter((f) => new RegExp(`${roundId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-r\\d+$`).test(f)).sort().at(-1); + const oldDir = path.join(rounds, oldReplayDir ?? `${roundId}-rX`); + const newDir = path.join(neu.ops, "rounds", roundId); + const report = { roundId, sides: { old: { code: oldR.code, envelope: oldR.envelope }, new: { code: newR.code, envelope: newR.envelope } }, files: [], verdict: "SAME" }; + // P1-1:双面皆败(或任一面信封非 ok)=闸门失败,不得假 SAME;可比集空/台账行缺同理。 + // 实证:双面同因败(如共享 steps 回归)可比集空 → 旧版 VERDICT SAME exit 0。 + const sideOk = (s) => s.envelope?.ok === true; + if (!sideOk(oldR) || !sideOk(newR)) { + report.verdict = "DIFF"; + report.files.push({ file: "(envelope)", verdict: "SIDE-FAILED", hint: `old.ok=${oldR.envelope?.ok}(${oldR.envelope?.error?.code ?? "?"}) new.ok=${newR.envelope?.ok}(${newR.envelope?.error?.code ?? "?"})` }); + } + const oldFiles = filesOf(oldDir).filter((f) => f !== "state.json"); + const newFiles = filesOf(newDir).filter((f) => f !== "state.json"); + const names = [...new Set([...oldFiles, ...newFiles])].filter((f) => COMPARABLE.some((p) => f.startsWith(p))); + for (const f of names.sort()) { + const o = oldFiles.includes(f), n = newFiles.includes(f); + if (o && !n) { report.files.push({ file: f, verdict: "ONLY-OLD" }); report.verdict = "DIFF"; continue; } + if (!o && n) { report.files.push({ file: f, verdict: "ONLY-NEW" }); report.verdict = "DIFF"; continue; } + const a = normalizeText(fs.readFileSync(path.join(oldDir, f), "utf8"), base); + const b = normalizeText(fs.readFileSync(path.join(newDir, f), "utf8"), base); + report.files.push({ file: f, verdict: a === b ? "SAME" : "DIFF", ...(a === b ? {} : { hint: firstDiff(a, b) }) }); + if (a !== b) report.verdict = "DIFF"; + } + // P2-⑪ NEW 侧残余文件哨:白名单(01-/02- 罐装种子+09-budget.json CLOSE 落件声明+ + // .v2-claim.json 健康轮轮目录认领件声明)之外、又不在 COMPARABLE 闭集的文件 = + // 未声明新面 → DIFF 记 ONLY-NEW-EXTRA。防「闭集外新面盲」:COMPARABLE 只防已知面 + // 漂移,新落件若无此哨则静默漏过。判定函数导出供哨正例单测(复核 P2-2)。 + for (const f of newFiles) + if (isUndeclaredNewFile(f)) { + report.files.push({ file: f, verdict: "ONLY-NEW-EXTRA", hint: "闭集外未声明新面" }); + report.verdict = "DIFF"; + } + const artifactN = report.files.length; // 可比工件条目数(不含后续台账行;P1-1 空集判据) + // 台账行(RETRO 写面;行形 | 日期 | 轮次 | model | 用量 | 备注 |)。语义面=行存在+ + // 日期/轮次/model/备注轮 id;usage 计数细胞归一(两套 harness 计量边界合法差), + // cost 细胞逐字比(faux 恒 0;生产 shadow 期 cost 差即真 DIFF——月度准入只吃 cost)。 + const normLedger = (t) => normalizeText(t, base).replace(/in=\d+ out=\d+ total=\d+/g, "in= out= total="); + const oldLed = fs.existsSync(path.join(old.ops, "ledger.md")) ? fs.readFileSync(path.join(old.ops, "ledger.md"), "utf8") : ""; + const newLed = fs.existsSync(path.join(neu.ops, "ledger.md")) ? fs.readFileSync(path.join(neu.ops, "ledger.md"), "utf8") : ""; + const ledSame = normLedger(oldLed).trim() === normLedger(newLed).trim(); + report.files.push({ file: "ledger.md", verdict: ledSame ? "SAME" : "DIFF", ...(ledSame ? {} : { hint: `OLD:[${oldLed.trim().split("\n").at(-1)}] NEW:[${newLed.trim().split("\n").at(-1)}]` }) }); + if (!ledSame) report.verdict = "DIFF"; + if (sideOk(oldR) && sideOk(newR) && artifactN === 0) { + report.verdict = "DIFF"; // P1-1:双面 ok 但可比集空=闸门失败(种子/闭集漂移),不假 SAME + report.files.push({ file: "(comparable-set)", verdict: "EMPTY", hint: "双面 ok 但可比工件集为空" }); + } + if (opts.keep) report.keepDirs = { old: old.tmp, new: neu.tmp }; + else { fs.rmSync(old.tmp, { recursive: true, force: true }); fs.rmSync(neu.tmp, { recursive: true, force: true }); } + return report; +} +function firstDiff(a, b) { + const la = a.split("\n"), lb = b.split("\n"); + for (let i = 0; i < Math.max(la.length, lb.length); i++) + if (la[i] !== lb[i]) return `L${i + 1}: OLD=${(la[i] ?? "").slice(0, 120)} | NEW=${(lb[i] ?? "").slice(0, 120)}`; + return "length"; +} + +// ── CLI ── +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : undefined; }; + const roundId = args[0] && !args[0].startsWith("--") ? args[0] : undefined // 位置首参=roundId(终审修复:flag 先于位置参时原 find 会把 flag 值误当 id); + if (!roundId) { process.stderr.write("用法:node shadow-diff.mjs [--fixtures DIR] [--keep]\n"); process.exitCode = 2; } // exitCode 而非 exit:stdout 写入异步落管,process.exit 可截断报告行(旧 pipeline.mjs:547 判例) + else { + let r; + try { + r = await shadowDiff({ roundId, fixturesDir: flag("fixtures") ? path.resolve(flag("fixtures")) : undefined, keep: args.includes("--keep") }); + } catch (e) { // P1-2:运行期异常 exit 3(与 DIFF 的 1 分码) + process.stderr.write(`${e.stack ?? e}\n`); + process.exitCode = 3; + } + if (r) { + for (const f of r.files) process.stdout.write(`${f.verdict.padEnd(9)} ${f.file}${f.hint ? ` ${f.hint}` : ""}\n`); + if (!r.sides.old.envelope?.ok) process.stdout.write(`OLD envelope: ${JSON.stringify(r.sides.old.envelope)}\n`); + if (!r.sides.new.envelope?.ok) process.stdout.write(`NEW envelope: ${JSON.stringify(r.sides.new.envelope)}\n`); + process.stdout.write(`VERDICT ${r.verdict}(old.exit=${r.sides.old.code} new.exit=${r.sides.new.code})\n`); + process.exitCode = r.verdict === "SAME" ? 0 : 1; + } + } +} diff --git a/ops/agent-v2/tasks/budget-guard.mjs b/ops/agent-v2/tasks/budget-guard.mjs new file mode 100644 index 00000000..edf393cd --- /dev/null +++ b/ops/agent-v2/tasks/budget-guard.mjs @@ -0,0 +1,109 @@ +// tasks/budget-guard.mjs — v2 双点执法接线面(票 #226;spec 定案 3:接线点在 entry/tasks 层, +// 不 hook/memo 化)。旧管线接线参考(读码定位,ops/agent/pipeline.mjs): +// 步前准入 = runPipeline/agent.mjs runRound 的 budget.admitOrRefuse(月度 kill switch; +// 拒跑零花费,先于模型装载/harness 开启) +// 环内①③ attempt 前查 = runModelStage 检查点(calls+1>帽、墙钟超=违界即停,不重试不降级) +// 环内②④ 结算后查 = runModelStage 检查点(token 轨迹/花费;发现轮 tokenBudget +// undefined 即跳过——#207 T1 发现轮无 token 界) +// 环内③ 步边界查 = runStages 每步体前(resume 沿原 started_at 续算) +// 本 guard 为上述检查点的 v2 同构纯函数面:决策对象 {ok, code?, message?, remaining?} +// (allow/deny+剩余量,供同参哈希对拍逐条断言);执法真源=旧 budget.mjs 纯函数 +// (overCallsCap/overWallClock/overTokenBudget/overSpendCap),本文件不另立判据; +// 文案逐字对齐旧检查点(含 per-type 旋钮名指认,#207 T1)。 +// T3/T4 相位边界消费本面(相位 handler 尾/头调 stepBoundary/preModelCall);entry.mjs +// 在 submit 前后接线(轮粒度)。月帽语义(DESIGN.md §6 预算四界行):每轮 ¥3 ∧ 月度 ¥30, +// 同台账(ledger.md)并发安全,flock 锁内读,拒跑留痕——budget.mjs 原样,零改动。 +import path from "node:path"; +import * as budget from "../budget.mjs"; // 零复制 seam(单一真源=旧管线 budget.mjs) + +const ROOT = path.resolve(import.meta.dirname, "..", "..", ".."); // 仓根(本文件居 tasks/ 子层,三层上移;entry.mjs 居浅一层故两层,两处落点须同仓根——对拍由 budget-parity 台账落位测试钉死。不 import entry 防环) + +// per-type 旋钮读面(pipeline.mjs mkCtx 同选择:discover→readDiscoverKnobs,其余→readBudgetKnobs; +// 月帽/dup 两键两族共享同键同缺省)。非法值 throw {usage:true} → CLI 用法错 exit 2(不带病跑轮)。 +export const roundKnobs = (env, roundType = "health") => + (roundType === "discover" ? budget.readDiscoverKnobs : budget.readBudgetKnobs)(env ?? process.env); + +// per-type 旋钮名指认(旧 runModelStage knobSrc 同表:报错文案指向真读键)。 +export const knobNames = (roundType) => roundType === "discover" + ? { calls: "OPS_DISCOVER_MAX_MODEL_CALLS", wall: "OPS_DISCOVER_WALL_CLOCK_S", spend: "OPS_DISCOVER_SPEND_CAP_CNY" } + : { calls: "OPS_PIPELINE_MAX_MODEL_CALLS", wall: "OPS_PIPELINE_WALL_CLOCK_S", spend: "OPS_PIPELINE_SPEND_CAP_CNY" }; + +// 台账路径推导(旧 agent.mjs runRound 同形:LEDGER_FILE > DOCS_ROOT/ops/ledger.md; +// 月帽是全 agent 面共享底线,shadow 期与旧管线读同一 ledger.md,同裁决同留痕)。 +export function resolveLedgerPath(env = process.env) { + return env.LEDGER_FILE ?? process.env.LEDGER_FILE + ?? path.join(env.DOCS_ROOT ?? path.join(ROOT, "docs"), "ops", "ledger.md"); +} + +// —— 步前准入(双点之一;旧 runPipeline/agent.mjs runRound 的 admitOrRefuse 同壳)—— +// est=本轮预算上界(OPS_PIPELINE_BUDGET_CNY,与旧 mkCtx/agent.mjs 同读同校验同文案); +// 拒跑留痕([admission] log+台账行)全在旧 admitOrRefuse 内(原样);返回追加 knobs/est 读数。 +export async function admitRound({ env = process.env, log = () => {}, roundType = "health", ledgerFile } = {}) { + const knobs = roundKnobs(env, roundType); + const estRaw = env.OPS_PIPELINE_BUDGET_CNY ?? process.env.OPS_PIPELINE_BUDGET_CNY ?? "0.05"; + const estCny = +estRaw; + if (!Number.isFinite(estCny) || estCny < 0) + throw Object.assign(new Error(`OPS_PIPELINE_BUDGET_CNY 须为非负数,得到:${estRaw}`), { usage: true }); + const file = ledgerFile ?? resolveLedgerPath(env); + const admission = await budget.admitOrRefuse({ ledgerFile: file, capCny: knobs.monthlyCapCny, estCny, env, log, round: roundType }); + return { ...admission, knobs, estCny, ledgerFile: file }; +} + +// —— 环内①③ attempt 前查(双点之二;旧 runModelStage 检查点同构,①→③ 顺序)—— +// 剩余量=符号值(帽−已用;负=超出):calls 为 attempt 后口径(cap−calls−1),对拍逐条断言用。 +export function preModelCall({ roundType = "health", knobs, modelCalls, startedAtIso, now = Date.now() }) { + const src = knobNames(roundType); + if (budget.overCallsCap(modelCalls, knobs.maxModelCalls)) + return { ok: false, code: "budget_exceeded", fatalBudget: true, + message: `预算四界①:模型调用将超帽(已计 ${modelCalls}+1 > ${knobs.maxModelCalls},${src.calls})`, + remaining: { calls: knobs.maxModelCalls - modelCalls } }; + if (budget.overWallClock(startedAtIso, knobs.wallClockS, now)) + return { ok: false, code: "wall_clock_exceeded", fatalBudget: true, + message: `预算四界③:墙钟 ${budget.elapsedWallS(startedAtIso, now).toFixed(1)}s 超帽 ${knobs.wallClockS}s(${src.wall})`, + remaining: { calls: knobs.maxModelCalls - modelCalls, wallS: knobs.wallClockS - budget.elapsedWallS(startedAtIso, now) } }; + return { ok: true, remaining: { + calls: knobs.maxModelCalls - modelCalls - 1, + wallS: knobs.wallClockS - budget.elapsedWallS(startedAtIso, now), + } }; +} + +// —— 环内③ 步边界查(旧 runStages 步边界同构:步体前查,超=终态先于步体)—— +export function stepBoundary({ roundType = "health", knobs, startedAtIso, now = Date.now() }) { + if (!budget.overWallClock(startedAtIso, knobs.wallClockS, now)) + return { ok: true, remaining: { wallS: knobs.wallClockS - budget.elapsedWallS(startedAtIso, now) } }; + return { ok: false, code: "wall_clock_exceeded", + message: `预算四界③:墙钟 ${budget.elapsedWallS(startedAtIso, now).toFixed(1)}s 超帽 ${knobs.wallClockS}s(${knobNames(roundType).wall})`, + remaining: { wallS: knobs.wallClockS - budget.elapsedWallS(startedAtIso, now) } }; +} + +// —— 环内②④ 结算后查(旧 runModelStage 检查点同构,②→④ 顺序;发现轮无 token 界=跳过②)—— +export function postModelCall({ roundType = "health", knobs, totalTokens, costCny }) { + const src = knobNames(roundType); + if (knobs.tokenBudget !== undefined && budget.overTokenBudget(totalTokens, knobs.tokenBudget)) + return { ok: false, code: "budget_exceeded", fatalBudget: true, + message: `预算四界②:token 轨迹 ${totalTokens} 超预算 ${knobs.tokenBudget}(OPS_PIPELINE_TOKEN_BUDGET)`, + remaining: { tokens: knobs.tokenBudget - totalTokens, spendCny: knobs.spendCapCny - costCny } }; + if (budget.overSpendCap(costCny, knobs.spendCapCny)) + return { ok: false, code: "budget_exceeded", fatalBudget: true, + message: `预算四界④:花费 ¥${costCny.toFixed(8)} 超帽 ¥${knobs.spendCapCny}(${src.spend})`, + remaining: { tokens: knobs.tokenBudget === undefined ? undefined : knobs.tokenBudget - totalTokens, spendCny: knobs.spendCapCny - costCny } }; + return { ok: true, remaining: { + tokens: knobs.tokenBudget === undefined ? undefined : knobs.tokenBudget - totalTokens, + spendCny: knobs.spendCapCny - costCny, + } }; +} + +// —— 环内 usage 聚合:轮次 token/花费 = settle 后 session usage − submit 前 snapshot —— +// v2 口径(t1-notes 风险7:预算口径自算保留):UsageState(harness.usage() 形状,models+tools +// 两桶,pi-ai Usage.totalTokens/cost.total)求和后差分=本轮增量;≡ 旧 agent.mjs subscribe +// 回合累计口径(in/out/total/cost.total 逐回合累加)。reasoning 计入口径:pi-ai Usage +// output 已含 reasoning,totalTokens 为 provider 实报总量,与旧面同源无分裂。 +export function sumUsageState(state) { + let totalTokens = 0, costCny = 0; + for (const bucket of [state?.models, state?.tools]) + for (const u of Object.values(bucket ?? {})) { + totalTokens += u?.totalTokens ?? 0; + costCny += u?.cost?.total ?? 0; + } + return { totalTokens, costCny }; +} diff --git a/ops/agent-v2/tasks/discover-round.mjs b/ops/agent-v2/tasks/discover-round.mjs new file mode 100644 index 00000000..9b001862 --- /dev/null +++ b/ops/agent-v2/tasks/discover-round.mjs @@ -0,0 +1,353 @@ +// tasks/discover-round.mjs — 发现轮 defineTask 化(票 #227 = T3;spec +// docs/superpowers/specs/2026-10-28-ops-agent-pidurable-migration-design.md; +// 旧发现六步管线化语义 = ADR 0010 / #207 T1)。 +// +// ── 相位映射(旧步 → v2 相位;语义逐相位零复制驱动旧实现)── +// SCAN→scan SHORTLIST→shortlist MEASURE→measure ENQUEUE→enqueue RETRO→retro CLOSE→close +// 脚本相位(scan/measure/enqueue/retro/close)直接 runStage 旧步(ops/agent/pipeline/steps.mjs +// 零 import 改动);SHORTLIST(唯一模型步)由本文件的会话提交驱动(见下)。 +// FAILED 旧终态书挡 → v2 terminal outcome {status:"failed"}(等价语义位)。 +// +// ── 月轮↔conversation 粒度决策(T0 未决1;依 t1-notes 建议4)── +// **每发现轮 = 自有 conversation**(harness.createConversation ownerless;轮任务 +// ownership={kind:"conversation"} 绑定该会话;root 会话只做骨架/引导,不承载轮)。 +// 理由:①旧管线每模型步全新 Agent 上下文(faux.mjs runRealStep),跨轮零渗漏是旧律, +// v2 一轮一会话同律保持;②轮的 transcript+UsageDoc(=spec 定案的台账真源)天然按轮 +// 闭合:轮预算/轮恢复/轮审计各自完备,不掺宿主骨架噪声;③崩溃 resume 同会话继续, +// requestId 幂等(见下)保证不双花;月度帽=跨轮共享(旧律,准入面 admitRound 已在 +// entry 执法,task 内不重复)——粒度选**轮**不选**月**,月粒度会把无关轮的失败恢复 +// 耦合在同一会话里(一坏俱坏),违背 spec 定案 5「一轮=一次 submit/任务运行」。 +// T4(#228 健康轮)同法。 +// +// ── requestId 纪律(幂等)── +// attempt 1 = round--shortlist;attempt ≥2(上次已结算但答案坏:非 JSON 降级 +// 不算坏,empty_text/infra 才重发)= round--shortlist-a。崩溃发生在提交后、 +// 结算前 → 同 key 重放 = pi-durable 恰好一次语义,恢复零新调用、零双花。 +// +// ── 预算执法(T2 遗留1+2 收口)── +// startedAtIso 在 initial() 一次性落 checkpoint,任何相位/恢复不改写 → 崩溃 resume +// 墙钟**续算**(遗留1);预算执法逐 attempt 双点(遗留2): +// · 每相位头 stepBoundary(③墙钟)——脚本相位唯一执法点(旧律:脚本步无模型面); +// · shortlist 相位内,每次 attempt 提交前 preModelCall(①调用数③墙钟)+ 提交结算后 +// postModelCall(②token[发现轮无 token 界=skip]④花费),usage 取会话 UsageDoc 活和 +// (sumUsageState;模型自报,与旧引擎同源)。 +// 违约 → terminal failed outcome(旧 fatalBudget 不重试律;code=budget_exceeded| +// wall_clock_exceeded,message 逐字 budget-guard 旧文案)。 +// checkpoint 在相位边界提交 modelCalls/usage;相位中途崩溃丢当次 attempt 计数 +// (requestId 重放恢复,不双花;少算 = 旧律宁少算)。 +// +// ── 凭证红线 ── env 不入 checkpoint/input(过程 env 现读,同旧 runResume 现境面); +// input 只带 roundId/knobs(轮始冻结)/budgetCny/modelFace/fixturesDir/admission/modelRef。 +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { defineTask, UsageDoc } from "@earendil-works/pi-durable"; +import { ctxShape, DISCOVER_PIPELINE } from "../../agent/pipeline.mjs"; // 零复制:ctx 形状+步定义表(单一真源) +import { runStage, modelIO } from "../../agent/pipeline/steps.mjs"; // 零复制:脚本步+模型 IO +import * as gates from "../../agent/pipeline/gates.mjs"; // 零复制:门评估+工件(gates.json) +import { readBreakerKnobs } from "../../agent/tools/breaker.mjs"; // 零复制(写侧经宿主适配器,见 tools/discovery-tools.mjs) +import { preModelCall, postModelCall, stepBoundary } from "./budget-guard.mjs"; // T2 执法面(文案/界值零漂移;token/cost 之和在本文件 roundUsage 逐字段展开) +import { ensureRoundHost, roundDupRegistries } from "../tools/discovery-tools.mjs"; // 轮级发现宿主(工具面+熔断写侧随迁) + +const sha = (s) => crypto.createHash("sha256").update(s).digest("hex").slice(0, 16); + +export const DISCOVER_TASK_NAME = "ops.discover-round"; +export const DISCOVER_PHASES = Object.freeze(["scan", "shortlist", "measure", "enqueue", "retro", "close"]); +const STAGE_OF_PHASE = Object.freeze({ scan: "SCAN", shortlist: "SHORTLIST", measure: "MEASURE", enqueue: "ENQUEUE", retro: "RETRO", close: "CLOSE" }); +const defOf = (stage) => DISCOVER_PIPELINE.find((d) => d.name === stage); +const nextPhase = (p) => DISCOVER_PHASES[DISCOVER_PHASES.indexOf(p) + 1] ?? null; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); // ops/agent-v2/tasks → 仓根(3 层;4 层会落到 .worktrees/ 份外——T2 F1 同类病,钉死) +function resolveOpsRoot(env = process.env) { // 旧 opsRootOf 同算式(未导出,本地同形;drift 由 golden 对拍钉) + return path.join(env.DOCS_ROOT ?? path.join(REPO_ROOT, "docs"), "ops"); +} + +// 崩溃钩子(测试面;env 现读不入档):进入指定相位头即 SIGKILL 自杀——上一相位 checkpoint +// 已提交,等价「该相位头进程崩溃」。真崩溃恢复路径与测试同一代码路径,零 mock。 +function crashHook(phase) { + if (process.env.OPS_V2_CRASH_AFTER_PHASE === phase) process.kill(process.pid, "SIGKILL"); +} + +// ── 旧 ctx 重建(checkpoint→ctxShape;每相位一次,环境现读)── +function buildCtx(input, checkpoint) { + const env = process.env; // 旧律:env 现读不入档(凭证红线;DOCS_ROOT 等由启动进程环境携带) + const ctx = ctxShape({ + type: "discover", + roundId: input.roundId, + opsRoot: resolveOpsRoot(env), + env, + log: (line) => process.stderr.write(`[discover:${checkpoint.phase}] ${line}\n`), // P2-⑦ stdout 只留末行信封(ENVELOPE.md 冻结面);进度行走 stderr(run-health/model-round 同律) + fixturesDir: input.fixturesDir, + modelFace: input.modelFace, + budgetCny: input.budgetCny, + budgetKnobs: input.knobs, + breakerKnobs: readBreakerKnobs(env), + state: { // 旧 state.json 面(CLOSE/RETRO 读字段同旧;checkpoint 为真源,本对象仅为旧步接口适配) + schema: 1, + round_id: input.roundId, + type: "discover", + pipeline_mode: "pipeline", + status: "running", + stages: checkpoint.stages.map((r) => r.name), + budget_cny: input.budgetCny, + budget_knobs: input.knobs, + fixtures_dir: input.fixturesDir, + model_face: input.modelFace, + prompt_hashes: checkpoint.prompt_hashes, + started_at: checkpoint.startedAtIso, // 墙钟续算真源(遗留1) + updated_at: new Date().toISOString(), + resume_count: 0, + replay_of: null, + monthly_admission: input.admission ?? null, + }, + }); + ctx.modelCalls = checkpoint.modelCalls; + ctx.usage = { ...checkpoint.usage }; + return ctx; +} + +// ── 建轮(旧 runPipeline 建轮段同义:同 id 守卫+mkdir+state 骨架;pipeline.mjs:397-399)── +// v2 真源=checkpoint;state.json 降级为进度镜像(观察面/旧工具兼容):每相位 advance 后 +// 尽力重写。守卫只在全新启动(checkpoint.stages 空)时判:state.json 同 round 且 stages +// 非空=旧律同 id 重跑(bad_round,走 replay);stages 空=自家崩溃在建轮窗口,续跑; +// round_id 不符=异物树,bad_round。resume(stages 非空)不判不重建,只镜像推进。 +function bootstrapRoundDir(ctx, checkpoint) { + const stateFile = path.join(ctx.dir, "state.json"); + if (checkpoint.stages.length === 0) { + let prior = null; + try { prior = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch { /* 无=全新 */ } + if (prior && (prior.round_id !== ctx.roundId || (Array.isArray(prior.stages) && prior.stages.length > 0))) + throw Object.assign(new Error(`rounds/${ctx.roundId} 已存在(同 id 重跑走 replay)`), { code: "bad_round" }); + fs.mkdirSync(ctx.dir, { recursive: true }); + fs.writeFileSync(stateFile, JSON.stringify(ctx.state, null, 2) + "\n"); // 骨架镜像(崩溃可重写:stages 空判掎同轮自救) + return; + } + fs.mkdirSync(ctx.dir, { recursive: true }); // resume:目录必在(防御);镜像在 advance 落 +} +function mirrorState(ctx, checkpoint) { // 进度镜像(best-effort;真源=checkpoint,失败不炸轮) + try { + ctx.state.stages = checkpoint.stages.map((r) => r.name); + ctx.state.updated_at = new Date().toISOString(); + fs.writeFileSync(path.join(ctx.dir, "state.json"), JSON.stringify(ctx.state, null, 2) + "\n"); + } catch { /* 镜像失败不阻断相位推进(哲学①:证据在 checkpoint+工件) */ } +} + +// 相位完成落账:提交 checkpoint(相位推进+step 记录+计数),恢复点=相位边界(旧律同粒度)。 +async function advance(runtime, context, checkpoint, patch = {}, ctx = null) { + await runtime.commit((tx, cur) => ({ + status: "running", + checkpoint: { ...cur.checkpoint, ...checkpoint, ...patch }, + }), context); + if (ctx) mirrorState(ctx, checkpoint); // 尽力镜像(真源=checkpoint;失败不炸轮) +} +async function finishFailed(runtime, context, error) { + return runtime.commit(() => ({ + status: "terminal", + outcome: { status: "failed", error }, + }), context); +} +async function finishOk(runtime, context, result) { + return runtime.commit(() => ({ + status: "terminal", + outcome: { status: "completed", result }, + }), context); +} + +// 信封(旧引擎 composeEnvelope 发现轮分支同形;pkg=null 恒、queue_writes 恒空——PROPOSE +// 只写不动作是旧律;键序=旧 golden pipeline_ok 键序断言面,T5 冻结前不增删)。 +function composeEnvelope(checkpoint, ctx) { + return { + ok: true, + round: "discover", + pipeline: { + round_id: checkpoint.roundId, + resumed_from: null, // v2 恢复证据在 task record(resume 后 startedAtIso 续算)+gates.json 双录;本字段恒 null(T5 冻结面再议) + replayed_of: null, + stages: checkpoint.stages, + }, + pkg: null, + queue_writes: [], + usage: { ...ctx.usage }, + verify: { ok: true, cited: 0 }, + }; +} + +// ── 脚本相位(通用:预算墙钟头执法 → runStage 旧步 → 门评估+落 gates.json → checkpoint)── +async function scriptPhase(task, runtime, context, phase) { + const input = task.input; + const cur = task.state.checkpoint; + crashHook(phase); + const b = stepBoundary({ roundType: "discover", knobs: input.knobs, startedAtIso: cur.startedAtIso }); // ③(每相位头;遗留2 脚本面) + if (!b.ok) return finishFailed(runtime, context, { message: b.message, detail: { code: b.code, stage: STAGE_OF_PHASE[phase] } }); + const def = defOf(STAGE_OF_PHASE[phase]); + const ctx = buildCtx(input, cur); + if (phase === "scan") { + try { bootstrapRoundDir(ctx, cur); } // 建轮同义(bad_round 域错→终态,同下) + catch (e) { // P0-① 实测面:原裸抛逃逸 handler → 框架 fault+重启循环,真码永不落 outcome、信封恒 infra——收进同款 finishFailed(detail.code)终态 + return finishFailed(runtime, context, { message: `${def.name}:${e.message}`, detail: { code: e.code ?? "infra", stage: def.name } }); + } + } + // dup 登记表跨相位同对象(CLOSE dup_top3 读数面;旧 ctx.dupRegistry 单例律) + const dup = roundDupRegistries.get(runtime.conversationId); + if (dup) ctx.dupRegistry = dup; + else ensureRoundHost(ctx, runtime.conversationId); // 首相位(scan)亦预立:后续相位携带同表 + const stage = { name: def.name, status: "done", attempts: 1, dur_ms: 0, usage: null }; + const t0 = Date.now(); + let rec; + try { + rec = await runStage(def, ctx); // 零复制旧步(RETRO 可能 {degraded} + } catch (e) { // 域失败=终态(旧 fail 三元组;handler 不抛=pi-durable fault 不消耗 restart) + return finishFailed(runtime, context, { message: `${def.name}:${e.message}`, detail: { code: e.code ?? "infra", stage: def.name } }); + } + stage.dur_ms = Date.now() - t0; + if (rec?.degraded) { stage.status = "degraded"; stage.reason = rec.degraded; } // 旧 degrade 路径(RETRO skip_retro) + // 脚本步恒零模型面:stage.usage=null(旧律;旧 golden pipeline_ok 断言=各步 usage 求和==信封 usage, + // 轮累计回填每步会把 SHORTLIST 计数回声 6 次) + const staged = [...cur.stages, stage]; + if (phase === "close") ctx.envelope = composeEnvelope({ ...cur, stages: staged }, ctx); // G_CLOSE 前置(旧引擎职责位;含 CLOSE 自身=旧门读 6 stages 序) + const gate = gates.evalStageGate(def, ctx); + if (rec?.skipGate) gates.appendGateBypass(ctx.dir, ctx.roundId, def, String(rec.degraded)); + else gates.appendGate(ctx.dir, ctx.roundId, gate); + if (!gate.pass) return finishFailed(runtime, context, { message: `GATE:${def.name}:${gate.reason}`, detail: { code: gate.code ?? "infra", stage: def.name } }); // 门红=终态(旧 fail-closed) + const next = nextPhase(phase); + const checkpoint = { ...cur, ...(next ? { phase: next } : {}), stages: [...cur.stages, stage], modelCalls: ctx.modelCalls, usage: { ...ctx.usage } }; // 相位推进=phase 字段前移(缺它=同相位重跑死循环) + return next + ? advance(runtime, context, checkpoint, {}, ctx) + : finishOk(runtime, context, composeEnvelope(checkpoint, ctx)); // close 终态=result=信封(旧信封同形) +} + +// ── SHORTLIST 相位(唯一模型步;自有会话提交)── +async function shortlistPhase(task, runtime, context) { + const input = task.input; + const cur = task.state.checkpoint; + crashHook("shortlist"); + const b = stepBoundary({ roundType: "discover", knobs: input.knobs, startedAtIso: cur.startedAtIso }); // ③ + if (!b.ok) return finishFailed(runtime, context, { message: b.message, detail: { code: b.code, stage: "SHORTLIST" } }); + + const def = defOf("SHORTLIST"); + const ctx = buildCtx(input, cur); + const io = modelIO(def); + const { systemPrompt, userMsg } = io.input(ctx); // 旧输入构造零复制(d1-scan.json 候选宇宙) + const convId = runtime.conversationId; // 绑定会话=轮会话(启动器创建;头注「轮会话来源」) + return shortlistSubmit(task, runtime, context, { ctx, def, io, systemPrompt, userMsg, convId }); +} + +// 提交腿(attempt 循环=旧 retry≤1 面;requestId 纪律见头注) +async function shortlistSubmit(task, runtime, context, s) { + const input = task.input; + const cur = task.state.checkpoint; + const attempt = cur.attempt ?? 1; + const requestId = attempt <= 1 ? `round-${input.roundId}-shortlist` : `round-${input.roundId}-shortlist-a${attempt}`; // 纪律形;同 attempt 崩溃重放同 key + + const b = preModelCall({ roundType: "discover", knobs: input.knobs, modelCalls: cur.modelCalls, startedAtIso: cur.startedAtIso }); // ①③(每 attempt 提交前;遗留2) + if (!b.ok) return finishFailed(runtime, context, { message: b.message, detail: { code: b.code, stage: "SHORTLIST" } }); + + const conversation = await runtime.conversation(s.convId, context); // ConversationHandle(绑定会话;invocation 内有效) + if (!conversation) return finishFailed(runtime, context, { message: "SHORTLIST:轮会话缺席", detail: { code: "infra", stage: "SHORTLIST" } }); + ensureRoundHost(s.ctx, s.convId); // 轮级发现宿主(模型面工具;faux=罐装方法零网络) + + const submission = await conversation.submit({ type: "input", content: s.userMsg, requestId }, context); + const settled = await submission.wait(context); // 恰好一次;同 key 崩溃重放不双花 + if (settled.status !== "done") { // unanswered(中止/机故障)→ 旧 retry≤1:attempt+1 新 requestId 再来 + if (attempt >= 2) return finishFailed(runtime, context, { message: `SHORTLIST:submission_${settled.status}`, detail: { code: "model_face", stage: "SHORTLIST" } }); + // P0-③ attempt 即调用(旧律 budget.mjs:34「重试同计」):失败 attempt 同步入账 modelCalls, + // 否则相位边界提交的 cur.modelCalls+1 会漏掉全部失败回合;usage 不补(UsageDoc 会话 + // 累计面,失败回合 usage 已自然含在下一次 usageNow 读数里) + return advance(runtime, context, cur, { attempt: attempt + 1, modelCalls: cur.modelCalls + 1 }, s.ctx); + } + + // 答案提取:会话上下文=最后一条 assistant 文本(旧 runRealStep text 面) + const view = await runtime.context(s.convId, context); + const messages = view.entries.filter((e) => e.kind === "pi.assistant").flatMap((e) => e.model); + const last = messages[messages.length - 1]; + const text = last?.content?.filter((c) => c.type === "text").map((c) => c.text).join("") ?? ""; + if (!text && attempt < 2) return advance(runtime, context, cur, { attempt: attempt + 1, modelCalls: cur.modelCalls + 1 }, s.ctx); // empty_text 面重发(新 requestId);P0-③ 同上:失败 attempt 同计 + if (!text) return finishFailed(runtime, context, { message: "SHORTLIST:empty_text", detail: { code: "empty_text", stage: "SHORTLIST" } }); + + const usageNow = await roundUsage(runtime, s.convId, context); + const post = postModelCall({ roundType: "discover", knobs: input.knobs, totalTokens: usageNow.totalTokens, costCny: usageNow.cost }); // ②(skip:无 token 界)④ + if (!post.ok) return finishFailed(runtime, context, { message: post.message, detail: { code: post.code, stage: "SHORTLIST" } }); + + s.ctx.modelCalls = cur.modelCalls + 1; + s.ctx.usage = usageNow; + s.io.consume(s.ctx, null, { text }); // 旧消费零复制:JSON 解析失败=降级空清单(步 done,零候选合法,轮不炸) + + // transcript(旧 faux.mjs 行型逐字同构:input/registry_seed/message;prompt hash 同算法。 + // message 行仅取 role/content/stopReason——pi 会话 entry 的 model 消息带 provider 元数据 + // (api/provider/model/timestamp/usage/durationMs),旧事件流面无此字段,多写=对拍噪音) + const host = s.ctx.discoverHost; + const lines = [ + JSON.stringify({ type: "input", stage: "SHORTLIST", system_sha: sha(s.systemPrompt), user_sha: sha(s.userMsg), meta: null }), + ...(host?.seeds ?? []).map((sd) => JSON.stringify({ type: "registry_seed", call_id: sd.call_id, tool: sd.tool, sc: sd.sc })), + ...messages.map((m) => JSON.stringify({ type: "message", role: m.role, content: m.content, stopReason: m.stopReason ?? null })), + ]; + fs.appendFileSync(path.join(s.ctx.dir, "d2-shortlist-transcript.jsonl"), lines.join("\n") + "\n"); + + const gate = gates.evalStageGate(defOf("SHORTLIST"), s.ctx); + gates.appendGate(s.ctx.dir, s.ctx.roundId, gate); + if (!gate.pass) return finishFailed(runtime, context, { message: `GATE:SHORTLIST:${gate.reason}`, detail: { code: gate.code ?? "infra", stage: "SHORTLIST" } }); + + const checkpoint = { + ...cur, phase: "measure", attempt: 1, + stages: [...cur.stages, { name: "SHORTLIST", status: "done", attempts: attempt, dur_ms: 0, usage: { ...usageNow } }], + modelCalls: s.ctx.modelCalls, + usage: { ...usageNow }, + prompt_hashes: { ...cur.prompt_hashes, SHORTLIST: sha(`${s.systemPrompt}\n${s.userMsg}`) }, + }; + return advance(runtime, context, checkpoint, {}, s.ctx); +} + +// ── 轮会话来源(粒度决策的落地形态)── +// 会话由**轮启动器**(run-discover.mjs;golden/entry 同一程序面)在任务外创建: +// harness.createConversation 带 agent 面(model=轮始冻结 modelRef + instructions= +// discover/shortlist 提示词 + extensions=[ops-discover-tools]);任务经 +// Conversation.commit(tx.createTask) 以 ownership={kind:"conversation"} 绑进该会话。 +// 任务内不再自建会话:TaskRuntime 的 tx.createConversation 无 agent 面且 +// ConversationHandle 无 configure,任务内造会话=无模型面死路(实测 API 面,1.4.0 +// dist/types.d.ts Tx.createConversation 仅 ownership;harness/types.d.ts ConversationHandle)。 +// 相位内会话操作全走 runtime.conversationId(=绑定会话):ConversationHandle submit / +// runtime.context 读面 / runtime.documentState(UsageDoc) 计量面。 + +// ── defineTask(initial 只定相;phases 按 checkpoint.phase 分发)── + +// 会话 UsageDoc 活和(模型自报=旧引擎 usage 同源;字段面=旧 ctx.usage 形状 +// {input,output,totalTokens,cost}——RETRO 台账行 toFixed(8) 等旧步消费面零改)。 +// 注:TaskRuntime 只暴露 snapshot(值直读),无 Session 级 documentState(实测 1.4.0)。 +async function roundUsage(runtime, convId, context) { + const v = await runtime.snapshot(UsageDoc, convId, context) ?? {}; + let input = 0, output = 0, totalTokens = 0, cost = 0; + for (const bucket of [v.models, v.tools]) + for (const u of Object.values(bucket ?? {})) { + input += u?.input ?? 0; output += u?.output ?? 0; + totalTokens += u?.totalTokens ?? 0; cost += u?.cost?.total ?? 0; + } + return { input, output, totalTokens, cost }; +} + +// ── defineTask(initial 只定相;phases 按 checkpoint.phase 分发)── +export const discoverRoundTask = defineTask({ + name: DISCOVER_TASK_NAME, + version: 1, + initial: (input) => ({ + phase: "scan", + roundId: input.roundId, + startedAtIso: new Date().toISOString(), // 墙钟真源:initial 一次,恢复不改写(遗留1) + modelCalls: 0, + usage: { input: 0, output: 0, totalTokens: 0, cost: 0 }, + stages: [], + prompt_hashes: {}, + attempt: 1, + }), + phases: { + scan: (task, runtime, context) => scriptPhase(task, runtime, context, "scan"), + shortlist: (task, runtime, context) => shortlistPhase(task, runtime, context), + measure: (task, runtime, context) => scriptPhase(task, runtime, context, "measure"), + enqueue: (task, runtime, context) => scriptPhase(task, runtime, context, "enqueue"), + retro: (task, runtime, context) => scriptPhase(task, runtime, context, "retro"), + close: (task, runtime, context) => scriptPhase(task, runtime, context, "close"), + }, + abort: async (_task, runtime, context) => { // P2-⑧ 中止终态留痕(model-round/health-round mkAbort 同款;缺省=框架 fault 面无终态,信封永不落) + await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context); + }, +}); diff --git a/ops/agent-v2/tasks/health-round.mjs b/ops/agent-v2/tasks/health-round.mjs new file mode 100644 index 00000000..b5be41af --- /dev/null +++ b/ops/agent-v2/tasks/health-round.mjs @@ -0,0 +1,402 @@ +// tasks/health-round.mjs — 健康轮 v2 任务(票 #228 T4):pipeline.mjs HEALTH_PIPELINE 十态 +// → defineTask 相位机,每态一相位;脚本步零复制调旧 steps.runStage/gates.evalStageGate(旧实现 +// 单源,本文件只造 ctx 喂它们);模型步=创建 ops-model-round 子任务(逐假设一实例,票 Q1), +// WAIT_MODEL join 相位收口 usage → deepFinalize(零复制)+G_DEEP 聚合门。 +// 失败三元组语义保留:retry≤def.fail.retry(handler 内圈,checkpoint.attempt 计数)→ +// perItem 降级(RETRO/ANCHOR_SWEEP degrade 面在脚本步内返回)→ 终态=commit FAILED 相位 +// 书挡 + outcome.failed(错误码闭集 ERROR_CODES)。G_CLOSE 回声哨逐字保留=零复制 +// gates.evalStageGate(CLOSE def)(信封单行/往返解析/预算读数≤帽对拍——spec 谱系原则:环内 +// 已执法,门只防工件漂移,非二次执法);CLOSE 步自身零复制 steps.runStage(09-budget.json +// 快照逐字节同旧:breaker snapshot 经 ops/agent-v2/breaker.mjs seam 同源旧 tools/breaker.mjs)。 +// 预算双点接线(票 #226):entry 层 admitRound 不在本任务(宿主职责,旧 runPipeline 同位); +// 相位 handler 消费 budget-guard:步边界 stepBoundary(旧 runStages 检查点③同构,相位级 +// startedAt 入 checkpoint——resume 沿原起算点续算,不重置)。requestId 幂等=子任务首 attempt 键。 +// 旧管线零改动红线;tasks/index.mjs 冲突(T3 同面)预期内留给 merger。 +import fs from "node:fs"; +import path from "node:path"; // MF1:rebuildRoundCtx 建轮目录用 path.join(漏 import 首相位即 ReferenceError;旧遗漏修复) +import { randomUUID } from "node:crypto"; +import { defineTask, configure } from "@earendil-works/pi-durable"; +import { HEALTH_PIPELINE } from "../../agent/pipeline.mjs"; +import * as steps from "../../agent/pipeline/steps.mjs"; +import { evalStageGate, appendGate, appendGateBypass } from "../../agent/pipeline/gates.mjs"; +import { STEP_ROUND } from "../../agent/pipeline/faux.mjs"; // 零复制:步→轮键映射(TRIAGE/SYNTH=health、DEEP=discover) +import { readBreakerKnobs } from "../../agent/tools/breaker.mjs"; // 零复制读面(轮始校验同旧 mkCtx) +import { ERROR_CODES } from "../../agent/error-codes.mjs"; +import { loadModels } from "../models.mjs"; // 零复制 seam(定案 7:装载器不动) +import { buildRoundCtx, opsRootOf, composeEnvelope, skipWhenNoNeedDeep } from "../round-ctx.mjs"; +import { roundKnobs, stepBoundary, postModelCall, knobNames } from "./budget-guard.mjs"; // P1-4:收口复查①②④ 面 +import { OPS_MODEL_ROUND, conversationUsage, conversationModelCalls } from "./model-round.mjs"; // P1-4:failed 子任务留痕计量面 +import { mkFauxFace, loadFauxFixture } from "../faux-face.mjs"; // 修复⑥:faux 面分派件(旧 faux.mjs 位等价副本) +import { OPS_PROBE_TOOLS } from "../tools/probes.mjs"; // DEEP 真面探针闭集(AgentChange.modelTools 要 ToolRegistration 对象非名字串——applyChange names() 映射 .name) + +const nowIso = () => new Date().toISOString(); +const defOf = (n) => HEALTH_PIPELINE.find((d) => d.name === n); +export const RUNNABLE = HEALTH_PIPELINE.filter((d) => d.kind === "script" || d.kind === "model").map((d) => d.name); // PROBE…RETRO(旧 runnableOf 同推导) +const MODEL_PHASES = new Set(["TRIAGE", "DEEP", "SYNTH"]); + +// ctx 重建:checkpoint.state 镜像旧 state.json 全字段(schema/round_id/stages/budget_knobs/ +// fixtures_dir/model_face/prompt_hashes/started_at/replay_of/finding_exits/monthly_admission)。 +export function rebuildRoundCtx(input, ck) { + const env = process.env; + const opsRoot = opsRootOf(env); + fs.mkdirSync(path.join(opsRoot, "rounds", String(input.roundId)), { recursive: true }); // 旧 runPipeline 建轮目录对应位(工件面仍磁盘;state 面=SQLite checkpoint) + const state = { + schema: 1, round_id: input.roundId, type: "health", pipeline_mode: "pidurable", + status: ck.failed ? "failed" : "in_flight", stage: ck.phase, stages: ck.state?.stages ?? [], + budget_cny: ck.budgetCny, budget_knobs: ck.budgetKnobs, fixtures_dir: input.fixturesDir ?? null, + model_face: input.modelFace ?? "faux", prompt_hashes: ck.state?.prompt_hashes ?? {}, + started_at: ck.startedAtIso, updated_at: nowIso(), resume_count: 0, replay_of: null, + ...(ck.state?.monthly_admission != null ? { monthly_admission: ck.state.monthly_admission } : {}), + ...(ck.state?.finding_exits != null ? { finding_exits: ck.state.finding_exits } : {}), + }; + return buildRoundCtx({ + type: "health", roundId: input.roundId, opsRoot, env, log: (l) => process.stderr.write(`${l}\n`), + fixturesDir: input.fixturesDir ?? null, modelFace: input.modelFace ?? "faux", + budgetCny: ck.budgetCny, budgetKnobs: ck.budgetKnobs, breakerKnobs: ck.breakerKnobs, + state, usage: ck.usage, modelCalls: ck.modelCalls, + }); +} + +// —— 阶段记录/终态面(旧 failStage/writeState 同语义,载体=checkpoint 而非 state.json)—— +const stageDone = (ck, rec, phase) => ({ ...ck, phase: nextPhase(phase ?? ck.phase), attempt: 0, children: null, childConvs: null, spawnPhase: null, state: { ...ck.state, stages: [...(ck.state?.stages ?? []), rec], prompt_hashes: { ...(ck.state?.prompt_hashes ?? {}) }, stage: phase ?? ck.phase }, modelCalls: ck.modelCalls, usage: ck.usage }); +function nextPhase(p) { const i = RUNNABLE.indexOf(p); return i >= 0 && i + 1 < RUNNABLE.length ? RUNNABLE[i + 1] : "CLOSE"; } +const failTerm = (ck, phase, code, message) => ({ ...ck, phase: "FAILED", failed: { code, message: `${phase}:${message}` } }); + +const mkAbort = () => async (_t, runtime, context) => { // 终审小项:无参名(name 未用) + await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context); +}; + +// P0-④ 跨库撞轮目录守卫:v2 健康轮无 state.json(实现细节,RUNBOOK 已知差异表), +// 跨库新任务撞已有轮目录无守卫(同库复用走 run-health HealthRoundsDoc 不触发; +// --from-phase TRIAGE 的前置工件 01-/02- 合法,故不能以「目录非空」判)。认领件 +// .v2-claim.json{token,taskId} 落轮目录:每相位头核认领,异主 → bad_round 终态(旧 +// discover bootstrapRoundDir 同义;该件已入 shadow-diff 白名单+RUNBOOK 差异表)。 +// 认领键=checkpoint 携带的随机 token 而非裸 taskId——taskId 是 per-db 计数器, +// 两库各自新建任务会数值相撞(db1/task1 vs db2/task1),裸 id 判同主即失效; +// token 随 checkpoint 持久,同任务 resume 恒自识,异任务(任意库)恒异主。 +async function claimRoundDir(task, runtime, context) { + const input = task.input; + const token = task.state.checkpoint?.claimToken ?? null; + const dir = path.join(opsRootOf(process.env), "rounds", String(input.roundId)); + fs.mkdirSync(dir, { recursive: true }); + const claimFile = path.join(dir, ".v2-claim.json"); + let claim = null; + try { claim = JSON.parse(fs.readFileSync(claimFile, "utf8")); } catch { /* 无=全新 */ } + if (claim && claim.token !== token) { + await runtime.commit(() => ({ + status: "terminal", + outcome: { status: "failed", error: { code: "bad_round", message: `rounds/${input.roundId} 已被任务认领(跨库撞轮目录;同库重跑走 HealthRoundsDoc 复用;claim.taskId=${claim.taskId ?? "?"})` } }, + }), context); + return false; + } + if (!claim) { + // 复核 P2-1:排他写(flag wx)闭同相位头并发窗——两库同轮同时启动时,败者在此即拒, + // 不再等下一相位头才识破(EEXIST=赢家已写,重读比对);胜者零感知。 + try { + fs.writeFileSync(claimFile, JSON.stringify({ token, taskId: runtime.taskId }, null, 2) + "\n", { flag: "wx" }); + } catch (e) { + if (e?.code !== "EEXIST") throw e; + let winner = null; + try { winner = JSON.parse(fs.readFileSync(claimFile, "utf8")); } catch { /* 竞读败则按异主拒,宁拒不跑 */ } + if (!winner || winner.token !== token) { + await runtime.commit(() => ({ + status: "terminal", + outcome: { status: "failed", error: { code: "bad_round", message: `rounds/${input.roundId} 并发认领败(赢家 taskId=${winner?.taskId ?? "?"};claim 写排他 wx)` } }, + }), context); + return false; + } + } + } + return true; +} +const claimGuard = (h) => async (task, runtime, context) => { + if (!(await claimRoundDir(task, runtime, context))) return; // 异主 → 已落 bad_round 终态,本相位不执行 + return h(task, runtime, context); +}; + +// 起步相位缝(修复⑥ E2E 面;旧 pipeline.mjs replay --from STAGE 同义语义):生产 cron +// 不传(缺省 PROBE 全十态);测试/faux 审计用前置工件面从指定相位起步(零网络红线下 +// PROBE 的真探测/矩阵面不可入测试)。闭集=RUNNABLE+CLOSE(WAIT_MODEL=join 态/FAILED=终态 +// 书挡,不可起步);非法=bad_round(闭集成员)。 +const startPhaseOf = (p, input) => { + if (p == null) return "PROBE"; + if (![...RUNNABLE, "CLOSE"].includes(p)) + throw Object.assign(new Error(`fromPhase 须 ∈ {${[...RUNNABLE, "CLOSE"].join("|")}}:${p}`), { code: "bad_round" }); + // toPhase(旧 pipeline.mjs replay --to 镜像;shadow 载具用):停在 toPhase 完成后(含其门+台账), + // 不入 CLOSE 书挡。默认 CLOSE=全链。合法性:toPhase 须晚于 fromPhase(否则零相位空转)。 + const tp = input?.toPhase ?? "CLOSE"; + if (![...RUNNABLE, "CLOSE"].includes(tp)) + throw Object.assign(new Error(`toPhase 须 ∈ {${[...RUNNABLE, "CLOSE"].join("|")}}:${tp}`), { code: "bad_round" }); + if (RUNNABLE.includes(tp) && RUNNABLE.indexOf(tp) <= RUNNABLE.indexOf(p)) + throw Object.assign(new Error(`toPhase(${tp}) 须晚于 fromPhase(${p})`), { code: "bad_round" }); + return p; +}; +// 停笔点统一书挡(相位完成、门已过后):终态 completed + 信封(旧 replay --to 同义: +// 不落 09-budget/G_CLOSE,信封 stages 止于停笔相位)。P2-4:与 stageDone 同一 commit +// 原子落(两笔问隔窗崩溃会越 toPhase 直入 CLOSE,违冻结口径)——故本函数只出 commit +// 载荷,由调用方合入唯一一笔 commit。 +function stopOutcome(input, ck, def) { + const fin = rebuildRoundCtx(input, ck); + fin.state.status = "done"; + fin.state.stage = def.name; + fin.state.updated_at = nowIso(); + const envelope = composeEnvelope(fin, { ok: true }); + return { + status: "terminal", + outcome: { status: "completed", result: { round_id: input.roundId, envelope, dir: fin.dir, usage: fin.usage } }, + }; +} + +// —— 模型相位共通:创建子任务(逐假设一实例)后转 WAIT_MODEL —— +let MODELS_BUNDLE = null; // 轮内缓存(旧 providerBundle 同型:目录只装载一次) +async function stepModelRef(def, input) { + if (input.modelRef) return input.modelRef; // 显式覆盖(测试 faux 注入面) + MODELS_BUNDLE ??= await loadModels(); + const m = MODELS_BUNDLE.resolve(STEP_ROUND[def.name]); // 步→轮键→模型(models.mjs 单源;缺省 flash/glm-5.3) + if (!m) throw Object.assign(new Error(`模型未装载:${STEP_ROUND[def.name]}`), { code: "infra" }); + return { provider: m.provider, modelId: m.id }; +} +async function spawnModelChildren(def, input, ck, runtime, context) { + const ctx = rebuildRoundCtx(input, ck); + const items = def.forEach ? steps.forEachItems(def, ctx) : [null]; // DEEP=bundles(零 bundle=金轨迹回退,旧 deepItems) + const childIds = []; + const childConvs = {}; // taskId → convId(P1-4:WAIT_MODEL 收口读 failed 子任务会话留痕计量) + // faux 面分派(修复⑥/审查 P2-4 最小接面,只接分派不动 T5 台账兼容面):显式 modelRef + // (测试注入面)优先;否则 modelFace 缺省 faux(零网络安全缺省,rebuildCtx 同口径;生产 + // cron 须显式传 real)→ 逐子任务一 fauxProvider 罐装回合(旧 runFauxStep 每步 new + // Agent 同义;provider 唯一名防并行串台,见 faux-face.mjs 头注),注册进 harness + // Models(runtime.models 面);真面路径不变(loadModels 真装载)。进程内注册=崩溃重启 + // 后在飞子任务 provider 不可复现(faux 面限制:测试/审计面,生产不走;宁少算不静默切真面)。 + const explicitRef = input.modelRef ?? null; + const fauxFace = !explicitRef && (input.modelFace ?? "faux") === "faux"; + const modelRef = explicitRef ?? (fauxFace ? null : await stepModelRef(def, input)); // 装载先于 commit(异步 IO 不入事务) + const fauxRefs = new Map(); // items 索引 → modelRef(faux 面逐子任务) + if (fauxFace) { + items.forEach((item, i) => { + const { faux, modelRef: ref } = mkFauxFace(def, { roundId: input.roundId, stage: def.name, hid: item?.hid }); + faux.setResponses(loadFauxFixture(def, input.fixturesDir, item).turns); // 缺 fixture=infra(旧 loadFixture 同判例,提交前抛) + runtime.models.setProvider(faux.provider); + fauxRefs.set(i, ref); + }); + } + // P1-4:轮级基数快照(spawn 时点轮累计)随 input 入子任务——子任务 pre①③/post②④ + // 以轮为基(旧 runModelStage 轮累计执法同口径;并行兄弟各自只见基数,全网收口在本轮 + // WAIT_MODEL ①②④复查) + const roundBase = { modelCalls: ck.modelCalls ?? 0, usage: ck.usage ?? null, startedAtIso: ck.startedAtIso }; + await runtime.commit(async (tx, cur) => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + // 每子任务一自有 conversation(api-map §3「子代理=自有 conversation」;并行 submit 不撞 busy) + const conv = await tx.createConversation({ ownership: { kind: "task", taskId: cur.id } }); + const io = steps.modelIO(def); + const minput = await io.input(rebuildRoundCtx(cur.input, cur.state.checkpoint), item); // 装配单源(与子任务内同函数) + await configure(tx, conv.id, { + model: fauxRefs.get(i) ?? modelRef, + extensions: [runtime.registry.extension("ops")].filter(Boolean), // AgentChange 要 Extension 对象(applyChange names();存态才是字符串) + instructions: minput.systemPrompt, // 旧提示词文件全文=会话 instructions(单源 prompts/health/*.md) + // 真 DEEP=微探针闭集三只(旧 runRealStep 同面;modelTools 传 ToolRegistration 对象, + // 框架 applyChange 存名字串——旧代码传名字串会 map 出 undefined 存储炸 strict JSON, + // 执行面暴露的第四处暗病,随 E2E 一并修复);faux 面恒 [](旧 runFauxStep tools=[] 同义: + // 罐装回合无工具面,种子=fixture registry_seed 行由 model-round 落盘) + ...(def.forEach && !fauxFace ? { modelTools: OPS_PROBE_TOOLS } : { modelTools: [] }), + }); + const id = await tx.createTask(OPS_MODEL_ROUND, { + roundId: input.roundId, stage: def.name, ...(item ? { hid: item.hid } : {}), + fixturesDir: input.fixturesDir ?? null, modelFace: input.modelFace ?? "faux", + roundBase, + }, { ownership: { kind: "conversation" }, conversationId: conv.id }); + childIds.push(id); + childConvs[String(id)] = conv.id; + } + // MF2:spawn 与 wait 同一 commit(框架 README §Child Tasks Canonical 模式;types.d.ts + // outcomes「rejects when one is missing or not terminal. Used after a wait」)——提交 + // waiting+on+policy,子任务全 settle 后才恢复 WAIT_MODEL 相位;逐假设败=降级面,故 + // allSettled 非 failFast(旧 runModelStage perItem 语义对应)。 + return { status: "waiting", checkpoint: { ...cur.state.checkpoint, phase: "WAIT_MODEL", children: childIds, childConvs, spawnPhase: def.name }, on: childIds, policy: "allSettled" }; + }, context); +} + +// WAIT_MODEL:join 全部子任务 → usage/modelCalls/prompt_hashes 收编 → DEEP 收编尾 finalize+聚合门 +async function waitModel(input, ck, runtime, context) { + const ids = ck.children ?? []; + const outcomes = await runtime.outcomes(ids, context); // 全 terminal 才返回(failFast 不用:逐假设败=降级面) + const def = defOf(ck.spawnPhase); + const ctx = rebuildRoundCtx(input, ck); + // P0-⑤:usage 从轮累计基线起步(终值=新轮累计;基线 null 按字段 0),本步增量=终值−基线 + // (恢复旧「各步 usage 求和==信封 usage」律;旧 golden pipeline_ok 断言面)。 + const base = { input: ck.usage?.input ?? 0, output: ck.usage?.output ?? 0, totalTokens: ck.usage?.totalTokens ?? 0, cost: ck.usage?.cost ?? 0 }; + const usage = { ...base }; + let modelCalls = ck.modelCalls ?? 0; + const promptHashes = { ...(ck.state?.prompt_hashes ?? {}) }; + const degradedItems = []; + let failedN = 0; + for (let i = 0; i < outcomes.length; i++) { + const o = outcomes[i]; + const hid = def.forEach ? steps.forEachItems(def, ctx)[i]?.hid : null; + const key = `${def.name}${hid ? `:${hid}` : ""}`; + if (o.status === "completed") { // TaskOutcome 面值即 {status,result|error,reason}(types.d.ts:510;无 .state 包裹) + const r = o.result; + for (const k of ["input", "output", "totalTokens", "cost"]) usage[k] += r.usage?.[k] ?? 0; + modelCalls += r.modelCalls ?? 1; + promptHashes[key] = r.prompt_sha; // replay 审计面(旧 runModelStage prompt_hashes 同位) + } else if (o.status === "failed") { + failedN += 1; + // P1-4 failed 子任务花费留痕(哨面宁多算不漏记):result 缺席 → 从其会话 transcript + // 实计(pi.assistant usage 求和/调用数=assistant 消息数;重试回合全计)——失败重试 + // 已花的钱不从轮账面消失(09-budget.json 读数与真实花费不背离)。读败只 stderr 注记 + // 不阻轮(缺口显式可见,不静默归零)。 + const convId = ck.childConvs?.[String(ids[i])]; + if (convId != null) { + try { + const view = await runtime.context(convId, context); + const cu = conversationUsage(view); + for (const k of ["input", "output", "totalTokens", "cost"]) usage[k] += cu[k]; + modelCalls += conversationModelCalls(view); + } catch (e) { process.stderr.write(`[waitModel] failed 子任务留痕读败(conv=${convId}):${e?.message ?? e}\n`); } + } + if (def.fail.perItem) degradedItems.push({ hid: hid ?? def.name, verdict: "unresolved", evidence: [], one_line: `重试尽降级 unresolved(${o.error?.message ?? "?"})` }); + else + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: o.error?.code ?? "model_face", message: `${def.name}:${o.error?.message ?? "?"}` } } }), context); // 终审修复:TaskOutcome 无 .state 包裹(类型面同注),原 o.state.outcome 会 TypeError→框架 fault + } + } + // —— P1-4 WAIT_MODEL 收口复查①②④(轮级累计口径;并行子任务 pre/post 各自只见基数, + // 收口补全网;违界即停不降级——旧 fatalBudget 同语义。①口径=已计>帽,不预支 +1 + // (overCallsCap 的 attempt-前查口径归子任务;帽内恰满的轮不误杀)—— + const knobs4 = ck.budgetKnobs ?? roundKnobs(process.env, "health"); + if (modelCalls > knobs4.maxModelCalls) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: "budget_exceeded", message: `预算四界①(收口):轮累计模型调用 ${modelCalls} > ${knobs4.maxModelCalls}(${knobNames("health").calls})` } } }), context); + const post = postModelCall({ roundType: "health", knobs: knobs4, totalTokens: usage.totalTokens, costCny: usage.cost }); + if (!post.ok) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: post.code, message: `${def.name}:${post.message}` } } }), context); + if (def.fail.perItem && failedN === (def.forEach ? steps.forEachItems(def, ctx).length : 1) && failedN > 0) { + const msg = `全部 ${failedN} 假设失败(model_face)`; // 旧 runModelStage 全败=终态(C稿 §2.2) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: "model_face", message: msg } } }), context); + } + // DEEP 收编尾(零复制 deepFinalize:逐假设工件→05-deep-verdicts)+聚合门(G_DEEP,收编尾后一次) + if (def.forEach) { + steps.modelIO(def).finalize(ctx, { degradedItems }); + const gateRec = evalStageGate(def, ctx); + appendGate(ctx.dir, ctx.roundId, gateRec); + if (!gateRec.pass) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: gateRec.code ?? def.fail.terminal, message: `门败 ${def.gate}:${gateRec.reason}` } } }), context); + } + const stepUsage = { input: usage.input - base.input, output: usage.output - base.output, totalTokens: usage.totalTokens - base.totalTokens, cost: usage.cost - base.cost }; // 本步增量(P0-⑤) + const rec = { name: def.name, status: degradedItems.length ? "degraded" : "done", attempts: 1, dur_ms: 0, usage: stepUsage }; + const finCk = stageDone({ ...ck, usage, modelCalls, state: { ...ck.state, prompt_hashes: promptHashes } }, rec, ck.spawnPhase); + // toPhase 停笔(模型相位版;与 stageDone 同 commit 原子落——P2-4) + if (input.toPhase && input.toPhase === def.name) + return void await runtime.commit(() => stopOutcome(input, finCk, def), context); + await runtime.commit(() => ({ status: "running", checkpoint: finCk }), context); +} + +// —— 脚本相位执行体(零复制 steps.runStage + 门 + retry/degrade/终态;旧 runStages 同语义)—— +async function runScriptPhase(def, input, ck, runtime, context) { + const ctx = rebuildRoundCtx(input, ck); + // 步边界查③(旧 runStages 检查点同构;resume 沿原 started_at 续算) + const knobs = ck.budgetKnobs ?? roundKnobs(process.env, "health"); + const sb = stepBoundary({ roundType: "health", knobs, startedAtIso: ck.startedAtIso }); + if (!sb.ok) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: sb.code, message: `${def.name}:${sb.message}` } } }), context); + // DISPATCH skipWhen(旧 SKIP_WHEN.no_need_deep 零谓词重写,round-ctx 逐字副本) + if (def.skipWhen === "no_need_deep" && skipWhenNoNeedDeep(ctx)) { + const rec = { name: def.name, status: "skipped", attempts: 0, dur_ms: 0, usage: null }; + return void await runtime.commit((tx, cur) => ({ status: "running", checkpoint: stageDone(cur.state.checkpoint, rec) }), context); + } + const t0 = performance.now(); + let attempt = ck.attempt ?? 0, degraded = null; + for (;;) { + try { degraded = (await steps.runStage(def, ctx)) ?? null; break; } // 零复制:脚本步单源 + catch (e) { + if (attempt < (def.fail.retry ?? 0)) { attempt += 1; continue; } + const code = ERROR_CODES.includes(e?.code) ? e.code : "infra"; + return void await runtime.commit((tx, cur) => ({ status: "running", checkpoint: failTerm(cur.state.checkpoint, def.name, code, e?.message ?? String(e)) }), context); + } + } + if (degraded?.skipGate) appendGateBypass(ctx.dir, ctx.roundId, def, degraded.degraded); + else { + const gateRec = evalStageGate(def, ctx); // 门单源(含 G_CLOSE 回声哨:信封单行/往返解析/预算读数≤帽) + appendGate(ctx.dir, ctx.roundId, gateRec); + if (!gateRec.pass) { + const code = gateRec.code ?? def.fail.terminal; + return void await runtime.commit((tx, cur) => ({ status: "running", checkpoint: failTerm(cur.state.checkpoint, def.name, code, `门败 ${def.gate}:${gateRec.reason}`) }), context); + } + } + const rec = { name: def.name, status: degraded ? "degraded" : "done", attempts: attempt + 1, dur_ms: Math.round(performance.now() - t0), usage: null }; + // ANCHOR_SWEEP 写 finding_exits 入 state(旧步内 ctx.state 直写;v2 面同步回 checkpoint) + const nextCk = stageDone(ck, rec); + if (ctx.state.finding_exits != null) nextCk.state = { ...nextCk.state, finding_exits: ctx.state.finding_exits }; + // toPhase 停笔(脚本相位版;与 stageDone 同 commit 原子落——P2-4:停在门后,不丢台账/工件) + if (input.toPhase && input.toPhase === def.name) + return void await runtime.commit(() => stopOutcome(input, nextCk, def), context); + await runtime.commit(() => ({ status: "running", checkpoint: nextCk }), context); +} + +// —— CLOSE 书挡(旧 finishClose 同序:存在性校验→09-budget.json→信封→G_CLOSE→终态)—— +async function runClose(input, ck, runtime, context) { + const ctx = rebuildRoundCtx(input, ck); + const def = defOf("CLOSE"); + const rec = { name: "CLOSE", status: "done", attempts: 1, dur_ms: 0, usage: null }; + try { + await steps.runStage(def, ctx); // 零复制:09-budget.json(breaker 快照)+pkg 校验同字节 + ctx.state.stages.push(rec); + ctx.state.status = "done"; ctx.state.stage = "CLOSE"; ctx.state.updated_at = nowIso(); + const envelope = composeEnvelope(ctx, { ok: true }); // round-ctx 逐字副本(旧 composeEnvelope 同形) + ctx.envelope = envelope; + const gr = evalStageGate(def, ctx); // G_CLOSE 回声哨:环内已执法,门只防工件漂移(spec 谱系原则) + appendGate(ctx.dir, ctx.roundId, gr); + if (!gr.pass) throw Object.assign(new Error(`门败 G_CLOSE:${gr.reason}`), { code: "infra" }); + await runtime.commit(() => ({ + status: "terminal", + outcome: { status: "completed", result: { round_id: input.roundId, envelope, dir: ctx.dir, usage: ctx.usage } }, + }), context); + } catch (e) { + const code = ERROR_CODES.includes(e?.code) ? e.code : "infra"; + await runtime.commit(() => ({ + status: "terminal", + outcome: { status: "failed", error: { code, message: `CLOSE:${e?.message ?? String(e)}` } }, + }), context); + } +} + +// FAILED 书挡(旧终态:工件全保留,replay 可救——v2 面=checkpoint 留全量 stageRecs) +async function runFailed(input, ck, runtime, context) { + await runtime.commit(() => ({ + status: "terminal", + outcome: { status: "failed", error: { code: ck.failed?.code ?? "infra", message: ck.failed?.message ?? "FAILED" } }, + }), context); +} + +// —— 相位表:十态 + WAIT_MODEL join + FAILED 书挡 —— +// 全部轮目录工作相位套 claimGuard(P0-④;FAILED 纯书挡不碰轮目录,不套)。 +const PHASES = { + WAIT_MODEL: claimGuard((t, r, c) => waitModel(t.input, t.state.checkpoint, r, c)), + FAILED: (t, r, c) => runFailed(t.input, t.state.checkpoint, r, c), +}; +for (const name of RUNNABLE) { + const def = defOf(name); + PHASES[name] = claimGuard(MODEL_PHASES.has(name) + ? (t, r, c) => spawnModelChildren(def, t.input, t.state.checkpoint, r, c) + : (t, r, c) => runScriptPhase(def, t.input, t.state.checkpoint, r, c)); +} +PHASES.CLOSE = claimGuard((t, r, c) => runClose(t.input, t.state.checkpoint, r, c)); + +export const OPS_HEALTH_ROUND = defineTask({ + name: "ops-health-round", + version: 1, + initial: (input) => ({ + phase: startPhaseOf(input.fromPhase, input), attempt: 0, children: null, childConvs: null, spawnPhase: null, failed: null, + claimToken: randomUUID(), // P0-④ 轮目录认领键(见 claimRoundDir;裸 taskId per-db 撞号,不可作键) + startedAtIso: new Date().toISOString(), + budgetCny: (() => { const raw = process.env.OPS_PIPELINE_BUDGET_CNY ?? "0.05"; const v = +raw; if (!Number.isFinite(v) || v < 0) throw Object.assign(new Error(`OPS_PIPELINE_BUDGET_CNY 须为非负数,得到:${raw}`), { usage: true }); return v; })(), + budgetKnobs: roundKnobs(process.env, "health"), // 轮始校验(旧 mkCtx 同判例:非法 throw usage) + breakerKnobs: readBreakerKnobs(process.env), // #199 B 同判例 + modelCalls: 0, usage: { input: 0, output: 0, totalTokens: 0, cost: 0 }, + // P1-5:admission 读数入链——submitter(Entry/T5)把 admitRound 的 admission 读数对象 + // 入任务 input.monthlyAdmission → initial → ck.state.monthly_admission(rebuildRoundCtx + // 已透传)→ CLOSE 09-budget.json readings.monthly(旧轮=月度准入投影对象;缺席=null + // =replay 面旧同形)。旧 pipeline.mjs:396 ctx.state.monthly_admission 同位。 + state: { stages: [], prompt_hashes: {}, ...(input.monthlyAdmission != null ? { monthly_admission: input.monthlyAdmission } : {}) }, + }), + phases: PHASES, + abort: mkAbort(), +}); diff --git a/ops/agent-v2/tasks/index.mjs b/ops/agent-v2/tasks/index.mjs new file mode 100644 index 00000000..181bc99a --- /dev/null +++ b/ops/agent-v2/tasks/index.mjs @@ -0,0 +1,10 @@ +// tasks/index.mjs — 各轮次 defineTask 落位聚合点(票 #225 骨架;票 #227 T3 落发现轮、 +// 票 #228 T4 填健康轮)。每轮次一个文件,export 进本数组,registry.mjs 统一装进 +// "ops" 扩展。并批合并(#228 收口时 T3+T4 并飞):本聚合点按并集解——T3 发现轮 +// (discover 六相位)+ T4 单模型步/健康轮(ops-model-round/ops-health-round); +// 任务文件之间互不 import,后续轮次继续只追加行。 +import { discoverRoundTask } from "./discover-round.mjs"; // 票 #227 T3:发现轮(旧六步语义逐相位映射) +import { OPS_MODEL_ROUND } from "./model-round.mjs"; // 票 #228 T4:单模型步(stage∈TRIAGE/DEEP/SYNTH) +import { OPS_HEALTH_ROUND } from "./health-round.mjs"; // 票 #228 T4:健康轮十态相位机 + +export const TASKS = [discoverRoundTask, OPS_MODEL_ROUND, OPS_HEALTH_ROUND]; diff --git a/ops/agent-v2/tasks/model-round.mjs b/ops/agent-v2/tasks/model-round.mjs new file mode 100644 index 00000000..e6664b64 --- /dev/null +++ b/ops/agent-v2/tasks/model-round.mjs @@ -0,0 +1,218 @@ +// tasks/model-round.mjs — 单个模型轮任务(票 #228 T4;主会话 Q1 裁决 A): +// 一个 defineTask(name="ops-model-round")覆盖健康轮全部模型步,input.stage∈{TRIAGE,DEEP,SYNTH}; +// 实现零复制 import 旧 steps.modelIO(input/consume)/gates.evalStageGate——本文件不重写提示词装配、 +// 不重写收编、不重写门,只造旧 ctx 形状(round-ctx.buildRoundCtx)喂它们。 +// 实例粒度(票面):健康轮 TRIAGE/SYNTH 相位各建一实例;DEEP 逐假设一实例(input.hid)。 +// requestId=round-[--](entry.requestIdFor 同族;首 attempt 携带= +// 崩溃重启幂等:同键重放返回既有 submission 零新调用,T0 smoke 已证;门败重试 attempt+1 的 +// 重发不带 requestId——旧语义「同提示词重发」要新调用,requestId 只保 attempt 0 的 exactly-once)。 +// DEEP 的微探针:submit 前按 runtime.conversationId 绑定宿主(tools/probes.bindProbeHost; +// 预算 ≤3/假设+轮次级 dup registry,旧 faux.mjs runRealStep 同口径;TRIAGE/SYNTH 无探针面= +// 旧 tools=[] 同义,未绑定会话调用探针=拒绝信封)。G_DEEP 聚合门不在本任务:逐假设步门评收编尾 +// 后一次(旧 runModelStage forEach 语义),归健康轮 DEEP 相位(deepFinalize+G_DEEP)。 +// 预算执法(票 #226 双点之环内):每 attempt 前 preModelCall①③、结算后 postModelCall②④—— +// 违界即停不重试(旧 fatalBudget 语义;宁少算)。相位级 startedAt 入 checkpoint(墙钟续算不重置)。 +// 旧管线零改动红线:ops/agent/ 只 import 不改;本文件属 ops/agent-v2/(shadow 期共存)。 +import fs from "node:fs"; +import path from "node:path"; +import { defineTask } from "@earendil-works/pi-durable"; +import { HEALTH_PIPELINE } from "../../agent/pipeline.mjs"; // 零复制:定义表单源 +import { modelIO, forEachItems } from "../../agent/pipeline/steps.mjs"; // 零复制:模型步装配/收编/逐假设枚举 +import { evalStageGate, appendGate } from "../../agent/pipeline/gates.mjs"; // 零复制:单发步门(G_TRIAGE/G_SYNTH) +import { hidSlug } from "../../agent/pipeline/steps.mjs"; +import { loadFauxFixture, fauxFixtureSeeds, transcriptFile } from "../faux-face.mjs"; // MF3:faux 面分派件(fixture/seeds/transcript 名,旧 faux.mjs 位等价副本) +import { ERROR_CODES } from "../../agent/error-codes.mjs"; +import { buildRoundCtx, opsRootOf, sha16 } from "../round-ctx.mjs"; +import { roundKnobs, preModelCall, postModelCall } from "./budget-guard.mjs"; // 票 #226 环内执法面 +import { bindProbeHost, unbindProbeHost } from "../tools/probes.mjs"; + +export const MODEL_STAGES = ["TRIAGE", "DEEP", "SYNTH"]; // stage 闭集(票 #228 Q1) + +// requestId 幂等键(entry.requestIdFor 同族扩展:DEEP 逐假设带 hid 后缀)。 +export const modelRequestId = (roundId, stage, hid) => + hid != null ? `round-${roundId}-${stage}-${hidSlug(hid)}` : `round-${roundId}-${stage}`; + +// 旧 def(HEALTH_PIPELINE 定义表项;fail 三元组 retry/terminal 语义单源) +const defOf = (stage) => HEALTH_PIPELINE.find((d) => d.name === stage); + +const EMPTY_USAGE = { input: 0, output: 0, totalTokens: 0, cost: 0 }; + +// assistant 应答文本(entry.mjs lastAssistantText 同形:末条 pi.assistant 的首个 text 块) +const lastAssistantText = (view) => (view.entries.filter((e) => e.kind === "pi.assistant").at(-1)?.model ?? []) + .flatMap((m) => m.content ?? []).find((b) => b.type === "text")?.text ?? ""; +// 末条 pi.assistant 的 stopReason(旧 runModelStep 失败面判据;块级缺省 null=faux 面无此字段) +const lastStopReason = (view) => (view.entries.filter((e) => e.kind === "pi.assistant").at(-1)?.model ?? [])[0]?.stopReason ?? null; +// 本轮新增 assistant 条目的 usage 求和(pi-ai Usage:input/output/totalTokens/cost.total;旧 addUsage 同面) +const usageSince = (view, n0) => { + const u = { ...EMPTY_USAGE }; + for (const e of view.entries.slice(n0)) { + if (e.kind !== "pi.assistant") continue; + for (const m of e.model ?? []) { + const w = m.usage ?? {}; + u.input += w.input ?? 0; u.output += w.output ?? 0; u.totalTokens += w.totalTokens ?? 0; u.cost += w.cost?.total ?? 0; + } + } + return u; +}; + +// transcript 工件留痕(MF3;旧 faux.mjs runFauxStep/runRealStep 的 transcript 写盘副作用面, +// 行型以旧源为准位等价:faux.mjs:87-94(faux 面)/faux.mjs:135-139(真面)—— +// input 行:{type:"input",stage,model(仅真面:provider/modelId),system_sha,user_sha,meta} +// registry_seed 行:faux 面=fixture seeds(call_id=sd.id);真面=微探针宿主登记行(call_id=sd.call_id) +// message 行:逐 assistant 消息 {type:"message",role,content,stopReason} +// 留痕先于失败面判定与 io.consume(旧同序:faux.mjs「留痕先于失败面判定」)。DEEP 的 +// deepConsume 宿主回填与 G_DEEP/G_SYNTH 门读面(deepSeeds/07 input 行 meta)都吃本工件—— +// 零复制链条的 transcript 半在此接回。v2 偏差(注明):重试同会话→message 行含全部 attempt +// 回合(旧=每 attempt 新 Agent 重写单回合文件);门读面不读 message 行,证据不灭失优先。 +async function writeTranscript(def, input, item, modelInput, view, ctx, runtime, context) { + const face = input.modelFace ?? "faux"; + const seeds = face === "faux" + ? fauxFixtureSeeds(loadFauxFixture(def, input.fixturesDir, item)) // 缺 fixture=infra(旧 loadFixture 同判例) + : (boundProbeHost(runtime.conversationId)?.seeds ?? []); // 真面=宿主登记行(seeds 必从宿主内存取,t1-notes 结论) + let modelField = null; + if (face !== "faux") { + const am = (await runtime.agent(context))?.model ?? null; // 本会话 agent 面(configure 时模型真源) + if (am) modelField = `${am.provider}/${am.modelId}`; + } + const tf = transcriptFile(def, item); + if (!tf) throw Object.assign(new Error(`transcript 名未装配:${def.name}`), { code: "infra" }); + const lines = [ + JSON.stringify({ type: "input", stage: input.stage, ...(modelField ? { model: modelField } : {}), + system_sha: sha16(modelInput.systemPrompt), user_sha: sha16(modelInput.userMsg), meta: modelInput.meta ?? null }), + ...seeds.map((sd) => JSON.stringify({ type: "registry_seed", call_id: sd.call_id, tool: sd.tool, sc: sd.sc })), + ...view.entries.filter((e) => e.kind === "pi.assistant").flatMap((e) => e.model ?? []) + .map((m) => JSON.stringify({ type: "message", role: "assistant", content: m.content, stopReason: m.stopReason ?? null })), + ]; + fs.writeFileSync(path.join(ctx.dir, tf), lines.join("\n") + "\n"); +} + +// ctx 重建(checkpoint 是唯一真源;旧 runResume ctxShape 同构面):modelFace 缺省 faux(零网络安全缺省, +// 旧 runModelStep 同缺省);工件面=opsRoot/rounds/(旧同形)。 +function rebuildCtx(input, ck) { + const env = process.env; + const opsRoot = opsRootOf(env); + const state = { + schema: 1, round_id: input.roundId, type: "health", status: "in_flight", stage: input.stage, + stages: ck.stageRecs ?? [], budget_cny: ck.budgetCny ?? null, + budget_knobs: ck.budgetKnobs ?? null, fixtures_dir: input.fixturesDir ?? null, + model_face: input.modelFace ?? "faux", prompt_hashes: ck.promptHashes ?? {}, + started_at: ck.startedAtIso, updated_at: ck.startedAtIso, resume_count: 0, replay_of: null, + }; + const roundDir = path.join(opsRoot, "rounds", String(input.roundId)); + fs.mkdirSync(roundDir, { recursive: true }); // v2 面:旧 runPipeline 建轮目录的对应位(checkpoint 在 SQLite,工件目录仍磁盘) + return buildRoundCtx({ + type: "health", roundId: input.roundId, opsRoot, env, log: (l) => process.stderr.write(`${l}\n`), + fixturesDir: input.fixturesDir ?? null, modelFace: input.modelFace ?? "faux", + budgetKnobs: ck.budgetKnobs, breakerKnobs: ck.breakerKnobs, state, + usage: ck.usage ?? { ...EMPTY_USAGE }, modelCalls: ck.modelCalls ?? 0, + }); +} + +export const OPS_MODEL_ROUND = defineTask({ + name: "ops-model-round", + version: 1, + initial: (input) => ({ + phase: "run", stage: input.stage, hid: input.hid ?? null, attempt: 0, + startedAtIso: new Date().toISOString(), modelCalls: 0, usage: { ...EMPTY_USAGE }, + promptHashes: {}, stageRecs: [], budgetKnobs: null, breakerKnobs: null, budgetCny: null, + }), + phases: { + // 单相位状态机:重试圈在 handler 内(api-map §3:相位无内建 per-stage retry;checkpoint.attempt 持数)。 + run: async (task, runtime, context) => { + const input = task.input; + const ck = task.state.checkpoint; + const def = defOf(input.stage); + if (!def) throw Object.assign(new Error(`stage 须 ∈ {${MODEL_STAGES.join("|")}}:${input.stage}`), { code: "bad_round" }); + const ctx = rebuildCtx(input, ck); + const knobs = ck.budgetKnobs ?? roundKnobs(process.env, "health"); // #199 A1 幂等:resume 重建面兑底同判例 + const io = modelIO(def); + const item = def.forEach + ? forEachItems(def, ctx).find((b) => b.hid === input.hid) ?? null + : null; + if (def.forEach && !item) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: "bad_round", message: `hid=${input.hid} 不在 04-dispatch-bundles(或金轨迹回退)` } } }), context); + const modelInput = await io.input(ctx, item); // 零复制:旧装配面(提示词/上下文判例注入单源) + // —— attempt 前查①③(budget-guard 环内;旧 runModelStage 检查点同构,违界即停不重试; + // P1-4:①③以轮累计为基——roundBase=健康轮 spawn 时点快照(轮调用数/轮 usage/轮 + // started_at;直建子任务缺省=自身起点)。并行兄弟各自只见基数,全网约東在健康轮 + // WAIT_MODEL 收口 ①②④复查(宁多算不漏记口径)—— + const base = input.roundBase ?? { modelCalls: 0, usage: null, startedAtIso: ck.startedAtIso }; + const pre = preModelCall({ roundType: "health", knobs, modelCalls: (base.modelCalls ?? 0) + ck.modelCalls, startedAtIso: base.startedAtIso ?? ck.startedAtIso }); + if (!pre.ok) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: pre.code, message: `${input.stage}:${pre.message}` } } }), context); + // —— 微探针绑定(仅 DEEP;旧 faux.mjs runRealStep 同口径:≤3/假设+轮次级 dup registry)—— + const conv = await runtime.conversation(runtime.conversationId, context); // 本任务会话句柄(handle 需显式 id) + if (!conv) throw Object.assign(new Error(`会话句柄缺失(conv=${runtime.conversationId})`), { code: "infra" }); + if (input.stage === "DEEP") + bindProbeHost(runtime.conversationId, { dupRegistry: ctx.dupRegistry, budget: 3, dupLimit: knobs.dupCallLimit ?? 3 }); + try { + // requestId:仅 attempt 0 携带(崩溃幂等);重试重发不带(要新调用) + const draft = { type: "input", content: modelInput.userMsg, ...(ck.attempt === 0 ? { requestId: modelRequestId(input.roundId, input.stage, input.hid) } : {}) }; + // 一次性会话前提(健康轮每模型步实例建新 conversation):usageSince 基线恒 0;复用会话须换 entry 游标(票面注记)。终审删除局部 base=0(遮蔽外层 roundBase 快照同名异义且无消费) + const sub = await conv.submit(draft, context); + const settled = await sub.wait(context); + if (settled.status !== "done") + throw Object.assign(new Error(`submission ${settled.status}`), { code: "model_face" }); + const view = await runtime.context(runtime.conversationId, context); // ContextView.entries=已提交可见 transcript(旧 faux 读面同源) + await writeTranscript(def, input, item, modelInput, view, ctx, runtime, context); // MF3:留痕先于失败面判定与 io.consume(旧同序) + const stopReason = lastStopReason(view); + if (stopReason === "error" || stopReason === "aborted") + throw Object.assign(new Error(`模型失败面 stopReason=${stopReason}`), { code: "model_face" }); // 旧 runModelStep 同判据 + const text = lastAssistantText(view).trim(); + const usage = usageSince(view, 0); // P2-⑬ 显式游标 0:一次性会话全量(上方前提);原传 base 对象是靠 slice(NaN) 兑回 0 的暗病,不留 + ctx.modelCalls = ck.modelCalls + 1; + await io.consume(ctx, item, { text }); // 零复制:收编落盘(03-triage-hypotheses/05-deep-/07-synth) + // —— 结算后查②④(旧检查点同构;P1-4:轮内累计=轮基数+本会话累计——会话含重试 + // 回合全计(宁多算不漏记);旧=ctx.usage(轮)+步内累计,同口径面)—— + const totalTokens = (base.usage?.totalTokens ?? 0) + usage.totalTokens; + const costCny = (base.usage?.cost ?? 0) + usage.cost; + const post = postModelCall({ roundType: "health", knobs, totalTokens, costCny }); + if (!post.ok) + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code: post.code, message: `${input.stage}:${post.message}` } } }), context); + const promptSha = sha16(`${modelInput.systemPrompt}\n${modelInput.userMsg}`); // 旧 transcript prompt_sha 同源算法 + // —— 单发步门入重试圈(旧 runModelStage:G_TRIAGE/G_SYNTH;DEEP 聚合门归健康轮)—— + if (!def.forEach) { + const gateRec = evalStageGate(def, ctx); + appendGate(ctx.dir, ctx.roundId, gateRec); + if (!gateRec.pass) throw Object.assign(new Error(`门败 ${def.gate}:${gateRec.reason}`), { code: gateRec.code ?? def.fail.terminal, gateRec }); // 终审修复:附 gateRec(旧 pipeline.mjs:236 同律)——带闭集错误码的门败保留「与 schema 违例同面重试」语义 + } + return void await runtime.commit(() => ({ + status: "terminal", + outcome: { + status: "completed", + result: { stage: input.stage, hid: input.hid ?? null, round_id: input.roundId, usage, modelCalls: ctx.modelCalls, prompt_sha: promptSha }, + }, + }), context); + } catch (e) { + if (input.stage === "DEEP") unbindProbeHost(runtime.conversationId); // 失败重绑定=宿主计数清零(宁少算口径) + const code = ERROR_CODES.includes(e?.code) ? e.code : "model_face"; + // 旧 fail.retry≤1 同提示词重发;坏 fixture/缺轮目录等 infra 类不重试(旧 retry 圈同捕面,此处按 code 收窄) + const retryable = code === "model_face" || code === "empty_text" || e?.gateRec; + if (retryable && ck.attempt < def.fail.retry) + return void await runtime.commit((tx, cur) => ({ + status: "running", + // P0-③ attempt 即调用(旧律 budget.mjs:34「重试同计」):失败 attempt 的调用 + // 数随重试 checkpoint 同步入账,否则成功面 ck.modelCalls+1 会漏掉全部失败回合 + // (P1-4 收口/09-budget 轮累计随之少算);usage 不在此补——结算面 + // usageSince(view,0) 按会话全量计,失败回合 usage 已含。 + checkpoint: { ...cur.state.checkpoint, attempt: cur.state.checkpoint.attempt + 1, modelCalls: cur.state.checkpoint.modelCalls + 1 }, + }), context); + return void await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error: { code, message: `${input.stage}:${e?.message ?? String(e)}` } } }), context); + } + }, + }, + abort: async (_task, runtime, context) => { + await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context); + }, +}); + +// 会话读面入口在相位 handler 内直用 runtime.context(convId, ctx)(ContextView.entries); +// 本文件不再另设包装(单一读面,与 entry.mjs lastAssistantText 同形工具函数在上)。 + +// 会话 transcript 计量面(健康轮 WAIT_MODEL 收口消费;P1-4 failed 子任务花费留痕—— +// 宁多算不漏记:result 缺席时从会话实计,重试回合全计;completed 子任务走 result.usage +// 权威面不经此)。 +export const conversationUsage = (view) => usageSince(view, 0); +export const conversationModelCalls = (view) => + view.entries.filter((e) => e.kind === "pi.assistant").reduce((n, e) => n + (e.model ?? []).length, 0); + diff --git a/ops/agent-v2/tests/.e2e-env.mjs b/ops/agent-v2/tests/.e2e-env.mjs new file mode 100644 index 00000000..7ab08e93 --- /dev/null +++ b/ops/agent-v2/tests/.e2e-env.mjs @@ -0,0 +1,20 @@ +// .e2e-env.mjs — health-e2e 的 env 先行件(票 #228 修复⑥)。 +// 为什么必须是「首个 import」的副作用模块:EVAL_PY 是 shared.mjs 模块加载期常量 +// (ops/agent/tools/shared.mjs:43 `process.env.OPS_EVAL_PY ?? <真 venv python>`), +// seedWorld 在测试体内设 env 时模块树早已求值——首跑曾因此跑真 backend python +// 锚巡检(非 hermetic,断言降级面也失真)。ESM 按源序求值依赖,把本模块放在 +// import 列表首位,后续经 OPS_HEALTH_ROUND → steps → shared 的链条读到的即是 stub。 +// stub=永败 py(裸失败:stderr 一行+exit 1)→ anchorSweepCli 走 infra 信封 +// → ANCHOR_SWEEP 降级旁路(旧 selftest-pipeline ⑫「stub 无旗标分发→sweep 面同死」 +// 同契约)。每跑重建,旧进程残留不复活。 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const dir = path.join(os.tmpdir(), "v2-health-e2e-stubs"); +fs.rmSync(dir, { recursive: true, force: true }); +fs.mkdirSync(dir, { recursive: true }); +const stub = path.join(dir, "py-fail"); +fs.writeFileSync(stub, "#!/usr/bin/env bash\necho 'e2e stub: anchor/matrix face dead' >&2\nexit 1\n"); +fs.chmodSync(stub, 0o755); +process.env.OPS_EVAL_PY = stub; diff --git a/ops/agent-v2/tests/budget-parity.test.mjs b/ops/agent-v2/tests/budget-parity.test.mjs new file mode 100644 index 00000000..c166389d --- /dev/null +++ b/ops/agent-v2/tests/budget-parity.test.mjs @@ -0,0 +1,329 @@ +// budget-parity.test.mjs — T2 预算移植对拍(票 #226;spec 定案 3:budget.mjs 原样零复制 + 双点执法接线) +// 全程零网络零模型调用:GLM usage 用固定数值样本(形状取 T0 smoke 实测:单次 cost.total ≈ ¥0.00003–0.00004)。 +// 对拍面:旧管线真源 ops/agent/pipeline/budget.mjs(只读 import)× v2 接线面(../budget.mjs 零复制 re-export +// + ../tasks/budget-guard.mjs 双点执法 guard + ../entry.mjs runRound 接线)。 +// 断言口径:执法决策逐条一致(allow/deny/code/message)+ 剩余量逐条一致(帽−已用,符号值:负=超出)。 +// 语义真源:ops/agent/DESIGN.md §6 预算四界行(迭代上限/token 轨迹/墙钟/花费帽 每轮 ¥3 ∧ 月度 ¥30, +// 同台账并发安全双点执法:准入+环内)+ 重复检测(工具调用哈希)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import * as oldBudget from "../../agent/pipeline/budget.mjs"; // 旧管线真源(只读对照;ops/agent 零改动红线) +import * as v2Budget from "../budget.mjs"; // v2 零复制接线面 +import { + admitRound, knobNames, postModelCall, preModelCall, + resolveLedgerPath, roundKnobs, stepBoundary, sumUsageState, +} from "../tasks/budget-guard.mjs"; +import { ROOT, runRound } from "../entry.mjs"; + +const V2_DIR = path.resolve(import.meta.dirname, ".."); +const ENTRY = path.join(V2_DIR, "entry.mjs"); + +// —— 固定样本:票面参数(迭代帽/token 帽/墙钟/花费帽 ¥3 ∧ 月帽 ¥30 = 六旋钮生产缺省)+ GLM usage 数值样本 —— +const KNOB_ENV_SAMPLES = [ + { name: "生产缺省(空 env)", env: {} }, + { name: "收紧旋钮", env: { + OPS_PIPELINE_MAX_MODEL_CALLS: "5", OPS_PIPELINE_TOKEN_BUDGET: "50000", + OPS_PIPELINE_WALL_CLOCK_S: "600", OPS_PIPELINE_SPEND_CAP_CNY: "1", + OPS_PIPELINE_MONTHLY_CAP_CNY: "9", OPS_PIPELINE_DUP_CALL_LIMIT: "2", + } }, + { name: "零帽(宁少算极端面:帽=0=零预算即拒)", env: { + OPS_PIPELINE_MAX_MODEL_CALLS: "0", OPS_PIPELINE_TOKEN_BUDGET: "0", + OPS_PIPELINE_WALL_CLOCK_S: "0", OPS_PIPELINE_SPEND_CAP_CNY: "0", + } }, +]; +// GLM usage 固定数值样本(glm-5.3-flash 量级;零网络:数字是死的,执法是纯函数) +const GLM_USAGE_SAMPLES = [ + { input: 1801, output: 76, totalTokens: 1877, cost: { input: 0.0000306, output: 0.0000064, total: 0.000037 } }, + { input: 120445, output: 3211, totalTokens: 123656, cost: { input: 0.0020476, output: 0.0002730, total: 0.0023206 } }, + { input: 65536, output: 1024, totalTokens: 66560, cost: { input: 0.0009432, output: 0.0001707, total: 0.00011139 } }, +]; +// 旧管线回合累计口径(agent.mjs subscribe:totalTokens += u.totalTokens ?? 0;cost += u.cost?.total ?? 0) +const accumulate = (samples) => { + const acc = { totalTokens: 0, costCny: 0 }; + for (const u of samples) { acc.totalTokens += u.totalTokens ?? 0; acc.costCny += u.cost?.total ?? 0; } + return acc; +}; + +test("零复制单源:v2 budget.mjs 导出面 = 旧管线 budget.mjs 逐名同引用(fork 即红)", () => { + const oldKeys = Object.keys(oldBudget).sort(); + const v2Keys = Object.keys(v2Budget).sort(); + assert.deepEqual(v2Keys, oldKeys, `导出名集漂移:旧=${oldKeys} v2=${v2Keys}`); + for (const k of oldKeys) + assert.equal(v2Budget[k], oldBudget[k], `${k} 不是同一引用——零复制被破坏(整文件拷贝属违票兜底,须票面明示)`); +}); + +test("旋钮读面对拍:同 env → readBudgetKnobs/readDiscoverKnobs(旧)与 roundKnobs(v2)逐字段一致;非法=同文案用法错", () => { + for (const { name, env } of KNOB_ENV_SAMPLES) { + assert.deepEqual(roundKnobs(env, "health"), oldBudget.readBudgetKnobs(env), `health 旋钮漂移:${name}`); + assert.deepEqual(roundKnobs(env, "discover"), oldBudget.readDiscoverKnobs(env), `discover 旋钮漂移:${name}`); + } + // discover 族无 tokenBudget 键(发现轮无 token 界,#207 T1);月帽/dup 共享同键 + assert.equal("tokenBudget" in roundKnobs({}, "discover"), false); + assert.equal(roundKnobs({}, "discover").monthlyCapCny, roundKnobs({}, "health").monthlyCapCny); + // 非法值:两面同 throw {usage:true} 同文案(exit 2 不带病跑轮判例) + const capture = (fn) => { try { fn(); return null; } catch (e) { return e; } }; + for (const bad of ["-1", "abc"]) { + const env = { OPS_PIPELINE_SPEND_CAP_CNY: bad }; + const o = capture(() => oldBudget.readBudgetKnobs(env)); + const n = capture(() => roundKnobs(env, "health")); + assert.ok(o && n, `两面均须 throw:${bad}`); + assert.equal(n.usage, true); assert.equal(o.usage, true); + assert.equal(n.message, o.message, "用法错文案漂移"); + } +}); + +test("四界①③ attempt 前查对拍(旧 runModelStage 语义 × v2 preModelCall):决策/码/文案/剩余量逐条一致", () => { + const knobs = oldBudget.readBudgetKnobs({}); // 生产缺省:12 调用/1800s + const t0 = "2026-10-28T00:00:00.000Z"; + const cases = [ // [modelCalls, startedAtIso, nowMs 偏移, 说明] + [0, t0, 0, "轮始零计数零流逝=放行"], + [11, t0, 0, "第 12 次调用=帽内放行(等号放行)"], + [12, t0, 0, "第 13 次调用=超帽拒(①)"], + [0, t0, 1800_000, "流逝 1800s=帽内放行(等号放行)"], + [0, t0, 1800_001, "流逝 1800.001s=超帽拒(③)"], + [0, "not-a-date", 999_999, "不可解析起点=退化 0 不误杀"], + [12, t0, 1800_001, "双违界=①先裁(旧检查点顺序:①→③)"], + ]; + for (const [calls, startedAt, offMs, label] of cases) { + const now = Date.parse(t0) + offMs; + const oldDenyCalls = oldBudget.overCallsCap(calls, knobs.maxModelCalls); + const oldDenyWall = oldBudget.overWallClock(startedAt, knobs.wallClockS, now); + const d = preModelCall({ roundType: "health", knobs, modelCalls: calls, startedAtIso: startedAt, now }); + if (oldDenyCalls) { + assert.equal(d.ok, false, label); + assert.equal(d.code, "budget_exceeded", label); + assert.equal(d.message, `预算四界①:模型调用将超帽(已计 ${calls}+1 > ${knobs.maxModelCalls},OPS_PIPELINE_MAX_MODEL_CALLS)`, `${label}:①文案漂移`); + assert.equal(d.remaining.calls, knobs.maxModelCalls - calls, `${label}:①剩余调用数`); + } else if (oldDenyWall) { + assert.equal(d.ok, false, label); + assert.equal(d.code, "wall_clock_exceeded", label); + assert.equal(d.message, `预算四界③:墙钟 ${oldBudget.elapsedWallS(startedAt, now).toFixed(1)}s 超帽 ${knobs.wallClockS}s(OPS_PIPELINE_WALL_CLOCK_S)`, `${label}:③文案漂移`); + } else { + assert.equal(d.ok, true, label); + assert.equal(d.remaining.calls, knobs.maxModelCalls - calls - 1, `${label}:attempt 后剩余调用数`); + } + } + // 零帽特判(帽=0=零预算即拒;两界皆零面) + const zero = oldBudget.readBudgetKnobs({ OPS_PIPELINE_MAX_MODEL_CALLS: "0", OPS_PIPELINE_WALL_CLOCK_S: "0" }); + const dz = preModelCall({ roundType: "health", knobs: zero, modelCalls: 0, startedAtIso: t0, now: Date.parse(t0) }); + assert.equal(dz.ok, false); assert.equal(dz.code, "budget_exceeded"); // ①先裁(0+1>0) + // discover 文案指认 OPS_DISCOVER_*(#207 T1 per-type) + const dk = oldBudget.readDiscoverKnobs({}); + const dd = preModelCall({ roundType: "discover", knobs: dk, modelCalls: dk.maxModelCalls, startedAtIso: t0, now: Date.parse(t0) }); + assert.equal(dd.message, `预算四界①:模型调用将超帽(已计 ${dk.maxModelCalls}+1 > ${dk.maxModelCalls},OPS_DISCOVER_MAX_MODEL_CALLS)`); +}); + +test("四界③ 相位边界查对拍(旧 runStages 步边界语义 × v2 stepBoundary)", () => { + const knobs = oldBudget.readBudgetKnobs({}); + const t0 = "2026-10-28T00:00:00.000Z"; + for (const [offMs, expectOk] of [[0, true], [1800_000, true], [1800_001, false]]) { + const now = Date.parse(t0) + offMs; + const d = stepBoundary({ roundType: "health", knobs, startedAtIso: t0, now }); + assert.equal(d.ok, expectOk, `off=${offMs}`); + assert.equal(d.ok, !oldBudget.overWallClock(t0, knobs.wallClockS, now), `off=${offMs}:决策须=旧 overWallClock`); + if (!d.ok) { + assert.equal(d.code, "wall_clock_exceeded"); + assert.equal(d.message, `预算四界③:墙钟 ${oldBudget.elapsedWallS(t0, now).toFixed(1)}s 超帽 ${knobs.wallClockS}s(OPS_PIPELINE_WALL_CLOCK_S)`); + } + } + const z = oldBudget.readBudgetKnobs({ OPS_PIPELINE_WALL_CLOCK_S: "0" }); + assert.equal(stepBoundary({ roundType: "health", knobs: z, startedAtIso: t0, now: Date.parse(t0) }).ok, false, "零帽墙钟=恒拒"); +}); + +test("四界②④ 结算后查对拍(旧 runModelStage 语义 × v2 postModelCall)+ GLM usage 样本累积", () => { + const knobs = oldBudget.readBudgetKnobs({}); // token 200000 / spend ¥3 + const acc = accumulate(GLM_USAGE_SAMPLES); + // 样本累计远低于帽 → 放行;剩余量 = 帽−已用(符号值) + const d0 = postModelCall({ roundType: "health", knobs, totalTokens: acc.totalTokens, costCny: acc.costCny }); + assert.equal(d0.ok, true); + assert.equal(d0.remaining.tokens, knobs.tokenBudget - acc.totalTokens); + assert.equal(d0.remaining.spendCny, knobs.spendCapCny - acc.costCny); + // 边界矩阵:等号放行,一尘即拒(②→④ 顺序;文案逐字对齐旧检查点) + const cases = [ + [200000, 3, true, "双等号=放行"], + [200001, 3, false, "token 超一=②拒"], + [200000, 3.0000001, false, "花费超一尘=④拒"], + [200001, 3.0000001, false, "双超=②先裁"], + ]; + for (const [tokens, cost, ok, label] of cases) { + const d = postModelCall({ roundType: "health", knobs, totalTokens: tokens, costCny: cost }); + assert.equal(d.ok, ok, label); + assert.equal(d.ok, !(oldBudget.overTokenBudget(tokens, knobs.tokenBudget) || oldBudget.overSpendCap(cost, knobs.spendCapCny)), `${label}:决策须=旧纯函数`); + if (!d.ok && oldBudget.overTokenBudget(tokens, knobs.tokenBudget)) + assert.equal(d.message, `预算四界②:token 轨迹 ${tokens} 超预算 ${knobs.tokenBudget}(OPS_PIPELINE_TOKEN_BUDGET)`, `${label}:②文案`); + if (!d.ok && !oldBudget.overTokenBudget(tokens, knobs.tokenBudget)) + assert.equal(d.message, `预算四界④:花费 ¥${cost.toFixed(8)} 超帽 ¥${knobs.spendCapCny}(OPS_PIPELINE_SPEND_CAP_CNY)`, `${label}:④文案`); + } + // 发现轮:无 token 界(tokenBudget undefined 即跳过,巨量 token 只有 ④ 执法) + const dk = oldBudget.readDiscoverKnobs({}); // spend ¥5 + assert.equal(postModelCall({ roundType: "discover", knobs: dk, totalTokens: 1e12, costCny: 4.99 }).ok, true); + assert.equal(postModelCall({ roundType: "discover", knobs: dk, totalTokens: 1, costCny: 5.01 }).ok, false); + // GLM 样本重复 k 份累计跨 ¥3 帽(迭代帽消费的实测量级路径) + for (let k = 1; k <= 4; k++) { + const a = accumulate(Array.from({ length: k }, () => GLM_USAGE_SAMPLES[1])); + const d = postModelCall({ roundType: "health", knobs, totalTokens: a.totalTokens, costCny: a.costCny }); + assert.equal(d.ok, !(oldBudget.overTokenBudget(a.totalTokens, knobs.tokenBudget) || oldBudget.overSpendCap(a.costCny, knobs.spendCapCny)), `k=${k}`); + } +}); + +test("环内 usage 聚合对拍:sumUsageState(harness UsageState)≡ 旧回合累计口径(GLM 样本)", () => { + // v2 环内口径:轮 delta = settle 后 session usage − submit 前 snapshot(UsageState.models+tools 求和)。 + // UsageState.models 按 provider/modelId 累计(新回合叠加在同 key 上,非替换)——fixture 照此构造。 + const acc2 = (a, b) => ({ totalTokens: a.totalTokens + b.totalTokens, cost: { total: a.cost.total + b.cost.total } }); + const before = { models: { "zai-coding-cn/glm-5.3-flash": GLM_USAGE_SAMPLES[0] }, tools: {} }; + const after = { + models: { "zai-coding-cn/glm-5.3-flash": acc2(GLM_USAGE_SAMPLES[0], GLM_USAGE_SAMPLES[1]) }, + tools: { probe_host: { totalTokens: 11, cost: { total: 0.0000011 } } }, + }; + const delta = { + totalTokens: sumUsageState(after).totalTokens - sumUsageState(before).totalTokens, + costCny: sumUsageState(after).costCny - sumUsageState(before).costCny, + }; + const expect = { // 旧口径:本轮新发生的回合样本 = [1] + [probe 工具位] + totalTokens: GLM_USAGE_SAMPLES[1].totalTokens + 11, + costCny: GLM_USAGE_SAMPLES[1].cost.total + 0.0000011, + }; + assert.ok(Math.abs(delta.totalTokens - expect.totalTokens) < 1e-9); + assert.ok(Math.abs(delta.costCny - expect.costCny) < 1e-12); + // 空库/缺字段退化面:零起步不炸 + assert.deepEqual(sumUsageState(undefined), { totalTokens: 0, costCny: 0 }); + assert.deepEqual(sumUsageState({ models: {}, tools: {} }), { totalTokens: 0, costCny: 0 }); +}); + +test("月度准入对拍(¥30 ∧ est):admitRound(v2)与旧 admitOrRefuse 同裁决同文案同留痕", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-budget-admit-")); + const mkLedger = (spendRows) => { + const f = path.join(tmp, `ledger-${Math.random().toString(36).slice(2)}.md`); + const month = oldBudget.monthNow(); + const lines = ["| 日期 | 轮次 | model | 用量 | 备注 |", "|---|---|---|---|---|"]; + spendRows.forEach((cost, i) => lines.push(`| ${month}-27 | 2026-${month.split("-")[1]}-0800-${i} | zai-coding-cn/glm-5.3-flash | in=1 out=2 total=3 cost=${cost} | r |`)); + // 异形行:他月行不计入、当月缺 cost 行=skipped + lines.push(`| 2026-01-01 | old-round | m | in=1 out=1 total=2 cost=99 | r |`); + lines.push(`| ${month}-28 | no-cost-round | m | in=1 | r |`); + fs.writeFileSync(f, lines.join("\n") + "\n"); + return f; + }; + const env = {}; // 生产缺省月帽 ¥30 + // 放行界:当月 29.93 + est 0.05 = 29.98 ≤ 30(等号放行面用 29.95+0.05=30.00) + const okLedger = mkLedger([29.93]); + const logs = []; + const v2r = await admitRound({ env, roundType: "health", ledgerFile: okLedger, log: (l) => logs.push(l) }); + const oldFaceLedger = mkLedger([29.93]); // 旧面独立台账副本(拒绝才留痕;两副本同内容) + const oldr = await oldBudget.admitOrRefuse({ ledgerFile: oldFaceLedger, capCny: env.OPS_PIPELINE_MONTHLY_CAP_CNY ?? 30, estCny: 0.05, env, log: () => {}, round: "health" }); + assert.equal(v2r.refused, false); assert.equal(oldr.refused, false); + assert.deepEqual({ month: v2r.admission.month, spend: v2r.admission.spend, counted: v2r.admission.counted, skipped: v2r.admission.skipped, projected: v2r.admission.projected }, + { month: oldr.admission.month, spend: oldr.admission.spend, counted: oldr.admission.counted, skipped: oldr.admission.skipped, projected: oldr.admission.projected }); + assert.equal(v2r.admission.counted, 1, "当月 cost 行恰 1 计入"); assert.equal(v2r.admission.skipped, 1, "当月缺 cost 行恰 1 跳过"); + // 等号放行:29.95+0.05=30.00 ≤ 30 + const eq = await admitRound({ env, roundType: "health", ledgerFile: mkLedger([29.95]), log: () => {} }); + assert.equal(eq.refused, false, "等号=帽内放行"); + // 拒跑界:29.96+0.05=30.01 > 30 → 双面同拒;同码同文案;台账留痕行都写(行为等价含留痕) + const v2RefLedger = mkLedger([29.96]); + const v2ref = await admitRound({ env, roundType: "health", ledgerFile: v2RefLedger, log: (l) => logs.push(l) }); + const oldRefLedger = mkLedger([29.96]); + const oldref = await oldBudget.admitOrRefuse({ ledgerFile: oldRefLedger, capCny: 30, estCny: 0.05, env, log: () => {}, round: "health" }); + assert.equal(v2ref.refused, true); assert.equal(oldref.refused, true); + assert.equal(v2ref.code, oldref.code); assert.equal(v2ref.message, oldref.message, "拒跑文案漂移(refusalOf 单源,须逐字)"); + assert.match(v2ref.message, /月度预算拒跑:当月已花 ¥29\.96 \+ 本轮预算 ¥0\.05 > 月帽 ¥30/); + for (const f of [v2RefLedger, oldRefLedger]) { + const text = fs.readFileSync(f, "utf8"); + assert.match(text, /budget_monthly refused est=0\.05 cap=30/, `${f}:拒跑留痕行缺失`); + } + // est 非法=用法错(与旧 agent.mjs runRound 同读同校验同文案;校验先于读台账,admitRound reject) + await assert.rejects( + admitRound({ env: { OPS_PIPELINE_BUDGET_CNY: "-1" }, roundType: "health", ledgerFile: okLedger, log: () => {} }), + (e) => e.usage === true && e.message === "OPS_PIPELINE_BUDGET_CNY 须为非负数,得到:-1", + ); + assert.ok(logs.some((l) => l.includes("[admission] budget_monthly refused")), "[admission] log 行缺失"); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +// est 校验的纯面对拍(admitRound 是 async,非法在校验即 throw——用同步子进程面钉 CLI exit 2) +test("月帽月读数对拍:monthNow/monthSpendOfLedgerText(v2 面=旧面)", () => { + const month = oldBudget.monthNow(); + assert.equal(typeof month, "string"); assert.match(month, /^\d{4}-\d{2}$/); + const sample = [ + `| ${month}-01 | r1 | m | in=1 out=1 total=2 cost=1.5 | x |`, + `| ${month}-02 | r2 | m | in=1 out=1 total=2 cost=2.25 | x |`, + ].join("\n"); + assert.deepEqual(v2Budget.monthSpendOfLedgerText(sample, month), oldBudget.monthSpendOfLedgerText(sample, month)); + assert.deepEqual(oldBudget.monthSpendOfLedgerText(sample, month), { spend: 3.75, counted: 2, skipped: 0 }); +}); + +test("台账落位:LEDGER_FILE > DOCS_ROOT/ops/ledger.md > <仓根>/docs/ops/ledger.md(旧 agent.mjs runRound 同推导;#226 F1 钉死)", () => { + // scaffold.test.mjs db 落位断言同模式。期望仓根取自 entry.mjs ROOT——entry 居 ops/agent-v2/ 浅一层, + // 两层上移=仓根;budget-guard 居 tasks/ 子层须三层上移,推导深度不同而落点须同仓根(旧面缺省 + // = ops/agent/tools/shared.mjs DOCS_ROOT(仓根/docs)+ "ops/ledger.md";漂移=缺省 env 下月帽读到 + // 不存在文件,Σcost=0,¥30 kill switch 静默失效——F1 即此)。 + assert.equal(resolveLedgerPath({ LEDGER_FILE: "/tmp/ledger-x.md" }), "/tmp/ledger-x.md"); + assert.equal(resolveLedgerPath({ DOCS_ROOT: "/d" }), path.join("/d", "ops", "ledger.md")); + assert.equal(resolveLedgerPath({}), path.join(ROOT, "docs", "ops", "ledger.md"), + `缺省台账漂移:得 ${resolveLedgerPath({})},须 <仓根>/docs/ops/ledger.md`); +}); + +test("同参哈希对拍:dupKey/canonicalJson/dupRecord(v2 面=旧面;第 limit 次同参即拦)", () => { + const tool = "web_search"; + const argVariants = [ + { q: "cve", lang: "zh", page: 1 }, + { page: 1, lang: "zh", q: "cve" }, // 键序无关 + { q: "cve", lang: "zh", page: 2 }, // 值变=新键 + { deep: { b: 1, a: [3, 2, 1] } }, // 嵌套+数组保序 + { uni: "判例预算四界" }, + undefined, + ]; + // 键序无关性:同参数不同键序 → 同哈希(同参拦截的前提) + assert.equal(v2Budget.dupKey(tool, argVariants[0]), v2Budget.dupKey(tool, argVariants[1])); + assert.notEqual(v2Budget.dupKey(tool, argVariants[0]), v2Budget.dupKey(tool, argVariants[2])); + for (const args of argVariants) + assert.equal(v2Budget.dupKey(tool, args), oldBudget.dupKey(tool, args), "v2 dupKey 漂移"); + // dupRecord 序列:limit=3 → 第 3 次起 blocked;limit=0 → 停用 + for (const limit of [3, 0]) { + const regOld = new Map(); const regV2 = new Map(); + const seq = []; + for (let i = 0; i < 4; i++) { + const o = oldBudget.dupRecord(regOld, tool, argVariants[0], limit); + const n = v2Budget.dupRecord(regV2, tool, argVariants[0], limit); + assert.equal(n.count, o.count); assert.equal(n.blocked, o.blocked); + seq.push(n.blocked); + } + assert.deepEqual(seq, limit === 3 ? [false, false, true, true] : [false, false, false, false]); + } + const dupReg = new Map([[1, { tool, key: "k", count: 5 }], [2, { tool, key: "j", count: 9 }]]); + assert.deepEqual(v2Budget.dupTop(dupReg, 1), oldBudget.dupTop(dupReg, 1)); +}); + +test("entry 双点接线(零网络):拒跑信封/零帽拒/用法错 exit 2;拒跑零 harness 开销", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-budget-entry-")); + const month = oldBudget.monthNow(); + const ledger = path.join(tmp, "ledger.md"); + fs.writeFileSync(ledger, `| 日期 | 轮次 | model | 用量 | 备注 |\n|---|---|---|---|---|\n| ${month}-27 | r | m | in=1 out=1 total=2 cost=29.96 | x |\n`); + // ① 步前准入拒跑:29.96+0.05 > 30 → 信封 refused + code=budget_monthly + 台账留痕;零模型调用(拒跑先于 harness) + const refused = await runRound({ roundId: "parity-refuse", content: "x", dbPath: path.join(tmp, "a.sqlite"), env: { LEDGER_FILE: ledger } }); + assert.equal(refused.status, "refused"); + assert.equal(refused.code, "budget_monthly"); + assert.match(refused.message, /月度预算拒跑/); + assert.match(fs.readFileSync(ledger, "utf8"), /budget_monthly refused est=0\.05 cap=30/); + // ② 零帽步前拒(四界①:0+1>0)——信封 failed+budget_exceeded,拒于 submit 前(零花费) + const zeroCalls = await runRound({ roundId: "parity-zero-calls", content: "x", dbPath: path.join(tmp, "b.sqlite"), env: { OPS_PIPELINE_MAX_MODEL_CALLS: "0" } }); + assert.equal(zeroCalls.status, "failed"); + assert.equal(zeroCalls.code, "budget_exceeded"); + assert.match(zeroCalls.message, /预算四界①/); + // ③ 零墙钟步前拒(四界③:帽=0 恒拒) + const zeroWall = await runRound({ roundId: "parity-zero-wall", content: "x", dbPath: path.join(tmp, "c.sqlite"), env: { OPS_PIPELINE_WALL_CLOCK_S: "0" } }); + assert.equal(zeroWall.code, "wall_clock_exceeded"); + assert.match(zeroWall.message, /预算四界③/); + // ④ 旋钮非法 → {usage:true} reject(CLI 面 exit 2,不带病跑轮) + await assert.rejects(runRound({ roundId: "parity-bad-knob", content: "x", dbPath: path.join(tmp, "d.sqlite"), env: { OPS_PIPELINE_WALL_CLOCK_S: "abc" } }), (e) => e.usage === true); + const cli = await new Promise((res) => { + const c = spawn(process.execPath, [ENTRY, "parity-bad-knob-cli", "--content", "x", "--db", path.join(tmp, "e.sqlite")], + { cwd: V2_DIR, env: { ...process.env, OPS_PIPELINE_WALL_CLOCK_S: "abc" } }); + let err = ""; c.stderr.on("data", (d) => { err += d; }); c.on("exit", (code) => res({ code, err })); + }); + assert.equal(cli.code, 2, `旋钮非法须 exit 2(stderr=${cli.err})`); + assert.match(cli.err, /OPS_PIPELINE_WALL_CLOCK_S 须为非负数/); + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/ops/agent-v2/tests/cron-launchers.test.mjs b/ops/agent-v2/tests/cron-launchers.test.mjs new file mode 100644 index 00000000..b4097cd3 --- /dev/null +++ b/ops/agent-v2/tests/cron-launchers.test.mjs @@ -0,0 +1,162 @@ +// cron-launchers.test.mjs — cron 入口 CLI 冻结面(票 #229 T5 任务书①:exit 码表 0/1/2/3、 +// 信封形状、stderr 进度行、幂等复用、toPhase 停笔、拒跑信封)。子进程真 CLI 面(spawn +// run-health.mjs),世界=shadow-diff seedWorld 导出面(判例+规则+01/02 前置工件+永败 +// eval stub);CLOSE 面全链在此钉(shadow 对拍载具停笔 RETRO,CLOSE 同构由本测补)。 +// run-discover.mjs 同律,其信封面已由 golden-discover-parity 测钉,此处不重复。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { seedWorld } from "../shadow-diff.mjs"; + +const HERE = import.meta.dirname; +const AGENT = path.resolve(HERE, "..", "..", "agent"); +const FIXTURES = path.join(AGENT, "fixtures", "pipeline"); +const today = new Date().toISOString().slice(0, 10); +// 测试用轮 id:同分钟+序数偏移(形状律 \d{4} 只验数字面,1870 等仍合法;字母后缀会进 HHMM 段违形状) +const rid = (n = 0) => { const d = new Date(); return `${today}-${String(d.getHours()).padStart(2, "0")}${String((d.getMinutes() + n) % 100).padStart(2, "0")}-health`; }; // 分钟位+n(mod 100),保 \d{4} 形状 + +function cli(args, world, extraEnv = {}) { + return new Promise((resolve) => { + const p = spawn("node", [path.join(HERE, "..", "run-health.mjs"), ...args], + { env: { ...world.env, ...extraEnv }, cwd: path.resolve(HERE, "..", "..") }); + let out = "", err = ""; + p.stdout.on("data", (d) => (out += d)); + p.stderr.on("data", (d) => (err += d)); + p.on("close", (code) => resolve({ code, out, err })); + }); +} +const lastLine = (s) => s.trimEnd().split("\n").at(-1); + +test("run-health 全链:exit 0,stdout 末行=旧形成功信封,CLOSE 门在,gates 有 G_CLOSE", async () => { + const id = rid(); + const w = seedWorld(id); + try { + // from-phase TRIAGE:零网络红线(PROBE/VERIFY=真探测面不可入测试;CLOSE 同构是本测目标) + const r = await cli([id, "--from-phase", "TRIAGE", "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite")], w); + assert.equal(r.code, 0, `stderr:\n${r.err}`); + const env = JSON.parse(lastLine(r.out)); + assert.equal(env.ok, true); + assert.equal(env.round, "health"); + assert.deepEqual(Object.keys(env).sort(), ["ok", "pipeline", "pkg", "queue_writes", "round", "usage", "verify"].sort(), "健康成功信封键闭集(P2-⑩;ENVELOPE.md 冻结面)"); + assert.equal(env.pipeline.round_id, id); + assert.ok(env.pipeline.stages.some((s) => s.name === "CLOSE"), "stages 含 CLOSE"); + assert.ok(env.usage && "cost" in env.usage, "信封带 usage.cost"); + assert.match(r.err, /\[v2-health\] round=/, "stderr 进度行"); + const gates = fs.readFileSync(path.join(w.roundDir, "gates.json"), "utf8"); + assert.match(gates, /G_CLOSE/, "gates.json 有 G_CLOSE"); + assert.ok(fs.existsSync(path.join(w.roundDir, "09-budget.json")), "09-budget.json 落件"); + assert.match(fs.readFileSync(path.join(w.ops, "ledger.md"), "utf8"), new RegExp(`\\| ${today} \\| health \\|`), "台账兼容行"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("run-health 幂等:同库同轮复用任务,二次运行零新建同信封", async () => { + const id = rid(1); + const w = seedWorld(id); + try { + const db = path.join(w.tmp, "s.sqlite"); + const r1 = await cli([id, "--from-phase", "TRIAGE", "--fixtures", FIXTURES, "--db", db], w); + const r2 = await cli([id, "--from-phase", "TRIAGE", "--fixtures", FIXTURES, "--db", db], w); + assert.equal(r1.code, 0); assert.equal(r2.code, 0); + assert.match(r2.err, /复用任务/, "stderr 报复用"); + assert.equal(lastLine(r1.out), lastLine(r2.out), "两次信封逐字同"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("run-health --to-phase RETRO:停笔 RETRO,无 G_CLOSE/09-budget,台账行仍在", async () => { + const id = rid(2); + const w = seedWorld(id); + try { + const r = await cli([id, "--from-phase", "TRIAGE", "--to-phase", "RETRO", "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite")], w); + assert.equal(r.code, 0, `stderr:\n${r.err}`); + const env = JSON.parse(lastLine(r.out)); + assert.equal(env.ok, true); + assert.ok(!env.pipeline.stages.some((s) => s.name === "CLOSE"), "stages 止于 RETRO"); + const gates = fs.readFileSync(path.join(w.roundDir, "gates.json"), "utf8"); + assert.doesNotMatch(gates, /G_CLOSE/); + assert.ok(!fs.existsSync(path.join(w.roundDir, "09-budget.json"))); + assert.match(fs.readFileSync(path.join(w.ops, "ledger.md"), "utf8"), new RegExp(`\\| ${today} \\| health \\|`), "RETRO 台账行"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("run-health 拒跑:月帽打满 → exit 1 + {ok:false,round:health,error.code=budget_monthly}", async () => { + const id = rid(3); + const w = seedWorld(id); + try { + fs.writeFileSync(path.join(w.ops, "ledger.md"), + `| ${today} | health | faux-pipeline | in=1 out=1 total=2 cost=30.00000000 | pipeline:spent | test |\n`); + const r = await cli([id, "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite")], w); + assert.equal(r.code, 1); + const env = JSON.parse(lastLine(r.out)); + assert.equal(env.ok, false); + assert.equal(env.round, "health"); + assert.equal(env.error.code, "budget_monthly"); + assert.match(r.err, /\[admission\] budget_monthly refused/); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("run-health 用法错:缺 roundId → exit 2;非法 toPhase → exit 2;非法 --model → exit 2", async () => { + const w = seedWorld(rid(4)); + try { + const r1 = await cli([], w); + assert.equal(r1.code, 2); + const r2 = await cli([rid(4), "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite"), "--to-phase", "NOPE"], w); + assert.equal(r2.code, 2); + const r3 = await cli([rid(4), "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite"), "--from-phase", "RETRO", "--to-phase", "TRIAGE"], w); + assert.equal(r3.code, 2, "toPhase 早于 fromPhase 拒"); + const r4 = await cli([rid(4), "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite"), "--model", "noprovider"], w); + assert.equal(r4.code, 2, "--model 缺 provider/modelId 斜杠拒"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("P0-④ 健康轮跨库撞轮目录:同轮 id 换新 db → exit 1 bad_round,台账不双写", async () => { + const id = rid(6); + const w = seedWorld(id); + try { + const r1 = await cli([id, "--from-phase", "TRIAGE", "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s1.sqlite")], w); + assert.equal(r1.code, 0, `首轮应成功:\n${r1.err}`); + assert.ok(fs.existsSync(path.join(w.roundDir, ".v2-claim.json")), "认领件落轮目录"); + const r2 = await cli([id, "--from-phase", "TRIAGE", "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s2.sqlite")], w); // 新库=新任务撞异主认领 + assert.equal(r2.code, 1); + const env2 = JSON.parse(lastLine(r2.out)); + assert.equal(env2.error.code, "bad_round", `跨库撞轮须 bad_round:\n${r2.out}`); + assert.match(env2.error.message, /认领/); + const ledger = fs.readFileSync(path.join(w.ops, "ledger.md"), "utf8"); + assert.equal((ledger.match(/\| health \|/g) ?? []).length, 1, "拒轮不写台账,首轮一行不双写"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("P2-⑩ 运行期异常:--db 指不可写路径(父路径是文件)→ exit 3,stdout 零信封", async () => { + const w = seedWorld(rid(7)); + try { + fs.writeFileSync(path.join(w.tmp, "file-not-dir"), "x"); // 父路径是文件 → 开库 ENOTDIR → launcher 异常面 + const r = await cli([rid(7), "--from-phase", "TRIAGE", "--fixtures", FIXTURES, "--db", path.join(w.tmp, "file-not-dir", "s.sqlite")], w); + assert.equal(r.code, 3); + assert.equal(r.out.trim(), "", "运行期异常不发 JSON 信封(ENVELOPE.md 码表:3=launcher 自身)"); + assert.ok(r.err.includes("Error"), "stderr 带栈"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("P2-5 轮 id 形状律:非 YYYY-MM-DD-HHMM-health → exit 1 bad_round(先于准入,note-9 序)", async () => { + const w = seedWorld("x"); + try { + const r = await cli(["2026-10-28-heath-typo", "--fixtures", FIXTURES, "--db", path.join(w.tmp, "s.sqlite")], w); + assert.equal(r.code, 1); + const env = JSON.parse(lastLine(r.out)); + assert.equal(env.error.code, "bad_round"); + assert.doesNotMatch(r.err, /\[admission\]/, "形状错不触发准入拒跑(序:校验先于准入)"); + } finally { fs.rmSync(w.tmp, { recursive: true, force: true }); } +}); + +test("P1-1 对拍闸门:双面皆败 → SIDE-FAILED 判 DIFF exit 1(不假 SAME)", async () => { + // 评审员实证面:双面同因败(缺 fixtures)旧版 VERDICT SAME exit 0——闸门自身失败语义回归钉 + const r = await new Promise((resolve) => { + const p = spawn("node", [path.join(HERE, "..", "shadow-diff.mjs"), rid(5), "--fixtures", "/tmp/definitely-missing-fixtures-t229"], + { cwd: path.resolve(HERE, "..", "..") }); + let out = ""; p.stdout.on("data", (d) => (out += d)); p.on("error", (e) => resolve({ code: -1, out: String(e) })); p.on("close", (code) => resolve({ code, out })); + }); + assert.equal(r.code, 1, `双面同因败不得 exit 0:\n${r.out}`); + assert.match(r.out, /SIDE-FAILED/); + assert.match(r.out, /VERDICT DIFF/); +}); diff --git a/ops/agent-v2/tests/discover-round.test.mjs b/ops/agent-v2/tests/discover-round.test.mjs new file mode 100644 index 00000000..2c891dc1 --- /dev/null +++ b/ops/agent-v2/tests/discover-round.test.mjs @@ -0,0 +1,193 @@ +// discover-round.test.mjs — 发现轮任务化测试面(票 #227 T3)。 +// 面:①replay 声明闭集表 ②全链 faux 轮(信封+工件+队列行+门全绿+台账行) +// ③同 id 重跑=bad_round(旧律) ④breaker 五通道写侧随迁(失败 verdict 落盘) +// ⑤相位头 SIGKILL 崩溃→同库 resume 续跑(相位边界恢复点;faux 零网络零花费)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; +import { runDiscoverRound } from "../run-discover.mjs"; +import { DISCOVERY_REPLAY, ensureRoundHost, roundHosts, executeDiscoveryTool } from "../tools/discovery-tools.mjs"; +import { breakerFileOf } from "../../agent/pipeline/discovery-host.mjs"; +import { parseBreakerState } from "../../agent/tools/breaker.mjs"; + +const execFile = promisify(execFileCb); +const HERE = path.resolve(import.meta.dirname, ".."); +const FIXTURES = path.resolve(HERE, "..", "agent", "fixtures", "pipeline"); +const ROUND = "2026-06-01-0400-discover"; + +function tmpTree(prefix) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return { tmp, docsRoot: path.join(tmp, "docs"), opsRoot: path.join(tmp, "docs", "ops") }; +} + +async function runRound(tree, db, fixturesDir = FIXTURES) { // fixturesDir 可覆盖(P0-③ 重试测:罐装短表首回合空文本) + // 任务侧 env=process.env 现读(旧律,buildCtx 注);in-proc 测试须同步设进程环境(于 finally 还原)。 + const keys = ["DOCS_ROOT", "LEDGER_FILE"]; + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + process.env.DOCS_ROOT = tree.docsRoot; + process.env.LEDGER_FILE = path.join(tree.opsRoot, "ledger.md"); + try { + return await runDiscoverRound({ + roundId: ROUND, + fixturesDir, + dbPath: db ?? path.join(tree.tmp, "v2.sqlite"), + env: { ...process.env }, + stderr: { write: () => true }, + }); + } finally { + for (const k of keys) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +test("replay 声明闭集表(重查询 unsafe/纯读 safe;与 tools/discovery-tools.mjs 注释单源)", () => { + assert.deepEqual(DISCOVERY_REPLAY, { web_read: "safe", web_search: "unsafe", rss_parse: "safe" }); +}); + +test("全链 faux 轮:信封 ok + 六步工件 + PROPOSE 队列行 + 门全绿 + 台账行", async () => { + const tree = tmpTree("v2-disc-full-"); + const r = await runRound(tree); + assert.equal(r.status, "completed", JSON.stringify(r)); + const env = r.envelope; + assert.equal(env.ok, true); + assert.deepEqual(Object.keys(env).sort(), ["ok", "pipeline", "pkg", "queue_writes", "round", "usage", "verify"].sort()); + assert.deepEqual(env.pipeline.stages.map((s) => s.name), ["SCAN", "SHORTLIST", "MEASURE", "ENQUEUE", "RETRO", "CLOSE"]); + // usage 求和律(旧 pipeline_ok 断言面):各步 usage 之和 == 信封 usage(脚本步 null 不回声) + const sum = { input: 0, output: 0, totalTokens: 0, cost: 0 }; + for (const s of env.pipeline.stages) if (s.usage) for (const k of Object.keys(sum)) sum[k] += s.usage[k]; + assert.deepEqual(sum, env.usage); + const dir = path.join(tree.opsRoot, "rounds", ROUND); + for (const f of ["d1-scan.json", "d2-shortlist.json", "d2-shortlist-transcript.jsonl", "d3-measure.json", "d4-enqueue.json", "09-budget.json", "gates.json", "state.json"]) + assert.ok(fs.existsSync(path.join(dir, f)), `工件缺:${f}`); + const gates = JSON.parse(fs.readFileSync(path.join(dir, "gates.json"), "utf8")); + assert.equal(gates.records.length, 6, "六步各一门留痕(appendGateBypass 降级旁路同入 records;P2-⑨ 原恒真 filter 删)"); + for (const rec of gates.records) assert.equal(rec.pass, true, `${rec.stage} 门红:${rec.reason}`); + const queue = fs.readFileSync(path.join(tree.opsRoot, "queue.md"), "utf8"); + assert.match(queue, /\| 2026-06-01-0400-discover \| PROPOSE \| rdns \|/, "PROPOSE 队列行缺失"); + const budget = JSON.parse(fs.readFileSync(path.join(dir, "09-budget.json"), "utf8")); + assert.equal(budget.readings.discover_channels.channels.web_read.calls, 1); + const ledger = fs.readFileSync(path.join(tree.opsRoot, "ledger.md"), "utf8"); + assert.match(ledger, /pipeline-discover:2026-06-01-0400-discover/, "台账轮次行缺失"); + fs.rmSync(tree.tmp, { recursive: true, force: true }); +}); + +test("同库同轮重放=复用任务零双写;跨库新任务撞轮目录=bad_round(旧律 ops 树守卫)", async () => { + const tree = tmpTree("v2-disc-badround-"); + const db = path.join(tree.tmp, "v2.sqlite"); + const first = await runRound(tree, db); + assert.equal(first.status, "completed"); + // 同库重跑(v2 幂等键律):复用终态任务重放,零新建零追加台账(旧「同 id 走 replay」的 v2 等价) + const again = await runRound(tree, db); + assert.equal(again.status, "completed"); + assert.equal(again.taskId, first.taskId, "同库同轮应复用任务而非新建"); + const ledger = fs.readFileSync(path.join(tree.opsRoot, "ledger.md"), "utf8"); + assert.equal((ledger.match(/pipeline-discover:2026-06-01-0400-discover/g) ?? []).length, 1, "重放不得双记台账"); + // 跨库新任务(另一 db,同 ops 树):建轮守卫拦截 bad_round(异物任务撞已有轮目录) + const foreign = await runRound(tree, path.join(tree.tmp, "v2-foreign.sqlite")); + assert.equal(foreign.status, "failed"); + assert.match(foreign.error.message, /已存在/); + assert.equal(foreign.error.detail?.code, "bad_round", "建轮守卫域错经 finishFailed 落 detail.code(P0-①)"); + fs.rmSync(tree.tmp, { recursive: true, force: true }); +}); + +test("P0-① CLI 级跨库撞轮:失败信封 error.code=bad_round 非 infra(detail.code 塌缩修复)", async () => { + const tree = tmpTree("v2-disc-cli-badround-"); + const env = { ...process.env, DOCS_ROOT: tree.docsRoot, LEDGER_FILE: path.join(tree.opsRoot, "ledger.md") }; + const run = (db) => execFile(process.execPath, [path.join(HERE, "run-discover.mjs"), ROUND, "--fixtures", FIXTURES, "--db", db], { env }).catch((e) => e); + try { + const r1 = await run(path.join(tree.tmp, "a.sqlite")); + assert.equal(JSON.parse(r1.stdout.trim().split("\n").pop()).ok, true, "首轮应成功"); + const r2 = await run(path.join(tree.tmp, "b.sqlite")); // 换库=新任务撞已有轮目录 + assert.equal(r2.code, 1, `stderr:\n${r2.stderr ?? ""}`); + const env2 = JSON.parse(r2.stdout.trim().split("\n").pop()); + assert.equal(env2.ok, false); + assert.equal(env2.error.code, "bad_round", `真码应从 detail.code 透出而非 infra:\n${r2.stdout}`); + assert.match(env2.error.message, /已存在/); + } finally { fs.rmSync(tree.tmp, { recursive: true, force: true }); } +}); + +test("P0-⑥ run-discover --model 校验:缺斜杠/空段 → exit 2;real 面漏 --model → exit 2", async () => { + const tree = tmpTree("v2-disc-model-"); + const env = { ...process.env, DOCS_ROOT: tree.docsRoot, LEDGER_FILE: path.join(tree.opsRoot, "ledger.md") }; + const run = (args) => execFile(process.execPath, [path.join(HERE, "run-discover.mjs"), ...args], { env }).catch((e) => e); + try { + assert.equal((await run([ROUND, "--model", "noprovider"])).code, 2, "--model 缺斜杠=用法错"); + assert.equal((await run([ROUND, "--model", "prov/"])).code, 2, "--model 空段=用法错"); + const r3 = await run([ROUND]); // real 面无 --model(不带 --fixtures) + assert.equal(r3.code, 2, "real 面 --model 缺=用法错(原裸 Error 落 exit 3)"); + assert.equal(r3.stdout.trim(), "", "用法错不发信封"); + } finally { fs.rmSync(tree.tmp, { recursive: true, force: true }); } +}); + +test("P0-③ SHORTLIST empty_text 首败重试:attempt 即调用同计,attempts=2 后成", async () => { + const tree = tmpTree("v2-disc-retry-"); + // 罐装短表首回合零文本块(empty_text 面)→ 重发新 requestId;次回合=原好回合 + const fxDir = path.join(tree.tmp, "fixtures"); + fs.cpSync(FIXTURES, fxDir, { recursive: true }); + const shortlist = JSON.parse(fs.readFileSync(path.join(fxDir, "shortlist.json"), "utf8")); + fs.writeFileSync(path.join(fxDir, "shortlist.json"), JSON.stringify({ ...shortlist, turns: [{ role: "assistant", content: [] }, ...shortlist.turns] })); + try { + const r = await runRound(tree, path.join(tree.tmp, "v2.sqlite"), fxDir); + assert.equal(r.status, "completed", JSON.stringify(r.error ?? r)); + const short = r.envelope.pipeline.stages.find((s) => s.name === "SHORTLIST"); + assert.equal(short.attempts, 2, "首败重试后第二轮成功"); + // 重试同计面:usageNow=会话累计(含失败回合)且仍过花费/预算门;模型调用计数在 + // 健康轮同型修复面断言(health-e2e:readings.model_calls 5) + const budget = JSON.parse(fs.readFileSync(path.join(tree.opsRoot, "rounds", ROUND, "09-budget.json"), "utf8")); + assert.equal(budget.readings.model_calls, 2, "失败 attempt 同计(旧律 budget.mjs:34)"); + fs.rmSync(tree.tmp, { recursive: true, force: true }); + } catch (e) { fs.rmSync(tree.tmp, { recursive: true, force: true }); throw e; } +}); + +test("breaker 写侧随迁:失败 verdict 经宿主适配器落 breaker.json(通道计数+consec)", async () => { + // 直调宿主核(与 defineTool execute 同入口):罐装 ok:false 读数 → 旧律 breaker.record + // → breaker.json 落盘(成功 verdict 在闭路零 consec 时零写=回声律,见 breaker.mjs applyResult)。 + const tree = tmpTree("v2-disc-breaker-"); + fs.mkdirSync(tree.opsRoot, { recursive: true }); + const dir = path.join(tree.opsRoot, "rounds", ROUND); + fs.mkdirSync(dir, { recursive: true }); + const ctx = { + dir, opsRoot: tree.opsRoot, roundId: ROUND, env: { DOCS_ROOT: tree.docsRoot }, + log: () => {}, modelFace: "faux", fixturesDir: FIXTURES, + budgetKnobs: { dupCallLimit: 3 }, breakerKnobs: undefined, ledgerFile: path.join(tree.opsRoot, "ledger.md"), + }; + const host = ensureRoundHost(ctx, "conv-test-breaker"); + // 罐装计数码失败面(breaker.mjs BREAKER_COUNTED_CODES=channel_auth/channel_missing/quota; + // io_error 非计数码不改态——旧律):临时 fixtures 只罐 ok:false code=quota 行。 + const fxDir = path.join(tree.tmp, "fixtures"); + fs.mkdirSync(fxDir, { recursive: true }); + fs.writeFileSync(path.join(fxDir, "measure.json"), JSON.stringify({ web_reads: [{ url: "https://quota.invalid/x", ok: false, code: "quota", reason: "罐装配额失败面" }] })); + ctx.fixturesDir = fxDir; + const host2 = ensureRoundHost({ ...ctx, discoverHost: undefined, dupRegistry: new Map() }, "conv-test-breaker2"); + const r = await executeDiscoveryTool(host2, "web_read", "measure-99", { url: "https://quota.invalid/x" }); + assert.equal(r.structuredContent.ok, false); + assert.equal(r.structuredContent.code, "quota"); + const st = parseBreakerState(fs.readFileSync(breakerFileOf(ctx), "utf8")); + assert.equal(st.channels.web_read.state, "closed"); + assert.equal(st.channels.web_read.consec, 1, "quota=计数码失败,consec 应 1(写侧随迁实证)"); + roundHosts.clear(); + fs.rmSync(tree.tmp, { recursive: true, force: true }); +}); + +test("相位头 SIGKILL 崩溃 → 同库 resume 续跑到 CLOSE(相位边界恢复点+startedAtIso 续算)", async () => { + const tree = tmpTree("v2-disc-crash-"); + const db = path.join(tree.tmp, "v2.sqlite"); + const env = { ...process.env, DOCS_ROOT: tree.docsRoot, LEDGER_FILE: path.join(tree.opsRoot, "ledger.md"), OPS_V2_CRASH_AFTER_PHASE: "shortlist" }; + const kill = await execFile(process.execPath, [path.join(HERE, "run-discover.mjs"), ROUND, "--fixtures", FIXTURES, "--db", db], { env }).catch((e) => e.code); + assert.equal(kill, null, `SIGKILL 期望进程被杀(exit code ${kill} 非自杀)`); + assert.ok(fs.existsSync(path.join(tree.opsRoot, "rounds", ROUND, "d1-scan.json")), "scan 工件应先于崩溃落盘"); + delete env.OPS_V2_CRASH_AFTER_PHASE; + const { stdout } = await execFile(process.execPath, [path.join(HERE, "run-discover.mjs"), ROUND, "--fixtures", FIXTURES, "--db", db], { env }); + const line = stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop(); + const r = JSON.parse(line); + // T5 冻结面(stdout 末行=旧形信封):completed ⟺ ok:true(envelope.mjs 信封纪律) + assert.equal(r.ok, true, JSON.stringify(r.error ?? r)); + assert.deepEqual(r.pipeline.stages.map((s) => s.name), ["SCAN", "SHORTLIST", "MEASURE", "ENQUEUE", "RETRO", "CLOSE"], "恢复后每步恰一次"); + fs.rmSync(tree.tmp, { recursive: true, force: true }); +}); diff --git a/ops/agent-v2/tests/golden-discover-parity.test.mjs b/ops/agent-v2/tests/golden-discover-parity.test.mjs new file mode 100644 index 00000000..8eb41183 --- /dev/null +++ b/ops/agent-v2/tests/golden-discover-parity.test.mjs @@ -0,0 +1,106 @@ +// golden-discover-parity.test.mjs — golden 对拍通道(票 #227 T3;ops/agent/golden/cases.json +// 发现轮管线例 pipeline_discover_full_faux):旧引擎(ops/agent/pipeline.mjs runPipeline, +// in-proc)vs 新任务面(ops/agent-v2 run-discover.mjs,子进程)逐工件 diff 清单。 +// 达标律(票面):通道通+失败可读,不强求全绿——本测试硬断言=两侧都跑出轮终态+核心工件 +// (d1/d3/d4/gates/queue 行)逐字节等价;已知差异面(09-budget env 快照/state 镜像/信封 +// resumed 字段)以清单打印,人工对拍入报告。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCb); +const V2 = path.resolve(import.meta.dirname, ".."); +const ROUND = "2026-06-01-0400-discover"; +const FIXTURES = path.resolve(V2, "..", "agent", "fixtures", "pipeline"); + +test("golden 对拍 pipeline_discover_full_faux:旧 runPipeline vs v2 发现轮任务,逐工件 diff 清单", async () => { + const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), "v2-parity-old-")); + const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), "v2-parity-new-")); + const envA = { ...process.env, DOCS_ROOT: path.join(tmpA, "docs"), LEDGER_FILE: path.join(tmpA, "docs", "ops", "ledger.md") }; + const envB = { ...process.env, DOCS_ROOT: path.join(tmpB, "docs"), LEDGER_FILE: path.join(tmpB, "docs", "ops", "ledger.md") }; + fs.mkdirSync(path.join(envA.DOCS_ROOT, "ops", "rules"), { recursive: true }); + fs.writeFileSync(path.join(envA.DOCS_ROOT, "ops", "rules", "suppressions.json"), JSON.stringify({ version: 1, rules: [] }, null, 2) + "\n"); // golden 例抑制规则库 fixture(run-golden.mjs 同设) + fs.mkdirSync(path.join(envB.DOCS_ROOT, "ops", "rules"), { recursive: true }); + fs.writeFileSync(path.join(envB.DOCS_ROOT, "ops", "rules", "suppressions.json"), JSON.stringify({ version: 1, rules: [] }, null, 2) + "\n"); + + // —— 旧侧:in-proc runPipeline(旧 golden runner 同调法;env 先于 import)—— + const saved = { DOCS_ROOT: process.env.DOCS_ROOT, LEDGER_FILE: process.env.LEDGER_FILE }; + Object.assign(process.env, { DOCS_ROOT: envA.DOCS_ROOT, LEDGER_FILE: envA.LEDGER_FILE }); + let oldEnv = null; + try { + const { runPipeline } = await import("../../agent/pipeline.mjs"); + const r = await runPipeline({ type: "discover", roundId: ROUND, fixturesDir: FIXTURES, + stdout: { write: () => true }, stderr: { write: () => true } }); + oldEnv = r?.envelope ?? null; + } finally { + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } + assert.ok(oldEnv?.ok, `旧引擎例应跑出 ok 信封(通道通前提):${JSON.stringify(oldEnv)?.slice(0, 200)}`); + + // —— 新侧:子进程 run-discover.mjs(相位任务面,隔离进程环境)—— + const { stdout } = await execFile(process.execPath, [path.join(V2, "run-discover.mjs"), ROUND, + "--fixtures", FIXTURES, "--db", path.join(tmpB, "v2.sqlite")], { env: envB }); + const newR = JSON.parse(stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop()); + // T5 冻结面(stdout 末行=旧形信封):completed ⟺ ok:true + assert.equal(newR.ok, true, `v2 例应终态 ok(失败面可读):${JSON.stringify(newR.error ?? newR).slice(0, 300)}`); + const newEnv = newR; + + // —— 逐工件 diff 清单(核心面硬断言等价;观察面打印留报告)—— + const dirA = path.join(envA.DOCS_ROOT, "ops", "rounds", ROUND); + const dirB = path.join(envB.DOCS_ROOT, "ops", "rounds", ROUND); + const read = (d, f) => { try { return fs.readFileSync(path.join(d, f), "utf8"); } catch { return null; } }; + const rows = []; + // 归一化:时间戳/dur_ms/路径(tmp 树)/usage 数值(模型自报口径异:旧=事件流,新=UsageDoc + // 含 cacheWrite)/门 reason 中信封字节数——已知非语义面,归一后比;归一仍异才 DIFF。 + const norm = (s) => (s === null ? null : s + .replace(/"(generated_at|updated_at|started_at|ended_at)":\s*"[^"]*"/g, '"$1": "T"') + .replace(/"dur_ms":\s*\d+/g, '"dur_ms": 0') + .replace(/"wall_s":\s*[\d.]+/g, '"wall_s": 0') + .replace(/\d+ 字节/g, "N 字节") + .replace(/\/tmp\/[A-Za-z0-9_.-]+/g, "/tmp/T") + .replace(/"(input|output|totalTokens)":\s*\d+/g, '"$1": N') + .replace(/"api":"[^"]*"/g, "") + .replace(/,"(provider|model|timestamp)":[^,}]+/g, "") + .replace(/,"durationMs":\d+/g, "") + .replace(/,"usage":\{[^}]*\}/g, "")); + const cmpText = (label, file, { core = false } = {}) => { + const a = norm(read(dirA, file)), b = norm(read(dirB, file)); + const same = a !== null && b !== null && a === b; + rows.push(`${same ? "SAME" : "DIFF"} ${label}${same ? "" : a === null ? "(旧缺)" : b === null ? "(新缺)" : "(内容异)"}`); + if (core) assert.ok(same, `核心工件 ${file} 新旧不等价`); + return same; + }; + cmpText("d1-scan.json", "d1-scan.json", { core: true }); + cmpText("d2-shortlist.json", "d2-shortlist.json", { core: true }); + cmpText("d3-measure.json", "d3-measure.json", { core: true }); + cmpText("d4-enqueue.json", "d4-enqueue.json", { core: true }); + cmpText("gates.json", "gates.json", { core: true }); + cmpText("d2-transcript", "d2-shortlist-transcript.jsonl"); // 观察面:input/seed 行应同;message 行=pi 内核封装差异可能 + cmpText("09-budget.json", "09-budget.json"); // 观察面:usage 来源=UsageDoc 活和 vs 旧事件流累计;env 快照同环境应近 + cmpText("state.json", "state.json"); // v2 降级镜像(checkpoint 真源):stages/updated_at 异=预期 + // 队列行(数据行等价;文件头模板行=写口首建差异容忍) + const qA = (read(path.join(envA.DOCS_ROOT, "ops"), "queue.md") ?? "").split("\n").filter((l) => l.startsWith("|")); + const qB = (read(path.join(envB.DOCS_ROOT, "ops"), "queue.md") ?? "").split("\n").filter((l) => l.startsWith("|")); + const dataRows = (q) => q.filter((l) => !/日期|---/.test(l)); + rows.push(`${JSON.stringify(dataRows(qA)) === JSON.stringify(dataRows(qB)) ? "SAME" : "DIFF"} queue.md 数据行`); + assert.deepEqual(dataRows(qB), dataRows(qA), "队列数据行新旧不等价"); + // 台账轮次行 + const lA = (read(path.join(envA.DOCS_ROOT, "ops"), "ledger.md") ?? "").split("\n").filter((l) => l.includes(`pipeline-discover:${ROUND}`)); + const lB = (read(path.join(envB.DOCS_ROOT, "ops"), "ledger.md") ?? "").split("\n").filter((l) => l.includes(`pipeline-discover:${ROUND}`)); + rows.push(`${lA.length === lB.length && lA[0]?.split("|").slice(0, 5).join("|") === lB[0]?.split("|").slice(0, 5).join("|") ? "SAME" : "DIFF"} ledger 轮次行(数值列 usage 源异可容,结构列等价)`); + assert.equal(lB.length, 1, "v2 台账轮次行恰一(重放零双记)"); + // 信封形状 + assert.deepEqual(newEnv.pipeline.stages.map((s) => [s.name, s.status]), oldEnv.pipeline.stages.map((s) => [s.name, s.status]), "相位序列/状态不等价"); + rows.push(`DIFF envelope.resumed_from(v2 恒 null,恢复证据在 task record;T5 冻结面)`); + rows.push(`DIFF envelope.usage(v2=UsageDoc 活和 ${JSON.stringify(newEnv.usage)};旧=事件流累计 ${JSON.stringify(oldEnv.usage)})`); + console.log(`[golden-parity] pipeline_discover_full_faux 逐件清单:\n ${rows.join("\n ")}`); + fs.rmSync(tmpA, { recursive: true, force: true }); + fs.rmSync(tmpB, { recursive: true, force: true }); +}); diff --git a/ops/agent-v2/tests/health-e2e.test.mjs b/ops/agent-v2/tests/health-e2e.test.mjs new file mode 100644 index 00000000..b082dc9d --- /dev/null +++ b/ops/agent-v2/tests/health-e2e.test.mjs @@ -0,0 +1,228 @@ +// health-e2e.test.mjs — 健康轮链路 E2E(票 #228 修复⑥:「结构绿≠执行绿」的直接补丁)。 +// 钉住本次三个 P0 的暴露面:MF1(path import——轮跑过 PROBE 后任意相位即触)/MF2( +// WAIT_MODEL join 契约——TRIAGE spawn 后 outcomes 只能在全 settle 后调)/MF3(transcript +// 工件面——DEEP 宿主回填与 G_SYNTH 闭合门都吃它)。链路=TRIAGE→WAIT_MODEL(settle)→ +// DISPATCH→DEEP(带 evidence)→ITEMIZE→SYNTH→ANCHOR_SWEEP(降级旁路)→RETRO→CLOSE, +// fromPhase 缝起步 TRIAGE(旧 pipeline.mjs replay --from 同义;G_PROBE 需 ≥30 URL 真探测, +// 零网络红线),01/02 前置工件=旧 selftest-pipeline ⑫ p170 面同法种子。 +// 罐装世界=旧 selftest R1 同契约:repo fixtures/pipeline(triage/deep×2/synth)+ 判例 +// 2026-06-01-golden-aggregator.md(与 synth.json 引用配对)+ R1 抑制规则(dshield)。 +// 环境面全 tmp:DOCS_ROOT/PRECEDENTS_DIR/LEDGER_FILE;OPS_EVAL_PY=永败 stub(锚巡检走 +// anchorSweepCli 失败信封→降级旁路,runFindingExits(sweepD=null)=skip 留痕零外发); +// OPS_SWEEP_DRY_RUN=1 双保险。faux provider 逐子任务分派(spawn 内注册),零网络零密钥。 +// 第二测:①收口复查钉负例(OPS_PIPELINE_MAX_MODEL_CALLS=2 → 3 调用后 WAIT_MODEL 收口 +// 终态 budget_exceeded——并行子任务 pre 只见基数,收口补全网的执法面)。 +import "./.e2e-env.mjs"; // 首位:env 先行件(EVAL_PY=shared.mjs 加载期常量,晚设无效;详见该文件头注) +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Harness } from "@earendil-works/pi-durable"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; +import { createModels } from "@earendil-works/pi-ai"; +import { OPS_HEALTH_ROUND } from "../tasks/health-round.mjs"; +import { buildRegistry } from "../registry.mjs"; + +const AGENT = path.resolve(import.meta.dirname, "..", "..", "agent"); // ops/agent(旧真源) +const FIXTURES = path.join(AGENT, "fixtures", "pipeline"); +const PREC = "2026-06-01-golden-aggregator.md"; // 与 fixtures/pipeline/synth.json 引用配对(旧 selftest 契约) +const CINS = "https://cinsscore.com/list/ci-badguys.txt"; +const BDEF = "https://www.binarydefense.com/banlist.txt"; +const CINS_SLUG = "https-cinsscore.com-list-ci-badguys.txt"; +const BDEF_SLUG = "https-www.binarydefense.com-banlist.txt"; + +// 判例 fixture 文件(旧 selftest-pipeline.mjs PREC 逐字副本——synth.json 引用+锚域词表 +// 检索词双命中面;动它须同步旧 selftest)。 +const PREC_BODY = `---\nid: 2026-06-01-golden-aggregator\ndate: 2026-06-01\nsubject: fixture — 聚合器候选必须实测排他率\ncontext: >\n 聚合器形状候选,上游映射推断重叠不可信。\nhuman_ruling: >\n 实测排他率 50.0%,推翻 REJECT,立项。\nreward:\n hard_gates:\n exclusive_rate: "50.0%"\n ruling_agreement: overruled\n---\n\n正文检索词:聚合器 排他率 回声 冻结 违例。\n`; + +// 世界装配:tmp DOCS 树 + 前置工件(01/02)+ 判例/规则库 + env 面。返回恢复函数。 +function seedWorld({ roundId, capCalls } = {}) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-health-e2e-")); + const docs = path.join(tmp, "docs"); + const ops = path.join(docs, "ops"); + const roundDir = path.join(ops, "rounds", roundId); + fs.mkdirSync(path.join(ops, "precedents"), { recursive: true }); + fs.mkdirSync(path.join(ops, "rules"), { recursive: true }); + fs.mkdirSync(path.join(ops, "items"), { recursive: true }); + fs.mkdirSync(roundDir, { recursive: true }); + fs.writeFileSync(path.join(ops, "precedents", PREC), PREC_BODY); + fs.writeFileSync(path.join(ops, "precedents", "_schema.md"), "schema 说明(注入排除面)。\n"); + fs.writeFileSync(path.join(ops, "rules", "suppressions.json"), JSON.stringify({ version: 1, rules: [ + { id: "R1", adopted: "2026-10-24", adopted_via: "item-fixture", match: { bucket: "frozen", url_shape: "*dshield.org*" }, + action: "suppress", note: "fixture:dshield 冻结读数为已知探测伪迹", review_at: "2027-01-24" } ] }, null, 2) + "\n"); + // 01/02 前置工件(fromPhase=TRIAGE;形状=旧 PROBE/VERIFY 产物面):2 冻结幸存+1 抑制 + fs.writeFileSync(path.join(roundDir, "01-probe-summary.json"), JSON.stringify({ ok: true, round_id: roundId, urls_n: 3, attempted_n: 3, + buckets: { fresh: 0, frozen: 3, unreachable: 0, token_required: 0, no_lm: 0 }, + flags: [ + { signal_id: CINS, url: CINS, bucket: "frozen", source: "cinsscore", age_days: 220, last_modified: "canned-220d" }, + { signal_id: BDEF, url: BDEF, bucket: "frozen", source: "binarydefense", age_days: 210, last_modified: "canned-210d" }, + { signal_id: "https://lists.dshield.org/block.txt", url: "https://lists.dshield.org/block.txt", bucket: "frozen", source: "dshield", age_days: 200, last_modified: "canned-200d" }, + ], matrix: { ok: true, exit_code: 0, violation_n: 0 } }, null, 2) + "\n"); + fs.writeFileSync(path.join(roundDir, "02-verify-survivors.json"), JSON.stringify({ survivors: [ + { signal_id: CINS, source: "cinsscore", bucket: "frozen", age_days: 220, url: CINS }, + { signal_id: BDEF, source: "binarydefense", bucket: "frozen", age_days: 210, url: BDEF }, + ], known_noise_digest: [{ id: "R1", action: "suppress", match: { bucket: "frozen", url_shape: "*dshield.org*" }, note: "fixture", review_at: "2027-01-24" }], rules_version: 1, in_flight_n: 0 }, null, 2) + "\n"); + fs.writeFileSync(path.join(roundDir, "02-verify-suppressed.json"), JSON.stringify({ suppressed: [ + { signal_id: "https://lists.dshield.org/block.txt", source: "dshield", bucket: "frozen", age_days: 200, url: "https://lists.dshield.org/block.txt", rule_id: "R1" }, + ], rules_version: 1 }, null, 2) + "\n"); + // 永败 py stub 由 ./.e2e-env.mjs 首位 import 提供(OPS_EVAL_PY 是 shared.mjs 加载期常量, + // 测试体内设已晚);这里只设调用期读取面的 env + const prev = { ...process.env }; + process.env.DOCS_ROOT = docs; + process.env.PRECEDENTS_DIR = path.join(ops, "precedents"); + process.env.LEDGER_FILE = path.join(ops, "ledger.md"); + process.env.OPS_SWEEP_DRY_RUN = "1"; + if (capCalls != null) process.env.OPS_PIPELINE_MAX_MODEL_CALLS = String(capCalls); + return { tmp, docs, ops, roundDir, restore: () => { + for (const k of ["DOCS_ROOT", "PRECEDENTS_DIR", "LEDGER_FILE", "OPS_SWEEP_DRY_RUN", "OPS_PIPELINE_MAX_MODEL_CALLS"]) + if (k in prev) process.env[k] = prev[k]; else delete process.env[k]; + } }; +} + +const jsonOf = (p) => JSON.parse(fs.readFileSync(p, "utf8")); +const jsonlOf = (p) => fs.readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); + +test("健康轮链路 E2E:TRIAGE→WAIT_MODEL(join)→DEEP(evidence)→SYNTH→…→CLOSE 全绿(faux 零网络)", async () => { + const roundId = "2026-10-28-e2e-health"; + const w = seedWorld({ roundId }); + const storage = await openNodeSqliteStorage(path.join(w.tmp, "t.sqlite")); + const context = BACKGROUND_CONTEXT; + const adm = { ok: true, projected: 0.06, month: "2026-10", spend: 0.01, counted: 1, skipped: 0, est: 0.05, cap: 30 }; // P1-5:admission 读数(submitter 链) + try { + // 空 Models(零真 provider 装载——faux 分派在 spawn 内注册;生产真面走 loadModels) + const harness = await Harness.open(storage, { models: createModels(), registry: buildRegistry() }, context); + try { + harness.resume(); + const root = await harness.root(context); + const taskId = await root.commit((tx) => tx.createTask(OPS_HEALTH_ROUND, { + roundId, fromPhase: "TRIAGE", modelFace: "faux", fixturesDir: FIXTURES, monthlyAdmission: adm, + }, { ownership: { kind: "conversation" } }), context); + const settled = await harness.waitForTask(taskId, context); + const outcome = settled.state.outcome; + assert.equal(outcome.status, "completed", `健康轮未完成:${JSON.stringify(outcome)}`); + // —— 信封(envelope=旧 composeEnvelope 逐字副本面)—— + const env = outcome.result.envelope; + assert.equal(env.ok, true); + assert.equal(env.round, "health"); + assert.equal(env.pipeline.round_id, roundId); + const stageNames = env.pipeline.stages.map((s) => s.name); + assert.deepEqual(stageNames, ["TRIAGE", "DISPATCH", "DEEP", "ITEMIZE", "SYNTH", "ANCHOR_SWEEP", "RETRO", "CLOSE"], "TRIAGE 起步→CLOSE 全相位留痕"); + assert.ok(env.pipeline.stages.every((s) => ["done", "skipped", "degraded"].includes(s.status)), `成功轮 stage 状态闭集:${JSON.stringify(env.pipeline.stages)}`); + assert.equal(env.pipeline.stages.find((s) => s.name === "ANCHOR_SWEEP").status, "degraded", "锚巡检面死=降级旁路(零网络世界)"); + assert.ok(env.usage.totalTokens > 0, `轮 usage 数字回传(faux 估算面):${JSON.stringify(env.usage)}`); + // P0-⑤ 求和律(旧 pipeline_ok 断言面):各步 usage 增量之和 == 信封 usage(脚本步 null 不计) + const sum = { input: 0, output: 0, totalTokens: 0, cost: 0 }; + for (const s of env.pipeline.stages) if (s.usage) for (const k of Object.keys(sum)) sum[k] += s.usage[k]; + assert.deepEqual(sum, env.usage, "各步求和==信封 usage(P0-⑤ 语义回旧律)"); + // —— MF3 transcript 面:TRIAGE 行型(faux 面无 model 字段/零 seed 行)—— + const tt = jsonlOf(path.join(w.roundDir, "03-triage-transcript.jsonl")); + assert.deepEqual(tt.map((x) => x.type), ["input", "message"]); + assert.equal(tt[0].model, undefined, "faux 面 input 行不带 model 字段(旧行型逐字节不变)"); + // —— DEEP:逐假设工件+transcript registry_seed 行(MF3)+宿主回填 evidence —— + const cinsV = jsonOf(path.join(w.roundDir, `05-deep-${CINS_SLUG}.json`)); + assert.equal(cinsV.verdict, "confirmed"); + assert.deepEqual(cinsV.evidence, [{ call_id: "c-cins", field: "age_days", value: 220 }], "evidence 值=transcript registry_seed 宿主回填(A 一律;MF3 断链修复的直接暴露面)"); + const cinsT = jsonlOf(path.join(w.roundDir, `05-deep-${CINS_SLUG}-transcript.jsonl`)); + assert.deepEqual(cinsT.map((x) => x.type), ["input", "registry_seed", "message"], "DEEP transcript:input+seed(≤3/假设)+message"); + assert.equal(cinsT[1].call_id, "c-cins"); + assert.equal(cinsT[1].tool, "reprobe_get"); + assert.ok(fs.existsSync(path.join(w.roundDir, `05-deep-${BDEF_SLUG}-transcript.jsonl`)), "第二假设 transcript 落盘(并行子任务分派不串台)"); + const verdicts = jsonOf(path.join(w.roundDir, "05-deep-verdicts.json")); + assert.equal(verdicts.verdicts.length, 2); + assert.deepEqual(verdicts.verdicts.map((v) => v.verdict).sort(), ["confirmed", "refuted"], "两罐装判例:cinsscore confirmed/binarydefense refuted"); + // —— SYNTH:报告+transcript input 行 meta.injected(G_SYNTH closure 门的面)—— + const report = fs.readFileSync(path.join(w.roundDir, "07-synth-report.md"), "utf8"); + assert.ok(report.includes("## 注入判例") && report.includes(PREC), "canned 报告引 fixture 判例"); + const st = jsonlOf(path.join(w.roundDir, "07-synth-transcript.jsonl")); + const stIn = st.find((l) => l.type === "input"); + assert.ok(stIn.meta?.injected?.includes(PREC), "input 行 meta.injected 含注入判例(旧 synthInput meta 面;G_SYNTH allowed 集同源)"); + // —— P1-5:09-budget.json monthly 维度对齐旧轮 —— + const budget = jsonOf(path.join(w.roundDir, "09-budget.json")); + assert.deepEqual(budget.readings.monthly, adm, "readings.monthly=admission 读数对象(旧轮=准入投影;断流修复)"); + assert.equal(budget.readings.model_calls, 4, "轮累计模型调用=TRIAGE+DEEP×2+SYNTH(P1-4 轮级口径)"); + assert.ok(budget.readings.usage.totalTokens > 0); + // —— 门留痕:执行序全门 PASS(ANCHOR_SWEEP=降级旁路留痕)—— + const gates = jsonOf(path.join(w.roundDir, "gates.json")).records; + const gateOf = (n) => gates.filter((r) => r.name === n); + for (const g of ["G_TRIAGE", "G_DISPATCH", "G_DEEP", "G_ITEMIZE", "G_SYNTH", "G_RETRO", "G_CLOSE"]) + assert.ok(gateOf(g).some((r) => r.pass === true), `门 ${g} 应有 PASS 记录`); + assert.ok(gateOf("G_ANCHOR_SWEEP").every((r) => r.reason.includes("门旁路")), "G_ANCHOR_SWEEP=降级旁路(求值面死不持门)"); + assert.ok(fs.existsSync(env.pkg), "pkg(07-synth-report.md)存在"); + assert.ok(fs.existsSync(path.join(w.ops, "retro", `${roundId}.json`)), "RETRO 工件落盘"); + } finally { await harness.close(context); } + } finally { + await storage.close(context); + w.restore(); + fs.rmSync(w.tmp, { recursive: true, force: true }); + } +}); + +// P0-③ attempt 即调用(旧律 budget.mjs:34「重试同计」):TRIAGE 首回合 stopReason=error +// (旧 runModelStep 失败面同判据)→ model_face 重试;重试 commit 同步 modelCalls+1, +// 成功面 ck.modelCalls+1 自动含失败回合 → 轮累计 5(TRIAGE 2 + DEEP×2 + SYNTH 1)。 +// 修复前:重试不计数 → 4(失败回合丢账)。 +test("P0-③ attempt 即调用:TRIAGE model_face 首败重试同计,轮累计=5 非 4", async () => { + const roundId = "2026-10-28-e2e-retry-health"; + const w = seedWorld({ roundId }); + const fxDir = path.join(w.tmp, "fixtures-retry"); + fs.cpSync(FIXTURES, fxDir, { recursive: true }); // DEEP/SYNTH 面仍用原罐装回合 + const triage = jsonOf(path.join(fxDir, "triage.json")); + fs.writeFileSync(path.join(fxDir, "triage.json"), JSON.stringify({ ...triage, turns: [{ role: "assistant", content: [], stopReason: "error" }, ...triage.turns] })); + const storage = await openNodeSqliteStorage(path.join(w.tmp, "t.sqlite")); + const context = BACKGROUND_CONTEXT; + try { + const harness = await Harness.open(storage, { models: createModels(), registry: buildRegistry() }, context); + try { + harness.resume(); + const root = await harness.root(context); + const taskId = await root.commit((tx) => tx.createTask(OPS_HEALTH_ROUND, { + roundId, fromPhase: "TRIAGE", modelFace: "faux", fixturesDir: fxDir, + }, { ownership: { kind: "conversation" } }), context); + const settled = await harness.waitForTask(taskId, context); + const outcome = settled.state.outcome; + assert.equal(outcome.status, "completed", `重试后应全链完成:${JSON.stringify(outcome)}`); + const budget = jsonOf(path.join(w.roundDir, "09-budget.json")); + assert.equal(budget.readings.model_calls, 5, "失败 attempt 同计:TRIAGE 2+DEEP×2+SYNTH 1(旧律 budget.mjs:34)"); + // P0-⑤ 求和律在重试轮同样成立(增量口径与重试回合无关) + const env = outcome.result.envelope; + const sum = { input: 0, output: 0, totalTokens: 0, cost: 0 }; + for (const s of env.pipeline.stages) if (s.usage) for (const k of Object.keys(sum)) sum[k] += s.usage[k]; + assert.deepEqual(sum, env.usage); + } finally { await harness.close(context); } + } finally { + await storage.close(context); + w.restore(); + fs.rmSync(w.tmp, { recursive: true, force: true }); + } +}); +// P1-4 ①收口复查钉负例:调用帽 2 < 实际 3(TRIAGE+DEEP×2 各自 pre 只见基数全放行)→ +// WAIT_MODEL 收口按轮累计终态 budget_exceeded(违界即停不降级;SYNTH 不再 spawn)。 +test("P1-4 ①收口:并行子任务穿透基数后 WAIT_MODEL 按轮累计终态 budget_exceeded", async () => { + const roundId = "2026-10-28-e2e-cap-health"; + const w = seedWorld({ roundId, capCalls: 2 }); + const storage = await openNodeSqliteStorage(path.join(w.tmp, "t.sqlite")); + const context = BACKGROUND_CONTEXT; + try { + const harness = await Harness.open(storage, { models: createModels(), registry: buildRegistry() }, context); + try { + harness.resume(); + const root = await harness.root(context); + const taskId = await root.commit((tx) => tx.createTask(OPS_HEALTH_ROUND, { + roundId, fromPhase: "TRIAGE", modelFace: "faux", fixturesDir: FIXTURES, + }, { ownership: { kind: "conversation" } }), context); + const settled = await harness.waitForTask(taskId, context); + const outcome = settled.state.outcome; + assert.equal(outcome.status, "failed", `应收口终态:${JSON.stringify(outcome)}`); + assert.equal(outcome.error.code, "budget_exceeded"); + assert.match(outcome.error.message, /预算四界①\(收口\):轮累计模型调用 3 > 2/); + assert.ok(fs.existsSync(path.join(w.roundDir, `05-deep-${CINS_SLUG}.json`)), "已花工件保留(证据不灭失)"); + assert.equal(fs.existsSync(path.join(w.roundDir, "07-synth-report.md")), false, "SYNTH 未再 spawn(违界即停)"); + } finally { await harness.close(context); } + } finally { + await storage.close(context); + w.restore(); + fs.rmSync(w.tmp, { recursive: true, force: true }); + } +}); diff --git a/ops/agent-v2/tests/resume.test.mjs b/ops/agent-v2/tests/resume.test.mjs new file mode 100644 index 00000000..52a0b553 --- /dev/null +++ b/ops/agent-v2/tests/resume.test.mjs @@ -0,0 +1,69 @@ +// resume.test.mjs — kill+resume 复跑变体(票 #225;参考 T0 notes/smoke.mjs,落在 v2 入口形态) +// 流程:子进程 `node entry.mjs --crash-after-submit`(submit 承认即 SIGKILL, +// 模型请求在飞/未回,拟小机 OOM)→ 新「进程」(同进程内 runRound,同库同 requestId) +// resume 续跑至 done → 再跑一遍同 requestId:信封一致、usage 不变(幂等零新调用)。 +// 真实 GLM 调用 ≤2 次(flash 档,合计 ≈¥0.0001,远低于 ¥1 上限)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { runRound, requestIdFor } from "../entry.mjs"; + +const V2_DIR = path.resolve(import.meta.dirname, ".."); +const ENTRY = path.join(V2_DIR, "entry.mjs"); + +function hasZaiCredential() { + const authPath = process.env.OPS_AUTH + ?? path.join(os.homedir(), ".pi", "agent", "auth.json"); + try { + const auth = JSON.parse(readFileSync(authPath, "utf8")); + if (auth?.["zai-coding-cn"]?.key) return true; + } catch { /* 无 auth.json → 只看 env */ } + return Boolean(process.env.ZAI_CODING_CN_API_KEY); +} +const SKIP = hasZaiCredential() ? false : "无 zai 凭证(auth.json/ZAI_CODING_CN_API_KEY 均缺)——SKIP(CI 无凭证判例)"; + +const ANSWER_TOKEN = "V2RES"; +const PROMPT = `只回复五个字符:${ANSWER_TOKEN}(不要任何其他内容)`; +const ROUND_ID = `smoke-resume-${new Date().toISOString().slice(0, 10)}`; + +function runChild(args) { + return new Promise((resolveChild) => { + const child = spawn(process.execPath, [ENTRY, ...args], { + cwd: V2_DIR, + env: { ...process.env }, + }); + let out = ""; let err = ""; + child.stdout.on("data", (d) => { out += d; }); + child.stderr.on("data", (d) => { err += d; }); + child.on("exit", (code, signal) => resolveChild({ code, signal, out, err })); + }); +} + +test("kill -9 后 entry 复跑:同 requestId 续跑完成;再复跑幂等零新调用", { skip: SKIP }, async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-resume-")); + const dbPath = path.join(tmp, "agent-v2.sqlite"); + try { + // phase 1:子进程跑到 submit 承认即自杀(SIGKILL;信封必未产出) + const crashed = await runChild([ROUND_ID, "--db", dbPath, "--content", PROMPT, "--crash-after-submit"]); + assert.equal(crashed.signal, "SIGKILL", `崩溃相不对:code=${crashed.code} signal=${crashed.signal} err=${crashed.err}`); + + // phase 2:复跑(同库同 requestId)→ resume 续跑完成 + const env1 = await runRound({ roundId: ROUND_ID, content: PROMPT, dbPath }); + assert.equal(env1.status, "done"); + assert.equal(env1.requestId, requestIdFor(ROUND_ID)); + assert.match(env1.answer, new RegExp(ANSWER_TOKEN)); + assert.ok(env1.usage && Object.keys(env1.usage.models ?? {}).length >= 1, "信封缺 usage.models"); + + // phase 3:再复跑同 requestId → 幂等:信封状态/应答一致,usage 零变化 + const env2 = await runRound({ roundId: ROUND_ID, content: PROMPT, dbPath }); + assert.equal(env2.status, "done"); + assert.match(env2.answer, new RegExp(ANSWER_TOKEN)); + assert.deepEqual(env2.usage, env1.usage, "同 requestId 复跑 usage 变化:发生新模型调用"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/ops/agent-v2/tests/rounds.test.mjs b/ops/agent-v2/tests/rounds.test.mjs new file mode 100644 index 00000000..4e6e95d7 --- /dev/null +++ b/ops/agent-v2/tests/rounds.test.mjs @@ -0,0 +1,160 @@ +// rounds.test.mjs — T4(票 #228)任务面测试:①breaker seam 五通道闭集+快照只读不造文件 +// (Q3③);②微探针三件套闭集+replay unsafe+未绑定拒绝/绑定派发(stub 传输层零网络); +// ③ops-model-round E2E(fauxProvider 注入,零网络零密钥):TRIAGE 一发→工件落盘→G_TRIAGE +// 过→任务 terminal completed;④健康轮相位结构面:相位闭集=旧 runnableOf+CLOS+WAIT_MODEL+ +// FAILED 书挡,轮始旋钮非法=usage 错(旧 mkCtx 同判例);⑤requestId 形状(Q1)。 +// 真 GLM:本文件零调用(预算 ¥0;provider 路径由 tests/smoke-glm.test.mjs 既有 18 例覆盖, +// 真轮对拍=T5 shadow harness 票面)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Harness } from "@earendil-works/pi-durable"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; +import { fauxProvider, createModels } from "@earendil-works/pi-ai"; +import * as v2breaker from "../breaker.mjs"; // 零复制 re-export seam(被测面) +import { BREAKER_CHANNELS, mkBreaker, readBreakerKnobs } from "../../agent/tools/breaker.mjs"; // 旧真源(对拍基准) +import { OPS_PROBE_TOOLS, bindProbeHost, unbindProbeHost } from "../tools/probes.mjs"; +import { OPS_MODEL_ROUND, modelRequestId, MODEL_STAGES } from "../tasks/model-round.mjs"; +import { OPS_HEALTH_ROUND, RUNNABLE, rebuildRoundCtx } from "../tasks/health-round.mjs"; +import { buildRegistry } from "../registry.mjs"; +import { HEALTH_PIPELINE } from "../../agent/pipeline.mjs"; + +// ── ① breaker seam:五通道闭集 + 快照只读不造文件(Q3③;旧 CLOSE 步同面)────────── +test("breaker seam:五通道闭集逐名;snapshot() 只读零写(回声律:不造文件)", async () => { + assert.deepEqual(v2breaker.BREAKER_CHANNELS, ["fetch_url", "gh_search", "rss_parse", "web_read", "web_search"]); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-breaker-")); + try { + const file = path.join(tmp, "breaker.json"); + const knobs = { threshold: 1, cooldownS: 60 }; // 阈值 1:单败即 open(旧语义;写侧转移才 persist) + const face = mkBreaker({ file, env: {}, round: "t", ledgerFile: path.join(tmp, "ledger.md"), knobs, log: () => {} }); // ledger 指 tmp:台账留痕不落生产面 + const snap = face.snapshot(); + assert.ok(snap && typeof snap === "object", "快照非对象"); + assert.equal(fs.existsSync(file), false, "CLOSE 观察面 snapshot() 不得创建 breaker.json(回声律)"); + // 写侧(=T3 通道工具面,票面注记):闭集内名结算 → 状态转移才 persist(状态文件才出现)——与 CLOSE 只读面互锁 + const t1 = face.record("fetch_url", { ok: false, code: "channel_auth" }); // counted code 才计入 consec(BREAKER_COUNTED_CODES 闭集) + assert.equal(t1, "opened", "阈值 1:单败即 opened 转移(旧转移词 opened)"); + assert.equal(fs.existsSync(file), true, "转移后状态文件应在(写侧与 CLOSE 只读面互锁)"); + assert.equal(face.verdict("not_a_channel").action, "allow", "通道外名=fail-判读面 allow 零写(旧语义)"); + assert.equal(face.verdict("fetch_url").action, "block", "open 态闭集内名=block(冷却未到)"); + } finally { fs.rmSync(tmp, { recursive: true, force: true }); } +}); + +// ── ② 微探针三件套(Q2:只迁三只,全 replay unsafe)──────────────────────────── +test("微探针闭集=三只全 replay:unsafe;未绑定会话=拒绝信封;绑定后派发旧宿主(stub 零网络)", async () => { + assert.deepEqual(OPS_PROBE_TOOLS.map((t) => t.name).sort(), ["reprobe_get", "reprobe_ua", "upstream_check"]); + assert.ok(OPS_PROBE_TOOLS.every((t) => t.replay === "unsafe"), "三只全 unsafe(真网络探测,重放=出网)"); + const byName = (n) => OPS_PROBE_TOOLS.find((t) => t.name === n); + // 未绑定会话(TRIAGE/SYNTH 无探针面同义):拒绝信封,不执行任何网络 + const refused = await byName("reprobe_get").execute({ url: "https://example.test/x" }, { conversationId: "conv-unbound", callId: "c1" }); + assert.equal(refused.isError, true); + assert.equal(refused.structuredOutput.ok, false); + // 绑定会话:stub 传输层派发进旧宿主(读数结构化面同源;零真网络) + bindProbeHost("conv-bound", { fetchImpl: async () => ({ status: 200, headers: { "last-modified": "Tue, 01 Jan 2026 00:00:00 GMT" }, finalUrl: "https://example.test/f", bytes: 4 }) }); + try { + const r = await byName("reprobe_get").execute({ url: "https://example.test/x" }, { conversationId: "conv-bound", callId: "c2" }); + assert.equal(r.isError, undefined, "绑定后成功读数非 isError"); + assert.equal(r.structuredOutput.ok, true); + assert.equal(r.structuredOutput.status, 200); + assert.equal(typeof r.structuredOutput.age_days, "number", "age_days 由旧宿主回填(值由宿主回填 A 一律)"); + } finally { unbindProbeHost("conv-bound"); } +}); + +// ── ③ ops-model-round E2E(fauxProvider;零网络零密钥)───────────────────────── +test("ops-model-round E2E:TRIAGE 一发→03-triage-hypotheses.json 落盘→G_TRIAGE PASS→terminal completed", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-modelround-")); + const prevDocs = process.env.DOCS_ROOT, prevDb = process.env.OPS_V2_DB; + process.env.DOCS_ROOT = path.join(tmp, "docs"); + const roundId = "2026-10-28-1234-health"; + const roundDir = path.join(process.env.DOCS_ROOT, "ops", "rounds", roundId); + fs.mkdirSync(roundDir, { recursive: true }); + // 前置工件:02-verify-survivors.json(幸存集=canned 假设两 signal_id,G_TRIAGE 穷尽判据) + fs.writeFileSync(path.join(roundDir, "02-verify-survivors.json"), JSON.stringify({ + survivors: [ + { signal_id: "https://cinsscore.com/list/ci-badguys.txt", source: "cinsscore", bucket: "frozen", age_days: 220, url: "https://cinsscore.com/list/ci-badguys.txt" }, + { signal_id: "https://www.binarydefense.com/banlist.txt", source: "binarydefense", bucket: "frozen", age_days: 210, url: "https://www.binarydefense.com/banlist.txt" }, + ], known_noise_digest: [] }, null, 2) + "\n"); + // canned 回合=旧 fixtures/pipeline/triage.json 同型(G_TRIAGE 闭集值全过);fixturesDir=同源 + // (MF3 后 faux 面 transcript 种子/fixture 必件——旧 loadFixture 同判例) + const fixturesDir = path.resolve(import.meta.dirname, "..", "..", "agent", "fixtures", "pipeline"); + const canned = JSON.parse(fs.readFileSync(path.join(fixturesDir, "triage.json"), "utf8")); + const systemPrompt = fs.readFileSync(path.resolve(import.meta.dirname, "..", "..", "agent", "prompts", "health", "triage.md"), "utf8"); + + const { fauxProvider: fp, createModels: cm } = { fauxProvider, createModels }; + const faux = fp({ models: [{ id: "glm-5.3-flash" }] }); + const models = cm(); + models.setProvider(faux.provider); + faux.setResponses(canned.turns); + const storage = await openNodeSqliteStorage(path.join(tmp, "t.sqlite")); + const harness = await Harness.open(storage, { models, registry: buildRegistry() }, BACKGROUND_CONTEXT); + const context = BACKGROUND_CONTEXT; + try { + harness.resume(); + const m = faux.getModel(); + const opsExt = buildRegistry().snapshot().installed().find((e) => e.name === "ops"); // AgentChange.extensions 要 Extension 对象(applyChange names() 读 .name;存态才是字符串) + const root = await harness.root(context, { agent: { model: { provider: m.provider, modelId: m.id }, extensions: [opsExt], instructions: systemPrompt, modelTools: [] } }); + const taskId = await root.commit((tx) => tx.createTask(OPS_MODEL_ROUND, { roundId, stage: "TRIAGE", modelFace: "faux", fixturesDir }, { ownership: { kind: "conversation" } }), context); + const settled = await harness.waitForTask(taskId, context); + const outcome = settled.state.outcome; + assert.equal(outcome.status, "completed", `任务未完成:${JSON.stringify(outcome)}`); + assert.equal(outcome.result.stage, "TRIAGE"); + assert.match(outcome.result.prompt_sha, /^[0-9a-f]{16}$/); + const h = JSON.parse(fs.readFileSync(path.join(roundDir, "03-triage-hypotheses.json"), "utf8")); + assert.equal(h.hypotheses.length, 2, "收编工件(零复制 triageConsume)落盘"); + // MF3 钉住:transcript 工件行型位等价(旧 faux.mjs:87-94——input 行无 model 字段/ + // 零 registry_seed/assistant message 行;门与 DEEP 宿主回填的读面) + const tl = fs.readFileSync(path.join(roundDir, "03-triage-transcript.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); + assert.deepEqual(tl.map((x) => x.type), ["input", "message"], "TRIAGE transcript 行型:input+message(单发步零 seed 行)"); + assert.equal(tl[0].model, undefined, "faux 面 input 行不带 model 字段(旧行型逐字节不变)"); + assert.equal(tl[0].stage, "TRIAGE"); + assert.match(tl[0].system_sha, /^[0-9a-f]{16}$/); + assert.equal(tl[0].meta, null, "TRIAGE 无判例注入面 meta=null(旧 input.meta ?? null 同形)"); + assert.equal(tl[1].role, "assistant"); + assert.equal(tl[1].stopReason, "stop"); + const gatesLog = JSON.parse(fs.readFileSync(path.join(roundDir, "gates.json"), "utf8")); + const g = (Array.isArray(gatesLog) ? gatesLog : gatesLog.records ?? []).find((r) => r.gate === "G_TRIAGE" || r.name === "G_TRIAGE"); + assert.ok(g, "gates.json 落 G_TRIAGE 记录"); + } finally { + await harness.close(context); + await storage.close(context); + if (prevDocs === undefined) delete process.env.DOCS_ROOT; else process.env.DOCS_ROOT = prevDocs; + if (prevDb === undefined) delete process.env.OPS_V2_DB; + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ── ④ 健康轮结构面:相位闭集 + 轮始旋钮判例 ────────────────────────────────── +test("健康轮相位闭集=旧 runnableOf(PROBE…RETRO)+CLOSE+WAIT_MODEL+FAILED;stage 闭集;requestId 形状", () => { + const oldRunnable = HEALTH_PIPELINE.filter((d) => d.kind === "script" || d.kind === "model").map((d) => d.name); + assert.deepEqual(RUNNABLE, oldRunnable, "v2 相位序=旧 runnableOf 单源推导"); + assert.equal(RUNNABLE[RUNNABLE.length - 1], "RETRO"); + const phases = Object.keys(OPS_HEALTH_ROUND.definition.phases).sort(); + assert.deepEqual(phases, [...oldRunnable, "CLOSE", "WAIT_MODEL", "FAILED"].sort(), "相位闭集=十态+join+书挡"); + assert.deepEqual(MODEL_STAGES.sort(), ["DEEP", "SYNTH", "TRIAGE"]); + assert.equal(modelRequestId("r1", "TRIAGE"), "round-r1-TRIAGE"); + assert.equal(modelRequestId("r1", "DEEP", "hyp 2"), "round-r1-DEEP-hyp-2", "hid 经 hidSlug(hyp 2→hyp-2)"); +}); + +test("轮始旋钮非法=usage 错不带病跑轮(旧 mkCtx 同判例;OPS_PIPELINE_BUDGET_CNY 面)", () => { + const prev = process.env.OPS_PIPELINE_BUDGET_CNY; + process.env.OPS_PIPELINE_BUDGET_CNY = "not-a-number"; + try { + assert.throws(() => OPS_HEALTH_ROUND.definition.initial({ roundId: "x-health" }), (e) => e.usage === true); + } finally { + if (prev === undefined) delete process.env.OPS_PIPELINE_BUDGET_CNY; else process.env.OPS_PIPELINE_BUDGET_CNY = prev; + } +}); + +// P1-5:admission 读数入链(submitter 链面——input.monthlyAdmission → initial → +// ck.state.monthly_admission → rebuildRoundCtx 透传 → CLOSE 09-budget.json readings.monthly) +test("P1-5:initial 接 monthlyAdmission 读数入 state.monthly_admission;缺席=不设(replay 面旧同形)", () => { + const adm = { ok: true, projected: 0.06, month: "2026-10", spend: 0.01, counted: 1, skipped: 0, est: 0.05, cap: 30 }; // admitOrRefuse 返回 admission 读数对象(budget.mjs monthlyAdmission 同形) + const ck = OPS_HEALTH_ROUND.definition.initial({ roundId: "r-p15-health", monthlyAdmission: adm }); + assert.deepEqual(ck.state.monthly_admission, adm, "读数对象原样入 checkpoint(旧 ctx.state.monthly_admission 同位)"); + assert.equal(OPS_HEALTH_ROUND.definition.initial({ roundId: "r2-health" }).state.monthly_admission, undefined, "缺席=不设键(09-budget readings.monthly=null,旧 replay 面同形)"); + // 重建面透传断言(rebuildRoundCtx:monthly_admission 有值才镜像) + const rebuilt = rebuildRoundCtx({ roundId: "r-p15-health", modelFace: "faux" }, ck); + assert.deepEqual(rebuilt.state.monthly_admission, adm, "rebuildRoundCtx 透传(旧 state.json 全字段镜像面)"); +}); diff --git a/ops/agent-v2/tests/scaffold.test.mjs b/ops/agent-v2/tests/scaffold.test.mjs new file mode 100644 index 00000000..a23cbabd --- /dev/null +++ b/ops/agent-v2/tests/scaffold.test.mjs @@ -0,0 +1,79 @@ +// scaffold.test.mjs — v2 骨架结构面断言(零网络零模型调用;票 #225) +// 面:①registry 红线(零工具/零自有任务;不装内建 CodingTools)②compaction 默认关 +// ③SQLite journal=WAL(t1-notes「进程崩溃 commit 安全」的落库前提)④pi-ai 单实例@1.4.0 +// (t1-notes 风险2 双实例陷阱)⑤db 落位推导。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { createRegistry } from "@earendil-works/pi-durable"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; +import { buildRegistry } from "../registry.mjs"; +import { HARNESS_SETTINGS, resolveDbPath, ROOT } from "../entry.mjs"; + +const V2_DIR = path.resolve(import.meta.dirname, ".."); // ops/agent-v2 + +test("registry 红线:零内建 CodingTools;工具面=T4 微探针三只+T3 发现轮三只闭集;任务面=内建+三只轮任务", () => { + const snapshot = buildRegistry().snapshot(); + // ops 红线(spec 背景节 + t1-notes §1.2):v2 不安装任何内建 coding 工具面 + // (CodingTools/read/write/edit/bash 一概不装)。工具闭集=T3+T4 并批并集: + // ops 扩展 = T4 微探针三只(replay 全 unsafe;仅健康轮真 DEEP 面选装) + // ops-discover-tools = T3 发现轮三只只读通道工具(仅发现轮会话选装;root 面钉 [] 见 entry.mjs) + const tools = snapshot.tools().map((t) => t.tool); // 快照元素={extension,tool} 包裹形(1.4.0) + assert.deepEqual(tools.map((t) => t.name).sort(), ["reprobe_get", "reprobe_ua", "rss_parse", "upstream_check", "web_read", "web_search"], "工具面=两批闭集并集(多=越权,少=门红)"); + for (const name of ["reprobe_get", "reprobe_ua", "upstream_check"]) + assert.equal(tools.find((t) => t.name === name)?.replay, "unsafe", `${name} 必须 replay:unsafe(真网络重放=出网动作)`); + for (const name of ["rss_parse", "web_read", "web_search"]) + assert.equal(tools.find((t) => t.name === name)?.annotations?.readOnlyHint, true, `${name} 非只读——发现轮闭集红线`); + assert.deepEqual(snapshot.installed().map((e) => e.name), ["ops", "ops-discover-tools"]); + // 任务面:内建三 + T3 发现轮 + T4 健康轮两只(命名形制随各自票面,不改写)。 + const names = (s) => s.tasks().map((t) => `${t.definition.name}@${t.definition.version}`).sort(); + assert.deepEqual(names(snapshot), [...names(createRegistry().snapshot()), "ops.discover-round@1", "ops-health-round@1", "ops-model-round@1"].sort()); +}); + +test("compaction 默认关(t1-notes 风险5:自动摘要改写上下文,shadow 对拍期禁用)", () => { + assert.equal(HARNESS_SETTINGS.compaction.enabled, false); +}); + +test("SQLite 落库 journal=WAL(chord 适配器缺省;断电面按 t1-notes 风险另计)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-scaffold-")); + const dbPath = path.join(tmp, "wal.sqlite"); + const storage = await openNodeSqliteStorage(dbPath); + await storage.close(BACKGROUND_CONTEXT); + const ro = new DatabaseSync(dbPath, { readOnly: true }); + try { + assert.equal(ro.prepare("PRAGMA journal_mode").get().journal_mode, "wal"); + } finally { + ro.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("pi-ai 单实例且 =1.4.0;跨目录(ops/agent)同版(t1-notes 风险2 双实例陷阱)", () => { + // v2 树内:npm ls --parseable 每个 pi-ai 物理路径必须唯一 + const out = execFileSync( + "npm", ["ls", "@earendil-works/pi-ai", "--all", "--parseable"], + { cwd: V2_DIR, encoding: "utf8" }, + ).trim(); + const paths = out.split("\n").map((l) => l.trim()).filter(Boolean); + assert.ok(paths.length >= 1, `npm ls 无输出:${out}`); + assert.equal(new Set(paths).size, 1, `pi-ai 多物理实例:${paths.join(" | ")}`); + assert.match(paths[0], /node_modules[/]@earendil-works[/]pi-ai$/); + const v2Version = JSON.parse(fs.readFileSync(path.join(paths[0], "package.json"), "utf8")).version; + assert.equal(v2Version, "1.4.0"); + // 跨目录:models.mjs 只读 import 自 ops/agent,其 pi-ai 解析自 ops/agent/node_modules, + // 两份物理拷贝必须同版(否则两套凭证店/目录互不相识,api-map §0)。 + const agentPkg = path.join(ROOT, "ops", "agent", "node_modules", "@earendil-works", "pi-ai", "package.json"); + assert.ok(fs.existsSync(agentPkg), `ops/agent 未 npm ci(缺 ${agentPkg})`); + assert.equal(JSON.parse(fs.readFileSync(agentPkg, "utf8")).version, v2Version, "ops/agent pi-ai 版本漂移"); +}); + +test("db 落位:OPS_V2_DB > DOCS_ROOT/ops/agent-v2.sqlite > <仓根>/docs/ops/agent-v2.sqlite(t1-notes 建议3)", () => { + assert.equal(resolveDbPath({ OPS_V2_DB: "/tmp/x.sqlite" }), "/tmp/x.sqlite"); + assert.equal(resolveDbPath({ DOCS_ROOT: "/d" }), path.join("/d", "ops", "agent-v2.sqlite")); + assert.equal(resolveDbPath({}), path.join(ROOT, "docs", "ops", "agent-v2.sqlite")); +}); diff --git a/ops/agent-v2/tests/shadow-health-parity.test.mjs b/ops/agent-v2/tests/shadow-health-parity.test.mjs new file mode 100644 index 00000000..2ff2055b --- /dev/null +++ b/ops/agent-v2/tests/shadow-health-parity.test.mjs @@ -0,0 +1,33 @@ +// shadow-health-parity.test.mjs — shadow 对拍闸门(票 #229 T5 任务书③:健康轮 faux 位等价)。 +// 跑 shadow-diff.mjs CLI 全程:OLD=旧管线 replay(罐装世界断点进,零网络),NEW=run-health +// --from-phase TRIAGE --to-phase RETRO;断言 VERDICT SAME(可比工件闭集+台账行,归一化窄面 +// 见 shadow-diff.mjs 头注)。运行期异常/任一面失败=测试红(exit 3 语义透传)。 +// 这是 spec 定案 2 安全轨的自动闸门:shadow 期每轮 cron 后人工跑一次同面;切流前本测+全量 +// 测试套必须绿(SHADOW-RUNBOOK §切流门槛)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { isUndeclaredNewFile } from "../shadow-diff.mjs"; + +const execFileP = promisify(execFile); +const HERE = import.meta.dirname; + +test("残余哨判定(复核 P2-2 正例):闭集/白名单放行,未声明新面捕获", () => { + for (const ok of ["03-triage-hypotheses.json", "05-deep-verdicts.json", "gates.json", "01-probe-summary.json", "02-verify-survivors.json", "09-budget.json", ".v2-claim.json"]) + assert.equal(isUndeclaredNewFile(ok), false, `${ok} 应放行`); + for (const bad of ["10-anything.json", "retro-note.md", "state2.json"]) + assert.equal(isUndeclaredNewFile(bad), true, `${bad} 应捕获为未声明新面`); +}); + +test("shadow 对拍:旧 replay vs v2 checkpoint 任务,可比面全 SAME(位等价闸门)", async () => { + const d = new Date(); + // 旧轮 id 形状律硬约束 YYYY-MM-DD-HHMM-health(bad_round 判例):秒位不可入 id; + // 世界各自隔离 tmp,同分钟重跑无冲突。 + const id = `${d.toISOString().slice(0, 10)}-${String(d.getHours()).padStart(2, "0")}${String(d.getMinutes()).padStart(2, "0")}-health`; + const r = await execFileP("node", [path.join(HERE, "..", "shadow-diff.mjs"), id], { cwd: path.resolve(HERE, "..", ".."), maxBuffer: 8 * 1024 * 1024 }); + assert.match(r.stdout, /VERDICT SAME\(old\.exit=0 new\.exit=0\)/, `对拍非全 SAME:\n${r.stdout}`); + for (const f of ["03-triage-transcript.jsonl", "05-deep-verdicts.json", "07-synth-report.md", "08-anchor-sweep.json", "gates.json", "ledger.md"]) + assert.ok(new RegExp(`^SAME\\s+${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").test(r.stdout), `关键工件须 SAME:${f}`); +}, { timeout: 240_000 }); diff --git a/ops/agent-v2/tests/smoke-glm.test.mjs b/ops/agent-v2/tests/smoke-glm.test.mjs new file mode 100644 index 00000000..835be1b3 --- /dev/null +++ b/ops/agent-v2/tests/smoke-glm.test.mjs @@ -0,0 +1,77 @@ +// smoke-glm.test.mjs — GLM smoke(票 #225;真实 1 次 glm-5.3-flash 调用,单次 ≈¥0.00004) +// 面:开 SQLite → root conversation → 1 次 GLM 调用 → 断言应答落库(重开 harness 仍在) +// + requestId exactly-once 重放零新调用(T0 notes/smoke.mjs 主链,落在 v2 适配层入口形态)。 +// 凭证照 ops/agent 现行链(auth.json → env 兜底),测试只判有无,零密钥入码/入日志。 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { openHarness, requestIdFor } from "../entry.mjs"; +import { loadModels } from "../models.mjs"; + +// 凭证在否(布尔判定,不打印值):auth.json(OPS_AUTH 覆盖)有 zai 家 key,或 env 兜底键在。 +function hasZaiCredential() { + const authPath = process.env.OPS_AUTH + ?? path.join(os.homedir(), ".pi", "agent", "auth.json"); + try { + const auth = JSON.parse(readFileSync(authPath, "utf8")); + if (auth?.["zai-coding-cn"]?.key) return true; + } catch { /* 无 auth.json → 只看 env */ } + return Boolean(process.env.ZAI_CODING_CN_API_KEY); +} +const SKIP = hasZaiCredential() ? false : "无 zai 凭证(auth.json/ZAI_CODING_CN_API_KEY 均缺)——SKIP(CI 无凭证判例)"; + +const ANSWER_TOKEN = "V2OK"; +const PROMPT = `只回复四个字符:${ANSWER_TOKEN}(不要任何其他内容)`; +const REQUEST_ID = "smoke-glm-v2"; + +const lastAssistantText = (view) => (view.entries.filter((e) => e.kind === "pi.assistant").at(-1)?.model ?? []) + .flatMap((m) => m.content ?? []).find((b) => b.type === "text")?.text ?? ""; + +test("GLM smoke:SQLite+root+1 次调用落库;重开仍在;requestId 重放零新调用", { skip: SKIP }, async () => { + const { resolve } = await loadModels(); + const m = resolve("health"); // 缺省 flash 档(OPS_MODEL_HEALTH 可覆盖;定案 7 同源) + const modelRef = { provider: m.provider, modelId: m.id }; + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "v2-smoke-glm-")); + const dbPath = path.join(tmp, "agent-v2.sqlite"); + try { + // phase A:开库 → root → 1 次 GLM 调用 + const h1 = (await openHarness({ dbPath })).harness; + const root1 = await h1.root(BACKGROUND_CONTEXT, { agent: { model: modelRef } }); + const sub = await root1.submit( + { type: "input", content: PROMPT, requestId: REQUEST_ID }, + BACKGROUND_CONTEXT, + ); + const settled = await sub.wait(BACKGROUND_CONTEXT); + assert.equal(settled.status, "done"); + assert.match(lastAssistantText(await root1.context(BACKGROUND_CONTEXT)), new RegExp(ANSWER_TOKEN)); + const usageA = await h1.usage(BACKGROUND_CONTEXT); + await h1.close(BACKGROUND_CONTEXT); + + // phase B:新进程视角重开同库 → transcript 应答仍在(落库断言) + const h2 = (await openHarness({ dbPath })).harness; + const root2 = await h2.root(BACKGROUND_CONTEXT, { agent: { model: modelRef } }); + assert.match(lastAssistantText(await root2.context(BACKGROUND_CONTEXT)), new RegExp(ANSWER_TOKEN), + "重开后 GLM 应答不在 transcript:落库失败"); + + // exactly-once:同 requestId 重放 → 既有 submission(同 id),usage 不变(零新调用) + const again = await root2.submit( + { type: "input", content: "重复请求", requestId: REQUEST_ID, whenBusy: "reject" }, + BACKGROUND_CONTEXT, + ); + assert.equal(again.id, sub.id, "同 requestId 重放应返回既有 submission"); + await again.wait(BACKGROUND_CONTEXT).catch(() => undefined); // 已 settle/被拒均可,不新建调用 + assert.deepEqual(await h2.usage(BACKGROUND_CONTEXT), usageA, "重放后 usage 变化:发生新模型调用"); + await h2.close(BACKGROUND_CONTEXT); + + // requestId 形状纪律(t1-notes 风险12):cron 幂等键 + assert.equal(requestIdFor("2026-11-01"), "round-2026-11-01"); + assert.equal(requestIdFor("2026-11-01", "probe"), "round-2026-11-01-probe"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/ops/agent-v2/tools/discovery-tools.mjs b/ops/agent-v2/tools/discovery-tools.mjs new file mode 100644 index 00000000..7c97c4e7 --- /dev/null +++ b/ops/agent-v2/tools/discovery-tools.mjs @@ -0,0 +1,113 @@ +// tools/discovery-tools.mjs — 发现轮 web 三工具 defineTool 化(票 #227;#217 T3.5 接线面 +// 的 v2 承接;spec docs/superpowers/specs/2026-10-28-ops-agent-pidurable-migration-design.md +// 定案 6 薄适配层)。 +// 零复制律:工具实现/守卫链/熔断写侧/种子登记全部经旧管线 discovery-host.mjs 的 +// mkDiscoveryHost 复用——真网络/解析/信封(dup→breaker→cap 同序同义)、breaker.record +// (五通道写侧,2026-10-28 steer:写侧随迁归 T3)、channelCounts、seeds 一概不 fork。 +// 本文件只做两件事:①轮级宿主按 conversation 缓存(同轮 SHORTLIST 模型面与 MEASURE +// 宿主补发共用同一守卫链同配额面——旧 ctx.discoverHost 单例律的 v2 等价);②把宿主 +// 工具适配成 pi-durable ToolRegistration(defineTool),挂 "ops-discover-tools" 扩展 +// 进 registry(registry.mjs;仅发现轮会话选装,root/健康轮面不选——见 tasks/discover-round.mjs)。 +// +// replay 声明逐工具裁(t1-notes 风险13;缺省 unsafe 是安全侧,逐个升格): +// web_read = "safe" 纯读 GET(r.jina.ai 无鉴权无计费,实测 2026-10-24 裸调用可用); +// 崩溃重放至多补发一次,守卫链在重放路径同样执行(每轮帽+dup 拦截兜); +// 重放读数=新鲜证据,无副作用面。 +// web_search= "unsafe" 重查询烧 provider 配额(duckduckgo/jina 回退链限速;ADR 0010: +// 配额是发现侧第一现实约束),且结果非确定——崩溃交 interrupted +// 结构化错误信封(铁律②),宁少算不重烧。 +// rss_parse = "safe" 纯读 GET 原始 feed + 本地零依赖解析,无副作用,同 web_read 律。 +// (fetch_url/gh_search 属旧 legacy discover 聊天面通道,不在发现轮管线工具闭集 +// (discovery-host.mjs DISCOVERY_TOOLS;gh_search 留 legacy 面=spec T3 明示),T3 不装; +// 若后续票接它们进 v2,同一 mkBreaker seam 照用,写侧律不变。) +// +// registry 红线(票 #225 判例):不装内建 CodingTools——本扩展只含上述三只自有领域工具, +// 全只读(annotations.readOnlyHint),模型无 bash/write/edit 面。 +import { defineTool } from "@earendil-works/pi-durable"; +import { Type } from "@earendil-works/pi-ai"; +import fs from "node:fs"; +import path from "node:path"; +import { + DISCOVERY_TOOLS, + ensureDiscoverHost, + fixtureDiscoveryMethods, +} from "../../agent/pipeline/discovery-host.mjs"; // 零复制真源(实现+守卫链+熔断写侧+种子) +import { parseRss } from "../../agent/tools/discovery.mjs"; // 形状钉子:parameters schema 与旧面同构(见下) + +// replay 声明单源(报告清单与 tests/discover-round.test.mjs 断言同源引用本表) +export const DISCOVERY_REPLAY = Object.freeze({ web_read: "safe", web_search: "unsafe", rss_parse: "safe" }); + +// 轮级宿主缓存:key=conversationId(=发现轮自有会话,一轮一会话——月轮↔conversation +// 粒度决策见 tasks/discover-round.mjs 头注)。进程内缓存;崩溃 resume 后宿主缺席=常态 +// (旧管线同律:dupRegistry/已用配额从零计,宁少算;CLOSE 读数走工件回放)。 +export const roundHosts = new Map(); +// 轮级 dup 登记表缓存(同键):旧 ctx.dupRegistry 轮内单例律的 v2 等价——相位问 ctx 每 +// 次重建,登记表必须跨相位同对象(CLOSE dup_top3 读数才不空;旧 mkCtx 同律)。 +export const roundDupRegistries = new Map(); + +// 取/建轮级发现宿主:首轮(SHORTLIST 相位)创建并写回 ctx.discoverHost——旧 +// ensureDiscoverHost 见 ctx.discoverHost 在场即原样返回,MEASURE(runStage 驱动旧步) +// 因此与模型面共用同一实例/守卫链/配额。faux 面=罐装方法(measure.json web_reads 表, +// 零网络律);真面=discoveryMethods 真网络。breaker 写侧:mkDiscoveryHost 适配器 finish +// 内 breaker.record(ops/breaker.json,breakerFileOf(ctx) 同文件)——零复制随迁。 +export function ensureRoundHost(ctx, conversationId) { + ctx.dupRegistry = roundDupRegistries.get(conversationId) ?? (roundDupRegistries.set(conversationId, new Map()).get(conversationId)); // 先立同轮登记表(host 守卫链捕获同对象) + if (roundHosts.has(conversationId)) { + ctx.discoverHost = roundHosts.get(conversationId); + return ctx.discoverHost; + } + const fauxFace = ctx.modelFace === "faux" && typeof ctx.fixturesDir === "string"; + const host = ensureDiscoverHost(ctx, fauxFace + ? { methods: fixtureDiscoveryMethods(readFixtureMeasure(ctx.fixturesDir)) } + : {}); + roundHosts.set(conversationId, host); + return host; +} + +function readFixtureMeasure(fixturesDir) { // faux 罐装读数(缺件=空表→全 io_error 未罐装,同旧 MEASURE 缝) + try { return JSON.parse(fs.readFileSync(path.join(fixturesDir, "measure.json"), "utf8")); } catch { return {}; } +} + +// 宿主工具直调核(defineTool execute 与测试共用;callId 上翻=宿主 content 前缀同旧律: +// 模型看不到运行时 id,evidence 回引靠 [call_id=…] 前缀)。守卫链/登记/熔断写侧全在 +// 宿主 wrap 内(零复制),本核不做任何拦截。 +export async function executeDiscoveryTool(host, name, callId, args) { + const tool = host.tools.find((t) => t.name === name); + if (!tool) throw new Error(`未知工具 ${name}(发现宿主闭集:${DISCOVERY_TOOLS.join("/")})`); + const r = await tool.execute(callId, args); + return r; // {content:[{type:"text",text}], structuredContent}——旧信封形状原样 +} + +// 三只 defineTool(挂 registry "ops-discover-tools" 扩展;仅发现轮会话选装)。 +// parameters/annotations/description 元数据与旧宿主工具同构(闭集三只,形状漂移=对拍红)。 +export function discoveryTools() { + const proto = DISCOVERY_TOOLS.map((n) => ({ name: n })); // 顺序=DISCOVERY_TOOLS 闭集序 + return proto.map(({ name }) => defineTool({ + name, + label: `ops 发现轮 ${name}`, + description: `发现轮只读通道工具 ${name}(实现零复制自旧管线 ops/agent/tools/discovery.mjs;同参重复拦截/通道熔断/每轮上限守卫链;失败=结构化信封 {ok:false,code,reason},非 isError——信封即契约读数)`, + annotations: { readOnlyHint: true, idempotentHint: name !== "web_search", openWorldHint: true }, + parameters: Type.Object({ url: Type.Optional(Type.String({ pattern: "^https?://", maxLength: 500 })), query: Type.Optional(Type.String({ maxLength: 500 })) }), + replay: DISCOVERY_REPLAY[name], // 逐工具声明(头注裁语);缺省本就是 unsafe,显式写出=报告与断言可对拍 + execute: async (args, api, context) => { + const host = roundHosts.get(api.conversationId); + // stderr 进度行(票 #142 语法,T5 冻结;ENVELOPE.md 契约面):[tool] 参数摘要 + [tool-result] 完成行 + const argSum = [args.url, args.query].filter(Boolean).join(" ").slice(0, 120); + process.stderr.write(`[tool] ${name}(${api.callId}) ${argSum}\n`); + if (!host) { + process.stderr.write(`[tool-result] ${name}(${api.callId}) fail code=channel_open\n`); + return { output: [{ type: "text", text: `[call_id=${api.callId}] 拒绝信封(code=channel_open):发现宿主未就绪(会话 ${api.conversationId} 无轮级宿主——SHORTLIST 相位先建宿主再提交)` }], structuredContent: { ok: false, code: "channel_open", reason: "发现宿主未就绪" } }; + } + const r = await executeDiscoveryTool(host, name, api.callId, args); + const sc = r.structuredContent ?? {}; + const okSum = name === "web_search" ? `total=${sc.total ?? "?"}` : name === "rss_parse" ? `entries=${sc.entries_n ?? sc.n ?? "?"}` : `bytes=${sc.bytes ?? "?"}`; + process.stderr.write(sc.ok === false + ? `[tool-result] ${name}(${api.callId}) fail code=${sc.code}\n` + : `[tool-result] ${name}(${api.callId}) ok ${okSum}\n`); + return { output: r.content, structuredOutput: r.structuredContent }; + }, + })); +} + +// 形状哨(测试消费):rss_parse 依赖的解析核与旧面同引用(防 v2 侧私 fork 漂移)。 +export { parseRss }; diff --git a/ops/agent-v2/tools/probes.mjs b/ops/agent-v2/tools/probes.mjs new file mode 100644 index 00000000..240b9197 --- /dev/null +++ b/ops/agent-v2/tools/probes.mjs @@ -0,0 +1,58 @@ +// tools/probes.mjs — 微探针三件套 v2 工具面(票 #228;spec 定案 2:工具随迁 defineTool 化, +// 实现零复制经旧 mkMicroprobeHost,不做第二实现)。 +// 形状:registry 装的是三只**静态** ToolRegistration(defineTool;模型可见的参数/输出 +// schema 面);执行期按 api.conversationId 派发到「绑定的宿主」——DEEP 每假设一个宿主 +// (旧口径:逐假设新上下文+每假设 ≤3 预算+轮次级 dup registry),由 tasks/model-round.mjs +// 在 submit 前绑定(进程内 Map;崩溃重启重绑=计数清零,宁少算口径同旧 resume)。 +// 未绑定会话调用 → isError 拒绝信封(健康轮 TRIAGE/SYNTH 无探针面,旧 tools=[] 同义)。 +// replay 声明(逐工具裁,票面清单入报告):三只全 = "unsafe"(真网络探测,重放重发= +// 出网动作;structuredContent 是读数快照不是可重放输入)。纯读类工具(T3 判例库检索等) +// 才标 safe——本文件无 safe 面。 +// registry 红线(票 #225/#228):不装内建 CodingTools——本文件只进这三只,tests/scaffold 钉死。 +import { defineTool } from "@earendil-works/pi-durable"; +import { mkMicroprobeHost } from "../../agent/pipeline/microprobes.mjs"; // 零复制:工具实现/预算闸/dup 闸/登记行全在旧宿主 +import { safeFetch } from "../../agent/tools.mjs"; // 缺省传输层(真网络;与旧 microprobes.mjs 同源同拼) + +// 模板宿主:只为取三只工具的静态元数据(name/label/annotations/parameters/outputSchema), +// fetchImpl 永不触发(budget:0 = 任何执行都会被预算闸拒,双保险)。 +const TEMPLATE = mkMicroprobeHost({ budget: 0, dupRegistry: new Map(), fetchImpl: async () => { throw new Error("模板宿主不可执行"); } }); + +// 每会话绑定面:convId → host(旧 faux.mjs runRealStep 的 probes 单例位;v2 经 +// bindProbeHost 由模型轮任务在 submit 前绑定)。seeds 读面:host.seeds(chronological +// {call_id,tool,sc})——旧 transcript registry_seed 行同源,不经 entry 解析(结构化输出 +// 不入 transcript,种子必从宿主内存取,t1-notes 结论)。 +const BOUND = new Map(); +export function bindProbeHost(convId, opts = {}) { + const host = mkMicroprobeHost({ + fetchImpl: opts.fetchImpl ?? safeFetch, + budget: opts.budget ?? 3, + dupRegistry: opts.dupRegistry ?? new Map(), + dupLimit: opts.dupLimit ?? 3, + }); + BOUND.set(convId, host); + return host; +} +export function boundProbeHost(convId) { return BOUND.get(convId) ?? null; } +export function unbindProbeHost(convId) { BOUND.delete(convId); } + +// 静态壳 → 绑定宿主派发。旧 execute(callId, p) → {content, structuredContent}; +// v2 ToolRegistration execute(args, api) → {output, structuredOutput, isError?}。 +const wrap = (templateTool) => defineTool({ + name: templateTool.name, + label: templateTool.label, + description: templateTool.description, + annotations: templateTool.annotations, + parameters: templateTool.parameters, + replay: "unsafe", // 显式声明(缺省即 unsafe;这里钉字面=评审面可见) + structuredOutputSchema: templateTool.outputSchema, + async execute(args, api) { + const host = boundProbeHost(api.conversationId); + const tool = host?.tools.find((t) => t.name === templateTool.name); + if (!tool) + return { isError: true, output: `[call_id=${api.callId}] 拒绝信封:本会话未绑定微探针宿主(非 DEEP 假设面)。`, structuredOutput: { ok: false, code: "channel_missing", reason: "本会话未绑定微探针宿主(非 DEEP 假设面)" } }; + const r = await tool.execute(api.callId, args); // 预算闸/dup 闸/登记行全在旧宿主内(零复制) + return { output: r.content, structuredOutput: r.structuredContent }; // 失败信封也是合法返回(ok:false 结构化面),不算 isError + }, +}); + +export const OPS_PROBE_TOOLS = TEMPLATE.tools.map(wrap); diff --git a/reports/t229-report.md b/reports/t229-report.md new file mode 100644 index 00000000..86e863c4 --- /dev/null +++ b/reports/t229-report.md @@ -0,0 +1,35 @@ +# T5 报告 — cron 入口冻结 + 台账兼容 + shadow 对拍 harness + RUNBOOK(票 #229) + +分支 `ticket/229-shadow`(worktree `.worktrees/t229`),四 commit:`f70fc03e`① `cc10892d`② `0c70715a`③ `2ead9bbf`④。全量 `node --test 'tests/*.test.mjs'` **38/38 绿**(新增 cron-launchers 5 测 + shadow-parity 1 测)。 + +执行背景:T5 首派 worker 30min 内死于 bigmodel 账户级 429(5h 窗上限,21:01 重置),worktree 零损失;父会话直收完成(用户明示「继续任务」)。 + +## ① cron 入口 + CLI 冻结(`run-health.mjs` 新 + `run-discover.mjs` 改 + `tasks/health-round.mjs` toPhase + `tools/discovery-tools.mjs` stderr) + +- `run-health.mjs`:健康轮启动器。admitRound 准入(拒跑→`{ok:false,round:"health",error:{code:"budget_monthly"}}` exit 1)→ openHarness → root 会话 commit 建 `ops-health-round` 任务(input 含 fromPhase/toPhase/modelFace/fixturesDir/monthlyAdmission)→ waitForTask → stdout 末行=旧形信封;幂等:同库同轮复用任务(HealthRoundsDoc session 单例索引)。 +- exit 码表冻结:0=completed / 1=failed·refused·parked / 2=用法 / 3=运行期异常。 +- `toPhase`(旧 replay `--to` 镜像):任务相位机停笔书挡 `stopAtPhase`——停在 toPhase 完成后(含门与台账),终态 completed + 信封 stages 止于该相位;CLI 层(from/to 顺序)与任务 initial(闭集)双校验。载具必需:shadow 对拍两侧同停 RETRO。 +- `discovery-tools.mjs` execute 补 `[tool]`/`[tool-result]` stderr 留痕行(票 #142 语法)。 +- discover 侧测试断言迁到冻结信封面(stdout 末行=旧形信封,completed ⟺ ok:true)。 + +## ② 台账兼容 + usage 口径(ENVELOPE.md) + +- 信封/台账 usage=**轮累计事件流口径**(harness.usage() 差分);UsageDoc 仅 harness 内部观察。 +- 台账兼容行由 RETRO 步写,与旧管线同点同形;月度准入与前端解析零改动(cron-launchers + parity 双测试钉)。 +- 已知计量边界差:v2 `in` 含 cache_read 且 `total` 再计缓存面 → 对拍中 in/out/total 归一、**cost 细胞逐字比**(faux 恒 0;真实轮 cost 独立求和不失真)。 + +## ③ shadow 对拍 harness(`shadow-diff.mjs` + `tests/shadow-health-parity.test.mjs`) + +- 双面同种子罐装世界(判例+R1 规则+01/02 前置工件+永败 eval stub,seedWorld 导出复用):OLD=`pipeline.mjs replay --from TRIAGE`(旧契约缺省停 RETRO);NEW=`run-health.mjs --from-phase TRIAGE --to-phase RETRO`。 +- **实测 15/15 SAME**:03-triage(假设+transcript)/04-dispatch/05-deep×5/06-itemize/07-synth×2/08-anchor×2/gates.json/ledger.md,退出 0。 +- 归一化窄面:ISO 时间戳/dur_ms/tmp 路径/replay `-rN` 后缀/台账 usage 三计数;语义字段逐字比(收口 T3「归一化宽缝」遗留)。 +- 载具判例两条:全链载具不可行(旧 PROBE 天生联网,golden 亦断点进;shadow 恒零网络红线)→ 只能断点进;轮 id 形状律 `YYYY-MM-DD-HHMM-health` 硬约束(bad_round)。 + +## ④ SHADOW-RUNBOOK.md + +观察面(每轮 shadow-diff + 全量套)/SAME 门槛(一月轮周期零 DIFF 零 infra+SIGKILL 演练)/cron 双轨(shadow 期 v2 独库不写生产台账)/已知差异面五条(声明非缺陷)/回退(停 cron 即可,库留审计)/切流四步(**用户裁决后**才动)。 + +## 风险与移交 + +- PROBE/VERIFY 不入罐装对拍(真探测面):两侧均零复制 `steps.runStage` 同码路,生产 v2 真跑;若要罐装面需旧侧先提供 probe fixture 机制(现状无)。 +- T0 遗留 #1-#6(月轮↔conversation 粒度/UsageDoc 口径已裁/逐工具 replay 裁定/SQLite NORMAL 断电/node≥22.19/attribution 头)不随本票收口,归切流前清单。