# 48 · Task/Subtask 验收契约 1:1 强绑定 > **状态**: ✅ 已实现并完成真实端到端回归(decomposed_work 链路,含真实 `org-coder`/`org-validator` CLI 全程跑通,`org-validator` 判词 `fail_reason=null`,不再 `input_underspecified`)。安全网仍只是原则,未写代码。 > **日期**: 2026-09-02(补充真实回归:2026-09-03) > **触发场景**: 46 号文档 Phase 3 v2 真实回归时,`org-validator` 在轻量子任务图(`org-coder` 实现 → `org-validator` 验证,跳过公司完整 org graph 的 `org-planner` 规划仪式)里卡死——`goal-contract.js validate --run-dir` 报 `input_underspecified`(没有 `acceptance.json`),`org-validator` 按它自己的硬指令"退 `org-planner` 重跑",但这张轻量图里**压根没有 `org-planner` 节点**,退无可退。追问下去牵出一个更根本的问题:"验收契约由谁产出"这件事目前完全绑死在"公司完整 org graph 一定会跑到 `org-planner`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。 --- ## 讨论纪要(结构化复述,不是单条原话,是几轮讨论收敛出的结论) 用户最初的观察:*"org validator 因为没 org planner 的产出就卡死了?而不是按照 task 的要求去 validator?"* ——直觉是 validator 应该"以 task 为主",文件只是参考物,不该是硬性评判标准。 讨论过程中的几个关键转折: 1. **查证后发现"卡死"是设计使然,不是疏漏**——`org-validator/tools.md` 第 45 行原文:"跑不出判决:没契约/契约非法/参数错 → `input_underspecified`,退 `org-planner`。**不是「那就跳过这条」**"。这条硬约束背后的理由是这个系统这几个月反复验证过的教训(43/44/47 号文档):让 agent 拿自然语言 task 描述自由判断"算不算做完"不可靠,所以验收必须走结构化契约,不能自由心证。**这条纪律本身不该改**。 2. **真正该改的是"契约从哪来"**——用户提炼:"硬约束的产生应该是自由灵活的,不至于被卡死"。也就是说:**消费端(validator 只认结构化契约、不自由判断)该保持硬约束;产出端(谁来写这份契约)不该被锁死在单一角色(`org-planner`)身上**。 3. **讨论过一个"运行时兜底级联"方案**(用户提出):org-planner 写 → 没有则 subtask-planning 写 → 都没有则 code 节点自己写 → 都没有则 validator 自己补一份再判、有问题就把 bug + 自己写的验收标准一起打回给上游重做。这个逻辑本身站得住,但有两个问题: - 对 `decomposed_work` 这条链路,第 3/4 层永远不会被触发——`decomposed_work` 子任务只有一个产生入口(`materializeDecomposedSubtasks`,只在 `subtask-planning` 成功产出 steps 之后才会被调用),所以"没有 planner、也没有 subtask-planning"这个前提在这条链路上不可能成立。给一条不可达的路径写实现,是在为不存在的场景预先建模。 - 更本质的问题:**同一个 agent 既写验收标准、又拿着自己刚写的标准去判自己/别人刚做完的东西**,跟"先出考卷再考试"比,可信度天然打折——即便格式上仍是结构化契约,也更容易"照着已经做出来的答案凑标准"。运行时兜底能保证流程不会永久卡死,但不能同等程度地保证补出来的东西和事先写好的一样可信。 4. **收敛到的方案**:**创建时强绑定**(预防性,主力机制)+ **运行时兜底**(纠正性,安全网,仅记录原则,本轮不实现)。具体:"每一个 task/subtask 天然带着自己的验收契约,这在创建那一刻就必须有,写在 `tasks` 表的字段上,1:1 强绑定"——用户原话:"这样是不是更简单?"。确认:**是**,比"在某个特定图形状里临时补数据"更彻底,因为约束点提到了 `createTask` 这个所有创建路径的公共入口,不用每条路径各自记得要不要造契约。 --- ## 现状调研(本次讨论核实的代码事实) ### `acceptance.json` / `acceptance.md` / `goal-benchmark.json` 是三个不同的东西(讨论中容易混用,需要先分清) | 产物 | 落点 | 产出方 | 消费方 | 格式 | |---|---|---|---|---| | `acceptance.json`(R19-B 契约) | `tasks/{taskId}/acceptance.json`,`goal-contract.js` | `goal-benchmark` 节点(唯一真正调 `put` 的 agent) | `org-validator`/`coder-validator`/`benchmark-validator`/`testdata-validator`/`planner-validator`/`validator-triager` | 结构化 JSON,`{benchmark, test_data, test_script}` 三段 | | `acceptance.md` | org-planner 私有目录 | `org-planner/agent.md` 硬约束要求的四份产物之一 | 43 号文档的 `maybeMaterializeItemizedSubtasks`(首选数据源) | Markdown 验收表格 | | `goal-benchmark.json` | `runs/{rootRunId}/artifacts/`(共享区) | `goal-benchmark` 节点 | `goal-contract.js`(被 `$ref` 指到)+ `maybeMaterializeItemizedSubtasks` 兜底数据源(47 号文档新增) | 结构化 JSON,`dimensions[]`/`traceability[]` | 本文档只关心第一个(`acceptance.json`,`org-validator` 真正用来判卷的那份)。跟 43/47 号文档的"子任务物化"是两回事,不要混。 ### 契约产出链是 `workgraph-planning` 现场展开出的子图节点,不是独立体系 `org-planner` 执行时,`workgraph-planning` 依据 `planner.json` 骨架(`brd → prd → sdd → benchmark → validator`)+ 一套裁剪判据现场规划出子图。`benchmark` 节点被标注"不可裁"——理由是它产的 `acceptance` 被**另一张图**(`org-validator` 宿主的 `validator.json`)跨图消费,裁了就是跨图断供。也就是说,现有机制里"契约一定会被产出"这件事,本身建立在"`workgraph-planning` 一定会把 `goal-benchmark` 节点规划进图里"这个假设上——**这个假设只在完整 org graph 路径里成立**,46 号文档的轻量子任务图完全绕开了 `workgraph-planning`,这个假设自然不成立。 ### 写入 API 已经是现成的、可直接程序化调用的函数 `server/services/goal-contract.js` 导出 `writeContract(runDir, contract)` 和 `writeContractSection(runDir, {section, content, goalId, createdBy})`——后者是"产出侧唯一该用的入口"(文件内注释原话),CLI 的 `put` 子命令只是它的一层包装。两者都是纯函数、不依赖 CLI 进程,可以直接从 `enqueueTaskRun`(Node.js 代码)里调用,不需要 shell 出去跑 `goal-contract.js put`。 `test_script` 段的形状要求:`{runner: 'node', path: , args?, timeout_ms?}`——`runner` 白名单目前只有 `node`。 --- ## 设计决策 ### 主力机制:`tasks` 表新增 1:1 字段,`createTask` 统一校验(预防性) 新增列(暂定名 `acceptance_contract`,TEXT,JSON blob,形状复用 `goal-contract.js` 的 `{benchmark, test_data, test_script}`,走应用层校验、不新造格式——跟 `acceptance_mode`/`subtask_kind` 同一套迁移写法,`ALTER TABLE ADD COLUMN`,新建库有 CHECK、存量库应用层守)。`createTask` 用 `goal-contract.js` 已经导出的 `validateContract` 做形状校验,不重写一遍规则。 > ⚠️ **这段的具体形状被真实回归推翻了一半,见下方「实现记录」§「与原设计的出入」**:最终落地的 `acceptance_contract` 存的是 **benchmark 段本身**(`{dimensions, overall_pass_threshold?}`),不是这里写的三段式 wrapper——校验/写入时才现套一层。本段留着不改是为了如实记录设计过程,别照这段的字面形状抄代码。 **本轮范围只收在 `decomposed_work` 这条链路**(`subtask-planning` → `materializeDecomposedSubtasks` → `enqueueTaskRun` 的轻量图分支),不动 `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单(`POST /:companyId/tasks`)这几条创建路径——`task-planning` 在 46 号文档 Phase 1 设计时就明确保护过"职责不变、极简、3 轮内、不碰磁盘",把"必须交结构化验收契约"这个要求推给它,值得单独起一轮讨论,不该这次顺手带上。 ### 安全网(仅记原则,本轮不写代码):发现缺失时就地补一份,但不与判卷同一次完成 如果以后出现另一种既没有 `subtask-planning` 也没有 `org-planner` 的图形状,撞上契约缺失时的兜底原则:**发现者退一步,先独立于已完成的产出补一份契约,再判**——不要在同一次调用里"边写标准边照着标准判",尽量保住"先立标准、后判"这个顺序。这条原则不需要现在写成代码,`decomposed_work` 这条链路走主力机制就已经完全覆盖,留给以后真的出现新图形状时再决定怎么接。 ### 与既有机制的边界 - `org-coder`、`org-validator`、`goal-benchmark` 等所有既有 agent 的 `agent.md`/`tools.md` **不需要改一行**——它们不关心契约从哪来,`enqueueTaskRun` 把契约落到它们熟悉的 `artifacts/acceptance.json` 位置,对它们来说和平时公司完整 org graph 产出的契约没有区别。 - 43/47 号文档的物化机制(`acceptance.md`/`goal-benchmark.json` → `acceptance_item` 子任务)完全不受影响,是另一套东西。 --- ## 开发规划:需要改动的 agent 和代码(盘点) ### Agent(提示词层面,只有一个要改) | Agent | 改动 | 为什么 | |---|---|---| | `subtask-planning` | `agent.md` 输出契约新增字段——每个 step 除了 `title`/`why`/`depends_on_hint`,还要交一份最小验收标准(形状对齐 `goal-contract.js` 的某个 section,具体哪个见下"待定")。这需要真的理解"这一步做完该长什么样",**不能**用代码从 `why` 字段机械转写(转出来的东西大概率是"尽量/基本可用"这类不可度量措辞,`goal-contract.js`/`org-validator` 明确拒收) | 这是本轮唯一需要改提示词的 agent | | `org-coder`、`org-validator`、`goal-benchmark`、`coder-validator` 等 | **不改** | 见上"与既有机制的边界" | ### 代码 | 文件 | 改动 | |---|---| | `server/services/work-graph-db.js` | `tasks` 表加 `acceptance_contract` 列(批 7 迁移);`createTask` 新增同名参数 + 用 `goal-contract.validateContract` 校验;`TASK_FROZEN_FIELDS` 收进这个字段(排队后不许再改,跟 `budgetJson` 那几个字段同待遇) | | `server/services/subtask-planning.js` | 解析 LLM 输出里新增的验收字段,`materializeDecomposedSubtasks` 建每条 `decomposed_work` 行时把它写进 `acceptance_contract` | | `server/routes/company.js`(`enqueueTaskRun`) | `decomposed_work` 分支:起 run 之前,读 `taskRow.acceptance_contract`,调 `goal-contract.writeContractSection`(或 `writeContract`),把它落进这条新 run 目录的 `artifacts/acceptance.json`,登记进 `graph_files`(`writeContractSection` 内部已经做,不用额外重复登记,需确认) | | `server/services/goal-contract.js` | 大概率不用改——`writeContract`/`writeContractSection`/`validateContract`/`SECTIONS` 已经导出,直接复用 | | 测试 | `createTask` 校验(新增/非法值拒绝);`materializeDecomposedSubtasks` 正确落 `acceptance_contract` 字段;`enqueueTaskRun` 正确把它写进新 run 的 `artifacts/acceptance.json`(可以直接读文件断言);一条端到端回归——真的走一遍轻量图,确认 `org-validator` 这次能读到契约、不再 `input_underspecified` | ### 一处待定,写实现前要先定案 —— ✅ 已定案:`benchmark` 段 `subtask-planning` 该交哪个 section(`benchmark`/`test_data`/`test_script`)取决于怎么落地最自然:`test_script` 形状是 `{runner:'node', path, args?, timeout_ms?}`——直觉是指向 `org-coder` 会写的测试文件(这次真实回归里 `org-coder` 确实主动写了 `test/convert.test.js`),但 `subtask-planning` 在 `org-coder` 动手之前就要交契约,那时候还不知道 `org-coder` 最终会把测试文件放在哪个路径——需要要么给 `org-coder` 约定一个固定的测试文件路径(比如统一要求 `test/.test.js`),要么改用 `benchmark` 段(不依赖具体文件路径,走"维度+及格线"这套已经验证过的格式,`org-validator` 自己想办法判)。这个选择直接决定 `org-coder` 的 `tools.md` 要不要跟着补一条"测试文件固定放哪"的约定——如果选 `benchmark` 段,`org-coder` 也可以维持"不改一行"。**建议选 `benchmark` 段**,避免牵连 `org-coder`,但最终定案留到真正实现这一步时再拍板。 按建议定案:选 `benchmark` 段,`org-coder` 全程未改一行(见「实现记录」)。 --- ## 明确不做(本轮范围外) - `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单三条创建路径的契约强制——留到以后单独讨论,不在这次改动里。 - "发现者就地补一份"的运行时兜底代码——只记原则,`decomposed_work` 链路走主力机制已完全覆盖,不为不可达的场景写代码。 - 不改 `org-coder`/`org-validator`/`goal-benchmark` 等既有 agent 的提示词(除非"待定"那条最终选了 `test_script` 段,才需要给 `org-coder` 补一条测试文件路径约定)。 ## 验收标准(后续实现完要检查的) 1. ✅ `createTask` 不给 `acceptance_contract` 直接创建 `decomposed_work` 子任务——应该被拒绝(如果按主力机制彻底做,`decomposed_work` 这个类型应该强制要求这个字段非空)。单测覆盖,见「实现记录」。 2. ✅ 真实回归:重复 46 号文档 Phase 3 v2 那次卡死的场景,这次 `org-validator` 真的读到了 `artifacts/acceptance.json`、真正执行了判分逻辑,`fail_reason=null`,不再是 `input_underspecified`。见「真实端到端回归补跑(2026-09-03)」。 3. ✅ 确认 `org-coder`/`org-validator` 全程不需要感知这份契约是"这次由 subtask-planning 供的"还是"平时 org-planner 供的"——两条路径对它们完全透明;`org-validator` 判词自己也确认了这一点("非本节点自行发明标准——维度/权重/及格线均来自契约原文")。 --- ## 实现记录(2026-09-02) ### 落地清单 | 文件 | 改动 | |---|---| | `server/services/work-graph-db.js` | `tasks` 表加 `acceptance_contract` 列(批 7 迁移,`ALTER TABLE ADD COLUMN`);`createTask` 新增 `acceptanceContract` 参数,`subtaskKind==='decomposed_work'` 时强制非空,套一层 `{version, goal_id, benchmark: acceptanceContract}` 走 `goal-contract.validateContract` 校验(**不重写规则**,见下方「与原设计的出入」);`acceptanceContract` 收进 `TASK_FROZEN_FIELDS`(`queued_at` 非空后不许再改,同 `budgetJson` 待遇);`materializeDecomposedSubtasks` 把 `step.acceptance` 原样递给 `createTask`,单步校验失败(`try/catch`)只跳过那一步、不作废整批,延续既有"宽松解析"哲学 | | `system/agents/subtask-planning/agent.md` | 硬约束新增第 10 条:每个 step 必须带 `acceptance`(benchmark 段本身),不许从 `why` 机械转写;输出形状(约束 1)、字段数(约束 5)同步改;补一节说明"轻量派发链路上 `acceptance` 会被直接拿去判卷" | | `server/services/subtask-planning.js` | `normalizeSteps` 原样透传 `acceptance`(不在这一层做形状校验,深层校验留给 `createTask`);`buildSubtaskPlanningPrompt` 的交回格式示例、字段说明同步加 `acceptance` | | `server/routes/company.js`(`enqueueTaskRun`) | `isDecomposedSubtask` 分支:`writeTaskGoalArtifact` 之后(`goal-contract` 靠 `task-goal.json` 推 `{companyId,taskId}`),把 `taskRow.acceptance_contract` 套一层 `{benchmark: ...}` 通过 `goal-contract.writeContract` 落进新 run 的 `artifacts/acceptance.json`;best-effort(写失败只 `console.error`,不阻塞入队——失败时行为退回改动前的 `input_underspecified`,不比原来更差) | | `server/services/goal-contract.js` | 未改一行——`writeContract`/`validateContract`/`CONTRACT_VERSION`/`resolveRunDir` 全部复用既有导出 | | 测试 | `task-decomposition-dispatch.test.js` 新增 8 个用例(`createTask` 四种校验分支、`materializeDecomposedSubtasks` 两种单步失败场景、路由层两个契约落盘/存量缺口场景);`task-decomposition-validator-gate.test.js` 补 1 处 `acceptanceContract`(存量夹具需要跟着改) | ### 与原设计的出入(重要,别照着「设计决策」一节里的形状抄) 设计决策里写的是"形状复用 `goal-contract.js` 的 `{benchmark, test_data, test_script}`"——这句话在写文档时想的是"契约的三段式 wrapper"。**真实起 `subtask-planning` agent 验证后发现这是错的**:agent.md 教的提示词是"acceptance 对齐 benchmark 段",真实 LLM(不是注入桩)老老实实交回的是 **benchmark 段内容本身**(`{dimensions:[...], overall_pass_threshold?}`),不会自己再包一层 `benchmark` 键——这符合直觉:没有人会在被要求"对齐 X 的格式"时,自己又发明一层 `{X: ...}` 包装。 第一版实现是照着设计决策的字面意思写的(`acceptanceContract` 直接当三段式契约展开),过了单测(因为单测的假数据也是我自己按错误理解造的),但拿真实 agent 输出一测就在 `validateContract` 那步报错(`unknown top-level field: dimensions`)——**这正是本轮反复强调"真实回归优于只信单测"的又一次验证**。修法:`acceptance_contract` 列存 **benchmark 段本身**,`createTask`/`enqueueTaskRun` 校验/落盘时现套一层给 `goal-contract.validateContract`/`writeContract` 用。`agent.md`/`buildSubtaskPlanningPrompt` 的提示词从一开始写的就是裸段格式,不用改。 ### 真实验证(不是只信单测) 1. 单测:`npx vitest run server/tests/unit`,1198/1200 通过(2 个失败是与本次改动无关的既有失败:`env-example-secrets.test.js`/`test-id-stability.test.js`,均未涉及本次改动的文件);`ps aux | grep claude` 确认零残留 CLI 子进程。 2. 真实起 `p2verify0902` 公司里已雇佣的 `subtask-planning` agent(真实 Claude CLI 调用,不是注入桩),给一条真实 task("实现摄氏度转华氏度的转换函数"),验证 agent 真的会按新 `agent.md` 交出 `acceptance` 字段——交了,且格式如上一节所述(暴露了设计文档与真实产出的形状分歧,已修正)。 3. 用真实 LLM 那次调用的原始产出(未修改一个字)跑一遍完整链路:`materializeDecomposedSubtasks` → `createTask`(校验+落库)→ 模拟 `enqueueTaskRun` 的写入段 → `goal-contract.readContract` + `validateContract`,结果 `{"ok":true,"errors":[]}`——这就是"`org-validator` 不会再撞 `input_underspecified`"这件事的直接证据(验收标准 2 的核心机制)。 ### 真实端到端回归补跑(2026-09-03):`org-validator` 真的判过了 用户明确要求补一次更干净的真实回归,跑通了完整链路,验收标准第 2 条现在**已兑现**: 1. 真实建一条全新任务("实现字符串反转函数")、真实起 `subtask-planning` agent(真实 CLI,不是注入桩)分解出 1 条 `decomposed_work` 子任务,`acceptance` 真实产出为 `{"dimensions":[{"name":"普通字符串反转正确","weight":0.6,...},{"name":"空字符串正确处理(返回空字符串,不抛异常)","weight":0.4,...}],"overall_pass_threshold":80}`。 2. 真实调用 `POST /tasks/:taskId/enqueue`(走 CEO 真实用的 `x-internal-token` 认证路径),起了真实的轻量图(`org-coder` → `org-validator`,两个真实 `agent_call` 节点)。 3. **过程中撞见两个纯属本次验证方法论本身的坑,均与 48 号文档代码无关**:① 第一次没起真正的 API 服务(只在脚本进程里跑图引擎),`org-coder` 完工后调 `bin/lemon.js task transition`/`report_task` 够不着 `localhost:3010`,连续 4 次 `infra_flake` 撞熔断硬顶、卡在 `blocked`;② 补起服务后单独重试,`org-coder` 子进程作为**另一个独立进程**(我的重试脚本)的子进程,没能继承真实服务进程自己生成的 `LEMON_INTERNAL_TOKEN`(该 token 是每进程随机生成、只有其自身子进程才认得),打 API 仍是 401。两次都被真实 agent 自己的诊断日志准确点破根因(org-validator 后来的判词原话也复述了这段"记账问题",并明确判定"不在本节点验收范围内")。最终解法:让驱动脚本 `require('server/index.js')` 在**同一个进程**里把真实服务和图引擎重试跑在一起,`org-coder` 子进程这才拿到匹配的 token。 4. 修好拓扑问题后重跑,`org-coder` 真实实现完成,`org-validator` 真实执行——**读到了 `acceptance.json`、真的走了 `goal-contract` 的判分逻辑,两条维度都打了分(100/100),`fail_reason` 为空,不是 `input_underspecified`**。`org-validator` 的判词原文还独立追加了 7 组自己写的测试用例复核交付物,附带一句准确的旁注:"本次 acceptance.json 仅含 benchmark 段(created_by=subtask-planning,48 号文档轻量子任务图路径:org-coder 不产出自报分,org-validator 自行取证判分,非本节点自行发明标准——维度/权重/及格线均来自契约原文,未改动)"。 5. 最终态:`graph_run_nodes` 两个节点均 `success`,`graph_runs.run_status = completed`,task `status = completed`。 **验证方法论本身留下的经验**(写下来免得下次重犯):真实驱动图执行必须让 agent 子进程与提供 API 的服务进程同源(同一个 Node 进程内 `require('server/index.js')`),否则 `LEMON_INTERNAL_TOKEN` 天然对不上、agent 侧的 CLI 工具调用会全部 401——这不是本次代码的缺陷,是"拿脚本模拟真实拓扑"这件事本身的一个通用坑,下次做类似真实回归可以直接照这个模式起。 ### 仍未做的(遗留,按「明确不做」的既有边界) - 安全网("发现缺失时就地补一份")仍然只是原则,未写代码——`decomposed_work` 链路走主力机制已完全覆盖,原设计判断不变。 - `task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径的契约强制——本轮范围外,未动。