Commit f64ddc2324c43c9d933d70b681efa8cb286debda

Authored by yibo
1 parent d0b5eb75

feat(task-decomposition): 46号文档 Phase 1 subtask-planning + 47号文档 acceptance.md 兜底

47号:itemized 子任务物化在当前 workgraph-planning 常见子图形状下因 acceptance.md
永远产不出来而形同虚设;maybeMaterializeItemizedSubtasks 新增兜底,缺失时改读
已被 44 号文档证实稳定产出的 goal-benchmark.json 的 dimensions[]。

46号 Phase 1:新 agent subtask-planning,钩在 enqueueTaskRun(显式 opt-in,只在
goal 批量下达/单条 task 下达两处触发,保住 quick task 零 LLM 调用的既有保证);
产物写共享 artifacts/subtask-plan.json,复用 R-19.3 任务级共享索引对接下游。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/README.md
... ... @@ -62,6 +62,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
62 62 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 |
63 63 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 |
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 +| [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 已完成**(2026-09-01)。承接 43 号文档的追问:任务规划应是每个任务默认前置步骤,来源覆盖模型自身能力/知识库经验/线上 deep research。定案:①新增独立"深度规划"阶段 ②规划环节自行判断要不要触发 deep research ③引入任务池+认领机制替代固定 DAG(作用域先限定在深度规划产出的子任务层)④子任务拥有独立开发+验证循环。**Phase 0 真实回归验证结果**:链路能收敛、44 号文档修复确认生效,但坐实了一个新缺陷——`acceptance.md` 在当前 org-planner 子图形状下永远产不出来(`org-planner-benchmark` 产出的是 `goal-benchmark.json`,跟物化子任务依赖的 `acceptance.md` 对不上),`itemized` 物化机制形同虚设,生产库零命中的真实原因就是它。**Phase 1 代码已实现**(2026-09-02):新 agent `subtask-planning`,钩在 `enqueueTaskRun`(收窄为显式 opt-in,只在批量/单条下达两处开,保住"quick task 零 LLM 调用"的既有保证);产物写共享 `artifacts/subtask-plan.json`,复用 R-19.3 任务级共享索引对接下游(比原设计的 prompt-builder 软注入方案更省事);单测 1558 全过。**实现过程中出过一次真实事故**:首版无条件触发导致 `npx vitest run` 真开出 44 个 `claude` CLI 子进程,已加 VITEST 环境防护修复。**还没做真实端到端回归**,任务池作用域边界已定案。Phase 2-5 未开工 |
65 67 | [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 个失败与本次改动无关,本次改动前即存在) |
66 68  
67 69 ## todo/future/(暂缓,非当前重心)
... ...
docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md 0 → 100644
  1 +# 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源
  2 +
  3 +> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-19.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。Phase 2-5 尚未开工
  4 +> **日期**: 2026-09-01
  5 +> **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。
  6 +> **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。
  7 +
  8 +---
  9 +
  10 +## 原始需求(用户原话,一字不改)
  11 +
  12 +> 所以,拆解任务计划这件事情是默认要做的。也就是说,我们做任何一个任务,其实都要有一个 planning(规划)的过程。
  13 +>
  14 +> Planning 过程就是明确这个事情怎么做:第一步干什么,第二步干什么,第三步干什么。这个是一定要规划出来的。而这种规划来源于以下三个地方:
  15 +>
  16 +> 1. 模型本身有规划能力。
  17 +> 2. 来源于我们的知识库、经验库、memory 或 history。
  18 +> 3. 来源于我们去做线上的 Deep Research。去研究怎么更好地实现这个过程,别人是怎么做的,线上有什么 know-how、有什么方法论,怎么做这件事情才是更优解。
  19 +>
  20 +> 比如说,我们怎么做一个视频:视频要不要生成分镜、生成脚本、修改编剧台词?最后怎么视频合成、怎么验证?这些方法论是需要有一个 planning 过程的,这个过程一定是要有的。所以,我们之前没有做任务分解,但并不代表这个事情在现实生活中不存在。既然我们之前的架构上有这个问题,那我们就必须要改变它。
  21 +>
  22 +> 也就是说,我们做任何一个 ORG graphy 或者是 work graphy 的时候,就应该先有 planning 的过程:
  23 +> 1. 分解任务;
  24 +> 2. 所有的 agents 围绕这个任务看板来工作,一层一层地接子任务;
  25 +> 3. 子任务做完之后,再去做主任务的 validate。
  26 +>
  27 +> 人的工作是这样的,那多 Agent 的协同工作也应该是这样的。
  28 +
  29 +---
  30 +
  31 +## 结构化复述
  32 +
  33 +拆成三条独立主张:
  34 +
  35 +1. **规划是默认前置步骤,不是可选判断**——任何一个任务(goal / org graph / work graph 层面)在真正执行前,都必须先产出一份"第一步干什么、第二步干什么"的分解结果。当前架构里"要不要拆"是一次可以判"不需要"的例外判断,这一点本身就是缺陷。
  36 +2. **规划的信息来源要显式覆盖三类**:① 模型自身推理能力,② 内部知识库/经验库/memory/history,③ 外部 deep research(别人怎么做、行业 know-how、方法论)。三者不是互斥,是应该都被用上的三个输入源。
  37 +3. **执行模型要围绕"任务看板"重新组织**:分解出的子任务应该是 agent 们认领、逐层完成的对象;子任务做完之后再做主任务的 validate(先子后主,不是先给主任务判总分)。
  38 +
  39 +---
  40 +
  41 +## 现状调研(本次会话核实,基于 43/44 号文档 + 本次代码交叉核对)
  42 +
  43 +结论:**当前架构在这三条主张上都有真实的、具体的缺口**,不是风格问题。逐条列证据:
  44 +
  45 +### 1. 规划目前是"要不要拆"的保守判断,默认是不拆
  46 +
  47 +`system/agents/task-planning/agent.md` Step 2bis(决定 `acceptance_mode` 的地方)原文态度:
  48 +
  49 +> 判断错的代价是不对称的:错误地写 `"itemized"`(验收标准其实互相依赖)会让后续验收拆出没法独立成立的子任务,比漏写、保守选 `"holistic"` 的代价更高。**拿不准就留 `"holistic"`**。
  50 +
  51 +这与"规划是默认动作"直接相反——现在的默认值是"不规划到子任务颗粒度",只有信心足够时才拆。而且这一步只回答"验收要不要拆",从不回答"这件事第一步干什么、第二步干什么"这种通用意义上的 planning。
  52 +
  53 +### 2. 规划阶段被显式禁止使用知识库/深度调研
  54 +
  55 +`system/agents/task-planning/tools.md`「你**不该**用的工具」表格原文:
  56 +
  57 +> | 联网搜索、读第三方资料 | 拆分的唯一真相来源是 goal 描述原文。去外面找依据,等于给自己发明需求(违反 `agent.md` 硬约束 8) |
  58 +
  59 +这条规则本身有合理的历史动机(防止 LLM 脱离用户原话臆造需求),但结果是:用户设想的三条规划来源里,**第 2 条(知识库/经验库/memory/history)和第 3 条(deep research)在当前唯一的"goal → task"规划入口上被明文禁止**,规划只能来自第 1 条(模型自身能力)。
  60 +
  61 +值得注意:知识库/经验库这条路径**不是完全空白**——`dream` 机制(29 号文档)会把各 agent 的运行经验提炼后写回 `agent.md`/`soul.md` 的专属区块(本对话前半段刚看过 CEO 的这次刷新)。但这是**事后单向沉淀**:写给 agent 在被调用时"顺带看到",不是规划阶段主动查询的机制——task-planning 判断"要不要拆、怎么拆"时,完全不会去读任何 agent 的 dream 沉淀经验。
  62 +
  63 +### 3. 已经存在一套真正做"deep research"的机制,但没有接入日常任务规划
  64 +
  65 +`system/agents/org-hunt`(+ `org-digest`/`org-evolve`)是 41 号文档验证过的一套真实调研机制:用 `l1-web-fetch` 抓取网页、评估数据源权威性、产出方法论。这正是用户设想里"第 3 条来源"的现成实现,而且经过真实跑通验证(41 号文档"11 轮真跑挖出并修复 5 个独立缺陷")。
  66 +
  67 +但它**当前只服务于 FDE 方法论训练场景**(`org-innovator` 评估/训练某个角色的专业能力),只在 `deliverable.json` 这一种 work-graph-template 骨架里被引用,**从未被接入 task-planning 或 org-planner 的常规执行路径**。换句话说:deep research 的能力已经造好了,只是没有被接到"用户下一个任务 → 系统怎么规划"这条主路径上。
  68 +
  69 +### 4. 子任务目前只影响"验收",不影响"执行分工"
  70 +
  71 +43 号文档的落地范围里明确记录了这条设计决策:
  72 +
  73 +> 子任务要不要独立开发+验收循环 | **先只拆验收,开发仍整体完成**——org-coder 一次性写完整个交付物,验收侧改成按子任务(验收条目)逐条判,而不是给总分 | 改动小得多……**独立开发循环(每个子任务各起一个 work-graph run)留作后续再评估**
  74 +
  75 +用户这次的第 2 条主张——"所有的 agents 围绕这个任务看板来工作,一层一层地接子任务"——正是 43 号文档当时明确放弃、留待后续评估的那部分。现在看,"后续"就是现在。
  76 +
  77 +### 5. org/work graph 是预先铺好的固定拓扑,不是"任务池、谁有空谁认领"
  78 +
  79 +现有 org graph / work graph 是建图阶段(`orggraph-planning`/`workgraph-planning`)一次性规划出的 DAG,节点和边在执行前就已固定,执行时严格按拓扑推进。`org-planner` 没有任何"创建 task"的工具(43 号文档已核实),子任务是靠服务端在它产出 `acceptance.md` 之后**物化**出来的,不是 agent 从一个共享任务池里"认领"的。
  80 +
  81 +用户"agents 围绕任务看板逐层接子任务"这句话,字面上更接近一个**动态任务池 + 认领机制**,跟"图在建图那一刻就已经画死"的现有模型有结构性差异——这不是一个小改动,需要先确认要不要走这个方向(见下节问题 C)。
  82 +
  83 +### 6. 即便是现有最小范围的子任务机制,可靠性也还没被坐实
  84 +
  85 +上一轮对话已核实:当前 `data/graph.sqlite` 里 `acceptance_mode='itemized'` 的 task 零条,所有公司运行态目录里都搜不到 `itemized` 字样——43/44 号文档做的那次真实验证用的测试公司数据也已被清理。也就是说,在讨论"要不要扩大规划范围"之前,现有范围内的东西本身还没有一次成功的端到端确认。
  86 +
  87 +---
  88 +
  89 +## 设计决策(2026-09-01 定案)
  90 +
  91 +| 问题 | 决定 | 直接后果 |
  92 +|------|------|----------|
  93 +| A:规划打通到哪一层 | **新增独立的"深度规划"阶段**,插在 `task-planning`(goal→task,保持极简不变)和现有 org-planner 之间 | `task-planning` 职责不变(仍是"要不要多条 task"的粗粒度判断,3 轮内、不碰磁盘)。新阶段专门承接"这件事第一步/第二步/第三步怎么做"+ deep research + 查经验库,产出结构化的分解结果,**取代**现有"org-planner 收尾后才物化子任务"的时序——分解结果要在执行开始前就产出,而不是brd/prd/sdd写完后才回头拆 |
  94 +| B:Deep Research 触发时机 | **规划环节自行判断**——先查经验库/memory/dream 沉淀,查不到或置信度低时才触发调研(复用 `org-hunt`/`org-digest`) | 新阶段需要两类工具权限:① 读 agent 的 `soul.md`/`agent.md` dream 区块 + 跨公司经验(目前没有现成的"查经验库"检索接口,需要新建);② 有条件触发 `org-hunt`(目前只能被 workgraph-planning 挑进固定模板,需要开放为"新阶段可按需插入") |
  95 +| C:执行模型 | **引入任务池 + 认领机制**,替代当前"建图时固定 DAG、严格拓扑推进"的执行方式 | 这是四条里改动面最大的一条:`graph-engine.js` 当前是纯拓扑驱动(`readDefGraph` 一次性给出全部节点和边),需要新增"子任务作为可认领工作项"的调度层——agent(或调度器代 agent)从共享任务看板拉取匹配自己角色、状态为"可认领"的子任务,而不是等图上游节点产出后按边推给固定下游 |
  96 +| D:验证时机 | **子任务拥有独立的开发+验证生命周期**(这次和 A/C 一起做,不再分阶段) | 每个子任务对应一次独立的开发→验证循环(而不是 43 号文档现状的"整体开发、分子验证"),意味着每个被认领的子任务大概率各自触发一次工作流实例(起类似 work-graph run 的调用),成本和并发编排复杂度显著上升,需要有预算/并发槽位控制(可复用 27-P3C 已实现的队列/并行槽位机制) |
  97 +
  98 +四条决策合起来的完整流程:
  99 +
  100 +```
  101 +goal 创建
  102 + │
  103 + ▼ task-planning(不变,粗粒度)
  104 + 产出 1..N 条 task(draft)
  105 + │
  106 + ▼ 人工"全部下达"(不变)
  107 + │
  108 + ▼ 【新】深度规划阶段
  109 + 1. 查经验库/memory/dream 沉淀,看有没有可复用的方法论
  110 + 2. 置信度不够 → 触发 org-hunt/org-digest 做 deep research
  111 + 3. 结合"模型自身规划能力 + 查到的经验/调研结果",产出结构化分解
  112 + ("这件事第一步做什么、第二步做什么…",形态类似 43 号文档的 acceptance.md
  113 + 但发生在执行前而不是收尾后,且不仅是验收条目,是完整的步骤清单)
  114 + 4. 把分解结果物化成任务看板上的一批"可认领子任务"(不是固定图节点)
  115 + │
  116 + ▼ 【新】任务池认领执行
  117 + 各角色 agent(org-coder / org-validator / …)按自己的角色,从共享看板拉取
  118 + 下一条匹配、状态为"可认领"的子任务 → 独立开发 → 独立验证 → 回写子任务终态
  119 + │
  120 + ▼ 汇总收敛
  121 + 全部子任务 completed → 父 task success;任一子任务 failed → 父 task fail
  122 + (沿用 43 号文档已实现的父子终态汇总规则,未变)
  123 +```
  124 +
  125 +---
  126 +
  127 +## 已知的高风险点(老实写,别假装这是小改动)
  128 +
  129 +- **任务池认领是对现有图执行引擎(`graph-engine.js`)的范式级改动**,不是加字段/加钩子能做到的。现有引擎的核心心智模型是"边决定谁在什么时候跑",任务池模型的心智模型是"角色决定谁能跑什么,时机由认领动作决定"——两者要并存(company 结构层的 org graph 显然还是要保留固定角色拓扑,不可能把 CEO/CTO 汇报关系也做成认领制),需要先明确**任务池模型的作用域边界**:只用于"深度规划产出的子任务"这一层,还是要往上影响到 work-graph 本身怎么跑。这个边界目前还没有定案,是 Phase 1 设计阶段必须先钉死的第一件事。
  130 +- **43/44 号文档已经踩过一次"新机制先天有可靠性缺口"的坑**(`acceptance.md` 未必被稳定产出,闸门只查私有目录不查共享区),且现有最小范围的子任务机制(43 号文档)在生产数据里至今零命中。在没有把现有范围坐实之前就叠加更大的改动,风险会累加而不是替换。
  131 +- **"每个子任务独立开发+验证循环"直接推高成本**——一个 goal 可能因此从"1 次 work-graph run"变成"N 次子任务级 run",需要先有预算模型(每个子任务分到多少预算、超预算怎么办)才能不失控。
  132 +- **Deep research 的调用成本和可靠性**——`org-hunt` 目前只在方法论训练场景验证过(41 号文档),没有在"日常任务规划、时间敏感"场景下验证过延迟和成功率。
  133 +
  134 +---
  135 +
  136 +## 技术实施计划(架构级阶段划分,未细化到代码行)
  137 +
  138 +建议顺序:**Phase 0(先坐实现有范围)→ Phase 1(深度规划阶段骨架)→ Phase 2(经验库检索 + deep research 接入)→ Phase 3(任务池认领)→ Phase 4(子任务独立开发验证循环)→ Phase 5(看板 UI)**。理由:每一阶段都在前一阶段的地基上加一层能力,且允许在任何一个阶段暂停评估效果/成本,不是一次性大爆炸式改造。
  139 +
  140 +### Phase 0 · 坐实现有范围(前置条件,不是本文档新增范围)
  141 +在叠加新机制前,先补一次 43/44 号文档遗留的端到端确认跑——用真实公司走一遍现有 `acceptance_mode='itemized'` 全链路,确认它能稳定收敛。当前生产数据零命中,不能假设地基是稳的。
  142 +
  143 +### Phase 1 · 深度规划阶段骨架
  144 +- 新增独立 agent(暂定 `deep-planning`,命名需过 27 号命名规范),接在 task-planning 产出 task、人工下达之后,org 图真正执行之前。
  145 +- 先只做"模型自身规划能力"这一路(用户三条来源里的第 1 条):产出结构化步骤分解,格式参考 org-planner 现有 `acceptance.md` 表格惯例,但覆盖"怎么做"而不仅是"怎么验收"。
  146 +- 暂不接经验库/deep research,暂不接任务池——先验证"分解结果能不能被下游正确消费"这一步。
  147 +- 明确任务池模型的作用域边界(上面"高风险点"第一条),写成本文档的补充小节,动 Phase 3 之前必须先定案。
  148 +
  149 +### Phase 2 · 经验库检索 + Deep Research 接入
  150 +- 新建一个"查经验库"的检索能力——扫描相关 agent 的 dream 沉淀区块 + 历史同类 goal 的执行记录,给深度规划阶段用。这是全新能力,当前没有任何 agent 具备"主动查别的 agent 经验"的工具。
  151 +- 打通深度规划阶段按需触发 `org-hunt`/`org-digest` 的路径(当前它们只能被 workgraph-planning 挑进固定模板,需要开放"按需调用"的口子)。
  152 +- 验收:给一个"经验库有答案"的任务和一个"经验库没答案、需要调研"的任务各跑一次,确认分支判断正确、调研产出确实被规划阶段消费。
  153 +
  154 +### Phase 3 · 任务池认领机制
  155 +- 范围先限定在"深度规划产出的子任务"这一层(不动 company 结构层的 org graph 固定拓扑)。
  156 +- 新增"可认领子任务"的状态位与认领接口;调度器或各角色 agent 心跳时拉取匹配自己角色的可认领项。
  157 +- 需要复用/扩展 27-P3C 已实现的队列与并行槽位机制,避免重新发明并发控制。
  158 +
  159 +### Phase 4 · 子任务独立开发+验证循环
  160 +- 每条被认领的子任务触发独立的开发→验证生命周期(复用 work-graph run 的既有机制,而不是新发明一套)。
  161 +- 预算模型:子任务预算怎么从父 task 预算里切分、超支怎么处理,需要单独设计(可能复用现有 wallclock/token 预算机制的分配逻辑)。
  162 +- 父子终态汇总规则沿用 43 号文档已实现的逻辑(全 success 才 success)。
  163 +
  164 +### Phase 5 · 看板 UI
  165 +- 任务看板要能展示"可认领池"状态(谁在等认领、谁已认领在跑),不只是现有的父子树形/表格展示。
  166 +
  167 +---
  168 +
  169 +## Phase 0 实现记录(2026-09-01,真实回归已完成)
  170 +
  171 +用真实(非 mock)测试公司 `p0verify0901` 走完一次完整的 goal→task→org 执行链路。**测试公司数据未清理,留作证据**(`data/companies/p0verify0901/`)。
  172 +
  173 +**结论**:链路能收敛,44 号文档的修复确认生效,但发现一个新的、独立的真实缺口——**`acceptance_mode='itemized'` 在当前 org-planner 子图形状下永远物化不出子任务,不是概率问题,是结构性打不通**。这解释了 46 号文档「现状调研」第 6 条记录的"生产库零命中"现象:不是运气差,是这条路径已经失效。
  174 +
  175 +### 验证结果
  176 +
  177 +| 检查项 | 结果 |
  178 +|---|---|
  179 +| task-planning 真实判定 | `itemized`(未诱导、未重试,`T-mtis98aq-8c7f`) |
  180 +| `goal-benchmark`/`planner-validator` 是否真正跑完(44 号文档修复验证) | ✅ 两者都 success,不再被误判 skip;`contract.json` 声明 `produces.acceptance` 指向共享 `artifacts/` 绝对路径,闸门正确识别 |
  181 +| `acceptance.md` 是否产出 | ❌ 全程未产出,四个 org-planner 节点私有目录搜遍为空 |
  182 +| 子任务是否物化(`parent_task_id` 指向该 task) | ❌ 0 条 |
  183 +| `coder-validator` 是否逐条上报子任务结果 | ❌ 未调用,走的是整体 holistic 判定(`SUBTASKS_INCOMPLETE` 闸门因为子任务本来就是 0 条而天然放行) |
  184 +| 父 task 终态 | `completed`(按 holistic 规则收敛,不是 43 号文档设计的"全子任务 completed 才 success"规则——因为没有子任务可供该规则生效) |
  185 +| 回退/报错 | 零回退(`backward_count` 全程 0),零 `fail_reason`,全程 18 分 55 秒 |
  186 +
  187 +### 根因
  188 +
  189 +`org-planner` 名义上(`agent.md`/`tools.md` 文档)要交 `brd.md/prd.md/sdd.md/acceptance.md` 四份产物,但它是 `nodeKind='subgraph'`,真正执行靠 `workgraph-planning` 现场规划的子图。这次真实规划出的子图是 `org-planner-prd → org-planner-sdd → org-planner-benchmark → org-planner-validator`(没有 brd-planning,也没有任何节点职责是写 `acceptance.md`)——`org-planner-benchmark` 产出的是 `goal-benchmark.json`(R-19-B 契约的 `benchmark` 段,44 号文档那套新机制),跟"子任务物化"依赖的 `acceptance.md` 是两套不同产物,已经对不上。`graph-engine.js` 里 `maybeMaterializeItemizedSubtasks`(约 2372-2417 行)扫不到 `acceptance.md` 时会静默降级为 holistic(设计上是"软失败不拖垮硬产出",出发点合理),但代价是这条路径悄无声息地形同虚设,没人会发现。
  190 +
  191 +`org-planner/agent.md`、`org-planner/tools.md` 里"四份产物含 acceptance.md"的文档描述本身可能已经过时,没跟上 `goal-benchmark.json`/R-19-B 契约这套后来的机制。
  192 +
  193 +### 遗留决策:这个新发现的 bug 怎么处理
  194 +
  195 +已拆分成独立文档处理,**已修复**:见 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md)(`acceptance.md` 缺失时物化逻辑改读 `goal-benchmark.json` 的 `dimensions[]`,2026-09-02,单测 5 条全过)。47 号文档末尾也记了一条需要 Phase 1 设计时想清楚的遗留点:这条兜底修的是**旧的(43 号文档设计的)物化机制**,Phase 1 打算把分解提前到执行开始之前,旧机制届时可能降级为安全网而不是主路径——不影响当下必要性,但 Phase 1 设计阶段要显式决定要不要保留它。
  196 +
  197 +---
  198 +
  199 +## Phase 1 详细设计(2026-09-02,设计已细化,未实现)
  200 +
  201 +### 命名
  202 +
  203 +按 [27-naming-conventions.md](./27-naming-conventions.md) §二「规划类 agent —— `{产出物}-planning`」的既有惯例(`brd-planning`/`prd-planning`/`sdd-planning` 都按自己产出什么命名),新 agent 命名为 **`subtask-planning`**——它的产出物就是 `subtask-goal`(该规范 §一 作用域表里已经预留了这一级:"子任务的目标(一个 task-goal 分解出多个)"),命名轴完全对上,不用新造概念。
  204 +
  205 +与 `task-planning` 的关系:`task-planning` 把 `company-goal` 拆成多条 `task`(= `task-goal`);`subtask-planning` 把一条 `task-goal` 拆成多条 `subtask-goal`——同一条命名轴上相邻的两级,职责边界清楚。
  206 +
  207 +### 触发点:`enqueueTaskRun`(不改 task-scheduler 的排队逻辑)
  208 +
  209 +`server/routes/company.js:1099 enqueueTaskRun()` 是 dispatch 时真正创建 org run 并把 task 标记 `queued` 的地方,紧接着会调用 `writeTaskGoalArtifact()` 写下这条 task 的验收边界产物。**这是最合适的钩子位置**——原因:
  210 +
  211 +- **不碰 `task-scheduler.js`**:该文件的排队/占坑逻辑充满精细的并发不变式(本次设计阶段读代码时看到的注释密度就是证据),新增一道"等 subtask-planning 跑完才能出队"的阻塞闸门风险很高,且违反 33/44/47 号文档反复验证过的教训——**软数据不能当硬闸门**。
  212 +- **异步、不阻塞**:`subtask-planning` 在 `enqueueTaskRun` 内用 `setImmediate` 触发(照抄 `runGoalSplit` 现成的异步模式),跟 org run 的排队/起跑并行,不等它。多数情况下 `subtask-planning` 只是一次单轮 LLM 调用,会比 org run 真正排到执行位、org-planner 开始跑之前完成;即使没赶上,也只是这一轮 org-planner 拿不到分解方案参考,不是失败。
  213 +
  214 +### 产出物:写共享 `artifacts/` 区,不是私有目录
  215 +
  216 +吸取 44/47 号文档的教训——产物必须落在**下游查得到的地方**,且格式要对已经证实稳定工作的机制友好。写入 `runRootDir(run)/artifacts/subtask-plan.json`,形状复用 47 号文档刚验证过的 `goal-benchmark.json` 风格(结构化、非 Markdown 表格,避免重蹈 `acceptance.md` 那种"文档说要写、实际没人写"的覆辙):
  217 +
  218 +```json
  219 +{
  220 + "steps": [
  221 + { "seq": 1, "title": "...", "why": "...", "depends_on_hint": null },
  222 + { "seq": 2, "title": "...", "why": "...", "depends_on_hint": [1] }
  223 + ],
  224 + "reasoning_source": "model_only"
  225 +}
  226 +```
  227 +
  228 +`reasoning_source` 字段是为 Phase 2 预留的("model_only" / "memory" / "deep_research"),Phase 1 恒为 `"model_only"`。
  229 +
  230 +### 与 org-planner 的衔接点:软注入,不建硬依赖
  231 +
  232 +`org-planner` 现在读的输入里已经有一套"P1 虚拟引用"注入模式(`company-context.md`,prompt-builder 直接拼进提示词,见 27-P1 文档)。`subtask-plan.json` 走同一种模式:prompt-builder 组装 org-planner 任务提示词时,检查这条 task 对应 run 的共享 `artifacts/subtask-plan.json` 是否存在,存在就拼进提示词一段"已有的分解参考(来自 subtask-planning,仅供参考,不是必须遵守的清单)",不存在就跟现在完全一样,什么都不加。**不改 org-planner 的硬约束、不要求它必须使用这份参考**——Phase 1 的验收标准只是"分解结果能被下游正确读到",不是"下游行为因此改变"(那是 Phase 1 完成后才评估的效果问题,不是这一步的实现范围)。
  233 +
  234 +### 工具权限
  235 +
  236 +比照 `task-planning` 的极简先例(该 agent 现在"大概率一个工具都不需要用"):v1 只给读权限(`bash`/`read_file`,无 `str_replace`),能读的输入是任务描述 + 公司 org graph 摘要(跟 task-planning 拿到的是同一批,首轮消息里给)。**明确不给**:创建/修改 task 的 API、创建/下达 run 的 API、招聘/改 org graph 的 API(原因与 task-planning 工具箱里那张"不该用的工具"表完全一致——落库是服务端的活,agent 只交结构化产出)。联网调研(Phase 2 才接)本阶段也不给。
  237 +
  238 +### Phase 3 前必须先定案的边界(呼应「已知的高风险点」第一条)
  239 +
  240 +任务池认领机制的作用域明确限定为:**`subtask-planning` 产出的 `subtask-plan.json` 里的 `steps[]`,物化成 `tasks` 表里 `parent_task_id` 指向该 task、可被认领的行**。不影响:
  241 +
  242 +- company 级 org graph(`ceo`/`cto`/`org-planner`/`org-coder` 这套角色拓扑,27-P2 已实现,继续保持人工固定/慢变/可审计);
  243 +- `workgraph-planning` 现场规划出的 org node 内部 work graph(`brd-planning → prd-planning → sdd-planning → ...` 这类,继续保持固定 DAG)。
  244 +
  245 +任务池只是在"task"和"当前这套固定的 org/work graph 执行"之间新插了一层——这层里的东西(subtask)才能被认领,图本身怎么跑不变。这条边界写在这里,是为了 Phase 3 设计时不用重新讨论一遍。
  246 +
  247 +### Phase 1 验收标准(后续真实回归要检查的)
  248 +
  249 +1. dispatch 一条 task 后,`subtask-planning` 是否被异步触发、是否产出了 `subtask-plan.json`?
  250 +2. `org-planner` 的任务提示词里是否真的拼进了这段参考(不是"写了注入逻辑但没生效")?
  251 +3. `subtask-planning` 没跑完/失败时,org-planner 这一轮是否完全不受影响(确认软注入的降级路径成立)?
  252 +
  253 +---
  254 +
  255 +## Phase 1 实现记录(2026-09-02,代码已实现,未做真实端到端回归)
  256 +
  257 +### 落地清单
  258 +
  259 +| 内容 | 位置 |
  260 +|---|---|
  261 +| 新 agent 本体 `subtask-planning` | `system/agents/subtask-planning/{config.json,agent.md,identity.md,soul.md,tools.md}`,`config.type='meta-planning'`(复用 `task-planning` 同款分类,不进 org graph) |
  262 +| 规划服务(含 CLI 调用 + 宽松解析归一化) | `server/services/subtask-planning.js`——单轮调用、无重试、单条不合法数据丢弃不作废整批(与 task-planning 的严格写入前校验刻意不同,见 agent.md「你的产出是建议,不是指令」) |
  263 +| 触发点 | `enqueueTaskRun`(`server/routes/company.js`)新增 `withSubtaskPlanning` 参数,**默认 `false`**,只在两处显式传 `true`:`/goals/:goalId/dispatch`(批量下达)、`/tasks/:taskId/enqueue`(单条下达)。`POST /:companyId/tasks`(quick task,外部 agent 自主派发)与 `/rerun` 保持默认不触发——理由见下「设计比原计划收窄的一处」 |
  264 +| 产物 | `runRootDir(run)/artifacts/subtask-plan.json`(共享区,47 号文档同款教训:不写节点私有目录) |
  265 +| 与下游的衔接 | 登记进 `graph_files`(`kind='subtask_plan'`),纳入 `graph-engine.TASK_SHARED_KINDS`——复用 R-9.3 任务级共享机制自动出现在该 task 下**任意**节点的「上游输入」提示词段落,**没有另写 prompt-builder 注入逻辑**(比原设计「衔接点」一节设想的软注入方案更省事,见下) |
  266 +| 测试 | `subtask-planning.test.js`(12 条,纯函数解析/归一化)+ `subtask-planning-materialize.test.js`(4 条,共享区落地 + `describeInputs` 真的能读到)。全量单测 1558 passed,唯二失败与本次改动无关(改动前即存在) |
  267 +
  268 +### 与原设计的两处出入
  269 +
  270 +1. **触发点从"`enqueueTaskRun` 内部无条件触发"收窄成"调用方显式 opt-in"**。原设计(本文档上一版)打算让 `enqueueTaskRun` 无条件触发,动手时撞上 `goal-task-acceptance.test.js` 的「验收 22」——它守着一条真实存在的产品保证:`POST /:companyId/tasks`(外部 agent curl 自主派发的快速任务,例子就是"把周报发出去"这种行政指令)必须**零 LLM 调用**,这是给 agent 间快速委派留的零成本通道。subtask-planning 无差别触发会直接击穿这条保证。改法:`enqueueTaskRun` 新增 `withSubtaskPlanning` 参数,默认 `false`,只在"goal 批量下达"和"单条 task 显式下达"这两个真正对应"人/系统郑重下达一条任务"的语义上打开。
  271 +
  272 +2. **与 org-planner 的衔接从"prompt-builder 软注入"改成"复用 R-19.3 任务级共享索引"**。原设计打算专门给 org-planner 写一段"检查 subtask-plan.json 存在就拼进提示词"的注入逻辑。动手核实 `graph-engine.describeInputs` 的实现后发现:这个函数**在 subgraph 节点上直接跳过**(`org-planner` 本身是 subgraph),真正的消费者应该是它展开出的子图里那些 `agent_call` 节点(`org-planner-prd`/`org-planner-sdd`/…)——而这些节点已经有一套现成机制(`TASK_SHARED_KINDS`,任务级共享产物索引,`acceptance`/`contract` 已经在用)自动把"这个 task 下的共享产物"塞进**任意**节点的提示词,不受拓扑相邻性限制。把 `subtask_plan` 加进这个白名单,比另写一段注入逻辑更省代码、更少一个新的失败模式,而且覆盖面更广(org-coder 阶段的节点也能看到,不只 org-planner)。
  273 +
  274 +### ⚠️ 一次真实的安全事故(写实话,别再犯)
  275 +
  276 +第一版实现(`enqueueTaskRun` 内部无条件触发,未做任何测试环境防护)在跑 `npx vitest run server/tests/unit` 时,**真实开出了 44 个 `claude` CLI 子进程**挂在后台(`ps aux` 现场抓到,手动 `kill` 清理干净,未产生数据损坏,但存在真实 API 调用成本)。根因:`task-planning`/`workgraph-planning` 那套"默认走真实实现,测试要显式注入桩才安全"的设计前提,建立在"只有专门测规划逻辑的测试才会碰到这条触发路径"之上;subtask-planning 的触发点(`enqueueTaskRun`)被全仓库大量跟规划毫不相关的单测(测排队、测预算、测状态流转)顺带路过,这些测试从没想过要为一个新加的旁支功能注入桩。
  277 +
  278 +修法:`subtask-planning.js` 的 `planSubtasks()` 新增一道防护——`process.env.VITEST`(vitest 对所有测试进程自动置位,通用判据不是我们发明的)为真、且没有人显式调用过 `setPlanSubtasks()` 注入时,直接跳过真实调用,返回空结果(等价于 47 号文档反复用到的软失败路径)。这是 `subtask-planning.js` 独有的防护,`task-planning.js` 没有、也不需要——它的触发面窄得多,从未出现过这个问题。加完防护后,全量测试(1558 条,含 integration/devloop)跑三遍,`ps aux` 确认零残留子进程。
  279 +
  280 +### 遗留:还没做真实端到端回归
  281 +
  282 +设计文档「Phase 1 验收标准」一节列的三条(① dispatch 后 subtask-planning 真的被触发并产出 artifact;② org-planner 展开的子图节点提示词里真的能读到这段参考;③ subtask-planning 没跑完时 org-planner 完全不受影响)——**①③ 已经被集成测试间接覆盖**(用注入桩验证了机制本身是对的),**②的"提示词真的拼上了共享索引这一段"也已经用集成测试直接验证**(`describeInputs` 真实读到 `subtask_plan` 并列进提示词)。**没做的是**:用真实公司、真实 goal、真实 Claude CLI 走一遍全链路,确认 subtask-planning 在真实调用下真的产出有意义的分解(不是机制通不通,是内容有没有用)——考虑到刚踩过的真实 CLI 子进程事故,这次真跑验证需要更谨慎地控制范围(单个任务、有人盯着、跑完立刻检查有没有残留进程),不建议顺手跑,值得单独作为一步来做。
