Commit 2bc73131c87229d7e313fdb7104d9b93f9bb66c6
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
Showing
8 changed files
with
327 additions
and
33 deletions
docs/README.md
| ... | ... | @@ -64,7 +64,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d |
| 64 | 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 | 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 | 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 | 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 | 70 | ## todo/future/(暂缓,非当前重心) | ... | ... |
docs/todo/system-design/48-task-acceptance-contract-binding.md
| 1 | 1 | # 48 · Task/Subtask 验收契约 1:1 强绑定 |
| 2 | 2 | |
| 3 | -> **状态**: 📝 讨论收敛,未实现。 | |
| 3 | +> **状态**: ✅ 已实现(主力机制,decomposed_work 链路)。安全网仍只是原则,未写代码。 | |
| 4 | 4 | > **日期**: 2026-09-02 |
| 5 | 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 | 51 | |
| 52 | 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 | 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 | 85 | | `server/services/goal-contract.js` | 大概率不用改——`writeContract`/`writeContractSection`/`validateContract`/`SECTIONS` 已经导出,直接复用 | |
| 84 | 86 | | 测试 | `createTask` 校验(新增/非法值拒绝);`materializeDecomposedSubtasks` 正确落 `acceptance_contract` 字段;`enqueueTaskRun` 正确把它写进新 run 的 `artifacts/acceptance.json`(可以直接读文件断言);一条端到端回归——真的走一遍轻量图,确认 `org-validator` 这次能读到契约、不再 `input_underspecified` | |
| 85 | 87 | |
| 86 | -### 一处待定,写实现前要先定案 | |
| 88 | +### 一处待定,写实现前要先定案 —— ✅ 已定案:`benchmark` 段 | |
| 87 | 89 | |
| 88 | 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 | 104 | 1. `createTask` 不给 `acceptance_contract` 直接创建 `decomposed_work` 子任务——应该被拒绝(如果按主力机制彻底做,`decomposed_work` 这个类型应该强制要求这个字段非空)。 |
| 101 | 105 | 2. 真实回归:重复 46 号文档 Phase 3 v2 那次卡死的场景(单位换算任务的轻量图),这次 `org-validator` 应该能读到 `artifacts/acceptance.json`、真正跑一次 `goal-contract.js validate` + `run`,不再是 `input_underspecified`。 |
| 102 | 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 | 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 | 1179 | const task = wdb.updateTask(taskRow.id, { |
| 1155 | 1180 | status: 'queued', |
| 1156 | 1181 | currentRunId: runId, | ... | ... |
server/services/subtask-planning.js
| ... | ... | @@ -25,6 +25,13 @@ |
| 25 | 25 | * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。 |
| 26 | 26 | * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的—— |
| 27 | 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 | 37 | const path = require('path'); |
| ... | ... | @@ -108,7 +115,10 @@ function planSubtasks(ctx) { |
| 108 | 115 | * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行 |
| 109 | 116 | * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string, |
| 110 | 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 | 123 | async function defaultPlanSubtasks(ctx) { |
| 114 | 124 | const agentName = resolvePlanningAgentName(ctx.companyId); |
| ... | ... | @@ -221,18 +231,28 @@ function buildSubtaskPlanningPrompt(ctx, extra = {}) { |
| 221 | 231 | lines.push( |
| 222 | 232 | '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', |
| 223 | 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 | 238 | `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`, |
| 227 | 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 | 245 | '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)' |
| 230 | 246 | + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划' |
| 231 | 247 | + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。', |
| 232 | - '- 只输出这两个顶层字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。', | |
| 248 | + '- 只输出这两个顶层字段(`steps`/`confidence`)。想法写在 JSON 代码块**前面**的正文里,' | |
| 249 | + + '代码块之后不要再写任何字。', | |
| 233 | 250 | '', |
| 234 | 251 | '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,' |
| 235 | 252 | + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。' |
| 253 | + + '**`acceptance` 是例外**:如果这条 task 走的是不经过 org-planner 的轻量派发链路,' | |
| 254 | + + '你交的 `acceptance` 会被原样写成这一步的验收契约,`org-validator` 直接拿它判卷——' | |
| 255 | + + '写得没用的后果不再只是"没人看",而是这一步真的会通不过验收(48 号文档)。' | |
| 236 | 256 | ); |
| 237 | 257 | return lines.join('\n'); |
| 238 | 258 | } |
| ... | ... | @@ -326,11 +346,18 @@ function normalizeSteps(raw) { |
| 326 | 346 | const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN; |
| 327 | 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 | 355 | return { |
| 330 | 356 | seq: idx + 1, |
| 331 | 357 | title: o.title.slice(0, 200), |
| 332 | 358 | why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000), |
| 333 | 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 | 569 | -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 |
| 570 | 570 | -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 |
| 571 | 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 | 582 | created_at TEXT NOT NULL, |
| 573 | 583 | updated_at TEXT NOT NULL, |
| 574 | 584 | queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) |
| ... | ... | @@ -664,6 +674,15 @@ function getDb() { |
| 664 | 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 | 686 | // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 |
| 668 | 687 | if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { |
| 669 | 688 | db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); |
| ... | ... | @@ -1886,6 +1905,11 @@ function validateDependsOn(goalId, taskId, dependsOn) { |
| 1886 | 1905 | * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。 |
| 1887 | 1906 | * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic' |
| 1888 | 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 | 1913 | * @returns {object} 刚插入的整行 |
| 1890 | 1914 | */ |
| 1891 | 1915 | function createTask({ |
| ... | ... | @@ -1903,6 +1927,7 @@ function createTask({ |
| 1903 | 1927 | parentTaskId = null, |
| 1904 | 1928 | acceptanceMode = 'holistic', |
| 1905 | 1929 | subtaskKind = null, |
| 1930 | + acceptanceContract = null, | |
| 1906 | 1931 | } = {}) { |
| 1907 | 1932 | if (!goalId) throw new Error('createTask: goalId 必填'); |
| 1908 | 1933 | if (!title) throw new Error('createTask: title 必填'); |
| ... | ... | @@ -1923,6 +1948,32 @@ function createTask({ |
| 1923 | 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 | 1977 | const db = getDb(); |
| 1927 | 1978 | const goal = getGoal(goalId); |
| 1928 | 1979 | if (!goal) throw new Error(`createTask: goal 不存在: ${goalId}`); |
| ... | ... | @@ -1953,8 +2004,8 @@ function createTask({ |
| 1953 | 2004 | const ins = db.prepare(` |
| 1954 | 2005 | INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, |
| 1955 | 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 | 2010 | const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( |
| 1960 | 2011 | tid, goalId, String(cid), nextSeq, String(title), String(description), |
| ... | ... | @@ -1962,7 +2013,7 @@ function createTask({ |
| 1962 | 2013 | // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 |
| 1963 | 2014 | // goal 上有就继承下来,别让每个调用方各记一遍。 |
| 1964 | 2015 | reportTo || goal.report_to || null, |
| 1965 | - createdBy, parentTaskId, acceptanceMode, subtaskKind, now, now | |
| 2016 | + createdBy, parentTaskId, acceptanceMode, subtaskKind, acceptanceContractText, now, now | |
| 1966 | 2017 | )); |
| 1967 | 2018 | return getTask(id); |
| 1968 | 2019 | } |
| ... | ... | @@ -2041,6 +2092,7 @@ const TASK_PATCH_COLUMNS = { |
| 2041 | 2092 | archivedAt: 'archived_at', |
| 2042 | 2093 | summary: 'summary', |
| 2043 | 2094 | executionConvId: 'execution_conv_id', |
| 2095 | + acceptanceContract: 'acceptance_contract', | |
| 2044 | 2096 | }; |
| 2045 | 2097 | |
| 2046 | 2098 | /** |
| ... | ... | @@ -2050,8 +2102,12 @@ const TASK_PATCH_COLUMNS = { |
| 2050 | 2102 | */ |
| 2051 | 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 | 2113 | * 改 task。 |
| ... | ... | @@ -2198,8 +2254,15 @@ function settleSubtaskResult(taskId, status, summary = null) { |
| 2198 | 2254 | * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用, |
| 2199 | 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 | 2267 | function materializeDecomposedSubtasks({ task, steps }) { |
| 2205 | 2268 | if (!task || !Array.isArray(steps) || !steps.length) return []; |
| ... | ... | @@ -2210,27 +2273,36 @@ function materializeDecomposedSubtasks({ task, steps }) { |
| 2210 | 2273 | if (already > 0) return []; |
| 2211 | 2274 | |
| 2212 | 2275 | const seqToId = new Map(); |
| 2276 | + const materialized = []; | |
| 2213 | 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 | 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 | 2300 | const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : []; |
| 2229 | 2301 | const deps = hints.map((h) => seqToId.get(h)).filter(Boolean); |
| 2230 | 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 = '/tmp/test-task-decomp-dispatch.sqlite'; |
| 20 | 20 | const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; |
| 21 | 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 | 29 | function ensureAgent(wdb, id) { |
| 24 | 30 | const db = wdb.getDb(); |
| 25 | 31 | const now = new Date().toISOString(); |
| ... | ... | @@ -68,12 +74,61 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { |
| 68 | 74 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); |
| 69 | 75 | const child = wdb.createTask({ |
| 70 | 76 | goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', |
| 77 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 71 | 78 | }); |
| 72 | 79 | expect(child.subtask_kind).toBe('decomposed_work'); |
| 73 | 80 | expect(child.status).toBe('draft'); // 没有专属状态了 |
| 74 | 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 | 132 | // ── materializeDecomposedSubtasks ───────────────────────────────── |
| 78 | 133 | |
| 79 | 134 | describe('materializeDecomposedSubtasks', () => { |
| ... | ... | @@ -85,9 +140,9 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { |
| 85 | 140 | it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => { |
| 86 | 141 | const task = setupTask('co-p3-2'); |
| 87 | 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 | 147 | const ids = wdb.materializeDecomposedSubtasks({ task, steps }); |
| 93 | 148 | expect(ids).toHaveLength(3); |
| ... | ... | @@ -104,7 +159,7 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { |
| 104 | 159 | |
| 105 | 160 | it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { |
| 106 | 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 | 163 | const ids = wdb.materializeDecomposedSubtasks({ task, steps }); |
| 109 | 164 | expect(ids).toHaveLength(1); |
| 110 | 165 | expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); |
| ... | ... | @@ -112,7 +167,7 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { |
| 112 | 167 | |
| 113 | 168 | it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { |
| 114 | 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 | 171 | const first = wdb.materializeDecomposedSubtasks({ task, steps }); |
| 117 | 172 | expect(first).toHaveLength(1); |
| 118 | 173 | const second = wdb.materializeDecomposedSubtasks({ task, steps }); |
| ... | ... | @@ -137,10 +192,33 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { |
| 137 | 192 | subtaskKind: 'acceptance_item', |
| 138 | 193 | }); |
| 139 | 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 | 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 | 224 | // ── createDecomposedWorkGraph ────────────────────────────────────── |
| ... | ... | @@ -269,6 +347,7 @@ describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', |
| 269 | 347 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); |
| 270 | 348 | const child = wdb.createTask({ |
| 271 | 349 | goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', |
| 350 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 272 | 351 | }); |
| 273 | 352 | |
| 274 | 353 | const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); |
| ... | ... | @@ -282,4 +361,51 @@ describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', |
| 282 | 361 | .all(run.graph_id); |
| 283 | 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('43 号文档 Phase 3', () => { |
| 156 | 156 | wdb.createTask({ |
| 157 | 157 | goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id, |
| 158 | 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 | 163 | const out = await runValidator(companyId, parent.id); | ... | ... |
system/agents/subtask-planning/agent.md
| ... | ... | @@ -3,15 +3,16 @@ |
| 3 | 3 | ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据) |
| 4 | 4 | |
| 5 | 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 | 7 | 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。 |
| 8 | 8 | 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** |
| 9 | 9 | 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。 |
| 10 | -5. **只输出这四个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 | |
| 10 | +5. **只输出这五个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 | |
| 11 | 11 | 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。 |
| 12 | 12 | 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。 |
| 13 | 13 | 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。 |
| 14 | 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 | 17 | > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。 |
| 17 | 18 | |
| ... | ... | @@ -44,3 +45,7 @@ |
| 44 | 45 | ## 你可能会看到两种提示词(Phase 2,46 号文档) |
| 45 | 46 | |
| 46 | 47 | 系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。 |
| 48 | + | |
| 49 | +## 你交的 `acceptance` 有时会被直接拿去判卷(48 号文档) | |
| 50 | + | |
| 51 | +如果这条 task 走的是"CEO 直接派发"这条轻量链路(没有 `org-planner` 的 brd/prd/sdd 仪式),你交的每一步 `acceptance` 会被系统原样写成这一步子任务的验收契约(`acceptance.json`),`org-validator` 完全靠它判断这一步算不算做完——不会有人事后帮你补一份更周全的,写得不合规或空泛,这一步就通不过验收。这条链路对你**透明**:你不需要知道走的是哪条链路,也不用区分对待——`acceptance` 本来就该认真写,不管它最终有没有被真正拿去判卷。 | ... | ... |