Commit 8b1e7fb65dd659bfc676e1d9f77aac4737c67a56

Authored by yibo
1 parent 407bef7b

feat(decomposed-work): Phase 4 父子任务生命周期 + 轻量图退回边(46 号文档收口)

46 号文档 Phase 4:decomposed_work 子任务的父子生命周期管理。

- 父任务被 subtask-planning 成功拆分后让位给子任务(新状态 decomposed),
  取消自己排队中的完整 org graph run
- 父任务预算按拆出的步数均分给每条子任务
- 父子终态汇总(settleParentTaskIfAllChildrenDone),子任务全部完成后
  自动结算父任务并联动 goal 收敛
- 同父子任务独立并发坑位(上限 3),判据钉在 updateRunStatus 的唯一准入
  事务里,不占用公司级 max_parallel_tasks 槽位
- 前端(Phase 5):decomposed 状态样式 + 子任务进度展示,复用已有的
  parentTaskId 通用树形逻辑,未改后端接口

真实回归当场撞出一个几乎必现的真缺陷:markTaskDecomposed 原本只认父任务
还是 queued,但 task-scheduler 抢坑是毫秒级、subtask-planning 是数十秒的
真实 LLM 调用,等它拆完时父任务几乎必然已经 running,接管必然失败,而
stopRun 依然会把真实在跑的 org graph 取消掉。已修复(接管条件放宽 +
调整先接管再取消的顺序)并用真实公司数据验证;同父并发坑位也用真实数据
确认生效(两条子任务真实同时 running 20+ 分钟)。

回归过程中牵出一个独立缺陷:org-validator 对同一种 benchmark 契约判断
不稳定(5 次里 1 次误把源码路径当 --target 判 target_not_json,反手把
责任推给 subtask-planning)。根因是 decomposed_work 轻量图当时只有一条
前进边、没有失败边,validator 判 fail 后无路可退。已补 validate --fail-->
impl 退回边(复用引擎已验证过的 backward_count 双层硬顶 + remediation_note
反思闭环),新增 3 条单测覆盖边形状、真实退回重试、硬顶熔断三个场景。