... ...
docs/todo/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md 0 → 100644
  1 +# 47 · `acceptance.md` 结构性产不出来,itemized 子任务物化形同虚设
  2 +
  3 +> **状态**: ✅ **已修复**(2026-09-02)。按下面「三、修法」实现:`maybeMaterializeItemizedSubtasks`
  4 +> 新增 `findGoalBenchmarkFile`/`parseGoalBenchmarkDimensions` 兜底路径,`acceptance.md` 缺失时
  5 +> 改读共享 `artifacts/goal-benchmark.json` 的 `dimensions[]`。单测见
  6 +> `server/tests/unit/task-decomposition-materialize.test.js`(5 条,2 条新增)
  7 +> **日期**: 2026-09-02
  8 +> **发现场景**:[46 号文档](./46-mandatory-planning-and-task-decomposition.md) Phase 0 的真实端到端验证——新建 `p0verify0901` 公司,走一条真实 `itemized` 任务(贪吃蛇游戏,真实 Claude CLI,非 mock),全程 19 分钟零回退零报错收敛到终态,但子任务物化了 0 条
  9 +> **与 43/44 号文档的关系**:43 号文档设计了物化机制本身,44 号文档修的是产出闸门误判 `goal-benchmark` 没产出;本文档修的是**物化机制依赖的产物本身在当前常见子图形状下从来不存在**——三份文档各自独立,但环环相扣:44 号文档修完之后,`goal-benchmark.json` 才第一次被证实能稳定产出,本文档才有了一个现成的兜底数据源可用
  10 +
  11 +---
  12 +
  13 +## 一、现象
  14 +
  15 +46 号文档 Phase 0 真实回归的结果(详见该文档「Phase 0 实现记录」):
  16 +
  17 +| 检查项 | 结果 |
  18 +|---|---|
  19 +| task-planning 判定 | `itemized`(未诱导) |
  20 +| `goal-benchmark`/`planner-validator` 是否真正跑完 | ✅ 两者都 success(44 号文档修复生效) |
  21 +| `acceptance.md` 是否产出 | ❌ 全程未产出 |
  22 +| 子任务是否物化 | ❌ 0 条 |
  23 +| 父 task 终态 | `completed`(按 holistic 规则收敛,不是设计中的"全子任务 completed 才 success") |
  24 +
  25 +系统自己检测到了这一点,timeline 里有一条明确记录:
  26 +
  27 +```json
  28 +{"kind":"node.output","summary":"task.acceptance_mode='itemized' 但没找到 acceptance.md,跳过子任务物化(仍按 holistic 收尾)","payload":{"degraded":"acceptance_md_missing"}}
  29 +```
  30 +
  31 +**这不是这次真跑运气差**——是结构性打不通,见下面根因。
  32 +
  33 +---
  34 +
  35 +## 二、根因
  36 +
  37 +`org-planner/agent.md` 硬约束第 3 条要求它交四份产物:`brd.md`/`prd.md`/`sdd.md`/`acceptance.md`。但 org 层节点全是 `nodeKind='subgraph'`(无一例外),org-planner 实际执行靠 `workgraph-planning` 现场规划出的子图。
  38 +
  39 +本次真实回归里,`workgraph-planning` 规划出的子图是:
  40 +
  41 +```
  42 +org-planner-prd → org-planner-sdd → org-planner-benchmark → org-planner-validator
  43 +```
  44 +
  45 +**没有 brd-planning,也没有任何一个节点的职责是写 `acceptance.md`。** `org-planner-benchmark`(本体 `goal-benchmark`)产出的是 `goal-benchmark.json`——R-19-B 契约的 `benchmark` 段(44 号文档处理的那套机制),是一份结构化的验收维度清单,不是 `acceptance.md`。这两份产物名字不同、来源不同、格式不同(一个 Markdown 表格、一个结构化 JSON),历史上就没打算是同一份东西——它们只是恰好都能回答"这个任务怎么算通过"这个问题。
  46 +
  47 +`server/services/graph-engine.js` 的 `maybeMaterializeItemizedSubtasks`(`findAcceptanceMdFile` 全节点扫描)**只认 `acceptance.md` 这一种产物**,扫不到就静默降级为 holistic——设计上是"软失败不拖垮硬产出"(33 号文档的教训:软产物不能当硬闸门),出发点合理,但代价是:只要 `workgraph-planning` 现在这套子图形状(prd/sdd/benchmark/validator,没有专门写 `acceptance.md` 的节点)持续是常态,`acceptance_mode='itemized'` 这条路径就永远物化不出子任务——这正是 46 号文档记录的"生产库 `itemized` 零命中"现象背后的确切原因。
  48 +
  49 +`org-planner/agent.md`、`org-planner/tools.md` 里"四份产物含 acceptance.md"的文档描述本身可能已经过时,没跟上 `goal-benchmark.json`/R-19-B 契约这套后来演进出的机制——这是本文档范围之外的一处文档陈旧,暂不处理(见「五、遗留」)。
  50 +
  51 +---
  52 +
  53 +## 三、修法
  54 +
  55 +不改 org-planner 的产物约定(不强推它必须再产出一份 `acceptance.md`,那需要改 prompt/工具且不保证 `workgraph-planning` 每次都会规划出对应节点),改**物化机制去认已经证实稳定产出的那份数据**——`goal-benchmark.json`。
  56 +
  57 +`goal-benchmark.json` 的 `dimensions[]` 天然就是一份验收条目清单,结构比 `acceptance.md` 的 Markdown 表格更丰富(`name`/`weight`/`pass_threshold`/`rubric`/`evidence_hint`),还带一份 `traceability[]` 把每个维度映射回原始需求条目原文(`task_goal_item`),物化子任务时用它当 `title` 比维度英文名(如 `direction_control`)对人更友好。
  58 +
  59 +`server/services/graph-engine.js` 新增两个函数:
  60 +
  61 +- `findGoalBenchmarkFile(run)`:查 `runRootDir(run)/artifacts/goal-benchmark.json`(共享区,`runRootDir` 只认 `root_run_id`,传根 run 还是子 run 对象都能解析到同一路径,不用像 `findAcceptanceMdFile` 那样扫全部节点)。
  62 +- `parseGoalBenchmarkDimensions(text)`:解析出跟 `parseAcceptanceMd` 同形状的行(`{seq, item, method, passCondition}`),下游 `createTask` 循环不用改。
  63 +
  64 +`maybeMaterializeItemizedSubtasks` 改成两段式:先按原逻辑找 `acceptance.md`(保留向后兼容——万一以后哪个模板真的规划出了写 `acceptance.md` 的节点,原路径优先生效),找不到再退而认 `goal-benchmark.json`;两者都没有才真正判定"没有可物化的数据源"。
  65 +
  66 +```
  67 +acceptance.md 存在 → 用它(不变)
  68 +acceptance.md 不存在
  69 + goal-benchmark.json 存在 → 用它的 dimensions[](新增)
  70 + goal-benchmark.json 也不存在 → 仍按 holistic 收尾(不变,软失败)
  71 +```
  72 +
  73 +---
  74 +
  75 +## 四、验证
  76 +
  77 +`server/tests/unit/task-decomposition-materialize.test.js` 新增 2 条用例(原 3 条 + 新 2 条 = 5 条全过):
  78 +
  79 +1. `acceptance.md` 不存在但 `goal-benchmark.json` 存在(共享 `artifacts/` 区)→ 按维度物化子任务,`title` 取 `traceability` 里的原始需求条目原文,`description` 含 `evidence_hint` 与 `pass_threshold`。
  80 +2. `acceptance.md` 和 `goal-benchmark.json` 都存在时,`acceptance.md` 优先,不走兜底(向后兼容确认)。
  81 +
  82 +测试数据用 46 号文档 Phase 0 真实回归产出的 `goal-benchmark.json`(`data/companies/p0verify0901/runs/run_d1c66d98c5bf/artifacts/goal-benchmark.json`)精简后的真实形状,不是凭空编的 fixture。
  83 +
  84 +全量单测:267(graph-engine + devloop)+ 1136(全量 unit)全过,唯二失败(`env-example-secrets.test.js`、`test-id-stability.test.js`)与本次改动无关,改动前即存在。
  85 +
  86 +**未做**:没有再起一次真实(非 mock)端到端回归去确认修复在真实生产链路里也生效——单测覆盖的是"给定一份真实捕获的 `goal-benchmark.json`,解析和物化逻辑对不对",不是"下一次真实 itemized 任务真的会走到这条兜底路径并成功物化"。原因:一次真实回归要 20 分钟量级的真实 Claude CLI 调用,而这次修复的逻辑本身是纯函数级别的、输入数据已经是真实捕获的产物,单测的把握足够高;如果后续 46 号文档 Phase 1 及以后需要一次新的真实回归验证,可以顺带把这条也验一遍。
  87 +
  88 +---
  89 +
  90 +## 五、遗留
  91 +
  92 +- `org-planner/agent.md`、`org-planner/tools.md` 里"四份产物含 acceptance.md"的描述与当前 `workgraph-planning` 常规划出的子图形状不一致,属于文档陈旧,本文档未处理。
  93 +- 这个兜底修复解决的是**43 号文档设计的物化机制**(org-planner 收尾后物化子任务)的可用性问题。46 号文档 Phase 1 打算把"分解"提前到执行开始之前(新增独立深度规划阶段),如果那条路径落地,本文档修的这条兜底路径可能会变成"旧机制的安全网"而不是主路径——不影响本次修复的必要性(旧机制在 Phase 1-5 落地前还是唯一在跑的路径),但后续设计 Phase 1 时应该想清楚要不要保留这条兜底、还是彻底换成新机制。
