27-goal-contract-format.md 36.6 KB

27-T0. 验收契约格式 —— acceptance.json 与执行器

所属: 27-P3/P4 §五 阶段 0(T0),需求源头 27-REQ R-15.6 实现: server/services/goal-contract.js 配套: 27-E2E §十一(三个验收产出节点)、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 §八.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 §四.A.5 已定的产物布局,本文档补上 verdicts/ 这一格;契约本体按 27-P4 §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 §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": "<artifacts/ 下的相对路径>"}。 一个机制覆盖三段,不为 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 §六 验收 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 改写。


三、执行器接口(稳定面)

const gc = require('@server/services/goal-contract');

// 路径
gc.resolveRunDir(companyId, rootRunId)   // → data/companies/{cid}/runs/{rootRunId}
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<verdict>                 ← 纯执行,不落盘
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:

# 产出侧(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 <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 <V>

两个刻意的设计:

  • --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

{
  "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(演进面)

{
  "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 —— 打分式验收

{
  "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 §11.4 的遗留缺口),执行器只负责把分数按契约算成判决。分从哪来是节点的事,怎么判是执行器的事——和 §四.D.2.1「数到 3 就停是代码的活,判断修好没有是 LLM 的活」同一条分工原则。

4.2 test_data —— 样本集比对

{
  "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 —— 真实子进程执行

{
  "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)没有调用者。建这三个模板时,产出动作就一行:
   node server/services/goal-contract.js put --run-dir $R --section <benchmark|test_data|test_script> \
        (--file <本地写好的 json> | --ref <artifacts 下的相对路径>) --goal-id <task-goal> --created-by <本体名>

不要让它们自己去写 acceptance.json——三个节点各写各的整份文件,一定互相覆盖(§三「产出侧为什么必须是 writeContractSection」)。

  1. 写 artifacts/ 是特权动作(§四.A.5)——只有被规划标记为验收契约产出者的节点能写。本文件不做权限判定(put 谁调都能写),权限模型属引擎侧(T2)。
  2. 提示词里缺 run_id:graph-engine.describeArtifactContract 给 agent 的那段里有产物目录和共享区路径,没有本次的 run_id。CLI 能从 --target 推出 runDir / node_run_id / attempt,四元组只差这一维。补一行 本次 run_id=... 即可,属引擎侧改动。
  3. qa-judge 的程序化入口仍缺(27-E2E §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 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 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(...) 表达式,提取时会红,这正是提醒改它的信号)。

⚠️ 提取前不要给这两个函数加任何行为(比如按公司别名映射、按环境分目录):加在一份里、另一份没加,就是「产生 → 传递 → 落库 → 读回」在「传递」这一环断掉 —— 索引里的路径拼出来指不到文件,而且不报错,下游只会看到提示词里一条指向空气的路径。