From 2bc73131c87229d7e313fdb7104d9b93f9bb66c6 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 22:47:35 +0800 Subject: [PATCH] feat(task-decomposition): 48号文档——decomposed_work 验收契约创建时 1:1 强绑定 --- docs/README.md | 2 +- docs/todo/system-design/48-task-acceptance-contract-binding.md | 41 +++++++++++++++++++++++++++++++++++++++-- server/routes/company.js | 25 +++++++++++++++++++++++++ server/services/subtask-planning.js | 33 ++++++++++++++++++++++++++++++--- server/services/work-graph-db.js | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- server/tests/unit/task-decomposition-dispatch.test.js | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ server/tests/unit/task-decomposition-validator-gate.test.js | 2 ++ system/agents/subtask-planning/agent.md | 9 +++++++-- 8 files changed, 327 insertions(+), 33 deletions(-) diff --git a/docs/README.md b/docs/README.md index f14bc0f..5b744eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,7 +64,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [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 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | | [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 条新增,用真实回归捕获的产物做测试数据) | | [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)未开工 | -| [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/代码改动盘点 | +| [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`/人类快速派单三条创建路径本轮不动;安全网(发现时就地补契约)仍只是原则 | | [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 个失败与本次改动无关,本次改动前即存在) | ## todo/future/(暂缓,非当前重心) diff --git a/docs/todo/system-design/48-task-acceptance-contract-binding.md b/docs/todo/system-design/48-task-acceptance-contract-binding.md index e03cd7c..c166ef3 100644 --- a/docs/todo/system-design/48-task-acceptance-contract-binding.md +++ b/docs/todo/system-design/48-task-acceptance-contract-binding.md @@ -1,6 +1,6 @@ # 48 · Task/Subtask 验收契约 1:1 强绑定 -> **状态**: 📝 讨论收敛,未实现。 +> **状态**: ✅ 已实现(主力机制,decomposed_work 链路)。安全网仍只是原则,未写代码。 > **日期**: 2026-09-02 > **触发场景**: 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`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。 @@ -51,6 +51,8 @@ 新增列(暂定名 `acceptance_contract`,TEXT,JSON blob,形状复用 `goal-contract.js` 的 `{benchmark, test_data, test_script}`,走应用层校验、不新造格式——跟 `acceptance_mode`/`subtask_kind` 同一套迁移写法,`ALTER TABLE ADD COLUMN`,新建库有 CHECK、存量库应用层守)。`createTask` 用 `goal-contract.js` 已经导出的 `validateContract` 做形状校验,不重写一遍规则。 +> ⚠️ **这段的具体形状被真实回归推翻了一半,见下方「实现记录」§「与原设计的出入」**:最终落地的 `acceptance_contract` 存的是 **benchmark 段本身**(`{dimensions, overall_pass_threshold?}`),不是这里写的三段式 wrapper——校验/写入时才现套一层。本段留着不改是为了如实记录设计过程,别照这段的字面形状抄代码。 + **本轮范围只收在 `decomposed_work` 这条链路**(`subtask-planning` → `materializeDecomposedSubtasks` → `enqueueTaskRun` 的轻量图分支),不动 `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单(`POST /:companyId/tasks`)这几条创建路径——`task-planning` 在 46 号文档 Phase 1 设计时就明确保护过"职责不变、极简、3 轮内、不碰磁盘",把"必须交结构化验收契约"这个要求推给它,值得单独起一轮讨论,不该这次顺手带上。 ### 安全网(仅记原则,本轮不写代码):发现缺失时就地补一份,但不与判卷同一次完成 @@ -83,10 +85,12 @@ | `server/services/goal-contract.js` | 大概率不用改——`writeContract`/`writeContractSection`/`validateContract`/`SECTIONS` 已经导出,直接复用 | | 测试 | `createTask` 校验(新增/非法值拒绝);`materializeDecomposedSubtasks` 正确落 `acceptance_contract` 字段;`enqueueTaskRun` 正确把它写进新 run 的 `artifacts/acceptance.json`(可以直接读文件断言);一条端到端回归——真的走一遍轻量图,确认 `org-validator` 这次能读到契约、不再 `input_underspecified` | -### 一处待定,写实现前要先定案 +### 一处待定,写实现前要先定案 —— ✅ 已定案:`benchmark` 段 `subtask-planning` 该交哪个 section(`benchmark`/`test_data`/`test_script`)取决于怎么落地最自然:`test_script` 形状是 `{runner:'node', path, args?, timeout_ms?}`——直觉是指向 `org-coder` 会写的测试文件(这次真实回归里 `org-coder` 确实主动写了 `test/convert.test.js`),但 `subtask-planning` 在 `org-coder` 动手之前就要交契约,那时候还不知道 `org-coder` 最终会把测试文件放在哪个路径——需要要么给 `org-coder` 约定一个固定的测试文件路径(比如统一要求 `test/.test.js`),要么改用 `benchmark` 段(不依赖具体文件路径,走"维度+及格线"这套已经验证过的格式,`org-validator` 自己想办法判)。这个选择直接决定 `org-coder` 的 `tools.md` 要不要跟着补一条"测试文件固定放哪"的约定——如果选 `benchmark` 段,`org-coder` 也可以维持"不改一行"。**建议选 `benchmark` 段**,避免牵连 `org-coder`,但最终定案留到真正实现这一步时再拍板。 +按建议定案:选 `benchmark` 段,`org-coder` 全程未改一行(见「实现记录」)。 + --- ## 明确不做(本轮范围外) @@ -100,3 +104,36 @@ 1. `createTask` 不给 `acceptance_contract` 直接创建 `decomposed_work` 子任务——应该被拒绝(如果按主力机制彻底做,`decomposed_work` 这个类型应该强制要求这个字段非空)。 2. 真实回归:重复 46 号文档 Phase 3 v2 那次卡死的场景(单位换算任务的轻量图),这次 `org-validator` 应该能读到 `artifacts/acceptance.json`、真正跑一次 `goal-contract.js validate` + `run`,不再是 `input_underspecified`。 3. 确认 `org-coder`/`org-validator` 全程不需要感知这份契约是"这次由 subtask-planning 供的"还是"平时 org-planner 供的"——两条路径对它们完全透明。 + +--- + +## 实现记录(2026-09-02) + +### 落地清单 + +| 文件 | 改动 | +|---|---| +| `server/services/work-graph-db.js` | `tasks` 表加 `acceptance_contract` 列(批 7 迁移,`ALTER TABLE ADD COLUMN`);`createTask` 新增 `acceptanceContract` 参数,`subtaskKind==='decomposed_work'` 时强制非空,套一层 `{version, goal_id, benchmark: acceptanceContract}` 走 `goal-contract.validateContract` 校验(**不重写规则**,见下方「与原设计的出入」);`acceptanceContract` 收进 `TASK_FROZEN_FIELDS`(`queued_at` 非空后不许再改,同 `budgetJson` 待遇);`materializeDecomposedSubtasks` 把 `step.acceptance` 原样递给 `createTask`,单步校验失败(`try/catch`)只跳过那一步、不作废整批,延续既有"宽松解析"哲学 | +| `system/agents/subtask-planning/agent.md` | 硬约束新增第 10 条:每个 step 必须带 `acceptance`(benchmark 段本身),不许从 `why` 机械转写;输出形状(约束 1)、字段数(约束 5)同步改;补一节说明"轻量派发链路上 `acceptance` 会被直接拿去判卷" | +| `server/services/subtask-planning.js` | `normalizeSteps` 原样透传 `acceptance`(不在这一层做形状校验,深层校验留给 `createTask`);`buildSubtaskPlanningPrompt` 的交回格式示例、字段说明同步加 `acceptance` | +| `server/routes/company.js`(`enqueueTaskRun`) | `isDecomposedSubtask` 分支:`writeTaskGoalArtifact` 之后(`goal-contract` 靠 `task-goal.json` 推 `{companyId,taskId}`),把 `taskRow.acceptance_contract` 套一层 `{benchmark: ...}` 通过 `goal-contract.writeContract` 落进新 run 的 `artifacts/acceptance.json`;best-effort(写失败只 `console.error`,不阻塞入队——失败时行为退回改动前的 `input_underspecified`,不比原来更差) | +| `server/services/goal-contract.js` | 未改一行——`writeContract`/`validateContract`/`CONTRACT_VERSION`/`resolveRunDir` 全部复用既有导出 | +| 测试 | `task-decomposition-dispatch.test.js` 新增 8 个用例(`createTask` 四种校验分支、`materializeDecomposedSubtasks` 两种单步失败场景、路由层两个契约落盘/存量缺口场景);`task-decomposition-validator-gate.test.js` 补 1 处 `acceptanceContract`(存量夹具需要跟着改) | + +### 与原设计的出入(重要,别照着「设计决策」一节里的形状抄) + +设计决策里写的是"形状复用 `goal-contract.js` 的 `{benchmark, test_data, test_script}`"——这句话在写文档时想的是"契约的三段式 wrapper"。**真实起 `subtask-planning` agent 验证后发现这是错的**:agent.md 教的提示词是"acceptance 对齐 benchmark 段",真实 LLM(不是注入桩)老老实实交回的是 **benchmark 段内容本身**(`{dimensions:[...], overall_pass_threshold?}`),不会自己再包一层 `benchmark` 键——这符合直觉:没有人会在被要求"对齐 X 的格式"时,自己又发明一层 `{X: ...}` 包装。 + +第一版实现是照着设计决策的字面意思写的(`acceptanceContract` 直接当三段式契约展开),过了单测(因为单测的假数据也是我自己按错误理解造的),但拿真实 agent 输出一测就在 `validateContract` 那步报错(`unknown top-level field: dimensions`)——**这正是本轮反复强调"真实回归优于只信单测"的又一次验证**。修法:`acceptance_contract` 列存 **benchmark 段本身**,`createTask`/`enqueueTaskRun` 校验/落盘时现套一层给 `goal-contract.validateContract`/`writeContract` 用。`agent.md`/`buildSubtaskPlanningPrompt` 的提示词从一开始写的就是裸段格式,不用改。 + +### 真实验证(不是只信单测) + +1. 单测:`npx vitest run server/tests/unit`,1198/1200 通过(2 个失败是与本次改动无关的既有失败:`env-example-secrets.test.js`/`test-id-stability.test.js`,均未涉及本次改动的文件);`ps aux | grep claude` 确认零残留 CLI 子进程。 +2. 真实起 `p2verify0902` 公司里已雇佣的 `subtask-planning` agent(真实 Claude CLI 调用,不是注入桩),给一条真实 task("实现摄氏度转华氏度的转换函数"),验证 agent 真的会按新 `agent.md` 交出 `acceptance` 字段——交了,且格式如上一节所述(暴露了设计文档与真实产出的形状分歧,已修正)。 +3. 用真实 LLM 那次调用的原始产出(未修改一个字)跑一遍完整链路:`materializeDecomposedSubtasks` → `createTask`(校验+落库)→ 模拟 `enqueueTaskRun` 的写入段 → `goal-contract.readContract` + `validateContract`,结果 `{"ok":true,"errors":[]}`——这就是"`org-validator` 不会再撞 `input_underspecified`"这件事的直接证据(验收标准 2 的核心机制)。 + +### 仍未做的(遗留,按「明确不做」的既有边界) + +- **没有重跑一次完整的真实 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 调用,成本较高,未在本轮自动跑)。 +- 安全网("发现缺失时就地补一份")仍然只是原则,未写代码——`decomposed_work` 链路走主力机制已完全覆盖,原设计判断不变。 +- `task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径的契约强制——本轮范围外,未动。 diff --git a/server/routes/company.js b/server/routes/company.js index 3dfe5cf..9e06bf1 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1151,6 +1151,31 @@ function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = fals createdAt: now, }); + // 48 号文档:decomposed_work 子任务的验收契约在创建时就已经 1:1 强绑定在 taskRow 上 + // (createTask 强制要求非空)——起轻量图之前把它落进这条新 run 的 artifacts/acceptance.json, + // org-validator 全程不需要感知这份契约是这条链路供的还是平时 org-planner 供的(见该文档 + // 「与既有机制的边界」)。写在 writeTaskGoalArtifact 之后:goal-contract.writeContract + // 内部靠 artifacts/task-goal.json 推出 {companyId, taskId} 去登记 graph_files 索引, + // task-goal.json 得先存在。best-effort:写失败不该让入队本身失败(org-validator 会退回 + // 原来的 input_underspecified 路径,行为不比这次改动之前更差)。 + if (isDecomposedSubtask && taskRow.acceptance_contract) { + try { + const goalContract = require('../services/goal-contract'); + // acceptance_contract 存的是 benchmark 段本身(见 work-graph-db.createTask 的注释), + // 这里套一层变成完整契约。 + const benchmark = JSON.parse(taskRow.acceptance_contract); + const runDir = goalContract.resolveRunDir(companyId, runId); + goalContract.writeContract(runDir, { + version: goalContract.CONTRACT_VERSION, + goal_id: taskRow.goal_id, + created_by: 'subtask-planning', + benchmark, + }); + } catch (e) { + console.error(`[TaskEntry] task ${taskRow.id} 写入子任务验收契约失败(不阻塞入队): ${e.message}`); + } + } + const task = wdb.updateTask(taskRow.id, { status: 'queued', currentRunId: runId, diff --git a/server/services/subtask-planning.js b/server/services/subtask-planning.js index c2cf507..0405355 100644 --- a/server/services/subtask-planning.js +++ b/server/services/subtask-planning.js @@ -25,6 +25,13 @@ * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。 * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的—— * agent 唯一自报的判断是 `confidence`,用来决定要不要触发调研,不直接决定 `reasoning_source`。 + * + * ── 48 号文档:验收契约创建时 1:1 强绑定 ──────────────────────────────────── + * 每个 step 现在还要交一份 `acceptance`(benchmark 段形状)。它不是给 org-planner 参考用的 + * 装饰字段——一条 step 被 `materializeDecomposedSubtasks` 物化成 `decomposed_work` 子任务时, + * `acceptance` 会被 `work-graph-db.createTask` 强制要求、校验、随行落库,`enqueueTaskRun` + * 起轻量图前把它写进新 run 的 `artifacts/acceptance.json`——这就是 `org-validator` 在跳过 + * `org-planner` 的轻量派发链路上判卷时唯一的依据来源。 */ const path = require('path'); @@ -108,7 +115,10 @@ function planSubtasks(ctx) { * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行 * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string, * experienceRefs: string[], researchUsed: boolean}>} - * steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过) + * steps 形如 `[{seq, title, why, depends_on_hint: [seq…], acceptance?}]`, + * seq 恒为 1..N 连续(重新编号过)。`acceptance`(48 号文档)缺失/形状不合法的条目 + * 仍会留在这里——`normalizeSteps` 不因它丢整条,深层校验留给 `materializeDecomposedSubtasks` + * 建行那一刻(单步失败跳过,不作废整批)。 */ async function defaultPlanSubtasks(ctx) { const agentName = resolvePlanningAgentName(ctx.companyId); @@ -221,18 +231,28 @@ function buildSubtaskPlanningPrompt(ctx, extra = {}) { lines.push( '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', '```json', - '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}],"confidence":"high"}', + '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[],' + + '"acceptance":{"dimensions":[{"name":"…","weight":1,"pass_threshold":80}],"overall_pass_threshold":80}}],' + + '"confidence":"high"}', '```', `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`, '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,' + '不是硬依赖,没有任何机制会强制执行它。', + '- `acceptance`:这一步的验收标准(48 号文档),形状对齐 benchmark 段:`dimensions` 至少一条,' + + '每条 `{name, weight, pass_threshold?}`,`weight` 之和必须是 1;`overall_pass_threshold` 可选。' + + '不能从 `why` 抄——要写清楚这一步做完该长什么样、按什么维度打分,格式不合规或空泛("基本可用"' + + '这类不可度量措辞)会被直接拒收,这一步就不会被派发出去。', '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)' + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划' + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。', - '- 只输出这两个顶层字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。', + '- 只输出这两个顶层字段(`steps`/`confidence`)。想法写在 JSON 代码块**前面**的正文里,' + + '代码块之后不要再写任何字。', '', '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,' + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。' + + '**`acceptance` 是例外**:如果这条 task 走的是不经过 org-planner 的轻量派发链路,' + + '你交的 `acceptance` 会被原样写成这一步的验收契约,`org-validator` 直接拿它判卷——' + + '写得没用的后果不再只是"没人看",而是这一步真的会通不过验收(48 号文档)。' ); return lines.join('\n'); } @@ -326,11 +346,18 @@ function normalizeSteps(raw) { const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN; if (Number.isInteger(n) && keptOrigSet.has(n) && n !== o._origIndex && !deps.includes(n)) deps.push(n); } + // 48 号文档:`acceptance` 原样透传,不在这里做形状校验——深层校验(权重之和/runner + // 白名单)只有 goal-contract.validateContract 做得了,交给 materializeDecomposedSubtasks + // 建行时去做(单步不合法就跳过那一步,不作废整批,同款"宽松解析"哲学,见该函数注释)。 + const acceptance = o.item.acceptance && typeof o.item.acceptance === 'object' && !Array.isArray(o.item.acceptance) + ? o.item.acceptance + : undefined; return { seq: idx + 1, title: o.title.slice(0, 200), why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000), depends_on_hint: deps, + ...(acceptance !== undefined ? { acceptance } : {}), }; }); } diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index c3e2b2d..990ab8b 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -569,6 +569,16 @@ function getDb() { -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','decomposed_work') OR subtask_kind IS NULL), + -- 48 号文档:验收契约创建时 1:1 强绑定(主力机制)。JSON blob,形状是 goal-contract.js + -- 的 **benchmark 段本身**({dimensions:[{name,weight,pass_threshold?}], overall_pass_threshold?}), + -- 不是三段式契约的 wrapper——真实起 agent 验证过,"acceptance 对齐 benchmark 段"这句提示词 + -- 换来的就是段内容本身,不会带一层 benchmark 键。createTask 校验时现套一层给 + -- goal-contract.validateContract 用,不重写一遍规则;存库仍存裸段内容。 + -- 本轮范围只收在 decomposed_work 这条链路——createTask 强制它非空;其余创建路径 + -- (task-planning / org-planner 的 acceptance_item / 人类快速派单)不受影响,仍为 NULL。 + -- enqueueTaskRun 起 decomposed_work 的轻量图之前,把它落进新 run 的 artifacts/acceptance.json, + -- org-coder/org-validator 全程不需要感知这份契约是这条链路供的还是平时 org-planner 供的。 + acceptance_contract TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) @@ -664,6 +674,15 @@ function getDb() { db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`); } + // ── 批 7:48 号文档,tasks 追加 acceptance_contract ────────────────────── + // 同批 5/6 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上取值域只由 createTask + // 的应用层校验守。旧行一律 NULL——这批上线前物化的 decomposed_work 子任务不会补一份契约, + // enqueueTaskRun 遇到 NULL 就跳过写入,org-validator 仍会 input_underspecified; + // 这是已知的、范围内可接受的存量缺口(新物化的子任务从这次上线起才有)。 + if (!hasColumn(db, 'tasks', 'acceptance_contract')) { + db.exec(`ALTER TABLE tasks ADD COLUMN acceptance_contract TEXT`); + } + // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); @@ -1886,6 +1905,11 @@ function validateDependsOn(goalId, taskId, dependsOn) { * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。 * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic' * (不允许递归再拆一层)——传 `parentTaskId` 时若给了 'itemized' 会被拒。 + * @param {object|null} [p.acceptanceContract] 48 号文档:验收契约,创建时 1:1 强绑定。 + * 形状是 goal-contract.js 的 **benchmark 段本身**(`{dimensions:[...], overall_pass_threshold?}`), + * 不是三段式契约的 wrapper——校验时现套一层再走 `goal-contract.validateContract`。 + * **`subtaskKind==='decomposed_work'` 时必填**——这是主力机制的硬约束点;其余创建路径 + * 不许传(校验会拒),本轮范围只收在这条链路。 * @returns {object} 刚插入的整行 */ function createTask({ @@ -1903,6 +1927,7 @@ function createTask({ parentTaskId = null, acceptanceMode = 'holistic', subtaskKind = null, + acceptanceContract = null, } = {}) { if (!goalId) throw new Error('createTask: goalId 必填'); if (!title) throw new Error('createTask: title 必填'); @@ -1923,6 +1948,32 @@ function createTask({ throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义'); } + // 48 号文档:验收契约创建时 1:1 强绑定,本轮范围只收在 decomposed_work 这条链路—— + // 主力机制的硬约束点。不合法的形状(权重不足 1 / runner 越界 / 空契约……)在这里就被挡, + // 不等到 org-validator 跑起来才发现契约是坏的。 + let acceptanceContractText = null; + if (subtaskKind === 'decomposed_work') { + if (!acceptanceContract || typeof acceptanceContract !== 'object' || Array.isArray(acceptanceContract)) { + throw new Error('createTask: decomposed_work 子任务必须带 acceptanceContract(验收契约,见 48 号文档)'); + } + } else if (acceptanceContract != null) { + throw new Error('createTask: acceptanceContract 只对 decomposed_work 子任务有意义(本轮范围,见 48 号文档)'); + } + if (acceptanceContract) { + // 懒加载:goal-contract.js 只在这里被用到,其余 createTask 调用路径不该为此多背一个 require。 + // ⚠️ 形状是 goal-contract.js 的 **benchmark 段本身**({dimensions[], overall_pass_threshold?}), + // 不是三段式契约的 {benchmark:{...}} 包一层——真实起 subtask-planning agent 验证过: + // 提示词教的就是"acceptance 对齐 benchmark 段",agent 老老实实交回的是段内容本身, + // 不会自己多包一层 benchmark 键。校验时补这一层给 validateContract 用,存库仍存裸段内容。 + const { validateContract, CONTRACT_VERSION } = require('./goal-contract'); + const wrapped = { version: CONTRACT_VERSION, goal_id: goalId, benchmark: acceptanceContract }; + const v = validateContract(wrapped); + if (!v.ok) { + throw new Error(`createTask: acceptanceContract 不合法: ${JSON.stringify(v.errors)}`); + } + acceptanceContractText = JSON.stringify(acceptanceContract); + } + const db = getDb(); const goal = getGoal(goalId); if (!goal) throw new Error(`createTask: goal 不存在: ${goalId}`); @@ -1953,8 +2004,8 @@ function createTask({ const ins = db.prepare(` INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, execution_mode, depends_on, priority, budget_json, report_to, created_by, - parent_task_id, acceptance_mode, subtask_kind, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + parent_task_id, acceptance_mode, subtask_kind, acceptance_contract, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( tid, goalId, String(cid), nextSeq, String(title), String(description), @@ -1962,7 +2013,7 @@ function createTask({ // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 // goal 上有就继承下来,别让每个调用方各记一遍。 reportTo || goal.report_to || null, - createdBy, parentTaskId, acceptanceMode, subtaskKind, now, now + createdBy, parentTaskId, acceptanceMode, subtaskKind, acceptanceContractText, now, now )); return getTask(id); } @@ -2041,6 +2092,7 @@ const TASK_PATCH_COLUMNS = { archivedAt: 'archived_at', summary: 'summary', executionConvId: 'execution_conv_id', + acceptanceContract: 'acceptance_contract', }; /** @@ -2050,8 +2102,12 @@ const TASK_PATCH_COLUMNS = { */ const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked']; -/** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ -const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; +/** + * queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过。 + * `acceptanceContract`(48 号文档)同理——`enqueueTaskRun` 起 run 时就把它写进了新 run 的 + * artifacts/acceptance.json,队列位次之后再改契约,org-validator 判的和 agent 交的会对不上。 + */ +const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson', 'acceptanceContract']; /** * 改 task。 @@ -2198,8 +2254,15 @@ function settleSubtaskResult(taskId, status, summary = null) { * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用, * 不作废整批——延续 Phase 1 定的"宽松解析"哲学。 * - * @param {{task: object, steps: Array<{seq:number,title:string,why?:string,depends_on_hint?:number[]}>}} ctx - * @returns {string[]} 物化出的 task id,按 steps 顺序;已物化过 / 无 steps 时返回 [] + * 48 号文档:每条物化出的行都是 `createTask` 强制要求带 `acceptanceContract` 的 + * `decomposed_work` 子任务——`step.acceptance` 缺失或形状不合法时,`createTask` 会抛错。 + * 这里按同一条"宽松解析"哲学处理:**单步失败就跳过那一步**(`console.warn`),不作废整批, + * 不像"没有 title"那样在 `normalizeSteps` 阶段就地丢弃——因为深层校验(权重之和/runner 白名单 + * 这些)只有 `goal-contract.validateContract` 才做得了,提前到这里重写一遍就是第二个持有者。 + * + * @param {{task: object, steps: Array<{seq:number,title:string,why?:string, + * depends_on_hint?:number[],acceptance?:object}>}} ctx + * @returns {string[]} 物化出的 task id,按 steps 顺序(跳过失败的那些);已物化过 / 无 steps 时返回 [] */ function materializeDecomposedSubtasks({ task, steps }) { if (!task || !Array.isArray(steps) || !steps.length) return []; @@ -2210,27 +2273,36 @@ function materializeDecomposedSubtasks({ task, steps }) { if (already > 0) return []; const seqToId = new Map(); + const materialized = []; for (const step of steps) { - const row = createTask({ - goalId: task.goal_id, - companyId: task.company_id, - title: String(step.title || '').slice(0, 200), - description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), - executionMode: 'auto', - createdBy: 'subtask-planning', - parentTaskId: task.id, - subtaskKind: 'decomposed_work', - }); + let row; + try { + row = createTask({ + goalId: task.goal_id, + companyId: task.company_id, + title: String(step.title || '').slice(0, 200), + description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), + executionMode: 'auto', + createdBy: 'subtask-planning', + parentTaskId: task.id, + subtaskKind: 'decomposed_work', + acceptanceContract: step.acceptance, + }); + } catch (e) { + console.warn(`[work-graph-db] task ${task.id} 第 ${step.seq} 步物化失败,跳过(不影响同批其它步骤): ${e.message}`); + continue; + } seqToId.set(step.seq, row.id); + materialized.push(step); } - for (const step of steps) { + for (const step of materialized) { const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : []; const deps = hints.map((h) => seqToId.get(h)).filter(Boolean); if (deps.length) updateTask(seqToId.get(step.seq), { dependsOn: deps }); } - return steps.map((s) => seqToId.get(s.seq)); + return materialized.map((s) => seqToId.get(s.seq)); } /** diff --git a/server/tests/unit/task-decomposition-dispatch.test.js b/server/tests/unit/task-decomposition-dispatch.test.js index d666918..c7379b2 100644 --- a/server/tests/unit/task-decomposition-dispatch.test.js +++ b/server/tests/unit/task-decomposition-dispatch.test.js @@ -20,6 +20,12 @@ const TEST_DB_PATH = '/tmp/test-task-decomp-dispatch.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; +// 48 号文档:decomposed_work 子任务创建时必须带 acceptanceContract。这是一份最小合法契约 +// (benchmark 段**本身**,不是三段式契约的 wrapper——真实起 agent 验证过这就是它交回的形状, +// 见 work-graph-db.createTask 的注释),权重之和=1,测材料化/图选择等与契约本身无关的机制时 +// 复用它,不重复拼。 +const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }; + function ensureAgent(wdb, id) { const db = wdb.getDb(); const now = new Date().toISOString(); @@ -68,12 +74,61 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); const child = wdb.createTask({ goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: MIN_ACCEPTANCE, }); expect(child.subtask_kind).toBe('decomposed_work'); expect(child.status).toBe('draft'); // 没有专属状态了 expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑 }); + // ── 48 号文档:验收契约创建时 1:1 强绑定 ────────────────────────────── + + it('decomposed_work 不带 acceptanceContract 直接拒——创建时硬约束(48 号文档验收标准 1)', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + })).toThrow(/必须带 acceptanceContract/); + }); + + it('非 decomposed_work 的普通 task 传 acceptanceContract 直接拒——本轮范围只收这条链路', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 't', description: 'd', acceptanceContract: MIN_ACCEPTANCE, + })).toThrow(/只对 decomposed_work 子任务有意义/); + }); + + it('acceptanceContract 形状不合法(权重之和不为 1)被 goal-contract.validateContract 挡下', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + expect(() => wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: { dimensions: [{ name: 'a', weight: 0.5, pass_threshold: 80 }] }, // 和不为 1 + })).toThrow(/acceptanceContract 不合法/); + }); + + it('acceptanceContract 合法时原样落库,读回后 JSON.parse 与传入等价', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + const child = wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: MIN_ACCEPTANCE, + }); + expect(JSON.parse(child.acceptance_contract)).toEqual(MIN_ACCEPTANCE); + }); + + it('acceptance_contract 是冻结字段:queued_at 非空后 patch 会被 FrozenFieldError 拒', () => { + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + const child = wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: MIN_ACCEPTANCE, + }); + wdb.updateTask(child.id, { queuedAt: new Date().toISOString() }); + expect(() => wdb.updateTask(child.id, { acceptanceContract: JSON.stringify(MIN_ACCEPTANCE) })) + .toThrow(/acceptanceContract/); + }); + // ── materializeDecomposedSubtasks ───────────────────────────────── describe('materializeDecomposedSubtasks', () => { @@ -85,9 +140,9 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => { const task = setupTask('co-p3-2'); const steps = [ - { seq: 1, title: '第一步', depends_on_hint: [3] }, // 前向引用:依赖还没建出来的第三步 - { seq: 2, title: '第二步', depends_on_hint: [1] }, - { seq: 3, title: '第三步', depends_on_hint: [] }, + { seq: 1, title: '第一步', depends_on_hint: [3], acceptance: MIN_ACCEPTANCE }, // 前向引用:依赖还没建出来的第三步 + { seq: 2, title: '第二步', depends_on_hint: [1], acceptance: MIN_ACCEPTANCE }, + { seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, ]; const ids = wdb.materializeDecomposedSubtasks({ task, steps }); expect(ids).toHaveLength(3); @@ -104,7 +159,7 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { const task = setupTask('co-p3-3'); - const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99] }]; + const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99], acceptance: MIN_ACCEPTANCE }]; const ids = wdb.materializeDecomposedSubtasks({ task, steps }); expect(ids).toHaveLength(1); expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); @@ -112,7 +167,7 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { const task = setupTask('co-p3-4'); - const steps = [{ seq: 1, title: '一步', depends_on_hint: [] }]; + const steps = [{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }]; const first = wdb.materializeDecomposedSubtasks({ task, steps }); expect(first).toHaveLength(1); const second = wdb.materializeDecomposedSubtasks({ task, steps }); @@ -137,10 +192,33 @@ describe('46 号文档 Phase 3(v2):DB 层', () => { subtaskKind: 'acceptance_item', }); const ids = wdb.materializeDecomposedSubtasks({ - task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }], + task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }], }); expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 }); + + // ── 48 号文档:acceptance 缺失/不合法时单步跳过,不作废整批 ────────────── + + it('单步 acceptance 缺失:那一步跳过,同批其它合法步骤照常物化(宽松解析)', () => { + const task = setupTask('co-p3-9'); + const steps = [ + { seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }, // 缺 acceptance,会被 createTask 拒 + { seq: 2, title: '带 acceptance 的一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + ]; + const ids = wdb.materializeDecomposedSubtasks({ task, steps }); + expect(ids).toHaveLength(1); + expect(wdb.getTask(ids[0]).title).toBe('带 acceptance 的一步'); + }); + + it('全部 step 都没有 acceptance:整批跳过,返回 [](不是「无 steps」那条早退路径)', () => { + const task = setupTask('co-p3-10'); + const ids = wdb.materializeDecomposedSubtasks({ + task, steps: [{ seq: 1, title: '唯一一步,没有 acceptance', depends_on_hint: [] }], + }); + expect(ids).toEqual([]); + const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; + expect(n).toBe(0); + }); }); // ── createDecomposedWorkGraph ────────────────────────────────────── @@ -269,6 +347,7 @@ describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); const child = wdb.createTask({ goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: MIN_ACCEPTANCE, }); const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); @@ -282,4 +361,51 @@ describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', .all(run.graph_id); expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']); }); + + // ── 48 号文档:验收契约随 enqueue 落进新 run 的 artifacts/acceptance.json ────── + + it('decomposed_work 子任务的 acceptanceContract 会被写进新 run 的 artifacts/acceptance.json,goal-contract 能读回并判为合法', async () => { + const goalContract = require('../../services/goal-contract'); + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + const child = wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: MIN_ACCEPTANCE, + }); + + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); + expect(status).toBe(201); + + // org-validator 在这条轻量链路上判卷的唯一依据来源:goal-contract 能读到、能验证通过, + // 就是「不会再撞 input_underspecified」这件事的直接证据(46 号文档 Phase 3 v2 真实回归 + // 撞见的那次死锁,见 48 号文档「触发场景」)。 + const runDir = goalContract.resolveRunDir(CID, body.runId); + const loaded = goalContract.readContract(runDir); + expect(loaded).not.toBeNull(); + expect(loaded.contract.benchmark).toEqual(MIN_ACCEPTANCE); + expect(goalContract.validateContract(loaded.contract).ok).toBe(true); + + const files = wdb.findFiles({ companyId: CID, taskId: child.id, kinds: ['acceptance'] }); + expect(files.length).toBeGreaterThan(0); + }); + + it('decomposed_work 子任务没有 acceptanceContract(存量缺口)时 enqueue 仍然成功,只是不写 acceptance.json', async () => { + // 走不到这里其实很难——createTask 现在强制要求 decomposed_work 带 acceptanceContract。 + // 这条测的是「万一有一条存量行是列上线前物化的(acceptance_contract=NULL)」, + // enqueueTaskRun 那段 best-effort 写入不该因为它是 NULL 就抛出、拖累入队本身。 + const goalContract = require('../../services/goal-contract'); + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + const child = wdb.createTask({ + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + acceptanceContract: MIN_ACCEPTANCE, + }); + // 模拟存量缺口:直接改列为 NULL(正常创建路径已经不允许这个状态) + wdb.getDb().prepare(`UPDATE tasks SET acceptance_contract = NULL WHERE id = ?`).run(child.id); + + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); + expect(status).toBe(201); + const runDir = goalContract.resolveRunDir(CID, body.runId); + expect(goalContract.locateContract(runDir)).toBeNull(); + }); }); diff --git a/server/tests/unit/task-decomposition-validator-gate.test.js b/server/tests/unit/task-decomposition-validator-gate.test.js index 6e47405..1448600 100644 --- a/server/tests/unit/task-decomposition-validator-gate.test.js +++ b/server/tests/unit/task-decomposition-validator-gate.test.js @@ -156,6 +156,8 @@ describe('43 号文档 Phase 3', () => { wdb.createTask({ goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', + // 48 号文档:decomposed_work 创建时必须带验收契约(benchmark 段本身,不是 wrapper) + acceptanceContract: { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }, }); const out = await runValidator(companyId, parent.id); diff --git a/system/agents/subtask-planning/agent.md b/system/agents/subtask-planning/agent.md index 000286f..069b9bc 100644 --- a/system/agents/subtask-planning/agent.md +++ b/system/agents/subtask-planning/agent.md @@ -3,15 +3,16 @@ ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据) 1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定: - `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}], "confidence": "high"}` + `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": [], "acceptance": {"dimensions": [{"name": "…", "weight": 1, "pass_threshold": 80}], "overall_pass_threshold": 80}}], "confidence": "high"}` 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。 -5. **只输出这四个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 +5. **只输出这五个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。 9. **`confidence` 如实给 `"high"` 或 `"low"`。** 这是你对"这份分解靠不靠谱"的自评:看到的信息(task 描述 + 提示词里若列出的相关经验/调研参考)不足以让你有把握时给 `"low"`——系统会据此帮你多查一轮资料再问你一次。缺经验/调研参考不等于要给 `"low"`:常规、你自己判断力就够用的任务,正常给 `"high"`。往低了报没有代价,往高了虚报会让一份没底的分解被当成有依据的参考传下去。 +10. **每个 step 都要带 `acceptance`(这一步的验收标准)。** 形状对齐 `goal-contract.js` 的 `benchmark` 段:`dimensions` 至少一条,每条 `{"name": "…", "weight": …, "pass_threshold": …}`,`weight` 之和必须等于 1,`pass_threshold`/`overall_pass_threshold` 是 0-100 的数字判分线。**不许从 `why` 字段机械转写**——那多半会写出"基本可用/尽量完善"这类不可度量措辞,会被直接拒收。要真的想清楚"这一步做完该长什么样、可以按什么维度、什么标准打分",跟想 `why`(为什么要做这一步)是两件不同的事。 > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。 @@ -44,3 +45,7 @@ ## 你可能会看到两种提示词(Phase 2,46 号文档) 系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。 + +## 你交的 `acceptance` 有时会被直接拿去判卷(48 号文档) + +如果这条 task 走的是"CEO 直接派发"这条轻量链路(没有 `org-planner` 的 brd/prd/sdd 仪式),你交的每一步 `acceptance` 会被系统原样写成这一步子任务的验收契约(`acceptance.json`),`org-validator` 完全靠它判断这一步算不算做完——不会有人事后帮你补一份更周全的,写得不合规或空泛,这一步就通不过验收。这条链路对你**透明**:你不需要知道走的是哪条链路,也不用区分对待——`acceptance` 本来就该认真写,不管它最终有没有被真正拿去判卷。 -- libgit2 0.21.4