... ...
server/routes/company.js
... ... @@ -1096,7 +1096,17 @@ function isSlotRace(err) {
1096 1096 *
1097 1097 * @returns {{runId, run, artifact, task}}
1098 1098 */
1099   -function enqueueTaskRun({ companyId, taskRow, budget }) {
  1099 +/**
  1100 + * `withSubtaskPlanning`(46 号文档 Phase 1,默认 `false`,显式开):只在「goal → task
  1101 + * → 人点下达」这条主叙事路径上(本文件的 `/goals/:goalId/dispatch` 与
  1102 + * `/tasks/:taskId/enqueue` 两处)触发 subtask-planning,**不**是 `enqueueTaskRun`
  1103 + * 的每个调用方都要——`POST /:companyId/tasks`(外部 agent curl 自主派发的快速任务,
  1104 + * 常是"把周报发出去"这类无需分解的行政指令,`goal-task-acceptance.test.js` 验收 22
  1105 + * 专门守着这条路径零 LLM 调用)与 `/rerun`(同一条 task 换个 run 重跑,描述没变,
  1106 + * 重新分解没有新增价值)都保持默认 `false`——默认关比默认开更安全,新增调用方不写这个
  1107 + * 参数就不会意外多花一次 LLM 调用。
  1108 + */
  1109 +function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = false }) {
1100 1110 const wdb = require('../services/work-graph-db');
1101 1111  
1102 1112 const created = wdb.createRun({
... ... @@ -1141,6 +1151,22 @@ function enqueueTaskRun({ companyId, taskRow, budget }) {
1141 1151 // 只在还没有真实值时才写,避免盖掉别的派单流程已经写好的 assignee。
1142 1152 assigneeAgentId: taskRow.assignee_agent_id || wdb.GRAPH_TAKEOVER_ASSIGNEE,
1143 1153 });
  1154 +
  1155 + // 46 号文档 Phase 1:异步触发 subtask-planning,照抄 runGoalSplit 的 setImmediate 模式——
  1156 + // 不等它、不阻塞入队本身。triggerSubtaskPlanning 内部已经把所有失败都兜成 console.warn,
  1157 + // 这里的 .catch 只防「兜底代码自己炸了」变成 unhandled rejection(同 runGoalSplit 那句注释)。
  1158 + if (withSubtaskPlanning) {
  1159 + // ⚠️ `created` 只是 `wdb.createRun()` 的 `{ok, id, nodeRunIds}` 返回值,没有 company_id /
  1160 + // root_run_id —— 必须传完整行(`wdb.getRun(runId)`),否则 `runRootDir()` 会拼出带
  1161 + // "undefined" 的坏路径且不报错(本条注释是踩过一次这个坑之后补的)。
  1162 + const runRow = wdb.getRun(runId);
  1163 + setImmediate(() => {
  1164 + require('../services/subtask-planning')
  1165 + .triggerSubtaskPlanning({ run: runRow, task })
  1166 + .catch((e) => console.error(`[TaskEntry] task ${taskRow.id} subtask-planning 兜底失败: ${e.message}`));
  1167 + });
  1168 + }
  1169 +
1144 1170 return { runId, run: wdb.getRun(runId) || {}, artifact, task };
1145 1171 } catch (err) {
1146 1172 // 入队做了一半:run 建出来了但 task 没翻过去。留着它就是一条**永远不会被出队**的
... ... @@ -1955,6 +1981,9 @@ router.post(&#39;/:companyId/goals/:goalId/dispatch&#39;, (req, res) =&gt; {
1955 1981 // body 覆盖后不回写 task 行:budget 在 queued_at 之后是冻结字段(§四.4),
1956 1982 // 草稿行留的是「当初申报的额度」,本次实际用量以 run 的 budget_json 为准。
1957 1983 budget: (req.body && req.body.budget) || parseBudget(d.budget_json) || DEFAULT_TASK_BUDGET,
  1984 + // 46 号文档 Phase 1:这是「goal → task-planning 拆分 → 人点全部下达」的主叙事路径,
  1985 + // 每条被下达的 task 都值得一份步骤分解参考。
  1986 + withSubtaskPlanning: true,
1958 1987 }));
1959 1988 }
1960 1989  
... ... @@ -2400,6 +2429,8 @@ router.post(&#39;/:companyId/tasks/:taskId/enqueue&#39;, (req, res) =&gt; {
2400 2429 companyId,
2401 2430 taskRow: task,
2402 2431 budget: (req.body && req.body.budget) || parseBudget(task.budget_json) || DEFAULT_TASK_BUDGET,
  2432 + // 46 号文档 Phase 1:显式下达一条 draft task,与 goal 批量下达同一类事件。
  2433 + withSubtaskPlanning: true,
2403 2434 });
2404 2435 res.status(201).json(queueReply(companyId, task, startNextIfSlotFree(companyId)));
2405 2436 } catch (err) {
... ...
server/services/graph-engine.js
... ... @@ -280,8 +280,9 @@ function parseNodeRef(json) {
280 280 }
281 281  
282 282 /** 判「这次失败了」的 verdict(agent.md 产出格式那三个取值里的两个);no_prior_failure 是放行 */
283   -/** 任务级共享产物的 kind(不受拓扑相邻性限制,任何下游都可能要读,R-19.3 定案①) */
284   -const TASK_SHARED_KINDS = ['acceptance', 'contract'];
  283 +/** 任务级共享产物的 kind(不受拓扑相邻性限制,任何下游都可能要读,R-19.3 定案①)
  284 + * 'subtask_plan':46 号文档 Phase 1,subtask-planning 的步骤分解,供本 task 下任意节点参考 */
  285 +const TASK_SHARED_KINDS = ['acceptance', 'contract', 'subtask_plan'];
285 286 /** 按文件名兜底认 kind 的白名单(出边没声明契约时用;认不出来一律 'artifact') */
286 287 const KNOWN_FILE_KINDS = ['brd', 'prd', 'sdd', 'acceptance', 'contract'];
287 288  
... ... @@ -2353,6 +2354,53 @@ function findAcceptanceMdFile(run) {
2353 2354 }
2354 2355  
2355 2356 /**
  2357 + * 47 号文档:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状
  2358 + * (prd/sdd/goal-benchmark/planner-validator)下永远不会被产出——没有任何节点的职责是写它。
  2359 + * `goal-benchmark`(R-19-B 契约 `benchmark` 段)倒是稳定产出,写在共享 `artifacts/` 区,
  2360 + * 结构就是一份可机器判定的验收维度清单(`dimensions[]`),拿它当兜底数据源。
  2361 + */
  2362 +function findGoalBenchmarkFile(run) {
  2363 + try {
  2364 + const file = path.join(runRootDir(run), 'artifacts', 'goal-benchmark.json');
  2365 + return fs.existsSync(file) ? file : null;
  2366 + } catch { return null; }
  2367 +}
  2368 +
  2369 +/**
  2370 + * `goal-benchmark.json` 的 `dimensions[]` → 与 `parseAcceptanceMd` 同形状的行
  2371 + * (`{seq, item, method, passCondition}`),下游物化逻辑不用区分数据源。
  2372 + * `item` 优先取 `traceability[].task_goal_item`(原始需求条目原文,人读起来比维度英文名直观),
  2373 + * 没有 traceability 覆盖的维度退回用维度 `name`。
  2374 + */
  2375 +function parseGoalBenchmarkDimensions(text) {
  2376 + const rows = [];
  2377 + let data;
  2378 + try { data = JSON.parse(text); } catch { return rows; }
  2379 + const dims = Array.isArray(data?.dimensions) ? data.dimensions : [];
  2380 + const itemsByDim = new Map();
  2381 + for (const t of (Array.isArray(data?.traceability) ? data.traceability : [])) {
  2382 + for (const dimName of (Array.isArray(t?.covered_by) ? t.covered_by : [])) {
  2383 + if (!itemsByDim.has(dimName)) itemsByDim.set(dimName, []);
  2384 + if (t.task_goal_item) itemsByDim.get(dimName).push(t.task_goal_item);
  2385 + }
  2386 + }
  2387 + let seq = 0;
  2388 + for (const d of dims) {
  2389 + if (!d || !d.name) continue;
  2390 + seq += 1;
  2391 + const item = (itemsByDim.get(d.name) || []).join(';') || d.name;
  2392 + const pass100 = d.rubric && d.rubric['100'] ? d.rubric['100'] : '(未写明 100 分判据)';
  2393 + rows.push({
  2394 + seq,
  2395 + item,
  2396 + method: d.evidence_hint || '(未写明)',
  2397 + passCondition: `pass_threshold=${d.pass_threshold ?? '?'};100 分判据:${pass100}`,
  2398 + });
  2399 + }
  2400 + return rows;
  2401 +}
  2402 +
  2403 +/**
2356 2404 * 43 号文档 Phase 2:`task.acceptance_mode='itemized'` 时,org-planner 这一轮真正判定
2357 2405 * success 之后,把它(或它展开出的子图)写的 acceptance.md 逐行物化成子任务。
2358 2406 *
... ... @@ -2382,19 +2430,32 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def,
2382 2430 if (already > 0) return;
2383 2431  
2384 2432 const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run;
2385   - const file = searchRun ? findAcceptanceMdFile(searchRun) : null;
  2433 + let file = searchRun ? findAcceptanceMdFile(searchRun) : null;
  2434 + let rows = file ? parseAcceptanceMd(fs.readFileSync(file, 'utf-8')) : [];
  2435 + let source = 'acceptance.md';
  2436 +
  2437 + // 47 号文档:真实回归验证坐实 acceptance.md 在当前常见子图形状下不会被产出,
  2438 + // 兜底认 goal-benchmark.json(同样只在 acceptance.md 缺失时才查,不改变已经在用
  2439 + // acceptance.md 的模板的行为)。
  2440 + if (!rows.length) {
  2441 + const gbFile = findGoalBenchmarkFile(run);
  2442 + if (gbFile) {
  2443 + file = gbFile;
  2444 + rows = parseGoalBenchmarkDimensions(fs.readFileSync(gbFile, 'utf-8'));
  2445 + source = 'goal-benchmark.json';
  2446 + }
  2447 + }
  2448 +
2386 2449 if (!file) {
2387 2450 timelineNote({ runId, nodeRunId }, 'node.output',
2388   - `task.acceptance_mode='itemized' 但没找到 acceptance.md,跳过子任务物化(仍按 holistic 收尾)`,
  2451 + `task.acceptance_mode='itemized' 但没找到 acceptance.md / goal-benchmark.json,跳过子任务物化(仍按 holistic 收尾)`,
2389 2452 { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_missing' });
2390 2453 return;
2391 2454 }
2392   -
2393   - const rows = parseAcceptanceMd(fs.readFileSync(file, 'utf-8'));
2394 2455 if (!rows.length) {
2395 2456 timelineNote({ runId, nodeRunId }, 'node.output',
2396   - `acceptance.md 存在但解析不出任何验收条目(表格形状不对?),跳过子任务物化`,
2397   - { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_unparseable' });
  2457 + `${source} 存在但解析不出任何验收条目(形状不对?),跳过子任务物化`,
  2458 + { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_unparseable', source });
2398 2459 return;
2399 2460 }
2400 2461  
... ... @@ -2409,8 +2470,8 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def,
2409 2470 });
2410 2471 }
2411 2472 timelineNote({ runId, nodeRunId }, 'node.output',
2412   - `按 acceptance.md 物化了 ${rows.length} 条子任务`,
2413   - { node_run_id: nodeRunId, task_id: task.id, subtask_count: rows.length });
  2473 + `按 ${source} 物化了 ${rows.length} 条子任务`,
  2474 + { node_run_id: nodeRunId, task_id: task.id, subtask_count: rows.length, source });
2414 2475 } catch (err) {
2415 2476 console.error('[graph-engine] maybeMaterializeItemizedSubtasks 失败(不影响节点结算):', err.message);
2416 2477 }
... ... @@ -5289,4 +5350,10 @@ module.exports = {
5289 5350 describeRoleDefinition,
5290 5351 LOOP_HARD_CAP_EDGE,
5291 5352 LOOP_HARD_CAP_RUN,
  5353 +
  5354 + // 46 号文档 Phase 1:subtask-planning 服务需要把产物写进同一个共享 artifacts 区
  5355 + // (runRootDir(run)/artifacts/,与 goal-benchmark.json 同规矩,见 47 号文档),
  5356 + // 导出只是让它复用同一份路径计算,不在别处重复拼一份可能跟着 GRAPH_RUNS_DIR
  5357 + // override 走样的逻辑。
  5358 + runRootDir,
5292 5359 };
... ...
server/services/subtask-planning.js 0 → 100644
  1 +/**
  2 + * subtask-planning —— 把一条 task 拆成"第一步/第二步/第三步"的步骤分解(46 号文档 Phase 1)
  3 + *
  4 + * 与 `task-planning`(goal → task)的关键差异:这是**建议性**产出,不落库、不创建 task 行、
  5 + * 没有重试轮次、没有写入前校验会打回 agent —— 唯一的失败模式是"没用",不是"格式错"。
  6 + * 所以本模块单轮调用、宽松解析:挑得出多少条 `steps` 就要多少条,个别字段缺失就地丢弃那一条,
  7 + * 不因为一条不合法就整批作废(`system/agents/subtask-planning/agent.md` 硬约束 8 附近的
  8 + * 「你的产出是建议,不是指令」)。
  9 + *
  10 + * 触发方式:`enqueueTaskRun`(`server/routes/company.js`)用 `setImmediate` 异步触发,
  11 + * 照抄 `runGoalSplit` 的现成模式,不等它、不阻塞排队。**`triggerSubtaskPlanning` 是本模块
  12 + * 唯一允许抛出以外的公开入口,内部任何一步失败都只 `console.warn`,绝不 throw** ——
  13 + * 33/44/47 号文档反复验证过的教训:软数据不能变成硬闸门,这里同理,招进来的这道钩子
  14 + * 更不能变成"入队本身"的新失败点。
  15 + *
  16 + * 产物写共享 `artifacts/` 区、登记进 `graph_files` 索引(`kind='subtask_plan'`,已纳入
  17 + * `graph-engine.TASK_SHARED_KINDS`)——这样任意节点(org-planner 展开出的子图里的
  18 + * brd/prd/sdd 各节点、乃至后续 org-coder)的「上游输入」提示词段落都会自动带上它,
  19 + * 不用为 org-planner 单独写一段 prompt-builder 注入逻辑(46 号文档「与 org-planner 的
  20 + * 衔接点」一节最初设想的软注入方案,改走这条更省事、且是引擎已验证在用的现成机制)。
  21 + */
  22 +
  23 +const path = require('path');
  24 +const fs = require('fs');
  25 +
  26 +const REPO_ROOT = path.resolve(__dirname, '../..');
  27 +
  28 +/** 全局唯一的一份步骤分解本体(`system/agents/subtask-planning/`) */
  29 +const PLANNING_AGENT_ID = 'subtask-planning';
  30 +
  31 +/** 一条 task 最多切几步。上限是体检指标,不是配额——agent.md 讲得很清楚 */
  32 +const MAX_STEPS = 12;
  33 +
  34 +// ────────────────────────────────────────────────────────────────
  35 +// 注入点(仿 task-planning.setPlanTasks)
  36 +// ────────────────────────────────────────────────────────────────
  37 +
  38 +let planSubtasksImpl = defaultPlanSubtasks;
  39 +let isInjected = false;
  40 +
  41 +/** 替换规划实现(传 null 回默认实现)。测试用,避免真起 CLI */
  42 +function setPlanSubtasks(fn) {
  43 + planSubtasksImpl = fn || defaultPlanSubtasks;
  44 + isInjected = !!fn;
  45 +}
  46 +
  47 +/**
  48 + * 当前生效的规划实现。调用方一律走这里。
  49 + *
  50 + * **踩过的坑,写在这里免得下次重犯**:`task-planning`/`workgraph-planning` 那一整套
  51 + * 「注入点」设计的前提是——调用方要触发真实实现,得先显式 `setPlanXxx(fn)`。这个前提
  52 + * 对 subtask-planning 不成立:本模块的触发点在 `enqueueTaskRun`(`server/routes/company.js`
  53 + * 的 `/goals/:goalId/dispatch` 与 `/tasks/:taskId/enqueue` 两处),而**全仓库有大量单测
  54 + * 会走到这两条路径**——它们测的是排队/预算/状态流转这些跟 subtask-planning 毫无关系的东西,
  55 + * 从没想过要为一个刚加进来的旁支功能显式注入桩。第一次实现时忽略了这一点,`npx vitest run
  56 + * server/tests/unit` 一次性真开了 44 个 `claude` CLI 子进程挂在后台(`ps aux` 现场抓到,
  57 + * 已手动 kill 干净)——正是 `docs/done/claude-cli-session-leak.md` 那份手册想防的事,
  58 + * 而且是本模块自己新引入的一个源头。
  59 + *
  60 + * 修法:`process.env.VITEST`(vitest 对所有测试进程都会自动置位,不是我们自己发明的判据)
  61 + * 为真、且没有人显式调用过 `setPlanSubtasks` 时,直接跳过真实调用——不是「默认桩返回假数据」
  62 + * (那会让没预料到这条路径的测试断言出诡异内容),是「什么都不做」,等价于 47/44 号文档
  63 + * 反复用到的软失败路径(0 条 steps,不写文件,只留一条日志)。真想在测试里验证 subtask-planning
  64 + * 的行为,显式 `setPlanSubtasks(fn)` 就行——那条路径不受这层保护影响。
  65 + */
  66 +function planSubtasks(ctx) {
  67 + if (process.env.VITEST && !isInjected) {
  68 + console.log('[SubtaskPlanning] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)');
  69 + return Promise.resolve({ steps: [], tokens: 0 });
  70 + }
  71 + return planSubtasksImpl(ctx);
  72 +}
  73 +
  74 +// ────────────────────────────────────────────────────────────────
  75 +// 默认实现
  76 +// ────────────────────────────────────────────────────────────────
  77 +
  78 +/**
  79 + * 调 subtask-planning 拆一次 task 的步骤。单轮调用,没有重试。
  80 + *
  81 + * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行
  82 + * @returns {Promise<{steps: object[], tokens: number}>}
  83 + * steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过)
  84 + */
  85 +async function defaultPlanSubtasks(ctx) {
  86 + const AgentManager = require('../src/core/agent-manager');
  87 + const manager = new AgentManager({}, REPO_ROOT);
  88 + const agentName = resolvePlanningAgentName(ctx.companyId);
  89 +
  90 + let tokens = 0;
  91 + const onAcpEvent = (ev) => {
  92 + if (!ev || ev.method !== 'session/result') return;
  93 + const u = (ev.params && ev.params.usage) || null;
  94 + if (u) tokens += (u.input || 0) + (u.output || 0);
  95 + };
  96 +
  97 + const prompt = buildSubtaskPlanningPrompt(ctx);
  98 + const res = await manager.callAgent(agentName, prompt, {
  99 + onAcpEvent,
  100 + metadata: {
  101 + // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId
  102 + // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。
  103 + taskId: ctx.task.id,
  104 + layer: 'subtask-plan',
  105 + goalId: ctx.task.goal_id,
  106 + companyId: ctx.companyId,
  107 + },
  108 + });
  109 +
  110 + const rawSteps = extractPlannedSteps(res && res.output);
  111 + return { steps: normalizeSteps(rawSteps), tokens };
  112 +}
  113 +
  114 +/**
  115 + * 本体的寻址名。与 `task-planning.resolvePlanningAgentName` 同一个模式:
  116 + * 按需幂等雇一次,判据查 `company_agents` 表(不是目录)。
  117 + */
  118 +function resolvePlanningAgentName(companyId) {
  119 + if (!companyId) return PLANNING_AGENT_ID;
  120 + const { CompanyService, listCompanyAgents } = require('./company-service');
  121 + const isHired = () => listCompanyAgents(companyId).some((r) => r.role_name === PLANNING_AGENT_ID);
  122 + if (!isHired()) {
  123 + try {
  124 + new CompanyService().hire(companyId, PLANNING_AGENT_ID, {});
  125 + } catch (e) {
  126 + console.warn(`[SubtaskPlanning] 按需雇佣 ${PLANNING_AGENT_ID} 失败(${companyId}): ${e.message}`);
  127 + }
  128 + }
  129 + return isHired() ? `${companyId}:${PLANNING_AGENT_ID}` : PLANNING_AGENT_ID;
  130 +}
  131 +
  132 +// ────────────────────────────────────────────────────────────────
  133 +// 提示词
  134 +// ────────────────────────────────────────────────────────────────
  135 +
  136 +function buildSubtaskPlanningPrompt(ctx) {
  137 + const t = ctx.task || {};
  138 + return [
  139 + `你正在为公司 **${ctx.companyId}** 的一条 task 想"怎么做"的步骤分解。`,
  140 + '',
  141 + '## task 描述(要拆步骤的对象,task-planning 已经切好的一条边界)',
  142 + t.description || '(调用方没给任务描述)',
  143 + '',
  144 + `- taskId:${t.id}(只读,不要写进你的产出)`,
  145 + `- 标题:${t.title || '(无)'}`,
  146 + '',
  147 + '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)',
  148 + '```json',
  149 + '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}]}',
  150 + '```',
  151 + `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`,
  152 + '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,'
  153 + + '不是硬依赖,没有任何机制会强制执行它。',
  154 + '- 只输出这三个字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。',
  155 + '',
  156 + '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,'
  157 + + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。',
  158 + ].join('\n');
  159 +}
  160 +
  161 +// ────────────────────────────────────────────────────────────────
  162 +// 产出解析(宽松:单条不合法就丢那一条,不作废整批)
  163 +// ────────────────────────────────────────────────────────────────
  164 +
  165 +/** 从回复里取出步骤数组。取法与 task-planning.extractPlannedTasks 同思路(围栏优先、从后往前取) */
  166 +function extractPlannedSteps(output) {
  167 + const text = String(output || '');
  168 + if (!text.trim()) return [];
  169 +
  170 + const candidates = jsonCandidates(text);
  171 + for (let i = candidates.length - 1; i >= 0; i -= 1) {
  172 + const doc = safeJsonParse(candidates[i]);
  173 + if (doc && Array.isArray(doc.steps)) return doc.steps;
  174 + if (Array.isArray(doc)) return doc; // 容错:直接回了裸数组也认
  175 + }
  176 + return [];
  177 +}
  178 +
  179 +function jsonCandidates(text) {
  180 + const out = [];
  181 + const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g;
  182 + let m;
  183 + while ((m = fence.exec(text))) out.push(m[1]);
  184 + out.push(...balanced(text, '[', ']'), ...balanced(text, '{', '}'));
  185 + return out;
  186 +}
  187 +
  188 +function balanced(text, open, close) {
  189 + const out = [];
  190 + let depth = 0;
  191 + let start = -1;
  192 + for (let i = 0; i < text.length; i += 1) {
  193 + const c = text[i];
  194 + if (c === open) {
  195 + if (depth === 0) start = i;
  196 + depth += 1;
  197 + } else if (c === close && depth > 0) {
  198 + depth -= 1;
  199 + if (depth === 0) out.push(text.slice(start, i + 1));
  200 + }
  201 + }
  202 + return out;
  203 +}
  204 +
  205 +function safeJsonParse(s) {
  206 + try {
  207 + return JSON.parse(s);
  208 + } catch {
  209 + return null;
  210 + }
  211 +}
  212 +
  213 +/**
  214 + * 宽松归一化:丢掉不合法的单条(没有 title 的、不是对象的),保留下来的重新编号
  215 + * (1..N 连续,不留空洞——跳过的条目不该在 seq 里留坑,下游读起来会以为漏了一条)。
  216 + * `depends_on_hint` 里指向被丢弃条目 / 越界 / 自引用的数字直接过滤掉,不报错。
  217 + */
  218 +function normalizeSteps(raw) {
  219 + if (!Array.isArray(raw) || !raw.length) return [];
  220 + const capped = raw.slice(0, MAX_STEPS);
  221 +
  222 + const out = [];
  223 + capped.forEach((item, i) => {
  224 + if (!item || typeof item !== 'object' || Array.isArray(item)) return;
  225 + const title = String(item.title == null ? '' : item.title).trim();
  226 + if (!title) return;
  227 + out.push({ _origIndex: i + 1, title, item });
  228 + });
  229 +
  230 + const keptOrigSet = new Set(out.map((o) => o._origIndex));
  231 + return out.map((o, idx) => {
  232 + const rawDeps = o.item.depends_on_hint !== undefined ? o.item.depends_on_hint : o.item.dependsOnHint;
  233 + const deps = [];
  234 + for (const d of Array.isArray(rawDeps) ? rawDeps : []) {
  235 + const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN;
  236 + if (Number.isInteger(n) && keptOrigSet.has(n) && n !== o._origIndex && !deps.includes(n)) deps.push(n);
  237 + }
  238 + return {
  239 + seq: idx + 1,
  240 + title: o.title.slice(0, 200),
  241 + why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000),
  242 + depends_on_hint: deps,
  243 + };
  244 + });
  245 +}
  246 +
  247 +// ────────────────────────────────────────────────────────────────
  248 +// 触发入口
  249 +// ────────────────────────────────────────────────────────────────
  250 +
  251 +/**
  252 + * 触发一次 subtask-planning 并把结果写进这条 task 所属 run 的共享 artifacts 区
  253 + * + 登记进 `graph_files` 索引。**纯 best-effort,绝不 throw**——见文件头注释。
  254 + *
  255 + * @param {{run: object, task: object}} ctx run 是 graph_runs 表整行(createRun 的返回值即可,
  256 + * 只需要 company_id / root_run_id / id),task 是 tasks 表整行
  257 + */
  258 +async function triggerSubtaskPlanning({ run, task }) {
  259 + try {
  260 + if (!run || !task) return;
  261 + const { steps, tokens } = await planSubtasks({ companyId: run.company_id, task });
  262 + if (!steps.length) {
  263 + console.log(`[SubtaskPlanning] task ${task.id} 没有可用的步骤分解(0 条),跳过写入`);
  264 + return;
  265 + }
  266 +
  267 + const { runRootDir } = require('./graph-engine');
  268 + const { companyRootDir } = require('./company-paths');
  269 +
  270 + const artifactsDir = path.join(runRootDir(run), 'artifacts');
  271 + fs.mkdirSync(artifactsDir, { recursive: true });
  272 + const file = path.join(artifactsDir, 'subtask-plan.json');
  273 + fs.writeFileSync(
  274 + file,
  275 + JSON.stringify({ steps, generated_by: PLANNING_AGENT_ID, task_id: task.id }, null, 2),
  276 + 'utf-8'
  277 + );
  278 +
  279 + const wdb = require('./work-graph-db');
  280 + wdb.recordFile({
  281 + companyId: run.company_id,
  282 + taskId: task.id,
  283 + relPath: path.relative(companyRootDir(run.company_id), file),
  284 + kind: 'subtask_plan',
  285 + runId: run.id,
  286 + nodeRunId: null,
  287 + });
  288 +
  289 + console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens)`);
  290 + } catch (err) {
  291 + console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`);
  292 + }
  293 +}
  294 +
  295 +module.exports = {
  296 + PLANNING_AGENT_ID,
  297 + MAX_STEPS,
  298 + planSubtasks,
  299 + setPlanSubtasks,
  300 + triggerSubtaskPlanning,
  301 + // 下面三个导出是给测试用的
  302 + extractPlannedSteps,
  303 + normalizeSteps,
  304 + buildSubtaskPlanningPrompt,
  305 +};
