Commit 5851516c66d338cd6d535c814a6538e7686261bc
Merge remote-tracking branch 'origin/main' into main
Conflicts: system/agents/ceo/agent.md system/agents/ceo/soul.md
Showing
26 changed files
with
3224 additions
and
28 deletions
docs/README.md
| ... | ... | @@ -62,6 +62,9 @@ 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-3 v2 均已实现且做过真实回归**(2026-09-02)。承接 43 号文档的追问:任务规划应是每个任务默认前置步骤,来源覆盖模型自身能力/知识库经验/线上 deep research。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索(扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭);真实回归揪出一个关键词打分噪音压过信号的真缺陷并修复。**Phase 3 走过一轮推翻重来**:v1("认领"pull 语义 + CAS)做完真实回归才发现"认领后没有触发源",讨论后确认"多方竞争"场景根本不存在(只有 CEO 一人拍板)——**v2 改成 CEO 心跳直接发现+派发(push)**,子任务走一张现造的轻量 work graph(`org-coder`+`org-validator` 两节点,跳过公司完整 org graph 的规划仪式)。v2 真实回归:CEO 心跳真的发现并派发了子任务,`org-coder` 产出质量达标,但 `org-validator` 卡在硬依赖的 `acceptance.json`(只有 `org-planner` 会产出,轻量图没有这个节点)——**验证这一半有真实的结构性缺口,后续方向见 [48 号文档](./todo/system-design/48-task-acceptance-contract-binding.md)**。验证过程顺手修了一个更大范围的真实 bug:两套心跳系统的自环调用一直缺内部认证令牌、暗中 401。Phase 4(独立开发验证循环)、Phase 5(看板 UI)未开工 | | |
| 67 | +| [48-task-acceptance-contract-binding.md](./todo/system-design/48-task-acceptance-contract-binding.md) | ✅ **已实现并完成真实端到端回归**(decomposed_work 链路,2026-09-02,回归补于 2026-09-03)。承接 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)并已修正。**补跑的真实端到端回归**:真实 `org-coder` → `org-validator` 全程跑通,`org-validator` 判词 `fail_reason=null`,不再 `input_underspecified`——过程中撞见两个纯属验证方法论本身(非代码)的坑(没起真实 API 服务、agent 子进程与服务进程不同源导致内部令牌对不上),修法与教训见文档「真实端到端回归补跑」。`task-planning`/`org-planner`/人类快速派单三条创建路径本轮不动;安全网(发现时就地补契约)仍只是原则 | | |
| 65 | 68 | | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) | |
| 66 | 69 | |
| 67 | 70 | ## 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-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现且已做真实端到端回归(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭)。真实回归第一次就跑出一个真缺陷(关键词打分噪音压过信号,导致"经验库有答案"分支误触发)并已修复验证;两条真实任务的分解内容质量都好,但 deep research 分支目前更接近"多问一个角色的第二意见"而非真的做了外部检索(org-hunt 那次调用没有工具调用痕迹),如实记在实现记录里。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3 走过一轮推翻重来(2026-09-02)**:v1("认领"pull 语义 + CAS)已实现并做过真实回归,但回归本身就暴露出"认领后没有触发源"的真问题,讨论后发现"认领"要解决的"多方竞争"场景根本不存在(只有 CEO 一人拍板要不要派)——**v2 改成 CEO 心跳直接发现+派发(push),删掉了 v1 的整套认领机制**,改为 CEO 判断节奏后调现成的 `/enqueue`,子任务默认走一张现造的轻量 work graph(`org-coder` 实现 + `org-validator` 验证两个节点,跳过公司完整 org graph 的规划仪式),效果不理想再升级成完整 org graph(见「Phase 3 v2」)。v1 设计和实现记录原样保留、标了废弃(留作决策过程的第一手记录,不是白做)。**v2 已做真实端到端回归**:真实 CEO 心跳确实发现并派发了子任务(顺手还修了一个真实的、影响面更大的 bug——两套心跳系统的自环调用一直缺内部令牌、暗中 401),递归防护、依赖阻塞都验证生效;`org-coder` 在没有 brd/prd/sdd 的轻量图里产出质量真实达标,但 `org-validator` 卡在硬依赖的 `acceptance.json`(只有 org-planner 会产出)上——**轻量图的执行没问题,验证这一半有真实的结构性缺口**,需要另外决定怎么补(见「Phase 3 v2」结尾三个方向)。Phase 4(独立开发验证循环)、Phase 5(看板 UI)仍未设计,留给后续 | |
| 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 子进程事故,这次真跑验证需要更谨慎地控制范围(单个任务、有人盯着、跑完立刻检查有没有残留进程),不建议顺手跑,值得单独作为一步来做。 | |
| 283 | + | |
| 284 | +--- | |
| 285 | + | |
| 286 | +## Phase 2 实现记录(2026-09-02,收窄版,代码已实现,未做真实端到端回归) | |
| 287 | + | |
| 288 | +用户原话要求"把 Phase 2-5 一次性做完"。动手前先做了一轮代码调研,发现文档字面描述掩盖了真实工作量落差:dream(29-DREAM)只**单向写回**经验,没有任何可查询索引;"R-19.3 共享索引"是笔误(实指 `TASK_SHARED_KINDS`/R-9.3,跟经验库无关);org-hunt 只能作为图节点被 `workgraph-planning` 动态选中,没有图外可调用入口;Phase 3(任务池认领)是"改动面最大的一条",要给纯拓扑驱动的 `graph-engine.js` 并行引入认领制调度层;Phase 4 需要全新的子任务级 run + 预算模型;Phase 5 在 3/4 落地前没有真实"可认领池"状态可展示。跟用户对齐后,**这一轮只做 Phase 2(收窄版)**,Phase 3-5 涉及核心调度引擎范式级改动 + 全新预算模型,风险量级不同,另起设计讨论。 | |
| 289 | + | |
| 290 | +### 落地清单 | |
| 291 | + | |
| 292 | +| 内容 | 位置 | | |
| 293 | +|---|---| | |
| 294 | +| 经验检索(新模块) | `server/services/experience-lookup.js`——`queryExperience({companyId, task})`:查这家公司已雇佣 agent 的 `memory/experience.md`(dream 沉淀),关键词重叠打分(中文用双字滑窗近似分词,不引依赖)。命中为零是真实信号("查不到"),不是异常 | | |
| 295 | +| 按需 Deep Research(新模块) | `server/services/deep-research.js`——`triggerDeepResearch({companyId, task, topic})`:图外直接 `AgentManager.callAgent('org-hunt', ...)`,轻量提示词,不套用 org-hunt 训练场景的三件套契约。**默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量,不为 `'1'` 直接返回 `null`,连注入的假实现都不调用) | | |
| 296 | +| `subtask-planning.js` 两段式改造 | `defaultPlanSubtasks`:先查经验库拼进提示词 → 调一次 agent,agent 自评 `confidence` → 经验为空或 `confidence:"low"` 就触发调研 → 有结果就带着调研结果再调一次取代第一轮。`reasoning_source`(`model_only`/`memory`/`deep_research`)由服务端根据实际发生的分支算出,不是 agent 自报的。新增更细粒度注入点 `setSubtaskAgentCaller`(仿 `dream-service.setDreamAgentCaller`),能测到这段编排逻辑本身,不只是测"落盘"那一层 | | |
| 297 | +| `subtask-plan.json` 增补字段 | `reasoning_source`/`experience_refs`/`research_used`,纯增量、向后兼容旧的注入桩(缺失时兜底 `model_only`/`[]`/`false`) | | |
| 298 | +| `system/agents/subtask-planning/agent.md` | 输出契约加 `confidence` 字段(硬约束 9),补充"你可能会看到两种提示词"一节 | | |
| 299 | +| 测试 | `experience-lookup.test.js`(6 条)、`deep-research.test.js`(6 条,含"关闭开关时哪怕注入了假实现也不调用"这条最重要的安全阀)、`subtask-planning.test.js` 扩展(`extractConfidence` + `buildSubtaskPlanningPrompt` 的经验/调研小节,7 条新增)、`subtask-planning-materialize.test.js` 扩展(新增字段落盘 2 条 + `defaultPlanSubtasks` 三条分支编排 4 条)。全量单测 1177 条,1175 passed,唯二失败(`.env.example` 覆盖 + 测试 id 稳定性)与本次改动无关、改动前即存在。测试全程 `ps aux` 确认零残留 CLI 子进程 | | |
| 300 | + | |
| 301 | +### 与原设计的出入 | |
| 302 | + | |
| 303 | +1. **"历史同类 goal 执行记录"这条来源本轮不做**——只做 agent 经验区块检索。原设计的用户原话把"经验库/memory/dream 沉淀"和"历史同类 goal 检索"并列成一条来源,但后者需要语义检索基础设施(当前没有),贸然上关键词匹配对"同类"这种判断噪音会很大。这是范围收窄,不是遗忘,留给以后需要时再评估怎么做语义检索。 | |
| 304 | +2. **Deep research 复用 org-hunt,但用轻量提示词,不套用它的训练场景三件套契约**(`knowhow_raw.md`/`crawl_config.json`/`seed_data.json` ≥50 条)。原因:那套契约是为 FDE 训练场景校准的,直接套用在"人在等一条 task 入队"这种时间敏感场景会不可控地慢。org-hunt 本身的图节点契约、`deliverable.json` 模板都没动。 | |
| 305 | +3. **Deep research 触发默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量)。org-hunt 的延迟/成功率在"日常任务规划、时间敏感"场景下从未验证过,且 Phase 1 已经因为一个新触发点疏于隔离出过真事故。这跟 Phase 1 `withSubtaskPlanning` 默认 `false` 是同一个谨慎级别——机制已实现且单测覆盖,但真实调用需要用户手动开关、挑单个任务验证。 | |
| 306 | + | |
| 307 | +### 真实端到端回归(2026-09-02,已完成) | |
| 308 | + | |
| 309 | +用真实测试公司 `p2verify0902`(真实 org graph、真实 goal→task→dispatch)+ `ENABLE_DEEP_RESEARCH=1` 跑了两条真实任务,全程 `ps aux` 确认零残留 CLI 子进程。 | |
| 310 | + | |
| 311 | +**发现一个真实缺陷(已修复)**:第一条真实任务(贪吃蛇小游戏)跑完,`reasoning_source` 落成 `"memory"`,但 `experience_refs` 列出的命中全是 `ceo`/`orggraph-planning` 关于"校验失败怎么兜底路由""修复要重新触发验证"这类**跟游戏开发毫无关系**的工程流程经验——查了实际打分才发现根因:原始实现"关键词交集计数、只要 >0 就算命中",一条 100+ 词的任务描述几乎必然跟经验库里的高频工程词汇("验证""检测""流程")偶然重合 1-3 个 bigram,噪音就压过了信号,**直接吃掉了本该触发的调研分支**——这正是 Phase 2 acceptance 标准要求确认的"分支判断正确",第一次真跑就没通过。 | |
| 312 | + | |
| 313 | +修法:打分改成"这条经验自己的词里有多少比例被 task 提到"(对经验块词数归一化的 containment ratio,不是对查询词数),阈值(命中 ≥2 词且比例 ≥12%)拿真实噪音案例(约 3%-4%)和合成的真正相关案例(约 16%-32%)校准。补了一条回归测试固定复现这个真事故场景。修完重启服务、用第二条真实任务(Markdown 转 HTML 工具)重跑:`reasoning_source` 正确变成 `"deep_research"`、`experience_refs` 正确为空、`research_used: true`——分支判断修复生效,且日志确认真的走了两轮调用(subtask-planning → org-hunt → subtask-planning)。 | |
| 314 | + | |
| 315 | +**两条真实任务的分解内容质量都相当好**:贪吃蛇任务给出"先定义状态 → 渲染循环 → 输入监听 → 移动逻辑 → 吃食物 → 碰撞检测"六步,依赖关系合理;Markdown 工具任务甚至体现出"代码块围栏必须最先扫描保护,否则块内的 `#`/`**`/`[]` 会被后续正则误转换"这种真实的解析器实现顺序知识。这直接回答了 Phase 1/2 一直悬而未决的"内容有没有用"——回答是有用。 | |
| 316 | + | |
| 317 | +**一个诚实的局限,没有掩盖**:查了 org-hunt 那次调用的日志,全程没有 `l1-web-fetch` 工具调用痕迹——它大概率是直接用自己训练知识回答的,没有真的去查外部资料。轻量提示词只要求"给一段总结",没有强制要求引用可验证来源、没有强制要求真的调用检索工具。这意味着当前的 `"deep_research"` 分支实际提供的价值更接近"多问一个角色的第二意见",不是名副其实的外部调研——如果以后要坐实"真的查了外部资料"这件事,需要在提示词里明确要求工具调用证据(比如强制要求 sources 是真实可验证 URL),这次没有额外去做,留给以后需要更强调研可信度时再补。 | |
| 318 | + | |
| 319 | +--- | |
| 320 | + | |
| 321 | +## Phase 3 详细设计(v1,2026-09-02,⚠️ 已废弃——见下方「Phase 3 v2」) | |
| 322 | + | |
| 323 | +> **这一版设计和它对应的实现记录整段废弃,但原样保留在下面**(不删):真实回归发现"认领后没有触发源"这个真问题之后,跟用户讨论出的根本原因是——"认领"(pull,CAS 抢占)解决的是"多方竞争同一条任务",但实际场景里从头到尾只有 CEO 一个人拍板要不要派、派给谁,压根不存在竞争,CAS 是在给不存在的问题找解。v2(本文档更后面)改成 CEO 直接派发(push),砍掉了 `claim`/`unclaim` 两个端点和 `claimable`/`claimed` 两个状态。留着 v1 不删是因为这段"先做认领、跑真实回归、发现问题、推翻重来"的过程本身有价值——**它是这次为什么改主意的第一手证据**,删掉只留结论会让人以为 v2 是一次性设计对的。 | |
| 324 | + | |
| 325 | +### 前置决策(跟用户对齐) | |
| 326 | + | |
| 327 | +Phase 3 最关键的架构分叉——可认领子任务要不要复用现有 `tasks` 表的 `parent_task_id` 机制——**已跟用户对齐:复用 `tasks` 表,加字段区分类型**。原因见下节,这条决定直接决定了后面所有设计。 | |
| 328 | + | |
| 329 | +### 数据模型:新增 `subtask_kind` 字段 + 两个新 `status` 值 | |
| 330 | + | |
| 331 | +`tasks` 表新增列 `subtask_kind`(`NULL` | `'acceptance_item'` | `'claimable_work'`),CHECK 约束仿 `acceptance_mode` 那次迁移的写法(batch 迁移): | |
| 332 | + | |
| 333 | +- `NULL`:普通 task(现状不变,绝大多数行)。 | |
| 334 | +- `'acceptance_item'`:43 号文档现有机制——`maybeMaterializeItemizedSubtasks` 从 `acceptance.md`/`goal-benchmark.json` 物化出的验收条目,**行为完全不变**,只是把它们打上一个明确的类型标签(之前隐含在"parent_task_id 非空"里,现在显式区分)。 | |
| 335 | +- `'claimable_work'`:Phase 3 新增——`subtask-planning` 产出的 `subtask-plan.json` 里 `steps[]` 物化出的可认领工作项。 | |
| 336 | + | |
| 337 | +两者都用 `parent_task_id` 指向同一条父 task,语义/生命周期完全独立,靠 `subtask_kind` 分流——这是为什么不需要开新表:现有 `tasks` 表本来就是"通用工作项容器",`subtask_kind` 只是给容器里已经混杂的两种东西正名,不是往一个干净的模型里硬塞第二套语义。 | |
| 338 | + | |
| 339 | +`status` 枚举新增两个值:`'claimable'`(已物化、开放认领、还没人要)、`'claimed'`(已被认领,等待被处理——处理方式见下"认领后怎么办"一节)。只对 `subtask_kind='claimable_work'` 的行有意义,其余行继续用现有 `draft|queued|running|completed|failed|blocked|cancelled` 六态,不受影响。新增 `claimed_at` 时间戳列(认领发生的时间,用于超时/僵尸认领巡检——复用心跳巡检已经在用的"时间戳 + 巡检" 模式,不新造一套监控)。 | |
| 340 | + | |
| 341 | +### 必须同步改的一处:`settleNode` 的 `SUBTASKS_INCOMPLETE` 硬闸门要收窄 | |
| 342 | + | |
| 343 | +`graph-engine.js:1881-1889`(43 号文档实现)现在是: | |
| 344 | + | |
| 345 | +```js | |
| 346 | +// coder-validator 报 success 时,查 parent_task_id 指向该 task 的所有行是否 completed | |
| 347 | +SELECT id, status FROM tasks WHERE parent_task_id = ? | |
| 348 | +``` | |
| 349 | + | |
| 350 | +Phase 3 一旦开始物化 `claimable_work` 行,这条查询会立刻把它们也扫进去——一堆天然不会是 `completed`(它们的终态是 Phase 4 才要定义的东西)的 `claimable_work` 行会让这条闸门对**所有**开了 `subtask-planning` 的 task 都误判 `SUBTASKS_INCOMPLETE`,把本该正常通过的验收打成失败。**这是实现时第一件必须做的事,不是可选项**:查询加一条 `AND subtask_kind = 'acceptance_item'`,把两套子任务的完成判定彻底分开。 | |
| 351 | + | |
| 352 | +### 触发/物化时机:跟 `subtask-planning` 同一时刻,不等 org-planner | |
| 353 | + | |
| 354 | +43 号文档的验收条目物化时机是"org-planner 完成后"(依赖它的产出)。Phase 3 的物化对象不一样——`subtask-plan.json` 在 `enqueueTaskRun` 时就异步产出了(`triggerSubtaskPlanning`),比 org-planner 开始规划早得多。**物化时机定在 `triggerSubtaskPlanning` 成功写完 `subtask-plan.json` 之后立刻做**,不拖到 org-planner 跑完: | |
| 355 | + | |
| 356 | +- 呼应用户原始诉求"分解任务 → 所有 agents 围绕这个任务看板来工作,一层一层地接子任务"——如果拖到 org-planner 整个流程跑完才有"看板","围绕看板协作"这句话在 org-planner 执行期间根本无从谈起。 | |
| 357 | +- 技术上更省事:`triggerSubtaskPlanning` 已经是 best-effort、绝不 throw 的收尾逻辑,加一步物化不引入新的失败模式(复用同一套"软失败,只打日志"的处理)。 | |
| 358 | + | |
| 359 | +具体实现:`steps[].depends_on_hint` 是这批 steps 内部的 1-based `seq` 引用,物化时先建好全部行拿到真实 `task.id`,再回填 `depends_on`(复用 `tasks.depends_on` 现有的"JSON 数组存 task id"格式,不新造依赖表示法)——`depends_on_hint` 本身"是提示不是硬依赖"这条设计(Phase 1 agent.md 就这么写的)延续下来:Phase 3 物化后的 `depends_on` 同样只做展示,**不做认领前置校验**(见下"明确不做")。 | |
| 360 | + | |
| 361 | +### 认领 API:这是本轮唯一真正的新机制 | |
| 362 | + | |
| 363 | +调研确认:**全仓库目前没有任何"认领/接单(pull)"语义的端点**——`executor='agent'` 那条路径是纯粹的"派发(push)":发送方在 `/task/dispatch` 请求体里显式指定 `to`,从没有过"多方竞争同一条任务,谁先抢到算谁的"这种场景,所以也没有任何 CAS 并发保护先例可抄。这是 Phase 3 唯一必须新写的东西: | |
| 364 | + | |
| 365 | +``` | |
| 366 | +POST /api/company/:companyId/tasks/:taskId/claim {agentId} | |
| 367 | +``` | |
| 368 | + | |
| 369 | +服务端逻辑(仿 `claimQueuedTask` 的 CAS 写法,`work-graph-db.js:2209`): | |
| 370 | + | |
| 371 | +```sql | |
| 372 | +UPDATE tasks SET status='claimed', assignee_agent_id=?, claimed_at=? | |
| 373 | +WHERE id=? AND subtask_kind='claimable_work' AND status='claimable' | |
| 374 | +``` | |
| 375 | + | |
| 376 | +`changes === 0` 就是"没抢到"(要么已被别人认领,要么状态不对),返回 409,不是 500——这是预期会发生的正常竞争结果,不是错误。对称补一个 `POST .../unclaim`(`status='claimed' → 'claimable'`,`assignee_agent_id` 清空)——认领了发现做不了要能退回去,成本很低,没理由不做。 | |
| 377 | + | |
| 378 | +**认领角色范围**:不引入能力匹配(调研确认 `system_agents`/`company_agents` 表没有任何结构化角色能力字段,能不能干一件事完全靠 agent 自己读 `agent.md` 判断,代码层不做匹配)。Phase 3 用最简单的白名单:`claimable_work` 行默认只对 `role_name='org-coder'` 开放认领(当前系统唯一的通用"写代码"角色,且物化出的 steps 基本都是实现类工作),认领 API 校验调用者的 `role_name` 是否在白名单里,不在直接拒绝。这个白名单先写死,不是留数据库字段——真需要扩展到多角色再加,不为假想的未来预先建模。 | |
| 379 | + | |
| 380 | +### 认领后怎么办:复用现成的 agent 派单路径,不新起执行机制 | |
| 381 | + | |
| 382 | +这是这轮设计里最重要的一处化简,值得单独说清楚:最初以为"认领后要真正驱动执行"必然要碰 `graph-engine.js`(比如给每个 `claimed` 的子任务起一次独立 work-graph run),那就是 Phase 4 的范畴,而且是范式级改动。但调研 `executor='agent'` 那条路径后发现——**它本来就是"一条 task 归某个 agent 负责、这个 agent 自己想办法搞定、搞定后自己调 `/task/report` 上报"的完整闭环**,跟"认领了之后谁去干"这个问题的答案高度重合。 | |
| 383 | + | |
| 384 | +所以 Phase 3 的落地方案是:`claim` 成功后,除了状态转成 `claimed`,**同时把这条行的 `executor` 设成 `'agent'`**——这样它立刻变成一条普通的"agent 自主任务",认领它的 agent(多半是 org-coder)用它已经在用的既有机制(自己决定怎么干、干完调 `/task/report`)走完剩下的生命周期,`META_TO_TASK_STATUS` 状态机(`agent-chat.js:1795`)直接接管后续的 `dispatched → done/failed/blocked/cancelled` 转移,**不需要 Phase 3 再发明一套新的执行触发逻辑**。`task-scheduler.js` 明确只认 `executor='graph'`(367 行注释已经写了这条判据),所以这条行天然不会被现有的图排队/并发逻辑碰到,两套系统互不干扰。 | |
| 385 | + | |
| 386 | +这个方案的好处:Phase 3 做完,"认领"不是一个没人会因此真正干活的静态标记,是一个立刻能跑的真实闭环,而且**零新增执行机制、不碰 graph-engine 一行代码**,完全落在 Phase 1 已经定案的边界内("图本身怎么跑不变")。代价是诚实的:`org-coder` 认领到的这一步"怎么干"完全靠它自己(可能是直接写代码、可能是走它熟悉的其他工具),没有 Phase 4 设想的那种"独立开发 + 独立验证"的正式生命周期(写代码有没有经过验证、有没有走测试,全凭 org-coder 自己负责)——这正是 Phase 4 真正要解决的问题:现在这条最小可用路径能跑,但"跑得糙",要不要、以及怎么给它补上正式的验证循环,留给 Phase 4 设计时决定。 | |
| 387 | + | |
| 388 | +### 明确不做(本轮范围边界) | |
| 389 | + | |
| 390 | +- **不做依赖顺序强制**:`depends_on` 只展示,不阻止认领——判断"前一步是否真的做完了"需要一个可靠的完成判据,Phase 3 阶段(`executor='agent'` 自主执行)没有这个判据,强行卡会卡出一堆认领不了的死锁。 | |
| 391 | +- **不做能力匹配**:认领角色白名单写死 `['org-coder']`,不引入任何"这一步该给谁"的推断逻辑。 | |
| 392 | +- **不碰 `graph-engine.js` 执行/调度模型**:认领这件事完全在 `tasks` 表 + 一个新端点里完成,不新增图节点类型、不改拓扑推进逻辑。 | |
| 393 | +- **不做 Phase 4 设想的"独立开发+验证循环"**:认领后的执行完全复用现有 `executor='agent'` 闭环,`org-coder` 自己对交付质量负责,没有独立验证阶段。 | |
| 394 | +- **不做看板 UI(Phase 5)**:可认领池先靠 `GET /:companyId/tasks?subtaskKind=claimable_work&status=claimable` 这类查询参数(复用现有 `parentTaskId` 过滤模式加一个新参数)暴露数据,UI 展示留给 Phase 5。 | |
| 395 | + | |
| 396 | +### Phase 3 验收标准(后续实现完要检查的) | |
| 397 | + | |
| 398 | +1. `subtask-plan.json` 产出后,`claimable_work` 子任务是否正确物化(`depends_on` 回填正确、`status='claimable'`)? | |
| 399 | +2. 有 `subtask-planning` 挂钩的 task,`coder-validator` 的验收判定是否完全不受这些新增 `claimable_work` 行影响(`SUBTASKS_INCOMPLETE` 闸门收窄生效)? | |
| 400 | +3. 并发认领同一条子任务,是否只有一方成功(CAS 生效,409 不是 500)? | |
| 401 | +4. 认领成功后,这条行是否真的能走完 `executor='agent'` 的既有生命周期(`org-coder` 自己完成、`/task/report` 上报、状态正确落到终态)? | |
| 402 | +5. 非白名单角色尝试认领,是否被正确拒绝? | |
| 403 | + | |
| 404 | +## Phase 3 实现记录(v1,2026-09-02,⚠️ 代码已被 v2 替换/删除——见「Phase 3 v2」) | |
| 405 | + | |
| 406 | +> 本节记录的代码(`claimSubtask`/`unclaimSubtask`/`/claim`/`/unclaim`/`claimable`/`claimed`)**已经不在仓库里**。保留这份记录是因为下面的"真实端到端回归"小节记录了促成 v2 拍板的两个关键发现(认领没有触发源、`/task/report` 耦合了非 graph 回调),这两个发现本身仍然成立、仍然是 v2 存在的理由,不能删。 | |
| 407 | + | |
| 408 | +### 落地清单 | |
| 409 | + | |
| 410 | +| 内容 | 位置 | | |
| 411 | +|---|---| | |
| 412 | +| schema:`subtask_kind` + `claimed_at` | `work-graph-db.js`(批 6 迁移,`ALTER TABLE ADD COLUMN`,纯增量向后兼容;`subtask_kind` 取值域 `'acceptance_item'|'claimable_work'`,只有新建库有 CHECK,存量库应用层守,跟 `acceptance_mode` 那次迁移同一套限制) | | |
| 413 | +| `createTask` 校验 | 新增 `subtaskKind`/`status` 参数:`subtaskKind` 非法值拒、没有 `parentTaskId` 拒;`status` 只放开 `'draft'`/`'claimable'` 两个初始值,`'claimable'` 只能配 `subtaskKind='claimable_work'` | | |
| 414 | +| 认领 CAS | `claimSubtask`/`unclaimSubtask`(`work-graph-db.js`)——全仓库第一个"认领(pull)"语义写入,照抄 `claimQueuedTask` 的 `changes` 判据。认领成功同时把 `executor` 改成 `'agent'`,复用既有 `executor='agent'` 派单闭环(`/task/report` 上报),**不碰 `graph-engine.js` 一行调度逻辑**,跟设计阶段的判断一致 | | |
| 415 | +| 物化 | `materializeClaimableSubtasks`(`work-graph-db.js`)——两阶段建法处理 `depends_on_hint` 前向引用,幂等(按 `subtask_kind='claimable_work'` 计数),从 `subtask-planning.triggerSubtaskPlanning` 写完 artifact 后立刻调用,不等 org-planner | | |
| 416 | +| **必须同步改的坑,已修** | `graph-engine.js` 的 `settleNode` `SUBTASKS_INCOMPLETE` 闸门查询收窄成 `AND subtask_kind = 'acceptance_item'`;`maybeMaterializeItemizedSubtasks` 的幂等判据和 `createTask` 调用同步打上 `subtask_kind='acceptance_item'`。两处都补了回归测试(见下) | | |
| 417 | +| 路由 | `POST /:companyId/tasks/:taskId/claim`、`.../unclaim`;`taskView` 补 `subtaskKind`/`claimedAt`;`GET /:companyId/tasks` 加 `subtaskKind` 过滤(可认领池查询=`?subtaskKind=claimable_work&status=claimable`,不用专门开池子端点) | | |
| 418 | +| 角色白名单 | 写死 `CLAIMABLE_WORK_ROLES = ['org-coder']`(`company.js`),不做能力匹配 | | |
| 419 | +| 测试 | 新增 `task-pool-claim.test.js`(23 条:DB 层 `createTask` 校验/`materializeClaimableSubtasks`/CAS + 路由层白名单/并发/放弃);`task-decomposition-validator-gate.test.js` 补 1 条回归测试(`claimable_work` 子任务不影响 `acceptance_item` 验收判定)+ 更新既有用例显式打 `subtaskKind`(闸门收窄后这些用例如果不补 `subtaskKind: 'acceptance_item'` 会全部失效——查询看不到没打标的行,等于闸门形同虚设,真被这次改动摸到了);`task-decomposition-materialize.test.js` 补 `subtask_kind` 落库断言。全量单测 1202 条,1200 passed,唯二失败与本次改动无关、改动前即存在 | | |
| 420 | + | |
| 421 | +### 一次诚实的记录:本地库被顺手迁移了一次 | |
| 422 | + | |
| 423 | +排查一处语法错误(SQL 模板字符串里误用了反引号,把 `db.exec` 的模板字符串提前闭合——`work-graph-db.js` 建表块头部本来就有一条注释警告这件事,这次还是踩上了)时,用了一句 `node -e "require('./server/services/work-graph-db.js')"` 做语法检查——这条命令**没有设 `GRAPH_DB_PATH`**,不是测试进程(没有 `assertNotProdDbInTests` 的拦截),实际对本机 `data/graph.sqlite`(本地开发库,Phase 2 真实回归的测试公司数据也在里面)跑了一次批 6 迁移。核实过:迁移是纯增量的 `ALTER TABLE ADD COLUMN`,102 条既有 task 行原样在、`subtask_kind` 全部是 `NULL`,没有数据损坏。但这本可以用一次性临时库避免,记在这里是给自己留个教训——语法检查也该走隔离环境,不该因为"只是 require 一下"就放松。 | |
| 424 | + | |
| 425 | +### 真实端到端回归(2026-09-02,已完成,发现两个真实问题) | |
| 426 | + | |
| 427 | +用真实公司 `p2verify0902` 走完一次真实链路:真实 goal→task→dispatch → `subtask-planning` 真实产出 4 步分解并物化 → 可认领池查询(`?subtaskKind=claimable_work&status=claimable`)正确返回 4 条,`depends_on` 正确解析成真实 task id(连"done 步骤依赖 list 步骤"这种需要理解语境的依赖关系都推对了)→ 认领 CAS 全部按预期(非白名单角色 403、重复认领 409、放弃 + 重新认领正确)→ `/task/report` 真的把 `claimed → completed` 转正确,`summary`/`finished_at` 落库正确,`description` 原样保留(round-trip 安全)。全程 `ps aux` 确认零残留 CLI 子进程(会话池里保留的常驻进程随服务器关闭一起清零,不是泄漏)。 | |
| 428 | + | |
| 429 | +**发现一:认领之后没有任何机制通知 org-coder。** `claimSubtask` 纯粹是一次数据库状态变更——认领 / 放弃 / 重新认领全程,服务端日志零 agent 活动。也就是说,除非有人或有别的机制手动调用 `/claim`,这些可认领子任务会永远原地躺着,**没有任何东西会主动去发现它们、认领它们**。这是设计阶段"认领后怎么办"一节已经承认的缺口("跑得糙"),真实回归把它坐实成了一个具体、可复现的观察:机制本身工作正常,但**没有触发源**。 | |
| 430 | + | |
| 431 | +**发现二:`/task/report` 不是一条干净的"状态回写"端点,它耦合了一整套遗留的非 graph 回调机制。** 调用 `POST /api/agent/task/report`(`server/routes/agent-chat.js`)之后,除了把 `tasks` 表状态转过去,还会**自动触发一次 `selfChat` 回调**——真实唤醒 `to` 指定的那个 agent,起一个带真实文件读写工具(`allowedTools: bash/read_file/str_replace/write_file`)的新 CLI 会话,提示词是"请检查上述任务的执行结果,然后根据你的 plan.md 继续执行下一步计划"。真实回归时这条回调确实触发了、真的起了一个新 CLI 进程、跑完自己正常退出,没有异常,但这意味着"复用现有 `executor='agent'` 闭环"这个设计判断,代价比设计阶段预想的更高——调 `/task/report` 不是"安静地改一行数据库",是"顺带唤醒另一个 agent 会话"。 | |
| 432 | + | |
| 433 | +**`/task/report` 的执行载体是什么**:它**既不是 org graph 节点,也不是 work graph 节点**——`agent-chat.js` 的 `/task/dispatch` ↔ `/task/report` 是一套完全独立于 `graph-engine.js` 的**非 graph 协议**(`work-graph-db.js` 里的原话:"agent 之间派单(`POST /api/agent/task/dispatch`)与「跑一张 org graph」是两种执行载体")。它是一个普通的 Express 路由处理函数,被 HTTP 请求直接调用,不挂在任何图的节点上、不经过 `graph-engine.settleNode`/`advanceOne` 那套结算逻辑。`claimSubtask` 复用的 `executor='agent'` 状态机和这条路由,是与 org/work graph 完全平行的第二套执行世界。 | |
| 434 | + | |
| 435 | +### 遗留给 Phase 4 的问题 | |
| 436 | + | |
| 437 | +- 现在"认领后怎么干"完全靠 `org-coder` 自主决定,质量没有保障——要不要给 `claimed` 状态补一段正式的"开发 + 验证"生命周期?如果要,是新起一次简化版 work-graph run,还是在现有 `executor='agent'` 闭环里加一道可选的验证钩子(成本更低,但仍然不是"独立 run")?两个方向哪个都不是这次 Phase 3 该决定的事,需要单独评估。 | |
| 438 | +- 子任务级预算:`executor='agent'` 现有闭环目前完全没有预算概念(`budget_json` 只在 `executor='graph'` 的 run 上有意义)——如果 Phase 4 决定要给认领的子任务设预算,需要先补这个空白。 | |
| 439 | + | |
| 440 | +--- | |
| 441 | + | |
| 442 | +## Phase 3 v2:CEO 直接派发(2026-09-02,代码已实现,未做真实端到端回归) | |
| 443 | + | |
| 444 | +### 为什么推翻 v1(讨论过程,不是事后诸葛亮) | |
| 445 | + | |
| 446 | +v1 真实回归跑完,用户追问"认领后没有触发源,谁来触发"——讨论过程: | |
| 447 | + | |
| 448 | +1. 用户最初设想:每个候选角色(`org-coder`)自己开心跳,扫描"有没有可认领的、匹配我角色的子任务",够条件自己 `/claim`。 | |
| 449 | +2. 反对意见(用户自己提出):心跳生命周期管理的开销是真实问题——几百个 agent 都开心跳,谁来决定什么时候启、什么时候停?如果还是需要 CEO 来管这些心跳的启停,那跟 CEO 直接扫一遍指派没有区别,等于多绕了一层。 | |
| 450 | +3. 拍板:**CEO 心跳统一发现 + 直接指派(push),不需要认领机制**。CEO 本来就有心跳、本来就在盯 task 板,成本最低;CEO 还能顺带控制派发节奏(先放哪批、等哪批)。 | |
| 451 | +4. 顺着这条拍板往下查:现有 `enqueueTaskRun`(起 org/work graph run 的唯一入口)默认假设自己服务顶层 task(硬要求 `status==='draft'`,起 run 时把 status 强改成 `'queued'`)——这个状态转移**跟 v1 的 `claimable`/`claimed` 状态机直接冲突**(CAS 会静默失败)。查证过程中发现:v1 设计"认领"要解决的"多方竞争"场景,在"CEO 一人拍板"的模型下根本不存在——CAS 是在给不存在的问题找解,不是保留价值、只是不再需要的复杂度。**拍板删除 `claim`/`unclaim` 和相关状态**,改成"物化即普通 draft task,CEO 判断节奏后调现成的 `/enqueue`"。 | |
| 452 | +5. 顺带定了执行载体:"先跑一个轻量的子任务图看效果,遇到质量问题再升级成完整 org graph"——不是先验地假设哪个对,是留一个可以真实观察、按需升级的路径。 | |
| 453 | + | |
| 454 | +### 落地清单 | |
| 455 | + | |
| 456 | +| 内容 | 位置 | | |
| 457 | +|---|---| | |
| 458 | +| 删除的代码 | `claimSubtask`/`unclaimSubtask`(`work-graph-db.js`)、`POST /claim`/`POST /unclaim`(`company.js`)、`CLAIMABLE_WORK_ROLES` 白名单、`claimable`/`claimed` 两个 status 值、`claimed_at` 列(连同批 6 迁移里加它的那部分) | | |
| 459 | +| 改名 | `subtask_kind` 的枚举值 `'claimable_work'` → `'decomposed_work'`(连同 `materializeClaimableSubtasks` → `materializeDecomposedSubtasks`)——"认领"这个词不再准确,趁还没发布改掉比以后改省事 | | |
| 460 | +| 物化行为简化 | `materializeDecomposedSubtasks` 物化出的行现在就是普通 `status='draft'` task(不再有专属初始状态),`executionMode='auto'`(CEO 一旦 enqueue 就该一路跑完,不停在入口等人工 `/next`) | | |
| 461 | +| **新增:递归防护** | `triggerSubtaskPlanning`(`subtask-planning.js`)新增 `if (task.parent_task_id) return`——这是设计 v2 时才发现的真实漏洞:CEO 对 `decomposed_work` 子任务调 `/enqueue` 时,该端点默认 `withSubtaskPlanning:true`,不挡的话会对一条已经是"第几步怎么做"粒度的子任务再拆一层,浪费调用还可能无限递归。已用测试验证挡住 | | |
| 462 | +| **新增:轻量子任务图** | `createDecomposedWorkGraph`(`work-graph-db.js`)——现造一张两节点 work graph(`impl`=`org-coder`、`validate`=`org-validator`,都是 `agent_call` 不是 `subgraph`),跳过公司完整 org graph 的 ceo→cto→org-planner→org-coder→org-validator 规划仪式。理由:`subtask-planning` 已经把"怎么做"拆到这一步的粒度了,再走一遍 org-planner 的 brd/prd/sdd 纯属重复劳动 | | |
| 463 | +| `enqueueTaskRun` 图选择 | `company.js`:`taskRow.subtask_kind === 'decomposed_work'` 时用 `graphKind:'work'` + 现造的图;否则行为完全不变(`graphKind:'org'` + `companyId`),回归测试覆盖 | | |
| 464 | +| CEO 心跳/工具 | `system/agents/ceo/heartbeat.md`、`tools.md` 补了"查 `?subtaskKind=decomposed_work&status=draft`、按节奏 `/enqueue`"的指令——`enqueue` 是 CEO 已经被授权自主调用的现成端点,子任务用同一条权限,不用新开一条 | | |
| 465 | +| 测试 | 删除 `task-pool-claim.test.js`(v1 的认领测试,随代码一起废弃),新增 `task-decomposition-dispatch.test.js`(12 条:DB 层 `createTask` 校验/`materializeDecomposedSubtasks`/`createDecomposedWorkGraph`,路由层 `enqueueTaskRun` 图选择回归 + decomposed_work 分支),更新 `task-decomposition-validator-gate.test.js`/`task-decomposition-materialize.test.js` 的措辞。全量单测 1191 条,1189 passed(唯二失败与本次改动无关、改动前即存在),零残留 CLI 子进程 | | |
| 466 | + | |
| 467 | +### 一处没有真实验证过的假设,如实写在这里 | |
| 468 | + | |
| 469 | +`org-coder`/`org-validator` 平时**只在"公司完整 org graph 的 subgraph 节点"这个上下文里被调用过**——它们的 `agent.md`/`tools.md` 有没有隐含"前面一定有 brd/prd/sdd 产物"这种假设,直接当轻量图里的裸 `agent_call` 节点调用,会不会读不到预期上下文、表现异常——**没有实测过**。图的机制本身(建图、起 run、CAS 判据不冲突)单测覆盖了,但"跑起来输出好不好"是内容问题,机制通不通答不了这个问题。这正是用户拍板"先跑轻量版看效果"这句话里"看效果"的意思——下一步真实回归要验的就是这个。 | |
| 470 | + | |
| 471 | +### 真实端到端回归(2026-09-02,已完成,机制全部验证通过,效果发现一个真实结构性缺口) | |
| 472 | + | |
| 473 | +用真实公司 `p2verify0902` 走完一次真实链路——真实 goal→task→dispatch → `subtask-planning` 产出 2 步分解并物化成 `decomposed_work` 草稿 → **真实 CEO 心跳**(不是模拟)读到 `heartbeat.md` 新加的指令后,真的查了 `?subtaskKind=decomposed_work&status=draft`、真的判断了节奏、真的调了 `/enqueue`——不只是这两条,CEO 还顺带自己发现并处理了一条与本次测试无关的历史遗留卡住的 run(证明心跳修复后其余巡检逻辑也正常了)。触发过程中确认 `triggerSubtaskPlanning` 的递归防护在真实场景下正确拦下了对子任务本身再拆一层的尝试(8 条 `decomposed_work` 行全部被正确跳过)。 | |
| 474 | + | |
| 475 | +**一处真实的额外发现(与 Phase 3 无关,但直接堵住了这次验证,顺手修了)**:触发 CEO 心跳时先撞上 401——`company-heartbeat-service.js`/`heartbeat-service.js` 里所有自环调用 `/api/agent/:agentId/chat`(`agent-chat.js` 挂了 `requireAuthOrInternal`)**都没有带内部令牌**,而 `company.js`/`task-settle.js` 的同类自环调用早就在带。也就是说**心跳系统的两条自触发路径(含 CEO 心跳)一直在暗中 401**,失败只落一行 `console.warn`,界面上什么都看不出来。已按现成模式补上 `INTERNAL_TOKEN_HEADER`(两个文件),修完确认心跳能正常跑完整段推理会话。这条影响面比 Phase 3 大得多,值得单独跟进核实生产环境是否也受影响。 | |
| 476 | + | |
| 477 | +**"看效果"的真实结果——机制通,内容质量分裂**: | |
| 478 | + | |
| 479 | +- `org-coder`(impl 节点)**表现完全达标**:给单位换算任务真实产出了 `src/convert.js`(正确的换算因子:km=1000/m=1/mi=1609.344/ft=0.3048)+ 一份质量相当好的测试文件(含同单位恒等、非法单位报错、往返精度、浮点容差等边界情况),**完全没有 brd/prd/sdd 上游产物,产出依然靠谱**——这条验证了"轻量图跳过 org-planner 规划仪式"这个核心假设成立。 | |
| 480 | +- `org-validator`(validate 节点)**卡死,`input_underspecified`**:它调用的 `goal-contract.js validate --run-dir` 硬性要求 `artifacts/acceptance.json` 存在,而这份文件只由 org-planner 的 work graph(`goal-benchmark`/`goal-test-data`/`goal-test-script` 三个节点)产出——轻量图完全跳过了 org-planner,这份文件天然不存在。**org-validator 自己给出的诊断相当精确**:明确写"本次上游 coder 节点已产出 src/convert.js + test/convert.test.js,属正常交付,不是本次失败的责任方",把根因准确指向 org-planner 缺位,`target_org_node` 字段也写对了。 | |
| 481 | +- 依赖阻塞也验证了:第二个子任务(依赖第一个)在第一个卡在 `blocked`(不是 `completed`)后,正确地没有被调度器放行,一直停在 `queued`——`task-scheduler` 的既有依赖推导对 `decomposed_work` 子任务同样生效,不需要 CEO 自己算顺序,讨论时的判断成立。 | |
| 482 | + | |
| 483 | +### 结论:轻量图的"实现"这一半跑通了,"验证"这一半有一个真实的结构性缺口 | |
| 484 | + | |
| 485 | +不是 agent 表现不好,是图的构成本身缺一块——`org-validator` 依赖的验收契约只能来自 org-planner,轻量图里没有任何节点能产出它。**这不是"再调一下提示词"能解决的**,是"要不要跳过 org-planner"这条设计决策本身的直接代价。摆几个方向留给下一步讨论: | |
| 486 | +1. 轻量图加一个第三节点,专门产出一份最小的 `acceptance.json`(不跑 org-planner 整套 brd/prd/sdd,只把 `subtask-planning` 已经写好的"这一步要做什么"转成 goal-contract 认的验收格式); | |
| 487 | +2. `org-validator` 之外找一个不依赖 `goal-contract.js`/`acceptance.json` 的轻量验证方式(比如直接跑 `org-coder` 自己写的测试文件、看退出码); | |
| 488 | +3. 按用户原话的退路——这条子任务级别就不追求跑通 `org-validator`,直接升级成完整 org graph(这就是当初讨论"先跑轻量版看效果,不行再走完整 org graph"里"不行"的具体触发条件)。 | |
| 489 | + | |
| 490 | +**后续讨论收敛到方向 1 的一个更彻底的版本**:不是"轻量图加一个专门产契约的节点",而是"每个 task/subtask 创建时就必须 1:1 带一份验收契约"——把约束点从"某张图怎么搭"提到"task 这个实体本身"。完整讨论过程、方案取舍、以及需要改动的 agent/代码盘点见 [48 号文档](./48-task-acceptance-contract-binding.md)。 | ... | ... |
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 时应该想清楚要不要保留这条兜底、还是彻底换成新机制。 | ... | ... |
docs/todo/system-design/48-task-acceptance-contract-binding.md
0 → 100644
| 1 | +# 48 · Task/Subtask 验收契约 1:1 强绑定 | |
| 2 | + | |
| 3 | +> **状态**: ✅ 已实现并完成真实端到端回归(decomposed_work 链路,含真实 `org-coder`/`org-validator` CLI 全程跑通,`org-validator` 判词 `fail_reason=null`,不再 `input_underspecified`)。安全网仍只是原则,未写代码。 | |
| 4 | +> **日期**: 2026-09-02(补充真实回归:2026-09-03) | |
| 5 | +> **触发场景**: 46 号文档 Phase 3 v2 真实回归时,`org-validator` 在轻量子任务图(`org-coder` 实现 → `org-validator` 验证,跳过公司完整 org graph 的 `org-planner` 规划仪式)里卡死——`goal-contract.js validate --run-dir` 报 `input_underspecified`(没有 `acceptance.json`),`org-validator` 按它自己的硬指令"退 `org-planner` 重跑",但这张轻量图里**压根没有 `org-planner` 节点**,退无可退。追问下去牵出一个更根本的问题:"验收契约由谁产出"这件事目前完全绑死在"公司完整 org graph 一定会跑到 `org-planner`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 讨论纪要(结构化复述,不是单条原话,是几轮讨论收敛出的结论) | |
| 10 | + | |
| 11 | +用户最初的观察:*"org validator 因为没 org planner 的产出就卡死了?而不是按照 task 的要求去 validator?"* ——直觉是 validator 应该"以 task 为主",文件只是参考物,不该是硬性评判标准。 | |
| 12 | + | |
| 13 | +讨论过程中的几个关键转折: | |
| 14 | + | |
| 15 | +1. **查证后发现"卡死"是设计使然,不是疏漏**——`org-validator/tools.md` 第 45 行原文:"跑不出判决:没契约/契约非法/参数错 → `input_underspecified`,退 `org-planner`。**不是「那就跳过这条」**"。这条硬约束背后的理由是这个系统这几个月反复验证过的教训(43/44/47 号文档):让 agent 拿自然语言 task 描述自由判断"算不算做完"不可靠,所以验收必须走结构化契约,不能自由心证。**这条纪律本身不该改**。 | |
| 16 | +2. **真正该改的是"契约从哪来"**——用户提炼:"硬约束的产生应该是自由灵活的,不至于被卡死"。也就是说:**消费端(validator 只认结构化契约、不自由判断)该保持硬约束;产出端(谁来写这份契约)不该被锁死在单一角色(`org-planner`)身上**。 | |
| 17 | +3. **讨论过一个"运行时兜底级联"方案**(用户提出):org-planner 写 → 没有则 subtask-planning 写 → 都没有则 code 节点自己写 → 都没有则 validator 自己补一份再判、有问题就把 bug + 自己写的验收标准一起打回给上游重做。这个逻辑本身站得住,但有两个问题: | |
| 18 | + - 对 `decomposed_work` 这条链路,第 3/4 层永远不会被触发——`decomposed_work` 子任务只有一个产生入口(`materializeDecomposedSubtasks`,只在 `subtask-planning` 成功产出 steps 之后才会被调用),所以"没有 planner、也没有 subtask-planning"这个前提在这条链路上不可能成立。给一条不可达的路径写实现,是在为不存在的场景预先建模。 | |
| 19 | + - 更本质的问题:**同一个 agent 既写验收标准、又拿着自己刚写的标准去判自己/别人刚做完的东西**,跟"先出考卷再考试"比,可信度天然打折——即便格式上仍是结构化契约,也更容易"照着已经做出来的答案凑标准"。运行时兜底能保证流程不会永久卡死,但不能同等程度地保证补出来的东西和事先写好的一样可信。 | |
| 20 | +4. **收敛到的方案**:**创建时强绑定**(预防性,主力机制)+ **运行时兜底**(纠正性,安全网,仅记录原则,本轮不实现)。具体:"每一个 task/subtask 天然带着自己的验收契约,这在创建那一刻就必须有,写在 `tasks` 表的字段上,1:1 强绑定"——用户原话:"这样是不是更简单?"。确认:**是**,比"在某个特定图形状里临时补数据"更彻底,因为约束点提到了 `createTask` 这个所有创建路径的公共入口,不用每条路径各自记得要不要造契约。 | |
| 21 | + | |
| 22 | +--- | |
| 23 | + | |
| 24 | +## 现状调研(本次讨论核实的代码事实) | |
| 25 | + | |
| 26 | +### `acceptance.json` / `acceptance.md` / `goal-benchmark.json` 是三个不同的东西(讨论中容易混用,需要先分清) | |
| 27 | + | |
| 28 | +| 产物 | 落点 | 产出方 | 消费方 | 格式 | | |
| 29 | +|---|---|---|---|---| | |
| 30 | +| `acceptance.json`(R19-B 契约) | `tasks/{taskId}/acceptance.json`,`goal-contract.js` | `goal-benchmark` 节点(唯一真正调 `put` 的 agent) | `org-validator`/`coder-validator`/`benchmark-validator`/`testdata-validator`/`planner-validator`/`validator-triager` | 结构化 JSON,`{benchmark, test_data, test_script}` 三段 | | |
| 31 | +| `acceptance.md` | org-planner 私有目录 | `org-planner/agent.md` 硬约束要求的四份产物之一 | 43 号文档的 `maybeMaterializeItemizedSubtasks`(首选数据源) | Markdown 验收表格 | | |
| 32 | +| `goal-benchmark.json` | `runs/{rootRunId}/artifacts/`(共享区) | `goal-benchmark` 节点 | `goal-contract.js`(被 `$ref` 指到)+ `maybeMaterializeItemizedSubtasks` 兜底数据源(47 号文档新增) | 结构化 JSON,`dimensions[]`/`traceability[]` | | |
| 33 | + | |
| 34 | +本文档只关心第一个(`acceptance.json`,`org-validator` 真正用来判卷的那份)。跟 43/47 号文档的"子任务物化"是两回事,不要混。 | |
| 35 | + | |
| 36 | +### 契约产出链是 `workgraph-planning` 现场展开出的子图节点,不是独立体系 | |
| 37 | + | |
| 38 | +`org-planner` 执行时,`workgraph-planning` 依据 `planner.json` 骨架(`brd → prd → sdd → benchmark → validator`)+ 一套裁剪判据现场规划出子图。`benchmark` 节点被标注"不可裁"——理由是它产的 `acceptance` 被**另一张图**(`org-validator` 宿主的 `validator.json`)跨图消费,裁了就是跨图断供。也就是说,现有机制里"契约一定会被产出"这件事,本身建立在"`workgraph-planning` 一定会把 `goal-benchmark` 节点规划进图里"这个假设上——**这个假设只在完整 org graph 路径里成立**,46 号文档的轻量子任务图完全绕开了 `workgraph-planning`,这个假设自然不成立。 | |
| 39 | + | |
| 40 | +### 写入 API 已经是现成的、可直接程序化调用的函数 | |
| 41 | + | |
| 42 | +`server/services/goal-contract.js` 导出 `writeContract(runDir, contract)` 和 `writeContractSection(runDir, {section, content, goalId, createdBy})`——后者是"产出侧唯一该用的入口"(文件内注释原话),CLI 的 `put` 子命令只是它的一层包装。两者都是纯函数、不依赖 CLI 进程,可以直接从 `enqueueTaskRun`(Node.js 代码)里调用,不需要 shell 出去跑 `goal-contract.js put`。 | |
| 43 | + | |
| 44 | +`test_script` 段的形状要求:`{runner: 'node', path: <artifacts/ 内相对路径>, args?, timeout_ms?}`——`runner` 白名单目前只有 `node`。 | |
| 45 | + | |
| 46 | +--- | |
| 47 | + | |
| 48 | +## 设计决策 | |
| 49 | + | |
| 50 | +### 主力机制:`tasks` 表新增 1:1 字段,`createTask` 统一校验(预防性) | |
| 51 | + | |
| 52 | +新增列(暂定名 `acceptance_contract`,TEXT,JSON blob,形状复用 `goal-contract.js` 的 `{benchmark, test_data, test_script}`,走应用层校验、不新造格式——跟 `acceptance_mode`/`subtask_kind` 同一套迁移写法,`ALTER TABLE ADD COLUMN`,新建库有 CHECK、存量库应用层守)。`createTask` 用 `goal-contract.js` 已经导出的 `validateContract` 做形状校验,不重写一遍规则。 | |
| 53 | + | |
| 54 | +> ⚠️ **这段的具体形状被真实回归推翻了一半,见下方「实现记录」§「与原设计的出入」**:最终落地的 `acceptance_contract` 存的是 **benchmark 段本身**(`{dimensions, overall_pass_threshold?}`),不是这里写的三段式 wrapper——校验/写入时才现套一层。本段留着不改是为了如实记录设计过程,别照这段的字面形状抄代码。 | |
| 55 | + | |
| 56 | +**本轮范围只收在 `decomposed_work` 这条链路**(`subtask-planning` → `materializeDecomposedSubtasks` → `enqueueTaskRun` 的轻量图分支),不动 `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单(`POST /:companyId/tasks`)这几条创建路径——`task-planning` 在 46 号文档 Phase 1 设计时就明确保护过"职责不变、极简、3 轮内、不碰磁盘",把"必须交结构化验收契约"这个要求推给它,值得单独起一轮讨论,不该这次顺手带上。 | |
| 57 | + | |
| 58 | +### 安全网(仅记原则,本轮不写代码):发现缺失时就地补一份,但不与判卷同一次完成 | |
| 59 | + | |
| 60 | +如果以后出现另一种既没有 `subtask-planning` 也没有 `org-planner` 的图形状,撞上契约缺失时的兜底原则:**发现者退一步,先独立于已完成的产出补一份契约,再判**——不要在同一次调用里"边写标准边照着标准判",尽量保住"先立标准、后判"这个顺序。这条原则不需要现在写成代码,`decomposed_work` 这条链路走主力机制就已经完全覆盖,留给以后真的出现新图形状时再决定怎么接。 | |
| 61 | + | |
| 62 | +### 与既有机制的边界 | |
| 63 | + | |
| 64 | +- `org-coder`、`org-validator`、`goal-benchmark` 等所有既有 agent 的 `agent.md`/`tools.md` **不需要改一行**——它们不关心契约从哪来,`enqueueTaskRun` 把契约落到它们熟悉的 `artifacts/acceptance.json` 位置,对它们来说和平时公司完整 org graph 产出的契约没有区别。 | |
| 65 | +- 43/47 号文档的物化机制(`acceptance.md`/`goal-benchmark.json` → `acceptance_item` 子任务)完全不受影响,是另一套东西。 | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## 开发规划:需要改动的 agent 和代码(盘点) | |
| 70 | + | |
| 71 | +### Agent(提示词层面,只有一个要改) | |
| 72 | + | |
| 73 | +| Agent | 改动 | 为什么 | | |
| 74 | +|---|---|---| | |
| 75 | +| `subtask-planning` | `agent.md` 输出契约新增字段——每个 step 除了 `title`/`why`/`depends_on_hint`,还要交一份最小验收标准(形状对齐 `goal-contract.js` 的某个 section,具体哪个见下"待定")。这需要真的理解"这一步做完该长什么样",**不能**用代码从 `why` 字段机械转写(转出来的东西大概率是"尽量/基本可用"这类不可度量措辞,`goal-contract.js`/`org-validator` 明确拒收) | 这是本轮唯一需要改提示词的 agent | | |
| 76 | +| `org-coder`、`org-validator`、`goal-benchmark`、`coder-validator` 等 | **不改** | 见上"与既有机制的边界" | | |
| 77 | + | |
| 78 | +### 代码 | |
| 79 | + | |
| 80 | +| 文件 | 改动 | | |
| 81 | +|---|---| | |
| 82 | +| `server/services/work-graph-db.js` | `tasks` 表加 `acceptance_contract` 列(批 7 迁移);`createTask` 新增同名参数 + 用 `goal-contract.validateContract` 校验;`TASK_FROZEN_FIELDS` 收进这个字段(排队后不许再改,跟 `budgetJson` 那几个字段同待遇) | | |
| 83 | +| `server/services/subtask-planning.js` | 解析 LLM 输出里新增的验收字段,`materializeDecomposedSubtasks` 建每条 `decomposed_work` 行时把它写进 `acceptance_contract` | | |
| 84 | +| `server/routes/company.js`(`enqueueTaskRun`) | `decomposed_work` 分支:起 run 之前,读 `taskRow.acceptance_contract`,调 `goal-contract.writeContractSection`(或 `writeContract`),把它落进这条新 run 目录的 `artifacts/acceptance.json`,登记进 `graph_files`(`writeContractSection` 内部已经做,不用额外重复登记,需确认) | | |
| 85 | +| `server/services/goal-contract.js` | 大概率不用改——`writeContract`/`writeContractSection`/`validateContract`/`SECTIONS` 已经导出,直接复用 | | |
| 86 | +| 测试 | `createTask` 校验(新增/非法值拒绝);`materializeDecomposedSubtasks` 正确落 `acceptance_contract` 字段;`enqueueTaskRun` 正确把它写进新 run 的 `artifacts/acceptance.json`(可以直接读文件断言);一条端到端回归——真的走一遍轻量图,确认 `org-validator` 这次能读到契约、不再 `input_underspecified` | | |
| 87 | + | |
| 88 | +### 一处待定,写实现前要先定案 —— ✅ 已定案:`benchmark` 段 | |
| 89 | + | |
| 90 | +`subtask-planning` 该交哪个 section(`benchmark`/`test_data`/`test_script`)取决于怎么落地最自然:`test_script` 形状是 `{runner:'node', path, args?, timeout_ms?}`——直觉是指向 `org-coder` 会写的测试文件(这次真实回归里 `org-coder` 确实主动写了 `test/convert.test.js`),但 `subtask-planning` 在 `org-coder` 动手之前就要交契约,那时候还不知道 `org-coder` 最终会把测试文件放在哪个路径——需要要么给 `org-coder` 约定一个固定的测试文件路径(比如统一要求 `test/<task 相关名>.test.js`),要么改用 `benchmark` 段(不依赖具体文件路径,走"维度+及格线"这套已经验证过的格式,`org-validator` 自己想办法判)。这个选择直接决定 `org-coder` 的 `tools.md` 要不要跟着补一条"测试文件固定放哪"的约定——如果选 `benchmark` 段,`org-coder` 也可以维持"不改一行"。**建议选 `benchmark` 段**,避免牵连 `org-coder`,但最终定案留到真正实现这一步时再拍板。 | |
| 91 | + | |
| 92 | +按建议定案:选 `benchmark` 段,`org-coder` 全程未改一行(见「实现记录」)。 | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## 明确不做(本轮范围外) | |
| 97 | + | |
| 98 | +- `task-planning`、`org-planner` 的 `acceptance_item` 物化、人类/CEO 快速派单三条创建路径的契约强制——留到以后单独讨论,不在这次改动里。 | |
| 99 | +- "发现者就地补一份"的运行时兜底代码——只记原则,`decomposed_work` 链路走主力机制已完全覆盖,不为不可达的场景写代码。 | |
| 100 | +- 不改 `org-coder`/`org-validator`/`goal-benchmark` 等既有 agent 的提示词(除非"待定"那条最终选了 `test_script` 段,才需要给 `org-coder` 补一条测试文件路径约定)。 | |
| 101 | + | |
| 102 | +## 验收标准(后续实现完要检查的) | |
| 103 | + | |
| 104 | +1. ✅ `createTask` 不给 `acceptance_contract` 直接创建 `decomposed_work` 子任务——应该被拒绝(如果按主力机制彻底做,`decomposed_work` 这个类型应该强制要求这个字段非空)。单测覆盖,见「实现记录」。 | |
| 105 | +2. ✅ 真实回归:重复 46 号文档 Phase 3 v2 那次卡死的场景,这次 `org-validator` 真的读到了 `artifacts/acceptance.json`、真正执行了判分逻辑,`fail_reason=null`,不再是 `input_underspecified`。见「真实端到端回归补跑(2026-09-03)」。 | |
| 106 | +3. ✅ 确认 `org-coder`/`org-validator` 全程不需要感知这份契约是"这次由 subtask-planning 供的"还是"平时 org-planner 供的"——两条路径对它们完全透明;`org-validator` 判词自己也确认了这一点("非本节点自行发明标准——维度/权重/及格线均来自契约原文")。 | |
| 107 | + | |
| 108 | +--- | |
| 109 | + | |
| 110 | +## 实现记录(2026-09-02) | |
| 111 | + | |
| 112 | +### 落地清单 | |
| 113 | + | |
| 114 | +| 文件 | 改动 | | |
| 115 | +|---|---| | |
| 116 | +| `server/services/work-graph-db.js` | `tasks` 表加 `acceptance_contract` 列(批 7 迁移,`ALTER TABLE ADD COLUMN`);`createTask` 新增 `acceptanceContract` 参数,`subtaskKind==='decomposed_work'` 时强制非空,套一层 `{version, goal_id, benchmark: acceptanceContract}` 走 `goal-contract.validateContract` 校验(**不重写规则**,见下方「与原设计的出入」);`acceptanceContract` 收进 `TASK_FROZEN_FIELDS`(`queued_at` 非空后不许再改,同 `budgetJson` 待遇);`materializeDecomposedSubtasks` 把 `step.acceptance` 原样递给 `createTask`,单步校验失败(`try/catch`)只跳过那一步、不作废整批,延续既有"宽松解析"哲学 | | |
| 117 | +| `system/agents/subtask-planning/agent.md` | 硬约束新增第 10 条:每个 step 必须带 `acceptance`(benchmark 段本身),不许从 `why` 机械转写;输出形状(约束 1)、字段数(约束 5)同步改;补一节说明"轻量派发链路上 `acceptance` 会被直接拿去判卷" | | |
| 118 | +| `server/services/subtask-planning.js` | `normalizeSteps` 原样透传 `acceptance`(不在这一层做形状校验,深层校验留给 `createTask`);`buildSubtaskPlanningPrompt` 的交回格式示例、字段说明同步加 `acceptance` | | |
| 119 | +| `server/routes/company.js`(`enqueueTaskRun`) | `isDecomposedSubtask` 分支:`writeTaskGoalArtifact` 之后(`goal-contract` 靠 `task-goal.json` 推 `{companyId,taskId}`),把 `taskRow.acceptance_contract` 套一层 `{benchmark: ...}` 通过 `goal-contract.writeContract` 落进新 run 的 `artifacts/acceptance.json`;best-effort(写失败只 `console.error`,不阻塞入队——失败时行为退回改动前的 `input_underspecified`,不比原来更差) | | |
| 120 | +| `server/services/goal-contract.js` | 未改一行——`writeContract`/`validateContract`/`CONTRACT_VERSION`/`resolveRunDir` 全部复用既有导出 | | |
| 121 | +| 测试 | `task-decomposition-dispatch.test.js` 新增 8 个用例(`createTask` 四种校验分支、`materializeDecomposedSubtasks` 两种单步失败场景、路由层两个契约落盘/存量缺口场景);`task-decomposition-validator-gate.test.js` 补 1 处 `acceptanceContract`(存量夹具需要跟着改) | | |
| 122 | + | |
| 123 | +### 与原设计的出入(重要,别照着「设计决策」一节里的形状抄) | |
| 124 | + | |
| 125 | +设计决策里写的是"形状复用 `goal-contract.js` 的 `{benchmark, test_data, test_script}`"——这句话在写文档时想的是"契约的三段式 wrapper"。**真实起 `subtask-planning` agent 验证后发现这是错的**:agent.md 教的提示词是"acceptance 对齐 benchmark 段",真实 LLM(不是注入桩)老老实实交回的是 **benchmark 段内容本身**(`{dimensions:[...], overall_pass_threshold?}`),不会自己再包一层 `benchmark` 键——这符合直觉:没有人会在被要求"对齐 X 的格式"时,自己又发明一层 `{X: ...}` 包装。 | |
| 126 | + | |
| 127 | +第一版实现是照着设计决策的字面意思写的(`acceptanceContract` 直接当三段式契约展开),过了单测(因为单测的假数据也是我自己按错误理解造的),但拿真实 agent 输出一测就在 `validateContract` 那步报错(`unknown top-level field: dimensions`)——**这正是本轮反复强调"真实回归优于只信单测"的又一次验证**。修法:`acceptance_contract` 列存 **benchmark 段本身**,`createTask`/`enqueueTaskRun` 校验/落盘时现套一层给 `goal-contract.validateContract`/`writeContract` 用。`agent.md`/`buildSubtaskPlanningPrompt` 的提示词从一开始写的就是裸段格式,不用改。 | |
| 128 | + | |
| 129 | +### 真实验证(不是只信单测) | |
| 130 | + | |
| 131 | +1. 单测:`npx vitest run server/tests/unit`,1198/1200 通过(2 个失败是与本次改动无关的既有失败:`env-example-secrets.test.js`/`test-id-stability.test.js`,均未涉及本次改动的文件);`ps aux | grep claude` 确认零残留 CLI 子进程。 | |
| 132 | +2. 真实起 `p2verify0902` 公司里已雇佣的 `subtask-planning` agent(真实 Claude CLI 调用,不是注入桩),给一条真实 task("实现摄氏度转华氏度的转换函数"),验证 agent 真的会按新 `agent.md` 交出 `acceptance` 字段——交了,且格式如上一节所述(暴露了设计文档与真实产出的形状分歧,已修正)。 | |
| 133 | +3. 用真实 LLM 那次调用的原始产出(未修改一个字)跑一遍完整链路:`materializeDecomposedSubtasks` → `createTask`(校验+落库)→ 模拟 `enqueueTaskRun` 的写入段 → `goal-contract.readContract` + `validateContract`,结果 `{"ok":true,"errors":[]}`——这就是"`org-validator` 不会再撞 `input_underspecified`"这件事的直接证据(验收标准 2 的核心机制)。 | |
| 134 | + | |
| 135 | +### 真实端到端回归补跑(2026-09-03):`org-validator` 真的判过了 | |
| 136 | + | |
| 137 | +用户明确要求补一次更干净的真实回归,跑通了完整链路,验收标准第 2 条现在**已兑现**: | |
| 138 | + | |
| 139 | +1. 真实建一条全新任务("实现字符串反转函数")、真实起 `subtask-planning` agent(真实 CLI,不是注入桩)分解出 1 条 `decomposed_work` 子任务,`acceptance` 真实产出为 `{"dimensions":[{"name":"普通字符串反转正确","weight":0.6,...},{"name":"空字符串正确处理(返回空字符串,不抛异常)","weight":0.4,...}],"overall_pass_threshold":80}`。 | |
| 140 | +2. 真实调用 `POST /tasks/:taskId/enqueue`(走 CEO 真实用的 `x-internal-token` 认证路径),起了真实的轻量图(`org-coder` → `org-validator`,两个真实 `agent_call` 节点)。 | |
| 141 | +3. **过程中撞见两个纯属本次验证方法论本身的坑,均与 48 号文档代码无关**:① 第一次没起真正的 API 服务(只在脚本进程里跑图引擎),`org-coder` 完工后调 `bin/lemon.js task transition`/`report_task` 够不着 `localhost:3010`,连续 4 次 `infra_flake` 撞熔断硬顶、卡在 `blocked`;② 补起服务后单独重试,`org-coder` 子进程作为**另一个独立进程**(我的重试脚本)的子进程,没能继承真实服务进程自己生成的 `LEMON_INTERNAL_TOKEN`(该 token 是每进程随机生成、只有其自身子进程才认得),打 API 仍是 401。两次都被真实 agent 自己的诊断日志准确点破根因(org-validator 后来的判词原话也复述了这段"记账问题",并明确判定"不在本节点验收范围内")。最终解法:让驱动脚本 `require('server/index.js')` 在**同一个进程**里把真实服务和图引擎重试跑在一起,`org-coder` 子进程这才拿到匹配的 token。 | |
| 142 | +4. 修好拓扑问题后重跑,`org-coder` 真实实现完成,`org-validator` 真实执行——**读到了 `acceptance.json`、真的走了 `goal-contract` 的判分逻辑,两条维度都打了分(100/100),`fail_reason` 为空,不是 `input_underspecified`**。`org-validator` 的判词原文还独立追加了 7 组自己写的测试用例复核交付物,附带一句准确的旁注:"本次 acceptance.json 仅含 benchmark 段(created_by=subtask-planning,48 号文档轻量子任务图路径:org-coder 不产出自报分,org-validator 自行取证判分,非本节点自行发明标准——维度/权重/及格线均来自契约原文,未改动)"。 | |
| 143 | +5. 最终态:`graph_run_nodes` 两个节点均 `success`,`graph_runs.run_status = completed`,task `status = completed`。 | |
| 144 | + | |
| 145 | +**验证方法论本身留下的经验**(写下来免得下次重犯):真实驱动图执行必须让 agent 子进程与提供 API 的服务进程同源(同一个 Node 进程内 `require('server/index.js')`),否则 `LEMON_INTERNAL_TOKEN` 天然对不上、agent 侧的 CLI 工具调用会全部 401——这不是本次代码的缺陷,是"拿脚本模拟真实拓扑"这件事本身的一个通用坑,下次做类似真实回归可以直接照这个模式起。 | |
| 146 | + | |
| 147 | +### 仍未做的(遗留,按「明确不做」的既有边界) | |
| 148 | + | |
| 149 | +- 安全网("发现缺失时就地补一份")仍然只是原则,未写代码——`decomposed_work` 链路走主力机制已完全覆盖,原设计判断不变。 | |
| 150 | +- `task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径的契约强制——本轮范围外,未动。 | ... | ... |
server/routes/company.js
| ... | ... | @@ -1026,6 +1026,8 @@ function taskView(t, progress = null, positions = null) { |
| 1026 | 1026 | // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务) |
| 1027 | 1027 | parentTaskId: t.parent_task_id || null, |
| 1028 | 1028 | acceptanceMode: t.acceptance_mode || 'holistic', |
| 1029 | + // 46 号文档 Phase 3:两种子任务靠这个区分——'acceptance_item' | 'decomposed_work' | null | |
| 1030 | + subtaskKind: t.subtask_kind || null, | |
| 1029 | 1031 | createdAt: t.created_at, |
| 1030 | 1032 | queuedAt: t.queued_at, |
| 1031 | 1033 | startedAt: t.started_at, |
| ... | ... | @@ -1096,12 +1098,33 @@ function isSlotRace(err) { |
| 1096 | 1098 | * |
| 1097 | 1099 | * @returns {{runId, run, artifact, task}} |
| 1098 | 1100 | */ |
| 1099 | -function enqueueTaskRun({ companyId, taskRow, budget }) { | |
| 1101 | +/** | |
| 1102 | + * `withSubtaskPlanning`(46 号文档 Phase 1,默认 `false`,显式开):只在「goal → task | |
| 1103 | + * → 人点下达」这条主叙事路径上(本文件的 `/goals/:goalId/dispatch` 与 | |
| 1104 | + * `/tasks/:taskId/enqueue` 两处)触发 subtask-planning,**不**是 `enqueueTaskRun` | |
| 1105 | + * 的每个调用方都要——`POST /:companyId/tasks`(外部 agent curl 自主派发的快速任务, | |
| 1106 | + * 常是"把周报发出去"这类无需分解的行政指令,`goal-task-acceptance.test.js` 验收 22 | |
| 1107 | + * 专门守着这条路径零 LLM 调用)与 `/rerun`(同一条 task 换个 run 重跑,描述没变, | |
| 1108 | + * 重新分解没有新增价值)都保持默认 `false`——默认关比默认开更安全,新增调用方不写这个 | |
| 1109 | + * 参数就不会意外多花一次 LLM 调用。 | |
| 1110 | + */ | |
| 1111 | +function enqueueTaskRun({ companyId, taskRow, budget, withSubtaskPlanning = false }) { | |
| 1100 | 1112 | const wdb = require('../services/work-graph-db'); |
| 1101 | 1113 | |
| 1114 | + // 46 号文档 Phase 3(v2):decomposed_work 子任务不走公司完整 org graph(那是给顶层 task | |
| 1115 | + // 走 ceo→cto→org-planner→org-coder→org-validator 整套规划仪式的),现造一张最小的 | |
| 1116 | + // impl→validate work graph——`subtask-planning` 已经把"怎么做"拆到这一步的粒度了, | |
| 1117 | + // 再走一遍 org-planner 的 brd/prd/sdd 纯属重复劳动。见 `createDecomposedWorkGraph` 的注释, | |
| 1118 | + // 含一条没有真实验证过的假设(org-coder/org-validator 裸调用会不会读不到预期上下文)。 | |
| 1119 | + const isDecomposedSubtask = taskRow.subtask_kind === 'decomposed_work'; | |
| 1120 | + const graphKind = isDecomposedSubtask ? 'work' : 'org'; | |
| 1121 | + const graphId = isDecomposedSubtask | |
| 1122 | + ? wdb.createDecomposedWorkGraph({ companyId, task: taskRow }) | |
| 1123 | + : companyId; // kind='org' 时 graph_id 就是 company_id | |
| 1124 | + | |
| 1102 | 1125 | const created = wdb.createRun({ |
| 1103 | - graphKind: 'org', | |
| 1104 | - graphId: companyId, // kind='org' 时 graph_id 就是 company_id | |
| 1126 | + graphKind, | |
| 1127 | + graphId, | |
| 1105 | 1128 | companyId, |
| 1106 | 1129 | goalId: taskRow.goal_id, |
| 1107 | 1130 | taskId: taskRow.id, |
| ... | ... | @@ -1128,6 +1151,31 @@ function enqueueTaskRun({ companyId, taskRow, budget }) { |
| 1128 | 1151 | createdAt: now, |
| 1129 | 1152 | }); |
| 1130 | 1153 | |
| 1154 | + // 48 号文档:decomposed_work 子任务的验收契约在创建时就已经 1:1 强绑定在 taskRow 上 | |
| 1155 | + // (createTask 强制要求非空)——起轻量图之前把它落进这条新 run 的 artifacts/acceptance.json, | |
| 1156 | + // org-validator 全程不需要感知这份契约是这条链路供的还是平时 org-planner 供的(见该文档 | |
| 1157 | + // 「与既有机制的边界」)。写在 writeTaskGoalArtifact 之后:goal-contract.writeContract | |
| 1158 | + // 内部靠 artifacts/task-goal.json 推出 {companyId, taskId} 去登记 graph_files 索引, | |
| 1159 | + // task-goal.json 得先存在。best-effort:写失败不该让入队本身失败(org-validator 会退回 | |
| 1160 | + // 原来的 input_underspecified 路径,行为不比这次改动之前更差)。 | |
| 1161 | + if (isDecomposedSubtask && taskRow.acceptance_contract) { | |
| 1162 | + try { | |
| 1163 | + const goalContract = require('../services/goal-contract'); | |
| 1164 | + // acceptance_contract 存的是 benchmark 段本身(见 work-graph-db.createTask 的注释), | |
| 1165 | + // 这里套一层变成完整契约。 | |
| 1166 | + const benchmark = JSON.parse(taskRow.acceptance_contract); | |
| 1167 | + const runDir = goalContract.resolveRunDir(companyId, runId); | |
| 1168 | + goalContract.writeContract(runDir, { | |
| 1169 | + version: goalContract.CONTRACT_VERSION, | |
| 1170 | + goal_id: taskRow.goal_id, | |
| 1171 | + created_by: 'subtask-planning', | |
| 1172 | + benchmark, | |
| 1173 | + }); | |
| 1174 | + } catch (e) { | |
| 1175 | + console.error(`[TaskEntry] task ${taskRow.id} 写入子任务验收契约失败(不阻塞入队): ${e.message}`); | |
| 1176 | + } | |
| 1177 | + } | |
| 1178 | + | |
| 1131 | 1179 | const task = wdb.updateTask(taskRow.id, { |
| 1132 | 1180 | status: 'queued', |
| 1133 | 1181 | currentRunId: runId, |
| ... | ... | @@ -1141,6 +1189,22 @@ function enqueueTaskRun({ companyId, taskRow, budget }) { |
| 1141 | 1189 | // 只在还没有真实值时才写,避免盖掉别的派单流程已经写好的 assignee。 |
| 1142 | 1190 | assigneeAgentId: taskRow.assignee_agent_id || wdb.GRAPH_TAKEOVER_ASSIGNEE, |
| 1143 | 1191 | }); |
| 1192 | + | |
| 1193 | + // 46 号文档 Phase 1:异步触发 subtask-planning,照抄 runGoalSplit 的 setImmediate 模式—— | |
| 1194 | + // 不等它、不阻塞入队本身。triggerSubtaskPlanning 内部已经把所有失败都兜成 console.warn, | |
| 1195 | + // 这里的 .catch 只防「兜底代码自己炸了」变成 unhandled rejection(同 runGoalSplit 那句注释)。 | |
| 1196 | + if (withSubtaskPlanning) { | |
| 1197 | + // ⚠️ `created` 只是 `wdb.createRun()` 的 `{ok, id, nodeRunIds}` 返回值,没有 company_id / | |
| 1198 | + // root_run_id —— 必须传完整行(`wdb.getRun(runId)`),否则 `runRootDir()` 会拼出带 | |
| 1199 | + // "undefined" 的坏路径且不报错(本条注释是踩过一次这个坑之后补的)。 | |
| 1200 | + const runRow = wdb.getRun(runId); | |
| 1201 | + setImmediate(() => { | |
| 1202 | + require('../services/subtask-planning') | |
| 1203 | + .triggerSubtaskPlanning({ run: runRow, task }) | |
| 1204 | + .catch((e) => console.error(`[TaskEntry] task ${taskRow.id} subtask-planning 兜底失败: ${e.message}`)); | |
| 1205 | + }); | |
| 1206 | + } | |
| 1207 | + | |
| 1144 | 1208 | return { runId, run: wdb.getRun(runId) || {}, artifact, task }; |
| 1145 | 1209 | } catch (err) { |
| 1146 | 1210 | // 入队做了一半:run 建出来了但 task 没翻过去。留着它就是一条**永远不会被出队**的 |
| ... | ... | @@ -1955,6 +2019,9 @@ router.post('/:companyId/goals/:goalId/dispatch', (req, res) => { |
| 1955 | 2019 | // body 覆盖后不回写 task 行:budget 在 queued_at 之后是冻结字段(§四.4), |
| 1956 | 2020 | // 草稿行留的是「当初申报的额度」,本次实际用量以 run 的 budget_json 为准。 |
| 1957 | 2021 | budget: (req.body && req.body.budget) || parseBudget(d.budget_json) || DEFAULT_TASK_BUDGET, |
| 2022 | + // 46 号文档 Phase 1:这是「goal → task-planning 拆分 → 人点全部下达」的主叙事路径, | |
| 2023 | + // 每条被下达的 task 都值得一份步骤分解参考。 | |
| 2024 | + withSubtaskPlanning: true, | |
| 1958 | 2025 | })); |
| 1959 | 2026 | } |
| 1960 | 2027 | |
| ... | ... | @@ -2080,6 +2147,8 @@ router.get('/:companyId/tasks', (req, res) => { |
| 2080 | 2147 | goalId: req.query.goalId || req.query.goal_id || null, |
| 2081 | 2148 | // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务 |
| 2082 | 2149 | parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null, |
| 2150 | + // 46 号文档 Phase 3:`?subtaskKind=decomposed_work&status=draft` = CEO 心跳巡检对象 | |
| 2151 | + subtaskKind: req.query.subtaskKind || req.query.subtask_kind || null, | |
| 2083 | 2152 | includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true', |
| 2084 | 2153 | limit: Number.isInteger(limit) ? limit : null, |
| 2085 | 2154 | }); |
| ... | ... | @@ -2400,6 +2469,8 @@ router.post('/:companyId/tasks/:taskId/enqueue', (req, res) => { |
| 2400 | 2469 | companyId, |
| 2401 | 2470 | taskRow: task, |
| 2402 | 2471 | budget: (req.body && req.body.budget) || parseBudget(task.budget_json) || DEFAULT_TASK_BUDGET, |
| 2472 | + // 46 号文档 Phase 1:显式下达一条 draft task,与 goal 批量下达同一类事件。 | |
| 2473 | + withSubtaskPlanning: true, | |
| 2403 | 2474 | }); |
| 2404 | 2475 | res.status(201).json(queueReply(companyId, task, startNextIfSlotFree(companyId))); |
| 2405 | 2476 | } catch (err) { | ... | ... |
server/services/company-heartbeat-service.js
| ... | ... | @@ -473,6 +473,11 @@ class CompanyHeartbeatService { |
| 473 | 473 | * isHeartbeat 仍然发,但只对真心跳为真 —— 老的消费方(如果还有)行为不变。 |
| 474 | 474 | */ |
| 475 | 475 | _sendAgentChat(companyId, agentId, message, conversationId, kind = 'heartbeat') { |
| 476 | + // agent-chat.js 挂了 requireAuthOrInternal(38 号文档 Phase 1)——这是一条服务端自环 | |
| 477 | + // fetch,没有用户 cookie,不带内部令牌会被 401 挡住。company.js/task-settle.js 的自环 | |
| 478 | + // 调用都带了这个头,这里漏了,是心跳(含 CEO 心跳、pipeline/schedule 触发)一直在暗中 | |
| 479 | + // 401 的根因:失败只落一行 console.warn,界面上什么都看不出来。 | |
| 480 | + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); | |
| 476 | 481 | const postData = JSON.stringify({ |
| 477 | 482 | message, |
| 478 | 483 | conversationId, |
| ... | ... | @@ -488,6 +493,7 @@ class CompanyHeartbeatService { |
| 488 | 493 | headers: { |
| 489 | 494 | 'Content-Type': 'application/json', |
| 490 | 495 | 'Content-Length': Buffer.byteLength(postData), |
| 496 | + [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN, | |
| 491 | 497 | }, |
| 492 | 498 | }; |
| 493 | 499 | ... | ... |
server/services/deep-research.js
0 → 100644
| 1 | +/** | |
| 2 | + * deep-research —— 按需触发 org-hunt 做一次轻量调研(46 号文档 Phase 2)。 | |
| 3 | + * | |
| 4 | + * org-hunt 目前只能作为图节点被 `workgraph-planning` 动态选中调用(`deliverable.json` | |
| 5 | + * 模板,FDE 训练场景),交付物是 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json` | |
| 6 | + * (≥50 条)那套重契约。这里**不**套用那套契约——"task 入队前的快速调研参考"是时间敏感 | |
| 7 | + * 场景,直接照训练场景的产出量级要求会不可控地慢。org-hunt 的图节点契约、`deliverable.json` | |
| 8 | + * 模板本身都不动,这里只是另开一条**图外**的调用路径,让它以"轻量问答"的形式被叫起来 | |
| 9 | + * (调用方式仿 `dream-service.callDreamAgent`:直接 `AgentManager.callAgent`,不经图引擎)。 | |
| 10 | + * | |
| 11 | + * **默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量):org-hunt 的延迟/成功率在"日常任务规划、 | |
| 12 | + * 时间敏感"场景下从未验证过(46 号文档「已知的高风险点」原话),且 subtask-planning 这条 | |
| 13 | + * 触发链已经因为疏于隔离出过一次真事故(44 个残留 CLI 子进程,见 subtask-planning.js 头注释)。 | |
| 14 | + * 不开这个开关,这个模块连 org-hunt 都不会尝试雇佣/调用——只打一行日志说明跳过。 | |
| 15 | + */ | |
| 16 | +const path = require('path'); | |
| 17 | + | |
| 18 | +const RESEARCH_AGENT_ID = 'org-hunt'; | |
| 19 | + | |
| 20 | +// ── 注入点(仿 subtask-planning.setPlanSubtasks)────────────────────── | |
| 21 | +let triggerImpl = defaultTriggerDeepResearch; | |
| 22 | +let isInjected = false; | |
| 23 | + | |
| 24 | +/** 替换调研实现(传 null 回默认实现)。测试用,避免真起 CLI */ | |
| 25 | +function setTriggerDeepResearch(fn) { | |
| 26 | + triggerImpl = fn || defaultTriggerDeepResearch; | |
| 27 | + isInjected = !!fn; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * @param {{companyId: string, task: object, topic: string}} ctx | |
| 32 | + * @returns {Promise<{findings: string, sources: string[], tokens: number} | null>} | |
| 33 | + * `null` = 没跑(开关关闭 / 测试环境未注入 / 调用失败 / 没解析出结构化结果)—— | |
| 34 | + * 调用方一律当"没有调研结果"处理,不是错误,不该抛出去打断规划流程。 | |
| 35 | + */ | |
| 36 | +async function triggerDeepResearch(ctx) { | |
| 37 | + if (process.env.ENABLE_DEEP_RESEARCH !== '1') { | |
| 38 | + console.log('[DeepResearch] ENABLE_DEEP_RESEARCH 未开启,跳过(默认关闭,见文件头注释)'); | |
| 39 | + return null; | |
| 40 | + } | |
| 41 | + if (process.env.VITEST && !isInjected) { | |
| 42 | + console.log('[DeepResearch] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)'); | |
| 43 | + return null; | |
| 44 | + } | |
| 45 | + try { | |
| 46 | + return await triggerImpl(ctx); | |
| 47 | + } catch (err) { | |
| 48 | + console.warn(`[DeepResearch] 触发失败,按"没有调研结果"处理:${err.message}`); | |
| 49 | + return null; | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** 寻址名。与 `subtask-planning.resolvePlanningAgentName` 同一个模式:按需幂等雇一次 */ | |
| 54 | +function resolveResearchAgentName(companyId) { | |
| 55 | + if (!companyId) return RESEARCH_AGENT_ID; | |
| 56 | + const { CompanyService, listCompanyAgents } = require('./company-service'); | |
| 57 | + const isHired = () => listCompanyAgents(companyId) | |
| 58 | + .some((r) => r.role_name === RESEARCH_AGENT_ID || r.system_agent_id === RESEARCH_AGENT_ID); | |
| 59 | + if (!isHired()) { | |
| 60 | + try { | |
| 61 | + new CompanyService().hire(companyId, RESEARCH_AGENT_ID, {}); | |
| 62 | + } catch (e) { | |
| 63 | + console.warn(`[DeepResearch] 按需雇佣 ${RESEARCH_AGENT_ID} 失败(${companyId}): ${e.message}`); | |
| 64 | + } | |
| 65 | + } | |
| 66 | + return isHired() ? `${companyId}:${RESEARCH_AGENT_ID}` : RESEARCH_AGENT_ID; | |
| 67 | +} | |
| 68 | + | |
| 69 | +function buildResearchPrompt({ topic }) { | |
| 70 | + return [ | |
| 71 | + '这是一次**任务规划前的快速调研参考**,不是你平时的训练场景任务——', | |
| 72 | + '不需要按你 `agent.md` 平时的产出契约交 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json` 三件套,', | |
| 73 | + '也不需要凑够 ≥50 条种子数据。只需要快速查一下下面这件事业界/公开资料里有没有值得参考的方法论,', | |
| 74 | + '给一段简短总结即可。', | |
| 75 | + '', | |
| 76 | + '## 调研主题', | |
| 77 | + topic || '(调用方没给主题)', | |
| 78 | + '', | |
| 79 | + '## 交回方式(只读你回复里最后一个 ```json 代码块)', | |
| 80 | + '```json', | |
| 81 | + '{"findings": "…(几句话总结,没查到就写清楚没查到)", "sources": ["url 或来源描述", "…"]}', | |
| 82 | + '```', | |
| 83 | + '查不到有用信息就如实说 `findings` 里没有可用结论、`sources` 给 `[]`——不要编。', | |
| 84 | + ].join('\n'); | |
| 85 | +} | |
| 86 | + | |
| 87 | +async function defaultTriggerDeepResearch({ companyId, task, topic }) { | |
| 88 | + const AgentManager = require('../src/core/agent-manager'); | |
| 89 | + const { extractJson } = require('./dream-service'); | |
| 90 | + const manager = new AgentManager({}, path.resolve(__dirname, '../..')); | |
| 91 | + const agentName = resolveResearchAgentName(companyId); | |
| 92 | + | |
| 93 | + let tokens = 0; | |
| 94 | + const onAcpEvent = (ev) => { | |
| 95 | + if (!ev || ev.method !== 'session/result') return; | |
| 96 | + const u = (ev.params && ev.params.usage) || null; | |
| 97 | + if (u) tokens += (u.input || 0) + (u.output || 0); | |
| 98 | + }; | |
| 99 | + | |
| 100 | + const res = await manager.callAgent(agentName, buildResearchPrompt({ topic }), { | |
| 101 | + onAcpEvent, | |
| 102 | + metadata: { taskId: task && task.id, layer: 'deep-research', companyId }, | |
| 103 | + }); | |
| 104 | + | |
| 105 | + const obj = extractJson(res && res.output, 'object'); | |
| 106 | + if (!obj || typeof obj.findings !== 'string' || !obj.findings.trim()) return null; | |
| 107 | + return { | |
| 108 | + findings: obj.findings.trim(), | |
| 109 | + sources: Array.isArray(obj.sources) ? obj.sources.filter((s) => typeof s === 'string') : [], | |
| 110 | + tokens, | |
| 111 | + }; | |
| 112 | +} | |
| 113 | + | |
| 114 | +module.exports = { | |
| 115 | + RESEARCH_AGENT_ID, | |
| 116 | + triggerDeepResearch, | |
| 117 | + setTriggerDeepResearch, | |
| 118 | + buildResearchPrompt, | |
| 119 | + resolveResearchAgentName, | |
| 120 | +}; | ... | ... |
server/services/experience-lookup.js
0 → 100644
| 1 | +/** | |
| 2 | + * experience-lookup —— 查经验库(46 号文档 Phase 2)。 | |
| 3 | + * | |
| 4 | + * dream(29-DREAM,见 `dream-service.js`)把跨公司经验沉淀到本体根 `memory/experience.md`, | |
| 5 | + * 但只是**单向写**——没有任何可查询的索引。这个模块补上"查"这一半,给 subtask-planning 用: | |
| 6 | + * 关键词命中就当"经验库有答案",命中为零就是"查不到"(真实信号,不是异常,早期系统 dream | |
| 7 | + * 还没攒够料时这是常态)。 | |
| 8 | + * | |
| 9 | + * 查询范围收窄到**这家公司已雇佣的 agent**——他们才是真正会去执行这条 task 的角色, | |
| 10 | + * 比空扫全系统本体贴题,也比"历史同类 goal 检索"(本轮不做,需要语义检索基础设施)便宜。 | |
| 11 | + * | |
| 12 | + * ── 打分:踩过一次真事故,记下来 ────────────────────────────────────────── | |
| 13 | + * 第一版用「原始交集计数、只要 >0 就算命中」,真实回归时(46 号文档 Phase 2 实现记录) | |
| 14 | + * 用一个真实贪吃蛇任务查真实 dream 数据,命中的全是 `ceo`/`orggraph-planning` 关于 | |
| 15 | + * "校验失败怎么兜底路由""修复要重新触发验证"这类**跟游戏开发毫无关系**的工程流程经验—— | |
| 16 | + * 命中原因是任务描述长(切出 100+ 个 bigram),跟经验库里的通用工程词汇("验证""检测""流程" | |
| 17 | + * 这类高频 bigram)偶然重合了 1-3 个,就被当成"经验库有答案",直接压过了本该触发的调研分支。 | |
| 18 | + * | |
| 19 | + * 现在改用**"这条经验自己的词里有多少比例被 task 提到"**(对经验块的词数归一化,不是对 | |
| 20 | + * 查询词数):查询长不该稀释信号,被稀释的应该是"提到了这条经验一星半点"这种弱命中。 | |
| 21 | + * 阈值(`MIN_CONTAINMENT_RATIO`/`MIN_MATCH_TOKENS`)是拿真实噪音案例(约 3%-4% 命中率)和一个 | |
| 22 | + * 合成的真正相关案例(约 16%-32% 命中率)校准出来的,不是拍脑袋——细节见该次真实回归记录。 | |
| 23 | + */ | |
| 24 | +const fs = require('fs'); | |
| 25 | +const path = require('path'); | |
| 26 | +const locator = require('./agent-locator'); | |
| 27 | +const { CROSS_COMPANY_MEMORY } = require('./dream-service'); | |
| 28 | + | |
| 29 | +const EXPERIENCE_FILE = 'experience.md'; | |
| 30 | + | |
| 31 | +/** 一条经验至少要命中这么多词,单个偶然重合的 bigram 不算数 */ | |
| 32 | +const MIN_MATCH_TOKENS = 2; | |
| 33 | +/** 命中词数 / 这条经验自己的总词数,至少要到这个比例才算"查到了"(校准依据见文件头注释) */ | |
| 34 | +const MIN_CONTAINMENT_RATIO = 0.12; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * 切词。中文没有空格分词,不引入分词依赖(也没这个必要——"查不到"本身就是有效信号), | |
| 38 | + * 用最朴素的字符双字滑窗(bigram)近似:把整句中文当一个词只会让任何两句话的重叠率 | |
| 39 | + * 趋近于零,bigram 至少能在"碰撞检测"vs"碰撞检测踩过的坑"这种真实重叠场景下命中。 | |
| 40 | + * 拉丁字母/数字按非字母数字切分,整段当一个词。单字符(不管中英文)噪音大于信号,丢掉。 | |
| 41 | + */ | |
| 42 | +function tokenize(text) { | |
| 43 | + const raw = String(text || '').toLowerCase(); | |
| 44 | + const tokens = new Set(); | |
| 45 | + for (const w of raw.split(/[^a-z0-9]+/)) { | |
| 46 | + if (w.length > 1) tokens.add(w); | |
| 47 | + } | |
| 48 | + for (const run of raw.match(/[一-龥]+/g) || []) { | |
| 49 | + for (let i = 0; i < run.length - 1; i += 1) tokens.add(run.slice(i, i + 2)); | |
| 50 | + } | |
| 51 | + return Array.from(tokens); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * 读一个本体的 `experience.md`,按 `### 标题` 分块(与 `dream-service.auditDistilled` | |
| 56 | + * 同一种切法——两处都在解析 `writeDistilled` 落的同一份格式,切法不能各写一套)。 | |
| 57 | + */ | |
| 58 | +function readExperienceBlocks(agentId) { | |
| 59 | + const file = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, EXPERIENCE_FILE); | |
| 60 | + let text; | |
| 61 | + try { text = fs.readFileSync(file, 'utf-8'); } catch { return []; } | |
| 62 | + return text.split(/\n### /).slice(1).map((block) => { | |
| 63 | + const lines = block.split('\n'); | |
| 64 | + const title = lines[0].trim(); | |
| 65 | + const body = lines.slice(1).join('\n').replace(/^> 提炼于.*$/gm, '').trim(); | |
| 66 | + return { agentId, title, body }; | |
| 67 | + }).filter((b) => b.title); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * @param {{companyId: string, task: object, limit?: number}} ctx | |
| 72 | + * @returns {{matches: Array<{agentId: string, title: string, body: string, score: number}>, | |
| 73 | + * queriedAgents: string[]}} | |
| 74 | + * `score`:命中词数 / 这条经验自身词数(0-1 之间的比例,不是原始计数——见文件头注释) | |
| 75 | + */ | |
| 76 | +function queryExperience({ companyId, task, limit = 5 } = {}) { | |
| 77 | + const queryTokens = new Set(tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`)); | |
| 78 | + if (!companyId || !queryTokens.size) return { matches: [], queriedAgents: [] }; | |
| 79 | + | |
| 80 | + let agentIds = []; | |
| 81 | + try { | |
| 82 | + const { listCompanyAgents } = require('./company-service'); | |
| 83 | + agentIds = [...new Set(listCompanyAgents(companyId).map((r) => r.system_agent_id))]; | |
| 84 | + } catch { | |
| 85 | + return { matches: [], queriedAgents: [] }; | |
| 86 | + } | |
| 87 | + | |
| 88 | + const matches = []; | |
| 89 | + for (const agentId of agentIds) { | |
| 90 | + for (const block of readExperienceBlocks(agentId)) { | |
| 91 | + const blockTokens = tokenize(`${block.title} ${block.body}`); | |
| 92 | + if (!blockTokens.length) continue; | |
| 93 | + const hit = blockTokens.filter((w) => queryTokens.has(w)).length; | |
| 94 | + const score = hit / blockTokens.length; | |
| 95 | + if (hit >= MIN_MATCH_TOKENS && score >= MIN_CONTAINMENT_RATIO) matches.push({ ...block, score }); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + matches.sort((a, b) => b.score - a.score); | |
| 99 | + return { matches: matches.slice(0, limit), queriedAgents: agentIds }; | |
| 100 | +} | |
| 101 | + | |
| 102 | +module.exports = { queryExperience, tokenize, readExperienceBlocks }; | ... | ... |
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 | |
| ... | ... | @@ -1877,7 +1878,15 @@ function settleNode(runId, nodeRunId, outcome = {}) { |
| 1877 | 1878 | // 由 coder-validator 调 /subtask-result 端点逐条写(server/routes/company.js)。 |
| 1878 | 1879 | const dRole = def0.nodes.get(nodeRun0.def_node_id); |
| 1879 | 1880 | if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) { |
| 1880 | - const subtasks = getDb().prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ?`).all(run0.task_id); | |
| 1881 | + // 46 号文档 Phase 3:这条闸门只该看 43 号文档的验收条目子任务——`AND subtask_kind = | |
| 1882 | + // 'acceptance_item'` 是必须的收窄,不是可选项。Phase 3 会往同一个 parent_task_id | |
| 1883 | + // 底下物化第二种子任务(decomposed_work,subtask-planning 拆出来等 CEO 派发执行的, | |
| 1884 | + // 完成生命周期完全独立、跟 coder-validator 的验收判定无关),不收窄这条查询会把 | |
| 1885 | + // 它们也扫进来,天然不会是 completed,直接把所有开了 subtask-planning 的 task | |
| 1886 | + // 验收判定拖垮。 | |
| 1887 | + const subtasks = getDb() | |
| 1888 | + .prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) | |
| 1889 | + .all(run0.task_id); | |
| 1881 | 1890 | const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id); |
| 1882 | 1891 | if (notDone.length) { |
| 1883 | 1892 | to = 'fail'; |
| ... | ... | @@ -2353,6 +2362,53 @@ function findAcceptanceMdFile(run) { |
| 2353 | 2362 | } |
| 2354 | 2363 | |
| 2355 | 2364 | /** |
| 2365 | + * 47 号文档:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状 | |
| 2366 | + * (prd/sdd/goal-benchmark/planner-validator)下永远不会被产出——没有任何节点的职责是写它。 | |
| 2367 | + * `goal-benchmark`(R-19-B 契约 `benchmark` 段)倒是稳定产出,写在共享 `artifacts/` 区, | |
| 2368 | + * 结构就是一份可机器判定的验收维度清单(`dimensions[]`),拿它当兜底数据源。 | |
| 2369 | + */ | |
| 2370 | +function findGoalBenchmarkFile(run) { | |
| 2371 | + try { | |
| 2372 | + const file = path.join(runRootDir(run), 'artifacts', 'goal-benchmark.json'); | |
| 2373 | + return fs.existsSync(file) ? file : null; | |
| 2374 | + } catch { return null; } | |
| 2375 | +} | |
| 2376 | + | |
| 2377 | +/** | |
| 2378 | + * `goal-benchmark.json` 的 `dimensions[]` → 与 `parseAcceptanceMd` 同形状的行 | |
| 2379 | + * (`{seq, item, method, passCondition}`),下游物化逻辑不用区分数据源。 | |
| 2380 | + * `item` 优先取 `traceability[].task_goal_item`(原始需求条目原文,人读起来比维度英文名直观), | |
| 2381 | + * 没有 traceability 覆盖的维度退回用维度 `name`。 | |
| 2382 | + */ | |
| 2383 | +function parseGoalBenchmarkDimensions(text) { | |
| 2384 | + const rows = []; | |
| 2385 | + let data; | |
| 2386 | + try { data = JSON.parse(text); } catch { return rows; } | |
| 2387 | + const dims = Array.isArray(data?.dimensions) ? data.dimensions : []; | |
| 2388 | + const itemsByDim = new Map(); | |
| 2389 | + for (const t of (Array.isArray(data?.traceability) ? data.traceability : [])) { | |
| 2390 | + for (const dimName of (Array.isArray(t?.covered_by) ? t.covered_by : [])) { | |
| 2391 | + if (!itemsByDim.has(dimName)) itemsByDim.set(dimName, []); | |
| 2392 | + if (t.task_goal_item) itemsByDim.get(dimName).push(t.task_goal_item); | |
| 2393 | + } | |
| 2394 | + } | |
| 2395 | + let seq = 0; | |
| 2396 | + for (const d of dims) { | |
| 2397 | + if (!d || !d.name) continue; | |
| 2398 | + seq += 1; | |
| 2399 | + const item = (itemsByDim.get(d.name) || []).join(';') || d.name; | |
| 2400 | + const pass100 = d.rubric && d.rubric['100'] ? d.rubric['100'] : '(未写明 100 分判据)'; | |
| 2401 | + rows.push({ | |
| 2402 | + seq, | |
| 2403 | + item, | |
| 2404 | + method: d.evidence_hint || '(未写明)', | |
| 2405 | + passCondition: `pass_threshold=${d.pass_threshold ?? '?'};100 分判据:${pass100}`, | |
| 2406 | + }); | |
| 2407 | + } | |
| 2408 | + return rows; | |
| 2409 | +} | |
| 2410 | + | |
| 2411 | +/** | |
| 2356 | 2412 | * 43 号文档 Phase 2:`task.acceptance_mode='itemized'` 时,org-planner 这一轮真正判定 |
| 2357 | 2413 | * success 之后,把它(或它展开出的子图)写的 acceptance.md 逐行物化成子任务。 |
| 2358 | 2414 | * |
| ... | ... | @@ -2378,23 +2434,40 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, |
| 2378 | 2434 | const task = getTask(run.task_id); |
| 2379 | 2435 | if (!task || task.acceptance_mode !== 'itemized') return; |
| 2380 | 2436 | |
| 2381 | - const already = getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; | |
| 2437 | + // 46 号文档 Phase 3:只数 acceptance_item——同一个 task 现在可能同时挂着 subtask-planning | |
| 2438 | + // 物化的 decomposed_work 行,那批不该让这条幂等判据误判"已经物化过验收条目"而提前退出。 | |
| 2439 | + const already = getDb() | |
| 2440 | + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'acceptance_item'`) | |
| 2441 | + .get(task.id).n; | |
| 2382 | 2442 | if (already > 0) return; |
| 2383 | 2443 | |
| 2384 | 2444 | const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run; |
| 2385 | - const file = searchRun ? findAcceptanceMdFile(searchRun) : null; | |
| 2445 | + let file = searchRun ? findAcceptanceMdFile(searchRun) : null; | |
| 2446 | + let rows = file ? parseAcceptanceMd(fs.readFileSync(file, 'utf-8')) : []; | |
| 2447 | + let source = 'acceptance.md'; | |
| 2448 | + | |
| 2449 | + // 47 号文档:真实回归验证坐实 acceptance.md 在当前常见子图形状下不会被产出, | |
| 2450 | + // 兜底认 goal-benchmark.json(同样只在 acceptance.md 缺失时才查,不改变已经在用 | |
| 2451 | + // acceptance.md 的模板的行为)。 | |
| 2452 | + if (!rows.length) { | |
| 2453 | + const gbFile = findGoalBenchmarkFile(run); | |
| 2454 | + if (gbFile) { | |
| 2455 | + file = gbFile; | |
| 2456 | + rows = parseGoalBenchmarkDimensions(fs.readFileSync(gbFile, 'utf-8')); | |
| 2457 | + source = 'goal-benchmark.json'; | |
| 2458 | + } | |
| 2459 | + } | |
| 2460 | + | |
| 2386 | 2461 | if (!file) { |
| 2387 | 2462 | timelineNote({ runId, nodeRunId }, 'node.output', |
| 2388 | - `task.acceptance_mode='itemized' 但没找到 acceptance.md,跳过子任务物化(仍按 holistic 收尾)`, | |
| 2463 | + `task.acceptance_mode='itemized' 但没找到 acceptance.md / goal-benchmark.json,跳过子任务物化(仍按 holistic 收尾)`, | |
| 2389 | 2464 | { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_missing' }); |
| 2390 | 2465 | return; |
| 2391 | 2466 | } |
| 2392 | - | |
| 2393 | - const rows = parseAcceptanceMd(fs.readFileSync(file, 'utf-8')); | |
| 2394 | 2467 | if (!rows.length) { |
| 2395 | 2468 | timelineNote({ runId, nodeRunId }, 'node.output', |
| 2396 | - `acceptance.md 存在但解析不出任何验收条目(表格形状不对?),跳过子任务物化`, | |
| 2397 | - { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_unparseable' }); | |
| 2469 | + `${source} 存在但解析不出任何验收条目(形状不对?),跳过子任务物化`, | |
| 2470 | + { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_unparseable', source }); | |
| 2398 | 2471 | return; |
| 2399 | 2472 | } |
| 2400 | 2473 | |
| ... | ... | @@ -2406,11 +2479,12 @@ function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, |
| 2406 | 2479 | description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`, |
| 2407 | 2480 | createdBy: 'org-planner', |
| 2408 | 2481 | parentTaskId: task.id, |
| 2482 | + subtaskKind: 'acceptance_item', // 46 号文档 Phase 3:显式打标,见 subtask_kind 列注释 | |
| 2409 | 2483 | }); |
| 2410 | 2484 | } |
| 2411 | 2485 | timelineNote({ runId, nodeRunId }, 'node.output', |
| 2412 | - `按 acceptance.md 物化了 ${rows.length} 条子任务`, | |
| 2413 | - { node_run_id: nodeRunId, task_id: task.id, subtask_count: rows.length }); | |
| 2486 | + `按 ${source} 物化了 ${rows.length} 条子任务`, | |
| 2487 | + { node_run_id: nodeRunId, task_id: task.id, subtask_count: rows.length, source }); | |
| 2414 | 2488 | } catch (err) { |
| 2415 | 2489 | console.error('[graph-engine] maybeMaterializeItemizedSubtasks 失败(不影响节点结算):', err.message); |
| 2416 | 2490 | } |
| ... | ... | @@ -3143,6 +3217,7 @@ function stopWallclockWatchdog() { |
| 3143 | 3217 | */ |
| 3144 | 3218 | function _reapStaleRunningNodes(rows, summary) { |
| 3145 | 3219 | const swept = []; |
| 3220 | + const runIdsToRedrive = new Set(); | |
| 3146 | 3221 | for (const row of rows) { |
| 3147 | 3222 | try { |
| 3148 | 3223 | const run = getRun(row.run_id); |
| ... | ... | @@ -3159,10 +3234,26 @@ function _reapStaleRunningNodes(rows, summary) { |
| 3159 | 3234 | const out = { activated: [], backwardEdgeId: null, loop: null, contractDiff: null, crossLayer: null, reset: [], infraRerun: false }; |
| 3160 | 3235 | routeFailure(row.run_id, def, nodeRun, out, null); |
| 3161 | 3236 | swept.push({ runId: row.run_id, nodeRunId: row.id, infraRerun: out.infraRerun }); |
| 3237 | + runIdsToRedrive.add(row.run_id); | |
| 3162 | 3238 | } catch (err) { |
| 3163 | 3239 | console.error(`[graph-engine] _reapStaleRunningNodes node=${row.id} 异常: ${err.message}`); |
| 3164 | 3240 | } |
| 3165 | 3241 | } |
| 3242 | + // T-mtjh8sdt 同一个洞的第二个入口:routeFailure 把节点重置回 ready/running 只改了状态, | |
| 3243 | + // 不会重新挂上 runAuto 的自驱动循环(reopenRunForRetry 那次已经补过人工重试路径,这里 | |
| 3244 | + // 是开机孤儿清扫 sweepOrphanedRunningNodes 与 45 号文档持续性存活扫描 | |
| 3245 | + // sweepStaleRunningAgentCalls 共用的结算内层循环,同一缺陷的另一条触发路径)。 | |
| 3246 | + // 异步补、不 await:sweepOrphanedRunningNodes 在服务器启动路径上同步调用,扫描可能 | |
| 3247 | + // 命中好几个 run,不能让重新驱动的 agent 调用顶着启动耗时走;按 run 去重,避免同一个 | |
| 3248 | + // run 里多个孤儿节点各自触发一次 runAuto。 | |
| 3249 | + for (const runId of runIdsToRedrive) { | |
| 3250 | + const after = getRun(runId); | |
| 3251 | + if (after && after.run_status === 'running' && after.execution_mode === 'auto') { | |
| 3252 | + runAuto(runId).catch((err) => { | |
| 3253 | + console.error(`[graph-engine] 孤儿清扫重挂 auto 驱动异常 run=${runId}: ${err.message}`); | |
| 3254 | + }); | |
| 3255 | + } | |
| 3256 | + } | |
| 3166 | 3257 | return { scanned: rows.length, swept }; |
| 3167 | 3258 | } |
| 3168 | 3259 | |
| ... | ... | @@ -3741,6 +3832,13 @@ function descendantRuns(run) { |
| 3741 | 3832 | * |
| 3742 | 3833 | * @returns {void} 没有「失败」这个出口:要么推进了,要么被别人抢先推走了(都可以继续动节点), |
| 3743 | 3834 | * 要么抛 RunSlotBusyError(整条重试作废,节点一个字都没动)。 |
| 3835 | + * | |
| 3836 | + * **auto 模式重挂驱动循环**(T-mtjh8sdt):这里把 run 从 blocked/ready 拉回 running 走的是 | |
| 3837 | + * 裸 `admitOrRequeue`,不是 `startRun`——所以以前只改了状态,从没重新挂上 `runAuto` 的自驱动 | |
| 3838 | + * 循环。手动 `nextStep` 推一步后没人接着推,agentRunId 仍是 null,backward 重试每次都会在 | |
| 3839 | + * 这里再卡一次。`startRun` 的收尾那段(判 execution_mode==='auto' 才挂 driver)搬过来一份: | |
| 3840 | + * 只在真的把 run 推成了 running 且 execution_mode 是 auto 时才挂,manual 模式或没推动(requeued/ | |
| 3841 | + * lost)都不挂,语义与 startRun 对齐。 | |
| 3744 | 3842 | */ |
| 3745 | 3843 | function reopenRunForRetry(runId, reopenRun) { |
| 3746 | 3844 | if (!reopenRun) return; |
| ... | ... | @@ -3752,6 +3850,12 @@ function reopenRunForRetry(runId, reopenRun) { |
| 3752 | 3850 | if (cur === 'blocked' || cur === 'ready') { |
| 3753 | 3851 | admitOrRequeue(runId, cur, 'running', { summary: `人工重试重开链路:${cur} → running` }); |
| 3754 | 3852 | } |
| 3853 | + const after = getRun(runId); | |
| 3854 | + if (after && after.run_status === 'running' && after.execution_mode === 'auto') { | |
| 3855 | + runAuto(runId).catch((err) => { | |
| 3856 | + console.error(`[graph-engine] 人工重试重挂 auto 驱动异常 run=${runId}: ${err.message}`); | |
| 3857 | + }); | |
| 3858 | + } | |
| 3755 | 3859 | } |
| 3756 | 3860 | |
| 3757 | 3861 | /** |
| ... | ... | @@ -5289,4 +5393,10 @@ module.exports = { |
| 5289 | 5393 | describeRoleDefinition, |
| 5290 | 5394 | LOOP_HARD_CAP_EDGE, |
| 5291 | 5395 | LOOP_HARD_CAP_RUN, |
| 5396 | + | |
| 5397 | + // 46 号文档 Phase 1:subtask-planning 服务需要把产物写进同一个共享 artifacts 区 | |
| 5398 | + // (runRootDir(run)/artifacts/,与 goal-benchmark.json 同规矩,见 47 号文档), | |
| 5399 | + // 导出只是让它复用同一份路径计算,不在别处重复拼一份可能跟着 GRAPH_RUNS_DIR | |
| 5400 | + // override 走样的逻辑。 | |
| 5401 | + runRootDir, | |
| 5292 | 5402 | }; | ... | ... |
server/services/heartbeat-service.js
| ... | ... | @@ -228,6 +228,11 @@ class HeartbeatService { |
| 228 | 228 | `无需等待人类确认,请完全自主决策。` |
| 229 | 229 | ].join('\n'); |
| 230 | 230 | |
| 231 | + // agent-chat.js 挂了 requireAuthOrInternal(38 号文档 Phase 1)——这是一条服务端自环 | |
| 232 | + // fetch,没有用户 cookie,不带内部令牌会被 401 挡住。company.js/task-settle.js 的自环 | |
| 233 | + // 调用都带了这个头,这里漏了,是"心跳一直在暗中 401"的根因之一(另一处见 | |
| 234 | + // company-heartbeat-service.js 的同款漏洞):失败只落一行 console.warn,界面上看不出来。 | |
| 235 | + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); | |
| 231 | 236 | const postData = JSON.stringify({ |
| 232 | 237 | message: heartbeatMessage, |
| 233 | 238 | conversationId: convId, |
| ... | ... | @@ -241,7 +246,8 @@ class HeartbeatService { |
| 241 | 246 | method: 'POST', |
| 242 | 247 | headers: { |
| 243 | 248 | 'Content-Type': 'application/json', |
| 244 | - 'Content-Length': Buffer.byteLength(postData) | |
| 249 | + 'Content-Length': Buffer.byteLength(postData), | |
| 250 | + [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN, | |
| 245 | 251 | } |
| 246 | 252 | }; |
| 247 | 253 | ... | ... |
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 | + * ── Phase 2(46 号文档):接上经验库检索 + 按需 deep research ────────────────── | |
| 23 | + * 第一轮调用前先查 `experience-lookup.queryExperience`(dream 沉淀的跨公司经验)拼进提示词; | |
| 24 | + * 查不到经验,或 agent 自评 `confidence:"low"`,就再调 `deep-research.triggerDeepResearch` | |
| 25 | + * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。 | |
| 26 | + * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的—— | |
| 27 | + * agent 唯一自报的判断是 `confidence`,用来决定要不要触发调研,不直接决定 `reasoning_source`。 | |
| 28 | + * | |
| 29 | + * ── 48 号文档:验收契约创建时 1:1 强绑定 ──────────────────────────────────── | |
| 30 | + * 每个 step 现在还要交一份 `acceptance`(benchmark 段形状)。它不是给 org-planner 参考用的 | |
| 31 | + * 装饰字段——一条 step 被 `materializeDecomposedSubtasks` 物化成 `decomposed_work` 子任务时, | |
| 32 | + * `acceptance` 会被 `work-graph-db.createTask` 强制要求、校验、随行落库,`enqueueTaskRun` | |
| 33 | + * 起轻量图前把它写进新 run 的 `artifacts/acceptance.json`——这就是 `org-validator` 在跳过 | |
| 34 | + * `org-planner` 的轻量派发链路上判卷时唯一的依据来源。 | |
| 35 | + */ | |
| 36 | + | |
| 37 | +const path = require('path'); | |
| 38 | +const fs = require('fs'); | |
| 39 | + | |
| 40 | +const REPO_ROOT = path.resolve(__dirname, '../..'); | |
| 41 | + | |
| 42 | +/** 全局唯一的一份步骤分解本体(`system/agents/subtask-planning/`) */ | |
| 43 | +const PLANNING_AGENT_ID = 'subtask-planning'; | |
| 44 | + | |
| 45 | +/** 一条 task 最多切几步。上限是体检指标,不是配额——agent.md 讲得很清楚 */ | |
| 46 | +const MAX_STEPS = 12; | |
| 47 | + | |
| 48 | +// ──────────────────────────────────────────────────────────────── | |
| 49 | +// 注入点(仿 task-planning.setPlanTasks) | |
| 50 | +// ──────────────────────────────────────────────────────────────── | |
| 51 | + | |
| 52 | +let planSubtasksImpl = defaultPlanSubtasks; | |
| 53 | +let isInjected = false; | |
| 54 | + | |
| 55 | +/** 替换规划实现(传 null 回默认实现)。测试用,避免真起 CLI */ | |
| 56 | +function setPlanSubtasks(fn) { | |
| 57 | + planSubtasksImpl = fn || defaultPlanSubtasks; | |
| 58 | + isInjected = !!fn; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * 更细粒度的注入点:只换掉 `defaultPlanSubtasks` 内部真正起 CLI 的那一层 | |
| 63 | + * (写法仿 `dream-service.setDreamAgentCaller`),不像 `setPlanSubtasks` 那样把整段 | |
| 64 | + * "查经验库 → 判断要不要调研 → 两轮调用"编排逻辑一起换掉。Phase 2 加的正是这段编排逻辑, | |
| 65 | + * 留这个更细的缝才测得到它本身的分支,而不是只测"调用方传回什么就原样落盘"。 | |
| 66 | + */ | |
| 67 | +let agentCaller = null; | |
| 68 | +/** 替换 agent 调用层(传 null 回真实现)。只给测试用 */ | |
| 69 | +function setSubtaskAgentCaller(fn) { agentCaller = fn; } | |
| 70 | + | |
| 71 | +async function callSubtaskPlanningAgent(agentName, prompt, opts) { | |
| 72 | + if (agentCaller) return agentCaller(agentName, prompt, opts); | |
| 73 | + const AgentManager = require('../src/core/agent-manager'); | |
| 74 | + const manager = new AgentManager({}, REPO_ROOT); | |
| 75 | + return manager.callAgent(agentName, prompt, opts); | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** | |
| 79 | + * 当前生效的规划实现。调用方一律走这里。 | |
| 80 | + * | |
| 81 | + * **踩过的坑,写在这里免得下次重犯**:`task-planning`/`workgraph-planning` 那一整套 | |
| 82 | + * 「注入点」设计的前提是——调用方要触发真实实现,得先显式 `setPlanXxx(fn)`。这个前提 | |
| 83 | + * 对 subtask-planning 不成立:本模块的触发点在 `enqueueTaskRun`(`server/routes/company.js` | |
| 84 | + * 的 `/goals/:goalId/dispatch` 与 `/tasks/:taskId/enqueue` 两处),而**全仓库有大量单测 | |
| 85 | + * 会走到这两条路径**——它们测的是排队/预算/状态流转这些跟 subtask-planning 毫无关系的东西, | |
| 86 | + * 从没想过要为一个刚加进来的旁支功能显式注入桩。第一次实现时忽略了这一点,`npx vitest run | |
| 87 | + * server/tests/unit` 一次性真开了 44 个 `claude` CLI 子进程挂在后台(`ps aux` 现场抓到, | |
| 88 | + * 已手动 kill 干净)——正是 `docs/done/claude-cli-session-leak.md` 那份手册想防的事, | |
| 89 | + * 而且是本模块自己新引入的一个源头。 | |
| 90 | + * | |
| 91 | + * 修法:`process.env.VITEST`(vitest 对所有测试进程都会自动置位,不是我们自己发明的判据) | |
| 92 | + * 为真、且没有人显式调用过 `setPlanSubtasks` 时,直接跳过真实调用——不是「默认桩返回假数据」 | |
| 93 | + * (那会让没预料到这条路径的测试断言出诡异内容),是「什么都不做」,等价于 47/44 号文档 | |
| 94 | + * 反复用到的软失败路径(0 条 steps,不写文件,只留一条日志)。真想在测试里验证 subtask-planning | |
| 95 | + * 的行为,显式 `setPlanSubtasks(fn)` 就行——那条路径不受这层保护影响。 | |
| 96 | + */ | |
| 97 | +function planSubtasks(ctx) { | |
| 98 | + if (process.env.VITEST && !isInjected) { | |
| 99 | + console.log('[SubtaskPlanning] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)'); | |
| 100 | + return Promise.resolve({ | |
| 101 | + steps: [], tokens: 0, reasoningSource: 'model_only', experienceRefs: [], researchUsed: false, | |
| 102 | + }); | |
| 103 | + } | |
| 104 | + return planSubtasksImpl(ctx); | |
| 105 | +} | |
| 106 | + | |
| 107 | +// ──────────────────────────────────────────────────────────────── | |
| 108 | +// 默认实现 | |
| 109 | +// ──────────────────────────────────────────────────────────────── | |
| 110 | + | |
| 111 | +/** | |
| 112 | + * 调 subtask-planning 拆一次 task 的步骤。先查经验库,再按需触发一次 deep research | |
| 113 | + * (Phase 2,46 号文档),最多两轮 agent 调用,没有重试。 | |
| 114 | + * | |
| 115 | + * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行 | |
| 116 | + * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string, | |
| 117 | + * experienceRefs: string[], researchUsed: boolean}>} | |
| 118 | + * steps 形如 `[{seq, title, why, depends_on_hint: [seq…], acceptance?}]`, | |
| 119 | + * seq 恒为 1..N 连续(重新编号过)。`acceptance`(48 号文档)缺失/形状不合法的条目 | |
| 120 | + * 仍会留在这里——`normalizeSteps` 不因它丢整条,深层校验留给 `materializeDecomposedSubtasks` | |
| 121 | + * 建行那一刻(单步失败跳过,不作废整批)。 | |
| 122 | + */ | |
| 123 | +async function defaultPlanSubtasks(ctx) { | |
| 124 | + const agentName = resolvePlanningAgentName(ctx.companyId); | |
| 125 | + | |
| 126 | + let tokens = 0; | |
| 127 | + const onAcpEvent = (ev) => { | |
| 128 | + if (!ev || ev.method !== 'session/result') return; | |
| 129 | + const u = (ev.params && ev.params.usage) || null; | |
| 130 | + if (u) tokens += (u.input || 0) + (u.output || 0); | |
| 131 | + }; | |
| 132 | + const callOnce = async (prompt) => { | |
| 133 | + const res = await callSubtaskPlanningAgent(agentName, prompt, { | |
| 134 | + onAcpEvent, | |
| 135 | + metadata: { | |
| 136 | + // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId | |
| 137 | + // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。 | |
| 138 | + taskId: ctx.task.id, | |
| 139 | + layer: 'subtask-plan', | |
| 140 | + goalId: ctx.task.goal_id, | |
| 141 | + companyId: ctx.companyId, | |
| 142 | + }, | |
| 143 | + }); | |
| 144 | + return res && res.output; | |
| 145 | + }; | |
| 146 | + | |
| 147 | + const { queryExperience } = require('./experience-lookup'); | |
| 148 | + const exp = queryExperience({ companyId: ctx.companyId, task: ctx.task }); | |
| 149 | + const experienceRefs = exp.matches.map((m) => `${m.agentId}: ${m.title}`); | |
| 150 | + | |
| 151 | + const round1Output = await callOnce(buildSubtaskPlanningPrompt(ctx, { experience: exp.matches })); | |
| 152 | + let steps = normalizeSteps(extractPlannedSteps(round1Output)); | |
| 153 | + const confidence = extractConfidence(round1Output); | |
| 154 | + | |
| 155 | + let reasoningSource = exp.matches.length ? 'memory' : 'model_only'; | |
| 156 | + let researchUsed = false; | |
| 157 | + | |
| 158 | + const needResearch = exp.matches.length === 0 || confidence === 'low'; | |
| 159 | + if (needResearch) { | |
| 160 | + const { triggerDeepResearch } = require('./deep-research'); | |
| 161 | + const topic = [ctx.task.title, ctx.task.description].filter(Boolean).join(' — '); | |
| 162 | + const research = await triggerDeepResearch({ companyId: ctx.companyId, task: ctx.task, topic }); | |
| 163 | + if (research) { | |
| 164 | + tokens += research.tokens || 0; | |
| 165 | + const round2Output = await callOnce(buildSubtaskPlanningPrompt(ctx, { experience: exp.matches, research })); | |
| 166 | + const round2Steps = normalizeSteps(extractPlannedSteps(round2Output)); | |
| 167 | + if (round2Steps.length) { | |
| 168 | + steps = round2Steps; | |
| 169 | + reasoningSource = 'deep_research'; | |
| 170 | + researchUsed = true; | |
| 171 | + } | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + return { steps, tokens, reasoningSource, experienceRefs, researchUsed }; | |
| 176 | +} | |
| 177 | + | |
| 178 | +/** | |
| 179 | + * 本体的寻址名。与 `task-planning.resolvePlanningAgentName` 同一个模式: | |
| 180 | + * 按需幂等雇一次,判据查 `company_agents` 表(不是目录)。 | |
| 181 | + */ | |
| 182 | +function resolvePlanningAgentName(companyId) { | |
| 183 | + if (!companyId) return PLANNING_AGENT_ID; | |
| 184 | + const { CompanyService, listCompanyAgents } = require('./company-service'); | |
| 185 | + const isHired = () => listCompanyAgents(companyId).some((r) => r.role_name === PLANNING_AGENT_ID); | |
| 186 | + if (!isHired()) { | |
| 187 | + try { | |
| 188 | + new CompanyService().hire(companyId, PLANNING_AGENT_ID, {}); | |
| 189 | + } catch (e) { | |
| 190 | + console.warn(`[SubtaskPlanning] 按需雇佣 ${PLANNING_AGENT_ID} 失败(${companyId}): ${e.message}`); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + return isHired() ? `${companyId}:${PLANNING_AGENT_ID}` : PLANNING_AGENT_ID; | |
| 194 | +} | |
| 195 | + | |
| 196 | +// ──────────────────────────────────────────────────────────────── | |
| 197 | +// 提示词 | |
| 198 | +// ──────────────────────────────────────────────────────────────── | |
| 199 | + | |
| 200 | +/** | |
| 201 | + * @param {{companyId: string, task: object}} ctx | |
| 202 | + * @param {{experience?: Array<{agentId:string,title:string,body:string}>, | |
| 203 | + * research?: {findings:string, sources?:string[]} | null}} [extra] | |
| 204 | + * `experience`:`experience-lookup.queryExperience` 的命中结果(Phase 2)。 | |
| 205 | + * `research`:`deep-research.triggerDeepResearch` 的结果,只在触发过调研时传入(第二轮)。 | |
| 206 | + */ | |
| 207 | +function buildSubtaskPlanningPrompt(ctx, extra = {}) { | |
| 208 | + const t = ctx.task || {}; | |
| 209 | + const { experience = [], research = null } = extra; | |
| 210 | + const lines = [ | |
| 211 | + `你正在为公司 **${ctx.companyId}** 的一条 task 想"怎么做"的步骤分解。`, | |
| 212 | + '', | |
| 213 | + '## task 描述(要拆步骤的对象,task-planning 已经切好的一条边界)', | |
| 214 | + t.description || '(调用方没给任务描述)', | |
| 215 | + '', | |
| 216 | + `- taskId:${t.id}(只读,不要写进你的产出)`, | |
| 217 | + `- 标题:${t.title || '(无)'}`, | |
| 218 | + '', | |
| 219 | + ]; | |
| 220 | + if (experience.length) { | |
| 221 | + lines.push('## 已知相关经验(供参考,不强制采纳,来自其他公司同角色沉淀下来的经验)'); | |
| 222 | + for (const e of experience) lines.push(`- [来自 ${e.agentId}] **${e.title}** —— ${e.body}`); | |
| 223 | + lines.push(''); | |
| 224 | + } | |
| 225 | + if (research) { | |
| 226 | + lines.push('## 调研参考(系统判断需要调研后跑的一次快速调研,供参考,不强制采纳)'); | |
| 227 | + lines.push(research.findings); | |
| 228 | + if (research.sources && research.sources.length) lines.push(`来源:${research.sources.join('、')}`); | |
| 229 | + lines.push(''); | |
| 230 | + } | |
| 231 | + lines.push( | |
| 232 | + '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', | |
| 233 | + '```json', | |
| 234 | + '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[],' | |
| 235 | + + '"acceptance":{"dimensions":[{"name":"…","weight":1,"pass_threshold":80}],"overall_pass_threshold":80}}],' | |
| 236 | + + '"confidence":"high"}', | |
| 237 | + '```', | |
| 238 | + `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`, | |
| 239 | + '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,' | |
| 240 | + + '不是硬依赖,没有任何机制会强制执行它。', | |
| 241 | + '- `acceptance`:这一步的验收标准(48 号文档),形状对齐 benchmark 段:`dimensions` 至少一条,' | |
| 242 | + + '每条 `{name, weight, pass_threshold?}`,`weight` 之和必须是 1;`overall_pass_threshold` 可选。' | |
| 243 | + + '不能从 `why` 抄——要写清楚这一步做完该长什么样、按什么维度打分,格式不合规或空泛("基本可用"' | |
| 244 | + + '这类不可度量措辞)会被直接拒收,这一步就不会被派发出去。', | |
| 245 | + '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)' | |
| 246 | + + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划' | |
| 247 | + + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。', | |
| 248 | + '- 只输出这两个顶层字段(`steps`/`confidence`)。想法写在 JSON 代码块**前面**的正文里,' | |
| 249 | + + '代码块之后不要再写任何字。', | |
| 250 | + '', | |
| 251 | + '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,' | |
| 252 | + + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。' | |
| 253 | + + '**`acceptance` 是例外**:如果这条 task 走的是不经过 org-planner 的轻量派发链路,' | |
| 254 | + + '你交的 `acceptance` 会被原样写成这一步的验收契约,`org-validator` 直接拿它判卷——' | |
| 255 | + + '写得没用的后果不再只是"没人看",而是这一步真的会通不过验收(48 号文档)。' | |
| 256 | + ); | |
| 257 | + return lines.join('\n'); | |
| 258 | +} | |
| 259 | + | |
| 260 | +// ──────────────────────────────────────────────────────────────── | |
| 261 | +// 产出解析(宽松:单条不合法就丢那一条,不作废整批) | |
| 262 | +// ──────────────────────────────────────────────────────────────── | |
| 263 | + | |
| 264 | +/** 从回复里取出步骤数组。取法与 task-planning.extractPlannedTasks 同思路(围栏优先、从后往前取) */ | |
| 265 | +function extractPlannedSteps(output) { | |
| 266 | + const text = String(output || ''); | |
| 267 | + if (!text.trim()) return []; | |
| 268 | + | |
| 269 | + const candidates = jsonCandidates(text); | |
| 270 | + for (let i = candidates.length - 1; i >= 0; i -= 1) { | |
| 271 | + const doc = safeJsonParse(candidates[i]); | |
| 272 | + if (doc && Array.isArray(doc.steps)) return doc.steps; | |
| 273 | + if (Array.isArray(doc)) return doc; // 容错:直接回了裸数组也认 | |
| 274 | + } | |
| 275 | + return []; | |
| 276 | +} | |
| 277 | + | |
| 278 | +/** 从回复里取 `confidence` 字段(`"high"`/`"low"`)。取不到就是 null——按"未知"处理,不强制触发调研 */ | |
| 279 | +function extractConfidence(output) { | |
| 280 | + const text = String(output || ''); | |
| 281 | + if (!text.trim()) return null; | |
| 282 | + const candidates = jsonCandidates(text); | |
| 283 | + for (let i = candidates.length - 1; i >= 0; i -= 1) { | |
| 284 | + const doc = safeJsonParse(candidates[i]); | |
| 285 | + if (doc && (doc.confidence === 'high' || doc.confidence === 'low')) return doc.confidence; | |
| 286 | + } | |
| 287 | + return null; | |
| 288 | +} | |
| 289 | + | |
| 290 | +function jsonCandidates(text) { | |
| 291 | + const out = []; | |
| 292 | + const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g; | |
| 293 | + let m; | |
| 294 | + while ((m = fence.exec(text))) out.push(m[1]); | |
| 295 | + out.push(...balanced(text, '[', ']'), ...balanced(text, '{', '}')); | |
| 296 | + return out; | |
| 297 | +} | |
| 298 | + | |
| 299 | +function balanced(text, open, close) { | |
| 300 | + const out = []; | |
| 301 | + let depth = 0; | |
| 302 | + let start = -1; | |
| 303 | + for (let i = 0; i < text.length; i += 1) { | |
| 304 | + const c = text[i]; | |
| 305 | + if (c === open) { | |
| 306 | + if (depth === 0) start = i; | |
| 307 | + depth += 1; | |
| 308 | + } else if (c === close && depth > 0) { | |
| 309 | + depth -= 1; | |
| 310 | + if (depth === 0) out.push(text.slice(start, i + 1)); | |
| 311 | + } | |
| 312 | + } | |
| 313 | + return out; | |
| 314 | +} | |
| 315 | + | |
| 316 | +function safeJsonParse(s) { | |
| 317 | + try { | |
| 318 | + return JSON.parse(s); | |
| 319 | + } catch { | |
| 320 | + return null; | |
| 321 | + } | |
| 322 | +} | |
| 323 | + | |
| 324 | +/** | |
| 325 | + * 宽松归一化:丢掉不合法的单条(没有 title 的、不是对象的),保留下来的重新编号 | |
| 326 | + * (1..N 连续,不留空洞——跳过的条目不该在 seq 里留坑,下游读起来会以为漏了一条)。 | |
| 327 | + * `depends_on_hint` 里指向被丢弃条目 / 越界 / 自引用的数字直接过滤掉,不报错。 | |
| 328 | + */ | |
| 329 | +function normalizeSteps(raw) { | |
| 330 | + if (!Array.isArray(raw) || !raw.length) return []; | |
| 331 | + const capped = raw.slice(0, MAX_STEPS); | |
| 332 | + | |
| 333 | + const out = []; | |
| 334 | + capped.forEach((item, i) => { | |
| 335 | + if (!item || typeof item !== 'object' || Array.isArray(item)) return; | |
| 336 | + const title = String(item.title == null ? '' : item.title).trim(); | |
| 337 | + if (!title) return; | |
| 338 | + out.push({ _origIndex: i + 1, title, item }); | |
| 339 | + }); | |
| 340 | + | |
| 341 | + const keptOrigSet = new Set(out.map((o) => o._origIndex)); | |
| 342 | + return out.map((o, idx) => { | |
| 343 | + const rawDeps = o.item.depends_on_hint !== undefined ? o.item.depends_on_hint : o.item.dependsOnHint; | |
| 344 | + const deps = []; | |
| 345 | + for (const d of Array.isArray(rawDeps) ? rawDeps : []) { | |
| 346 | + const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN; | |
| 347 | + if (Number.isInteger(n) && keptOrigSet.has(n) && n !== o._origIndex && !deps.includes(n)) deps.push(n); | |
| 348 | + } | |
| 349 | + // 48 号文档:`acceptance` 原样透传,不在这里做形状校验——深层校验(权重之和/runner | |
| 350 | + // 白名单)只有 goal-contract.validateContract 做得了,交给 materializeDecomposedSubtasks | |
| 351 | + // 建行时去做(单步不合法就跳过那一步,不作废整批,同款"宽松解析"哲学,见该函数注释)。 | |
| 352 | + const acceptance = o.item.acceptance && typeof o.item.acceptance === 'object' && !Array.isArray(o.item.acceptance) | |
| 353 | + ? o.item.acceptance | |
| 354 | + : undefined; | |
| 355 | + return { | |
| 356 | + seq: idx + 1, | |
| 357 | + title: o.title.slice(0, 200), | |
| 358 | + why: String(o.item.why == null ? '' : o.item.why).trim().slice(0, 1000), | |
| 359 | + depends_on_hint: deps, | |
| 360 | + ...(acceptance !== undefined ? { acceptance } : {}), | |
| 361 | + }; | |
| 362 | + }); | |
| 363 | +} | |
| 364 | + | |
| 365 | +// ──────────────────────────────────────────────────────────────── | |
| 366 | +// 触发入口 | |
| 367 | +// ──────────────────────────────────────────────────────────────── | |
| 368 | + | |
| 369 | +/** | |
| 370 | + * 触发一次 subtask-planning 并把结果写进这条 task 所属 run 的共享 artifacts 区 | |
| 371 | + * + 登记进 `graph_files` 索引。**纯 best-effort,绝不 throw**——见文件头注释。 | |
| 372 | + * | |
| 373 | + * @param {{run: object, task: object}} ctx run 是 graph_runs 表整行(createRun 的返回值即可, | |
| 374 | + * 只需要 company_id / root_run_id / id),task 是 tasks 表整行 | |
| 375 | + */ | |
| 376 | +async function triggerSubtaskPlanning({ run, task }) { | |
| 377 | + try { | |
| 378 | + if (!run || !task) return; | |
| 379 | + // 46 号文档 Phase 3:子任务不该被再拆一层——`materializeDecomposedSubtasks` 物化出的行 | |
| 380 | + // 也会经过 enqueueTaskRun(CEO 派发时),如果不挡住,会对一条已经是"第几步怎么做"粒度的 | |
| 381 | + // 子任务再跑一次 subtask-planning,浪费一次 LLM 调用还可能无限递归拆下去。 | |
| 382 | + // 43 号文档的验收条目子任务同理(createTask 本身已经不许它们再声明 itemized)。 | |
| 383 | + if (task.parent_task_id) { | |
| 384 | + console.log(`[SubtaskPlanning] task ${task.id} 本身就是子任务(parent_task_id=${task.parent_task_id}),跳过`); | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + const { | |
| 388 | + steps, tokens, reasoningSource, experienceRefs, researchUsed, | |
| 389 | + } = await planSubtasks({ companyId: run.company_id, task }); | |
| 390 | + if (!steps.length) { | |
| 391 | + console.log(`[SubtaskPlanning] task ${task.id} 没有可用的步骤分解(0 条),跳过写入`); | |
| 392 | + return; | |
| 393 | + } | |
| 394 | + | |
| 395 | + const { runRootDir } = require('./graph-engine'); | |
| 396 | + const { companyRootDir } = require('./company-paths'); | |
| 397 | + | |
| 398 | + const artifactsDir = path.join(runRootDir(run), 'artifacts'); | |
| 399 | + fs.mkdirSync(artifactsDir, { recursive: true }); | |
| 400 | + const file = path.join(artifactsDir, 'subtask-plan.json'); | |
| 401 | + fs.writeFileSync( | |
| 402 | + file, | |
| 403 | + JSON.stringify({ | |
| 404 | + steps, | |
| 405 | + generated_by: PLANNING_AGENT_ID, | |
| 406 | + task_id: task.id, | |
| 407 | + // Phase 2(46 号文档):这条分解到底是"模型自己想的"/"查到经验库"/"触发了调研", | |
| 408 | + // 由服务端根据实际发生了什么算出来(见 defaultPlanSubtasks),不是 agent 自报的。 | |
| 409 | + reasoning_source: reasoningSource || 'model_only', | |
| 410 | + experience_refs: experienceRefs || [], | |
| 411 | + research_used: !!researchUsed, | |
| 412 | + }, null, 2), | |
| 413 | + 'utf-8' | |
| 414 | + ); | |
| 415 | + | |
| 416 | + const wdb = require('./work-graph-db'); | |
| 417 | + wdb.recordFile({ | |
| 418 | + companyId: run.company_id, | |
| 419 | + taskId: task.id, | |
| 420 | + relPath: path.relative(companyRootDir(run.company_id), file), | |
| 421 | + kind: 'subtask_plan', | |
| 422 | + runId: run.id, | |
| 423 | + nodeRunId: null, | |
| 424 | + }); | |
| 425 | + | |
| 426 | + // 46 号文档 Phase 3:把这批 steps 物化成 decomposed_work 子任务。跟上面写 artifact/登记 | |
| 427 | + // 索引一样,走同一个 try/catch——物化失败不该让 subtask-plan.json 已经写好的部分失效。 | |
| 428 | + const materializedIds = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 429 | + | |
| 430 | + console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` | |
| 431 | + + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`); | |
| 432 | + } catch (err) { | |
| 433 | + console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); | |
| 434 | + } | |
| 435 | +} | |
| 436 | + | |
| 437 | +module.exports = { | |
| 438 | + PLANNING_AGENT_ID, | |
| 439 | + MAX_STEPS, | |
| 440 | + planSubtasks, | |
| 441 | + setPlanSubtasks, | |
| 442 | + setSubtaskAgentCaller, | |
| 443 | + triggerSubtaskPlanning, | |
| 444 | + // 下面几个导出是给测试用的。`defaultPlanSubtasks` 直接调用会绕开 `planSubtasks()` | |
| 445 | + // 的 VITEST 短路——测试这条分支编排逻辑必须搭配 `setSubtaskAgentCaller` 注入, | |
| 446 | + // 不要在没注入的情况下直接调用它(会真的起 CLI)。 | |
| 447 | + extractPlannedSteps, | |
| 448 | + extractConfidence, | |
| 449 | + normalizeSteps, | |
| 450 | + buildSubtaskPlanningPrompt, | |
| 451 | + defaultPlanSubtasks, | |
| 452 | +}; | ... | ... |
server/services/work-graph-db.js
| ... | ... | @@ -559,6 +559,26 @@ function getDb() { |
| 559 | 559 | -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— |
| 560 | 560 | -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 |
| 561 | 561 | parent_task_id TEXT REFERENCES tasks(id), |
| 562 | + -- 46 号文档 Phase 3:parent_task_id 非空的子任务分两种,靠这一列区分—— | |
| 563 | + -- 'acceptance_item' 43 号文档:验收条目物化(coder-validator 逐条上报,见 settleSubtaskResult) | |
| 564 | + -- 'decomposed_work' Phase 3:subtask-planning 步骤分解物化,等 CEO 心跳判断节奏后 enqueue | |
| 565 | + -- (v1 设计过"认领"pull 语义,真实回归后改成 CEO 直接派发的 push 语义—— | |
| 566 | + -- 认领需要的并发抢占场景不存在,CEO 一人拍板成本更低,历史决策见 46 号文档) | |
| 567 | + -- NULL = 普通 task(不是子任务,或 parent_task_id 也是 NULL)。两套子任务共用同一张表 | |
| 568 | + -- (而不是各开一张),因为 tasks 表本来就是通用工作项容器,这一列只是给已经混杂的 | |
| 569 | + -- 两种东西正名;settleNode 的 SUBTASKS_INCOMPLETE 闸门靠它把两套完成判定分开 | |
| 570 | + -- (graph-engine.js,只认 'acceptance_item',见该处注释;本段刻意不用反引号,见上)。 | |
| 571 | + subtask_kind TEXT CHECK (subtask_kind IN ('acceptance_item','decomposed_work') OR subtask_kind IS NULL), | |
| 572 | + -- 48 号文档:验收契约创建时 1:1 强绑定(主力机制)。JSON blob,形状是 goal-contract.js | |
| 573 | + -- 的 **benchmark 段本身**({dimensions:[{name,weight,pass_threshold?}], overall_pass_threshold?}), | |
| 574 | + -- 不是三段式契约的 wrapper——真实起 agent 验证过,"acceptance 对齐 benchmark 段"这句提示词 | |
| 575 | + -- 换来的就是段内容本身,不会带一层 benchmark 键。createTask 校验时现套一层给 | |
| 576 | + -- goal-contract.validateContract 用,不重写一遍规则;存库仍存裸段内容。 | |
| 577 | + -- 本轮范围只收在 decomposed_work 这条链路——createTask 强制它非空;其余创建路径 | |
| 578 | + -- (task-planning / org-planner 的 acceptance_item / 人类快速派单)不受影响,仍为 NULL。 | |
| 579 | + -- enqueueTaskRun 起 decomposed_work 的轻量图之前,把它落进新 run 的 artifacts/acceptance.json, | |
| 580 | + -- org-coder/org-validator 全程不需要感知这份契约是这条链路供的还是平时 org-planner 供的。 | |
| 581 | + acceptance_contract TEXT, | |
| 562 | 582 | created_at TEXT NOT NULL, |
| 563 | 583 | updated_at TEXT NOT NULL, |
| 564 | 584 | queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) |
| ... | ... | @@ -647,6 +667,22 @@ function getDb() { |
| 647 | 667 | // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 |
| 648 | 668 | db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`); |
| 649 | 669 | |
| 670 | + // ── 批 6:46 号文档 Phase 3,tasks 追加 subtask_kind ───────────────────── | |
| 671 | + // 同批 5 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上 subtask_kind 的取值域 | |
| 672 | + // 只由 createTask 的应用层校验守,与新建库的 CHECK 不同源。 | |
| 673 | + if (!hasColumn(db, 'tasks', 'subtask_kind')) { | |
| 674 | + db.exec(`ALTER TABLE tasks ADD COLUMN subtask_kind TEXT`); | |
| 675 | + } | |
| 676 | + | |
| 677 | + // ── 批 7:48 号文档,tasks 追加 acceptance_contract ────────────────────── | |
| 678 | + // 同批 5/6 的限制:ALTER TABLE ADD COLUMN 加不了 CHECK,存量库上取值域只由 createTask | |
| 679 | + // 的应用层校验守。旧行一律 NULL——这批上线前物化的 decomposed_work 子任务不会补一份契约, | |
| 680 | + // enqueueTaskRun 遇到 NULL 就跳过写入,org-validator 仍会 input_underspecified; | |
| 681 | + // 这是已知的、范围内可接受的存量缺口(新物化的子任务从这次上线起才有)。 | |
| 682 | + if (!hasColumn(db, 'tasks', 'acceptance_contract')) { | |
| 683 | + db.exec(`ALTER TABLE tasks ADD COLUMN acceptance_contract TEXT`); | |
| 684 | + } | |
| 685 | + | |
| 650 | 686 | // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 |
| 651 | 687 | if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { |
| 652 | 688 | db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); |
| ... | ... | @@ -1869,6 +1905,11 @@ function validateDependsOn(goalId, taskId, dependsOn) { |
| 1869 | 1905 | * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。 |
| 1870 | 1906 | * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic' |
| 1871 | 1907 | * (不允许递归再拆一层)——传 `parentTaskId` 时若给了 'itemized' 会被拒。 |
| 1908 | + * @param {object|null} [p.acceptanceContract] 48 号文档:验收契约,创建时 1:1 强绑定。 | |
| 1909 | + * 形状是 goal-contract.js 的 **benchmark 段本身**(`{dimensions:[...], overall_pass_threshold?}`), | |
| 1910 | + * 不是三段式契约的 wrapper——校验时现套一层再走 `goal-contract.validateContract`。 | |
| 1911 | + * **`subtaskKind==='decomposed_work'` 时必填**——这是主力机制的硬约束点;其余创建路径 | |
| 1912 | + * 不许传(校验会拒),本轮范围只收在这条链路。 | |
| 1872 | 1913 | * @returns {object} 刚插入的整行 |
| 1873 | 1914 | */ |
| 1874 | 1915 | function createTask({ |
| ... | ... | @@ -1885,6 +1926,8 @@ function createTask({ |
| 1885 | 1926 | createdBy = null, |
| 1886 | 1927 | parentTaskId = null, |
| 1887 | 1928 | acceptanceMode = 'holistic', |
| 1929 | + subtaskKind = null, | |
| 1930 | + acceptanceContract = null, | |
| 1888 | 1931 | } = {}) { |
| 1889 | 1932 | if (!goalId) throw new Error('createTask: goalId 必填'); |
| 1890 | 1933 | if (!title) throw new Error('createTask: title 必填'); |
| ... | ... | @@ -1895,6 +1938,41 @@ function createTask({ |
| 1895 | 1938 | if (parentTaskId && acceptanceMode === 'itemized') { |
| 1896 | 1939 | throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分'); |
| 1897 | 1940 | } |
| 1941 | + // 46 号文档 Phase 3:subtask_kind 只对子任务有意义,取值域应用层守(同上 acceptanceMode 那条注释)。 | |
| 1942 | + // 没有专属初始 status——decomposed_work 子任务就是普通 draft task,物化即走 | |
| 1943 | + // draft→(CEO 判断节奏后)enqueue 这条现成的路,不需要额外的状态。 | |
| 1944 | + if (subtaskKind !== null && !['acceptance_item', 'decomposed_work'].includes(subtaskKind)) { | |
| 1945 | + throw new Error(`createTask: subtaskKind 非法: ${subtaskKind}`); | |
| 1946 | + } | |
| 1947 | + if (subtaskKind && !parentTaskId) { | |
| 1948 | + throw new Error('createTask: subtaskKind 只对子任务(有 parentTaskId)有意义'); | |
| 1949 | + } | |
| 1950 | + | |
| 1951 | + // 48 号文档:验收契约创建时 1:1 强绑定,本轮范围只收在 decomposed_work 这条链路—— | |
| 1952 | + // 主力机制的硬约束点。不合法的形状(权重不足 1 / runner 越界 / 空契约……)在这里就被挡, | |
| 1953 | + // 不等到 org-validator 跑起来才发现契约是坏的。 | |
| 1954 | + let acceptanceContractText = null; | |
| 1955 | + if (subtaskKind === 'decomposed_work') { | |
| 1956 | + if (!acceptanceContract || typeof acceptanceContract !== 'object' || Array.isArray(acceptanceContract)) { | |
| 1957 | + throw new Error('createTask: decomposed_work 子任务必须带 acceptanceContract(验收契约,见 48 号文档)'); | |
| 1958 | + } | |
| 1959 | + } else if (acceptanceContract != null) { | |
| 1960 | + throw new Error('createTask: acceptanceContract 只对 decomposed_work 子任务有意义(本轮范围,见 48 号文档)'); | |
| 1961 | + } | |
| 1962 | + if (acceptanceContract) { | |
| 1963 | + // 懒加载:goal-contract.js 只在这里被用到,其余 createTask 调用路径不该为此多背一个 require。 | |
| 1964 | + // ⚠️ 形状是 goal-contract.js 的 **benchmark 段本身**({dimensions[], overall_pass_threshold?}), | |
| 1965 | + // 不是三段式契约的 {benchmark:{...}} 包一层——真实起 subtask-planning agent 验证过: | |
| 1966 | + // 提示词教的就是"acceptance 对齐 benchmark 段",agent 老老实实交回的是段内容本身, | |
| 1967 | + // 不会自己多包一层 benchmark 键。校验时补这一层给 validateContract 用,存库仍存裸段内容。 | |
| 1968 | + const { validateContract, CONTRACT_VERSION } = require('./goal-contract'); | |
| 1969 | + const wrapped = { version: CONTRACT_VERSION, goal_id: goalId, benchmark: acceptanceContract }; | |
| 1970 | + const v = validateContract(wrapped); | |
| 1971 | + if (!v.ok) { | |
| 1972 | + throw new Error(`createTask: acceptanceContract 不合法: ${JSON.stringify(v.errors)}`); | |
| 1973 | + } | |
| 1974 | + acceptanceContractText = JSON.stringify(acceptanceContract); | |
| 1975 | + } | |
| 1898 | 1976 | |
| 1899 | 1977 | const db = getDb(); |
| 1900 | 1978 | const goal = getGoal(goalId); |
| ... | ... | @@ -1926,8 +2004,8 @@ function createTask({ |
| 1926 | 2004 | const ins = db.prepare(` |
| 1927 | 2005 | INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status, |
| 1928 | 2006 | execution_mode, depends_on, priority, budget_json, report_to, created_by, |
| 1929 | - parent_task_id, acceptance_mode, created_at, updated_at) | |
| 1930 | - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 2007 | + parent_task_id, acceptance_mode, subtask_kind, acceptance_contract, created_at, updated_at) | |
| 2008 | + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 1931 | 2009 | `); |
| 1932 | 2010 | const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run( |
| 1933 | 2011 | tid, goalId, String(cid), nextSeq, String(title), String(description), |
| ... | ... | @@ -1935,7 +2013,7 @@ function createTask({ |
| 1935 | 2013 | // report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。 |
| 1936 | 2014 | // goal 上有就继承下来,别让每个调用方各记一遍。 |
| 1937 | 2015 | reportTo || goal.report_to || null, |
| 1938 | - createdBy, parentTaskId, acceptanceMode, now, now | |
| 2016 | + createdBy, parentTaskId, acceptanceMode, subtaskKind, acceptanceContractText, now, now | |
| 1939 | 2017 | )); |
| 1940 | 2018 | return getTask(id); |
| 1941 | 2019 | } |
| ... | ... | @@ -1962,7 +2040,8 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) { |
| 1962 | 2040 | * 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。 |
| 1963 | 2041 | */ |
| 1964 | 2042 | function listCompanyTasks(companyId, { |
| 1965 | - status = null, goalId = null, parentTaskId = null, includeArchived = false, limit = null, | |
| 2043 | + status = null, goalId = null, parentTaskId = null, subtaskKind = null, | |
| 2044 | + includeArchived = false, limit = null, | |
| 1966 | 2045 | } = {}) { |
| 1967 | 2046 | const where = ['company_id = @companyId']; |
| 1968 | 2047 | const params = { companyId }; |
| ... | ... | @@ -1980,6 +2059,12 @@ function listCompanyTasks(companyId, { |
| 1980 | 2059 | where.push('parent_task_id = @parentTaskId'); |
| 1981 | 2060 | params.parentTaskId = parentTaskId; |
| 1982 | 2061 | } |
| 2062 | + // 46 号文档 Phase 3:`?subtaskKind=decomposed_work&status=draft` 就是 CEO 心跳巡检 | |
| 2063 | + // "有哪些子任务等我判断节奏"的查询——不需要专门的端点,复用这条通用列表接口加一个过滤参数。 | |
| 2064 | + if (subtaskKind) { | |
| 2065 | + where.push('subtask_kind = @subtaskKind'); | |
| 2066 | + params.subtaskKind = subtaskKind; | |
| 2067 | + } | |
| 1983 | 2068 | if (!includeArchived) where.push('archived_at IS NULL'); |
| 1984 | 2069 | const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : ''; |
| 1985 | 2070 | return getDb() |
| ... | ... | @@ -2007,6 +2092,7 @@ const TASK_PATCH_COLUMNS = { |
| 2007 | 2092 | archivedAt: 'archived_at', |
| 2008 | 2093 | summary: 'summary', |
| 2009 | 2094 | executionConvId: 'execution_conv_id', |
| 2095 | + acceptanceContract: 'acceptance_contract', | |
| 2010 | 2096 | }; |
| 2011 | 2097 | |
| 2012 | 2098 | /** |
| ... | ... | @@ -2016,8 +2102,12 @@ const TASK_PATCH_COLUMNS = { |
| 2016 | 2102 | */ |
| 2017 | 2103 | const DEPENDS_ON_EDITABLE_STATUS = ['draft', 'queued', 'blocked']; |
| 2018 | 2104 | |
| 2019 | -/** queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过 */ | |
| 2020 | -const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson']; | |
| 2105 | +/** | |
| 2106 | + * queued_at 非空后就不许再改的字段(§四.4):它们都已经被 task-goal.json 或预算池消费过。 | |
| 2107 | + * `acceptanceContract`(48 号文档)同理——`enqueueTaskRun` 起 run 时就把它写进了新 run 的 | |
| 2108 | + * artifacts/acceptance.json,队列位次之后再改契约,org-validator 判的和 agent 交的会对不上。 | |
| 2109 | + */ | |
| 2110 | +const TASK_FROZEN_FIELDS = ['description', 'executionMode', 'budgetJson', 'acceptanceContract']; | |
| 2021 | 2111 | |
| 2022 | 2112 | /** |
| 2023 | 2113 | * 改 task。 |
| ... | ... | @@ -2141,6 +2231,122 @@ function settleSubtaskResult(taskId, status, summary = null) { |
| 2141 | 2231 | } |
| 2142 | 2232 | |
| 2143 | 2233 | /** |
| 2234 | + * 46 号文档 Phase 3(v2,2026-09-02 改版):把 `subtask-planning` 产出的 `steps[]` 物化成 | |
| 2235 | + * decomposed_work 子任务。**v1 曾经设计成"认领"(pull,`claimable`/`claimed` 两个专属状态 + | |
| 2236 | + * `claimSubtask`/`unclaimSubtask` 两个 CAS 函数),真实回归后拍板改掉**:认领要解决的是 | |
| 2237 | + * "多方竞争同一条任务",但实际执行者只有 CEO 一个人拍板要不要派、派给谁,压根不存在竞争, | |
| 2238 | + * CAS 是给不存在的问题找的解——现在直接砍掉,物化出的子任务就是普通 `draft` task, | |
| 2239 | + * CEO 心跳判断节奏后调现成的 `/tasks/:taskId/enqueue`(旧 API,不用再发明新端点)。 | |
| 2240 | + * | |
| 2241 | + * 两阶段建法:先把全部行建好拿到真实 task id,再回填 `depends_on`——`steps[].depends_on_hint` | |
| 2242 | + * 是这批 steps 内部的 1-based `seq` 引用,`subtask-planning.js` 的 `normalizeSteps` 只保证 | |
| 2243 | + * 它指向本批次内某个存在的 `seq`,**不保证只能指向更早的 seq**(agent.md 没有强制这条顺序), | |
| 2244 | + * 边建边填会在"前一步依赖后一步"这种前向引用上直接炸(`validateDependsOn` 要求依赖必须已存在)。 | |
| 2245 | + * 先建后填就不用管顺序。 | |
| 2246 | + * | |
| 2247 | + * `executionMode: 'auto'`——CEO 一旦决定 enqueue 就该一路跑完,不需要停在入口等人工 `/next` | |
| 2248 | + * (那是 `manual` 模式的语义,给"人下达"这个场景设计的,不适合"CEO 已经拍过板"这条路)。 | |
| 2249 | + * | |
| 2250 | + * 幂等:已经物化过 `decomposed_work` 子任务的 task 直接跳过,不重复物化(同款判据见 | |
| 2251 | + * `graph-engine.maybeMaterializeItemizedSubtasks` 的 `already` 检查——两处独立各写一份, | |
| 2252 | + * 因为分属两个模块,不值得为一次判空抽个共享工具函数)。 | |
| 2253 | + * | |
| 2254 | + * 找不到某个 `depends_on_hint` 对应的兄弟行(理论上不该发生,防御性处理)就丢那一条引用, | |
| 2255 | + * 不作废整批——延续 Phase 1 定的"宽松解析"哲学。 | |
| 2256 | + * | |
| 2257 | + * 48 号文档:每条物化出的行都是 `createTask` 强制要求带 `acceptanceContract` 的 | |
| 2258 | + * `decomposed_work` 子任务——`step.acceptance` 缺失或形状不合法时,`createTask` 会抛错。 | |
| 2259 | + * 这里按同一条"宽松解析"哲学处理:**单步失败就跳过那一步**(`console.warn`),不作废整批, | |
| 2260 | + * 不像"没有 title"那样在 `normalizeSteps` 阶段就地丢弃——因为深层校验(权重之和/runner 白名单 | |
| 2261 | + * 这些)只有 `goal-contract.validateContract` 才做得了,提前到这里重写一遍就是第二个持有者。 | |
| 2262 | + * | |
| 2263 | + * @param {{task: object, steps: Array<{seq:number,title:string,why?:string, | |
| 2264 | + * depends_on_hint?:number[],acceptance?:object}>}} ctx | |
| 2265 | + * @returns {string[]} 物化出的 task id,按 steps 顺序(跳过失败的那些);已物化过 / 无 steps 时返回 [] | |
| 2266 | + */ | |
| 2267 | +function materializeDecomposedSubtasks({ task, steps }) { | |
| 2268 | + if (!task || !Array.isArray(steps) || !steps.length) return []; | |
| 2269 | + | |
| 2270 | + const already = getDb() | |
| 2271 | + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) | |
| 2272 | + .get(task.id).n; | |
| 2273 | + if (already > 0) return []; | |
| 2274 | + | |
| 2275 | + const seqToId = new Map(); | |
| 2276 | + const materialized = []; | |
| 2277 | + for (const step of steps) { | |
| 2278 | + let row; | |
| 2279 | + try { | |
| 2280 | + row = createTask({ | |
| 2281 | + goalId: task.goal_id, | |
| 2282 | + companyId: task.company_id, | |
| 2283 | + title: String(step.title || '').slice(0, 200), | |
| 2284 | + description: step.why ? `${step.title}\n\n${step.why}` : String(step.title || ''), | |
| 2285 | + executionMode: 'auto', | |
| 2286 | + createdBy: 'subtask-planning', | |
| 2287 | + parentTaskId: task.id, | |
| 2288 | + subtaskKind: 'decomposed_work', | |
| 2289 | + acceptanceContract: step.acceptance, | |
| 2290 | + }); | |
| 2291 | + } catch (e) { | |
| 2292 | + console.warn(`[work-graph-db] task ${task.id} 第 ${step.seq} 步物化失败,跳过(不影响同批其它步骤): ${e.message}`); | |
| 2293 | + continue; | |
| 2294 | + } | |
| 2295 | + seqToId.set(step.seq, row.id); | |
| 2296 | + materialized.push(step); | |
| 2297 | + } | |
| 2298 | + | |
| 2299 | + for (const step of materialized) { | |
| 2300 | + const hints = Array.isArray(step.depends_on_hint) ? step.depends_on_hint : []; | |
| 2301 | + const deps = hints.map((h) => seqToId.get(h)).filter(Boolean); | |
| 2302 | + if (deps.length) updateTask(seqToId.get(step.seq), { dependsOn: deps }); | |
| 2303 | + } | |
| 2304 | + | |
| 2305 | + return materialized.map((s) => seqToId.get(s.seq)); | |
| 2306 | +} | |
| 2307 | + | |
| 2308 | +/** | |
| 2309 | + * 46 号文档 Phase 3(v2):给一条 `decomposed_work` 子任务现造一张最小的 work graph—— | |
| 2310 | + * `实现(org-coder)→ 验证(org-validator)`两个节点,`agent_call`(不是 `subgraph`), | |
| 2311 | + * 跳过公司完整 org graph 那套 ceo→cto→org-planner→org-coder→org-validator 的规划仪式。 | |
| 2312 | + * | |
| 2313 | + * 为什么跳得掉:`subtask-planning` 已经把"怎么做"拆到了这一步的粒度,`org-planner` 平时干的 | |
| 2314 | + * brd/prd/sdd 那一套是在回答"这件事怎么做"——对一条已经具体到"实现 add 子命令"的子任务, | |
| 2315 | + * 再走一遍纯属重复劳动,Phase 0 真实测过一条完整 task 走完整 org graph 近 19 分钟, | |
| 2316 | + * 子任务量级不该背这个成本。 | |
| 2317 | + * | |
| 2318 | + * 没有声明任何 `needs`/`produces` 边契约(`declareEdgeContract` 一次都没调)——`agent_call` | |
| 2319 | + * 节点没有声明 needs 就不会触发 `requiredInputsMissing`,没有声明 produces 就不会触发 | |
| 2320 | + * `unsatisfiedRequiredProduces`,这两道闸门天然对这张图是空的,不用额外放行。 | |
| 2321 | + * | |
| 2322 | + * ⚠️ **这是一次没有真实验证过的假设,写在这里免得下一个人以为它是钉死的设计**: | |
| 2323 | + * `org-coder`/`org-validator` 平时只在"公司完整 org graph 的 subgraph 节点"这个上下文里 | |
| 2324 | + * 被调用过,它们的 `agent.md`/`tools.md` 有没有隐含"前面一定有 brd/prd/sdd 产物"这种假设, | |
| 2325 | + * 直接当 `agent_call` 裸调用会不会读不到预期上下文——没有实测过,需要真实回归验证。 | |
| 2326 | + * 用户的决定是"先跑轻量版看效果,不行再上完整 org graph"(46 号文档),这就是那次实验。 | |
| 2327 | + * | |
| 2328 | + * @param {{companyId: string, task: object}} ctx task 是那条 decomposed_work 子任务本身 | |
| 2329 | + * @returns {string} 新建的 work graph id | |
| 2330 | + */ | |
| 2331 | +function createDecomposedWorkGraph({ companyId, task }) { | |
| 2332 | + const g = createWorkGraph({ | |
| 2333 | + companyId, | |
| 2334 | + plannedBy: 'subtask-dispatch', | |
| 2335 | + goalId: task.goal_id, | |
| 2336 | + taskId: task.id, | |
| 2337 | + nodes: [ | |
| 2338 | + { nodeName: 'impl', systemAgentId: 'org-coder', nodeKind: 'agent_call' }, | |
| 2339 | + { nodeName: 'validate', systemAgentId: 'org-validator', nodeKind: 'agent_call' }, | |
| 2340 | + ], | |
| 2341 | + edges: [{ from: 'impl', to: 'validate', condition: 'success' }], | |
| 2342 | + }); | |
| 2343 | + if (!g.ok) { | |
| 2344 | + throw new Error(`createDecomposedWorkGraph: 建图失败: ${JSON.stringify(g.errors)}`); | |
| 2345 | + } | |
| 2346 | + return g.id; | |
| 2347 | +} | |
| 2348 | + | |
| 2349 | +/** | |
| 2144 | 2350 | * 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。 |
| 2145 | 2351 | * |
| 2146 | 2352 | * 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的, |
| ... | ... | @@ -2774,6 +2980,9 @@ module.exports = { |
| 2774 | 2980 | settleTaskFromRun, |
| 2775 | 2981 | settleSubtaskResult, |
| 2776 | 2982 | GRAPH_TAKEOVER_ASSIGNEE, |
| 2983 | + // 46 号文档 Phase 3(v2):CEO 派发 decomposed_work 子任务 | |
| 2984 | + materializeDecomposedSubtasks, | |
| 2985 | + createDecomposedWorkGraph, | |
| 2777 | 2986 | // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 |
| 2778 | 2987 | claimQueuedTask, |
| 2779 | 2988 | blockQueuedTask, | ... | ... |
server/tests/unit/deep-research.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * 46 号文档 Phase 2:deep-research 按需触发 org-hunt 的轻量调研钩子。 | |
| 3 | + * | |
| 4 | + * 最重要的一条是"默认关闭"这条安全阀——`ENABLE_DEEP_RESEARCH` 不为 `'1'` 时, | |
| 5 | + * 连注入的假实现都不应该被调用到,直接短路返回 null(避免真事故:Phase 1 就因为 | |
| 6 | + * 一个新触发点疏于隔离,一次单测跑出 44 个残留 CLI 子进程)。 | |
| 7 | + */ | |
| 8 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | |
| 9 | +import { | |
| 10 | + triggerDeepResearch, setTriggerDeepResearch, buildResearchPrompt, | |
| 11 | +} from '../../services/deep-research.js'; | |
| 12 | + | |
| 13 | +describe('deep-research · triggerDeepResearch', () => { | |
| 14 | + const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH; | |
| 15 | + | |
| 16 | + beforeEach(() => { | |
| 17 | + setTriggerDeepResearch(null); | |
| 18 | + }); | |
| 19 | + afterEach(() => { | |
| 20 | + setTriggerDeepResearch(null); | |
| 21 | + if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH; | |
| 22 | + else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE; | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it('ENABLE_DEEP_RESEARCH 未开启时直接返回 null,不调用任何实现(哪怕注入了假实现)', async () => { | |
| 26 | + delete process.env.ENABLE_DEEP_RESEARCH; | |
| 27 | + let called = false; | |
| 28 | + setTriggerDeepResearch(async () => { called = true; return { findings: 'x', sources: [], tokens: 1 }; }); | |
| 29 | + | |
| 30 | + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: '贪吃蛇怎么做' }); | |
| 31 | + expect(out).toBeNull(); | |
| 32 | + expect(called).toBe(false); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it('开启后走注入实现,返回其结果', async () => { | |
| 36 | + process.env.ENABLE_DEEP_RESEARCH = '1'; | |
| 37 | + setTriggerDeepResearch(async (ctx) => { | |
| 38 | + expect(ctx.topic).toBe('贪吃蛇怎么做'); | |
| 39 | + return { findings: '业界通常先定义棋盘再做碰撞检测', sources: ['https://example.com'], tokens: 123 }; | |
| 40 | + }); | |
| 41 | + | |
| 42 | + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: '贪吃蛇怎么做' }); | |
| 43 | + expect(out).toEqual({ | |
| 44 | + findings: '业界通常先定义棋盘再做碰撞检测', | |
| 45 | + sources: ['https://example.com'], | |
| 46 | + tokens: 123, | |
| 47 | + }); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it('开启后注入实现抛错:吞掉,返回 null,不向调用方传播', async () => { | |
| 51 | + process.env.ENABLE_DEEP_RESEARCH = '1'; | |
| 52 | + setTriggerDeepResearch(async () => { throw new Error('CLI 起不来'); }); | |
| 53 | + | |
| 54 | + await expect( | |
| 55 | + triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: 't' }) | |
| 56 | + ).resolves.toBeNull(); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it('VITEST 环境且未注入时即使开启开关也跳过真实调用', async () => { | |
| 60 | + process.env.ENABLE_DEEP_RESEARCH = '1'; | |
| 61 | + // 不调用 setTriggerDeepResearch,保持默认实现——process.env.VITEST 由 vitest 自动置位 | |
| 62 | + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: 't' }); | |
| 63 | + expect(out).toBeNull(); | |
| 64 | + }); | |
| 65 | +}); | |
| 66 | + | |
| 67 | +describe('deep-research · buildResearchPrompt', () => { | |
| 68 | + it('提示词包含调研主题、交回格式,明确说明不套用训练场景三件套契约', () => { | |
| 69 | + const prompt = buildResearchPrompt({ topic: '贪吃蛇怎么做' }); | |
| 70 | + expect(prompt).toContain('贪吃蛇怎么做'); | |
| 71 | + expect(prompt).toContain('findings'); | |
| 72 | + expect(prompt).toContain('sources'); | |
| 73 | + expect(prompt).toContain('不需要'); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it('主题缺失时给出明确占位', () => { | |
| 77 | + const prompt = buildResearchPrompt({}); | |
| 78 | + expect(prompt).toContain('调用方没给主题'); | |
| 79 | + }); | |
| 80 | +}); | ... | ... |
server/tests/unit/experience-lookup.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * 46 号文档 Phase 2:experience-lookup 查经验库。 | |
| 3 | + * | |
| 4 | + * 夹具模式照抄 `dream-service.test.js`:真实磁盘下建一个 `zz-explookup-*` 测试 agentId, | |
| 5 | + * 写 `memory/experience.md`(dream `writeDistilled` 落的同一种格式),`company_agents` | |
| 6 | + * 表插入雇佣关系——测的是"读得到 dream 已经写好的经验"这一半,不是 dream 本身。 | |
| 7 | + */ | |
| 8 | +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; | |
| 9 | +import fs from 'fs'; | |
| 10 | +import os from 'os'; | |
| 11 | +import path from 'path'; | |
| 12 | + | |
| 13 | +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-explookup-')); | |
| 14 | +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); | |
| 15 | + | |
| 16 | +const ROOT = path.resolve(__dirname, '../../..'); | |
| 17 | +const AGENT = 'zz-explookup-body'; | |
| 18 | +const OTHER_AGENT = 'zz-explookup-other'; | |
| 19 | +const COMPANY = 'zz-explookup-co'; | |
| 20 | +const BODY = path.join(ROOT, 'system', 'agents', AGENT); | |
| 21 | +const OTHER_BODY = path.join(ROOT, 'system', 'agents', OTHER_AGENT); | |
| 22 | + | |
| 23 | +const { queryExperience, tokenize, readExperienceBlocks } = require('../../services/experience-lookup'); | |
| 24 | +const wdb = require('../../services/work-graph-db'); | |
| 25 | + | |
| 26 | +function writeExperience(agentId, blocks) { | |
| 27 | + const dir = path.join(ROOT, 'system', 'agents', agentId, 'memory'); | |
| 28 | + fs.mkdirSync(dir, { recursive: true }); | |
| 29 | + const body = blocks.map((b) => `### ${b.title}\n\n${b.body}\n\n> 提炼于 2026-09-01;原料:x:memory.md#1\n`).join('\n'); | |
| 30 | + fs.writeFileSync(path.join(dir, 'experience.md'), `# 跨公司经验(dream 提炼)\n\n${body}`, 'utf-8'); | |
| 31 | +} | |
| 32 | + | |
| 33 | +function hire(companyId, agentId) { | |
| 34 | + const db = wdb.getDb(); | |
| 35 | + const now = new Date().toISOString(); | |
| 36 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 37 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 38 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 39 | + .run(agentId, agentId, `system/agents/${agentId}`, now, now); | |
| 40 | + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) | |
| 41 | + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now); | |
| 42 | +} | |
| 43 | + | |
| 44 | +describe('experience-lookup · queryExperience', () => { | |
| 45 | + beforeEach(() => { | |
| 46 | + fs.rmSync(BODY, { recursive: true, force: true }); | |
| 47 | + fs.rmSync(OTHER_BODY, { recursive: true, force: true }); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + afterAll(() => { | |
| 51 | + fs.rmSync(BODY, { recursive: true, force: true }); | |
| 52 | + fs.rmSync(OTHER_BODY, { recursive: true, force: true }); | |
| 53 | + try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {} | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it('命中相关经验,按关键词重叠打分排序', () => { | |
| 57 | + hire(COMPANY, AGENT); | |
| 58 | + writeExperience(AGENT, [ | |
| 59 | + { title: '贪吃蛇碰撞检测踩过的坑', body: '棋盘边界判断要在移动前做,不然会穿墙' }, | |
| 60 | + { title: '完全不相关的经验', body: '发工资流程走财务系统,不归这里管' }, | |
| 61 | + ]); | |
| 62 | + | |
| 63 | + const { matches, queriedAgents } = queryExperience({ | |
| 64 | + companyId: COMPANY, | |
| 65 | + task: { title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' }, | |
| 66 | + }); | |
| 67 | + | |
| 68 | + expect(queriedAgents).toContain(AGENT); | |
| 69 | + expect(matches.length).toBeGreaterThan(0); | |
| 70 | + expect(matches[0].title).toBe('贪吃蛇碰撞检测踩过的坑'); | |
| 71 | + expect(matches[0].agentId).toBe(AGENT); | |
| 72 | + expect(matches[0].score).toBeGreaterThan(0); | |
| 73 | + // 不相关的那条不该排在命中结果里靠前的位置(分数应明显更低或不命中) | |
| 74 | + const irrelevant = matches.find((m) => m.title === '完全不相关的经验'); | |
| 75 | + if (irrelevant) expect(irrelevant.score).toBeLessThan(matches[0].score); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it('长任务描述 + 通用工程词汇的经验只是偶然重合 1-2 个 bigram 时不算命中' | |
| 79 | + + '(真实回归事故复现:46 号文档 Phase 2 真实端到端验证时,一个贪吃蛇任务把' | |
| 80 | + + '"验证修复要重新触发流程"这类完全不相关的工程经验当成了"经验库有答案")', () => { | |
| 81 | + hire(COMPANY, AGENT); | |
| 82 | + writeExperience(AGENT, [ | |
| 83 | + { title: '验证修复要重新触发流程看真实产出', body: '不能只信别人汇报,要亲自跑一遍确认修复真的生效' }, | |
| 84 | + ]); | |
| 85 | + | |
| 86 | + const longTask = { | |
| 87 | + title: '实现命令行贪吃蛇游戏', | |
| 88 | + description: '用 Node.js 实现一个可以在终端里运行和游玩的贪吃蛇游戏。要求:使用方向键控制蛇的移动' | |
| 89 | + + '方向;蛇吃到食物后身体变长,并在场景中重新生成新的食物;蛇撞到边界或撞到自己身体时游戏立即' | |
| 90 | + + '结束,并在结束时显示本局得分。达成判据:在终端执行启动命令后能看到游戏画面并用方向键操作蛇' | |
| 91 | + + '移动;吃到食物后蛇身变长且得分增加;撞墙或撞到自己身体后游戏停止并打印出最终分数。', | |
| 92 | + }; | |
| 93 | + const { matches } = queryExperience({ companyId: COMPANY, task: longTask }); | |
| 94 | + expect(matches).toEqual([]); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it('没有命中时返回空数组,不是异常', () => { | |
| 98 | + hire(COMPANY, AGENT); | |
| 99 | + writeExperience(AGENT, [{ title: '完全不相关的经验', body: '发工资流程走财务系统' }]); | |
| 100 | + | |
| 101 | + const { matches } = queryExperience({ | |
| 102 | + companyId: COMPANY, | |
| 103 | + task: { title: '实现贪吃蛇', description: '碰撞检测' }, | |
| 104 | + }); | |
| 105 | + expect(matches).toEqual([]); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it('agent 没有 experience.md 文件时不报错,视为该 agent 无命中', () => { | |
| 109 | + hire(COMPANY, AGENT); // 雇了但从没被 dream 写过经验 | |
| 110 | + expect(() => queryExperience({ | |
| 111 | + companyId: COMPANY, | |
| 112 | + task: { title: '实现贪吃蛇', description: '碰撞检测' }, | |
| 113 | + })).not.toThrow(); | |
| 114 | + expect(readExperienceBlocks(AGENT)).toEqual([]); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + it('公司没有雇任何 agent 时返回空数组', () => { | |
| 118 | + const { matches, queriedAgents } = queryExperience({ | |
| 119 | + companyId: 'zz-explookup-empty-co', | |
| 120 | + task: { title: '实现贪吃蛇', description: '碰撞检测' }, | |
| 121 | + }); | |
| 122 | + expect(matches).toEqual([]); | |
| 123 | + expect(queriedAgents).toEqual([]); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it('task 标题/描述都为空时返回空数组,不做无意义的全量扫描', () => { | |
| 127 | + hire(COMPANY, AGENT); | |
| 128 | + const { matches } = queryExperience({ companyId: COMPANY, task: {} }); | |
| 129 | + expect(matches).toEqual([]); | |
| 130 | + }); | |
| 131 | + | |
| 132 | + it('tokenize:拉丁词整段切,中文用双字滑窗(无分词依赖),单字符丢弃', () => { | |
| 133 | + const tokens = tokenize('贪吃蛇 game 的 A 实现'); | |
| 134 | + expect(tokens).toEqual(expect.arrayContaining(['贪吃', '吃蛇', 'game', '实现'])); | |
| 135 | + expect(tokens).not.toContain('a'); | |
| 136 | + expect(tokens).not.toContain('的'); | |
| 137 | + }); | |
| 138 | +}); | ... | ... |
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 { | |
| 11 | + describe, it, expect, beforeAll, afterAll, afterEach, | |
| 12 | +} from 'vitest'; | |
| 13 | +import fs from 'fs'; | |
| 14 | +import os from 'os'; | |
| 15 | +import path from 'path'; | |
| 16 | + | |
| 17 | +const ROOT = path.resolve(__dirname, '../../..'); | |
| 18 | +const TEST_DB_PATH = '/tmp/test-subtask-planning-materialize.sqlite'; | |
| 19 | +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | |
| 20 | +const MODULES = [ | |
| 21 | + '../../services/graph-db', | |
| 22 | + '../../services/graph-service', | |
| 23 | + '../../services/org-graph-service', | |
| 24 | + '../../services/work-graph-db', | |
| 25 | + '../../services/graph-run-sse', | |
| 26 | + '../../services/graph-engine', | |
| 27 | + '../../services/subtask-planning', | |
| 28 | +]; | |
| 29 | + | |
| 30 | +describe('46 号文档 Phase 1:subtask-plan.json 共享区落地 + 任务级共享注入', () => { | |
| 31 | + let wdb; | |
| 32 | + let engine; | |
| 33 | + let subtaskPlanning; | |
| 34 | + let runsDir; | |
| 35 | + | |
| 36 | + beforeAll(() => { | |
| 37 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 38 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 39 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subtask-planning-runs-')); | |
| 40 | + process.env.GRAPH_RUNS_DIR = runsDir; | |
| 41 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 42 | + wdb = require('../../services/work-graph-db'); | |
| 43 | + engine = require('../../services/graph-engine'); | |
| 44 | + subtaskPlanning = require('../../services/subtask-planning'); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + afterAll(() => { | |
| 48 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 49 | + delete process.env.GRAPH_DB_PATH; | |
| 50 | + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | |
| 51 | + delete process.env.GRAPH_RUNS_DIR; | |
| 52 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 53 | + }); | |
| 54 | + | |
| 55 | + function ensureAgent(id) { | |
| 56 | + const db = wdb.getDb(); | |
| 57 | + const now = new Date().toISOString(); | |
| 58 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 59 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 60 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 61 | + .run(id, id, `system/agents/${id}`, now, now); | |
| 62 | + } | |
| 63 | + | |
| 64 | + /** wdb.createRun 只回 {ok, id, nodeRunIds},不是完整行 —— 服务层要的 root_run_id / | |
| 65 | + * company_id 得回读一次(company.js 的 enqueueTaskRun 也是这么用的,同一处坑)。 */ | |
| 66 | + function createRunRow(opts) { | |
| 67 | + const created = wdb.createRun(opts); | |
| 68 | + return wdb.getRun(created.id); | |
| 69 | + } | |
| 70 | + | |
| 71 | + it('triggerSubtaskPlanning 写共享 artifacts/subtask-plan.json + 登记 graph_files', async () => { | |
| 72 | + // setPlanSubtasks 注入替换的是整条 planSubtasks(含 normalizeSteps 那一步), | |
| 73 | + // 单条不合法数据被丢弃/重新编号是 normalizeSteps 自己的职责,已在 | |
| 74 | + // subtask-planning.test.js 单独钉住;这里注入的是"已经规整过"的 steps, | |
| 75 | + // 测的是 triggerSubtaskPlanning 往后那一段(写文件 + 登记索引)。 | |
| 76 | + const companyId = 'co-subplan-1'; | |
| 77 | + const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' }); | |
| 78 | + const task = wdb.createTask({ goalId: goal.id, title: '实现贪吃蛇', description: 'd' }); | |
| 79 | + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); | |
| 80 | + | |
| 81 | + subtaskPlanning.setPlanSubtasks(async () => ({ | |
| 82 | + steps: [ | |
| 83 | + { seq: 1, title: '定义游戏状态与棋盘', why: '', depends_on_hint: [] }, | |
| 84 | + { seq: 2, title: '实现移动与碰撞检测', why: '', depends_on_hint: [1] }, | |
| 85 | + ], | |
| 86 | + tokens: 42, | |
| 87 | + })); | |
| 88 | + try { | |
| 89 | + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); | |
| 90 | + } finally { | |
| 91 | + subtaskPlanning.setPlanSubtasks(null); | |
| 92 | + } | |
| 93 | + | |
| 94 | + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json'); | |
| 95 | + expect(fs.existsSync(file)).toBe(true); | |
| 96 | + const content = JSON.parse(fs.readFileSync(file, 'utf-8')); | |
| 97 | + expect(content.task_id).toBe(task.id); | |
| 98 | + expect(content.steps).toHaveLength(2); | |
| 99 | + expect(content.steps[0].title).toBe('定义游戏状态与棋盘'); | |
| 100 | + expect(content.steps[1].depends_on_hint).toEqual([1]); | |
| 101 | + | |
| 102 | + const rows = wdb.findFiles({ companyId, taskId: task.id, kinds: ['subtask_plan'] }); | |
| 103 | + expect(rows).toHaveLength(1); | |
| 104 | + expect(rows[0].rel_path).toContain('subtask-plan.json'); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it('这个 task 下任意 agent_call 节点的提示词都能自动看到 subtask_plan(任务级共享,不受相邻性限制)', async () => { | |
| 108 | + const companyId = 'co-subplan-2'; | |
| 109 | + ensureAgent('impl'); | |
| 110 | + const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' }); | |
| 111 | + const task = wdb.createTask({ goalId: goal.id, title: '实现贪吃蛇', description: 'd' }); | |
| 112 | + | |
| 113 | + // 先起一条"入队时的" run 触发 subtask-planning(模拟 enqueueTaskRun 的时序) | |
| 114 | + const enqueueRun = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); | |
| 115 | + subtaskPlanning.setPlanSubtasks(async () => ({ | |
| 116 | + steps: [{ title: '定义游戏状态' }], | |
| 117 | + tokens: 10, | |
| 118 | + })); | |
| 119 | + try { | |
| 120 | + await subtaskPlanning.triggerSubtaskPlanning({ run: enqueueRun, task }); | |
| 121 | + } finally { | |
| 122 | + subtaskPlanning.setPlanSubtasks(null); | |
| 123 | + } | |
| 124 | + | |
| 125 | + // 真正跑图的是另一条 run(同一个 task,不同 run —— 与真实场景一致:enqueueTaskRun 建的 | |
| 126 | + // 那条 run 才是真正被执行的图,这里只是拆开两步验证互不依赖时序)。 | |
| 127 | + const g = wdb.createWorkGraph({ | |
| 128 | + companyId, | |
| 129 | + nodes: [{ nodeName: 'impl', systemAgentId: 'impl' }], | |
| 130 | + edges: [], | |
| 131 | + }); | |
| 132 | + expect(g.errors || []).toEqual([]); | |
| 133 | + const execRun = createRunRow({ graphKind: 'work', graphId: g.id, companyId, taskId: task.id }); | |
| 134 | + engine.markRunReady(execRun.id); | |
| 135 | + | |
| 136 | + let captured = null; | |
| 137 | + engine.setAgentExecutor(async (ctx) => { | |
| 138 | + captured = ctx.task; | |
| 139 | + return { status: 'success' }; | |
| 140 | + }); | |
| 141 | + try { | |
| 142 | + engine.startRun(execRun.id, { mode: 'manual' }); | |
| 143 | + await engine.advanceOne(execRun.id); | |
| 144 | + } finally { | |
| 145 | + engine.setAgentExecutor(null); | |
| 146 | + } | |
| 147 | + | |
| 148 | + expect(captured).toContain('[上游输入'); | |
| 149 | + expect(captured).toContain('subtask_plan(任务级共享)'); | |
| 150 | + expect(captured).toContain(path.join(runsDir, enqueueRun.root_run_id, 'artifacts', 'subtask-plan.json')); | |
| 151 | + }); | |
| 152 | + | |
| 153 | + it('planSubtasks 产出 0 条步骤时不写文件、不报错(软失败,不影响调用方)', async () => { | |
| 154 | + const companyId = 'co-subplan-3'; | |
| 155 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 156 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 157 | + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); | |
| 158 | + | |
| 159 | + subtaskPlanning.setPlanSubtasks(async () => ({ steps: [], tokens: 5 })); | |
| 160 | + try { | |
| 161 | + await expect(subtaskPlanning.triggerSubtaskPlanning({ run, task })).resolves.toBeUndefined(); | |
| 162 | + } finally { | |
| 163 | + subtaskPlanning.setPlanSubtasks(null); | |
| 164 | + } | |
| 165 | + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json'); | |
| 166 | + expect(fs.existsSync(file)).toBe(false); | |
| 167 | + }); | |
| 168 | + | |
| 169 | + it('planSubtasks 本身抛错时 triggerSubtaskPlanning 吞掉,不向调用方传播(46 号文档:绝不能拖累入队本身)', async () => { | |
| 170 | + const companyId = 'co-subplan-4'; | |
| 171 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 172 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 173 | + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); | |
| 174 | + | |
| 175 | + subtaskPlanning.setPlanSubtasks(async () => { throw new Error('CLI 起不来'); }); | |
| 176 | + try { | |
| 177 | + await expect(subtaskPlanning.triggerSubtaskPlanning({ run, task })).resolves.toBeUndefined(); | |
| 178 | + } finally { | |
| 179 | + subtaskPlanning.setPlanSubtasks(null); | |
| 180 | + } | |
| 181 | + }); | |
| 182 | + | |
| 183 | + it('reasoning_source / experience_refs / research_used 原样落盘(Phase 2 新增字段,向后兼容)', async () => { | |
| 184 | + const companyId = 'co-subplan-5'; | |
| 185 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 186 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 187 | + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); | |
| 188 | + | |
| 189 | + subtaskPlanning.setPlanSubtasks(async () => ({ | |
| 190 | + steps: [{ seq: 1, title: '结合调研后的分解', why: '', depends_on_hint: [] }], | |
| 191 | + tokens: 88, | |
| 192 | + reasoningSource: 'deep_research', | |
| 193 | + experienceRefs: ['impl: 贪吃蛇碰撞检测踩过的坑'], | |
| 194 | + researchUsed: true, | |
| 195 | + })); | |
| 196 | + try { | |
| 197 | + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); | |
| 198 | + } finally { | |
| 199 | + subtaskPlanning.setPlanSubtasks(null); | |
| 200 | + } | |
| 201 | + | |
| 202 | + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json'); | |
| 203 | + const content = JSON.parse(fs.readFileSync(file, 'utf-8')); | |
| 204 | + expect(content.reasoning_source).toBe('deep_research'); | |
| 205 | + expect(content.experience_refs).toEqual(['impl: 贪吃蛇碰撞检测踩过的坑']); | |
| 206 | + expect(content.research_used).toBe(true); | |
| 207 | + }); | |
| 208 | + | |
| 209 | + it('注入的 planSubtasks 没带 Phase 2 字段时兜底为 model_only/空数组/false(向后兼容旧注入桩)', async () => { | |
| 210 | + const companyId = 'co-subplan-6'; | |
| 211 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 212 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 213 | + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); | |
| 214 | + | |
| 215 | + subtaskPlanning.setPlanSubtasks(async () => ({ steps: [{ seq: 1, title: '一步' }], tokens: 1 })); | |
| 216 | + try { | |
| 217 | + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); | |
| 218 | + } finally { | |
| 219 | + subtaskPlanning.setPlanSubtasks(null); | |
| 220 | + } | |
| 221 | + | |
| 222 | + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json'); | |
| 223 | + const content = JSON.parse(fs.readFileSync(file, 'utf-8')); | |
| 224 | + expect(content.reasoning_source).toBe('model_only'); | |
| 225 | + expect(content.experience_refs).toEqual([]); | |
| 226 | + expect(content.research_used).toBe(false); | |
| 227 | + }); | |
| 228 | +}); | |
| 229 | + | |
| 230 | +describe('46 号文档 Phase 2:defaultPlanSubtasks 编排(经验检索 + 按需 deep research)', () => { | |
| 231 | + let wdb; | |
| 232 | + let subtaskPlanning; | |
| 233 | + let deepResearch; | |
| 234 | + const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH; | |
| 235 | + const EXP_AGENT = 'zz-subplan-p2-impl'; | |
| 236 | + const EXP_AGENT_DIR = path.join(ROOT, 'system', 'agents', EXP_AGENT); | |
| 237 | + | |
| 238 | + beforeAll(() => { | |
| 239 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 240 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 241 | + for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) { | |
| 242 | + try { delete require.cache[require.resolve(m)]; } catch {} | |
| 243 | + } | |
| 244 | + wdb = require('../../services/work-graph-db'); | |
| 245 | + subtaskPlanning = require('../../services/subtask-planning'); | |
| 246 | + deepResearch = require('../../services/deep-research'); | |
| 247 | + }); | |
| 248 | + | |
| 249 | + afterAll(() => { | |
| 250 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 251 | + delete process.env.GRAPH_DB_PATH; | |
| 252 | + fs.rmSync(EXP_AGENT_DIR, { recursive: true, force: true }); | |
| 253 | + if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH; | |
| 254 | + else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE; | |
| 255 | + for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) { | |
| 256 | + try { delete require.cache[require.resolve(m)]; } catch {} | |
| 257 | + } | |
| 258 | + }); | |
| 259 | + | |
| 260 | + afterEach(() => { | |
| 261 | + subtaskPlanning.setSubtaskAgentCaller(null); | |
| 262 | + deepResearch.setTriggerDeepResearch(null); | |
| 263 | + }); | |
| 264 | + | |
| 265 | + function hireInto(companyId, agentId) { | |
| 266 | + const db = wdb.getDb(); | |
| 267 | + const now = new Date().toISOString(); | |
| 268 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 269 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 270 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 271 | + .run(agentId, agentId, `system/agents/${agentId}`, now, now); | |
| 272 | + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) | |
| 273 | + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now); | |
| 274 | + } | |
| 275 | + | |
| 276 | + it('经验库命中 + agent 给 high confidence → reasoning_source=memory,只调一轮,不触发调研', async () => { | |
| 277 | + const companyId = 'co-subplan-p2-memory'; | |
| 278 | + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); | |
| 279 | + hireInto(companyId, EXP_AGENT); | |
| 280 | + const expDir = path.join(EXP_AGENT_DIR, 'memory'); | |
| 281 | + fs.mkdirSync(expDir, { recursive: true }); | |
| 282 | + fs.writeFileSync( | |
| 283 | + path.join(expDir, 'experience.md'), | |
| 284 | + '# 跨公司经验(dream 提炼)\n\n### 贪吃蛇碰撞检测踩过的坑\n\n棋盘边界判断要在移动前做,不然会穿墙\n\n> 提炼于 2026-09-01;原料:x\n', | |
| 285 | + 'utf-8' | |
| 286 | + ); | |
| 287 | + | |
| 288 | + let calls = 0; | |
| 289 | + subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => { | |
| 290 | + calls += 1; | |
| 291 | + expect(prompt).toContain('已知相关经验'); | |
| 292 | + return { output: '```json\n{"steps":[{"seq":1,"title":"定义棋盘"}],"confidence":"high"}\n```' }; | |
| 293 | + }); | |
| 294 | + | |
| 295 | + const task = { id: 'T-p2-memory', title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' }; | |
| 296 | + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); | |
| 297 | + | |
| 298 | + expect(calls).toBe(1); | |
| 299 | + expect(result.reasoningSource).toBe('memory'); | |
| 300 | + expect(result.researchUsed).toBe(false); | |
| 301 | + expect(result.experienceRefs.length).toBeGreaterThan(0); | |
| 302 | + expect(result.steps[0].title).toBe('定义棋盘'); | |
| 303 | + }); | |
| 304 | + | |
| 305 | + it('经验库无命中 + deep research 关闭 → reasoning_source=model_only,只调一轮', async () => { | |
| 306 | + delete process.env.ENABLE_DEEP_RESEARCH; | |
| 307 | + const companyId = 'co-subplan-p2-modelonly'; | |
| 308 | + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); | |
| 309 | + | |
| 310 | + let calls = 0; | |
| 311 | + subtaskPlanning.setSubtaskAgentCaller(async () => { | |
| 312 | + calls += 1; | |
| 313 | + return { output: '```json\n{"steps":[{"seq":1,"title":"随便一步"}],"confidence":"high"}\n```' }; | |
| 314 | + }); | |
| 315 | + | |
| 316 | + const task = { id: 'T-p2-modelonly', title: '发一封周报', description: '把周报发给团队' }; | |
| 317 | + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); | |
| 318 | + | |
| 319 | + expect(calls).toBe(1); | |
| 320 | + expect(result.reasoningSource).toBe('model_only'); | |
| 321 | + expect(result.researchUsed).toBe(false); | |
| 322 | + expect(result.experienceRefs).toEqual([]); | |
| 323 | + }); | |
| 324 | + | |
| 325 | + it('经验库无命中 + deep research 开启且返回结果 → reasoning_source=deep_research,两轮调用', async () => { | |
| 326 | + process.env.ENABLE_DEEP_RESEARCH = '1'; | |
| 327 | + const companyId = 'co-subplan-p2-research'; | |
| 328 | + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); | |
| 329 | + | |
| 330 | + deepResearch.setTriggerDeepResearch(async () => ({ | |
| 331 | + findings: '业界通常先定义棋盘再做碰撞检测', sources: ['https://example.com'], tokens: 50, | |
| 332 | + })); | |
| 333 | + | |
| 334 | + let calls = 0; | |
| 335 | + subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => { | |
| 336 | + calls += 1; | |
| 337 | + if (calls === 2) expect(prompt).toContain('调研参考'); | |
| 338 | + return { | |
| 339 | + output: calls === 1 | |
| 340 | + ? '```json\n{"steps":[{"seq":1,"title":"第一轮草稿"}],"confidence":"low"}\n```' | |
| 341 | + : '```json\n{"steps":[{"seq":1,"title":"结合调研后的分解"}],"confidence":"high"}\n```', | |
| 342 | + }; | |
| 343 | + }); | |
| 344 | + | |
| 345 | + const task = { id: 'T-p2-research', title: '实现贪吃蛇', description: '一个全新玩法,系统里从没做过' }; | |
| 346 | + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); | |
| 347 | + | |
| 348 | + expect(calls).toBe(2); | |
| 349 | + expect(result.reasoningSource).toBe('deep_research'); | |
| 350 | + expect(result.researchUsed).toBe(true); | |
| 351 | + expect(result.steps[0].title).toBe('结合调研后的分解'); | |
| 352 | + expect(result.tokens).toBeGreaterThanOrEqual(50); | |
| 353 | + }); | |
| 354 | + | |
| 355 | + it('deep research 开启但没查到有用结果(返回 null)→ 仍是 model_only,不算失败', async () => { | |
| 356 | + process.env.ENABLE_DEEP_RESEARCH = '1'; | |
| 357 | + const companyId = 'co-subplan-p2-research-empty'; | |
| 358 | + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); | |
| 359 | + | |
| 360 | + deepResearch.setTriggerDeepResearch(async () => null); | |
| 361 | + | |
| 362 | + let calls = 0; | |
| 363 | + subtaskPlanning.setSubtaskAgentCaller(async () => { | |
| 364 | + calls += 1; | |
| 365 | + return { output: '```json\n{"steps":[{"seq":1,"title":"没有调研也照样给一版"}],"confidence":"low"}\n```' }; | |
| 366 | + }); | |
| 367 | + | |
| 368 | + const task = { id: 'T-p2-research-empty', title: '一个冷门任务', description: '系统里第一次遇到' }; | |
| 369 | + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); | |
| 370 | + | |
| 371 | + expect(calls).toBe(1); // 调研没结果,不会有第二轮 | |
| 372 | + expect(result.reasoningSource).toBe('model_only'); | |
| 373 | + expect(result.researchUsed).toBe(false); | |
| 374 | + expect(result.steps[0].title).toBe('没有调研也照样给一版'); | |
| 375 | + }); | |
| 376 | +}); | ... | ... |
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 | + extractConfidence, | |
| 11 | + normalizeSteps, | |
| 12 | + buildSubtaskPlanningPrompt, | |
| 13 | + MAX_STEPS, | |
| 14 | +} from '../../services/subtask-planning.js'; | |
| 15 | + | |
| 16 | +describe('subtask-planning:extractPlannedSteps', () => { | |
| 17 | + it('从围栏 json 代码块里取 {steps:[...]}', () => { | |
| 18 | + const output = [ | |
| 19 | + '我是这么想的……', | |
| 20 | + '```json', | |
| 21 | + '{"steps":[{"seq":1,"title":"定义游戏状态","why":"后面都要用","depends_on_hint":[]}]}', | |
| 22 | + '```', | |
| 23 | + ].join('\n'); | |
| 24 | + expect(extractPlannedSteps(output)).toEqual([ | |
| 25 | + { seq: 1, title: '定义游戏状态', why: '后面都要用', depends_on_hint: [] }, | |
| 26 | + ]); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('容错:顶层直接是裸数组也认', () => { | |
| 30 | + const output = '```json\n[{"title":"a"},{"title":"b"}]\n```'; | |
| 31 | + expect(extractPlannedSteps(output)).toEqual([{ title: 'a' }, { title: 'b' }]); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it('多个代码块时取最后一个(agent.md 硬约束 6:JSON 块必须是回复最后一段)', () => { | |
| 35 | + const output = [ | |
| 36 | + '```json', | |
| 37 | + '{"steps":[{"seq":1,"title":"上一轮草稿,应被忽略"}]}', | |
| 38 | + '```', | |
| 39 | + '重新想了一下:', | |
| 40 | + '```json', | |
| 41 | + '{"steps":[{"seq":1,"title":"最终版"}]}', | |
| 42 | + '```', | |
| 43 | + ].join('\n'); | |
| 44 | + expect(extractPlannedSteps(output)).toEqual([{ seq: 1, title: '最终版' }]); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it('回复里没有可解析 JSON 时返回空数组(不抛错)', () => { | |
| 48 | + expect(extractPlannedSteps('我不知道该怎么拆')).toEqual([]); | |
| 49 | + expect(extractPlannedSteps('')).toEqual([]); | |
| 50 | + expect(extractPlannedSteps(null)).toEqual([]); | |
| 51 | + }); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +describe('subtask-planning:extractConfidence(Phase 2 新增字段)', () => { | |
| 55 | + it('取到 "high"/"low" 就原样返回', () => { | |
| 56 | + expect(extractConfidence('```json\n{"steps":[],"confidence":"low"}\n```')).toBe('low'); | |
| 57 | + expect(extractConfidence('```json\n{"steps":[],"confidence":"high"}\n```')).toBe('high'); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it('取不到 / 值不合法 / 字段缺失时返回 null,不强行猜', () => { | |
| 61 | + expect(extractConfidence('```json\n{"steps":[]}\n```')).toBeNull(); | |
| 62 | + expect(extractConfidence('```json\n{"steps":[],"confidence":"maybe"}\n```')).toBeNull(); | |
| 63 | + expect(extractConfidence('我不知道')).toBeNull(); | |
| 64 | + expect(extractConfidence('')).toBeNull(); | |
| 65 | + expect(extractConfidence(null)).toBeNull(); | |
| 66 | + }); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +describe('subtask-planning:normalizeSteps(宽松归一化,单条不合法就丢那一条)', () => { | |
| 70 | + it('正常路径:重新编号为 1..N,depends_on_hint 保留合法引用', () => { | |
| 71 | + const raw = [ | |
| 72 | + { title: '第一步', why: 'w1', depends_on_hint: [] }, | |
| 73 | + { title: '第二步', why: 'w2', depends_on_hint: [1] }, | |
| 74 | + ]; | |
| 75 | + expect(normalizeSteps(raw)).toEqual([ | |
| 76 | + { seq: 1, title: '第一步', why: 'w1', depends_on_hint: [] }, | |
| 77 | + { seq: 2, title: '第二步', why: 'w2', depends_on_hint: [1] }, | |
| 78 | + ]); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it('缺 title 的条目被丢弃,其余重新编号、不留空洞', () => { | |
| 82 | + const raw = [ | |
| 83 | + { title: '第一步' }, | |
| 84 | + { title: '' }, // 空 title,丢 | |
| 85 | + { why: '没有 title 字段' }, // 丢 | |
| 86 | + { title: '第二步' }, | |
| 87 | + ]; | |
| 88 | + const out = normalizeSteps(raw); | |
| 89 | + expect(out.map((s) => s.seq)).toEqual([1, 2]); | |
| 90 | + expect(out.map((s) => s.title)).toEqual(['第一步', '第二步']); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it('depends_on_hint 指向被丢弃条目 / 越界 / 自引用:过滤掉,不报错', () => { | |
| 94 | + const raw = [ | |
| 95 | + { title: '' }, // 原 seq 1,会被丢 | |
| 96 | + { title: '第一步', depends_on_hint: [1, 2, 99] }, // 原 seq 2 → 新 seq 1;1 已被丢,2 是自己,99 越界 | |
| 97 | + ]; | |
| 98 | + const out = normalizeSteps(raw); | |
| 99 | + expect(out).toEqual([{ seq: 1, title: '第一步', why: '', depends_on_hint: [] }]); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + it('非对象 / 数组类型的条目直接跳过', () => { | |
| 103 | + const raw = [null, 'not an object', ['x'], { title: '合法的一条' }]; | |
| 104 | + expect(normalizeSteps(raw)).toEqual([ | |
| 105 | + { seq: 1, title: '合法的一条', why: '', depends_on_hint: [] }, | |
| 106 | + ]); | |
| 107 | + }); | |
| 108 | + | |
| 109 | + it(`条数超过 MAX_STEPS(${MAX_STEPS}) 时截断,不报错`, () => { | |
| 110 | + const raw = Array.from({ length: MAX_STEPS + 5 }, (_, i) => ({ title: `步骤${i + 1}` })); | |
| 111 | + const out = normalizeSteps(raw); | |
| 112 | + expect(out).toHaveLength(MAX_STEPS); | |
| 113 | + expect(out[0].title).toBe('步骤1'); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it('空输入 / 非数组输入返回空数组', () => { | |
| 117 | + expect(normalizeSteps([])).toEqual([]); | |
| 118 | + expect(normalizeSteps(null)).toEqual([]); | |
| 119 | + expect(normalizeSteps(undefined)).toEqual([]); | |
| 120 | + }); | |
| 121 | +}); | |
| 122 | + | |
| 123 | +describe('subtask-planning:buildSubtaskPlanningPrompt', () => { | |
| 124 | + it('提示词里包含 task 描述、标题、交回格式,不包含创建 task/run 的诱导', () => { | |
| 125 | + const prompt = buildSubtaskPlanningPrompt({ | |
| 126 | + companyId: 'co-x', | |
| 127 | + task: { id: 'T-abc', title: '实现贪吃蛇', description: '一个可以在浏览器玩的贪吃蛇游戏' }, | |
| 128 | + }); | |
| 129 | + expect(prompt).toContain('一个可以在浏览器玩的贪吃蛇游戏'); | |
| 130 | + expect(prompt).toContain('实现贪吃蛇'); | |
| 131 | + expect(prompt).toContain('"steps"'); | |
| 132 | + expect(prompt).toContain('depends_on_hint'); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it('task 描述缺失时给出明确占位,不假装有内容', () => { | |
| 136 | + const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc' } }); | |
| 137 | + expect(prompt).toContain('调用方没给任务描述'); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + it('不传 extra 时不出现"已知相关经验"/"调研参考"小节标题(Phase 1 行为不变)', () => { | |
| 141 | + const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc', title: 't' } }); | |
| 142 | + expect(prompt).not.toContain('## 已知相关经验'); | |
| 143 | + expect(prompt).not.toContain('## 调研参考'); | |
| 144 | + expect(prompt).toContain('confidence'); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + it('传 experience 命中时拼进"已知相关经验"小节,带来源 agentId', () => { | |
| 148 | + const prompt = buildSubtaskPlanningPrompt( | |
| 149 | + { companyId: 'co-x', task: { id: 'T-abc', title: 't' } }, | |
| 150 | + { experience: [{ agentId: 'impl', title: '踩过的坑', body: '别忘了边界检测' }] } | |
| 151 | + ); | |
| 152 | + expect(prompt).toContain('## 已知相关经验'); | |
| 153 | + expect(prompt).toContain('[来自 impl]'); | |
| 154 | + expect(prompt).toContain('踩过的坑'); | |
| 155 | + expect(prompt).toContain('别忘了边界检测'); | |
| 156 | + expect(prompt).not.toContain('## 调研参考'); | |
| 157 | + }); | |
| 158 | + | |
| 159 | + it('传 research 时拼进"调研参考"小节,带 findings 和 sources', () => { | |
| 160 | + const prompt = buildSubtaskPlanningPrompt( | |
| 161 | + { companyId: 'co-x', task: { id: 'T-abc', title: 't' } }, | |
| 162 | + { research: { findings: '业界通常先定义棋盘', sources: ['https://example.com'] } } | |
| 163 | + ); | |
| 164 | + expect(prompt).toContain('## 调研参考'); | |
| 165 | + expect(prompt).toContain('业界通常先定义棋盘'); | |
| 166 | + expect(prompt).toContain('https://example.com'); | |
| 167 | + }); | |
| 168 | +}); | ... | ... |
server/tests/unit/task-decomposition-dispatch.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * 46 号文档 Phase 3(v2,2026-09-02 改版):CEO 派发 decomposed_work 子任务。 | |
| 3 | + * | |
| 4 | + * v1 做的是"认领"(pull,CAS 抢占),真实回归后拍板改成"CEO 直接派发"(push,不存在 | |
| 5 | + * 多方竞争,CAS 是在给不存在的问题找解)。这个文件替掉已删除的 task-pool-claim.test.js, | |
| 6 | + * 覆盖新设计的三层: | |
| 7 | + * 1. `materializeDecomposedSubtasks`(物化,depends_on 回填,幂等) | |
| 8 | + * 2. `createDecomposedWorkGraph`(现造的轻量 impl→validate 图,形状对不对) | |
| 9 | + * 3. 路由层 `enqueueTaskRun`——decomposed_work 走轻量 work graph,普通 task 走公司 org | |
| 10 | + * graph 不受影响(回归),以及 `triggerSubtaskPlanning` 的递归防护(子任务不会被再拆一层) | |
| 11 | + */ | |
| 12 | +import { | |
| 13 | + describe, it, expect, beforeAll, afterAll, | |
| 14 | +} from 'vitest'; | |
| 15 | +import fs from 'fs'; | |
| 16 | +import os from 'os'; | |
| 17 | +import path from 'path'; | |
| 18 | + | |
| 19 | +const TEST_DB_PATH = '/tmp/test-task-decomp-dispatch.sqlite'; | |
| 20 | +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | |
| 21 | +const MODULES = ['../../services/graph-db', '../../services/work-graph-db']; | |
| 22 | + | |
| 23 | +// 48 号文档:decomposed_work 子任务创建时必须带 acceptanceContract。这是一份最小合法契约 | |
| 24 | +// (benchmark 段**本身**,不是三段式契约的 wrapper——真实起 agent 验证过这就是它交回的形状, | |
| 25 | +// 见 work-graph-db.createTask 的注释),权重之和=1,测材料化/图选择等与契约本身无关的机制时 | |
| 26 | +// 复用它,不重复拼。 | |
| 27 | +const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }; | |
| 28 | + | |
| 29 | +function ensureAgent(wdb, id) { | |
| 30 | + const db = wdb.getDb(); | |
| 31 | + const now = new Date().toISOString(); | |
| 32 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 33 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 34 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 35 | + .run(id, id, `system/agents/${id}`, now, now); | |
| 36 | +} | |
| 37 | + | |
| 38 | +describe('46 号文档 Phase 3(v2):DB 层', () => { | |
| 39 | + let wdb; | |
| 40 | + | |
| 41 | + beforeAll(() => { | |
| 42 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 43 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 44 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 45 | + wdb = require('../../services/work-graph-db'); | |
| 46 | + ensureAgent(wdb, 'org-coder'); | |
| 47 | + ensureAgent(wdb, 'org-validator'); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + afterAll(() => { | |
| 51 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 52 | + delete process.env.GRAPH_DB_PATH; | |
| 53 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 54 | + }); | |
| 55 | + | |
| 56 | + // ── createTask 校验(简化后:没有专属初始 status 了)───────────────── | |
| 57 | + | |
| 58 | + it('subtaskKind 非法值直接拒', () => { | |
| 59 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 60 | + expect(() => wdb.createTask({ | |
| 61 | + goalId: goal.id, title: 't', description: 'd', subtaskKind: 'not-a-real-kind', | |
| 62 | + })).toThrow(/subtaskKind 非法/); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it('subtaskKind 没有 parentTaskId 时直接拒', () => { | |
| 66 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 67 | + expect(() => wdb.createTask({ | |
| 68 | + goalId: goal.id, title: 't', description: 'd', subtaskKind: 'decomposed_work', | |
| 69 | + })).toThrow(/只对子任务.*有意义/); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + it('正常创建 decomposed_work 子任务:就是一条普通 draft task,只多了 subtask_kind 标签', () => { | |
| 73 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 74 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 75 | + const child = wdb.createTask({ | |
| 76 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 77 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 78 | + }); | |
| 79 | + expect(child.subtask_kind).toBe('decomposed_work'); | |
| 80 | + expect(child.status).toBe('draft'); // 没有专属状态了 | |
| 81 | + expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑 | |
| 82 | + }); | |
| 83 | + | |
| 84 | + // ── 48 号文档:验收契约创建时 1:1 强绑定 ────────────────────────────── | |
| 85 | + | |
| 86 | + it('decomposed_work 不带 acceptanceContract 直接拒——创建时硬约束(48 号文档验收标准 1)', () => { | |
| 87 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 88 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 89 | + expect(() => wdb.createTask({ | |
| 90 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 91 | + })).toThrow(/必须带 acceptanceContract/); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it('非 decomposed_work 的普通 task 传 acceptanceContract 直接拒——本轮范围只收这条链路', () => { | |
| 95 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 96 | + expect(() => wdb.createTask({ | |
| 97 | + goalId: goal.id, title: 't', description: 'd', acceptanceContract: MIN_ACCEPTANCE, | |
| 98 | + })).toThrow(/只对 decomposed_work 子任务有意义/); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it('acceptanceContract 形状不合法(权重之和不为 1)被 goal-contract.validateContract 挡下', () => { | |
| 102 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 103 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 104 | + expect(() => wdb.createTask({ | |
| 105 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 106 | + acceptanceContract: { dimensions: [{ name: 'a', weight: 0.5, pass_threshold: 80 }] }, // 和不为 1 | |
| 107 | + })).toThrow(/acceptanceContract 不合法/); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + it('acceptanceContract 合法时原样落库,读回后 JSON.parse 与传入等价', () => { | |
| 111 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 112 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 113 | + const child = wdb.createTask({ | |
| 114 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 115 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 116 | + }); | |
| 117 | + expect(JSON.parse(child.acceptance_contract)).toEqual(MIN_ACCEPTANCE); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + it('acceptance_contract 是冻结字段:queued_at 非空后 patch 会被 FrozenFieldError 拒', () => { | |
| 121 | + const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); | |
| 122 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 123 | + const child = wdb.createTask({ | |
| 124 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 125 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 126 | + }); | |
| 127 | + wdb.updateTask(child.id, { queuedAt: new Date().toISOString() }); | |
| 128 | + expect(() => wdb.updateTask(child.id, { acceptanceContract: JSON.stringify(MIN_ACCEPTANCE) })) | |
| 129 | + .toThrow(/acceptanceContract/); | |
| 130 | + }); | |
| 131 | + | |
| 132 | + // ── materializeDecomposedSubtasks ───────────────────────────────── | |
| 133 | + | |
| 134 | + describe('materializeDecomposedSubtasks', () => { | |
| 135 | + function setupTask(companyId) { | |
| 136 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 137 | + return wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 138 | + } | |
| 139 | + | |
| 140 | + it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => { | |
| 141 | + const task = setupTask('co-p3-2'); | |
| 142 | + const steps = [ | |
| 143 | + { seq: 1, title: '第一步', depends_on_hint: [3], acceptance: MIN_ACCEPTANCE }, // 前向引用:依赖还没建出来的第三步 | |
| 144 | + { seq: 2, title: '第二步', depends_on_hint: [1], acceptance: MIN_ACCEPTANCE }, | |
| 145 | + { seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, | |
| 146 | + ]; | |
| 147 | + const ids = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 148 | + expect(ids).toHaveLength(3); | |
| 149 | + | |
| 150 | + const rows = ids.map((id) => wdb.getTask(id)); | |
| 151 | + expect(rows.every((r) => r.subtask_kind === 'decomposed_work')).toBe(true); | |
| 152 | + expect(rows.every((r) => r.status === 'draft')).toBe(true); | |
| 153 | + expect(rows.every((r) => r.execution_mode === 'auto')).toBe(true); | |
| 154 | + expect(rows[0].title).toBe('第一步'); | |
| 155 | + expect(JSON.parse(rows[0].depends_on)).toEqual([ids[2]]); // 前向引用正确解析成第三步的真实 id | |
| 156 | + expect(JSON.parse(rows[1].depends_on)).toEqual([ids[0]]); | |
| 157 | + expect(JSON.parse(rows[2].depends_on)).toEqual([]); | |
| 158 | + }); | |
| 159 | + | |
| 160 | + it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => { | |
| 161 | + const task = setupTask('co-p3-3'); | |
| 162 | + const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99], acceptance: MIN_ACCEPTANCE }]; | |
| 163 | + const ids = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 164 | + expect(ids).toHaveLength(1); | |
| 165 | + expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]); | |
| 166 | + }); | |
| 167 | + | |
| 168 | + it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => { | |
| 169 | + const task = setupTask('co-p3-4'); | |
| 170 | + const steps = [{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }]; | |
| 171 | + const first = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 172 | + expect(first).toHaveLength(1); | |
| 173 | + const second = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 174 | + expect(second).toEqual([]); | |
| 175 | + | |
| 176 | + const rows = wdb.getDb() | |
| 177 | + .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) | |
| 178 | + .get(task.id); | |
| 179 | + expect(rows.n).toBe(1); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + it('空 steps / 无 task 时返回 [],不报错', () => { | |
| 183 | + const task = setupTask('co-p3-5'); | |
| 184 | + expect(wdb.materializeDecomposedSubtasks({ task, steps: [] })).toEqual([]); | |
| 185 | + expect(wdb.materializeDecomposedSubtasks({ task: null, steps: [{ seq: 1, title: 'x' }] })).toEqual([]); | |
| 186 | + }); | |
| 187 | + | |
| 188 | + it('与 43 号文档的 acceptance_item 共存互不干扰:各自幂等判据只看自己的 subtask_kind', () => { | |
| 189 | + const task = setupTask('co-p3-6'); | |
| 190 | + wdb.createTask({ | |
| 191 | + goalId: task.goal_id, title: 'a1', description: 'd', parentTaskId: task.id, | |
| 192 | + subtaskKind: 'acceptance_item', | |
| 193 | + }); | |
| 194 | + const ids = wdb.materializeDecomposedSubtasks({ | |
| 195 | + task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }], | |
| 196 | + }); | |
| 197 | + expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 | |
| 198 | + }); | |
| 199 | + | |
| 200 | + // ── 48 号文档:acceptance 缺失/不合法时单步跳过,不作废整批 ────────────── | |
| 201 | + | |
| 202 | + it('单步 acceptance 缺失:那一步跳过,同批其它合法步骤照常物化(宽松解析)', () => { | |
| 203 | + const task = setupTask('co-p3-9'); | |
| 204 | + const steps = [ | |
| 205 | + { seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }, // 缺 acceptance,会被 createTask 拒 | |
| 206 | + { seq: 2, title: '带 acceptance 的一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, | |
| 207 | + ]; | |
| 208 | + const ids = wdb.materializeDecomposedSubtasks({ task, steps }); | |
| 209 | + expect(ids).toHaveLength(1); | |
| 210 | + expect(wdb.getTask(ids[0]).title).toBe('带 acceptance 的一步'); | |
| 211 | + }); | |
| 212 | + | |
| 213 | + it('全部 step 都没有 acceptance:整批跳过,返回 [](不是「无 steps」那条早退路径)', () => { | |
| 214 | + const task = setupTask('co-p3-10'); | |
| 215 | + const ids = wdb.materializeDecomposedSubtasks({ | |
| 216 | + task, steps: [{ seq: 1, title: '唯一一步,没有 acceptance', depends_on_hint: [] }], | |
| 217 | + }); | |
| 218 | + expect(ids).toEqual([]); | |
| 219 | + const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; | |
| 220 | + expect(n).toBe(0); | |
| 221 | + }); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + // ── createDecomposedWorkGraph ────────────────────────────────────── | |
| 225 | + | |
| 226 | + describe('createDecomposedWorkGraph', () => { | |
| 227 | + it('建出一张两节点的轻量图:impl(org-coder) → validate(org-validator),都是 agent_call', () => { | |
| 228 | + const goal = wdb.createGoal({ companyId: 'co-p3-7', title: 'g', description: 'd' }); | |
| 229 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 230 | + const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-7', task }); | |
| 231 | + expect(graphId).toBeTruthy(); | |
| 232 | + | |
| 233 | + const nodes = wdb.getDb() | |
| 234 | + .prepare(`SELECT node_name, system_agent_id, node_kind FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`) | |
| 235 | + .all(graphId); | |
| 236 | + expect(nodes).toEqual([ | |
| 237 | + { node_name: 'impl', system_agent_id: 'org-coder', node_kind: 'agent_call' }, | |
| 238 | + { node_name: 'validate', system_agent_id: 'org-validator', node_kind: 'agent_call' }, | |
| 239 | + ]); | |
| 240 | + | |
| 241 | + const wg = wdb.getDb().prepare(`SELECT task_id, goal_id FROM work_graphs WHERE id = ?`).get(graphId); | |
| 242 | + expect(wg.task_id).toBe(task.id); | |
| 243 | + expect(wg.goal_id).toBe(goal.id); | |
| 244 | + }); | |
| 245 | + | |
| 246 | + it('这张图能被 createRun 直接拿去起一条 work run(机制打通,不真的执行)', () => { | |
| 247 | + const goal = wdb.createGoal({ companyId: 'co-p3-8', title: 'g', description: 'd' }); | |
| 248 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 249 | + const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-8', task }); | |
| 250 | + const run = wdb.createRun({ | |
| 251 | + graphKind: 'work', graphId, companyId: 'co-p3-8', goalId: goal.id, taskId: task.id, | |
| 252 | + }); | |
| 253 | + expect(run.ok).not.toBe(false); | |
| 254 | + expect(wdb.getRun(run.id).graph_kind).toBe('work'); | |
| 255 | + expect(wdb.getRun(run.id).graph_id).toBe(graphId); | |
| 256 | + }); | |
| 257 | + }); | |
| 258 | +}); | |
| 259 | + | |
| 260 | +describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', () => { | |
| 261 | + const CID = 'route-p3v2-co'; | |
| 262 | + let wdb; | |
| 263 | + let org; | |
| 264 | + let server; | |
| 265 | + let base; | |
| 266 | + let origCompanyGet; | |
| 267 | + | |
| 268 | + async function call(method, url, body) { | |
| 269 | + const r = await fetch(base + url, { | |
| 270 | + method, | |
| 271 | + headers: { 'Content-Type': 'application/json' }, | |
| 272 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 273 | + }); | |
| 274 | + return [r.status, await r.json()]; | |
| 275 | + } | |
| 276 | + | |
| 277 | + beforeAll(async () => { | |
| 278 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 279 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 280 | + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3v2-dispatch-routes-')); | |
| 281 | + const allModules = [ | |
| 282 | + ...MODULES, '../../services/graph-service', '../../services/org-graph-service', | |
| 283 | + '../../services/subtask-planning', '../../routes/company', | |
| 284 | + ]; | |
| 285 | + for (const m of allModules) { | |
| 286 | + try { delete require.cache[require.resolve(m)]; } catch {} | |
| 287 | + } | |
| 288 | + wdb = require('../../services/work-graph-db'); | |
| 289 | + org = require('../../services/org-graph-service'); | |
| 290 | + ensureAgent(wdb, 'org-planner'); | |
| 291 | + ensureAgent(wdb, 'org-coder'); | |
| 292 | + ensureAgent(wdb, 'org-validator'); | |
| 293 | + | |
| 294 | + const companyService = require('../../services/company-service'); | |
| 295 | + origCompanyGet = companyService.CompanyService.prototype.get; | |
| 296 | + companyService.CompanyService.prototype.get = (companyId) => | |
| 297 | + (companyId === CID ? { id: CID, name: CID, status: 'running' } : null); | |
| 298 | + | |
| 299 | + // 起 /enqueue 前置门禁要求公司有 org graph——最小起一张(org-planner→org-coder→org-validator) | |
| 300 | + expect(org.writeGraph(CID, { | |
| 301 | + nodes: ['org-planner', 'org-coder', 'org-validator'], | |
| 302 | + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }, | |
| 303 | + { from: 'org-coder', to: 'org-validator', condition: 'success' }], | |
| 304 | + }).ok).toBe(true); | |
| 305 | + | |
| 306 | + // subtask-planning 的真实调用不走这个测试(VITEST 环境下 planSubtasks 自动短路,见该模块), | |
| 307 | + // 不用额外注入桩。 | |
| 308 | + | |
| 309 | + const express = require('express'); | |
| 310 | + const app = express(); | |
| 311 | + app.use(express.json()); | |
| 312 | + app.use('/api/company', require('../../routes/company')); | |
| 313 | + await new Promise((resolve) => { server = app.listen(0, resolve); }); | |
| 314 | + base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`; | |
| 315 | + }); | |
| 316 | + | |
| 317 | + afterAll(() => { | |
| 318 | + if (server) server.close(); | |
| 319 | + const companyService = require('../../services/company-service'); | |
| 320 | + if (origCompanyGet) companyService.CompanyService.prototype.get = origCompanyGet; | |
| 321 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | |
| 322 | + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch {} | |
| 323 | + delete process.env.GRAPH_DB_PATH; | |
| 324 | + delete process.env.GRAPH_RUNS_DIR; | |
| 325 | + const allModules = [ | |
| 326 | + ...MODULES, '../../services/graph-service', '../../services/org-graph-service', | |
| 327 | + '../../services/subtask-planning', '../../routes/company', | |
| 328 | + ]; | |
| 329 | + for (const m of allModules) { | |
| 330 | + try { delete require.cache[require.resolve(m)]; } catch {} | |
| 331 | + } | |
| 332 | + }); | |
| 333 | + | |
| 334 | + it('普通顶层 task:enqueue 后走公司 org graph(graph_kind=org, graph_id=companyId)——回归,行为不变', async () => { | |
| 335 | + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 336 | + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); | |
| 337 | + | |
| 338 | + const [status, body] = await call('POST', `/tasks/${task.id}/enqueue`); | |
| 339 | + expect(status).toBe(201); | |
| 340 | + const run = wdb.getRun(body.runId); | |
| 341 | + expect(run.graph_kind).toBe('org'); | |
| 342 | + expect(run.graph_id).toBe(CID); | |
| 343 | + }); | |
| 344 | + | |
| 345 | + it('decomposed_work 子任务:enqueue 后走轻量 work graph(graph_kind=work,graph_id 不是 companyId)', async () => { | |
| 346 | + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 347 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 348 | + const child = wdb.createTask({ | |
| 349 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 350 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 351 | + }); | |
| 352 | + | |
| 353 | + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); | |
| 354 | + expect(status).toBe(201); | |
| 355 | + const run = wdb.getRun(body.runId); | |
| 356 | + expect(run.graph_kind).toBe('work'); | |
| 357 | + expect(run.graph_id).not.toBe(CID); | |
| 358 | + | |
| 359 | + const nodes = wdb.getDb() | |
| 360 | + .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`) | |
| 361 | + .all(run.graph_id); | |
| 362 | + expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']); | |
| 363 | + }); | |
| 364 | + | |
| 365 | + // ── 48 号文档:验收契约随 enqueue 落进新 run 的 artifacts/acceptance.json ────── | |
| 366 | + | |
| 367 | + it('decomposed_work 子任务的 acceptanceContract 会被写进新 run 的 artifacts/acceptance.json,goal-contract 能读回并判为合法', async () => { | |
| 368 | + const goalContract = require('../../services/goal-contract'); | |
| 369 | + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 370 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 371 | + const child = wdb.createTask({ | |
| 372 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 373 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 374 | + }); | |
| 375 | + | |
| 376 | + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); | |
| 377 | + expect(status).toBe(201); | |
| 378 | + | |
| 379 | + // org-validator 在这条轻量链路上判卷的唯一依据来源:goal-contract 能读到、能验证通过, | |
| 380 | + // 就是「不会再撞 input_underspecified」这件事的直接证据(46 号文档 Phase 3 v2 真实回归 | |
| 381 | + // 撞见的那次死锁,见 48 号文档「触发场景」)。 | |
| 382 | + const runDir = goalContract.resolveRunDir(CID, body.runId); | |
| 383 | + const loaded = goalContract.readContract(runDir); | |
| 384 | + expect(loaded).not.toBeNull(); | |
| 385 | + expect(loaded.contract.benchmark).toEqual(MIN_ACCEPTANCE); | |
| 386 | + expect(goalContract.validateContract(loaded.contract).ok).toBe(true); | |
| 387 | + | |
| 388 | + const files = wdb.findFiles({ companyId: CID, taskId: child.id, kinds: ['acceptance'] }); | |
| 389 | + expect(files.length).toBeGreaterThan(0); | |
| 390 | + }); | |
| 391 | + | |
| 392 | + it('decomposed_work 子任务没有 acceptanceContract(存量缺口)时 enqueue 仍然成功,只是不写 acceptance.json', async () => { | |
| 393 | + // 走不到这里其实很难——createTask 现在强制要求 decomposed_work 带 acceptanceContract。 | |
| 394 | + // 这条测的是「万一有一条存量行是列上线前物化的(acceptance_contract=NULL)」, | |
| 395 | + // enqueueTaskRun 那段 best-effort 写入不该因为它是 NULL 就抛出、拖累入队本身。 | |
| 396 | + const goalContract = require('../../services/goal-contract'); | |
| 397 | + const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' }); | |
| 398 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); | |
| 399 | + const child = wdb.createTask({ | |
| 400 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work', | |
| 401 | + acceptanceContract: MIN_ACCEPTANCE, | |
| 402 | + }); | |
| 403 | + // 模拟存量缺口:直接改列为 NULL(正常创建路径已经不允许这个状态) | |
| 404 | + wdb.getDb().prepare(`UPDATE tasks SET acceptance_contract = NULL WHERE id = ?`).run(child.id); | |
| 405 | + | |
| 406 | + const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); | |
| 407 | + expect(status).toBe(201); | |
| 408 | + const runDir = goalContract.resolveRunDir(CID, body.runId); | |
| 409 | + expect(goalContract.locateContract(runDir)).toBeNull(); | |
| 410 | + }); | |
| 411 | +}); | ... | ... |
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('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { |
| 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('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { |
| 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 { |
| ... | ... | @@ -130,7 +152,12 @@ describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { |
| 130 | 152 | expect(children[0].acceptance_mode).toBe('holistic'); |
| 131 | 153 | expect(children[0].description).toContain('手动操作'); |
| 132 | 154 | expect(children[0].description).toContain('蛇按方向键移动'); |
| 155 | + // 46 号文档 Phase 3:必须打上 subtask_kind='acceptance_item'—— | |
| 156 | + // 少了它,settleNode 的 SUBTASKS_INCOMPLETE 闸门收窄之后会把这些行当"看不见", | |
| 157 | + // 验收判定会失效(永远不会因为子任务未完成而拦下 success) | |
| 158 | + expect(children[0].subtask_kind).toBe('acceptance_item'); | |
| 133 | 159 | expect(children[1].title).toBe('吃到食物加分'); |
| 160 | + expect(children[1].subtask_kind).toBe('acceptance_item'); | |
| 134 | 161 | }); |
| 135 | 162 | |
| 136 | 163 | it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => { |
| ... | ... | @@ -149,4 +176,26 @@ describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => { |
| 149 | 176 | `).get(run.id); |
| 150 | 177 | expect(plannerNode.status).toBe('success'); // 找不到 acceptance.md 不该把这一步打成 fail |
| 151 | 178 | }); |
| 179 | + | |
| 180 | + it('47 号文档:acceptance.md 不存在但 goal-benchmark.json 存在(共享 artifacts 区)→ 按维度物化子任务', async () => { | |
| 181 | + const { task } = await setupAndRunPlanner('co-materialize-4', { writeFile: false, writeGoalBenchmark: true }); | |
| 182 | + const children = wdb.getDb() | |
| 183 | + .prepare(`SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at`) | |
| 184 | + .all(task.id); | |
| 185 | + expect(children).toHaveLength(2); | |
| 186 | + expect(children[0].title).toBe('①方向键控制'); // 优先取 traceability 的原始需求条目,不是维度英文名 | |
| 187 | + expect(children[0].acceptance_mode).toBe('holistic'); | |
| 188 | + expect(children[0].description).toContain('模拟按键序列断言 state.direction'); | |
| 189 | + expect(children[0].description).toContain('pass_threshold=80'); | |
| 190 | + expect(children[1].title).toBe('②吃到食物加分'); | |
| 191 | + }); | |
| 192 | + | |
| 193 | + it('acceptance.md 优先于 goal-benchmark.json:两者都存在时不走兜底', async () => { | |
| 194 | + const { task } = await setupAndRunPlanner('co-materialize-5', { writeFile: true, writeGoalBenchmark: true }); | |
| 195 | + const children = wdb.getDb() | |
| 196 | + .prepare(`SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at`) | |
| 197 | + .all(task.id); | |
| 198 | + expect(children).toHaveLength(2); | |
| 199 | + expect(children[0].title).toBe('方向键控制移动'); // 来自 ACCEPTANCE_MD,不是 GOAL_BENCHMARK_JSON | |
| 200 | + }); | |
| 152 | 201 | }); | ... | ... |
server/tests/unit/task-decomposition-validator-gate.test.js
| ... | ... | @@ -67,7 +67,9 @@ describe('43 号文档 Phase 3', () => { |
| 67 | 67 | it('settleSubtaskResult:正常上报 + 终态幂等(第二次不同结果上报不生效)', () => { |
| 68 | 68 | const goal = wdb.createGoal({ companyId: 'co-gate-2', title: 'g', description: 'd' }); |
| 69 | 69 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); |
| 70 | - const child = wdb.createTask({ goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id }); | |
| 70 | + const child = wdb.createTask({ | |
| 71 | + goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', | |
| 72 | + }); | |
| 71 | 73 | |
| 72 | 74 | expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1); |
| 73 | 75 | const after1 = wdb.getTask(child.id); |
| ... | ... | @@ -108,8 +110,12 @@ describe('43 号文档 Phase 3', () => { |
| 108 | 110 | const companyId = 'co-gate-3'; |
| 109 | 111 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); |
| 110 | 112 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); |
| 111 | - const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id }); | |
| 112 | - wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id }); | |
| 113 | + const c1 = wdb.createTask({ | |
| 114 | + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', | |
| 115 | + }); | |
| 116 | + wdb.createTask({ | |
| 117 | + goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', | |
| 118 | + }); | |
| 113 | 119 | wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半 |
| 114 | 120 | |
| 115 | 121 | const out = await runValidator(companyId, parent.id); |
| ... | ... | @@ -123,8 +129,12 @@ describe('43 号文档 Phase 3', () => { |
| 123 | 129 | const companyId = 'co-gate-4'; |
| 124 | 130 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); |
| 125 | 131 | const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); |
| 126 | - const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id }); | |
| 127 | - const c2 = wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id }); | |
| 132 | + const c1 = wdb.createTask({ | |
| 133 | + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', | |
| 134 | + }); | |
| 135 | + const c2 = wdb.createTask({ | |
| 136 | + goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', | |
| 137 | + }); | |
| 128 | 138 | wdb.settleSubtaskResult(c1.id, 'completed'); |
| 129 | 139 | wdb.settleSubtaskResult(c2.id, 'completed'); |
| 130 | 140 | |
| ... | ... | @@ -132,6 +142,28 @@ describe('43 号文档 Phase 3', () => { |
| 132 | 142 | expect(out.node.status).toBe('success'); |
| 133 | 143 | }); |
| 134 | 144 | |
| 145 | + it('46 号文档 Phase 3:同一个 task 下还挂着 decomposed_work 子任务(未 completed)时,' | |
| 146 | + + '不影响 acceptance_item 的验收判定——两套子任务完成判定互不干扰', async () => { | |
| 147 | + const companyId = 'co-gate-6'; | |
| 148 | + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | |
| 149 | + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' }); | |
| 150 | + const c1 = wdb.createTask({ | |
| 151 | + goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id, subtaskKind: 'acceptance_item', | |
| 152 | + }); | |
| 153 | + wdb.settleSubtaskResult(c1.id, 'completed'); // 验收条目全部完成 | |
| 154 | + // decomposed_work 子任务还停在 draft,还没被 CEO enqueue、更不会 completed—— | |
| 155 | + // 如果闸门没收窄,这条会被当成"未完成的子任务"误伤,把上面本该通过的验收打成失败 | |
| 156 | + wdb.createTask({ | |
| 157 | + goalId: goal.id, title: 'todo-step', description: 'd', parentTaskId: parent.id, | |
| 158 | + subtaskKind: 'decomposed_work', | |
| 159 | + // 48 号文档:decomposed_work 创建时必须带验收契约(benchmark 段本身,不是 wrapper) | |
| 160 | + acceptanceContract: { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }, | |
| 161 | + }); | |
| 162 | + | |
| 163 | + const out = await runValidator(companyId, parent.id); | |
| 164 | + expect(out.node.status).toBe('success'); | |
| 165 | + }); | |
| 166 | + | |
| 135 | 167 | it('holistic 任务(没有子任务):闸门不生效,success 照常通过', async () => { |
| 136 | 168 | const companyId = 'co-gate-5'; |
| 137 | 169 | const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); | ... | ... |
system/agents/ceo/heartbeat.md
| ... | ... | @@ -7,8 +7,23 @@ |
| 7 | 7 | |
| 8 | 8 | - [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的 |
| 9 | 9 | - [ ] 查 `GET /goals?status=active`,扫一眼有没有明显该调整优先级的 |
| 10 | +- [ ] 查 `GET /tasks?subtaskKind=decomposed_work&status=draft`,看有没有等你派发的子任务 | |
| 11 | + (46 号文档 Phase 3:`subtask-planning` 拆出来的步骤,落库就是普通 draft task, | |
| 12 | + 不会自己跑起来,要你判断"现在放不放") | |
| 10 | 13 | - [ ] 都正常就直接回复"心跳正常",不要为了"用一下工具"而用工具 |
| 11 | 14 | |
| 15 | +## 子任务怎么派(decomposed_work) | |
| 16 | + | |
| 17 | +- 看到的每一条都已经是具体到"这一步做什么"的粒度(`subtask-planning` 已经拆过了), | |
| 18 | + 不需要你再判断"这条该不该做"——**只需要判断节奏**:现在放,还是等前置步骤先跑完再放。 | |
| 19 | +- 想知道前置步骤是谁:任务详情里的 `dependsOn` 数组。不用你自己排队——`depends_on` | |
| 20 | + 没完成的,`enqueue` 了也会被调度器自动拦成 `blocked`,一次性把一条 task 下所有 | |
| 21 | + `draft` 的子任务全部 `enqueue` 是安全的,先后顺序、并发几个都是调度器的活,不是你的。 | |
| 22 | +- 派一条:`POST /tasks/<taskId>/enqueue`(跟你派自己提的草稿任务是同一个端点,同一条权限)。 | |
| 23 | +- 这批子任务会走一张**轻量图**(只有实现+验证两步,不是公司完整的组织架构那套), | |
| 24 | + 成本比一条顶层 task 低很多,但目前还没有正式跑过真实验证——如果发现同一个 task 下的 | |
| 25 | + 子任务频繁失败或者结果质量不行,在回复里如实说,不要自己想办法绕过去。 | |
| 26 | + | |
| 12 | 27 | ## 发现问题时 |
| 13 | 28 | |
| 14 | 29 | - 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节 | ... | ... |
system/agents/ceo/tools.md
| ... | ... | @@ -45,6 +45,14 @@ curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals/ |
| 45 | 45 | -H "x-internal-token: $LEMON_INTERNAL_TOKEN" |
| 46 | 46 | ``` |
| 47 | 47 | |
| 48 | +### 找等你派发的子任务(46 号文档 Phase 3) | |
| 49 | +```bash | |
| 50 | +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks?subtaskKind=decomposed_work&status=draft" \ | |
| 51 | + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" | |
| 52 | +``` | |
| 53 | +每条都带 `parentTaskId`(它是哪条 task 拆出来的)和 `dependsOn`(同批里它依赖哪几条, | |
| 54 | +数组里是别的子任务的 taskId,不是要你手动排序——只是给你看清楚"这条要等谁先完成")。 | |
| 55 | + | |
| 48 | 56 | --- |
| 49 | 57 | |
| 50 | 58 | ## 写:只在真正判断需要时才用 |
| ... | ... | @@ -76,6 +84,10 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId |
| 76 | 84 | 必要的——`enqueue` 之后就会真的排队执行、真的烧预算。判断依据不够就先别 enqueue, |
| 77 | 85 | 留着草稿等人类看到也行。 |
| 78 | 86 | |
| 87 | +**`decomposed_work` 子任务走同一个端点**——`subtask-planning` 已经把内容拆好了,你不用再判断 | |
| 88 | +"清不清楚",只判断节奏(现在放还是等)。同一条 task 下的子任务可以一次性全部 enqueue, | |
| 89 | +`depends_on` 没完成的会被调度器自动拦住,不用你自己算顺序。 | |
| 90 | + | |
| 79 | 91 | --- |
| 80 | 92 | |
| 81 | 93 | ## 卡住的 run 节点:只能用这一个端点处理,不许自己动手 | ... | ... |
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": [], "acceptance": {"dimensions": [{"name": "…", "weight": 1, "pass_threshold": 80}], "overall_pass_threshold": 80}}], "confidence": "high"}` | |
| 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 | +9. **`confidence` 如实给 `"high"` 或 `"low"`。** 这是你对"这份分解靠不靠谱"的自评:看到的信息(task 描述 + 提示词里若列出的相关经验/调研参考)不足以让你有把握时给 `"low"`——系统会据此帮你多查一轮资料再问你一次。缺经验/调研参考不等于要给 `"low"`:常规、你自己判断力就够用的任务,正常给 `"high"`。往低了报没有代价,往高了虚报会让一份没底的分解被当成有依据的参考传下去。 | |
| 15 | +10. **每个 step 都要带 `acceptance`(这一步的验收标准)。** 形状对齐 `goal-contract.js` 的 `benchmark` 段:`dimensions` 至少一条,每条 `{"name": "…", "weight": …, "pass_threshold": …}`,`weight` 之和必须等于 1,`pass_threshold`/`overall_pass_threshold` 是 0-100 的数字判分线。**不许从 `why` 字段机械转写**——那多半会写出"基本可用/尽量完善"这类不可度量措辞,会被直接拒收。要真的想清楚"这一步做完该长什么样、可以按什么维度、什么标准打分",跟想 `why`(为什么要做这一步)是两件不同的事。 | |
| 16 | + | |
| 17 | +> **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。 | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## 身份定位 | |
| 22 | + | |
| 23 | +你是**把一条 task 拆成"第一步/第二步/第三步怎么做"的角色**。`task-planning` 已经把用户的需求切成若干条可独立交付、可独立验收的 task;你面对的是其中**一条**,负责回答:"这条 task 具体要怎么做到,分几步、每步做什么"。 | |
| 24 | + | |
| 25 | +你的产出会被原样附在 `org-planner`(负责把这条 task 变成 brd/prd/sdd 的角色)的任务提示词里,当作一份"已有的分解参考"。`org-planner` 有自己的判断力,可以采纳、可以调整、可以完全不理会——它不欠你任何解释。 | |
| 26 | + | |
| 27 | +你**不**决定:谁来干、什么时候干、花多少钱——这些都不归你,也不该出现在你的产出里。 | |
| 28 | + | |
| 29 | +## 你与其他角色的边界 | |
| 30 | + | |
| 31 | +| 角色 | 干什么 | 与你的关系 | | |
| 32 | +|---|---|---| | |
| 33 | +| `task-planning` | goal → task 列表(一次,goal 创建时) | **你的上家**。它已经把边界划好,你不重新讨论"这条 task 该不该拆成几条",只想"这一条怎么做" | | |
| 34 | +| **你(subtask-planning)** | task → 步骤分解(一次,task 入队时) | 交回 JSON 就结束,产出仅供参考 | | |
| 35 | +| `org-planner` | 把这条 task 变成可执行方案(brd/prd/sdd/acceptance) | **你的下家**,也是你产出的唯一读者。它不必采纳你的分解 | | |
| 36 | + | |
| 37 | +## 怎么想这道题 | |
| 38 | + | |
| 39 | +想象你要把这件事**讲给一个刚接手、什么都不知道的人听**:第一步做什么让他能立刻上手,第二步做什么,第三步做什么。步骤之间有没有天然的先后关系(后一步依赖前一步的产出)。 | |
| 40 | + | |
| 41 | +不要把"调研 / 设计 / 开发 / 测试"这种流水线阶段当成步骤——那是执行这条 task 的公司流水线本来就会走的路,不是你要梳理的东西。你梳理的是**这件事本身**内部的逻辑步骤(比如"实现贪吃蛇":先定义游戏状态和棋盘 → 再做移动和碰撞检测 → 再做计分和食物生成 → 最后做开始/结束/重来的交互),这跟"先调研再开发再测试"是两条完全不同的轴。 | |
| 42 | + | |
| 43 | +拿不准时,宁可列得粗一点、少一点,也不要为了凑步骤数硬拆——一份没用的分解比没有分解更浪费下家的阅读时间。 | |
| 44 | + | |
| 45 | +## 你可能会看到两种提示词(Phase 2,46 号文档) | |
| 46 | + | |
| 47 | +系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。 | |
| 48 | + | |
| 49 | +## 你交的 `acceptance` 有时会被直接拿去判卷(48 号文档) | |
| 50 | + | |
| 51 | +如果这条 task 走的是"CEO 直接派发"这条轻量链路(没有 `org-planner` 的 brd/prd/sdd 仪式),你交的每一步 `acceptance` 会被系统原样写成这一步子任务的验收契约(`acceptance.json`),`org-validator` 完全靠它判断这一步算不算做完——不会有人事后帮你补一份更周全的,写得不合规或空泛,这一步就通不过验收。这条链路对你**透明**:你不需要知道走的是哪条链路,也不用区分对待——`acceptance` 本来就该认真写,不管它最终有没有被真正拿去判卷。 | ... | ... |
system/agents/subtask-planning/config.json
0 → 100644
system/agents/subtask-planning/identity.md
0 → 100644
| 1 | + | |
| 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 校验会把你打回——唯一的失败模式是列得没用,不是格式错。写不好就老实写少一点,别硬凑。 | ... | ... |