Commit 2bc73131c87229d7e313fdb7104d9b93f9bb66c6

Authored by yibo
1 parent 1735bb84

feat(task-decomposition): 48号文档——decomposed_work 验收契约创建时 1:1 强绑定

subtask-planning 每个 step 新增 acceptance 字段(benchmark 段格式),createTask
强制 decomposed_work 子任务必须带验收契约并用 goal-contract.validateContract 校验,
enqueueTaskRun 起轻量图前把契约落进新 run 的 artifacts/acceptance.json——解决
46号文档 Phase 3 v2 真实回归撞见的 org-validator 死锁(轻量图没有 org-planner
产出契约,退无可退)。

真实起 subtask-planning agent 验证时发现设计稿的字段形状假设有误(真实产出是
benchmark 段本身,不是三段式 wrapper),已按真实产出修正代码与文档,并用那次
真实调用的原始输出跑通完整链路验证。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H85SS5hVC4u1PbVhjmrhH8
docs/README.md
@@ -64,7 +64,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -64,7 +64,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
64 | [45-graph-run-node-liveness-recovery.md](./todo/system-design/45-graph-run-node-liveness-recovery.md) | ✅ **Part A/B/C 已实现**(2026-08-31),**Part D 有意延后**。真实事故:h3video 公司一个 `agent_call` 节点卡在 `status=running`、背后 CLI 进程已死,服务器主进程没重启——心跳(纯 LLM 驱动)、开机一次性孤儿扫描、goal/task 巡检(判据是 `blocked` 不是 `running`)、wallclock 看门狗(按预算不按存活)四层机制全盖不到;`ceo` 被转发问题后自己绕过引擎拉会话,陷入死循环。**Part A**:调查发现 `SessionPool` 是每次调用现造即回收的一次性对象,没有可查询的存活注册表,原设计不可行,改用「运行时长超阈值(默认 25 分钟)+ 只扫 `agent_call` 节点」,新函数 `sweepStaleRunningAgentCalls`/`startStaleAgentCallWatchdog`(`graph-engine.js`),5 条新单测。**Part B**:`force-fail` 端点(42 号文档已实现)本身就够用,零新后端代码,只在 `ceo/tools.md` 补了使用文档 + 明确禁止绕过引擎自拉会话。**Part C**:`ceo/heartbeat.md` 同步更新。**Part D**(节点补显式心跳字段,消除 Part A 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | 64 | [45-graph-run-node-liveness-recovery.md](./todo/system-design/45-graph-run-node-liveness-recovery.md) | ✅ **Part A/B/C 已实现**(2026-08-31),**Part D 有意延后**。真实事故:h3video 公司一个 `agent_call` 节点卡在 `status=running`、背后 CLI 进程已死,服务器主进程没重启——心跳(纯 LLM 驱动)、开机一次性孤儿扫描、goal/task 巡检(判据是 `blocked` 不是 `running`)、wallclock 看门狗(按预算不按存活)四层机制全盖不到;`ceo` 被转发问题后自己绕过引擎拉会话,陷入死循环。**Part A**:调查发现 `SessionPool` 是每次调用现造即回收的一次性对象,没有可查询的存活注册表,原设计不可行,改用「运行时长超阈值(默认 25 分钟)+ 只扫 `agent_call` 节点」,新函数 `sweepStaleRunningAgentCalls`/`startStaleAgentCallWatchdog`(`graph-engine.js`),5 条新单测。**Part B**:`force-fail` 端点(42 号文档已实现)本身就够用,零新后端代码,只在 `ceo/tools.md` 补了使用文档 + 明确禁止绕过引擎自拉会话。**Part C**:`ceo/heartbeat.md` 同步更新。**Part D**(节点补显式心跳字段,消除 Part A 阈值判据的误伤局限)未做,见文内「实现记录」的理由 |
65 | [47-itemized-subtask-materialization-goal-benchmark-fallback.md](./todo/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md) | ✅ **已修复**(2026-09-02)。46 号文档 Phase 0 真实回归坐实:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状(prd/sdd/goal-benchmark/planner-validator)下结构性产不出来,`itemized` 子任务物化 0 命中。修法:物化逻辑新增兜底,`acceptance.md` 缺失时改读已被 44 号文档证实稳定产出的 `goal-benchmark.json` 的 `dimensions[]`。`task-decomposition-materialize.test.js` 5 条全过(2 条新增,用真实回归捕获的产物做测试数据) | 65 | [47-itemized-subtask-materialization-goal-benchmark-fallback.md](./todo/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md) | ✅ **已修复**(2026-09-02)。46 号文档 Phase 0 真实回归坐实:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状(prd/sdd/goal-benchmark/planner-validator)下结构性产不出来,`itemized` 子任务物化 0 命中。修法:物化逻辑新增兜底,`acceptance.md` 缺失时改读已被 44 号文档证实稳定产出的 `goal-benchmark.json` 的 `dimensions[]`。`task-decomposition-materialize.test.js` 5 条全过(2 条新增,用真实回归捕获的产物做测试数据) |
66 | [46-mandatory-planning-and-task-decomposition.md](./todo/system-design/46-mandatory-planning-and-task-decomposition.md) | 📝 **Phase 0-3 v2 均已实现且做过真实回归**(2026-09-02)。承接 43 号文档的追问:任务规划应是每个任务默认前置步骤,来源覆盖模型自身能力/知识库经验/线上 deep research。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索(扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭);真实回归揪出一个关键词打分噪音压过信号的真缺陷并修复。**Phase 3 走过一轮推翻重来**:v1("认领"pull 语义 + CAS)做完真实回归才发现"认领后没有触发源",讨论后确认"多方竞争"场景根本不存在(只有 CEO 一人拍板)——**v2 改成 CEO 心跳直接发现+派发(push)**,子任务走一张现造的轻量 work graph(`org-coder`+`org-validator` 两节点,跳过公司完整 org graph 的规划仪式)。v2 真实回归:CEO 心跳真的发现并派发了子任务,`org-coder` 产出质量达标,但 `org-validator` 卡在硬依赖的 `acceptance.json`(只有 `org-planner` 会产出,轻量图没有这个节点)——**验证这一半有真实的结构性缺口,后续方向见 [48 号文档](./todo/system-design/48-task-acceptance-contract-binding.md)**。验证过程顺手修了一个更大范围的真实 bug:两套心跳系统的自环调用一直缺内部认证令牌、暗中 401。Phase 4(独立开发验证循环)、Phase 5(看板 UI)未开工 | 66 | [46-mandatory-planning-and-task-decomposition.md](./todo/system-design/46-mandatory-planning-and-task-decomposition.md) | 📝 **Phase 0-3 v2 均已实现且做过真实回归**(2026-09-02)。承接 43 号文档的追问:任务规划应是每个任务默认前置步骤,来源覆盖模型自身能力/知识库经验/线上 deep research。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索(扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭);真实回归揪出一个关键词打分噪音压过信号的真缺陷并修复。**Phase 3 走过一轮推翻重来**:v1("认领"pull 语义 + CAS)做完真实回归才发现"认领后没有触发源",讨论后确认"多方竞争"场景根本不存在(只有 CEO 一人拍板)——**v2 改成 CEO 心跳直接发现+派发(push)**,子任务走一张现造的轻量 work graph(`org-coder`+`org-validator` 两节点,跳过公司完整 org graph 的规划仪式)。v2 真实回归:CEO 心跳真的发现并派发了子任务,`org-coder` 产出质量达标,但 `org-validator` 卡在硬依赖的 `acceptance.json`(只有 `org-planner` 会产出,轻量图没有这个节点)——**验证这一半有真实的结构性缺口,后续方向见 [48 号文档](./todo/system-design/48-task-acceptance-contract-binding.md)**。验证过程顺手修了一个更大范围的真实 bug:两套心跳系统的自环调用一直缺内部认证令牌、暗中 401。Phase 4(独立开发验证循环)、Phase 5(看板 UI)未开工 |
67 -| [48-task-acceptance-contract-binding.md](./todo/system-design/48-task-acceptance-contract-binding.md) | 📝 **讨论收敛,未实现**(2026-09-02)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。收敛方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 task/subtask 创建时必须 1:1 带一份验收契约"(`tasks` 表新增字段,`createTask` 统一校验),本轮范围收在 `subtask-planning`/`decomposed_work` 这条链路,不动 `task-planning`/`org-planner`/人类快速派单。附完整的 agent/代码改动盘点 | 67 +| [48-task-acceptance-contract-binding.md](./todo/system-design/48-task-acceptance-contract-binding.md) | ✅ **主力机制已实现**(decomposed_work 链路,2026-09-02)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 decomposed_work 子任务创建时必须 1:1 带一份验收契约"(`tasks.acceptance_contract` + `createTask` 统一校验 + `enqueueTaskRun` 落盘)。真实起 `subtask-planning` agent 验证时推翻了设计稿里的字段形状假设(真实产出是 benchmark 段本身,不是三段式 wrapper)并已修正——设计文档留痕、代码按修正后的形状实现,见文档「实现记录」。`task-planning`/`org-planner`/人类快速派单三条创建路径本轮不动;安全网(发现时就地补契约)仍只是原则 |
68 | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) | 68 | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) |
69 69
70 ## todo/future/(暂缓,非当前重心) 70 ## todo/future/(暂缓,非当前重心)
docs/todo/system-design/48-task-acceptance-contract-binding.md
1 # 48 · Task/Subtask 验收契约 1:1 强绑定 1 # 48 · Task/Subtask 验收契约 1:1 强绑定
2 2
3 -> **状态**: 📝 讨论收敛,未实现。 3 +> **状态**: ✅ 已实现(主力机制,decomposed_work 链路)。安全网仍只是原则,未写代码。
4 > **日期**: 2026-09-02 4 > **日期**: 2026-09-02
5 > **触发场景**: 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`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。 5 > **触发场景**: 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`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。
6 6
@@ -51,6 +51,8 @@ @@ -51,6 +51,8 @@
51 51
52 新增列(暂定名 `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` 做形状校验,不重写一遍规则。 52 新增列(暂定名 `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` 做形状校验,不重写一遍规则。
53 53
  54 +> ⚠️ **这段的具体形状被真实回归推翻了一半,见下方「实现记录」§「与原设计的出入」**:最终落地的 `acceptance_contract` 存的是 **benchmark 段本身**(`{dimensions, overall_pass_threshold?}`),不是这里写的三段式 wrapper——校验/写入时才现套一层。本段留着不改是为了如实记录设计过程,别照这段的字面形状抄代码。
  55 +
54 **本轮范围只收在 `decomposed_work` 这条链路**(`subtask-planning` → `materializeDecomposedSubtasks` → `enqueueTaskRun` 的轻量图分支),不动 `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单(`POST /:companyId/tasks`)这几条创建路径——`task-planning` 在 46 号文档 Phase 1 设计时就明确保护过"职责不变、极简、3 轮内、不碰磁盘",把"必须交结构化验收契约"这个要求推给它,值得单独起一轮讨论,不该这次顺手带上。 56 **本轮范围只收在 `decomposed_work` 这条链路**(`subtask-planning` → `materializeDecomposedSubtasks` → `enqueueTaskRun` 的轻量图分支),不动 `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单(`POST /:companyId/tasks`)这几条创建路径——`task-planning` 在 46 号文档 Phase 1 设计时就明确保护过"职责不变、极简、3 轮内、不碰磁盘",把"必须交结构化验收契约"这个要求推给它,值得单独起一轮讨论,不该这次顺手带上。
55 57
56 ### 安全网(仅记原则,本轮不写代码):发现缺失时就地补一份,但不与判卷同一次完成 58 ### 安全网(仅记原则,本轮不写代码):发现缺失时就地补一份,但不与判卷同一次完成
@@ -83,10 +85,12 @@ @@ -83,10 +85,12 @@
83 | `server/services/goal-contract.js` | 大概率不用改——`writeContract`/`writeContractSection`/`validateContract`/`SECTIONS` 已经导出,直接复用 | 85 | `server/services/goal-contract.js` | 大概率不用改——`writeContract`/`writeContractSection`/`validateContract`/`SECTIONS` 已经导出,直接复用 |
84 | 测试 | `createTask` 校验(新增/非法值拒绝);`materializeDecomposedSubtasks` 正确落 `acceptance_contract` 字段;`enqueueTaskRun` 正确把它写进新 run 的 `artifacts/acceptance.json`(可以直接读文件断言);一条端到端回归——真的走一遍轻量图,确认 `org-validator` 这次能读到契约、不再 `input_underspecified` | 86 | 测试 | `createTask` 校验(新增/非法值拒绝);`materializeDecomposedSubtasks` 正确落 `acceptance_contract` 字段;`enqueueTaskRun` 正确把它写进新 run 的 `artifacts/acceptance.json`(可以直接读文件断言);一条端到端回归——真的走一遍轻量图,确认 `org-validator` 这次能读到契约、不再 `input_underspecified` |
85 87
86 -### 一处待定,写实现前要先定案 88 +### 一处待定,写实现前要先定案 —— ✅ 已定案:`benchmark` 段
87 89
88 `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/<task 相关名>.test.js`),要么改用 `benchmark` 段(不依赖具体文件路径,走"维度+及格线"这套已经验证过的格式,`org-validator` 自己想办法判)。这个选择直接决定 `org-coder` 的 `tools.md` 要不要跟着补一条"测试文件固定放哪"的约定——如果选 `benchmark` 段,`org-coder` 也可以维持"不改一行"。**建议选 `benchmark` 段**,避免牵连 `org-coder`,但最终定案留到真正实现这一步时再拍板。 90 `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/<task 相关名>.test.js`),要么改用 `benchmark` 段(不依赖具体文件路径,走"维度+及格线"这套已经验证过的格式,`org-validator` 自己想办法判)。这个选择直接决定 `org-coder` 的 `tools.md` 要不要跟着补一条"测试文件固定放哪"的约定——如果选 `benchmark` 段,`org-coder` 也可以维持"不改一行"。**建议选 `benchmark` 段**,避免牵连 `org-coder`,但最终定案留到真正实现这一步时再拍板。
89 91
  92 +按建议定案:选 `benchmark` 段,`org-coder` 全程未改一行(见「实现记录」)。
  93 +