... ...
server/tests/unit/subtask-planning-materialize.test.js 0 → 100644
  1 +/**
  2 + * 46 号文档 Phase 1:`triggerSubtaskPlanning` 写共享 artifacts 区 + 登记 `graph_files`
  3 + * 索引之后,任务级共享机制(R-19.3,`graph-engine.describeInputs`)要能把它自动递给
  4 + * 这个 task 下任意 agent_call 节点的提示词——这是本文档设计的「与 org-planner 的衔接点」
  5 + * 真正落地的地方(复用已验证在跑的 R-9.3 任务级共享机制,不是新写一条注入逻辑)。
  6 + *
  7 + * 隔离手法同 `graph-boundary.test.js`/`task-decomposition-materialize.test.js`:
  8 + * 先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,agent 调用全走注入,不起 Claude CLI。
  9 + */
  10 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  11 +import fs from 'fs';
  12 +import os from 'os';
  13 +import path from 'path';
  14 +
  15 +const TEST_DB_PATH = '/tmp/test-subtask-planning-materialize.sqlite';
  16 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  17 +const MODULES = [
  18 + '../../services/graph-db',
  19 + '../../services/graph-service',
  20 + '../../services/org-graph-service',
  21 + '../../services/work-graph-db',
  22 + '../../services/graph-run-sse',
  23 + '../../services/graph-engine',
  24 + '../../services/subtask-planning',
  25 +];
  26 +
  27 +describe('46 号文档 Phase 1:subtask-plan.json 共享区落地 + 任务级共享注入', () => {
  28 + let wdb;
  29 + let engine;
  30 + let subtaskPlanning;
  31 + let runsDir;
  32 +
  33 + beforeAll(() => {
  34 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  35 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  36 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subtask-planning-runs-'));
  37 + process.env.GRAPH_RUNS_DIR = runsDir;
  38 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  39 + wdb = require('../../services/work-graph-db');
  40 + engine = require('../../services/graph-engine');
  41 + subtaskPlanning = require('../../services/subtask-planning');
  42 + });
  43 +
  44 + afterAll(() => {
  45 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  46 + delete process.env.GRAPH_DB_PATH;
  47 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  48 + delete process.env.GRAPH_RUNS_DIR;
  49 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  50 + });
  51 +
  52 + function ensureAgent(id) {
  53 + const db = wdb.getDb();
  54 + const now = new Date().toISOString();
  55 + db.prepare(`INSERT OR IGNORE INTO system_agents
  56 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  57 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
  58 + .run(id, id, `system/agents/${id}`, now, now);
  59 + }
  60 +
  61 + /** wdb.createRun 只回 {ok, id, nodeRunIds},不是完整行 —— 服务层要的 root_run_id /
  62 + * company_id 得回读一次(company.js 的 enqueueTaskRun 也是这么用的,同一处坑)。 */
  63 + function createRunRow(opts) {
  64 + const created = wdb.createRun(opts);
  65 + return wdb.getRun(created.id);
  66 + }
  67 +
  68 + it('triggerSubtaskPlanning 写共享 artifacts/subtask-plan.json + 登记 graph_files', async () => {
  69 + // setPlanSubtasks 注入替换的是整条 planSubtasks(含 normalizeSteps 那一步),
  70 + // 单条不合法数据被丢弃/重新编号是 normalizeSteps 自己的职责,已在
  71 + // subtask-planning.test.js 单独钉住;这里注入的是"已经规整过"的 steps,
  72 + // 测的是 triggerSubtaskPlanning 往后那一段(写文件 + 登记索引)。
  73 + const companyId = 'co-subplan-1';
  74 + const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' });
  75 + const task = wdb.createTask({ goalId: goal.id, title: '实现贪吃蛇', description: 'd' });
  76 + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
  77 +
  78 + subtaskPlanning.setPlanSubtasks(async () => ({
  79 + steps: [
  80 + { seq: 1, title: '定义游戏状态与棋盘', why: '', depends_on_hint: [] },
  81 + { seq: 2, title: '实现移动与碰撞检测', why: '', depends_on_hint: [1] },
  82 + ],
  83 + tokens: 42,
  84 + }));
  85 + try {
  86 + await subtaskPlanning.triggerSubtaskPlanning({ run, task });
  87 + } finally {
  88 + subtaskPlanning.setPlanSubtasks(null);
  89 + }
  90 +
  91 + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
  92 + expect(fs.existsSync(file)).toBe(true);
  93 + const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
  94 + expect(content.task_id).toBe(task.id);
  95 + expect(content.steps).toHaveLength(2);
  96 + expect(content.steps[0].title).toBe('定义游戏状态与棋盘');
  97 + expect(content.steps[1].depends_on_hint).toEqual([1]);
  98 +
  99 + const rows = wdb.findFiles({ companyId, taskId: task.id, kinds: ['subtask_plan'] });
  100 + expect(rows).toHaveLength(1);
  101 + expect(rows[0].rel_path).toContain('subtask-plan.json');
  102 + });
  103 +
  104 + it('这个 task 下任意 agent_call 节点的提示词都能自动看到 subtask_plan(任务级共享,不受相邻性限制)', async () => {
  105 + const companyId = 'co-subplan-2';
  106 + ensureAgent('impl');
  107 + const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' });
  108 + const task = wdb.createTask({ goalId: goal.id, title: '实现贪吃蛇', description: 'd' });
  109 +
  110 + // 先起一条"入队时的" run 触发 subtask-planning(模拟 enqueueTaskRun 的时序)
  111 + const enqueueRun = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
  112 + subtaskPlanning.setPlanSubtasks(async () => ({
  113 + steps: [{ title: '定义游戏状态' }],
  114 + tokens: 10,
  115 + }));
  116 + try {
  117 + await subtaskPlanning.triggerSubtaskPlanning({ run: enqueueRun, task });
  118 + } finally {
  119 + subtaskPlanning.setPlanSubtasks(null);
  120 + }
  121 +
  122 + // 真正跑图的是另一条 run(同一个 task,不同 run —— 与真实场景一致:enqueueTaskRun 建的
  123 + // 那条 run 才是真正被执行的图,这里只是拆开两步验证互不依赖时序)。
  124 + const g = wdb.createWorkGraph({
  125 + companyId,
  126 + nodes: [{ nodeName: 'impl', systemAgentId: 'impl' }],
  127 + edges: [],
  128 + });
  129 + expect(g.errors || []).toEqual([]);
  130 + const execRun = createRunRow({ graphKind: 'work', graphId: g.id, companyId, taskId: task.id });
  131 + engine.markRunReady(execRun.id);
  132 +
  133 + let captured = null;
  134 + engine.setAgentExecutor(async (ctx) => {
  135 + captured = ctx.task;
  136 + return { status: 'success' };
  137 + });
  138 + try {
  139 + engine.startRun(execRun.id, { mode: 'manual' });
  140 + await engine.advanceOne(execRun.id);
  141 + } finally {
  142 + engine.setAgentExecutor(null);
  143 + }
  144 +
  145 + expect(captured).toContain('[上游输入');
  146 + expect(captured).toContain('subtask_plan(任务级共享)');
  147 + expect(captured).toContain(path.join(runsDir, enqueueRun.root_run_id, 'artifacts', 'subtask-plan.json'));
  148 + });
  149 +
  150 + it('planSubtasks 产出 0 条步骤时不写文件、不报错(软失败,不影响调用方)', async () => {
  151 + const companyId = 'co-subplan-3';
  152 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  153 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  154 + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
  155 +
  156 + subtaskPlanning.setPlanSubtasks(async () => ({ steps: [], tokens: 5 }));
  157 + try {
  158 + await expect(subtaskPlanning.triggerSubtaskPlanning({ run, task })).resolves.toBeUndefined();
  159 + } finally {
  160 + subtaskPlanning.setPlanSubtasks(null);
  161 + }
  162 + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
  163 + expect(fs.existsSync(file)).toBe(false);
  164 + });
  165 +
  166 + it('planSubtasks 本身抛错时 triggerSubtaskPlanning 吞掉,不向调用方传播(46 号文档:绝不能拖累入队本身)', async () => {
  167 + const companyId = 'co-subplan-4';
  168 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  169 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  170 + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
  171 +
  172 + subtaskPlanning.setPlanSubtasks(async () => { throw new Error('CLI 起不来'); });
  173 + try {
  174 + await expect(subtaskPlanning.triggerSubtaskPlanning({ run, task })).resolves.toBeUndefined();
  175 + } finally {
  176 + subtaskPlanning.setPlanSubtasks(null);
  177 + }
  178 + });
  179 +});
... ...
server/tests/unit/subtask-planning.test.js 0 → 100644
  1 +/**
  2 + * 46 号文档 Phase 1:subtask-planning 的产出解析 + 宽松归一化。
  3 + *
  4 + * 这一层是纯函数,不碰 DB/CLI —— 与 `triggerSubtaskPlanning` 写共享 artifacts 区 +
  5 + * 登记 graph_files 的集成路径分开测(见 subtask-planning-materialize.test.js)。
  6 + */
  7 +import { describe, it, expect } from 'vitest';
  8 +import {
  9 + extractPlannedSteps,
  10 + normalizeSteps,
  11 + buildSubtaskPlanningPrompt,
  12 + MAX_STEPS,
  13 +} from '../../services/subtask-planning.js';
  14 +
  15 +describe('subtask-planning:extractPlannedSteps', () => {
  16 + it('从围栏 json 代码块里取 {steps:[...]}', () => {
  17 + const output = [
  18 + '我是这么想的……',
  19 + '```json',
  20 + '{"steps":[{"seq":1,"title":"定义游戏状态","why":"后面都要用","depends_on_hint":[]}]}',
  21 + '```',
  22 + ].join('\n');
  23 + expect(extractPlannedSteps(output)).toEqual([
  24 + { seq: 1, title: '定义游戏状态', why: '后面都要用', depends_on_hint: [] },
  25 + ]);
  26 + });
  27 +
  28 + it('容错:顶层直接是裸数组也认', () => {
  29 + const output = '```json\n[{"title":"a"},{"title":"b"}]\n```';
  30 + expect(extractPlannedSteps(output)).toEqual([{ title: 'a' }, { title: 'b' }]);
  31 + });
  32 +
  33 + it('多个代码块时取最后一个(agent.md 硬约束 6:JSON 块必须是回复最后一段)', () => {
  34 + const output = [
  35 + '```json',
  36 + '{"steps":[{"seq":1,"title":"上一轮草稿,应被忽略"}]}',
  37 + '```',
  38 + '重新想了一下:',
  39 + '```json',
  40 + '{"steps":[{"seq":1,"title":"最终版"}]}',
  41 + '```',
  42 + ].join('\n');
  43 + expect(extractPlannedSteps(output)).toEqual([{ seq: 1, title: '最终版' }]);
  44 + });
  45 +
  46 + it('回复里没有可解析 JSON 时返回空数组(不抛错)', () => {
  47 + expect(extractPlannedSteps('我不知道该怎么拆')).toEqual([]);
  48 + expect(extractPlannedSteps('')).toEqual([]);
  49 + expect(extractPlannedSteps(null)).toEqual([]);
  50 + });
  51 +});
  52 +
  53 +describe('subtask-planning:normalizeSteps(宽松归一化,单条不合法就丢那一条)', () => {
  54 + it('正常路径:重新编号为 1..N,depends_on_hint 保留合法引用', () => {
  55 + const raw = [
  56 + { title: '第一步', why: 'w1', depends_on_hint: [] },
  57 + { title: '第二步', why: 'w2', depends_on_hint: [1] },
  58 + ];
  59 + expect(normalizeSteps(raw)).toEqual([
  60 + { seq: 1, title: '第一步', why: 'w1', depends_on_hint: [] },
  61 + { seq: 2, title: '第二步', why: 'w2', depends_on_hint: [1] },
  62 + ]);
  63 + });
  64 +
  65 + it('缺 title 的条目被丢弃,其余重新编号、不留空洞', () => {
  66 + const raw = [
  67 + { title: '第一步' },
  68 + { title: '' }, // 空 title,丢
  69 + { why: '没有 title 字段' }, // 丢
  70 + { title: '第二步' },
  71 + ];
  72 + const out = normalizeSteps(raw);
  73 + expect(out.map((s) => s.seq)).toEqual([1, 2]);
  74 + expect(out.map((s) => s.title)).toEqual(['第一步', '第二步']);
  75 + });
  76 +
  77 + it('depends_on_hint 指向被丢弃条目 / 越界 / 自引用:过滤掉,不报错', () => {
  78 + const raw = [
  79 + { title: '' }, // 原 seq 1,会被丢
  80 + { title: '第一步', depends_on_hint: [1, 2, 99] }, // 原 seq 2 → 新 seq 1;1 已被丢,2 是自己,99 越界
  81 + ];
  82 + const out = normalizeSteps(raw);
  83 + expect(out).toEqual([{ seq: 1, title: '第一步', why: '', depends_on_hint: [] }]);
  84 + });
  85 +
  86 + it('非对象 / 数组类型的条目直接跳过', () => {
  87 + const raw = [null, 'not an object', ['x'], { title: '合法的一条' }];
  88 + expect(normalizeSteps(raw)).toEqual([
  89 + { seq: 1, title: '合法的一条', why: '', depends_on_hint: [] },
  90 + ]);
  91 + });
  92 +
  93 + it(`条数超过 MAX_STEPS(${MAX_STEPS}) 时截断,不报错`, () => {
  94 + const raw = Array.from({ length: MAX_STEPS + 5 }, (_, i) => ({ title: `步骤${i + 1}` }));
  95 + const out = normalizeSteps(raw);
  96 + expect(out).toHaveLength(MAX_STEPS);
  97 + expect(out[0].title).toBe('步骤1');
  98 + });
  99 +
  100 + it('空输入 / 非数组输入返回空数组', () => {
  101 + expect(normalizeSteps([])).toEqual([]);
  102 + expect(normalizeSteps(null)).toEqual([]);
  103 + expect(normalizeSteps(undefined)).toEqual([]);
  104 + });
  105 +});
  106 +
  107 +describe('subtask-planning:buildSubtaskPlanningPrompt', () => {
  108 + it('提示词里包含 task 描述、标题、交回格式,不包含创建 task/run 的诱导', () => {
  109 + const prompt = buildSubtaskPlanningPrompt({
  110 + companyId: 'co-x',
  111 + task: { id: 'T-abc', title: '实现贪吃蛇', description: '一个可以在浏览器玩的贪吃蛇游戏' },
  112 + });
  113 + expect(prompt).toContain('一个可以在浏览器玩的贪吃蛇游戏');
  114 + expect(prompt).toContain('实现贪吃蛇');
  115 + expect(prompt).toContain('"steps"');
  116 + expect(prompt).toContain('depends_on_hint');
  117 + });
  118 +
  119 + it('task 描述缺失时给出明确占位,不假装有内容', () => {
  120 + const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc' } });
  121 + expect(prompt).toContain('调用方没给任务描述');
  122 + });
  123 +});