全量单测 1229/1231 通过(2 个失败改动前即存在、与本次无关)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Mq8rViHp8xuj6zTy76hh
docs/README.md
@@ -45,6 +45,7 @@ @@ -45,6 +45,7 @@
45 | [system-design/45-graph-run-node-liveness-recovery.md](./done/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 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | 45 | [system-design/45-graph-run-node-liveness-recovery.md](./done/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 阈值判据的误伤局限)未做,见文内「实现记录」的理由 |
46 | [system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md](./done/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md) | ✅ **已修复**(2026-09-02)。46 号文档 Phase 0 真实回归坐实:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状(prd/sdd/goal-benchmark/planner-validator)下结构性产不出来,`itemized` 子任务物化 0 命中。修法:物化逻辑新增兜底,`acceptance.md` 缺失时改读已被 44 号文档证实稳定产出的 `goal-benchmark.json` 的 `dimensions[]`。`task-decomposition-materialize.test.js` 5 条全过(2 条新增,用真实回归捕获的产物做测试数据) | 46 | [system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md](./done/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 条新增,用真实回归捕获的产物做测试数据) |
47 | [system-design/48-task-acceptance-contract-binding.md](./done/system-design/48-task-acceptance-contract-binding.md) | ✅ **已实现并完成真实端到端回归,已收口**(decomposed_work 链路 2026-09-02/回归补于 2026-09-03,剩余三条路径调研收口于 2026-09-03)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 decomposed_work 子任务创建时必须 1:1 带一份验收契约"(`tasks.acceptance_contract` + `createTask` 统一校验 + `enqueueTaskRun` 落盘),真实 `org-coder`→`org-validator` 全程跑通验证。**收口时追加调研**:`task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径经核实**不需要**同款强绑定——都走完整 org graph,`workgraph-planning` 的 `benchmark` 节点"不可裁"约定已天然保证有契约,强行加会跟 46 号文档保护的 `task-planning` 极简边界、快速派单"零 LLM"定位冲突。真正残留风险是"org graph 一定含 org-planner"目前只是提示词约定非代码强制,属于 org 拓扑保证问题,不在本文档范围。安全网(发现时就地补契约)按设计仍只留原则,`decomposed_work` 链路已被主力机制完全覆盖,不为不可达场景写代码 | 47 | [system-design/48-task-acceptance-contract-binding.md](./done/system-design/48-task-acceptance-contract-binding.md) | ✅ **已实现并完成真实端到端回归,已收口**(decomposed_work 链路 2026-09-02/回归补于 2026-09-03,剩余三条路径调研收口于 2026-09-03)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 decomposed_work 子任务创建时必须 1:1 带一份验收契约"(`tasks.acceptance_contract` + `createTask` 统一校验 + `enqueueTaskRun` 落盘),真实 `org-coder`→`org-validator` 全程跑通验证。**收口时追加调研**:`task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径经核实**不需要**同款强绑定——都走完整 org graph,`workgraph-planning` 的 `benchmark` 节点"不可裁"约定已天然保证有契约,强行加会跟 46 号文档保护的 `task-planning` 极简边界、快速派单"零 LLM"定位冲突。真正残留风险是"org graph 一定含 org-planner"目前只是提示词约定非代码强制,属于 org 拓扑保证问题,不在本文档范围。安全网(发现时就地补契约)按设计仍只留原则,`decomposed_work` 链路已被主力机制完全覆盖,不为不可达场景写代码 |
  48 +| [system-design/46-mandatory-planning-and-task-decomposition.md](./done/system-design/46-mandatory-planning-and-task-decomposition.md) | ✅ **已收口**(2026-09-06)。Phase 0-5 全部实现。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索 + 按需 deep research(默认关闭)。**Phase 3 v2**:CEO 心跳直接发现+派发(push),子任务走轻量 work graph(`org-coder`+`org-validator` 两节点)。**Phase 4**:父任务被拆分后让位给子任务(新状态 `decomposed`)、预算按步数均分、父子终态汇总(`settleParentTaskIfAllChildrenDone`)、同父子任务独立并发坑位(上限 3,不占公司级槽位)——真实回归当场撞出一个几乎必现的真缺陷(`markTaskDecomposed` 原本只认父任务还是 `queued`,但 task-scheduler 抢坑是毫秒级、`subtask-planning` 是数十秒的真实 LLM 调用,等它拆完时父任务几乎必然已经 `running`,接管必然失败)已修复并用真实数据验证;同父并发坑位也用真实数据确认生效(两条子任务真实同时 `running` 20+ 分钟)。**Phase 5**:看板加 `decomposed` 状态样式 + 子任务进度展示,父子树形复用已有通用逻辑,未改后端接口。回归过程中牵出一个独立缺陷——`org-validator` 对同一种 benchmark 契约判断不稳定(5 次里 1 次误把源码路径当 `--target` 判 `target_not_json`,反手倒退归因给 `subtask-planning`)——已定位根因(`decomposed_work` 轻量图当时只有一条前进边,没有失败边,validator 判 fail 后无路可退)并修复(补 `validate --fail--> impl` 退回边,复用引擎已验证过的 `backward_count` 双层硬顶 + `remediation_note` 反思闭环,新增 3 条单测),详见文末补跑记录 |
48 49
49 ## todo/(当前主线) 50 ## todo/(当前主线)
50 51
@@ -68,7 +69,6 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -68,7 +69,6 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
68 | [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(训练产物 → 雇佣本体转换)尚未开工 | 69 | [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(训练产物 → 雇佣本体转换)尚未开工 |
69 | [49-antigravity-inspired-parallel-verification-proposal.md](./todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md) | 📝 **设计讨论,不代表已拍板**(2026-09-04)。对照 Google Antigravity「Teamwork」多智能体框架博客文章,给 27 号 P4「真并行」与 34/35 号「验证严谨度不稳定」两个议题出的方案草稿。**先纠正一个前提**:重读 27-P4 全文后发现阶段 A/B/C 早已全部实现(又一次「文档落后于代码」),真并行现在只剩 T2/role 列/`advanceTree` 兄弟子 run 并发三件小事,可独立收口。**新提案 A**:借鉴长证明模式的「竞争候选+对抗批评+综合」,区别于现有 `executor-fanout.json` 的分解型 fan-out——需要先有真实场景验证价值,不建议凭空立项,详细设计见 [50 号文档](./todo/system-design/50-competing-candidates-critic-synthesis-design.md)。**新提案 B**:把 35 号提案 D「浏览器关键词命中才要证据」泛化成 benchmark 段全维度默认要求执行级证据(**唯一建议现在推进的部分**);实地核实过把验收路径引导到 `test_script` 段的工作量后**结论是不做**——撞上 `test_script.path` 解析根不含 `org-coder` 实际写代码的项目根这个结构性冲突,改造面远超预期;新增「陷阱注册表」,把 validator 判卷失败的结构化原因喂回 Dream 机制,落点已定案为独立文件 `known_pitfalls.md`(不复用 `memory.md`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | 70 | [49-antigravity-inspired-parallel-verification-proposal.md](./todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md) | 📝 **设计讨论,不代表已拍板**(2026-09-04)。对照 Google Antigravity「Teamwork」多智能体框架博客文章,给 27 号 P4「真并行」与 34/35 号「验证严谨度不稳定」两个议题出的方案草稿。**先纠正一个前提**:重读 27-P4 全文后发现阶段 A/B/C 早已全部实现(又一次「文档落后于代码」),真并行现在只剩 T2/role 列/`advanceTree` 兄弟子 run 并发三件小事,可独立收口。**新提案 A**:借鉴长证明模式的「竞争候选+对抗批评+综合」,区别于现有 `executor-fanout.json` 的分解型 fan-out——需要先有真实场景验证价值,不建议凭空立项,详细设计见 [50 号文档](./todo/system-design/50-competing-candidates-critic-synthesis-design.md)。**新提案 B**:把 35 号提案 D「浏览器关键词命中才要证据」泛化成 benchmark 段全维度默认要求执行级证据(**唯一建议现在推进的部分**);实地核实过把验收路径引导到 `test_script` 段的工作量后**结论是不做**——撞上 `test_script.path` 解析根不含 `org-coder` 实际写代码的项目根这个结构性冲突,改造面远超预期;新增「陷阱注册表」,把 validator 判卷失败的结构化原因喂回 Dream 机制,落点已定案为独立文件 `known_pitfalls.md`(不复用 `memory.md`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 |
70 | [50-competing-candidates-critic-synthesis-design.md](./todo/system-design/50-competing-candidates-critic-synthesis-design.md) | 📝 **设计草稿,不代表已拍板**(2026-09-04)。49 号文档提案 A 的展开:竞争候选骨架 JSON 形状草稿(`candidate-council.json`,未定案)、命名(按 27-NAMING 节点带前缀/本体不带前缀两轴)、`workgraph-planning` 选型判据方向。**关键设计约束**:critic(评判)与 synthesis(择优/融合)必须拆成两个节点,依据是仓库真实事故——`qa-judge` 早期"评判+终态判决合一"设计导致过一次无限循环(20 次调用/31 分钟才熔断,记录在 `deliverable.json` 的反模式说明里),新骨架延续 `validator-triager`/`benchmark-validator` 已验证的"归因/打分/判定三者分离"范式。**预算风险量化**:N 路候选并行消耗 N 倍预算,且 `checkBudget` 只在批量拾取前查一次,实际可能超支到 `maxConcurrency-1`(默认最多多花 4 次调用)才被拦下,候选数 N 目前代码层没有硬顶 | 71 | [50-competing-candidates-critic-synthesis-design.md](./todo/system-design/50-competing-candidates-critic-synthesis-design.md) | 📝 **设计草稿,不代表已拍板**(2026-09-04)。49 号文档提案 A 的展开:竞争候选骨架 JSON 形状草稿(`candidate-council.json`,未定案)、命名(按 27-NAMING 节点带前缀/本体不带前缀两轴)、`workgraph-planning` 选型判据方向。**关键设计约束**:critic(评判)与 synthesis(择优/融合)必须拆成两个节点,依据是仓库真实事故——`qa-judge` 早期"评判+终态判决合一"设计导致过一次无限循环(20 次调用/31 分钟才熔断,记录在 `deliverable.json` 的反模式说明里),新骨架延续 `validator-triager`/`benchmark-validator` 已验证的"归因/打分/判定三者分离"范式。**预算风险量化**:N 路候选并行消耗 N 倍预算,且 `checkBudget` 只在批量拾取前查一次,实际可能超支到 `maxConcurrency-1`(默认最多多花 4 次调用)才被拦下,候选数 N 目前代码层没有硬顶 |
71 -| [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 号文档](./done/system-design/48-task-acceptance-contract-binding.md)**。验证过程顺手修了一个更大范围的真实 bug:两套心跳系统的自环调用一直缺内部认证令牌、暗中 401。Phase 4(独立开发验证循环)、Phase 5(看板 UI)未开工 |  
72 72
73 ## todo/future/(暂缓,非当前重心) 73 ## todo/future/(暂缓,非当前重心)
74 74
docs/done/system-design/46-mandatory-planning-and-task-decomposition.md
1 # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源 1 # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源
2 2
3 -> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](../../done/system-design/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)仍未设计,留给后续 3 +> **状态**: ✅ **已收口**(2026-09-06)。Phase 0-5 全部实现,核心机制均已用真实数据验证(父任务让位/预算均分/同父并发坑位/子任务真实跑通 org-coder→org-validator),父子终态汇总由单测严格覆盖但没等到真实链路触发那一刻。过程中牵出一个独立缺陷(`org-validator` 对同一种 benchmark 契约判断不稳定,5 次里 1 次误判)并已修复+补测试验证——`decomposed_work` 轻量图现在有了退回边,能对这类判断失误重试而不是永久卡死,详见文末补跑记录。四个关键决策已拍板。**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(父任务让位 + 预算均分 + 父子终态汇总 + 同父并发坑位)已实现并做过真实端到端回归(2026-09-04,见「Phase 4 详细设计与实现记录」)**:真实回归第一次就撞出一个几乎必现的真缺陷——`markTaskDecomposed` 原本只认「父任务还是 `queued`」,但 task-scheduler 抢坑是毫秒级、`subtask-planning` 是数十秒的真实 LLM 调用,等它拆完回来时父任务几乎必然已经是 `running`,接管必然失败,而 `stopRun` 依然会把真实在跑的 org graph 取消掉,后果是父任务卡成 `cancelled`、物化好的子任务全变孤儿。已修复(接管条件放宽成"还没到终态、也没被接管过")并用真实公司复现+验证通过:父任务从真实 `running` 状态被正确接管为 `decomposed`,预算按步数精确均分(`100/5=20` 次调用、`5000000/5=1000000` token、`7200000/5=1440000` ms wallclock,真实落库数值核对过),依赖链驱动的子任务顺序生效,第一条子任务真实跑通了 `org-coder→org-validator` 全流程。**未在真实条件下跑到底**:驱动脚本的等待窗口到期后误杀了正在真实执行的第二条子任务(`org-coder` CLI 被中途 SIGTERM),该子任务因此被图引擎判成 `blocked`——这是回归方法论本身的时间预算问题,不是 Phase 4 代码的缺陷;父子终态汇总(`settleParentTaskIfAllChildrenDone`)与同父 3 并发坑位这两块在真实条件下没有跑到触发的那一刻,改由单测(`task-decomposition-phase4.test.js`)覆盖其 CAS/并发语义。**Phase 5(看板最小展示)已实现**:`decomposed` 状态样式 + 子任务进度 label,父子任务树形展示复用已有的通用 `parentTaskId` 分组逻辑,未改后端接口
4 > **日期**: 2026-09-01 4 > **日期**: 2026-09-01
5 -> **触发场景**: 用户复盘 [43 号文档](../../done/system-design/43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。  
6 -> **格式对齐** `27-requirement.md`/[`43-task-decomposition-granularity.md`](../../done/system-design/43-task-decomposition-granularity.md):原话照搬 + 结构化复述,现状调研另起一节。 5 +> **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。
  6 +> **格式对齐** `27-requirement.md`/[`43-task-decomposition-granularity.md`](./43-task-decomposition-granularity.md):原话照搬 + 结构化复述,现状调研另起一节。
7 7
8 --- 8 ---
9 9
@@ -192,7 +192,7 @@ goal 创建 @@ -192,7 +192,7 @@ goal 创建
192 192
193 ### 遗留决策:这个新发现的 bug 怎么处理 193 ### 遗留决策:这个新发现的 bug 怎么处理
194 194
195 -已拆分成独立文档处理,**已修复**:见 [47 号文档](../../done/system-design/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 设计阶段要显式决定要不要保留它。 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 196
197 --- 197 ---
198 198
@@ -200,7 +200,7 @@ goal 创建 @@ -200,7 +200,7 @@ goal 创建
200 200
201 ### 命名 201 ### 命名
202 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 分解出多个)"),命名轴完全对上,不用新造概念。 203 +按 [27-naming-conventions.md](../../todo/system-design/27-naming-conventions.md) §二「规划类 agent —— `{产出物}-planning`」的既有惯例(`brd-planning`/`prd-planning`/`sdd-planning` 都按自己产出什么命名),新 agent 命名为 **`subtask-planning`**——它的产出物就是 `subtask-goal`(该规范 §一 作用域表里已经预留了这一级:"子任务的目标(一个 task-goal 分解出多个)"),命名轴完全对上,不用新造概念。
204 204
205 与 `task-planning` 的关系:`task-planning` 把 `company-goal` 拆成多条 `task`(= `task-goal`);`subtask-planning` 把一条 `task-goal` 拆成多条 `subtask-goal`——同一条命名轴上相邻的两级,职责边界清楚。 205 与 `task-planning` 的关系:`task-planning` 把 `company-goal` 拆成多条 `task`(= `task-goal`);`subtask-planning` 把一条 `task-goal` 拆成多条 `subtask-goal`——同一条命名轴上相邻的两级,职责边界清楚。
206 206
@@ -488,3 +488,65 @@ v1 真实回归跑完,用户追问&quot;认领后没有触发源,谁来触发&quot;— @@ -488,3 +488,65 @@ v1 真实回归跑完,用户追问&quot;认领后没有触发源,谁来触发&quot;—
488 3. 按用户原话的退路——这条子任务级别就不追求跑通 `org-validator`,直接升级成完整 org graph(这就是当初讨论"先跑轻量版看效果,不行再走完整 org graph"里"不行"的具体触发条件)。 488 3. 按用户原话的退路——这条子任务级别就不追求跑通 `org-validator`,直接升级成完整 org graph(这就是当初讨论"先跑轻量版看效果,不行再走完整 org graph"里"不行"的具体触发条件)。
489 489
490 **后续讨论收敛到方向 1 的一个更彻底的版本**:不是"轻量图加一个专门产契约的节点",而是"每个 task/subtask 创建时就必须 1:1 带一份验收契约"——把约束点从"某张图怎么搭"提到"task 这个实体本身"。完整讨论过程、方案取舍、以及需要改动的 agent/代码盘点见 [48 号文档](./48-task-acceptance-contract-binding.md)。 490 **后续讨论收敛到方向 1 的一个更彻底的版本**:不是"轻量图加一个专门产契约的节点",而是"每个 task/subtask 创建时就必须 1:1 带一份验收契约"——把约束点从"某张图怎么搭"提到"task 这个实体本身"。完整讨论过程、方案取舍、以及需要改动的 agent/代码盘点见 [48 号文档](./48-task-acceptance-contract-binding.md)。
  491 +
  492 +---
  493 +
  494 +## Phase 4 详细设计与实现记录(2026-09-04)
  495 +
  496 +48 号文档把 Phase 3 v2 留下的验证缺口补上后,`decomposed_work` 子任务已经有了完整可用的独立 impl→validate 循环。复盘发现 Phase 4 原计划的三件事——预算模型、父子终态汇总、并发/节奏控制——都还没做,且过程中浮出一个文档没覆盖的新问题:父任务被拆分后,自己那条完整 org graph 依然会独立真实执行,跟子任务的轻量图各跑各的、互不知情,同一件事被做两遍。
  497 +
  498 +### 拍板(逐条与用户对齐,非单方面假设)
  499 +
  500 +1. **子任务是父任务唯一执行体**——父任务被成功拆分后,跳过自己的完整 org graph 执行,终态完全由子任务汇总决定。
  501 +2. **预算按子任务数均分**父任务的 `budget_json`(按计划步数 `steps.length` 均分,不按事后实际建库成功的条数——单步校验失败被丢弃时更保守,不因落库顺序影响每条子任务分到多少)。
  502 +3. **父任务保持"进行中"直到全部子任务达终态**才结算,不做百分比中间态展示。
  503 +4. **同一父任务下的无依赖子任务开专属并发槽位**,固定上限 3,不受公司级 `max_parallel_tasks`(默认 1)限制,两个坑位池完全独立。
  504 +5. **拆分决策时机保持异步**:父任务的 run 照旧立即创建排队,等 `subtask-planning` 拆分成功后再补一步取消/作废父 run。
  505 +6. **验证方式**:单测覆盖机制本身 + 一次真实公司端到端回归。
  506 +7. **Phase 5(看板 UI)接着在同一轮做,最小范围**:只加状态/进度展示,不做新交互。
  507 +
  508 +### 实现
  509 +
  510 +| 内容 | 位置 |
  511 +|---|---|
  512 +| 新状态 `decomposed`(app 层枚举,无 DB CHECK) | `server/services/work-graph-db.js`——父任务被拆分后进入的中间态,`task-scheduler.listQueue` 只捞 `status='queued'`,天然不会把它当候选 |
  513 +| `markTaskDecomposed(taskId)` | `work-graph-db.js`——CAS:`status NOT IN (终态, 'decomposed')` 才能转 `decomposed`(**见下方「真实回归纠正过的设计」,这条件不是一开始就这么写的**) |
  514 +| `settleDecomposedParentStatus(parentTaskId, status)` | `work-graph-db.js`——CAS:只能从 `'decomposed'` 转终态,防重复结算 |
  515 +| `settleTaskFromRun` 补一条排除 | `work-graph-db.js`——`AND status != 'decomposed'`:`stopRun` 取消父 run 会触发这条run 收敛回写口,写 `status='cancelled'`;不排除的话会覆盖掉刚接管成的 `decomposed`(真实回归第一次就撞上,见下) |
  516 +| 预算均分 | `materializeDecomposedSubtasks`(work-graph-db.js)——读父任务 `budget_json`(没有则退 `DEFAULT_TASK_BUDGET` 同款形状),按 `steps.length` 均分三项(call/token/wallclock),`Math.max(1, ...)` 保底,写进每条子任务的 `budgetJson` |
  517 +| 父任务取消 | `triggerSubtaskPlanning`(subtask-planning.js)——materialize 出 ≥1 条子任务后,**先 `markTaskDecomposed` 再 `stopRun`**(顺序不能反,见下);父 run 若已经是真终态,两个操作天然都是 no-op,不覆盖已有结论 |
  518 +| 父子终态汇总 | `settleParentTaskIfAllChildrenDone`(task-settle.js)——`settleGoalIfAllTasksDone` 的父任务版本,判据从 `goal_id` 换成 `parent_task_id + subtask_kind='decomposed_work'`;不做 goal 级那套「≥2 条再验一次」,因为每条子任务已经在自己的轻量图里被 `org-validator` 验证过一次,没有对应的"父任务级验收"agent;成功结算后顺手补调一次 `settleGoalIfAllTasksDone`(父任务转终态不会自动触发 goal 收敛,必须补这一步,否则父子全部做完 goal 会永远停在 active)。挂在 `settleRootRun` 里,跟既有的 `settleGoalIfAllTasksDone` 调用点并列 |
  519 +| 同父并发坑位(上限 3) | `work-graph-db.updateRunStatus`——**准入判据钉死在唯一的准入事务里**(不是 task-scheduler 的候选预筛选):`decomposed_work` 子任务的 run 走独立的按 `parent_task_id` 计数的坑位检查,公司级 `listActiveRootRunIds`/`countActiveRootRuns` 改成排除 `decomposed_work`(两个坑位池完全独立);`RunSlotBusyError` 加 `scope`(`'company'`/`'decomposed-sibling'`)区分两种坑满;`task-scheduler.tickInner` 候选按 `subtask_kind` 分成两条独立循环——普通候选保留原有「坑满即 break 整轮」语义,decomposed_work 候选坑满时只 `continue`(这个父任务坑位满不代表别的父任务/别的候选也满,两者语义相反,混在一个循环里判断会两头都错) |
  520 +| CEO 文档 | `system/agents/ceo/heartbeat.md`——补一行说明:父任务变成 `decomposed` 状态是正常现象,不是卡住 |
  521 +| 前端(Phase 5) | `goal-task-model.js` 加 `TASK_STYLE.decomposed`(复用 running 同款蓝+脉冲)+ `decomposedProgressLabel`;`TaskTable.vue`/`GoalTaskPane.vue` 接入进度 label;父子任务树形展示(`parentTaskId` 分组)两处视图**本来就不检查 `subtask_kind`**,`decomposed_work` 子任务已经自动出现在树里,不用改 |
  522 +| 测试 | `task-decomposition-phase4.test.js`(新增,20 条):预算均分、CAS 语义(含真实回归纠正后的 running/blocked 分支)、并发坑位(同父第 4 条撞 `RunSlotBusyError`、不占公司级坑位、不同父互不占用)、父子终态汇总四种分支;`subtask-planning-materialize.test.js` 扩展(4 条):拆分成功后取消父 run、0 条子任务不接管、父 run 已终态不覆盖、**父 run 已真实跑到 running 时依然正确接管**(复现并钉死真实回归撞到的那个 bug);`goal-task-model.test.js` 扩展(4 条):新状态样式 + 进度 label。全量单测 1226/1228 通过(唯二失败与本次改动无关、改动前即存在:`.env-example-secrets`/`test-id-stability`) |
  523 +
  524 +### ⚠️ 真实回归纠正过的设计:`markTaskDecomposed` 不能只认 `queued`
  525 +
  526 +第一版实现里,`markTaskDecomposed` 的 CAS 条件是 `WHERE status='queued'`——理由是"`enqueueTaskRun` 刚落的状态就是 queued",这句话本身没错,但推论错了:`triggerSubtaskPlanning` 是异步触发的一次真实 LLM 调用(数十秒),而 task-scheduler 抢占空闲坑位是毫秒级的(`updateRunStatus` 的 `BEGIN IMMEDIATE` 事务)。真实回归用真实公司(`p2verify0902`)走一遍完整链路时,第一次就当场撞上:`subtask-planning` 拆出 5 条子任务、真实调用 `writeContract`/`materializeDecomposedSubtasks` 都成功,但 `markTaskDecomposed` 因为父任务此刻早已是 `running`(不是 `queued`)而拿不到锁——`stopRun` 依然把这条真实在跑的 org graph 取消掉,父任务最终卡成 `cancelled`,5 条物化好的子任务变成没有父任务收敛、没人会去汇总的孤儿。
  527 +
  528 +这不是小概率竞态,是**几乎每次都会发生**的时序——只要公司当时有空闲坑位(真实回归用的测试公司就是这种情况),task-scheduler 几乎立刻就会把任务推进 `running`。修法:把接管条件从"必须是 `queued`"放宽成"还没到终态、也还没被接管过"(`status NOT IN (完成/失败/取消, 'decomposed')`),`queued`/`running`/`blocked` 都算"父 run 还没有效完成,可以被接管"。同时把 `settleTaskFromRun` 的 run 收敛回写口也加了一条 `AND status != 'decomposed'` 排除——否则 `markTaskDecomposed` 先成功、`stopRun` 触发的收敛回写紧接着还是会把 `decomposed` 覆盖成 `cancelled`。**顺序也很关键**:必须先 `markTaskDecomposed` 再 `stopRun`,反过来的话 `stopRun` 触发的收敛回写会抢在接管之前把任务写成 `cancelled`,`markTaskDecomposed` 随后必然扑空(第一版代码就是这个顺序,真实回归复现的正是这个现象)。
  529 +
  530 +修复后用同一个真实场景验证:父任务从真实 `running` 状态被正确接管为 `decomposed`(不是 `cancelled`),5 条子任务全部正确物化,预算精确均分(真实落库核对:`total_agent_calls:20`=100/5、`total_tokens:1000000`=5000000/5、`wallclock_ms:1440000`=7200000/5),依赖链驱动的执行顺序生效(`subtask-planning` 真实给出的依赖是一条近似链式的图:步骤 2 依赖 1,3/4 都依赖 2,5 依赖 1+3+4),第一条子任务真实走完 `org-coder→org-validator` 全流程判为 `completed`。
  531 +
  532 +### 真实回归未覆盖到的部分(如实记录,不是遗漏)
  533 +
  534 +驱动脚本给了 15 分钟的等待窗口,第二条子任务的 `org-coder` 是真实在执行(大量真实工具调用),没有卡死,但超出了等待窗口——脚本退出前的子进程清理逻辑把这条真实在跑的 CLI 进程 SIGTERM 了,图引擎把它判成 `blocked`(`impl` 节点"未给出 fail_reason,走兜底边",这是既有的、48 号文档之前就验证过的降级路径,不是 Phase 4 新代码的行为)。这是回归方法论本身的时间预算问题,不是代码缺陷:
  535 +
  536 +- **父子终态汇总**(`settleParentTaskIfAllChildrenDone`)没有在真实条件下被真正触发过一次(全部子任务到终态这个条件没达成)——由单测覆盖其 CAS/分支逻辑,机制层面有把握,但"真实调用链路上会不会被正确触发"这一点严格说只验证到"父任务转 `decomposed`后没有被过早/错误结算"(回归观察期间父任务全程稳定停在 `decomposed`,没有被 `settleGoalIfAllTasksDone` 误判提前收敛,这本身也是一条有效证据,但不是"全部子任务完成后确实会自动汇总"这条主张的直接证明)。
  537 +- **同父 3 并发坑位**没有被真实触发——`subtask-planning` 给出的依赖图基本是链式的,同一时刻最多只有 1 条无依赖的子任务,没有出现≥2 条子任务同时可跑的窗口。机制本身由单测直接验证(`updateRunStatus` 层面的 CAS + 计数),真实条件下"两条子任务真的同时占了两个坑位、互不干扰"这一幕没有被观察到。
  538 +
  539 +测试公司 `p2verify0902` 的这次回归数据(goal `G-mtn5zewz-8d47`)未清理,留作证据;此前两次因为脚本 bug(等待条件判早/退出过快误杀子进程)失败的尝试(goal `G-mtn5q2r7-786b`、`G-mtn5tvih-bed4`)同样留在库里未清理,第二次那条父任务卡成 `cancelled`、子任务变孤儿的现场是本节记录的那个 bug 的直接物证。回归全程 `ps aux` 确认零残留 CLI 子进程。
  540 +
  541 +### 补跑:同父并发坑位在真实条件下确认生效,顺带牵出并修复一个独立发现(2026-09-05/06)
  542 +
  543 +用不改变服务进程生命周期的方式(起一个长驻服务脚本 + 独立短命脚本发起调用,观察脚本只读不杀进程)把上面卡住的第 2 条子任务重跑起来,补齐了上一轮没跑到的两幕:
  544 +
  545 +- **同父 3 并发坑位——已用真实数据确认生效**:子任务 3(`b48b`,吃食物逻辑)与子任务 4(`35e1`,碰撞检测)都只依赖子任务 2,子任务 2 完成后两者被真实观察到**同时 `running`**(持续 20+ 分钟,不是巧合的瞬间重叠),期间公司级 `max_parallel_tasks` 仍是默认值 1——证明两个坑位池确实互相独立,`decomposed_work` 子任务没有被公司级槽位卡成串行。
  546 +- **父子终态汇总——被一个新发现的独立缺陷挡住,未在这轮跑到底**:5 条子任务里前 4 条全部 `completed`,第 5 条("空格键重开一局")卡在 `validate` 节点,`org-validator` 报 `input_underspecified`,理由是"契约只有 benchmark 段,但没有任何节点产出自报分数 JSON"。深挖发现:这个理由站不住——5 条子任务的 `acceptance.json` 格式完全一样,前 4 条 `org-validator` 都正确执行了自己文档里写明的规程(自己按 `benchmark-validator/agent.md` 的方法读代码取证据、打分、把分数写成 `attempt-n.scores.json` 落到自己节点目录,再拿这份自报文件当 `--target` 喂给 `goal-contract.js run`),唯独第 5 次它把 `--target` 直接指向了 `impl` 交付的源码文件(自然读不出 JSON,`target_not_json`),然后把这次工具用法失误当成"契约本身不完整"反手倒退归因给 `subtask-planning`。**这是一次纯粹的 LLM 执行不稳定,不是 Phase 4 代码的缺陷**:指令写得很清楚,5 次里 4 次做对了。
  547 +
  548 +**根因更深一层**:即便 `org-validator` 判断正确,`decomposed_work` 的轻量图(`impl --success--> validate`)当时**只有一条前进边,没有任何失败边**——validator 判 fail 之后图上无路可退,只能永久卡死。这正是本文档一直沿用的 retry-with-reflection 模式(DevLoop L1 循环、完整 org graph 的 validator 退回边)在这张图上唯独没有被接上。
  549 +
  550 +**发现与修复过程中的一个插曲**:诊断到一半时发现 `system/agents/org-validator/{agent.md,tools.md}` 和 `work-graph-db.js` 的 `createDecomposedWorkGraph` 已经被**同一个共享工作目录里的另一个并行会话**修复过了——`agent.md` 补了一整节"你可能被裸调用"的规程说明(点名了另一条真实撞上同类问题的任务 `T-mto6lp1c`,证明这不是孤例),`createDecomposedWorkGraph` 补了 `{ from: 'validate', to: 'impl', condition: 'fail', edgeKind: 'backward' }` 这条边。核实这条边只用一行代码就接上了引擎里已经被 `graph-engine.test.js` 反复验证过的老机制(`backward_count` 双层硬顶 + `remediation_note` 自动喂给下一轮执行者),没有引入任何新的判断逻辑,是正确、低风险的修法。这条边此前完全没有测试覆盖,本次补了 3 条(`graph-engine.test.js`):图定义里边的形状对不对、"validate 判 fail → 真退回 impl → remediation_note 带给下一轮 → 第二轮 validate 判过 → run 收敛成 completed"整条链路(假 executor,零真实 API 成本)、连续判 fail 撞硬顶后不再无限重试。三条全过。
  551 +
  552 +**这条修复只对新建的 decomposed_work 任务生效**——之前卡住的第 5 条子任务的 work graph 是修复前建的,edges 表里天生没有这条退回边,不会自动受益;真要在真实条件下走一遍"判 fail → 真退回 → 带反馈重跑 → 判过"的完整闭环,需要一条全新任务,本轮未做(机制已经用假 executor 验证得很扎实,真实条件下的完整验证留给下次touch这块代码时顺手做)。
server/services/subtask-planning.js
@@ -429,6 +429,29 @@ async function triggerSubtaskPlanning({ run, task }) { @@ -429,6 +429,29 @@ async function triggerSubtaskPlanning({ run, task }) {
429 429
430 console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` 430 console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),`
431 + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`); 431 + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`);
  432 +
  433 + // 46 号文档 Phase 4:拆分成功 → 子任务成为父任务唯一的执行体,父任务自己那条排队中的
  434 + // 完整 org graph run 要让位。**顺序很关键,先接管任务再取消 run,不能反过来**:
  435 + // `stopRun` 会同步触发 run 收敛的回写口 `settleTaskFromRun`,那条路径会把任务写成
  436 + // `cancelled`——如果先 `stopRun` 再 `markTaskDecomposed`,`markTaskDecomposed` 的 CAS
  437 + // (`WHERE status='queued'`)必然扑空,因为任务此刻已经被 `cancelled` 了(这不是小概率
  438 + // 竞态,是每次都会发生的确定性顺序,第一版实现在真实测试里当场撞上)。反过来先标
  439 + // `decomposed`,`settleTaskFromRun` 新增的 `AND status != 'decomposed'` 闸门会让
  440 + // 随后触发的 cancelled 回写扑空,任务保持 `decomposed` 不被覆盖。
  441 + // 父 run 仍有极小概率在这一步之前就已经跑完/失败(真赢了竞态)——那种情况下
  442 + // `markTaskDecomposed` 的 CAS 本来就拿不到锁(任务已是有效终态,不是 'queued'),
  443 + // `stopRun` 对已终态 run 也是安全的 no-op,两者叠加不会覆盖一个已经有效终态的父任务。
  444 + if (materializedIds.length) {
  445 + try {
  446 + const taken = wdb.markTaskDecomposed(task.id);
  447 + if (!taken) {
  448 + console.warn(`[SubtaskPlanning] task ${task.id} 接管失败(已不是 queued,多半是父 run 已经赢了竞态跑完了),保留父任务原有终态`);
  449 + }
  450 + require('./graph-engine').stopRun(run.id);
  451 + } catch (err) {
  452 + console.error(`[SubtaskPlanning] task ${task.id} 取消父 run(${run.id})失败: ${err.message}`);
  453 + }
  454 + }
432 } catch (err) { 455 } catch (err) {
433 console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); 456 console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`);
434 } 457 }
server/services/task-scheduler.js
@@ -241,6 +241,56 @@ function tick(companyId) { @@ -241,6 +241,56 @@ function tick(companyId) {
241 } 241 }
242 } 242 }
243 243
  244 +/**
  245 + * 出队单条候选的共享逻辑(原 5a-5e)。**不决定 busy 时是 break 还是 continue**——
  246 + * 那是调用方的事:公司级坑满意味着整轮都别再试(see tickInner 里的普通候选循环),
  247 + * 而 46 号文档 Phase 4 的「同父子任务坑满」只挡这一个父任务,其它候选(含别的父任务)
  248 + * 完全不受影响,必须接着试——两种语义不同,所以异常照样往外抛,调用方各自处理。
  249 + *
  250 + * @returns {{taskId:string,runId:string,goalId:string}|null} null = 这条没抢到(试下一条),
  251 + * 非 null = 起跑成功
  252 + * @throws {Error} RunSlotBusyError 等——由调用方决定 break 还是 continue
  253 + */
  254 +function dispatchOneCandidate(t, { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask, blocked }) {
  255 + const runId = t.current_run_id;
  256 + // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半,这条任务永远起不来。
  257 + // 标成 blocked(不带 upstream_failed 前缀 → 不会被自动重排)让人看见,
  258 + // 而不是让它继续假装在排队。
  259 + if (!runId || !getRun(runId)) {
  260 + if (blockQueuedTask(t.id, `enqueue_incomplete:missing_run${runId ? `:${runId}` : ''}`)) {
  261 + blocked.push({ taskId: t.id, upstream: null, reason: 'missing_run' });
  262 + }
  263 + console.error(`[task-scheduler] task=${t.id} 是 queued 但找不到 run(current_run_id=${runId})`);
  264 + return null;
  265 + }
  266 +
  267 + // 5b. 排队期间 org graph 可能被重新规划过:节点行是入队那一刻的快照,定义是读时取最新。
  268 + // 不对齐的话新增节点没有 run 行,出队后永远点不亮,而且全程无报错(§四.3 末)。
  269 + syncRunNodesToDef(runId);
  270 +
  271 + // 5c. 原子出队:`WHERE id=? AND run_status='queued'` 的条件更新,整段(数坑 + 占坑)
  272 + // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。
  273 + // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、
  274 + // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。
  275 + if (!sse().transitionRun(runId, 'queued', 'ready', {}, {
  276 + summary: `出队:queued → ready(task ${t.id})`,
  277 + task_id: t.id,
  278 + })) return null;
  279 +
  280 + // 5d. 认领 task。抢不到(被取消 / 被别的 tick 抢走 / 已归档)就把 run 收回去,
  281 + // 否则会留下一条 ready 却没有任务的 run 白占着坑。
  282 + if (!claimQueuedTask(t.id, runId)) {
  283 + abandonRun(runId, `task=${t.id} 认领失败(已不是 queued 或 current_run_id 已改指)`);
  284 + return null;
  285 + }
  286 +
  287 + // 5e. 起跑。mode 恒为 auto(候选已过滤),description 从 task 行读回——
  288 + // 排队之后请求闭包早就不在了(§四.7 禁令二说的就是这件事)。
  289 + // startRunSync 内部会回读确认真的转成了 running,没起来会抛,落到调用方的 catch。
  290 + engine().startRun(runId, { mode: t.execution_mode, task: t.description });
  291 + return { taskId: t.id, runId, goalId: t.goal_id };
  292 +}
  293 +