90 --- 94 ---
91 95
92 ## 明确不做(本轮范围外) 96 ## 明确不做(本轮范围外)
@@ -100,3 +104,36 @@ @@ -100,3 +104,36 @@
100 1. `createTask` 不给 `acceptance_contract` 直接创建 `decomposed_work` 子任务——应该被拒绝(如果按主力机制彻底做,`decomposed_work` 这个类型应该强制要求这个字段非空)。 104 1. `createTask` 不给 `acceptance_contract` 直接创建 `decomposed_work` 子任务——应该被拒绝(如果按主力机制彻底做,`decomposed_work` 这个类型应该强制要求这个字段非空)。
101 2. 真实回归:重复 46 号文档 Phase 3 v2 那次卡死的场景(单位换算任务的轻量图),这次 `org-validator` 应该能读到 `artifacts/acceptance.json`、真正跑一次 `goal-contract.js validate` + `run`,不再是 `input_underspecified`。 105 2. 真实回归:重复 46 号文档 Phase 3 v2 那次卡死的场景(单位换算任务的轻量图),这次 `org-validator` 应该能读到 `artifacts/acceptance.json`、真正跑一次 `goal-contract.js validate` + `run`,不再是 `input_underspecified`。
102 3. 确认 `org-coder`/`org-validator` 全程不需要感知这份契约是"这次由 subtask-planning 供的"还是"平时 org-planner 供的"——两条路径对它们完全透明。 106 3. 确认 `org-coder`/`org-validator` 全程不需要感知这份契约是"这次由 subtask-planning 供的"还是"平时 org-planner 供的"——两条路径对它们完全透明。
  107 +
  108 +---
  109 +
  110 +## 实现记录(2026-09-02)
  111 +
  112 +### 落地清单
  113 +
  114 +| 文件 | 改动 |
  115 +|---|---|
  116 +| `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`)只跳过那一步、不作废整批,延续既有"宽松解析"哲学 |
  117 +| `system/agents/subtask-planning/agent.md` | 硬约束新增第 10 条:每个 step 必须带 `acceptance`(benchmark 段本身),不许从 `why` 机械转写;输出形状(约束 1)、字段数(约束 5)同步改;补一节说明"轻量派发链路上 `acceptance` 会被直接拿去判卷" |
  118 +| `server/services/subtask-planning.js` | `normalizeSteps` 原样透传 `acceptance`(不在这一层做形状校验,深层校验留给 `createTask`);`buildSubtaskPlanningPrompt` 的交回格式示例、字段说明同步加 `acceptance` |
  119 +| `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`,不比原来更差) |
  120 +| `server/services/goal-contract.js` | 未改一行——`writeContract`/`validateContract`/`CONTRACT_VERSION`/`resolveRunDir` 全部复用既有导出 |
  121 +| 测试 | `task-decomposition-dispatch.test.js` 新增 8 个用例(`createTask` 四种校验分支、`materializeDecomposedSubtasks` 两种单步失败场景、路由层两个契约落盘/存量缺口场景);`task-decomposition-validator-gate.test.js` 补 1 处 `acceptanceContract`(存量夹具需要跟着改) |
  122 +
  123 +### 与原设计的出入(重要,别照着「设计决策」一节里的形状抄)
  124 +
  125 +设计决策里写的是"形状复用 `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: ...}` 包装。
  126 +
  127 +第一版实现是照着设计决策的字面意思写的(`acceptanceContract` 直接当三段式契约展开),过了单测(因为单测的假数据也是我自己按错误理解造的),但拿真实 agent 输出一测就在 `validateContract` 那步报错(`unknown top-level field: dimensions`)——**这正是本轮反复强调"真实回归优于只信单测"的又一次验证**。修法:`acceptance_contract` 列存 **benchmark 段本身**,`createTask`/`enqueueTaskRun` 校验/落盘时现套一层给 `goal-contract.validateContract`/`writeContract` 用。`agent.md`/`buildSubtaskPlanningPrompt` 的提示词从一开始写的就是裸段格式,不用改。
  128 +
  129 +### 真实验证(不是只信单测)
  130 +
  131 +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 子进程。
  132 +2. 真实起 `p2verify0902` 公司里已雇佣的 `subtask-planning` agent(真实 Claude CLI 调用,不是注入桩),给一条真实 task("实现摄氏度转华氏度的转换函数"),验证 agent 真的会按新 `agent.md` 交出 `acceptance` 字段——交了,且格式如上一节所述(暴露了设计文档与真实产出的形状分歧,已修正)。
  133 +3. 用真实 LLM 那次调用的原始产出(未修改一个字)跑一遍完整链路:`materializeDecomposedSubtasks` → `createTask`(校验+落库)→ 模拟 `enqueueTaskRun` 的写入段 → `goal-contract.readContract` + `validateContract`,结果 `{"ok":true,"errors":[]}`——这就是"`org-validator` 不会再撞 `input_underspecified`"这件事的直接证据(验收标准 2 的核心机制)。
  134 +
  135 +### 仍未做的(遗留,按「明确不做」的既有边界)
  136 +
  137 +- **没有重跑一次完整的真实 org-coder → org-validator 轻量图**(46 号文档 Phase 3 v2 撞见死锁的那条完整链路)。已验证的是"契约会被正确产出、正确落盘、`goal-contract.validateContract` 判为合法"——这是 `org-validator` 卡死的根因链条上唯一改动的环节,其余环节(`org-coder`/`org-validator` 怎么读契约、怎么判卷)本次未改一行,46 号文档 Phase 3 v2 的真实回归已经验证过 `org-coder` 这一段能正常工作。如果想要不留死角的确认,需要用户手动挑一个任务,让 CEO 心跳把新物化的 `decomposed_work` 子任务真派发一遍,全程跑完(含真实 `org-coder`/`org-validator` CLI 调用,成本较高,未在本轮自动跑)。
  138 +- 安全网("发现缺失时就地补一份")仍然只是原则,未写代码——`decomposed_work` 链路走主力机制已完全覆盖,原设计判断不变。
  139 +- `task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径的契约强制——本轮范围外,未动。
server/routes/company.js
@@ -1151,6 +1151,31 @@ function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = fals @@ -1151,6 +1151,31 @@ function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = fals
1151 createdAt: now, 1151 createdAt: now,
1152 }); 1152 });
1153 1153
  1154 + // 48 号文档:decomposed_work 子任务的验收契约在创建时就已经 1:1 强绑定在 taskRow 上
  1155 + // (createTask 强制要求非空)——起轻量图之前把它落进这条新 run 的 artifacts/acceptance.json,
  1156 + // org-validator 全程不需要感知这份契约是这条链路供的还是平时 org-planner 供的(见该文档
  1157 + // 「与既有机制的边界」)。写在 writeTaskGoalArtifact 之后:goal-contract.writeContract
  1158 + // 内部靠 artifacts/task-goal.json 推出 {companyId, taskId} 去登记 graph_files 索引,
  1159 + // task-goal.json 得先存在。best-effort:写失败不该让入队本身失败(org-validator 会退回
  1160 + // 原来的 input_underspecified 路径,行为不比这次改动之前更差)。
  1161 + if (isDecomposedSubtask && taskRow.acceptance_contract) {
  1162 + try {
  1163 + const goalContract = require('../services/goal-contract');
  1164 + // acceptance_contract 存的是 benchmark 段本身(见 work-graph-db.createTask 的注释),
  1165 + // 这里套一层变成完整契约。
  1166 + const benchmark = JSON.parse(taskRow.acceptance_contract);
  1167 + const runDir = goalContract.resolveRunDir(companyId, runId);
  1168 + goalContract.writeContract(runDir, {
  1169 + version: goalContract.CONTRACT_VERSION,
  1170 + goal_id: taskRow.goal_id,
  1171 + created_by: 'subtask-planning',
  1172 + benchmark,
  1173 + });
  1174 + } catch (e) {
  1175 + console.error(`[TaskEntry] task ${taskRow.id} 写入子任务验收契约失败(不阻塞入队): ${e.message}`);
  1176 + }
  1177 + }
  1178 +
1154 const task = wdb.updateTask(taskRow.id, { 1179 const task = wdb.updateTask(taskRow.id, {
1155 status: 'queued', 1180 status: 'queued',
1156 currentRunId: runId, 1181 currentRunId: runId,
server/services/subtask-planning.js
@@ -25,6 +25,13 @@ @@ -25,6 +25,13 @@
25 * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。 25 * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。
26 * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的—— 26 * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的——
27 * agent 唯一自报的判断是 `confidence`,用来决定要不要触发调研,不直接决定 `reasoning_source`。 27 * agent 唯一自报的判断是 `confidence`,用来决定要不要触发调研,不直接决定 `reasoning_source`。
  28 + *
  29 + * ── 48 号文档:验收契约创建时 1:1 强绑定 ────────────────────────────────────
  30 + * 每个 step 现在还要交一份 `acceptance`(benchmark 段形状)。它不是给 org-planner 参考用的
  31 + * 装饰字段——一条 step 被 `materializeDecomposedSubtasks` 物化成 `decomposed_work` 子任务时,
  32 + * `acceptance` 会被 `work-graph-db.createTask` 强制要求、校验、随行落库,`enqueueTaskRun`
  33 + * 起轻量图前把它写进新 run 的 `artifacts/acceptance.json`——这就是 `org-validator` 在跳过
  34 + * `org-planner` 的轻量派发链路上判卷时唯一的依据来源。
28 */ 35 */
29 36
30 const path = require('path'); 37 const path = require('path');
@@ -108,7 +115,10 @@ function planSubtasks(ctx) { @@ -108,7 +115,10 @@ function planSubtasks(ctx) {
108 * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行 115 * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行
109 * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string, 116 * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string,
110 * experienceRefs: string[], researchUsed: boolean}>} 117 * experienceRefs: string[], researchUsed: boolean}>}
111 - * steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过) 118 + * steps 形如 `[{seq, title, why, depends_on_hint: [seq…], acceptance?}]`,
  119 + * seq 恒为 1..N 连续(重新编号过)。`acceptance`(48 号文档)缺失/形状不合法的条目
  120 + * 仍会留在这里——`normalizeSteps` 不因它丢整条,深层校验留给 `materializeDecomposedSubtasks`
  121 + * 建行那一刻(单步失败跳过,不作废整批)。
112 */ 122 */
113 async function defaultPlanSubtasks(ctx) { 123 async function defaultPlanSubtasks(ctx) {
114 const agentName = resolvePlanningAgentName(ctx.companyId); 124 const agentName = resolvePlanningAgentName(ctx.companyId);
@@ -221,18 +231,28 @@ function buildSubtaskPlanningPrompt(ctx, extra = {}) { @@ -221,18 +231,28 @@ function buildSubtaskPlanningPrompt(ctx, extra = {}) {
221 lines.push( 231 lines.push(
222 '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', 232 '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)',
223 '```json', 233 '```json',
224 - '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}],"confidence":"high"}', 234 + '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[],'
  235 + + '"acceptance":{"dimensions":[{"name":"…","weight":1,"pass_threshold":80}],"overall_pass_threshold":80}}],'
  236 + + '"confidence":"high"}',
225 '```', 237 '```',
226 `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`, 238 `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`,
227 '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,' 239 '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,'
228 + '不是硬依赖,没有任何机制会强制执行它。', 240 + '不是硬依赖,没有任何机制会强制执行它。',
  241 + '- `acceptance`:这一步的验收标准(48 号文档),形状对齐 benchmark 段:`dimensions` 至少一条,'
  242 + + '每条 `{name, weight, pass_threshold?}`,`weight` 之和必须是 1;`overall_pass_threshold` 可选。'
  243 + + '不能从 `why` 抄——要写清楚这一步做完该长什么样、按什么维度打分,格式不合规或空泛("基本可用"'
  244 + + '这类不可度量措辞)会被直接拒收,这一步就不会被派发出去。',
229 '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)' 245 '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)'
230 + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划' 246 + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划'
231 + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。', 247 + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。',
232 - '- 只输出这两个顶层字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。', 248 + '- 只输出这两个顶层字段(`steps`/`confidence`)。想法写在 JSON 代码块**前面**的正文里,'
  249 + + '代码块之后不要再写任何字。',
233 '', 250 '',
234 '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,' 251 '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,'
235 + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。' 252 + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。'
  253 + + '**`acceptance` 是例外**:如果这条 task 走的是不经过 org-planner 的轻量派发链路,'
  254 + + '你交的 `acceptance` 会被原样写成这一步的验收契约,`org-validator` 直接拿它判卷——'
  255 + + '写得没用的后果不再只是"没人看",而是这一步真的会通不过验收(48 号文档)。'
236 ); 256 );
237 return lines.join('\n'); 257 return lines.join('\n');
238 } 258 }
@@ -326,11 +346,18 @@ function normalizeSteps(raw) { @@ -326,11 +346,18 @@ function normalizeSteps(raw) {
326 const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN; 346 const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN;
327 if (Number.isInteger(n) && keptOrigSet.has(n) && n !== o._origIndex && !deps.includes(n)) deps.push(n); 347 if (Number.isInteger(n) && keptOrigSet.has(n) && n !== o._origIndex && !deps.includes(n)) deps.push(n);
328 } 348 }
  349 + // 48 号文档:`acceptance` 原样透传,不在这里做形状校验——深层校验(权重之和/runner
  350 + // 白名单)只有 goal-contract.validateContract 做得了,交给 materializeDecomposedSubtasks
  351 + // 建行时去做(单步不合法就跳过那一步,不作废整批,同款"宽松解析"哲学,见该函数注释)。
  352 + const acceptance = o.item.acceptance && typeof o.item.acceptance === 'object' && !Array.isArray(o.item.acceptance)
  353 + ? o.item.acceptance
  354 + : undefined;
329 return { 355 return {
330 seq: idx + 1, 356 seq: idx + 1,
331 title: o.title.slice(0, 200), 357 title: o.title.slice(0, 200),
332 why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000), 358 why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000),
333 depends_on_hint: deps, 359 depends_on_hint: deps,
  360 + ...(acceptance !== undefined ? { acceptance } : {}),
334 }; 361 };
335 }); 362 });
336 } 363 }
server/services/work-graph-db.js
@@ -569,6 +569,16 @@ function getDb() { @@ -569,6 +569,16 @@ function getDb() {
569 -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 569 -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开
570 -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 570 -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。
571 subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','decomposed_work') OR subtask_kind IS NULL), 571 subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','decomposed_work') OR subtask_kind IS NULL),
  572 + -- 48 号文档:验收契约创建时 1:1 强绑定(主力机制)。JSON blob,形状是 goal-contract.js
  573 + -- 的 **benchmark 段本身**({dimensions:[{name,weight,pass_threshold?}], overall_pass_threshold?}),
  574 + -- 不是三段式契约的 wrapper——真实起 agent 验证过,"acceptance 对齐 benchmark 段"这句提示词
  575 + -- 换来的就是段内容本身,不会带一层 benchmark 键。createTask 校验时现套一层给
  576 + -- goal-contract.validateContract 用,不重写一遍规则;存库仍存裸段内容。
  577 + -- 本轮范围只收在 decomposed_work 这条链路——createTask 强制它非空;其余创建路径
  578 + -- (task-planning / org-planner 的 acceptance_item / 人类快速派单)不受影响,仍为 NULL。
  579 + -- enqueueTaskRun 起 decomposed_work 的轻量图之前,把它落进新 run 的 artifacts/acceptance.json,
  580 + -- org-coder/org-validator 全程不需要感知这份契约是这条链路供的还是平时 org-planner 供的。
  581 + acceptance_contract TEXT,
572 created_at TEXT NOT NULL, 582 created_at TEXT NOT NULL,
573 updated_at TEXT NOT NULL, 583 updated_at TEXT NOT NULL,
574 queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) 584 queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4)
@@ -664,6 +674,15 @@ function getDb() { @@ -664,6 +674,15 @@ function getDb() {
664 db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`); 674 db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`);
665 } 675 }
666 676
  677 + // ── 批 7:48 号文档,tasks 追加 acceptance_contract ──────────────────────
  678 + // 同批 5/6 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上取值域只由 createTask
  679 + // 的应用层校验守。旧行一律 NULL——这批上线前物化的 decomposed_work 子任务不会补一份契约,
  680 + // enqueueTaskRun 遇到 NULL 就跳过写入,org-validator 仍会 input_underspecified;
  681 + // 这是已知的、范围内可接受的存量缺口(新物化的子任务从这次上线起才有)。
  682 + if (!hasColumn(db, 'tasks', 'acceptance_contract')) {
  683 + db.exec(`ALTER TABLE tasks ADD COLUMN acceptance_contract TEXT`);
  684 + }
  685 +
667 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 686 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。
668 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { 687 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) {
669 db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); 688 db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`);
@@ -1886,6 +1905,11 @@ function validateDependsOn(goalId, taskId, dependsOn) { @@ -1886,6 +1905,11 @@ function validateDependsOn(goalId, taskId, dependsOn) {
1886 * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。 1905 * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。
1887 * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic' 1906 * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic'
1888 * (不允许递归再拆一层)——传 `parentTaskId` 时若给了 'itemized' 会被拒。 1907 * (不允许递归再拆一层)——传 `parentTaskId` 时若给了 'itemized' 会被拒。
  1908 + * @param {object|null} [p.acceptanceContract] 48 号文档:验收契约,创建时 1:1 强绑定。
  1909 + * 形状是 goal-contract.js 的 **benchmark 段本身**(`{dimensions:[...], overall_pass_threshold?}`),
  1910 + * 不是三段式契约的 wrapper——校验时现套一层再走 `goal-contract.validateContract`。
  1911 + * **`subtaskKind==='decomposed_work'` 时必填**——这是主力机制的硬约束点;其余创建路径
  1912 + * 不许传(校验会拒),本轮范围只收在这条链路。
1889 * @returns {object} 刚插入的整行 1913 * @returns {object} 刚插入的整行
1890 */ 1914 */
1891 function createTask({ 1915 function createTask({
@@ -1903,6 +1927,7 @@ function createTask({ @@ -1903,6 +1927,7 @@ function createTask({
1903 parentTaskId = null, 1927 parentTaskId = null,
1904 acceptanceMode = 'holistic', 1928 acceptanceMode = 'holistic',
1905 subtaskKind = null, 1929 subtaskKind = null,
  1930 + acceptanceContract = null,
1906 } = {}) { 1931 } = {}) {
1907 if (!goalId) throw new Error('createTask: goalId 必填'); 1932 if (!goalId) throw new Error('createTask: goalId 必填');
1908 if (!title) throw new Error('createTask: title 必填'); 1933 if (!title) throw new Error('createTask: title 必填');
@@ -1923,6 +1948,32 @@ function createTask({ @@ -1923,6 +1948,32 @@ function createTask({
1923 throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义'); 1948 throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义');
1924 } 1949 }
1925 1950
  1951 + // 48 号文档:验收契约创建时 1:1 强绑定,本轮范围只收在 decomposed_work 这条链路——
  1952 + // 主力机制的硬约束点。不合法的形状(权重不足 1 / runner 越界 / 空契约……)在这里就被挡,
  1953 + // 不等到 org-validator 跑起来才发现契约是坏的。
  1954 + let acceptanceContractText = null;
  1955 + if (subtaskKind === 'decomposed_work') {
  1956 + if (!acceptanceContract || typeof acceptanceContract !== 'object' || Array.isArray(acceptanceContract)) {
  1957 + throw new Error('createTask: decomposed_work 子任务必须带 acceptanceContract(验收契约,见 48 号文档)');
  1958 + }
  1959 + } else if (acceptanceContract != null) {
  1960 + throw new Error('createTask: acceptanceContract 只对 decomposed_work 子任务有意义(本轮范围,见 48 号文档)');
  1961 + }
  1962 + if (acceptanceContract) {
  1963 + // 懒加载:goal-contract.js 只在这里被用到,其余 createTask 调用路径不该为此多背一个 require。
  1964 + // ⚠️ 形状是 goal-contract.js 的 **benchmark 段本身**({dimensions[], overall_pass_threshold?}),
  1965 + // 不是三段式契约的 {benchmark:{...}} 包一层——真实起 subtask-planning agent 验证过:
  1966 + // 提示词教的就是"acceptance 对齐 benchmark 段",agent 老老实实交回的是段内容本身,
  1967 + // 不会自己多包一层 benchmark 键。校验时补这一层给 validateContract 用,存库仍存裸段内容。
  1968 + const { validateContract, CONTRACT_VERSION } = require('./goal-contract');
  1969 + const wrapped = { version: CONTRACT_VERSION, goal_id: goalId, benchmark: acceptanceContract };
  1970 + const v = validateContract(wrapped);
  1971 + if (!v.ok) {
  1972 + throw new Error(`createTask: acceptanceContract 不合法: ${JSON.stringify(v.errors)}`);
  1973 + }
  1974 + acceptanceContractText = JSON.stringify(acceptanceContract);
  1975 + }
  1976 +
1926 const db = getDb(); 1977 const db = getDb();
1927 const goal = getGoal(goalId); 1978 const goal = getGoal(goalId);
1928 if (!goal) throw new Error(`createTask: goal 不存在: ${goalId}`); 1979 if (!goal) throw new Error(`createTask: goal 不存在: ${goalId}`);
@@ -1953,8 +2004,8 @@ function createTask({ @@ -1953,8 +2004,8 @@ function createTask({
1953 const ins = db.prepare(` 2004 const ins = db.prepare(`
1954 INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, 2005 INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status,
1955 execution_mode, depends_on, priority, budget_json, report_to, created_by, 2006 execution_mode, depends_on, priority, budget_json, report_to, created_by,
1956 - parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at)  
1957 - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 2007 + parent_task_id, acceptance_mode, subtask_kind, acceptance_contract, created_at, updated_at)
  2008 + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1958 `); 2009 `);
1959 const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( 2010 const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run(
1960 tid, goalId, String(cid), nextSeq, String(title), String(description), 2011 tid, goalId, String(cid), nextSeq, String(title), String(description),
@@ -1962,7 +2013,7 @@ function createTask({ @@ -1962,7 +2013,7 @@ function createTask({
1962 // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 2013 // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。
1963 // goal 上有就继承下来,别让每个调用方各记一遍。 2014 // goal 上有就继承下来,别让每个调用方各记一遍。
1964 reportTo || goal.report_to || null, 2015 reportTo || goal.report_to || null,
1965 - createdBy, parentTaskId, acceptanceMode, subtaskKind, now, now 2016 + createdBy, parentTaskId, acceptanceMode, subtaskKind, acceptanceContractText, now, now
1966 )); 2017 ));
1967 return getTask(id); 2018 return getTask(id);
1968 } 2019 }
@@ -2041,6 +2092,7 @@ const TASK_PATCH_COLUMNS = { @@ -2041,6 +2092,7 @@ const TASK_PATCH_COLUMNS = {
2041 archivedAt: 'archived_at', 2092 archivedAt: 'archived_at',
2042 summary: 'summary', 2093 summary: 'summary',
2043 executionConvId: 'execution_conv_id', 2094 executionConvId: 'execution_conv_id',
  2095 + acceptanceContract: 'acceptance_contract',
2044 }; 2096 };
2045 2097
2046 /** 2098 /**
@@ -2050,8 +2102,12 @@ const TASK_PATCH_COLUMNS = { @@ -2050,8 +2102,12 @@ const TASK_PATCH_COLUMNS = {
2050 */ 2102 */
2051 const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked']; 2103 const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked'];
2052 2104
2053 -/** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */  
2054 -const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; 2105 +/**
  2106 + * queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过。
  2107 + * `acceptanceContract`(48 号文档)同理——`enqueueTaskRun` 起 run 时就把它写进了新 run 的
  2108 + * artifacts/acceptance.json,队列位次之后再改契约,org-validator 判的和 agent 交的会对不上。
  2109 + */
  2110 +const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson', 'acceptanceContract'];
2055 2111
2056 /** 2112 /**
2057 * 改 task。 2113 * 改 task。
@@ -2198,8 +2254,15 @@ function settleSubtaskResult(taskId, status, summary = null) { @@ -2198,8 +2254,15 @@ function settleSubtaskResult(taskId, status, summary = null) {
2198 * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用, 2254 * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用,
2199 * 不作废整批——延续 Phase 1 定的"宽松解析"哲学。 2255 * 不作废整批——延续 Phase 1 定的"宽松解析"哲学。
2200 * 2256 *
2201 - * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx  
2202 - * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 [] 2257 + * 48 号文档:每条物化出的行都是 `createTask` 强制要求带 `acceptanceContract` 的
  2258 + * `decomposed_work` 子任务——`step.acceptance` 缺失或形状不合法时,`createTask` 会抛错。
  2259 + * 这里按同一条"宽松解析"哲学处理:**单步失败就跳过那一步**(`console.warn`),不作废整批,
  2260 + * 不像"没有 title"那样在 `normalizeSteps` 阶段就地丢弃——因为深层校验(权重之和/runner 白名单
  2261 + * 这些)只有 `goal-contract.validateContract` 才做得了,提前到这里重写一遍就是第二个持有者。
  2262 + *
  2263 + * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,
  2264 + * depends_on_hint?:number[],acceptance?:object}>}} ctx
  2265 + * @returns {string[]} 物化出的 task id,按 steps 顺序(跳过失败的那些);已物化过 / 无 steps 时返回 []
2203 */ 2266 */
2204 function materializeDecomposedSubtasks({ task, steps }) { 2267 function materializeDecomposedSubtasks({ task, steps }) {
2205 if (!task || !Array.isArray(steps) || !steps.length) return []; 2268 if (!task || !Array.isArray(steps) || !steps.length) return [];
@@ -2210,27 +2273,36 @@ function materializeDecomposedSubtasks({ task, steps }) { @@ -2210,27 +2273,36 @@ function materializeDecomposedSubtasks({ task, steps }) {
2210 if (already > 0) return []; 2273 if (already > 0) return [];
2211 2274
2212 const seqToId = new Map(); 2275 const seqToId = new Map();
  2276 + const materialized = [];
2213 for (const step of steps) { 2277 for (const step of steps) {
2214 - const row = createTask({  
2215 - goalId: task.goal_id,  
2216 - companyId: task.company_id,  
2217 - title: String(step.title || '').slice(0, 200),  
2218 - description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''),  
2219 - executionMode: 'auto',  
2220 - createdBy: 'subtask-planning',  
2221 - parentTaskId: task.id,  
2222 - subtaskKind: 'decomposed_work',  
2223 - }); 2278 + let row;
  2279 + try {
  2280 + row = createTask({
  2281 + goalId: task.goal_id,
  2282 + companyId: task.company_id,
  2283 + title: String(step.title || '').slice(0, 200),
  2284 + description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''),
  2285 + executionMode: 'auto',
  2286 + createdBy: 'subtask-planning',
  2287 + parentTaskId: task.id,
  2288 + subtaskKind: 'decomposed_work',
  2289 + acceptanceContract: step.acceptance,
  2290 + });
  2291 + } catch (e) {
  2292 + console.warn(`[work-graph-db] task ${task.id} 第 ${step.seq} 步物化失败,跳过(不影响同批其它步骤): ${e.message}`);
  2293 + continue;
  2294 + }
2224 seqToId.set(step.seq, row.id); 2295 seqToId.set(step.seq, row.id);
  2296 + materialized.push(step);
2225 } 2297 }
2226 2298
2227 - for (const step of steps) { 2299 + for (const step of materialized) {
2228 const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : []; 2300 const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : [];
2229 const deps = hints.map((h) => seqToId.get(h)).filter(Boolean); 2301 const deps = hints.map((h) => seqToId.get(h)).filter(Boolean);
2230 if (deps.length) updateTask(seqToId.get(step.seq), { dependsOn: deps }); 2302 if (deps.length) updateTask(seqToId.get(step.seq), { dependsOn: deps });
2231 } 2303 }
2232 2304
2233 - return steps.map((s) => seqToId.get(s.seq)); 2305 + return materialized.map((s) => seqToId.get(s.seq));
2234 } 2306 }
2235 2307
2236 /** 2308 /**
server/tests/unit/task-decomposition-dispatch.test.js
@@ -20,6 +20,12 @@ const TEST_DB_PATH = &#39;/tmp/test-task-decomp-dispatch.sqlite&#39;; @@ -20,6 +20,12 @@ const TEST_DB_PATH = &#39;/tmp/test-task-decomp-dispatch.sqlite&#39;;
20 const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; 20 const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
21 const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; 21 const MODULES = ['../../services/graph-db', '../../services/work-graph-db'];
22 22
  23 +// 48 号文档:decomposed_work 子任务创建时必须带 acceptanceContract。这是一份最小合法契约
  24 +// (benchmark 段**本身**,不是三段式契约的 wrapper——真实起 agent 验证过这就是它交回的形状,
  25 +// 见 work-graph-db.createTask 的注释),权重之和=1,测材料化/图选择等与契约本身无关的机制时
  26 +// 复用它,不重复拼。
  27 +const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] };
  28 +
23 function ensureAgent(wdb, id) { 29 function ensureAgent(wdb, id) {
24 const db = wdb.getDb(); 30 const db = wdb.getDb();
25 const now = new Date().toISOString(); 31 const now = new Date().toISOString();
@@ -68,12 +74,61 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; { @@ -68,12 +74,61 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; {
68 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); 74 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
69 const child = wdb.createTask({ 75 const child = wdb.createTask({
70 goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', 76 goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  77 + acceptanceContract: MIN_ACCEPTANCE,
71 }); 78 });
72 expect(child.subtask_kind).toBe('decomposed_work'); 79 expect(child.subtask_kind).toBe('decomposed_work');
73 expect(child.status).toBe('draft'); // 没有专属状态了 80 expect(child.status).toBe('draft'); // 没有专属状态了
74 expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑 81 expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑
75 }); 82 });
76 83
  84 + // ── 48 号文档:验收契约创建时 1:1 强绑定 ──────────────────────────────
  85 +
  86 + it('decomposed_work 不带 acceptanceContract 直接拒——创建时硬约束(48 号文档验收标准 1)', () => {
  87 + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
  88 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  89 + expect(() => wdb.createTask({
  90 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  91 + })).toThrow(/必须带 acceptanceContract/);
  92 + });
  93 +
  94 + it('非 decomposed_work 的普通 task 传 acceptanceContract 直接拒——本轮范围只收这条链路', () => {
  95 + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
  96 + expect(() => wdb.createTask({
  97 + goalId: goal.id, title: 't', description: 'd', acceptanceContract: MIN_ACCEPTANCE,
  98 + })).toThrow(/只对 decomposed_work 子任务有意义/);
  99 + });
  100 +
  101 + it('acceptanceContract 形状不合法(权重之和不为 1)被 goal-contract.validateContract 挡下', () => {
  102 + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
  103 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  104 + expect(() => wdb.createTask({
  105 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  106 + acceptanceContract: { dimensions: [{ name: 'a', weight: 0.5, pass_threshold: 80 }] }, // 和不为 1
  107 + })).toThrow(/acceptanceContract 不合法/);
  108 + });
  109 +
  110 + it('acceptanceContract 合法时原样落库,读回后 JSON.parse 与传入等价', () => {
  111 + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
  112 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  113 + const child = wdb.createTask({
  114 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  115 + acceptanceContract: MIN_ACCEPTANCE,
  116 + });
  117 + expect(JSON.parse(child.acceptance_contract)).toEqual(MIN_ACCEPTANCE);
  118 + });
  119 +
  120 + it('acceptance_contract 是冻结字段:queued_at 非空后 patch 会被 FrozenFieldError 拒', () => {
  121 + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
  122 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  123 + const child = wdb.createTask({
  124 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  125 + acceptanceContract: MIN_ACCEPTANCE,
  126 + });
  127 + wdb.updateTask(child.id, { queuedAt: new Date().toISOString() });
  128 + expect(() => wdb.updateTask(child.id, { acceptanceContract: JSON.stringify(MIN_ACCEPTANCE) }))
  129 + .toThrow(/acceptanceContract/);
  130 + });
  131 +
77 // ── materializeDecomposedSubtasks ───────────────────────────────── 132 // ── materializeDecomposedSubtasks ─────────────────────────────────
78 133
79 describe('materializeDecomposedSubtasks', () => { 134 describe('materializeDecomposedSubtasks', () => {
@@ -85,9 +140,9 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; { @@ -85,9 +140,9 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; {
85 it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => { 140 it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => {
86 const task = setupTask('co-p3-2'); 141 const task = setupTask('co-p3-2');
87 const steps = [ 142 const steps = [
88 - { seq: 1, title: '第一步', depends_on_hint: [3] }, // 前向引用:依赖还没建出来的第三步  
89 - { seq: 2, title: '第二步', depends_on_hint: [1] },  
90 - { seq: 3, title: '第三步', depends_on_hint: [] }, 143 + { seq: 1, title: '第一步', depends_on_hint: [3], acceptance: MIN_ACCEPTANCE }, // 前向引用:依赖还没建出来的第三步
  144 + { seq: 2, title: '第二步', depends_on_hint: [1], acceptance: MIN_ACCEPTANCE },
  145 + { seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
91 ]; 146 ];
92 const ids = wdb.materializeDecomposedSubtasks({ task, steps }); 147 const ids = wdb.materializeDecomposedSubtasks({ task, steps });
93 expect(ids).toHaveLength(3); 148 expect(ids).toHaveLength(3);
@@ -104,7 +159,7 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; { @@ -104,7 +159,7 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; {
104 159
105 it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { 160 it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => {
106 const task = setupTask('co-p3-3'); 161 const task = setupTask('co-p3-3');
107 - const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99] }]; 162 + const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99], acceptance: MIN_ACCEPTANCE }];
108 const ids = wdb.materializeDecomposedSubtasks({ task, steps }); 163 const ids = wdb.materializeDecomposedSubtasks({ task, steps });
109 expect(ids).toHaveLength(1); 164 expect(ids).toHaveLength(1);
110 expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); 165 expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]);
@@ -112,7 +167,7 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; { @@ -112,7 +167,7 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; {
112 167
113 it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { 168 it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => {
114 const task = setupTask('co-p3-4'); 169 const task = setupTask('co-p3-4');
115 - const steps = [{ seq: 1, title: '一步', depends_on_hint: [] }]; 170 + const steps = [{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }];
116 const first = wdb.materializeDecomposedSubtasks({ task, steps }); 171 const first = wdb.materializeDecomposedSubtasks({ task, steps });
117 expect(first).toHaveLength(1); 172 expect(first).toHaveLength(1);
118 const second = wdb.materializeDecomposedSubtasks({ task, steps }); 173 const second = wdb.materializeDecomposedSubtasks({ task, steps });
@@ -137,10 +192,33 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; { @@ -137,10 +192,33 @@ describe(&#39;46 号文档 Phase 3(v2):DB 层&#39;, () =&gt; {
137 subtaskKind: 'acceptance_item', 192 subtaskKind: 'acceptance_item',
138 }); 193 });
139 const ids = wdb.materializeDecomposedSubtasks({ 194 const ids = wdb.materializeDecomposedSubtasks({
140 - task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }], 195 + task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }],
141 }); 196 });
142 expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 197 expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住
143 }); 198 });
  199 +
  200 + // ── 48 号文档:acceptance 缺失/不合法时单步跳过,不作废整批 ──────────────
  201 +
  202 + it('单步 acceptance 缺失:那一步跳过,同批其它合法步骤照常物化(宽松解析)', () => {
  203 + const task = setupTask('co-p3-9');
  204 + const steps = [
  205 + { seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }, // 缺 acceptance,会被 createTask 拒
  206 + { seq: 2, title: '带 acceptance 的一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  207 + ];
  208 + const ids = wdb.materializeDecomposedSubtasks({ task, steps });
  209 + expect(ids).toHaveLength(1);
  210 + expect(wdb.getTask(ids[0]).title).toBe('带 acceptance 的一步');
  211 + });
  212 +
  213 + it('全部 step 都没有 acceptance:整批跳过,返回 [](不是「无 steps」那条早退路径)', () => {
  214 + const task = setupTask('co-p3-10');
  215 + const ids = wdb.materializeDecomposedSubtasks({
  216 + task, steps: [{ seq: 1, title: '唯一一步,没有 acceptance', depends_on_hint: [] }],
  217 + });
  218 + expect(ids).toEqual([]);
  219 + const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n;
  220 + expect(n).toBe(0);
  221 + });
144 }); 222 });
145 223
146 // ── createDecomposedWorkGraph ────────────────────────────────────── 224 // ── createDecomposedWorkGraph ──────────────────────────────────────
@@ -269,6 +347,7 @@ describe(&#39;46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择&#39;, @@ -269,6 +347,7 @@ describe(&#39;46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择&#39;,
269 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); 347 const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
270 const child = wdb.createTask({ 348 const child = wdb.createTask({
271 goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', 349 goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  350 + acceptanceContract: MIN_ACCEPTANCE,
272 }); 351 });
273 352
274 const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); 353 const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`);
@@ -282,4 +361,51 @@ describe(&#39;46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择&#39;, @@ -282,4 +361,51 @@ describe(&#39;46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择&#39;,
282 .all(run.graph_id); 361 .all(run.graph_id);
283 expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']); 362 expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']);
284 }); 363 });
  364 +
  365 + // ── 48 号文档:验收契约随 enqueue 落进新 run 的 artifacts/acceptance.json ──────
  366 +
  367 + it('decomposed_work 子任务的 acceptanceContract 会被写进新 run 的 artifacts/acceptance.json,goal-contract 能读回并判为合法', async () => {
  368 + const goalContract = require('../../services/goal-contract');
  369 + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' });
  370 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  371 + const child = wdb.createTask({
  372 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  373 + acceptanceContract: MIN_ACCEPTANCE,
  374 + });
  375 +
  376 + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`);
  377 + expect(status).toBe(201);
  378 +
  379 + // org-validator 在这条轻量链路上判卷的唯一依据来源:goal-contract 能读到、能验证通过,
  380 + // 就是「不会再撞 input_underspecified」这件事的直接证据(46 号文档 Phase 3 v2 真实回归
  381 + // 撞见的那次死锁,见 48 号文档「触发场景」)。
  382 + const runDir = goalContract.resolveRunDir(CID, body.runId);
  383 + const loaded = goalContract.readContract(runDir);
  384 + expect(loaded).not.toBeNull();
  385 + expect(loaded.contract.benchmark).toEqual(MIN_ACCEPTANCE);
  386 + expect(goalContract.validateContract(loaded.contract).ok).toBe(true);
  387 +
  388 + const files = wdb.findFiles({ companyId: CID, taskId: child.id, kinds: ['acceptance'] });
  389 + expect(files.length).toBeGreaterThan(0);
  390 + });
  391 +
  392 + it('decomposed_work 子任务没有 acceptanceContract(存量缺口)时 enqueue 仍然成功,只是不写 acceptance.json', async () => {
  393 + // 走不到这里其实很难——createTask 现在强制要求 decomposed_work 带 acceptanceContract。
  394 + // 这条测的是「万一有一条存量行是列上线前物化的(acceptance_contract=NULL)」,
  395 + // enqueueTaskRun 那段 best-effort 写入不该因为它是 NULL 就抛出、拖累入队本身。
  396 + const goalContract = require('../../services/goal-contract');
  397 + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' });
  398 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  399 + const child = wdb.createTask({
  400 + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
  401 + acceptanceContract: MIN_ACCEPTANCE,
  402 + });
  403 + // 模拟存量缺口:直接改列为 NULL(正常创建路径已经不允许这个状态)
  404 + wdb.getDb().prepare(`UPDATE tasks SET acceptance_contract = NULL WHERE id = ?`).run(child.id);
  405 +
  406 + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`);
  407 + expect(status).toBe(201);
  408 + const runDir = goalContract.resolveRunDir(CID, body.runId);
  409 + expect(goalContract.locateContract(runDir)).toBeNull();
  410 + });
285 }); 411 });
server/tests/unit/task-decomposition-validator-gate.test.js
@@ -156,6 +156,8 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; { @@ -156,6 +156,8 @@ describe(&#39;43 号文档 Phase 3&#39;, () =&gt; {
156 wdb.createTask({ 156 wdb.createTask({
157 goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id, 157 goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id,
158 subtaskKind: 'decomposed_work', 158 subtaskKind: 'decomposed_work',
  159 + // 48 号文档:decomposed_work 创建时必须带验收契约(benchmark 段本身,不是 wrapper)
  160 + acceptanceContract: { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] },
159 }); 161 });
160 162
161 const out = await runValidator(companyId, parent.id); 163 const out = await runValidator(companyId, parent.id);
system/agents/subtask-planning/agent.md
@@ -3,15 +3,16 @@ @@ -3,15 +3,16 @@
3 ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据) 3 ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据)
4 4
5 1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定: 5 1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定:
6 - `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}], "confidence": "high"}` 6 + `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": [], "acceptance": {"dimensions": [{"name": "…", "weight": 1, "pass_threshold": 80}], "overall_pass_threshold": 80}}], "confidence": "high"}`
7 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。 7 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。
8 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** 8 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。**
9 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。 9 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。
10 -5. **只输出这四个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 10 +5. **只输出这五个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。
11 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。 11 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。
12 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。 12 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。
13 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。 13 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。
14 9. **`confidence` 如实给 `"high"` 或 `"low"`。** 这是你对"这份分解靠不靠谱"的自评:看到的信息(task 描述 + 提示词里若列出的相关经验/调研参考)不足以让你有把握时给 `"low"`——系统会据此帮你多查一轮资料再问你一次。缺经验/调研参考不等于要给 `"low"`:常规、你自己判断力就够用的任务,正常给 `"high"`。往低了报没有代价,往高了虚报会让一份没底的分解被当成有依据的参考传下去。 14 9. **`confidence` 如实给 `"high"` 或 `"low"`。** 这是你对"这份分解靠不靠谱"的自评:看到的信息(task 描述 + 提示词里若列出的相关经验/调研参考)不足以让你有把握时给 `"low"`——系统会据此帮你多查一轮资料再问你一次。缺经验/调研参考不等于要给 `"low"`:常规、你自己判断力就够用的任务,正常给 `"high"`。往低了报没有代价,往高了虚报会让一份没底的分解被当成有依据的参考传下去。
  15 +10. **每个 step 都要带 `acceptance`(这一步的验收标准)。** 形状对齐 `goal-contract.js` 的 `benchmark` 段:`dimensions` 至少一条,每条 `{"name": "…", "weight": …, "pass_threshold": …}`,`weight` 之和必须等于 1,`pass_threshold`/`overall_pass_threshold` 是 0-100 的数字判分线。**不许从 `why` 字段机械转写**——那多半会写出"基本可用/尽量完善"这类不可度量措辞,会被直接拒收。要真的想清楚"这一步做完该长什么样、可以按什么维度、什么标准打分",跟想 `why`(为什么要做这一步)是两件不同的事。
15 16
16 > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。 17 > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。
17 18
@@ -44,3 +45,7 @@ @@ -44,3 +45,7 @@
44 ## 你可能会看到两种提示词(Phase 2,46 号文档) 45 ## 你可能会看到两种提示词(Phase 2,46 号文档)
45 46
46 系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。 47 系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。
  48 +
  49 +## 你交的 `acceptance` 有时会被直接拿去判卷(48 号文档)
  50 +
  51 +如果这条 task 走的是"CEO 直接派发"这条轻量链路(没有 `org-planner` 的 brd/prd/sdd 仪式),你交的每一步 `acceptance` 会被系统原样写成这一步子任务的验收契约(`acceptance.json`),`org-validator` 完全靠它判断这一步算不算做完——不会有人事后帮你补一份更周全的,写得不合规或空泛,这一步就通不过验收。这条链路对你**透明**:你不需要知道走的是哪条链路,也不用区分对待——`acceptance` 本来就该认真写,不管它最终有没有被真正拿去判卷。