... ...
server/tests/unit/task-decomposition-materialize.test.js
... ... @@ -36,6 +36,23 @@ const ACCEPTANCE_MD = `# 验收清单
36 36 | 2 | 吃到食物加分 | 手动操作 | 分数 +1 |
37 37 `;
38 38  
  39 +// 47 号文档:p0verify0901 真实回归跑出来的 goal-benchmark.json 精简版(保留形状,砍掉部分维度)
  40 +const GOAL_BENCHMARK_JSON = JSON.stringify({
  41 + dimensions: [
  42 + { name: 'direction_control', weight: 0.5, pass_threshold: 80,
  43 + rubric: { '0': '方向键无效', '100': '四个方向键都能正确转向' },
  44 + evidence_hint: '模拟按键序列断言 state.direction' },
  45 + { name: 'eat_food_grow_score', weight: 0.5, pass_threshold: 80,
  46 + rubric: { '0': '吃到食物无变化', '100': '长度和分数同步增加' },
  47 + evidence_hint: '断言 snake.length 与 score 同步变化' },
  48 + ],
  49 + overall_pass_threshold: 80,
  50 + traceability: [
  51 + { task_goal_item: '①方向键控制', covered_by: ['direction_control'] },
  52 + { task_goal_item: '②吃到食物加分', covered_by: ['eat_food_grow_score'] },
  53 + ],
  54 +});
  55 +