244 function tickInner(companyId) { 294 function tickInner(companyId) {
245 const { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask } = wdb(); 295 const { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask } = wdb();
246 296
@@ -253,10 +303,14 @@ function tickInner(companyId) { @@ -253,10 +303,14 @@ function tickInner(companyId) {
253 // 权威判据在 `updateRunStatus` 的准入事务里(BEGIN IMMEDIATE 内数坑 + 占坑一步做完), 303 // 权威判据在 `updateRunStatus` 的准入事务里(BEGIN IMMEDIATE 内数坑 + 占坑一步做完),
254 // 坑被抢走时它抛 RunSlotBusyError,由 5c 的 catch + isRaceError 当成「这轮没抢到」。 304 // 坑被抢走时它抛 RunSlotBusyError,由 5c 的 catch + isRaceError 当成「这轮没抢到」。
255 // 批 4 起 max 是 per-company 的(company.json.max_parallel_tasks,缺省 1)。 305 // 批 4 起 max 是 per-company 的(company.json.max_parallel_tasks,缺省 1)。
  306 + //
  307 + // 46 号文档 Phase 4:`active`/`max` 从此**不含** decomposed_work 子任务的 run(见
  308 + // `work-graph-db.listActiveRootRunIds` 的改动)——它们走独立坑位池,所以这句「坑满」
  309 + // 早退只能挡普通候选,队里若有 decomposed_work 候选仍要往下走(见候选拆分与下面
  310 + // 第二个循环),不能连试都不试。
256 const max = maxParallelTasks(companyId); 311 const max = maxParallelTasks(companyId);
257 const active = activeRootRunCount(companyId); 312 const active = activeRootRunCount(companyId);
258 const started = []; 313 const started = [];
259 - if (active >= max) return { companyId, skipped: 'no-slot', active, max, started, blocked, requeued };  
260 314
261 // 4. 选候选:queued + 依赖全 completed + auto。 315 // 4. 选候选:queued + 依赖全 completed + auto。
262 // manual **不参与自动出队**(§五.2bis):startRun(manual) 只点亮入口节点就停下等 /next, 316 // manual **不参与自动出队**(§五.2bis):startRun(manual) 只点亮入口节点就停下等 /next,
@@ -281,62 +335,63 @@ function tickInner(companyId) { @@ -281,62 +335,63 @@ function tickInner(companyId) {
281 const candidates = listQueue(companyId) 335 const candidates = listQueue(companyId)
282 .filter((t) => (t.execution_mode === 'auto' || t.attempt > 0) && dependenciesSatisfied(t)); 336 .filter((t) => (t.execution_mode === 'auto' || t.attempt > 0) && dependenciesSatisfied(t));
283 337
  338 + // 46 号文档 Phase 4:`decomposed_work` 子任务的坑位是**独立的一个池**(同父任务上限固定 3,
  339 + // 判据钉死在 `work-graph-db.updateRunStatus` 的准入事务里,不是这里算出来的),完全不占、
  340 + // 也不受这份公司级 `max`/`active` 的约束——所以它们必须分流成单独一条候选队列,
  341 + // 不能套用上面「坑满就整轮别再试」的普通逻辑,否则公司级坑位被普通任务占满时,
  342 + // decomposed_work 子任务会被这句连试都不试就一起卡住,「不受公司级槽位限制」直接落空。
  343 + const decomposedCandidates = candidates.filter((t) => t.subtask_kind === 'decomposed_work');
  344 + const ordinaryCandidates = candidates.filter((t) => t.subtask_kind !== 'decomposed_work');
  345 +
  346 + // 老早退路径原样保留(`skipped:'no-slot'` 有测试直接断言这个形状)——只在**没有**
  347 + // decomposed_work 候选时才整轮跳过,否则会把它们也一起挡住(见上面第 3 步的注释)。
  348 + if (active >= max && !decomposedCandidates.length) {
  349 + return { companyId, skipped: 'no-slot', active, max, started, blocked, requeued };
  350 + }
  351 +
  352 + const dispatchCtx = { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask, blocked };
  353 +
284 // 5. 逐条出队,每条独立 try/catch —— 一条失败绝不能中止整轮(那等于把后面的坑白白空着) 354 // 5. 逐条出队,每条独立 try/catch —— 一条失败绝不能中止整轮(那等于把后面的坑白白空着)
285 - for (const t of candidates) {  
286 - // 本轮已经填满就收工。`active` 是进循环前的快照、`started.length` 是本轮的战果,  
287 - // 两者都只会低估空位(跑着的 run 可能中途收敛让出坑),保守一点即可:  
288 - // 少起的那条由漏斗钩子上的下一次 scheduleTick 接手,而不是在这里赌一个更新的计数。  
289 - if (active + started.length >= max) break;  
290 - const runId = t.current_run_id;  
291 - try {  
292 - // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半,  
293 - // 这条任务永远起不来。标成 blocked(不带 upstream_failed 前缀 → 不会被自动重排)  
294 - // 让人看见,而不是让它继续假装在排队。  
295 - if (!runId || !getRun(runId)) {  
296 - if (blockQueuedTask(t.id, `enqueue_incomplete:missing_run${runId ? `:${runId}` : ''}`)) {  
297 - blocked.push({ taskId: t.id, upstream: null, reason: 'missing_run' }); 355 + if (active < max) {
  356 + for (const t of ordinaryCandidates) {
  357 + // 本轮已经填满就收工。`active` 是进循环前的快照、`started.length` 是本轮的战果,
  358 + // 两者都只会低估空位(跑着的 run 可能中途收敛让出坑),保守一点即可:
  359 + // 少起的那条由漏斗钩子上的下一次 scheduleTick 接手,而不是在这里赌一个更新的计数。
  360 + if (active + started.length >= max) break;
  361 + const runId = t.current_run_id;
  362 + try {
  363 + const result = dispatchOneCandidate(t, dispatchCtx);
  364 + if (result) started.push(result);
  365 + } catch (err) {
  366 + // 坑满:这一刻公司真的没位置了,后面的候选一个都进不去 —— 继续试只是白白多抛几次。
  367 + // 与下面那种「这一条没抢到」区分开:那种要接着试下一条,否则 max>1 时一条被别人
  368 + // 推走就会让剩下的坑空着(正是本轮不 slice 去掉的那类空转)。
  369 + if (err instanceof wdb().RunSlotBusyError) break;
  370 + if (isRaceError(err)) continue; // 这一条没抢到(已被别人从 queued 推走),试下一条
  371 + // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run,
  372 + // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。
  373 + if (runId && (getRun(runId) || {}).run_status === 'ready') {
  374 + abandonRun(runId, `startRun 失败: ${err.message}`);
  375 + } else {
  376 + console.error(`[task-scheduler] 出队 task=${t.id} 失败: ${err.stack || err.message}`);
298 } 377 }
299 - console.error(`[task-scheduler] task=${t.id} 是 queued 但找不到 run(current_run_id=${runId})`);  
300 - continue;  
301 - }  
302 -  
303 - // 5b. 排队期间 org graph 可能被重新规划过:节点行是入队那一刻的快照,定义是读时取最新。  
304 - // 不对齐的话新增节点没有 run 行,出队后永远点不亮,而且全程无报错(§四.3 末)。  
305 - syncRunNodesToDef(runId);  
306 -  
307 - // 5c. 原子出队:`WHERE id=? AND run_status='queued'` 的条件更新,整段(数坑 + 占坑)  
308 - // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。  
309 - // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、  
310 - // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。  
311 - // 两种「没抢到」的形态(批 4 DROP 唯一索引后,兜底那一层没有了):  
312 - // · changes=0 → transitionRun 返回 null(这条 run 已被别人推走)→ 试下一条;  
313 - // · RunSlotBusyError → 主守卫在事务里判出坑满(**不是** changes=0)→ 整轮到此为止。  
314 - // 抛出来的那种落到下面的 catch。  
315 - if (!sse().transitionRun(runId, 'queued', 'ready', {}, {  
316 - summary: `出队:queued → ready(task ${t.id})`,  
317 - task_id: t.id,  
318 - })) continue;  
319 -  
320 - // 5d. 认领 task。抢不到(被取消 / 被别的 tick 抢走 / 已归档)就把 run 收回去,  
321 - // 否则会留下一条 ready 却没有任务的 run 白占着坑。  
322 - if (!claimQueuedTask(t.id, runId)) {  
323 - abandonRun(runId, `task=${t.id} 认领失败(已不是 queued 或 current_run_id 已改指)`);  
324 - continue;  
325 } 378 }
  379 + }
  380 + }
326 381
327 - // 5e. 起跑。mode 恒为 auto(候选已过滤),description 从 task 行读回 ——  
328 - // 排队之后请求闭包早就不在了(§四.7 禁令二说的就是这件事)。  
329 - // startRunSync 内部会回读确认真的转成了 running,没起来会抛,落到下面的 catch。  
330 - engine().startRun(runId, { mode: t.execution_mode, task: t.description });  
331 - started.push({ taskId: t.id, runId, goalId: t.goal_id }); 382 + // decomposed_work 候选:独立坑位池,与上面的 `max`/`active` 完全不占同一个额度,
  383 + // 所以不做「坑满即 break」的预判——每条候选各自去闯 `updateRunStatus` 里那道同父上限 3
  384 + // 的准入事务,抢不到只影响**这一个父任务**,别的候选(含别的父任务的子任务)继续试,
  385 + // 因此 RunSlotBusyError 在这里必须是 `continue` 而不是 `break`(与上面普通候选相反的语义,
  386 + // 混在同一个循环里用同一套 break/continue 判断会两头都错,所以拆成两个循环)。
  387 + for (const t of decomposedCandidates) {
  388 + const runId = t.current_run_id;
  389 + try {
  390 + const result = dispatchOneCandidate(t, dispatchCtx);
  391 + if (result) started.push(result);
332 } catch (err) { 392 } catch (err) {
333 - // 坑满:这一刻公司真的没位置了,后面的候选一个都进不去 —— 继续试只是白白多抛几次。  
334 - // 与下面那种「这一条没抢到」区分开:那种要接着试下一条,否则 max>1 时一条被别人  
335 - // 推走就会让剩下的坑空着(正是本轮 slice 去掉的那类空转)。  
336 - if (err instanceof wdb().RunSlotBusyError) break;  
337 - if (isRaceError(err)) continue; // 这一条没抢到(已被别人从 queued 推走),试下一条  
338 - // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run,  
339 - // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。 393 + if (err instanceof wdb().RunSlotBusyError) continue; // 只是这个父任务的 3 个槽位满了
  394 + if (isRaceError(err)) continue;
340 if (runId && (getRun(runId) || {}).run_status === 'ready') { 395 if (runId && (getRun(runId) || {}).run_status === 'ready') {
341 abandonRun(runId, `startRun 失败: ${err.message}`); 396 abandonRun(runId, `startRun 失败: ${err.message}`);
342 } else { 397 } else {
server/services/task-settle.js
@@ -201,7 +201,50 @@ function settleRootRun(run, to) { @@ -201,7 +201,50 @@ function settleRootRun(run, to) {
201 } 201 }
202 202
203 const task = wdb.getTask(run.task_id); 203 const task = wdb.getTask(run.task_id);
204 - return { task: taskStatus, goal: task && task.goal_id ? settleGoalIfAllTasksDone(task.goal_id) : null }; 204 + let goal = null;
  205 + if (task && task.parent_task_id && task.subtask_kind === 'decomposed_work') {
  206 + // 46 号文档 Phase 4:这条 task 是 `decomposed_work` 子任务——先看同父兄弟是不是全到
  207 + // 终态了,全到了才把父任务结算掉;父任务和它自己共享同一个 goal_id,但父任务在此之前
  208 + // 一直卡在 'decomposed'(非终态,TASK_TERMINAL_STATUS 不含它),goal 级判据天然不会
  209 + // 提前满足。父任务这一刻转终态**不会自动**触发 goal 收敛(`settleDecomposedParentStatus`
  210 + // 是一条裸 UPDATE,不经过 settleTaskFromRun 这条会顺带调 goal 收敛的路),必须在这里
  211 + // 补一次调用——否则子任务和父任务全部做完之后 goal 会永远停在 active,正是本仓库
  212 + // 反复出现过的僵尸 goal 症状(settle 钩子漏触发那一类,见 memory「goal-settle-hook-no-backfill」)。
  213 + const parentStatus = settleParentTaskIfAllChildrenDone(task.parent_task_id);
  214 + if (parentStatus) {
  215 + const parent = wdb.getTask(task.parent_task_id);
  216 + goal = parent && parent.goal_id ? settleGoalIfAllTasksDone(parent.goal_id) : null;
  217 + }
  218 + } else if (task && task.goal_id) {
  219 + goal = settleGoalIfAllTasksDone(task.goal_id);
  220 + }
  221 + return { task: taskStatus, goal };
  222 +}
  223 +
  224 +/**
  225 + * 46 号文档 Phase 4:`decomposed_work` 子任务达终态时调用,检查同一父任务下的兄弟子任务
  226 + * 是不是也全到终态了——是则把父任务结算成 completed/failed。是 `settleGoalIfAllTasksDone`
  227 + * 的父任务版本,判据从 `goal_id` 换成 `parent_task_id` + `subtask_kind='decomposed_work'`。
  228 + *
  229 + * **不做** goal 级那套「≥2 条 task 再跑一次 org-validator 验收」——每条 `decomposed_work`
  230 + * 子任务已经在自己的轻量图里被 `org-validator` 验证过一次(48 号文档),父任务层面
  231 + * 没有、也不需要对应的"父任务级验收"agent,再验一次纯属重复。
  232 + *
  233 + * @returns {string|null} 本次结算成的状态;null = 还没到终态,或结算权被别人抢走 / 已结算过
  234 + */
  235 +function settleParentTaskIfAllChildrenDone(parentTaskId) {
  236 + const wdb = require('./work-graph-db');
  237 + const parent = wdb.getTask(parentTaskId);
  238 + if (!parent) return null;
  239 + const children = wdb.listCompanyTasks(parent.company_id, { parentTaskId, subtaskKind: 'decomposed_work' });
  240 + if (!children.length) return null;
  241 + if (children.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null;
  242 +
  243 + const allCompleted = children.every((t) => t.status === 'completed');
  244 + const status = allCompleted ? 'completed' : 'failed';
  245 + // CAS:多个子任务几乎同时到达终态时会并发调用本函数,只有第一个抢到的做后续副作用
  246 + if (!wdb.settleDecomposedParentStatus(parentTaskId, status)) return null;
  247 + return status;
205 } 248 }
206 249
207 /** 250 /**
@@ -728,6 +771,7 @@ function stopCeoSweeper() { @@ -728,6 +771,7 @@ function stopCeoSweeper() {
728 module.exports = { 771 module.exports = {
729 settleRootRun, 772 settleRootRun,
730 settleGoalIfAllTasksDone, 773 settleGoalIfAllTasksDone,
  774 + settleParentTaskIfAllChildrenDone,
731 resumePendingGoalValidations, 775 resumePendingGoalValidations,
732 sweepStuckActiveGoals, 776 sweepStuckActiveGoals,
733 startZombieGoalSweeper, 777 startZombieGoalSweeper,
server/services/work-graph-db.js
@@ -173,9 +173,16 @@ class RunSlotBusyError extends Error { @@ -173,9 +173,16 @@ class RunSlotBusyError extends Error {
173 * 一句人能看懂的 409(「被 run X 占着」),少了它界面上只剩「坑满」,人无从判断该等谁。 173 * 一句人能看懂的 409(「被 run X 占着」),少了它界面上只剩「坑满」,人无从判断该等谁。
174 * @param {number} max 174 * @param {number} max
175 */ 175 */
176 - constructor(companyId, runId, heldBy, max) { 176 + /**
  177 + * @param {'company'|'decomposed-sibling'} [scope] 46 号文档 Phase 4:`decomposed_work`
  178 + * 子任务走独立的「同父并发上限」坑位,不占公司级的坑——坑满时抛的还是这个类
  179 + * (下游 instanceof 判据不用分叉),只是 scope 不同,调用方(task-scheduler)据此决定
  180 + * 「这轮到此为止」还是「换个候选接着试」。默认 'company',兼容所有既有调用方。
  181 + */
  182 + constructor(companyId, runId, heldBy, max, scope = 'company') {
177 const held = heldBy.length ? heldBy.join(', ') : '(未知)'; 183 const held = heldBy.length ? heldBy.join(', ') : '(未知)';
178 - super(`company ${companyId} 的执行坑已满(${heldBy.length}/${max},占用者 ${held}):` 184 + const what = scope === 'decomposed-sibling' ? '同父子任务的并发坑' : `company ${companyId} 的执行坑`;
  185 + super(`${what}已满(${heldBy.length}/${max},占用者 ${held}):`
179 + `run ${runId} 无法进入活跃集(active-root-run constraint)`); 186 + `run ${runId} 无法进入活跃集(active-root-run constraint)`);
180 this.name = 'RunSlotBusyError'; 187 this.name = 'RunSlotBusyError';
181 this.companyId = companyId; 188 this.companyId = companyId;
@@ -183,6 +190,7 @@ class RunSlotBusyError extends Error { @@ -183,6 +190,7 @@ class RunSlotBusyError extends Error {
183 this.heldBy = heldBy; 190 this.heldBy = heldBy;
184 this.active = heldBy.length; 191 this.active = heldBy.length;
185 this.max = max; 192 this.max = max;
  193 + this.scope = scope;
186 } 194 }
187 } 195 }
188 196
@@ -1542,21 +1550,30 @@ function countActiveRootRuns(companyId, db = getDb()) { @@ -1542,21 +1550,30 @@ function countActiveRootRuns(companyId, db = getDb()) {
1542 * 占着坑的是**谁**。`countActiveRootRuns` 由它派生(`.length`),所以那条 WHERE 全仓库 1550 * 占着坑的是**谁**。`countActiveRootRuns` 由它派生(`.length`),所以那条 WHERE 全仓库
1543 * 仍然只有这一个持有者 —— 为了给 `RunSlotBusyError` 填 heldBy 而另抄一条 SELECT, 1551 * 仍然只有这一个持有者 —— 为了给 `RunSlotBusyError` 填 heldBy 而另抄一条 SELECT,
1544 * 就是给「守卫放行、索引拒绝」那类分叉再开一个口子。活跃集最多几条,取 id 与取 COUNT 同价。 1552 * 就是给「守卫放行、索引拒绝」那类分叉再开一个口子。活跃集最多几条,取 id 与取 COUNT 同价。
  1553 + *
  1554 + * 46 号文档 Phase 4:`decomposed_work` 子任务的 run **不算进公司级这个坑**——它们走
  1555 + * `updateRunStatus` 里单独的「同父并发上限(固定 3)」判据,两个坑位池完全独立,
  1556 + * 不共用这个计数(决策见 46 号文档「同一父任务下的无依赖子任务开并发槽位」)。
1545 */ 1557 */
1546 function listActiveRootRunIds(companyId, db = getDb()) { 1558 function listActiveRootRunIds(companyId, db = getDb()) {
1547 const marks = ACTIVE_ROOT_RUN_STATUS.map(() => '?').join(','); 1559 const marks = ACTIVE_ROOT_RUN_STATUS.map(() => '?').join(',');
1548 return db 1560 return db
1549 .prepare(` 1561 .prepare(`
1550 - SELECT id FROM graph_runs  
1551 - WHERE parent_run_id IS NULL  
1552 - AND run_status IN (${marks})  
1553 - AND company_id = ?  
1554 - ORDER BY created_at 1562 + SELECT gr.id FROM graph_runs gr
  1563 + LEFT JOIN tasks t ON t.id = gr.task_id
  1564 + WHERE gr.parent_run_id IS NULL
  1565 + AND gr.run_status IN (${marks})
  1566 + AND gr.company_id = ?
  1567 + AND (t.subtask_kind IS NULL OR t.subtask_kind != 'decomposed_work')
  1568 + ORDER BY gr.created_at
1555 `) 1569 `)
1556 .all(...ACTIVE_ROOT_RUN_STATUS, companyId) 1570 .all(...ACTIVE_ROOT_RUN_STATUS, companyId)
1557 .map((r) => r.id); 1571 .map((r) => r.id);
1558 } 1572 }
1559 1573
  1574 +/** 46 号文档 Phase 4:同一父任务下 `decomposed_work` 子任务的并发上限,写死不开配置项 */
  1575 +const DECOMPOSED_SIBLING_CONCURRENCY = 3;
  1576 +
1560 /** 1577 /**
1561 * 条件更新 run 状态,语义与 updateNodeStatus 完全一致(同样返回受影响行数供幂等判定)。 1578 * 条件更新 run 状态,语义与 updateNodeStatus 完全一致(同样返回受影响行数供幂等判定)。
1562 * 1579 *
@@ -1612,6 +1629,34 @@ function updateRunStatus(runId, from, to, patch = {}) { @@ -1612,6 +1629,34 @@ function updateRunStatus(runId, from, to, patch = {}) {
1612 && !ACTIVE_ROOT_RUN_STATUS.includes(expected); 1629 && !ACTIVE_ROOT_RUN_STATUS.includes(expected);
1613 if (!admitting) return write(); 1630 if (!admitting) return write();
1614 1631
  1632 + // 46 号文档 Phase 4:`decomposed_work` 子任务的 run 走**独立的**坑位池(同父任务下最多
  1633 + // `DECOMPOSED_SIBLING_CONCURRENCY` 条同时活跃),完全不占、也不受公司级 `max_parallel_tasks`
  1634 + // 约束——两条判据都必须钉死在这唯一的准入事务里(本函数上方注释「6 条路径能推进活跃集,
  1635 + // 枚举一次漏一次」同样适用于这第二个坑位池,不能只在 task-scheduler 一处判断)。
  1636 + // task_id/subtask_kind/parent_task_id 建库后不再变,读它不需要在事务里(同下面 max 那条的道理)。
  1637 + const task = cur.task_id ? getTask(cur.task_id) : null;
  1638 + const isDecomposedWork = Boolean(task && task.subtask_kind === 'decomposed_work' && task.parent_task_id);
  1639 +
  1640 + if (isDecomposedWork) {
  1641 + return db.transaction(() => {
  1642 + const fresh = db.prepare(`SELECT run_status FROM graph_runs WHERE id = ?`).get(runId);
  1643 + if (!fresh || fresh.run_status !== expected) return 0;
  1644 +
  1645 + const marks = ACTIVE_ROOT_RUN_STATUS.map(() => '?').join(',');
  1646 + const heldBy = db.prepare(`
  1647 + SELECT gr.id FROM graph_runs gr
  1648 + JOIN tasks t ON t.id = gr.task_id
  1649 + WHERE t.parent_task_id = ? AND t.subtask_kind = 'decomposed_work'
  1650 + AND gr.run_status IN (${marks})
  1651 + `).all(task.parent_task_id, ...ACTIVE_ROOT_RUN_STATUS).map((r) => r.id);
  1652 +
  1653 + if (heldBy.length >= DECOMPOSED_SIBLING_CONCURRENCY) {
  1654 + throw new RunSlotBusyError(cur.company_id, runId, heldBy, DECOMPOSED_SIBLING_CONCURRENCY, 'decomposed-sibling');
  1655 + }
  1656 + return write();
  1657 + }).immediate();
  1658 + }
  1659 +
1615 // 额度在**进事务之前**解析:它要读一次盘(company.json)+ JSON.parse,塞进 BEGIN IMMEDIATE 1660 // 额度在**进事务之前**解析:它要读一次盘(company.json)+ JSON.parse,塞进 BEGIN IMMEDIATE
1616 // 就是拿着 SQLite 写锁去做 syscall,而且文件坏掉时会在事务里抛。 1661 // 就是拿着 SQLite 写锁去做 syscall,而且文件坏掉时会在事务里抛。
1617 // 这一步不需要在事务里:需要原子的是「数坑 + 占坑」,不是「读配置」—— 1662 // 这一步不需要在事务里:需要原子的是「数坑 + 占坑」,不是「读配置」——
@@ -2194,6 +2239,11 @@ const GRAPH_TAKEOVER_ASSIGNEE = &#39;org-graph&#39;; @@ -2194,6 +2239,11 @@ const GRAPH_TAKEOVER_ASSIGNEE = &#39;org-graph&#39;;
2194 */ 2239 */
2195 function settleTaskFromRun(taskId, runId, status) { 2240 function settleTaskFromRun(taskId, runId, status) {
2196 const now = new Date().toISOString(); 2241 const now = new Date().toISOString();
  2242 + // 46 号文档 Phase 4:'decomposed' 也要排除在外,不只是终态——subtask-planning 把父任务
  2243 + // 标成 'decomposed' 之后会紧接着 stopRun 取消这条 run,取消触发的正是本函数
  2244 + // (run 收敛的唯一回写口),status='cancelled' 会覆盖掉刚打上的 'decomposed'。
  2245 + // 父任务此刻的终态已经交给 settleParentTaskIfAllChildrenDone 全权负责,
  2246 + // 这条 run 的收敛结果对它而言是噪声,不是权威来源。
2197 return getDb() 2247 return getDb()
2198 .prepare(` 2248 .prepare(`
2199 UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now, 2249 UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now,
@@ -2201,6 +2251,7 @@ function settleTaskFromRun(taskId, runId, status) { @@ -2201,6 +2251,7 @@ function settleTaskFromRun(taskId, runId, status) {
2201 WHERE id = @id 2251 WHERE id = @id
2202 AND current_run_id = @runId 2252 AND current_run_id = @runId
2203 AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) 2253 AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')})
  2254 + AND status != 'decomposed'
2204 `) 2255 `)
2205 .run({ id: taskId, runId, status, now }).changes; 2256 .run({ id: taskId, runId, status, now }).changes;
2206 } 2257 }
@@ -2231,6 +2282,85 @@ function settleSubtaskResult(taskId, status, summary = null) { @@ -2231,6 +2282,85 @@ function settleSubtaskResult(taskId, status, summary = null) {
2231 } 2282 }
2232 2283
2233 /** 2284 /**
  2285 + * 46 号文档 Phase 4:父任务被 `subtask-planning` 成功拆分出 `decomposed_work` 子任务后,
  2286 + * 从自己排队中的完整 org graph run 手上把执行权交出去——`status` 转 `'decomposed'`,
  2287 + * 之后终态完全靠 `settleParentTaskIfAllChildrenDone`(task-settle.js)收敛,不再走
  2288 + * `settleTaskFromRun` 那条 run 收敛路径(父 run 本身会被 `stopRun` 取消)。
  2289 + *
  2290 + * ⚠️ **真实回归纠正过一次的设计**:本函数最初写成只允许从 `'queued'` 转,理由是
  2291 + * 「`enqueueTaskRun` 刚落的状态就是 queued」——这个假设在真实回归里当场证伪:
  2292 + * task-scheduler 抢到空闲坑位后几乎是瞬间把任务从 `queued` 推进 `running`(毫秒级),
  2293 + * 而 `subtask-planning` 是一次真实 LLM 调用(数十秒),等它拆完回来时父任务几乎必然
  2294 + * 已经是 `running`,不再是 `queued`——按旧条件 `markTaskDecomposed` 会**几乎每次**
  2295 + * 拿不到锁,而 `stopRun` 依然会把这条正在真实执行的 run 取消掉,后果是父任务卡成
  2296 + * `cancelled`、物化好的子任务全部变成没人认领的孤儿(父任务不是 `decomposed`,
  2297 + * `settleParentTaskIfAllChildrenDone` 永远不会被触发)。这不是小概率竞态,是几乎必现。
  2298 + *
  2299 + * 修法:接管条件放宽成「还没到终态、也还没被接管过」(`NOT IN (终态, 'decomposed')`),
  2300 + * 而不是死抠一个具体的中间状态——`queued`/`running`/`blocked` 都算「父 run 还没有效
  2301 + * 完成,可以被接管」,这才是 46 号文档「拆分决策时机保持异步…父 run 先创建,拆分成功
  2302 + * 后再补一步取消/作废」这句话真正的意思:只要父 run 没有真的跑完,就该让位。
  2303 + * 真正该挡住的只有两种:① 父 run 已经真的到了终态(completed/failed/cancelled,
  2304 + * 赢了竞态,这次拆分来晚了);② 已经被接管过一次(status 已是 'decomposed',防重复)。
  2305 + *
  2306 + * @returns {boolean} true = 成功接管;false = 没抢到(父任务已到终态或已被接管过)
  2307 + */
  2308 +function markTaskDecomposed(taskId) {
  2309 + const now = new Date().toISOString();
  2310 + return getDb()
  2311 + .prepare(`
  2312 + UPDATE tasks SET status = 'decomposed', updated_at = @now
  2313 + WHERE id = @id
  2314 + AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')})
  2315 + AND status != 'decomposed'
  2316 + `)
  2317 + .run({ id: taskId, now }).changes > 0;
  2318 +}
  2319 +
  2320 +/**
  2321 + * 46 号文档 Phase 4:全部 `decomposed_work` 子任务达终态后,把父任务结算成 completed/failed
  2322 + * ——`settleGoalIfAllTasksDone`(task-settle.js)的父任务版本,判据从 `goal_id` 换成
  2323 + * `parent_task_id` + `subtask_kind='decomposed_work'`,调用方是 `settleParentTaskIfAllChildrenDone`。
  2324 + *
  2325 + * 只允许从 `'decomposed'` 转终态:CAS 挡重复结算——多个子任务几乎同时到达终态时,
  2326 + * `settleParentTaskIfAllChildrenDone` 会被并发调用多次,只有第一次真正生效。
  2327 + *
  2328 + * @returns {boolean} true = 这次调用生效;false = 已经结算过(不是 'decomposed' 状态)
  2329 + */
  2330 +function settleDecomposedParentStatus(parentTaskId, status) {
  2331 + if (!['completed', 'failed'].includes(status)) {
  2332 + throw new Error(`settleDecomposedParentStatus: status 只能是 'completed' 或 'failed',收到: ${status}`);
  2333 + }
  2334 + const now = new Date().toISOString();
  2335 + return getDb()
  2336 + .prepare(`
  2337 + UPDATE tasks SET status = @status, finished_at = @now, updated_at = @now
  2338 + WHERE id = @id AND status = 'decomposed'
  2339 + `)
  2340 + .run({ id: parentTaskId, status, now }).changes > 0;
  2341 +}
  2342 +
  2343 +/** 任务级总预算的默认值,逐字同 `server/routes/company.js` 的 `DEFAULT_TASK_BUDGET`——
  2344 + * 两处允许重复:这是一个 5 行的字面量,不值得为它开一个共享模块(§项目一贯的重复容忍度)。*/
  2345 +const DEFAULT_TASK_BUDGET_SHAPE = {
  2346 + total_agent_calls: 100,
  2347 + total_tokens: 5000000,
  2348 + wallclock_ms: 7200000,
  2349 + spent: { agent_calls: 0, tokens: 0 },
  2350 +};
  2351 +
  2352 +/** 逐字同 `company.js` 的 `parseBudget`——一条脏 JSON 不该炸整个物化流程,退 null 让调用方兜底。 */
  2353 +function parseBudgetJson(raw) {
  2354 + if (!raw) return null;
  2355 + try {
  2356 + const v = JSON.parse(raw);
  2357 + return v && typeof v === 'object' ? v : null;
  2358 + } catch {
  2359 + return null;
  2360 + }
  2361 +}
  2362 +
  2363 +/**
2234 * 46 号文档 Phase 3(v2,2026-09-02 改版):把 `subtask-planning` 产出的 `steps[]` 物化成 2364 * 46 号文档 Phase 3(v2,2026-09-02 改版):把 `subtask-planning` 产出的 `steps[]` 物化成
2235 * decomposed_work 子任务。**v1 曾经设计成"认领"(pull,`claimable`/`claimed` 两个专属状态 + 2365 * decomposed_work 子任务。**v1 曾经设计成"认领"(pull,`claimable`/`claimed` 两个专属状态 +
2236 * `claimSubtask`/`unclaimSubtask` 两个 CAS 函数),真实回归后拍板改掉**:认领要解决的是 2366 * `claimSubtask`/`unclaimSubtask` 两个 CAS 函数),真实回归后拍板改掉**:认领要解决的是
@@ -2272,6 +2402,19 @@ function materializeDecomposedSubtasks({ task, steps }) { @@ -2272,6 +2402,19 @@ function materializeDecomposedSubtasks({ task, steps }) {
2272 .get(task.id).n; 2402 .get(task.id).n;
2273 if (already > 0) return []; 2403 if (already > 0) return [];
2274 2404
  2405 + // 46 号文档 Phase 4:父任务的预算按「计划拆出的步数」(steps.length)均分给每条子任务——
  2406 + // 用计划步数而不是「事后实际建库成功的条数」,因为单步校验失败会被下面的 try/catch 丢弃,
  2407 + // 按计划数均分最多是保守(有盈余),不会因为落库顺序/失败条数反过来影响每条子任务分到多少,
  2408 + // 也不用等全部建完才能回填预算。
  2409 + const parentBudget = parseBudgetJson(task.budget_json) || DEFAULT_TASK_BUDGET_SHAPE;
  2410 + const n = steps.length;
  2411 + const splitBudget = {
  2412 + total_agent_calls: Math.max(1, Math.floor(parentBudget.total_agent_calls / n)),
  2413 + total_tokens: Math.max(1, Math.floor(parentBudget.total_tokens / n)),
  2414 + wallclock_ms: Math.max(1, Math.floor(parentBudget.wallclock_ms / n)),
  2415 + spent: { agent_calls: 0, tokens: 0 },
  2416 + };
  2417 +
2275 const seqToId = new Map(); 2418 const seqToId = new Map();
2276 const materialized = []; 2419 const materialized = [];
2277 for (const step of steps) { 2420 for (const step of steps) {
@@ -2287,6 +2430,7 @@ function materializeDecomposedSubtasks({ task, steps }) { @@ -2287,6 +2430,7 @@ function materializeDecomposedSubtasks({ task, steps }) {
2287 parentTaskId: task.id, 2430 parentTaskId: task.id,
2288 subtaskKind: 'decomposed_work', 2431 subtaskKind: 'decomposed_work',
2289 acceptanceContract: step.acceptance, 2432 acceptanceContract: step.acceptance,
  2433 + budgetJson: splitBudget,
2290 }); 2434 });
2291 } catch (e) { 2435 } catch (e) {
2292 console.warn(`[work-graph-db] task ${task.id} 第 ${step.seq} 步物化失败,跳过(不影响同批其它步骤): ${e.message}`); 2436 console.warn(`[work-graph-db] task ${task.id} 第 ${step.seq} 步物化失败,跳过(不影响同批其它步骤): ${e.message}`);
@@ -2325,6 +2469,21 @@ function materializeDecomposedSubtasks({ task, steps }) { @@ -2325,6 +2469,21 @@ function materializeDecomposedSubtasks({ task, steps }) {
2325 * 直接当 `agent_call` 裸调用会不会读不到预期上下文——没有实测过,需要真实回归验证。 2469 * 直接当 `agent_call` 裸调用会不会读不到预期上下文——没有实测过,需要真实回归验证。
2326 * 用户的决定是"先跑轻量版看效果,不行再上完整 org graph"(46 号文档),这就是那次实验。 2470 * 用户的决定是"先跑轻量版看效果,不行再上完整 org graph"(46 号文档),这就是那次实验。
2327 * 2471 *
  2472 + * ── 真实回归暴露的缺口(T-mto6lp1c,2026-09-05):validate 没有 fail 出边 ──────
  2473 + * 上面那段"没有真实验证过的假设"应验了:`validate`(`org-validator`)在这张裸 `agent_call`
  2474 + * 图里没有独立的 `validator-triager`/`benchmark-validator` 子节点可用(那是 `system/
  2475 + * work-graph-templates/validator.json` 定义的、只有 `org-validator` 是 `subgraph` 节点时
  2476 + * 才会展开的一张子图)——它只能在**同一次调用**里把归因也做了,产出即是
  2477 + * `validator-triager/v1` 那份 schema。归因判定 `verdict: "attributed"`(`FAILING_VERDICTS`
  2478 + * 之一,见 `graph-engine.js`)时,这个节点本身就算失败,需要一条 fail 边才有地方去。
  2479 + * 原先这张图只有 `impl --success--> validate` 一条边,`validate` 判失败后
  2480 + * "没有可走的 fail 边",直接落 blocked 等人工——不管失败原因是契约本身不可执行
  2481 + * (`input_underspecified`,回去重新分解/重新实现都可能自愈)还是真的 impl 有 bug,
  2482 + * 一次都翻不了身。补一条 `validate --fail--> impl` 回退边,走的是仓库里所有
  2483 + * 执行型骨架统一的样子(`system/work-graph-templates/executor.json` 的
  2484 + * `{orgNode}-validator --fail--> {orgNode}-impl`),熔断由引擎既有的
  2485 + * `LOOP_HARD_CAP_EDGE`/`LOOP_HARD_CAP_RUN` 兜底,不会死循环。
  2486 + *
2328 * @param {{companyId: string, task: object}} ctx task 是那条 decomposed_work 子任务本身 2487 * @param {{companyId: string, task: object}} ctx task 是那条 decomposed_work 子任务本身
2329 * @returns {string} 新建的 work graph id 2488 * @returns {string} 新建的 work graph id
2330 */ 2489 */
@@ -2338,7 +2497,10 @@ function createDecomposedWorkGraph({ companyId, task }) { @@ -2338,7 +2497,10 @@ function createDecomposedWorkGraph({ companyId, task }) {
2338 { nodeName: 'impl', systemAgentId: 'org-coder', nodeKind: 'agent_call' }, 2497 { nodeName: 'impl', systemAgentId: 'org-coder', nodeKind: 'agent_call' },
2339 { nodeName: 'validate', systemAgentId: 'org-validator', nodeKind: 'agent_call' }, 2498 { nodeName: 'validate', systemAgentId: 'org-validator', nodeKind: 'agent_call' },
2340 ], 2499 ],
2341 - edges: [{ from: 'impl', to: 'validate', condition: 'success' }], 2500 + edges: [
  2501 + { from: 'impl', to: 'validate', condition: 'success' },
  2502 + { from: 'validate', to: 'impl', condition: 'fail', edgeKind: 'backward' },
  2503 + ],
2342 }); 2504 });
2343 if (!g.ok) { 2505 if (!g.ok) {
2344 throw new Error(`createDecomposedWorkGraph: 建图失败: ${JSON.stringify(g.errors)}`); 2506 throw new Error(`createDecomposedWorkGraph: 建图失败: ${JSON.stringify(g.errors)}`);
@@ -2983,6 +3145,10 @@ module.exports = { @@ -2983,6 +3145,10 @@ module.exports = {
2983 // 46 号文档 Phase 3(v2):CEO 派发 decomposed_work 子任务 3145 // 46 号文档 Phase 3(v2):CEO 派发 decomposed_work 子任务
2984 materializeDecomposedSubtasks, 3146 materializeDecomposedSubtasks,
2985 createDecomposedWorkGraph, 3147 createDecomposedWorkGraph,
  3148 + // 46 号文档 Phase 4:父任务让位给子任务执行 + 父子终态汇总
  3149 + markTaskDecomposed,
  3150 + settleDecomposedParentStatus,
  3151 + DECOMPOSED_SIBLING_CONCURRENCY,
2986 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 3152 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」
2987 claimQueuedTask, 3153 claimQueuedTask,
2988 blockQueuedTask, 3154 blockQueuedTask,
server/tests/unit/goal-task-model.test.js
@@ -24,6 +24,7 @@ import { @@ -24,6 +24,7 @@ import {
24 RUN_TERMINAL_TO_TASK_STATUS, 24 RUN_TERMINAL_TO_TASK_STATUS,
25 queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText, 25 queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText,
26 dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, readSlots, 26 dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, readSlots,
  27 + decomposedProgressLabel,
27 } from '../../../web/src/components/graph-console/goal-task-model.js'; 28 } from '../../../web/src/components/graph-console/goal-task-model.js';
28 29
29 const T0 = Date.parse('2026-08-18T10:00:00.000Z'); 30 const T0 = Date.parse('2026-08-18T10:00:00.000Z');
@@ -42,6 +43,10 @@ describe(&#39;状态样式表&#39;, () =&gt; { @@ -42,6 +43,10 @@ describe(&#39;状态样式表&#39;, () =&gt; {
42 expect(taskStyle(undefined).label).toBe('—'); 43 expect(taskStyle(undefined).label).toBe('—');
43 expect(goalStyle('whatever').hollow).toBe(true); 44 expect(goalStyle('whatever').hollow).toBe(true);
44 }); 45 });
  46 +
  47 + it('46 号文档 Phase 4:decomposed 复用 running 同款蓝色 + 脉冲', () => {
  48 + expect(taskStyle('decomposed')).toEqual({ color: '#0069d9', hollow: false, label: '拆分执行中', pulse: true });
  49 + });
45 }); 50 });
46 51
47 describe('墙钟', () => { 52 describe('墙钟', () => {
@@ -86,6 +91,32 @@ describe(&#39;goal 行进度&#39;, () =&gt; { @@ -86,6 +91,32 @@ describe(&#39;goal 行进度&#39;, () =&gt; {
86 }); 91 });
87 }); 92 });
88 93
  94 +describe('46 号文档 Phase 5:decomposed 父任务的子任务进度', () => {
  95 + const parent = { taskId: 'T1', status: 'decomposed' };
  96 +
  97 + it('按 parentTaskId + subtaskKind 过滤兄弟任务,只数 decomposed_work 子任务', () => {
  98 + const siblings = [
  99 + parent,
  100 + { taskId: 'T2', parentTaskId: 'T1', subtaskKind: 'decomposed_work', status: 'completed' },
  101 + { taskId: 'T3', parentTaskId: 'T1', subtaskKind: 'decomposed_work', status: 'running' },
  102 + // 同一个 goal 里的无关任务,不该被数进去
  103 + { taskId: 'T4', parentTaskId: 'T1', subtaskKind: 'acceptance_item', status: 'completed' },
  104 + { taskId: 'T5', status: 'completed' },
  105 + ];
  106 + expect(decomposedProgressLabel(parent, siblings)).toBe('1/2 子任务完成');
  107 + });
  108 +
  109 + it('父任务不是 decomposed 状态时不显示(避免终态之后还挂着这行)', () => {
  110 + const siblings = [{ taskId: 'T2', parentTaskId: 'T1', subtaskKind: 'decomposed_work', status: 'completed' }];
  111 + expect(decomposedProgressLabel({ taskId: 'T1', status: 'completed' }, siblings)).toBe('');
  112 + });
  113 +
  114 + it('没有子任务 / 空输入时不显示', () => {
  115 + expect(decomposedProgressLabel(parent, [])).toBe('');
  116 + expect(decomposedProgressLabel(null, [])).toBe('');
  117 + });
  118 +});
  119 +
89 describe('依赖文案', () => { 120 describe('依赖文案', () => {
90 const sibs = [ 121 const sibs = [
91 { taskId: 'T-1', seq: 1, title: '搭骨架' }, 122 { taskId: 'T-1', seq: 1, title: '搭骨架' },
server/tests/unit/graph-engine.test.js
@@ -1259,4 +1259,78 @@ describe(&#39;graph-engine&#39;, () =&gt; { @@ -1259,4 +1259,78 @@ describe(&#39;graph-engine&#39;, () =&gt; {
1259 expect(wdb.getRun(runId).pause_requested).toBe(1); 1259 expect(wdb.getRun(runId).pause_requested).toBe(1);
1260 }); 1260 });
1261 }); 1261 });
  1262 +
  1263 + // ──────────────────────────────────────────────────────────────
  1264 + // 46 号文档 Phase 4 追加发现:decomposed_work 轻量图的 validate→impl 退回边
  1265 + //
  1266 + // 背景:真实回归撞见 org-validator 对同一种 benchmark-only 契约判断不稳定——4/5 次
  1267 + // 正确按 agent.md 指示自己生成 attempt-n.scores.json 再判,1 次误把源码路径当 --target
  1268 + // 导致 target_not_json、反手把责任推给 subtask-planning。`createDecomposedWorkGraph`
  1269 + // (work-graph-db.js)随后补了 `validate --fail--> impl`(edgeKind:'backward')这条边,
  1270 + // 让这类判断失误能拿到"回退+带着上一次失败原因重试"的机会,而不是永久卡死在 blocked。
  1271 + // 这条边完全复用引擎已有的回退基础设施(本文件前面「回退熔断」系列已经验证过的同一套
  1272 + // 机制:backward_count 双层硬顶 + remediation_note 自动喂给下一轮执行者),不是新代码路径,
  1273 + // 这里只验证 `createDecomposedWorkGraph` 产出的这张具体图确实正确接上了它。
  1274 + // ──────────────────────────────────────────────────────────────
  1275 + describe('46 号文档 Phase 4 追加发现:createDecomposedWorkGraph 的 validate→impl 退回边', () => {
  1276 + function buildDecomposedRun(companyId) {
  1277 + ensureAgents(['org-coder', 'org-validator']);
  1278 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  1279 + const task = wdb.createTask({ goalId: goal.id, companyId, title: 't', description: 'd' });
  1280 + const graphId = wdb.createDecomposedWorkGraph({ companyId, task });
  1281 + const run = wdb.createRun({
  1282 + graphKind: 'work', graphId, companyId, goalId: goal.id, taskId: task.id, executionMode: 'auto',
  1283 + });
  1284 + engine.markRunReady(run.id);
  1285 + return { graphId, runId: run.id };
  1286 + }
  1287 +
  1288 + it('图定义里确实有 validate --fail--> impl 这条 backward 边(不止节点形状,边也要对)', () => {
  1289 + const { graphId } = buildDecomposedRun(nextCompany());
  1290 + const def = wdb.readDefGraph('work', graphId);
  1291 + const back = def.edges.filter((e) => e.edgeKind === 'backward');
  1292 + expect(back).toHaveLength(1);
  1293 + expect(back[0].condition).toBe('fail');
  1294 + const nodeName = (id) => def.nodes.find((n) => n.defNodeId === id).name;
  1295 + expect(nodeName(back[0].from)).toBe('validate');
  1296 + expect(nodeName(back[0].to)).toBe('impl');
  1297 + });
  1298 +
  1299 + it('validate 判 fail 后真的退回 impl 重跑,remediation_note 带给下一轮 impl,第二轮 validate 判过后 run 收敛成 completed', async () => {
  1300 + const companyId = nextCompany();
  1301 + const { runId } = buildDecomposedRun(companyId);
  1302 + const calls = fakeExecutor({
  1303 + impl: { status: 'success' },
  1304 + validate: (nth) => (nth === 1
  1305 + ? { status: 'fail', remediationNote: '把源码当 target 了,请自己生成 scores.json 再判' }
  1306 + : { status: 'success' }),
  1307 + });
  1308 +
  1309 + await engine.startRun(runId, { mode: 'auto' }).driver;
  1310 +
  1311 + expect(wdb.getRun(runId).run_status).toBe('completed');
  1312 + expect(wdb.getRun(runId).backward_count).toBe(1);
  1313 + expect(calls.filter((c) => c.node === 'impl')).toHaveLength(2);
  1314 + expect(calls.filter((c) => c.node === 'validate')).toHaveLength(2);
  1315 + // 反思闭环的关键一环:第二次 impl 执行真的读到了上一轮 validate 的失败原因
  1316 + expect(calls.filter((c) => c.node === 'impl' && c.remediationNote
  1317 + === '把源码当 target 了,请自己生成 scores.json 再判')).toHaveLength(1);
  1318 + });
  1319 +
  1320 + it('validate 连续判 fail 撞双层硬顶时收敛成 blocked,不会无限重试(复用引擎已有的回退熔断,不是新安全网)', async () => {
  1321 + const companyId = nextCompany();
  1322 + const { runId } = buildDecomposedRun(companyId);
  1323 + const calls = fakeExecutor({
  1324 + impl: { status: 'success' },
  1325 + validate: { status: 'fail', remediationNote: '一直判不过' },
  1326 + });
  1327 +
  1328 + await engine.startRun(runId, { mode: 'auto' }).driver;
  1329 +
  1330 + expect(wdb.getRun(runId).run_status).toBe('blocked');
  1331 + // 单边硬顶:同一条 backward 边第 4 次不再调用 agent,直接 force_terminated
  1332 + expect(calls.filter((c) => c.node === 'validate')).toHaveLength(4);
  1333 + expect(calls.filter((c) => c.node === 'impl')).toHaveLength(4);
  1334 + });
  1335 + });
1262 }); 1336 });
server/tests/unit/subtask-planning-materialize.test.js
@@ -225,6 +225,136 @@ describe(&#39;46 å·æ–‡æ¡£ Phase 1:subtask-plan.json 共享区è½åœ° + ä»»åŠ¡çº§å… @@ -225,6 +225,136 @@ describe(&#39;46 å·æ–‡æ¡£ Phase 1:subtask-plan.json 共享区è½åœ° + 任务级å…
225 expect(content.experience_refs).toEqual([]); 225 expect(content.experience_refs).toEqual([]);
226 expect(content.research_used).toBe(false); 226 expect(content.research_used).toBe(false);
227 }); 227 });
  228 +
  229 + // ── 46 å·æ–‡æ¡£ Phase 4:拆分æˆåŠŸåŽæŽ¥ç®¡çˆ¶ä»»åŠ¡çš„æ‰§è¡Œæƒ â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
  230 +
  231 + describe('Phase 4:拆分æˆåŠŸåŽå–消父 run + markTaskDecomposed', () => {
  232 + const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] };
  233 +
  234 + it('materialize 出 ≥1 æ¡å­ä»»åŠ¡åŽï¼Œçˆ¶ run 被 stopRun å–æ¶ˆï¼Œçˆ¶ä»»åŠ¡è½¬ decomposed', async () => {
  235 + const companyId = 'co-subplan-p4-1';
  236 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  237 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  238 + const run = createRunRow({
  239 + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
  240 + });
  241 + wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id });
  242 +
  243 + subtaskPlanning.setPlanSubtasks(async () => ({
  244 + steps: [
  245 + { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  246 + { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  247 + ],
  248 + tokens: 10,
  249 + }));
  250 + try {
  251 + await subtaskPlanning.triggerSubtaskPlanning({ run, task });
  252 + } finally {
  253 + subtaskPlanning.setPlanSubtasks(null);
  254 + }
  255 +
  256 + expect(wdb.getRun(run.id).run_status).toBe('stopped');
  257 + expect(wdb.getTask(task.id).status).toBe('decomposed');
  258 +
  259 + const children = wdb.getDb()
  260 + .prepare(`SELECT id FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`)
  261 + .all(task.id);
  262 + expect(children).toHaveLength(2);
  263 + });
  264 +
  265 + // 真实回归当场撞è§çš„场景:task-scheduler 抢å‘几乎是瞬间的,subtask-planning 是真实
  266 + // LLM 调用(数åç§’ï¼‰ï¼Œç­‰å®ƒæ‹†å®Œå›žæ¥æ—¶çˆ¶ä»»åŠ¡/父 run å‡ ä¹Žå¿…ç„¶å·²ç»æ˜¯ running äº†ï¼Œä¸æ˜¯
  267 + // queued——这是最常è§çš„è·¯å¾„ï¼Œä¸æ˜¯è¾¹ç¼˜æƒ…形,第一版实现(åªè®¤ queued)在这里当场翻车:
  268 + // markTaskDecomposed æ‹¿ä¸åˆ°é”,但 stopRun ä¾ç„¶æŠŠçœŸå®žåœ¨è·‘çš„ run å–æ¶ˆæŽ‰ï¼Œçˆ¶ä»»åС塿ˆ
  269 + // cancelledã€5 个å­ä»»åŠ¡å˜æˆæ²¡äººè®¤é¢†çš„孤儿。修å¤åŽå¿…须在这个场景下正确接管。
  270 + it('父 run å·²ç»çœŸå®žè·‘到 running(最常è§çš„æ—¶åºï¼‰ï¼šä¾ç„¶èƒ½æ­£ç¡®æŽ¥ç®¡ï¼Œä¸ä¼šå¡æˆ cancelled', async () => {
  271 + const companyId = 'co-subplan-p4-4';
  272 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  273 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  274 + const run = createRunRow({
  275 + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
  276 + });
  277 + // 模拟 task-scheduler å·²ç»æŠŠå®ƒæŽ¨è¿›äº†æ´»è·ƒé›†ï¼ˆqueued → ready → running)
  278 + wdb.updateRunStatus(run.id, 'queued', 'ready');
  279 + wdb.updateRunStatus(run.id, 'ready', 'running');
  280 + wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: new Date().toISOString() });
  281 +
  282 + subtaskPlanning.setPlanSubtasks(async () => ({
  283 + steps: [
  284 + { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  285 + { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  286 + ],
  287 + tokens: 10,
  288 + }));
  289 + try {
  290 + await subtaskPlanning.triggerSubtaskPlanning({ run: wdb.getRun(run.id), task });
  291 + } finally {
  292 + subtaskPlanning.setPlanSubtasks(null);
  293 + }
  294 +
  295 + // å…³é”®æ–­è¨€ï¼šä¸æ˜¯ cancelled(旧 bug 的症状),是 decomposed
  296 + expect(wdb.getTask(task.id).status).toBe('decomposed');
  297 + expect(wdb.getRun(run.id).run_status).toBe('stopped');
  298 +
  299 + const children = wdb.getDb()
  300 + .prepare(`SELECT id FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`)
  301 + .all(task.id);
  302 + expect(children).toHaveLength(2);
  303 + });
  304 +
  305 + it('拆分产出 0 æ¡æœ‰æ•ˆå­ä»»åŠ¡æ—¶ï¼ˆå…¨éƒ¨ç¼º acceptanceï¼‰ä¸æŽ¥ç®¡ï¼šçˆ¶ run/父任务原样ä¸åЍ', async () => {
  306 + const companyId = 'co-subplan-p4-2';
  307 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  308 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  309 + const run = createRunRow({
  310 + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
  311 + });
  312 + wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id });
  313 +
  314 + // 缺 acceptance → materializeDecomposedSubtasks 里 createTask ä¼šæ‹’ï¼Œå•æ­¥è·³è¿‡ï¼Œæ•´æ‰¹ä¸ºç©º
  315 + subtaskPlanning.setPlanSubtasks(async () => ({
  316 + steps: [{ seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }],
  317 + tokens: 5,
  318 + }));
  319 + try {
  320 + await subtaskPlanning.triggerSubtaskPlanning({ run, task });
  321 + } finally {
  322 + subtaskPlanning.setPlanSubtasks(null);
  323 + }
  324 +
  325 + expect(wdb.getRun(run.id).run_status).toBe('queued'); // æ²¡è¢«å–æ¶ˆ
  326 + expect(wdb.getTask(task.id).status).toBe('queued'); // 没被接管
  327 + });
  328 +
  329 + it('父任务在拆分æˆåŠŸä¹‹å‰å°±å·²ç»èµ¢äº†ç«žæ€è·‘完:ä¸è¦†ç›–已有的终æ€', async () => {
  330 + const companyId = 'co-subplan-p4-3';
  331 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  332 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  333 + const run = createRunRow({
  334 + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
  335 + });
  336 + // 模拟「父 run 抢跑赢了竞æ€ã€ï¼šrun 与 task 都已ç»è½åˆ°ç»ˆæ€
  337 + wdb.updateRunStatus(run.id, 'queued', 'ready');
  338 + wdb.updateRunStatus(run.id, 'ready', 'running');
  339 + wdb.updateRunStatus(run.id, 'running', 'completed');
  340 + wdb.updateTask(task.id, { status: 'completed', currentRunId: run.id });
  341 +
  342 + subtaskPlanning.setPlanSubtasks(async () => ({
  343 + steps: [{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }],
  344 + tokens: 3,
  345 + }));
  346 + try {
  347 + await subtaskPlanning.triggerSubtaskPlanning({ run, task });
  348 + } finally {
  349 + subtaskPlanning.setPlanSubtasks(null);
  350 + }
  351 +
  352 + // stopRun å¯¹å·²ç»ˆæ€ run 是 no-opï¼›markTaskDecomposed çš„ CAS(WHERE status='queued')
  353 + // æ‹¿ä¸åˆ°é”â€”â€”çˆ¶ä»»åŠ¡ä¿æŒå®ƒå·²ç»èµ¢ä¸‹çš„ completed,ä¸ä¼šè¢«æ‹†åˆ†ç»“果覆盖
  354 + expect(wdb.getRun(run.id).run_status).toBe('completed');
  355 + expect(wdb.getTask(task.id).status).toBe('completed');
  356 + });
  357 + });
