# 27-T0. 验收契约格式 —— `acceptance.json` 与执行器 > **所属**: [27-P3/P4](./27-p3-p4-work-graph-console.md) §五 **阶段 0**(T0),需求源头 [27-REQ](./27-requirement.md) R-15.6 > **实现**: `server/services/goal-contract.js` > **配套**: [27-E2E](./27-e2e-task-execution-flow.md) §十一(三个验收产出节点)、`docs/prd/newfunction/_spec/sdd-model.md` M-6/M-7/M-8(形态来源) > **日期**: 2026-08-16(2026-08-17 R19-B:契约落点归并到 `tasks/{taskId}/` 并进产物索引,见 §一 / §八) --- ## 〇、这份文档定死什么、放开什么 [27-P3/P4](./27-p3-p4-work-graph-console.md) §八.9 给的边界很明确——阶段 0 的契约格式一旦定错,阶段 A 的 schema 要返工,所以 > 先把**存储位置**和**与 `output_ref` / `artifacts/` 的关联方式**定死,格式内部细节允许后续演进。 本文档照这个优先级组织:**§一~§三 是稳定面**(改动即破坏兼容),**§四 是演进面**(加字段、加断言种类都不影响引擎)。 | | 稳定面(定死) | 演进面(可改) | |---|---|---| | 契约放哪 | `tasks/{taskId}/acceptance.json`,一个 task 一份(**R19-B 归并**,原为 `runs/{rootRunId}/artifacts/acceptance.json`,旧路径只读兜底,见 §八) | — | | 谁引用它 | validator **节点**(agent)。两条通路:CLI 从 `--run-dir` / `--target` 推出落点;引擎从 `graph_files`(`kind='acceptance'`)查出来直接拼进提示词 | — | | 怎么被执行 | agent 调 CLI → `runAcceptance({runDir, targetPath, ...})`,被测产物由 `output_ref` 给出(§二bis) | — | | 谁写它 | 三个产出节点各调一次 `put --section`,合并进同一份(§三) | — | | 判决存哪 | `artifacts/verdicts/{verdictId}.json` + `index.ndjson` | — | | 判决的追溯四元组 | `run_id` / `node_run_id` / `attempt` / `contract_hash` | — | | 三个 section 各自怎么断言 | — | ✅ 随便演进 | | `fail_reason` 取值域 | — | ✅ 引擎不硬编码(§四.D.2.2) | --- ## 一、存储位置(稳定面) 沿用 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.A.5 已定的产物布局,本文档补上 `verdicts/` 这一格;契约本体按 [27-P4](./27-p4-parallel-and-artifacts.md) §4.6 的目录约定归并到 `tasks/{taskId}/`(**R19-B**): ``` data/companies/{cid}/ ├── tasks/{taskId}/ │ └── acceptance.json # ← 唯一契约入口(本文档的主角)【R19-B 后的落点】 └── runs/{rootRunId}/ ├── artifacts/ # run 级共享产物区,跨 org node 只读 │ ├── task-goal.json # ← {companyId, taskId}:契约落点就是从这里推的 │ ├── goal-benchmark.json # ← 大块内容外置,契约里 {"$ref": "goal-benchmark.json"} │ ├── goal-test-data/ │ ├── goal-test-script/ │ ├── acceptance.json # ← 【存量】R19-B 之前的落点,只读兜底(§八) │ └── verdicts/ │ ├── {verdictId}.json # ← 一次执行一份完整判决 │ └── index.ndjson # ← append-only 索引,可查、可追溯 └── nodes/{nodeRunId}/ └── attempt-{n}.json # ← 被测产物,即 output_ref 指向的东西 ``` **为什么契约搬到 task 级、判决留在 run 级**(R19-B 的核心取舍): - 契约是**任务级**事实。同一个 task 会跑好几个 root run(重试 / 续跑),契约必须跨 run 稳定;更关键的是它要能被 `graph_files`(`kind='acceptance'`,落在 `graph-engine.TASK_SHARED_KINDS` 里)按 `taskId` 索引到 —— 那是 R-9.3「验收产物由 planner 生成、由 validator 执行」**隔着一个 coder**(两者之间没有边)也能拿到契约的唯一通路。留在 `runs/{rootRunId}/artifacts/` 里,第二个 run 一来就找不着,索引也无处挂 `taskId`。 - 判决是**运行级**事实:一份判决回答的是「**这次 run 的这个节点的第几次尝试**达标没有」。搬到 task 级只会让不同 run 的 `index.ndjson` 挤成一条流,而 `listVerdicts({runId})` 的过滤本来就是按 run 分的。所以 `verdicts/` **不跟着搬**。 四条不变量: 1. **一个 task 一份 `acceptance.json`**。三个产出节点(`goal-benchmark` / `goal-test-data` / `goal-test-script`)各写自己那一段,互不覆盖。这是 [27-E2E](./27-e2e-task-execution-flow.md) §11.2 的定案:三者产出形态不同,但下游 validator 必须能统一消费,**否则每加一种验收节点就要改一次 validator**。 2. **契约落盘 = 进索引,同一次调用里成对发生**。`writeContract()` 写完文件立刻 `recordFile({kind:'acceptance'})`;同路径重复产出走 `graph_files` 的版本链(旧行 `superseded_by` 指向新行,`superseded_by IS NULL` 的永远只有一行)。§4.6 担心的「索引与实际文件漂移」在这个位置结构上不成立 —— 不是靠 agent 自觉,也不是引擎事后扫目录(引擎那一遍 `indexNodeOutputs` 只扫节点私有目录,扫不到 task 级的契约)。登记失败**不抛**(文件已经落盘了),但 `put` 会把「未登记 + 原因」打出来:不打就是又一处「落库这一环静默空掉」。 3. **契约里的相对路径不许逃出契约根**。`resolveInsideArtifacts()` 强制这条——一份契约不能指向 `../../../etc/passwd`,也不能指向别的节点的 `nodes/`。这是信任边界,不是洁癖:契约由 LLM 产出,`test_script.path` 会被真的拿去起进程。R19-B 后**根有两个**(契约目录 `tasks/{taskId}/` + 旧的 `{runDir}/artifacts/`,前者优先、取第一个真实存在的),因为模板仍教 agent 把 `goal-benchmark.json` / `goal-test-script/*.js` 写进 `$R/artifacts/`。多一个根不等于多一条逃逸路子:逃出**任一**根的候选直接被丢掉,且 `validateContract` 在更早一层就拒绝了 `..` 与绝对路径。 4. **`verdicts/index.ndjson` append-only,永不改已写行**(`CONVENTIONS.md` §12 的 ndjson 不变量)。 ### 为什么允许 `$ref` §四.A.5 的布局写着 `goal-benchmark.json` / `goal-test-data/` / `goal-test-script/` 三个独立文件位,§11.2 又要求三段合成一份 `acceptance.json`。两者都成立——一份 5000 行的测试数据集塞进 `acceptance.json` 会让契约本身不可读。 **统一解法:任何一段都可以写成 `{"$ref": ""}`。** 一个机制覆盖三段,不为 benchmark 和 test_data 各发明一套外置规则。 ⚠️ **`contract_hash` 一律按解引用后的形态计算**。否则「内联写」和「$ref 外置写」同一份验收标准会算出两个 hash,判决就追溯不回契约版本了。`writeContract()` 和 `readContract()` 共用同一个 `resolveRefs()`,就是为了堵这条缝(自查时先踩到过:写入返回的 hash 与执行时算出的 hash 不一致)。 --- ## 二、与 `output_ref` / `artifacts/` 的关联方式(稳定面) ``` graph_run_nodes.output_ref ──解析──▶ targetPath(被测产物) │ tasks/{taskId}/acceptance.json ─readContract┼──▶ executeContract ──▶ verdict ▲(graph_files: kind=acceptance) │ │ └── describeInputs 拼进 validator 提示词 └────────────────────────────┘ verdict.run_id / node_run_id / attempt / contract_hash 落 runs/{rootRunId}/artifacts/verdicts/ ``` - **被测产物**:执行器的入参 `targetPath` **就是**某个 `graph_run_nodes.output_ref` 解析出来的绝对路径。执行器不猜、不扫目录、不自己去找产物。 - **契约**:从 `--run-dir` 读 `artifacts/task-goal.json` 拿到 `{companyId, taskId}`,再拼 `{companyRoot}/tasks/{taskId}/acceptance.json`(公司根**由代码推导、不入库**,R-19.3;`GRAPH_RUNS_DIR` 可覆盖)。子 run、孙 run 的 validator 节点读的是**同一份**契约——这正是不把验收契约放在节点私有区的原因(产出者 `org-planner` 和消费者 `org-validator` 是两个不同的 org node)。 另一条等价通路是引擎侧的 `describeInputs`:按 `TASK_SHARED_KINDS` 查 `graph_files`,把绝对路径直接拼进 validator 的提示词,不给 agent 猜路径的机会。两条通路必须算出同一个字符串,所以 `goal-contract.companyRootDir` / `taskDirRel` 与 `graph-engine` 里的同名函数是**逐字镜像**(见 §八)。 - **判决**:落在 run 级共享区(`runs/{rootRunId}/artifacts/verdicts/`),不落在节点私有目录 —— 一次 run 里可能对多个节点的产物做验收,判决是 run 级资产。R19-B 把契约搬到 task 级后,判决**没有**跟着搬(理由见 §一)。 - **相对路径**:`verdict.target` 落在 run 目录内时存相对路径,整个 run 目录可整体归档搬走后仍可读。 **双向可追溯**:给一个 `node_run_id` 能查出它历次验收判决(`listVerdicts(runDir, {nodeRunId})`);给一份判决能查出它用的是哪版契约(`contract_hash`)、判的是哪次尝试(`run_id` + `attempt`)。 > ⚠️ `attempt` 是 **0 基**(`graph-engine` 的 `picked.attempt` 从 0 起)。`executeContract` 与 `persistVerdict` 里都不能写 `opts.attempt || null`——第一次尝试的 `0` 会被 `||` 判成假值吞掉,四元组当场少一环。R5 修掉了这两处,`goal-contract.test.js` 里有一条用例专盯 `attempt: 0` 能原样读回。 --- ## 二bis、接进生产路径:为什么是 agent 工具层,不是引擎钩子(R5-F3) T0 交付后有四轮时间里,全仓库唯一提到 `goal-contract.js` 的是 `graph-engine.js:331` 的一句注释——实现完整、零调用方、零单测,[27-P3/P4](./27-p3-p4-work-graph-console.md) §六 验收 1 从未被执行过。接线时有两个位置可选,选了后者: | | ① 引擎在节点收尾时自动跑 | ② **validator agent 通过工具(CLI)调用** ← 选这个 | |---|---|---| | 谁触发 | `graph-engine.defaultAgentExecutor` 在 agent 返回后 | validator 节点自己,在写判决 JSON **之前** | | 改哪些文件 | `graph-engine.js` | `goal-contract.js` 加 CLI + 三个 validator 模板 | **四条理由,任意一条单独成立都足以否掉 ①:** 1. **引擎手上没有「被测产物」的指针。** 执行器的 `targetPath` 是**上游交付节点**的 `output_ref`;而引擎在节点收尾时能拿到的只有**这个节点自己**的产物目录(`readNodeProduct` 里 `outputRef = dir` 就是自己的私有目录)。validator 自己的产出是判决 JSON,不是被测产物。要引擎自动跑,它必须先猜「这张图里哪个节点的产物才是交付物」——图上没有这个信息(`readsArtifacts` 声明的是共享区读权限,不是被测目标)。 2. **时序反了。** §四.D.0 的三值透传是引擎**从 validator 写下的 `attempt-{n}.json` 里读**走的。契约判决必须在 agent 写那份 JSON 之前拿到,才能进 `fail_reason` / `score` / `verdict_ref`。引擎在 agent 返回之后才跑,判决赶不上这班车,只能另开一条与三值透传并行的通路——两条判决来源打架是必然的。 3. **引擎不认识节点语义,认了就得硬编码。** 判定「哪些节点该跑契约」需要按节点名匹配(`org-validator` / `*-validator` / `benchmark-validator` …),而节点名由 `workgraph-planning` 自由生成(R-15.2 通用本体)。这与 §四.D.2.2「取值域不在引擎里硬编码」是同一条边界:**引擎只搬运判词,不生产判词**。 4. **§四.D.3 本来就把它画成图上的节点。** work 层 `{orgNode}-validator` 是 work graph 里一个普通 `agent_call` 节点;org 层 `org-validator` 是 subgraph 节点,它自己的 work graph 里 `benchmark-validator` / `testdata-validator` 是独立节点。「跑契约」这件事在设计里就是**某个节点在干的活**,引擎再插一个隐藏钩子等于同一件事做两遍。 **代价与对策**:② 依赖 agent 真的去敲那行命令。对策是把命令写进三个模板的强制工作流(不是「可以用」而是「跑契约的唯一方式」),并让判决 JSON 带 `verdict_id` / `verdict_ref`——**没有这两个值 = 没跑执行器 = 判决没有机器证据**,人和控制台都看得见。 **引擎侧需要配合的只有一件(可选)**:`describeArtifactContract` 拼给 agent 的提示词里目前没有本次的 `run_id`。CLI 能从 `--target` 路径推出 `runDir` / `node_run_id` / `attempt`,四元组里只差 `run_id` 要 agent 自己填。提示词里补一行 `本次 run_id=...` 即可补齐,不补也只是这一维为 null,不影响判决。 **踩到的第 10 处「产生 → 传递 → 落库 → 读回」断链**(同一模式已出现 9 次):`org-validator/tools.md` 原本写着用 `npx vitest run <脚本>` 跑 `goal-test-script`,而执行器的 `runner` 白名单只有 `node`(§4.3 的实测结论就是 vitest 跑不了)。**模板教的命令和执行器支持的 runner 直接冲突**,且手拼命令跑出来的结果不进 `verdicts/`、不可追溯——等于没跑。三个模板已按 CLI 改写。 --- ## 三、执行器接口(稳定面) ```js const gc = require('@server/services/goal-contract'); // 路径 gc.resolveRunDir(companyId, rootRunId) // → data/companies/{cid}/runs/{rootRunId} // GRAPH_RUNS_DIR 覆盖时 → {GRAPH_RUNS_DIR}/{rootRunId}(扁平,rootRunId 全局唯一) // 形状与 graph-engine.runRootDir 逐字一致 gc.resolveArtifactsDir(runDir) // → {runDir}/artifacts gc.resolveContractPath(runDir) // → {runDir}/artifacts/acceptance.json gc.resolveVerdictsDir(runDir) // → {runDir}/artifacts/verdicts // 契约 gc.validateContract(contract) // → {ok, errors: [{rule, detail}]} ← 只校验契约本身 gc.readContract(runDir) // → {contract, path} | null ← 含 $ref 解引用 gc.writeContract(runDir, contract) // → {path, contract_hash} ← 整份替换,写前必校验,tmp+rename gc.writeContractSection(runDir, {section, content, goalId, createdBy}) // → {path, contract_hash, sections} ← **产出侧走这个**,见下 gc.contractHash(resolvedContract) // → 'sha256:...'(复用 doc-store.hashContract) // 执行 gc.executeContract(contract, targetPath, {runDir, runId, nodeRunId, attempt}) // → Promise ← 纯执行,不落盘 gc.runAcceptance({runDir, targetPath, runId, nodeRunId, attempt}) // → Promise<{verdict, path}> ← 读契约→执行→落盘,引擎调这个 // 判决 gc.persistVerdict(runDir, verdict) // → {path, indexPath, entry} gc.listVerdicts(runDir, {runId, nodeRunId, onlyFailed, limit}) // → [indexEntry] gc.readVerdict(runDir, verdictId) // → verdict | null ``` `errors` 形态 `{rule, detail}` 与 `org-graph-service.validateGraph()` 对齐(`rule ∈ schema | section | reference | runner`),两处校验器的调用方可以共用同一套错误渲染。 ### 产出侧为什么必须是 `writeContractSection` 而不是 `writeContract` §一.1 的不变量是「三个产出节点各写自己那一段,**互不覆盖**」,但 `writeContract` 是**整份替换**——三个节点先后调它,后写的会把先写的那两段抹掉,契约到 `org-validator` 手上只剩最后一段。这是「产生 → 落库」之间会被静默吞掉的一环,所以产出侧只能走 `writeContractSection`:它读原文(**不解 `$ref`**,保留别人写的外置形态)→ 塞进自己那一段 → 整份过一遍校验再落盘。 两条额外的保护: - **`goal_id` 串台直接拒绝**。已有契约的 `goal_id` 与传入的不一致 = 调用方拿错了 `runDir`,静默覆盖会让判决追溯到一个根本不是它的 goal 上。 - **`created_at` 保留首写时间**。`contract_hash` 含它,每写一段就刷新一次会让 hash 无谓变动,判决追溯到的「哪版契约」跟着抖。 - `created_by` 因此有两种合法形态:单写者写字符串,三段各写时是 `{section: producer}` 映射(一个字符串存不下三个作者)。审计字段,不参与判定。 ### CLI(agent 侧的生产入口) 契约的产出方和消费方都是 **agent**(见 §二bis),而 agent 侧唯一稳定的执行面是 bash。所以 `goal-contract.js` 自带 CLI: ```bash # 产出侧(goal-benchmark / goal-test-data / goal-test-script 三个节点各调一次) node server/services/goal-contract.js put --run-dir $R --section benchmark \ --ref goal-benchmark.json --goal-id GOAL-x --created-by goal-benchmark node server/services/goal-contract.js put --run-dir $R --section test_data --file <本地写好的 json> # 审契约(planner-validator,只 validate 不 run) node server/services/goal-contract.js validate --run-dir $R # 跑契约(org-validator / coder-validator) node server/services/goal-contract.js run --target <被测产物路径> --run-id [--json] # 查判决 node server/services/goal-contract.js list --run-dir $R [--node-run-id N] [--only-failed] [--limit n] node server/services/goal-contract.js show --run-dir $R --verdict-id ``` 两个刻意的设计: - **`--run-dir` / `--node-run-id` / `--attempt` 可省**,从 `--target` 路径推(向上找到含 `artifacts/acceptance.json` 的那层;`.../nodes/{id}/attempt-{n}.json` 里带着另两个)。少一个参数就少一次 LLM 填错的机会。 - **退出码三分**:`0` = 通过 / 操作成功;`1` = **判决为 fail**(工具是好的);`2` = 跑不出判决(参数错 / 没契约 / 契约非法 = `input_underspecified`)。把「产物没达标」和「工具压根没跑起来」合并成同一个非零码,agent 一定会把后者当成前者报上去。 - **`put --file` 而不是 `put --json '<内联>'`**:所有 agent 模板都有「绝不在 bash 里内联 JSON 字符串」的铁律(多重双引号会让工具调用崩溃并陷入死循环)。 ### 判决 schema ```jsonc { "verdict_id": "vd-20260816-1208-a3b4", // CONVENTIONS §2 的 ID 形态 "goal_id": "GOAL-xxx", "contract_hash": "sha256:bf76e6de3eb406b2", // ← 追溯到哪版契约 "run_id": "run_xxx", // ← 追溯到哪次 run "node_run_id": "rn_xxx", // ← 哪个节点 "attempt": 1, // ← 第几次尝试 "target": "nodes/rn_xxx/attempt-1.json", "ok": false, // 所有非 null section 全 ok 才 true "fail_reason": "acceptance_not_met", // null | acceptance_not_met | infra_flake | input_underspecified "sections": { "benchmark": { "ok": false, "score": 73.4, "dimensions": [...], "failures": [...] } | null, "test_data": { "ok": false, "total": 2, "passed": 0, "failed": 2, "failures": [...] } | null, "test_script": { "ok": false, "exit_code": 1, "timed_out": false, "stdout_excerpt": "...", "failures": [...] } | null }, "failures": [ { "case": "...", "expected": ..., "got": ..., "hint": "..." } ], // 三段扁平汇总 "duration_ms": 812, "created_at": "2026-08-16T04:08:48.387Z" } ``` `failures[]` 的 `{case, expected, got, hint}` 四字段直接抄 DevLoop 的 `runs/result.json`(M-7)——归因节点已经会读这个形状了,不另发明。 ### `fail_reason` 与引擎的关系 判决产出的 `fail_reason` 落进 `graph_run_nodes.fail_reason`,供 §四.D.2.2 选边。取三个值: | 值 | 含义 | 引擎侧动作 | |---|---|---| | `acceptance_not_met` | 真的没达标 | 正常走 fail 边,烧计数 | | `infra_flake` | 脚本超时 / 进程起不来 | 原地重跑,**不烧任何计数**(§四.D.2.1) | | `input_underspecified` | 契约本身不合法 | 退回契约产出者——不是产物的错 | `infra_flake` 这一档是刻意的:把「进程超时」误判成「实现有问题」会白烧一轮回退预算。 --- ## 四、契约 schema(演进面) ```jsonc { "version": "1", "goal_id": "GOAL-xxx", // 必填,一次人类需求 = 一个验收边界(27-NAMING §四) "created_by": "goal-benchmark", // 可选,审计用 "created_at": "2026-08-16T...", "benchmark": { ... } | { "$ref": "goal-benchmark.json" } | null, "test_data": { ... } | { "$ref": "..." } | null, "test_script": { ... } | { "$ref": "..." } | null } ``` **三段各自可为 null**(对应「这次没加这个节点」),validator 只处理非 null 的部分。但**三段全 null 直接判非法**——空契约会「平凡通过」,正是 §四.D.3 要求 `planner-validator` 盯的那类漏洞,在格式层面就堵掉,不指望下游发现。 ### 4.1 `benchmark` —— 打分式验收 ```jsonc { "dimensions": [ { "name": "coverage", "weight": 0.6, "pass_threshold": 80 }, { "name": "readability", "weight": 0.4, "pass_threshold": 70 } ], "overall_pass_threshold": 78 } ``` - 权重之和必须 = 1(容差 1e-6),维度名不许重复。 - **被测产物形态**:`{"scores": {"coverage": 92, "readability": 85}}`,或直接 `{"coverage": 92, ...}`。 - 判定:逐维度 `score >= pass_threshold`,再看加权总分 `>= overall_pass_threshold`。缺某维度的分 = 该维度 fail(不是当 0 分,是明确报 `missing score in target`)。 > **为什么阶段 0 不起 LLM 打分**:§五 明写阶段 0「起 LLM ❌」。打分这件事本身是 agent 的活(`qa-judge` 那条线,[27-E2E](./27-e2e-task-execution-flow.md) §11.4 的遗留缺口),**执行器只负责把分数按契约算成判决**。分从哪来是节点的事,怎么判是执行器的事——和 §四.D.2.1「数到 3 就停是代码的活,判断修好没有是 LLM 的活」同一条分工原则。 ### 4.2 `test_data` —— 样本集比对 ```jsonc { "match": "deep-equal", // 或 "subset"(默认 deep-equal) "cases": [ { "id": "c-normal", "input": {"n": 2}, "expect": {"out": 4} }, { "id": "c-edge", "input": {"n": 0}, "expect": {"out": 0} } ] } ``` - **被测产物形态**:`{"results": {"c-normal": {...}, ...}}`,或 `{"results": [{"id": "...", "output": {...}}]}`。 - `deep-equal` = key 排序后规范序列化比对;`subset` = expect 里出现的键必须匹配,实际输出允许有额外键(数组仍要求等长)。 - case 缺结果 = fail,且 hint 明说 `no result for case X in target`——和「值错了」区分开,归因时有用。 - `input` 字段执行器**不读**,它是给产出被测产物的那个节点看的。契约同时是「考题」和「答案」,执行器只用后半截。 ### 4.3 `test_script` —— 真实子进程执行 ```jsonc { "runner": "node", "path": "goal-test-script/acceptance.js", // 相对 artifacts/,不许逃出去 "args": [], "cwd": ".", // 相对 artifacts/,默认 runDir "timeout_ms": 120000, "pass_on": { "exit_code": 0 } } ``` - **脚本怎么拿到被测产物**:env 注入 `ACCEPTANCE_TARGET`(绝对路径)、`ACCEPTANCE_RUN_DIR`、`ACCEPTANCE_ARTIFACTS_DIR`。不走 argv——argv 位置约定容易和 `args` 打架。 - **判定**:退出码 == `pass_on.exit_code`(默认 0)。超时先 `SIGTERM`、3s 后 `SIGKILL`(沿用 `integrator-tools.js` 的 `killAll` 做法),超时归 `infra_flake`。 - stdout/stderr 各截 64KB,判决里只留末 4KB 摘要——完整输出应由脚本自己写文件。 **`runner` v1 只支持 `node`**。实测结论(不是偷懒,是核实过):仓库根 `vitest.config` 的 `include` 只覆盖 `src/` `server/tests/` `packages/`,验收脚本住在 `data/companies/...` 下,`vitest run <脚本>` / `--root` / `--dir` 三种写法都会被根 config 的 include 盖掉,直接 `No test files found, exiting with code 1`。要支持 vitest 得先为验收脚本生成一份临时 config 再 `--config` 指过去——没人要求的机器。 **上限与升级路径**:眼下 node 脚本里自己 `spawn` 任何测试框架不受限制,所以不急;真需要原生 vitest 时,加临时 config + 把 `'vitest'` 加回 `RUNNERS` 白名单即可,`runner` 字段就是为此留的扩展位。 --- ## 五、设计取舍 | 取舍 | 选了 | 没选 | 理由 | |---|---|---|---| | 契约文件数 | 一份 `acceptance.json` + 可选 `$ref` 外置 | 三份独立文件各自为政 | §11.2:下游 validator 必须统一消费,否则每加一种验收节点就改一次 validator。`$ref` 让「大块内容外置」这个真实需求不必破坏统一入口 | | hash 算在哪一版 | 解引用后 | 原文件字节 | 同一份标准两种写法必须算出同一个 hash,否则判决追溯不回契约 | | benchmark 谁打分 | 被测产物提供分数,执行器只算加权 | 执行器起 LLM 打分 | §五 阶段 0 明写不起 LLM;且「打分」是节点职责、「判定」是执行器职责,分工与 §四.D.2.1 一致 | | test_script 执行方式 | 真子进程 + 退出码 | 沙箱内 `eval` | DevLoop Integrator 起真实进程跑 e2e 是已验证的先例(§11.3);`eval` 拿不到真实进程的超时/信号语义 | | runner 白名单 | 只有 `node` | 任意 shell 命令串 | 契约由 LLM 产出且会被真的执行,命令串 = 任意代码执行。白名单 + 路径必须落在 `artifacts/` 内是这里唯一的信任边界防线 | | 契约放哪(R19-B) | `tasks/{taskId}/`,进 `graph_files` | 留在 `runs/{rootRunId}/artifacts/` | 契约要跨 run 稳定、要能按 `taskId` 被索引到(R-9.3 的非相邻依赖只有这条通路)。详见 §一 | | 旧路径怎么办 | 读时兜底(新优先),首次 `put` 顺手搬到新路径 | 写迁移脚本 / 直接切断 | 存量只有「几个跑过的 run 里的 acceptance.json」这一种形态,读兜底 3 行就够;迁移脚本要跑一遍全量目录,收益不抵风险。删兜底的条件见 §八 | | 判决存哪 | `runs/{rootRunId}/artifacts/verdicts/` | 跟着契约搬到 `tasks/{taskId}/` | 判决是 run 级事实(哪次 run 的哪个节点的第几次尝试);搬过去会让不同 run 的 `index.ndjson` 挤成一条流 | | 索引形态 | ndjson append-only | SQLite 表 | 与 `timeline.ndjson` / `bugs.ndjson` 同款;判决量级是每 run 几十条,不值得开表。**上限**:`listVerdicts` 是全文件扫描,O(n);上万条时再考虑进 `graph.sqlite` | | 空契约 | 判非法 | 判通过 | 平凡通过是 §四.D.3 点名的漏洞 | | 接在哪一层 | validator agent 的工具调用(CLI) | 引擎在节点收尾时自动跑 | 引擎手上没有「被测产物」的指针、时序赶不上三值透传、判定哪些节点该跑要按节点名硬编码——三条任一成立都足以否掉引擎钩子。完整论证见 §二bis | | agent 的执行面 | CLI 子命令 + `--file` 传 JSON | 让 agent 自己写 require 脚本 / 内联 JSON | 模板铁律是「绝不在 bash 里内联 JSON 字符串」;CLI 还能把「跑不出判决」和「判决为 fail」用退出码 2/1 分开 | | 产出侧写法 | `writeContractSection` 合并 | 三个节点各调 `writeContract` | 后者一定互相覆盖,契约到 validator 手上只剩最后一段 | --- ## 六、测试(`server/tests/unit/goal-contract.test.js`,28 passed) `npx vitest run server/tests/unit/goal-contract.test.js`。全部用例在 `os.tmpdir()` 下建 run 目录(R19-B 的那组靠**先设 `GRAPH_RUNS_DIR` / `GRAPH_DB_PATH` 再 require** 把公司根和 `graph.sqlite` 一起重定向到临时目录,手法与 `graph-engine.test.js` 一致),仓库 `data/` 不留残留;`test_script` 起的是**真实 node 子进程**,被测脚本由用例就地生成,不依赖外部环境。 | 组 | 覆盖 | |---|---| | 契约 schema 校验 | 合法(三段齐全,benchmark 走 `$ref`,内联/外置算出同一个 hash);非法逐条被挡:空契约、`runner` 越界、路径逃逸 `../../../etc/passwd`、权重之和 ≠ 1、版本错 / 缺 `goal_id` / 未知字段、`created_by` 形态;`writeContract` 在 `$ref` 指向的文件不存在时就报错且一个字节都不落盘 | | 三段互不覆盖 | `writeContractSection` ×3 后三段都在、`$ref` 没被抄开、`created_by` 是三个作者的映射;`goal_id` 串台被拒且原契约一字未动 | | benchmark | 通过(加权 89.2 ≥ 78 且逐维度过线);不通过(一维没过线 + 一维缺分,两条 `failures` 的 hint 分得开);被测产物根本不存在 → 不通过而不是崩溃/平凡通过 | | test_data | 通过(`subset` 模式忽略额外键,`results` 数组形态);不通过(一个值错 + 一个 case 缺结果,两者 hint 分得开) | | test_script | 通过(脚本读 `ACCEPTANCE_TARGET` → exit 0);不通过(exit 1,stderr 进 hint);**超时 → `infra_flake`**;脚本缺失不起进程;契约本身非法 → `input_underspecified` | | 判决持久化 | `runAcceptance` 读契约 → 执行 → 落盘;`readVerdict` 取回四元组齐全(**含 `attempt: 0`**);`listVerdicts` 的 `onlyFailed` / `nodeRunId` / `limit` 过滤正确;没有契约时明确报错而不是判通过 | | **CLI(生产路径)** | `put` ×3 → `validate` → `run`(pass 退 0 / fail 退 1)→ `list` / `show`,跑的**就是三个 validator 模板 tools.md 里印的那几行命令**;`run-dir` / `node-run-id` / `attempt` 从 `--target` 推出来的值与预期一致;契约非法 / 没契约 / 参数错 / 未知子命令一律退 2;`put` 的输出里带 `graph_files: gf_…` 那一行 | | **R19-B 落点与索引** | 契约落在 `{companyRoot}/tasks/{taskId}/acceptance.json` 且旧落点一个字不写、`verdicts/` 没跟着搬;**四环**(写文件 → `graph_files` 有 `kind=acceptance` 的行 → `rel_path` 相对公司根 → `join(companyRootDir, rel_path)` 拼回去正是那个文件),重复 `put` 后当前版本行恒为 1;存量兜底(旧路径契约读得到、首次 `put` 搬过去且旧段与 `created_at` 都不丢);`$ref` / `test_script.path` 仍认旧 `artifacts/` 且逃逸照旧被拒、两根都没有时报错指向新根;`deriveRunDir` 靠 `task-goal.json` 认路(不带 `--run-dir` 的 `run --target` 仍能跑);推不出身份时退回旧路径**且不假登记**;`companyRootDir` / `taskDirRel` 与 `graph-engine` 同构 | --- ## 七、遗留 / 交给下游 1. ~~**谁调 `runAcceptance`**~~ **已接(R5-F3)**:消费侧是 `org-validator`(`tools.md` §2 + `agent.md` Step 3)和 `coder-validator`(`tools.md` §2b,在 work 层先跑一遍,问题不必烧到 org 层回退预算);审契约的是 `planner-validator`(`agent.md` Step 3 的 B.0,**只 `validate` 不 `run`**)。三处都走 CLI,见 §三「CLI」。论证见 §二bis。 2. **产出侧的三个 agent 模板还不存在**(本轮唯一剩下的断链)。`system/work-graph-templates/planner.json` 把 `goal-benchmark` / `goal-test-data` / `goal-test-script` 列为 `systemAgentId`,但 `system/templates/` 下没有这三个目录、`_registry.json` 里也没有它们——**产出侧现在有接口(`put`)没有调用者**。建这三个模板时,产出动作就一行: ```bash node server/services/goal-contract.js put --run-dir $R --section \ (--file <本地写好的 json> | --ref ) --goal-id --created-by <本体名> ``` **不要让它们自己去写 `acceptance.json`**——三个节点各写各的整份文件,一定互相覆盖(§三「产出侧为什么必须是 `writeContractSection`」)。 3. **写 `artifacts/` 是特权动作**(§四.A.5)——只有被规划标记为验收契约产出者的节点能写。本文件不做权限判定(`put` 谁调都能写),权限模型属引擎侧(T2)。 4. **提示词里缺 `run_id`**:`graph-engine.describeArtifactContract` 给 agent 的那段里有产物目录和共享区路径,没有本次的 `run_id`。CLI 能从 `--target` 推出 `runDir` / `node_run_id` / `attempt`,四元组只差这一维。补一行 `本次 run_id=...` 即可,属引擎侧改动。 3. **`qa-judge` 的程序化入口仍缺**([27-E2E](./27-e2e-task-execution-flow.md) §11.4)。benchmark 的分数目前只能由产出被测产物的节点自报。接上 `qa-judge` 后它的输出协议(`PASS`/`REWORK`/`RETRAIN_REQUIRED`)需映射进 `scores` + `fail_reason`,其中 `RETRAIN_REQUIRED` → `skill_defect` 那一支即 L4,v1 挂起等人工。 4. ~~**一处 R-16 待定案按假设执行**(判决只归到 org node 粒度、不跨层寻址)~~ **已定案,假设作废(第七轮撤注释)**:[27-REQ](./27-requirement.md) **R-17.1** 定案「归因到哪个 work graph node 就退到哪个 node」,跨层地址走 `graph_run_nodes.fail_node_ref`(`{"orgNode":…,"workNode":…}`),org 层回退可以定点重置子图里的单个节点。`goal-contract.js` 的判决**本身**仍不寻址节点——它只回答「这次验收有没有达成」(`acceptance_not_met` / `infra_flake`),因为它手上只有 target 与 sections、没有图的信息;定点由下游 `validator-triager` 依据 `failures` 归因产出。四处 `// 假设(R-16待定案): 归因保持 org node 粒度封装` 注释(`graph-engine.js` / `graph-db.js` / `goal-contract.js` / 本条)**已在第七轮全部撤掉**——它们描述的行为与实现相反。 (原先并列的第二处「归因节点名」已由 [27-REQ](./27-requirement.md) **R-17.2** 定案为 **`validator-triager`**,假设注释已撤,`validator-reflection` 是历史名。) --- ## 八、R19-B:路径归并的存量兜底与镜像 helper(何时可以删) ### 8.1 两处兜底,各自的删除条件 | # | 兜底 | 在哪 | 可以删的条件 | |---|---|---|---| | ① | **契约读取回落**:`locateContract` 找不到 `tasks/{taskId}/acceptance.json` 时读 `runs/{rootRunId}/artifacts/acceptance.json` | `goal-contract.js` `contractCandidates` / `locateContract` | `find data/companies -path '*/runs/*/artifacts/acceptance.json'` 为空(存量 run 都已归档或都已被首次 `put` 搬走),且不再需要复跑旧 run 的验收。删的时候连 `deriveRunDir` 的 `RUN_DIR_MARKERS` 里那个 `CONTRACT_FILE` 一起删 | | ② | **相对路径双根**:`$ref` / `test_script.path` / `test_script.cwd` 在契约目录找不到就去 `{runDir}/artifacts/` 找 | `goal-contract.js` `contractBases` / `resolveInsideArtifacts` | 等 `goal-benchmark` / `testdata-validator` 等模板的 tools.md 改成把大块内容写进 `tasks/{taskId}/`(那是模板侧改动,本轮不动模板)。**在那之前删掉 ② 会当场打断生产链路**:模板现在印的是 `$R/artifacts/goal-benchmark.json` | ① 与 ② 互不依赖,可以分别删。删任一处之前先跑 `npx vitest run server/tests/unit/goal-contract.test.js` —— §六 的「R19-B 落点与索引」那组会直接把它钉住。 > 首次 `put` 的**搬迁**语义(读旧路径、写新路径、旧段与 `created_at` 都保留)本身就是渐进迁移:不需要迁移脚本,跑过一次 `put` 的 task 自动就在新路径上了。旧文件留着不删 —— 它已经读不到了(新路径优先),删它没有收益,而删错了没法回滚。 ### 8.2 `companyRootDir` / `taskDirRel` 是镜像实现(待提取) `graph_files.rel_path` 存的是**相对公司根**的路径(R-19.3:绝对路径入库会烂)。于是两边必须算出同一个公司根: - 写的一侧:`goal-contract.js` 登记契约时 `path.relative(companyRootDir(companyId), abs)` - 读的一侧:`graph-engine.describeInputs` 拼提示词时 `path.join(companyRootDir(companyId), rel_path)` 这两个 `companyRootDir`(含 `GRAPH_RUNS_DIR` 覆盖分支)与 `taskDirRel` 目前是**两份逐字相同的实现**,本轮 `graph-engine.js` 被别的改动占用、不能动它,所以先镜像。 **提取方案(下一轮,20 行以内)**:新建 `server/services/company-paths.js`,只放这两个函数(外加 `RUNS_ROOT` 的读取),`graph-engine.js` 与 `goal-contract.js` 各 `require` 它,删掉两处本地副本。提取后 `goal-contract.test.js` 里那条「与 `graph-engine` 的那两个同构」用例要从**扫源码文本**改成**直接比两个函数的返回值**(它现在断言 `graph-engine.js` 里仍有那三个 `path.join(...)` 表达式,提取时会红,这正是提醒改它的信号)。 ⚠️ 提取前不要给这两个函数加任何行为(比如按公司别名映射、按环境分目录):加在一份里、另一份没加,就是「产生 → 传递 → 落库 → 读回」在「传递」这一环断掉 —— 索引里的路径拼出来指不到文件,而且**不报错**,下游只会看到提示词里一条指向空气的路径。