Commit 8b1e7fb65dd659bfc676e1d9f77aac4737c67a56
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
Showing
16 changed files
with
1088 additions
and
70 deletions
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 真实回归跑完,用户追问"认领后没有触发源,谁来触发"— | @@ -488,3 +488,65 @@ v1 真实回归跑完,用户追问"认领后没有触发源,谁来触发"— | ||
| 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 = 'org-graph'; | @@ -2194,6 +2239,11 @@ const GRAPH_TAKEOVER_ASSIGNEE = 'org-graph'; | ||
| 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('状态样式表', () => { | @@ -42,6 +43,10 @@ describe('状态样式表', () => { | ||
| 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('goal 行进度', () => { | @@ -86,6 +91,32 @@ describe('goal 行进度', () => { | ||
| 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('graph-engine', () => { | @@ -1259,4 +1259,78 @@ describe('graph-engine', () => { | ||
| 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('46 å·æ–‡æ¡£ Phase 1:subtask-plan.json 共享区è½åœ° + ä»»åŠ¡çº§å… | @@ -225,6 +225,136 @@ describe('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 './NewGoalForm.vue'; | @@ -155,6 +156,7 @@ import NewGoalForm from './NewGoalForm.vue'; | ||
| 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 = []; |
-
mentioned in commit c162484a46d1e72c9d01d77aa966e16194ef8b5d