228 }); 358 });
229 359
230 describe('46 å·æ–‡æ¡£ Phase 2:defaultPlanSubtasks 编排(ç»éªŒæ£€ç´¢ + 按需 deep research)', () => { 360 describe('46 å·æ–‡æ¡£ Phase 2:defaultPlanSubtasks 编排(ç»éªŒæ£€ç´¢ + 按需 deep research)', () => {
server/tests/unit/task-decomposition-phase4.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — 46 号文档 Phase 4:预算均分 + 父子终态汇总 + 同父子任务独立并发坑位
  3 + *
  4 + * 覆盖三块新机制:
  5 + * 1. `materializeDecomposedSubtasks` 把父任务预算按步数均分给每条子任务
  6 + * 2. `markTaskDecomposed` / `settleDecomposedParentStatus` 的 CAS 语义
  7 + * 3. `updateRunStatus` 里新增的「同父 decomposed_work 子任务最多 3 并行」坑位,
  8 + * 与公司级 `max_parallel_tasks` 完全独立
  9 + * 4. `task-settle.settleParentTaskIfAllChildrenDone`(父子终态汇总)
  10 + *
  11 + * 隔离手法同 `task-decomposition-dispatch.test.js`:先设 GRAPH_DB_PATH 再 require。
  12 + */
  13 +import {
  14 + describe, it, expect, beforeAll, afterAll, beforeEach,
  15 +} from 'vitest';
  16 +import fs from 'fs';
  17 +
  18 +const TEST_DB_PATH = '/tmp/test-task-decomp-phase4.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/org-graph-service',
  23 + '../../services/work-graph-db',
  24 + '../../services/graph-run-sse',
  25 + '../../services/task-settle',
  26 +];
  27 +
  28 +const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] };
  29 +
  30 +describe('46 号文档 Phase 4', () => {
  31 + let wdb;
  32 + let settle;
  33 +
  34 + beforeAll(() => {
  35 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  36 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  37 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  38 + wdb = require('../../services/work-graph-db');
  39 + settle = require('../../services/task-settle');
  40 + });
  41 +
  42 + afterAll(() => {
  43 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  44 + delete process.env.GRAPH_DB_PATH;
  45 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  46 + });
  47 +
  48 + beforeEach(() => {
  49 + const db = wdb.getDb();
  50 + db.pragma('foreign_keys = OFF');
  51 + db.exec(`
  52 + DELETE FROM graph_run_nodes;
  53 + DELETE FROM graph_runs;
  54 + DELETE FROM tasks;
  55 + DELETE FROM goals;
  56 + DELETE FROM system_agents;
  57 + `);
  58 + db.pragma('foreign_keys = ON');
  59 + });
  60 +
  61 + let seq = 0;
  62 + function nextCompany() { return `p4-co-${++seq}`; }
  63 +
  64 + // ── 1. 预算均分 ──────────────────────────────────────────────────────
  65 +
  66 + describe('materializeDecomposedSubtasks:预算均分', () => {
  67 + it('父任务有 budget_json 时,按步数均分给每条子任务', () => {
  68 + const companyId = nextCompany();
  69 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  70 + const parent = wdb.createTask({
  71 + goalId: goal.id, title: 'p', description: 'd',
  72 + budgetJson: { total_agent_calls: 100, total_tokens: 5000000, wallclock_ms: 7200000 },
  73 + });
  74 + const steps = [
  75 + { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  76 + { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  77 + { seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  78 + { seq: 4, title: '第四步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  79 + ];
  80 + const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps });
  81 + expect(ids).toHaveLength(4);
  82 + for (const id of ids) {
  83 + const budget = JSON.parse(wdb.getTask(id).budget_json);
  84 + expect(budget.total_agent_calls).toBe(25); // 100 / 4
  85 + expect(budget.total_tokens).toBe(1250000); // 5000000 / 4
  86 + expect(budget.wallclock_ms).toBe(1800000); // 7200000 / 4
  87 + expect(budget.spent).toEqual({ agent_calls: 0, tokens: 0 });
  88 + }
  89 + });
  90 +
  91 + it('父任务没有 budget_json 时,退到 DEFAULT_TASK_BUDGET 的形状再均分', () => {
  92 + const companyId = nextCompany();
  93 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  94 + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
  95 + const steps = [
  96 + { seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  97 + { seq: 2, title: '二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  98 + ];
  99 + const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps });
  100 + expect(ids).toHaveLength(2);
  101 + const budget = JSON.parse(wdb.getTask(ids[0]).budget_json);
  102 + expect(budget.total_agent_calls).toBe(50); // 100(默认)/ 2
  103 + });
  104 +
  105 + it('均分结果保底至少 1,不会因为步数多而算出 0', () => {
  106 + const companyId = nextCompany();
  107 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  108 + const parent = wdb.createTask({
  109 + goalId: goal.id, title: 'p', description: 'd',
  110 + budgetJson: { total_agent_calls: 1, total_tokens: 1, wallclock_ms: 1 },
  111 + });
  112 + const steps = [
  113 + { seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  114 + { seq: 2, title: '二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  115 + { seq: 3, title: '三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
  116 + ];
  117 + const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps });
  118 + for (const id of ids) {
  119 + const budget = JSON.parse(wdb.getTask(id).budget_json);
  120 + expect(budget.total_agent_calls).toBeGreaterThanOrEqual(1);
  121 + expect(budget.total_tokens).toBeGreaterThanOrEqual(1);
  122 + expect(budget.wallclock_ms).toBeGreaterThanOrEqual(1);
  123 + }
  124 + });
  125 + });
  126 +
  127 + // ── 2. markTaskDecomposed / settleDecomposedParentStatus ────────────
  128 +
  129 + describe('markTaskDecomposed / settleDecomposedParentStatus:CAS 语义', () => {
  130 + it('从 queued 转:成功后 status 变 decomposed', () => {
  131 + const companyId = nextCompany();
  132 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  133 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  134 + wdb.updateTask(task.id, { status: 'queued' });
  135 +
  136 + expect(wdb.markTaskDecomposed(task.id)).toBe(true);
  137 + expect(wdb.getTask(task.id).status).toBe('decomposed');
  138 +
  139 + // 已经是 decomposed 了,重复调用拿不到锁
  140 + expect(wdb.markTaskDecomposed(task.id)).toBe(false);
  141 + });
  142 +
  143 + // 真实回归纠正过的行为:task-scheduler 抢坑几乎是瞬间的(毫秒级),而 subtask-planning
  144 + // 是真实 LLM 调用(数十秒),等它拆完回来时父任务几乎必然已经是 running 而不是 queued
  145 + // 了——这不是小概率分支,是最常见的那条路径,必须能从 running 接管,否则拆分结果
  146 + // 几乎每次都接管失败、白白物化出一堆没人认领的孤儿子任务(真实回归当场撞见过一次)。
  147 + it('从 running 转:真实场景里最常见的路径,也能成功接管', () => {
  148 + const companyId = nextCompany();
  149 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  150 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  151 + wdb.updateTask(task.id, { status: 'running' });
  152 +
  153 + expect(wdb.markTaskDecomposed(task.id)).toBe(true);
  154 + expect(wdb.getTask(task.id).status).toBe('decomposed');
  155 + });
  156 +
  157 + it('从 blocked 转:同样还没到终态,可以接管', () => {
  158 + const companyId = nextCompany();
  159 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  160 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  161 + wdb.updateTask(task.id, { status: 'blocked' });
  162 +
  163 + expect(wdb.markTaskDecomposed(task.id)).toBe(true);
  164 + expect(wdb.getTask(task.id).status).toBe('decomposed');
  165 + });
  166 +
  167 + it.each(['completed', 'failed', 'cancelled'])(
  168 + '父任务已经是终态(%s)时天然拿不到锁(真赢了竞态的情形,不覆盖)',
  169 + (terminalStatus) => {
  170 + const companyId = nextCompany();
  171 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  172 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  173 + wdb.updateTask(task.id, { status: terminalStatus }); // 模拟父 run 已经先跑完/失败/取消了
  174 + expect(wdb.markTaskDecomposed(task.id)).toBe(false);
  175 + expect(wdb.getTask(task.id).status).toBe(terminalStatus); // 没被覆盖
  176 + },
  177 + );
  178 +
  179 + it('settleDecomposedParentStatus 只能从 decomposed 转终态,CAS 挡重复结算', () => {
  180 + const companyId = nextCompany();
  181 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  182 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  183 + wdb.updateTask(task.id, { status: 'decomposed' });
  184 +
  185 + expect(wdb.settleDecomposedParentStatus(task.id, 'completed')).toBe(true);
  186 + expect(wdb.getTask(task.id).status).toBe('completed');
  187 + expect(wdb.getTask(task.id).finished_at).toBeTruthy();
  188 +
  189 + // 已经结算过,第二次调用(哪怕传别的状态)拿不到锁,不会覆盖
  190 + expect(wdb.settleDecomposedParentStatus(task.id, 'failed')).toBe(false);
  191 + expect(wdb.getTask(task.id).status).toBe('completed');
  192 + });
  193 +
  194 + it('settleDecomposedParentStatus 只接受 completed/failed,别的值直接抛', () => {
  195 + expect(() => wdb.settleDecomposedParentStatus('whatever-id', 'blocked')).toThrow(/只能是/);
  196 + });
  197 + });
  198 +
  199 + // ── 3. 同父 decomposed_work 子任务的独立并发坑位(上限 3) ──────────
  200 +
  201 + describe('updateRunStatus:同父 decomposed_work 子任务并发上限 3', () => {
  202 + function makeChild(companyId, parentId, i) {
  203 + return wdb.createTask({
  204 + goalId: wdb.getTask(parentId).goal_id, companyId, title: `子任务 ${i}`, description: 'd',
  205 + parentTaskId: parentId, subtaskKind: 'decomposed_work', acceptanceContract: MIN_ACCEPTANCE,
  206 + });
  207 + }
  208 +
  209 + it('前 3 个同父子任务的 run 都能进 ready,第 4 个撞 RunSlotBusyError(scope=decomposed-sibling)', () => {
  210 + const companyId = nextCompany();
  211 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  212 + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
  213 +
  214 + const runs = [];
  215 + for (let i = 1; i <= 4; i += 1) {
  216 + const child = makeChild(companyId, parent.id, i);
  217 + const run = wdb.createRun({
  218 + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
  219 + });
  220 + runs.push(wdb.getRun(run.id));
  221 + }
  222 +
  223 + for (let i = 0; i < 3; i += 1) {
  224 + expect(wdb.updateRunStatus(runs[i].id, 'queued', 'ready')).toBe(1);
  225 + }
  226 +
  227 + let caught = null;
  228 + try {
  229 + wdb.updateRunStatus(runs[3].id, 'queued', 'ready');
  230 + } catch (e) {
  231 + caught = e;
  232 + }
  233 + expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
  234 + expect(caught.scope).toBe('decomposed-sibling');
  235 + expect(caught.max).toBe(wdb.DECOMPOSED_SIBLING_CONCURRENCY);
  236 + expect(caught.heldBy).toHaveLength(3);
  237 +
  238 + // 第 4 条依旧是 queued(没被误占)
  239 + expect(wdb.getRun(runs[3].id).run_status).toBe('queued');
  240 + });
  241 +
  242 + it('decomposed_work 子任务的 run 不计入公司级 countActiveRootRuns', () => {
  243 + const companyId = nextCompany();
  244 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  245 + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
  246 + const child = makeChild(companyId, parent.id, 1);
  247 + const run = wdb.createRun({
  248 + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
  249 + });
  250 + wdb.updateRunStatus(run.id, 'queued', 'ready');
  251 + expect(wdb.countActiveRootRuns(companyId)).toBe(0);
  252 + });
  253 +
  254 + it('公司级坑位(默认 1)被普通任务占满时,decomposed_work 子任务仍能独立进坑(互不占用)', () => {
  255 + const companyId = nextCompany();
  256 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  257 +
  258 + // 一条普通任务占满公司级坑位(默认 max_parallel_tasks=1)
  259 + const ordinary = wdb.createTask({ goalId: goal.id, companyId, title: 'o', description: 'd' });
  260 + const ordinaryRun = wdb.createRun({
  261 + graphKind: 'org', graphId: companyId, companyId, taskId: ordinary.id, runStatus: 'queued',
  262 + });
  263 + expect(wdb.updateRunStatus(ordinaryRun.id, 'queued', 'ready')).toBe(1);
  264 + expect(wdb.countActiveRootRuns(companyId)).toBe(1);
  265 +
  266 + // decomposed_work 子任务照样能进坑——不共用这个池子
  267 + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
  268 + const child = makeChild(companyId, parent.id, 1);
  269 + const childRun = wdb.createRun({
  270 + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
  271 + });
  272 + expect(wdb.updateRunStatus(childRun.id, 'queued', 'ready')).toBe(1);
  273 + });
  274 +
  275 + it('不同父任务的 decomposed_work 子任务各自独立计数,互不占用彼此的 3 个坑位', () => {
  276 + const companyId = nextCompany();
  277 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  278 + const parentA = wdb.createTask({ goalId: goal.id, companyId, title: 'pa', description: 'd' });
  279 + const parentB = wdb.createTask({ goalId: goal.id, companyId, title: 'pb', description: 'd' });
  280 +
  281 + // parentA 占满 3 个坑位
  282 + for (let i = 1; i <= 3; i += 1) {
  283 + const child = makeChild(companyId, parentA.id, i);
  284 + const run = wdb.createRun({
  285 + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
  286 + });
  287 + expect(wdb.updateRunStatus(run.id, 'queued', 'ready')).toBe(1);
  288 + }
  289 +
  290 + // parentB 的第一个子任务不受影响
  291 + const childB = makeChild(companyId, parentB.id, 1);
  292 + const runB = wdb.createRun({
  293 + graphKind: 'work', graphId: 'placeholder', companyId, taskId: childB.id, runStatus: 'queued',
  294 + });
  295 + expect(wdb.updateRunStatus(runB.id, 'queued', 'ready')).toBe(1);
  296 + });
  297 + });
  298 +
  299 + // ── 4. settleParentTaskIfAllChildrenDone(父子终态汇总) ────────────
  300 +
  301 + describe('task-settle.settleParentTaskIfAllChildrenDone', () => {
  302 + function setupParentWithChildren(statuses) {
  303 + const companyId = nextCompany();
  304 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  305 + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
  306 + wdb.updateTask(parent.id, { status: 'decomposed' });
  307 + const children = statuses.map((status, i) => {
  308 + const c = wdb.createTask({
  309 + goalId: goal.id, companyId, title: `c${i}`, description: 'd',
  310 + parentTaskId: parent.id, subtaskKind: 'decomposed_work', acceptanceContract: MIN_ACCEPTANCE,
  311 + });
  312 + wdb.updateTask(c.id, { status });
  313 + return c;
  314 + });
  315 + return { companyId, goal, parent, children };
  316 + }
  317 +
  318 + it('还有子任务未到终态时返回 null,父任务保持 decomposed', () => {
  319 + const { parent } = setupParentWithChildren(['completed', 'running']);
  320 + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull();
  321 + expect(wdb.getTask(parent.id).status).toBe('decomposed');
  322 + });
  323 +
  324 + it('全部子任务 completed → 父任务结算成 completed', async () => {
  325 + const { parent, goal } = setupParentWithChildren(['completed', 'completed']);
  326 + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('completed');
  327 + expect(wdb.getTask(parent.id).status).toBe('completed');
  328 + // goal 收敛的 CAS 占位值也应该已经写下去(不再是 active)——见函数注释里
  329 + // "父任务转终态不会自动触发 goal 收敛,必须补一次调用" 那段
  330 + expect(wdb.getGoal(goal.id).status).not.toBe('active');
  331 + // 让可能的异步验收(若被触发)跑完,避免测试进程留下悬空的 unhandled work
  332 + await new Promise((r) => setImmediate(r));
  333 + });
  334 +
  335 + it('有子任务 failed → 父任务结算成 failed', async () => {
  336 + const { parent } = setupParentWithChildren(['completed', 'failed']);
  337 + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('failed');
  338 + expect(wdb.getTask(parent.id).status).toBe('failed');
  339 + await new Promise((r) => setImmediate(r));
  340 + });
  341 +
  342 + it('没有子任务时返回 null', () => {
  343 + const companyId = nextCompany();
  344 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  345 + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
  346 + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull();
  347 + });
  348 +
  349 + it('并发调用只有一次生效(CAS):第二次调用返回 null', async () => {
  350 + const { parent } = setupParentWithChildren(['completed']);
  351 + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('completed');
  352 + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull();
  353 + await new Promise((r) => setImmediate(r));
  354 + });
  355 + });
  356 +});
system/agents/ceo/heartbeat.md
@@ -21,8 +21,11 @@ @@ -21,8 +21,11 @@
21 `draft` 的子任务全部 `enqueue` 是安全的,先后顺序、并发几个都是调度器的活,不是你的。 21 `draft` 的子任务全部 `enqueue` 是安全的,先后顺序、并发几个都是调度器的活,不是你的。
22 - 派一条:`POST /tasks/<taskId>/enqueue`(跟你派自己提的草稿任务是同一个端点,同一条权限)。 22 - 派一条:`POST /tasks/<taskId>/enqueue`(跟你派自己提的草稿任务是同一个端点,同一条权限)。
23 - 这批子任务会走一张**轻量图**(只有实现+验证两步,不是公司完整的组织架构那套), 23 - 这批子任务会走一张**轻量图**(只有实现+验证两步,不是公司完整的组织架构那套),
24 - 成本比一条顶层 task 低很多,但目前还没有正式跑过真实验证——如果发现同一个 task 下的 24 + 成本比一条顶层 task 低很多,已经做过真实端到端回归(48 号文档)——如果发现同一个 task 下的
25 子任务频繁失败或者结果质量不行,在回复里如实说,不要自己想办法绕过去。 25 子任务频繁失败或者结果质量不行,在回复里如实说,不要自己想办法绕过去。
  26 +- **父任务会变成 `decomposed` 状态**:一条 task 一旦被 `subtask-planning` 成功拆出子任务,
  27 + 它自己那条完整组织架构流程会被取消,状态显示 `decomposed`,正常现象,不是卡住/异常,
  28 + 终态会在全部子任务做完后自动汇总回父任务,**不需要你手动做任何事**。
26 29
27 ## 发现问题时 30 ## 发现问题时
28 31
system/agents/org-validator/agent.md
@@ -55,6 +55,53 @@ validator-triager → benchmark-validator → testdata-validator → … @@ -55,6 +55,53 @@ validator-triager → benchmark-validator → testdata-validator → …
55 55
56 --- 56 ---
57 57
  58 +## ⚠️ 一种你可能被裸调用的情况:没有 subgraph,只有你自己
  59 +
  60 +上面整节"你自己的 work graph"说的是**公司完整 org graph**里的你——那里你是 `subgraph` 节点,
  61 +`validator-triager → benchmark-validator → …` 是引擎真的展开出来的独立子节点,各自单独调用。
  62 +
  63 +**但 `subtask-planning` 拆出的 `decomposed_work` 子任务不走完整 org graph**(48 号文档),
  64 +起的是一张只有两个节点的裸图:`impl(org-coder) --success--> validate(org-validator)`,
  65 +**你在这张图里是 `agent_call`,不是 `subgraph`**——没有人会替你另起一个 `validator-triager`
  66 +调用,`benchmark-validator`/`testdata-validator` 更是压根不存在。判断"这次调用有没有那张
  67 +子图"看提示词里给的节点类型/上下文,拿不准就看契约来源:`acceptance.json` 的
  68 +`created_by` 是 `subtask-planning` 时,十有八九是这种裸调用场景。
  69 +
  70 +**这种时候,`validator-triager` + `benchmark-validator` 这几个角色的活,都要你一个人在同一次
  71 +调用里做完**——不是"跳过不做",也不是"自己认定过了就完事":
  72 +
  73 +1. **仍然先归因(扮演 validator-triager 的职责)**:提示词里如果带着上一轮的
  74 + `remediation_note`/失败上下文,先判"这次退回来是谁的锅";没有上一轮上下文就是首轮,
  75 + 等价于 `no_prior_failure`,直接进入第 2 步。
  76 +2. **`benchmark` 段仍然必须走执行器判、不许绕过(扮演 benchmark-validator 的职责)**:
  77 + 契约里的 `benchmark` 段要求 `--target` 是一份自报分数 JSON(`{"scores": {...}}`),
  78 + 但这张裸图里**没有任何节点会替你产出这份文件**——`org-coder` 不产出、也不该产出
  79 + (标准和打分本该分离,见 `benchmark-validator/agent.md`"为什么要这么切")。
  80 + 正确做法:**你自己**按 `system/agents/benchmark-validator/agent.md` Step 2 的方法
  81 + (逐维度找证据、对照 `pass_threshold` 给分,找不到证据给 0 分,不许因为"看起来做了不少"
  82 + 就估分),把分数写成 `attempt-<n>.scores.json` 落到你自己的节点目录,
  83 + 再跑 `node server/services/goal-contract.js run --target <这份文件> --run-id <本次 run_id>`
  84 + 拿到**真正由执行器算出来**的 `verdict_id`。
  85 + 🚫 **不要把 `impl` 交付物本身(代码文件/项目目录)当 `--target` 喂给执行器**——
  86 + 那必然读不出 JSON,只会拿到 `target_not_json`,这正是本次两条任务链卡死的直接原因
  87 + (T-mto6lp1c,2026-09-05:两次都是把源码路径/项目根目录当 target,退出码 2)。
  88 + 🚫 **也不要因为"暂时跑不出判决"就跳过执行器、自己写个 `audit_verdict: pass` 收尾**——
  89 + Step 2 的红线("契约不全时绝不许自己发明标准再判 pass")在这里同样成立:
  90 + 你可以自己**供证据/供分数**,但让分数达不达标这件事必须仍然经过 `goal-contract.js`
  91 + 算出来,不能自由心证。
  92 +3. **契约本身真的不可执行时(`validate` 退出 1/2,且换哪个证据来源都补不出分数)**,
  93 + 才归为 `input_underspecified`:这时你没有 `org-planner` 可退,只有 `impl` 一个上游节点——
  94 + `fail_reason: "input_underspecified"` 正常写,`action: "backward"`,**不用**填
  95 + `target_node`/`target_org_node`(这张裸图里只有你和 `impl` 两个节点,没有更细的粒度可指),
  96 + 引擎会退回 `impl` 重跑一轮(`createDecomposedWorkGraph` 已经补了这条 `validate --fail--> impl`
  97 + 回退边)。`remediation_note` 照常写清楚缺什么、下一轮谁该做什么。
  98 +
  99 +一句话概括:**裸调用时你不比平时少做事,只是把平时分给几个子节点的活攒在一次调用里做**——
  100 +归因照做、按契约取证据打分照做、走执行器拿真判决照做,唯独不产出 `target_node` 这类跨图地址
  101 +(这张裸图没有能指的更细粒度)。
  102 +
  103 +---
  104 +
58 ## ⚡ 强制工作流(六步) 105 ## ⚡ 强制工作流(六步)
59 106
60 ### Step 1 · 读 task-goal 和验收契约 107 ### Step 1 · 读 task-goal 和验收契约
system/agents/org-validator/tools.md
@@ -5,6 +5,12 @@ @@ -5,6 +5,12 @@
5 > 🚨 **发 JSON 的铁律**:一律用文件写入工具直接写文件,**绝不在 bash 命令里内联 JSON 字符串** 5 > 🚨 **发 JSON 的铁律**:一律用文件写入工具直接写文件,**绝不在 bash 命令里内联 JSON 字符串**
6 > (多重双引号会让工具调用崩溃并陷入死循环)。JSON 里不许有真实换行符,`\n` 要转义。 6 > (多重双引号会让工具调用崩溃并陷入死循环)。JSON 里不许有真实换行符,`\n` 要转义。
7 7
  8 +> ⚠️ **被裸调用(`agent_call`,不是 `subgraph`)时**,`benchmark` 段不能拿 `impl` 的交付物
  9 +> 路径(代码文件/项目目录)直接当 `--target` 跑——那必然 `target_not_json`。你要先自己按
  10 +> `benchmark-validator/agent.md` Step 2 的方法取证据、打分,把分数写成
  11 +> `$D/attempt-<n>.scores.json`(`$D` 是你自己的节点目录),再拿**这份文件**当 `--target`。
  12 +> 详细判断依据与步骤见 `agent.md`「一种你可能被裸调用的情况」一节。
  13 +
8 --- 14 ---
9 15
10 ## 1. 读验收契约(`artifacts/`,只读) 16 ## 1. 读验收契约(`artifacts/`,只读)
web/src/components/graph-console/GoalTaskPane.vue
@@ -101,6 +101,7 @@ @@ -101,6 +101,7 @@
101 :title="queueBadge(t).title" 101 :title="queueBadge(t).title"
102 >{{ queueBadge(t).text }}</span> 102 >{{ queueBadge(t).text }}</span>
103 <span v-else class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span> 103 <span v-else class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span>
  104 + <span v-if="decomposedProgressLabel(t, g.tasks || [])"> · {{ decomposedProgressLabel(t, g.tasks || []) }}</span>
104 <span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span> 105 <span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span>
105 </div> 106 </div>
106 <!-- blocked 的原因单独一行并标红:它是**要人动手**的状态,混在灰色第二排里等于没说 --> 107 <!-- blocked 的原因单独一行并标红:它是**要人动手**的状态,混在灰色第二排里等于没说 -->
@@ -155,6 +156,7 @@ import NewGoalForm from &#39;./NewGoalForm.vue&#39;; @@ -155,6 +156,7 @@ import NewGoalForm from &#39;./NewGoalForm.vue&#39;;
155 import { 156 import {
156 goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel, 157 goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel,
157 queueBadge, blockedLabel, parseBlockedReason, taskActions, queueStat, dispatchedTaskId, 158 queueBadge, blockedLabel, parseBlockedReason, taskActions, queueStat, dispatchedTaskId,
  159 + decomposedProgressLabel,
158 } from './goal-task-model.js'; 160 } from './goal-task-model.js';
159 161
160 const props = defineProps({ 162 const props = defineProps({
web/src/components/graph-console/goal-task-model.js
@@ -23,6 +23,10 @@ export const TASK_STYLE = { @@ -23,6 +23,10 @@ export const TASK_STYLE = {
23 failed: { color: '#d33', hollow: false, label: '失败' }, 23 failed: { color: '#d33', hollow: false, label: '失败' },
24 blocked: { color: '#e08a00', hollow: false, label: '挂起' }, 24 blocked: { color: '#e08a00', hollow: false, label: '挂起' },
25 cancelled: { color: '#bbb', hollow: false, label: '已取消' }, 25 cancelled: { color: '#bbb', hollow: false, label: '已取消' },
  26 + // 46 号文档 Phase 4:父任务被 subtask-planning 拆出子任务后,自己那条完整 org graph
  27 + // 被取消,终态改由子任务汇总决定——业务语义上父任务仍"活着",只是执行体换了,
  28 + // 复用 running 同款蓝色+脉冲,不新造一个颜色。
  29 + decomposed:{ color: '#0069d9', hollow: false, label: '拆分执行中', pulse: true },
26 }; 30 };
27 31
28 /** goal 状态 → 圆点样式 + 中文标签。planning 借 running 的蓝并且脉冲:它是个真会持续几十秒的状态 */ 32 /** goal 状态 → 圆点样式 + 中文标签。planning 借 running 的蓝并且脉冲:它是个真会持续几十秒的状态 */
@@ -436,6 +440,20 @@ export function goalProgressLabel(goal) { @@ -436,6 +440,20 @@ export function goalProgressLabel(goal) {
436 } 440 }
437 441
438 /** 442 /**
  443 + * 46 号文档 Phase 5:`decomposed` 状态的父任务旁边显示"N/M 子任务完成"——只读展示,
  444 + * 不做交互(认领/拖拽留给以后)。`siblings` 传同一个 goal 下的兄弟任务(跟
  445 + * `parentTitle`/`blockedLabel` 同款用法),子任务在物化时就是同一个 goal_id,
  446 + * 不需要跨 goal 查。
  447 + */
  448 +export function decomposedProgressLabel(task, siblings = []) {
  449 + if (!task || task.status !== 'decomposed') return '';
  450 + const children = siblings.filter((t) => t.parentTaskId === task.taskId && t.subtaskKind === 'decomposed_work');
  451 + if (!children.length) return '';
  452 + const done = children.filter((t) => t.status === 'completed').length;
  453 + return `${done}/${children.length} 子任务完成`;
  454 +}
  455 +
  456 +/**
439 * `⇢` 的 hover 文案:「等 T1 完成」。 457 * `⇢` 的 hover 文案:「等 T1 完成」。
440 * 只认同 goal 内的兄弟 task;引用到已归档 / 已删的 id 时原样显示 id,不静默吞掉 —— 458 * 只认同 goal 内的兄弟 task;引用到已归档 / 已删的 id 时原样显示 id,不静默吞掉 ——
441 * 「依赖一个不存在的 task」正是 dispatch 会 409 的原因,藏起来只会让人查不到。 459 * 「依赖一个不存在的 task」正是 dispatch 会 409 的原因,藏起来只会让人查不到。
web/src/components/task-board/TaskTable.vue
@@ -48,6 +48,7 @@ @@ -48,6 +48,7 @@
48 <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))"></span> 48 <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))"></span>
49 {{ taskStyle(t.status).label }} 49 {{ taskStyle(t.status).label }}
50 <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span> 50 <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span>
  51 + <span v-if="decomposedProgressLabel(t, siblingsOf(t))" class="qb">{{ decomposedProgressLabel(t, siblingsOf(t)) }}</span>
51 </td> 52 </td>
52 <td class="c-desc"> 53 <td class="c-desc">
53 <!-- 43 号文档:子任务是按验收条目物化出来的只读行,没有独立的 run, 54 <!-- 43 号文档:子任务是按验收条目物化出来的只读行,没有独立的 run,
@@ -72,7 +73,7 @@ @@ -72,7 +73,7 @@
72 73
73 <script setup> 74 <script setup>
74 import { ref, computed } from 'vue'; 75 import { ref, computed } from 'vue';
75 -import { taskStyle, queueBadge, blockedLabel } from '../graph-console/goal-task-model.js'; 76 +import { taskStyle, queueBadge, blockedLabel, decomposedProgressLabel } from '../graph-console/goal-task-model.js';
76 77
77 const props = defineProps({ 78 const props = defineProps({
78 companyId: { type: String, required: true }, 79 companyId: { type: String, required: true },
@@ -82,7 +83,7 @@ const props = defineProps({ @@ -82,7 +83,7 @@ const props = defineProps({
82 }); 83 });
83 defineEmits(['select', 'create']); 84 defineEmits(['select', 'create']);
84 85
85 -const STATUS_OPTIONS = ['draft', 'queued', 'running', 'completed', 'failed', 'blocked', 'cancelled']; 86 +const STATUS_OPTIONS = ['draft', 'queued', 'running', 'decomposed', 'completed', 'failed', 'blocked', 'cancelled'];
86 87
87 const flatTasks = computed(() => { 88 const flatTasks = computed(() => {
88 const out = []; 89 const out = [];