39 56 describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => {
40 57 let wdb;
41 58 let org;
... ... @@ -71,7 +88,7 @@ describe(&#39;43 号文档 Phase 2:acceptance.md → 子任务物化&#39;, () =&gt; {
71 88 }
72 89  
73 90 /** 建一家公司的 org 图 + 一条 task,起一条 org run,跑到 org-planner(含它展开的子图)结算为止 */
74   - async function setupAndRunPlanner(companyId, { acceptanceMode = 'itemized', writeFile = true } = {}) {
  91 + async function setupAndRunPlanner(companyId, { acceptanceMode = 'itemized', writeFile = true, writeGoalBenchmark = false } = {}) {
75 92 expect(org.writeGraph(companyId, { nodes: ORG_NODES, edges: ORG_EDGES }).ok).toBe(true);
76 93 ensureAgent('planner-worker');
77 94  
... ... @@ -104,6 +121,11 @@ describe(&#39;43 号文档 Phase 2:acceptance.md → 子任务物化&#39;, () =&gt; {
104 121 const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId);
105 122 fs.mkdirSync(dir, { recursive: true });
106 123 if (writeFile) fs.writeFileSync(path.join(dir, 'acceptance.md'), ACCEPTANCE_MD);
  124 + if (writeGoalBenchmark) {
  125 + const artifactsDir = path.join(runsDir, ctx.run.root_run_id, 'artifacts');
  126 + fs.mkdirSync(artifactsDir, { recursive: true });
  127 + fs.writeFileSync(path.join(artifactsDir, 'goal-benchmark.json'), GOAL_BENCHMARK_JSON);
  128 + }
107 129 return { status: 'success', outputRef: dir };
108 130 });
109 131 try {
... ... @@ -149,4 +171,26 @@ describe(&#39;43 号文档 Phase 2:acceptance.md → 子任务物化&#39;, () =&gt; {
149 171 `).get(run.id);
150 172 expect(plannerNode.status).toBe('success'); // 找不到 acceptance.md 不该把这一步打成 fail
151 173 });
  174 +
  175 + it('47 号文档:acceptance.md 不存在但 goal-benchmark.json 存在(共享 artifacts 区)→ 按维度物化子任务', async () => {
  176 + const { task } = await setupAndRunPlanner('co-materialize-4', { writeFile: false, writeGoalBenchmark: true });
  177 + const children = wdb.getDb()
  178 + .prepare(`SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at`)
  179 + .all(task.id);
  180 + expect(children).toHaveLength(2);
  181 + expect(children[0].title).toBe('①方向键控制'); // 优先取 traceability 的原始需求条目,不是维度英文名
  182 + expect(children[0].acceptance_mode).toBe('holistic');
  183 + expect(children[0].description).toContain('模拟按键序列断言 state.direction');
  184 + expect(children[0].description).toContain('pass_threshold=80');
  185 + expect(children[1].title).toBe('②吃到食物加分');
  186 + });
  187 +
  188 + it('acceptance.md 优先于 goal-benchmark.json:两者都存在时不走兜底', async () => {
  189 + const { task } = await setupAndRunPlanner('co-materialize-5', { writeFile: true, writeGoalBenchmark: true });
  190 + const children = wdb.getDb()
  191 + .prepare(`SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at`)
  192 + .all(task.id);
  193 + expect(children).toHaveLength(2);
  194 + expect(children[0].title).toBe('方向键控制移动'); // 来自 ACCEPTANCE_MD,不是 GOAL_BENCHMARK_JSON
  195 + });
152 196 });
... ...
system/agents/subtask-planning/agent.md 0 → 100644
  1 +# Subtask Planning Agent(task → 步骤分解 —— 读·想·列·交)
  2 +
  3 +## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据)
  4 +
  5 +1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定:
  6 + `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}]}`
  7 +2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。
  8 +3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。**
  9 +4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。
  10 +5. **只输出这三个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。
  11 +6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。
  12 +7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。
  13 +8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。
  14 +
  15 +> **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。
  16 +
  17 +---
  18 +
  19 +## 身份定位
  20 +
  21 +你是**把一条 task 拆成"第一步/第二步/第三步怎么做"的角色**。`task-planning` 已经把用户的需求切成若干条可独立交付、可独立验收的 task;你面对的是其中**一条**,负责回答:"这条 task 具体要怎么做到,分几步、每步做什么"。
  22 +
  23 +你的产出会被原样附在 `org-planner`(负责把这条 task 变成 brd/prd/sdd 的角色)的任务提示词里,当作一份"已有的分解参考"。`org-planner` 有自己的判断力,可以采纳、可以调整、可以完全不理会——它不欠你任何解释。
  24 +
  25 +你**不**决定:谁来干、什么时候干、花多少钱——这些都不归你,也不该出现在你的产出里。
  26 +
  27 +## 你与其他角色的边界
  28 +
  29 +| 角色 | 干什么 | 与你的关系 |
  30 +|---|---|---|
  31 +| `task-planning` | goal → task 列表(一次,goal 创建时) | **你的上家**。它已经把边界划好,你不重新讨论"这条 task 该不该拆成几条",只想"这一条怎么做" |
  32 +| **你(subtask-planning)** | task → 步骤分解(一次,task 入队时) | 交回 JSON 就结束,产出仅供参考 |
  33 +| `org-planner` | 把这条 task 变成可执行方案(brd/prd/sdd/acceptance) | **你的下家**,也是你产出的唯一读者。它不必采纳你的分解 |
  34 +
  35 +## 怎么想这道题
  36 +
  37 +想象你要把这件事**讲给一个刚接手、什么都不知道的人听**:第一步做什么让他能立刻上手,第二步做什么,第三步做什么。步骤之间有没有天然的先后关系(后一步依赖前一步的产出)。
  38 +
  39 +不要把"调研 / 设计 / 开发 / 测试"这种流水线阶段当成步骤——那是执行这条 task 的公司流水线本来就会走的路,不是你要梳理的东西。你梳理的是**这件事本身**内部的逻辑步骤(比如"实现贪吃蛇":先定义游戏状态和棋盘 → 再做移动和碰撞检测 → 再做计分和食物生成 → 最后做开始/结束/重来的交互),这跟"先调研再开发再测试"是两条完全不同的轴。
  40 +
  41 +拿不准时,宁可列得粗一点、少一点,也不要为了凑步骤数硬拆——一份没用的分解比没有分解更浪费下家的阅读时间。
... ...
system/agents/subtask-planning/config.json 0 → 100644
  1 +{
  2 + "type": "meta-planning",
  3 + "description": "任务深度规划师 — task 下达入队时跑一次,把这条 task 的验收边界拆成一份「第一步/第二步/第三步」的步骤分解,供 org-planner 参考(46 号文档 Phase 1)",
  4 + "version": "1.0.0",
  5 + "status": "enabled",
  6 + "heartbeat": {
  7 + "enabled": false
  8 + }
  9 +}
... ...
system/agents/subtask-planning/identity.md 0 → 100644
  1 +![Subtask Planning Avatar](https://dummyimage.com/150x150/7c3aed/fff&text=STEP)
  2 +
  3 +> "别问我这件事该拆成几个任务——那件事已经有人做过了。告诉我这一件事,讲给一个刚接手的人听,第一步做什么。"
  4 +
  5 +这名 Agent 只在一条 task 入队的那一刻出现。他不重新讨论边界该划在哪——`task-planning` 已经划好了;他只想一件更小的事:**这条 task 具体怎么做,分几步**。
  6 +
  7 +他写的东西从不强制任何人。`org-planner` 会看到他的分解,可以照着走,可以调整,也可以完全不理——他对此毫无异议,他要的从来不是被执行,而是给下家省一点从零想起的力气。
... ...
system/agents/subtask-planning/soul.md 0 → 100644
  1 +# Subtask Planning 灵魂档案
  2 +
  3 +## 核心人格
  4 +
  5 +你是**画路线图的人**,不是走这条路的人,也不是划地图边界的人。
  6 +
  7 +`task-planning` 已经把用户的需求切成了几件边界清楚的事;你面对的是其中一件,只问一句:
  8 +**"讲给一个刚接手、什么都不知道的人听,第一步做什么、第二步做什么?"**
  9 +
  10 +## 工作原则
  11 +
  12 +1. **梳理的是事情本身的逻辑步骤,不是流水线的阶段。** "先调研再设计再开发再测试"是这条 task 走的公司流水线自己会做的事,不是你要交的东西。你要交的是**这件事内部**天然的先后关系——比如做一个游戏,是"先有状态和棋盘,再有交互,最后有边界情况"这种顺序,不是"先调研再写代码"这种流程。
  13 +
  14 +2. **你的产出没有强制力。** 没有任何机制会因为 `org-planner` 没照你写的步骤走而判它错。你写得好,它能少想很多;你写得没用,它就当没看见——这是设计如此,不是对你的否定。正因为没有强制力,你也没有"怎样才能不被打回"的心理负担,只需要想"这样写有没有用"。
  15 +
  16 +3. **步骤是给人省力气的,不是给自己凑数的。** 一步也是答案:事情本身够小、拆开反而啰嗦时,就写一条。硬凑成三五步只会让下家多读几行没用的字。
  17 +
  18 +4. **不发明需求。** task 描述里没提到的功能,一个字都不许出现在你的步骤里——你在梳理"怎么做已经定好的事",不是在扩写"还可以做什么"。
  19 +
  20 +5. **交完就走。** 你的产出会被原样附进下家的提示词,之后的事跟你无关——它怎么用、用不用,都不是你要操心的。
... ...
system/agents/subtask-planning/tools.md 0 → 100644
  1 +# Subtask Planning 工具箱
  2 +
  3 +> **这一岗不需要用任何工具。** 你需要的唯一输入(这条 task 的描述原文)会直接写在首轮消息里。
  4 +> 你的交付物是回复最后那个 JSON 代码块。读输入 → 想步骤 → 输出,全程不碰磁盘、不发请求。
  5 +
  6 +## 你**不该**用的工具
  7 +
  8 +| 工具 / 动作 | 为什么不该 |
  9 +|---|---|
  10 +| 任何创建 / 修改 task 的 API | 你的产出是**参考**,不是可执行的任务清单,不落库、不建行 |
  11 +| 任何创建 run / 下达任务的 API | 不归你管,你没有这个权限也不需要 |
  12 +| 招聘 / 改 org graph 的 API | 与你的职责无关 |
  13 +| `git commit` / 改引擎仓库任何文件 | 你不在任何 run 里,没有项目根,仓库不归你动 |
  14 +| 联网搜索、读第三方资料 | 本阶段(Phase 1)你只用自己的推理能力。deep research 是后续阶段的事,还没接进来 |
  15 +
  16 +你没有重试轮次,也没有 schema 校验会把你打回——唯一的失败模式是列得没用,不是格式错。写不好就老实写少一点,别硬凑。
... ...