diff --git a/docs/README.md b/docs/README.md index c4b5e69..2590218 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,7 @@ | [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 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | | [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 条新增,用真实回归捕获的产物做测试数据) | | [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` 链路已被主力机制完全覆盖,不为不可达场景写代码 | +| [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 条单测),详见文末补跑记录 | ## todo/(当前主线) @@ -68,7 +69,6 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [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:` 写进本体 `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(训练产物 → 雇佣本体转换)尚未开工 | | [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`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | | [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 目前代码层没有硬顶 | -| [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)未开工 | ## todo/future/(暂缓,非当前重心) diff --git a/docs/done/system-design/46-mandatory-planning-and-task-decomposition.md b/docs/done/system-design/46-mandatory-planning-and-task-decomposition.md index 419c44e..6360a8c 100644 --- a/docs/done/system-design/46-mandatory-planning-and-task-decomposition.md +++ b/docs/done/system-design/46-mandatory-planning-and-task-decomposition.md @@ -1,9 +1,9 @@ # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源 -> **状态**: 📝 设计中——四个关键决策已拍板。**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)仍未设计,留给后续 +> **状态**: ✅ **已收口**(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` 分组逻辑,未改后端接口 > **日期**: 2026-09-01 -> **触发场景**: 用户复盘 [43 号文档](../../done/system-design/43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 -> **格式对齐** `27-requirement.md`/[`43-task-decomposition-granularity.md`](../../done/system-design/43-task-decomposition-granularity.md):原话照搬 + 结构化复述,现状调研另起一节。 +> **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 +> **格式对齐** `27-requirement.md`/[`43-task-decomposition-granularity.md`](./43-task-decomposition-granularity.md):原话照搬 + 结构化复述,现状调研另起一节。 --- @@ -192,7 +192,7 @@ goal 创建 ### 遗留决策:这个新发现的 bug 怎么处理 -已拆分成独立文档处理,**已修复**:见 [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 设计阶段要显式决定要不要保留它。 +已拆分成独立文档处理,**已修复**:见 [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 设计阶段要显式决定要不要保留它。 --- @@ -200,7 +200,7 @@ goal 创建 ### 命名 -按 [27-naming-conventions.md](./27-naming-conventions.md) §二「规划类 agent —— `{产出物}-planning`」的既有惯例(`brd-planning`/`prd-planning`/`sdd-planning` 都按自己产出什么命名),新 agent 命名为 **`subtask-planning`**——它的产出物就是 `subtask-goal`(该规范 §一 作用域表里已经预留了这一级:"子任务的目标(一个 task-goal 分解出多个)"),命名轴完全对上,不用新造概念。 +按 [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 分解出多个)"),命名轴完全对上,不用新造概念。 与 `task-planning` 的关系:`task-planning` 把 `company-goal` 拆成多条 `task`(= `task-goal`);`subtask-planning` 把一条 `task-goal` 拆成多条 `subtask-goal`——同一条命名轴上相邻的两级,职责边界清楚。 @@ -488,3 +488,65 @@ v1 真实回归跑完,用户追问"认领后没有触发源,谁来触发"— 3. 按用户原话的退路——这条子任务级别就不追求跑通 `org-validator`,直接升级成完整 org graph(这就是当初讨论"先跑轻量版看效果,不行再走完整 org graph"里"不行"的具体触发条件)。 **后续讨论收敛到方向 1 的一个更彻底的版本**:不是"轻量图加一个专门产契约的节点",而是"每个 task/subtask 创建时就必须 1:1 带一份验收契约"——把约束点从"某张图怎么搭"提到"task 这个实体本身"。完整讨论过程、方案取舍、以及需要改动的 agent/代码盘点见 [48 号文档](./48-task-acceptance-contract-binding.md)。 + +--- + +## Phase 4 详细设计与实现记录(2026-09-04) + +48 号文档把 Phase 3 v2 留下的验证缺口补上后,`decomposed_work` 子任务已经有了完整可用的独立 impl→validate 循环。复盘发现 Phase 4 原计划的三件事——预算模型、父子终态汇总、并发/节奏控制——都还没做,且过程中浮出一个文档没覆盖的新问题:父任务被拆分后,自己那条完整 org graph 依然会独立真实执行,跟子任务的轻量图各跑各的、互不知情,同一件事被做两遍。 + +### 拍板(逐条与用户对齐,非单方面假设) + +1. **子任务是父任务唯一执行体**——父任务被成功拆分后,跳过自己的完整 org graph 执行,终态完全由子任务汇总决定。 +2. **预算按子任务数均分**父任务的 `budget_json`(按计划步数 `steps.length` 均分,不按事后实际建库成功的条数——单步校验失败被丢弃时更保守,不因落库顺序影响每条子任务分到多少)。 +3. **父任务保持"进行中"直到全部子任务达终态**才结算,不做百分比中间态展示。 +4. **同一父任务下的无依赖子任务开专属并发槽位**,固定上限 3,不受公司级 `max_parallel_tasks`(默认 1)限制,两个坑位池完全独立。 +5. **拆分决策时机保持异步**:父任务的 run 照旧立即创建排队,等 `subtask-planning` 拆分成功后再补一步取消/作废父 run。 +6. **验证方式**:单测覆盖机制本身 + 一次真实公司端到端回归。 +7. **Phase 5(看板 UI)接着在同一轮做,最小范围**:只加状态/进度展示,不做新交互。 + +### 实现 + +| 内容 | 位置 | +|---|---| +| 新状态 `decomposed`(app 层枚举,无 DB CHECK) | `server/services/work-graph-db.js`——父任务被拆分后进入的中间态,`task-scheduler.listQueue` 只捞 `status='queued'`,天然不会把它当候选 | +| `markTaskDecomposed(taskId)` | `work-graph-db.js`——CAS:`status NOT IN (终态, 'decomposed')` 才能转 `decomposed`(**见下方「真实回归纠正过的设计」,这条件不是一开始就这么写的**) | +| `settleDecomposedParentStatus(parentTaskId, status)` | `work-graph-db.js`——CAS:只能从 `'decomposed'` 转终态,防重复结算 | +| `settleTaskFromRun` 补一条排除 | `work-graph-db.js`——`AND status != 'decomposed'`:`stopRun` 取消父 run 会触发这条run 收敛回写口,写 `status='cancelled'`;不排除的话会覆盖掉刚接管成的 `decomposed`(真实回归第一次就撞上,见下) | +| 预算均分 | `materializeDecomposedSubtasks`(work-graph-db.js)——读父任务 `budget_json`(没有则退 `DEFAULT_TASK_BUDGET` 同款形状),按 `steps.length` 均分三项(call/token/wallclock),`Math.max(1, ...)` 保底,写进每条子任务的 `budgetJson` | +| 父任务取消 | `triggerSubtaskPlanning`(subtask-planning.js)——materialize 出 ≥1 条子任务后,**先 `markTaskDecomposed` 再 `stopRun`**(顺序不能反,见下);父 run 若已经是真终态,两个操作天然都是 no-op,不覆盖已有结论 | +| 父子终态汇总 | `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` 调用点并列 | +| 同父并发坑位(上限 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`(这个父任务坑位满不代表别的父任务/别的候选也满,两者语义相反,混在一个循环里判断会两头都错) | +| CEO 文档 | `system/agents/ceo/heartbeat.md`——补一行说明:父任务变成 `decomposed` 状态是正常现象,不是卡住 | +| 前端(Phase 5) | `goal-task-model.js` 加 `TASK_STYLE.decomposed`(复用 running 同款蓝+脉冲)+ `decomposedProgressLabel`;`TaskTable.vue`/`GoalTaskPane.vue` 接入进度 label;父子任务树形展示(`parentTaskId` 分组)两处视图**本来就不检查 `subtask_kind`**,`decomposed_work` 子任务已经自动出现在树里,不用改 | +| 测试 | `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`) | + +### ⚠️ 真实回归纠正过的设计:`markTaskDecomposed` 不能只认 `queued` + +第一版实现里,`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 条物化好的子任务变成没有父任务收敛、没人会去汇总的孤儿。 + +这不是小概率竞态,是**几乎每次都会发生**的时序——只要公司当时有空闲坑位(真实回归用的测试公司就是这种情况),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` 随后必然扑空(第一版代码就是这个顺序,真实回归复现的正是这个现象)。 + +修复后用同一个真实场景验证:父任务从真实 `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`。 + +### 真实回归未覆盖到的部分(如实记录,不是遗漏) + +驱动脚本给了 15 分钟的等待窗口,第二条子任务的 `org-coder` 是真实在执行(大量真实工具调用),没有卡死,但超出了等待窗口——脚本退出前的子进程清理逻辑把这条真实在跑的 CLI 进程 SIGTERM 了,图引擎把它判成 `blocked`(`impl` 节点"未给出 fail_reason,走兜底边",这是既有的、48 号文档之前就验证过的降级路径,不是 Phase 4 新代码的行为)。这是回归方法论本身的时间预算问题,不是代码缺陷: + +- **父子终态汇总**(`settleParentTaskIfAllChildrenDone`)没有在真实条件下被真正触发过一次(全部子任务到终态这个条件没达成)——由单测覆盖其 CAS/分支逻辑,机制层面有把握,但"真实调用链路上会不会被正确触发"这一点严格说只验证到"父任务转 `decomposed`后没有被过早/错误结算"(回归观察期间父任务全程稳定停在 `decomposed`,没有被 `settleGoalIfAllTasksDone` 误判提前收敛,这本身也是一条有效证据,但不是"全部子任务完成后确实会自动汇总"这条主张的直接证明)。 +- **同父 3 并发坑位**没有被真实触发——`subtask-planning` 给出的依赖图基本是链式的,同一时刻最多只有 1 条无依赖的子任务,没有出现≥2 条子任务同时可跑的窗口。机制本身由单测直接验证(`updateRunStatus` 层面的 CAS + 计数),真实条件下"两条子任务真的同时占了两个坑位、互不干扰"这一幕没有被观察到。 + +测试公司 `p2verify0902` 的这次回归数据(goal `G-mtn5zewz-8d47`)未清理,留作证据;此前两次因为脚本 bug(等待条件判早/退出过快误杀子进程)失败的尝试(goal `G-mtn5q2r7-786b`、`G-mtn5tvih-bed4`)同样留在库里未清理,第二次那条父任务卡成 `cancelled`、子任务变孤儿的现场是本节记录的那个 bug 的直接物证。回归全程 `ps aux` 确认零残留 CLI 子进程。 + +### 补跑:同父并发坑位在真实条件下确认生效,顺带牵出并修复一个独立发现(2026-09-05/06) + +用不改变服务进程生命周期的方式(起一个长驻服务脚本 + 独立短命脚本发起调用,观察脚本只读不杀进程)把上面卡住的第 2 条子任务重跑起来,补齐了上一轮没跑到的两幕: + +- **同父 3 并发坑位——已用真实数据确认生效**:子任务 3(`b48b`,吃食物逻辑)与子任务 4(`35e1`,碰撞检测)都只依赖子任务 2,子任务 2 完成后两者被真实观察到**同时 `running`**(持续 20+ 分钟,不是巧合的瞬间重叠),期间公司级 `max_parallel_tasks` 仍是默认值 1——证明两个坑位池确实互相独立,`decomposed_work` 子任务没有被公司级槽位卡成串行。 +- **父子终态汇总——被一个新发现的独立缺陷挡住,未在这轮跑到底**: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 次做对了。 + +**根因更深一层**:即便 `org-validator` 判断正确,`decomposed_work` 的轻量图(`impl --success--> validate`)当时**只有一条前进边,没有任何失败边**——validator 判 fail 之后图上无路可退,只能永久卡死。这正是本文档一直沿用的 retry-with-reflection 模式(DevLoop L1 循环、完整 org graph 的 validator 退回边)在这张图上唯独没有被接上。 + +**发现与修复过程中的一个插曲**:诊断到一半时发现 `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 撞硬顶后不再无限重试。三条全过。 + +**这条修复只对新建的 decomposed_work 任务生效**——之前卡住的第 5 条子任务的 work graph 是修复前建的,edges 表里天生没有这条退回边,不会自动受益;真要在真实条件下走一遍"判 fail → 真退回 → 带反馈重跑 → 判过"的完整闭环,需要一条全新任务,本轮未做(机制已经用假 executor 验证得很扎实,真实条件下的完整验证留给下次touch这块代码时顺手做)。 diff --git a/server/services/subtask-planning.js b/server/services/subtask-planning.js index 0405355..52ddca2 100644 --- a/server/services/subtask-planning.js +++ b/server/services/subtask-planning.js @@ -429,6 +429,29 @@ async function triggerSubtaskPlanning({ run, task }) { console.log(`[SubtaskPlanning] task ${task.id} 写入 ${steps.length} 条步骤分解(${tokens || 0} tokens),` + `物化 ${materializedIds.length} 条待 CEO 派发的子任务`); + + // 46 号文档 Phase 4:拆分成功 → 子任务成为父任务唯一的执行体,父任务自己那条排队中的 + // 完整 org graph run 要让位。**顺序很关键,先接管任务再取消 run,不能反过来**: + // `stopRun` 会同步触发 run 收敛的回写口 `settleTaskFromRun`,那条路径会把任务写成 + // `cancelled`——如果先 `stopRun` 再 `markTaskDecomposed`,`markTaskDecomposed` 的 CAS + // (`WHERE status='queued'`)必然扑空,因为任务此刻已经被 `cancelled` 了(这不是小概率 + // 竞态,是每次都会发生的确定性顺序,第一版实现在真实测试里当场撞上)。反过来先标 + // `decomposed`,`settleTaskFromRun` 新增的 `AND status != 'decomposed'` 闸门会让 + // 随后触发的 cancelled 回写扑空,任务保持 `decomposed` 不被覆盖。 + // 父 run 仍有极小概率在这一步之前就已经跑完/失败(真赢了竞态)——那种情况下 + // `markTaskDecomposed` 的 CAS 本来就拿不到锁(任务已是有效终态,不是 'queued'), + // `stopRun` 对已终态 run 也是安全的 no-op,两者叠加不会覆盖一个已经有效终态的父任务。 + if (materializedIds.length) { + try { + const taken = wdb.markTaskDecomposed(task.id); + if (!taken) { + console.warn(`[SubtaskPlanning] task ${task.id} 接管失败(已不是 queued,多半是父 run 已经赢了竞态跑完了),保留父任务原有终态`); + } + require('./graph-engine').stopRun(run.id); + } catch (err) { + console.error(`[SubtaskPlanning] task ${task.id} 取消父 run(${run.id})失败: ${err.message}`); + } + } } catch (err) { console.warn(`[SubtaskPlanning] task ${task && task.id} 触发失败(不影响入队):${err.message}`); } diff --git a/server/services/task-scheduler.js b/server/services/task-scheduler.js index 8095088..ed9514d 100644 --- a/server/services/task-scheduler.js +++ b/server/services/task-scheduler.js @@ -241,6 +241,56 @@ function tick(companyId) { } } +/** + * 出队单条候选的共享逻辑(原 5a-5e)。**不决定 busy 时是 break 还是 continue**—— + * 那是调用方的事:公司级坑满意味着整轮都别再试(see tickInner 里的普通候选循环), + * 而 46 号文档 Phase 4 的「同父子任务坑满」只挡这一个父任务,其它候选(含别的父任务) + * 完全不受影响,必须接着试——两种语义不同,所以异常照样往外抛,调用方各自处理。 + * + * @returns {{taskId:string,runId:string,goalId:string}|null} null = 这条没抢到(试下一条), + * 非 null = 起跑成功 + * @throws {Error} RunSlotBusyError 等——由调用方决定 break 还是 continue + */ +function dispatchOneCandidate(t, { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask, blocked }) { + const runId = t.current_run_id; + // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半,这条任务永远起不来。 + // 标成 blocked(不带 upstream_failed 前缀 → 不会被自动重排)让人看见, + // 而不是让它继续假装在排队。 + if (!runId || !getRun(runId)) { + if (blockQueuedTask(t.id, `enqueue_incomplete:missing_run${runId ? `:${runId}` : ''}`)) { + blocked.push({ taskId: t.id, upstream: null, reason: 'missing_run' }); + } + console.error(`[task-scheduler] task=${t.id} 是 queued 但找不到 run(current_run_id=${runId})`); + return null; + } + + // 5b. 排队期间 org graph 可能被重新规划过:节点行是入队那一刻的快照,定义是读时取最新。 + // 不对齐的话新增节点没有 run 行,出队后永远点不亮,而且全程无报错(§四.3 末)。 + syncRunNodesToDef(runId); + + // 5c. 原子出队:`WHERE id=? AND run_status='queued'` 的条件更新,整段(数坑 + 占坑) + // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。 + // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、 + // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。 + if (!sse().transitionRun(runId, 'queued', 'ready', {}, { + summary: `出队:queued → ready(task ${t.id})`, + task_id: t.id, + })) return null; + + // 5d. 认领 task。抢不到(被取消 / 被别的 tick 抢走 / 已归档)就把 run 收回去, + // 否则会留下一条 ready 却没有任务的 run 白占着坑。 + if (!claimQueuedTask(t.id, runId)) { + abandonRun(runId, `task=${t.id} 认领失败(已不是 queued 或 current_run_id 已改指)`); + return null; + } + + // 5e. 起跑。mode 恒为 auto(候选已过滤),description 从 task 行读回—— + // 排队之后请求闭包早就不在了(§四.7 禁令二说的就是这件事)。 + // startRunSync 内部会回读确认真的转成了 running,没起来会抛,落到调用方的 catch。 + engine().startRun(runId, { mode: t.execution_mode, task: t.description }); + return { taskId: t.id, runId, goalId: t.goal_id }; +} + function tickInner(companyId) { const { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask } = wdb(); @@ -253,10 +303,14 @@ function tickInner(companyId) { // 权威判据在 `updateRunStatus` 的准入事务里(BEGIN IMMEDIATE 内数坑 + 占坑一步做完), // 坑被抢走时它抛 RunSlotBusyError,由 5c 的 catch + isRaceError 当成「这轮没抢到」。 // 批 4 起 max 是 per-company 的(company.json.max_parallel_tasks,缺省 1)。 + // + // 46 号文档 Phase 4:`active`/`max` 从此**不含** decomposed_work 子任务的 run(见 + // `work-graph-db.listActiveRootRunIds` 的改动)——它们走独立坑位池,所以这句「坑满」 + // 早退只能挡普通候选,队里若有 decomposed_work 候选仍要往下走(见候选拆分与下面 + // 第二个循环),不能连试都不试。 const max = maxParallelTasks(companyId); const active = activeRootRunCount(companyId); const started = []; - if (active >= max) return { companyId, skipped: 'no-slot', active, max, started, blocked, requeued }; // 4. 选候选:queued + 依赖全 completed + auto。 // manual **不参与自动出队**(§五.2bis):startRun(manual) 只点亮入口节点就停下等 /next, @@ -281,62 +335,63 @@ function tickInner(companyId) { const candidates = listQueue(companyId) .filter((t) => (t.execution_mode === 'auto' || t.attempt > 0) && dependenciesSatisfied(t)); + // 46 号文档 Phase 4:`decomposed_work` 子任务的坑位是**独立的一个池**(同父任务上限固定 3, + // 判据钉死在 `work-graph-db.updateRunStatus` 的准入事务里,不是这里算出来的),完全不占、 + // 也不受这份公司级 `max`/`active` 的约束——所以它们必须分流成单独一条候选队列, + // 不能套用上面「坑满就整轮别再试」的普通逻辑,否则公司级坑位被普通任务占满时, + // decomposed_work 子任务会被这句连试都不试就一起卡住,「不受公司级槽位限制」直接落空。 + const decomposedCandidates = candidates.filter((t) => t.subtask_kind === 'decomposed_work'); + const ordinaryCandidates = candidates.filter((t) => t.subtask_kind !== 'decomposed_work'); + + // 老早退路径原样保留(`skipped:'no-slot'` 有测试直接断言这个形状)——只在**没有** + // decomposed_work 候选时才整轮跳过,否则会把它们也一起挡住(见上面第 3 步的注释)。 + if (active >= max && !decomposedCandidates.length) { + return { companyId, skipped: 'no-slot', active, max, started, blocked, requeued }; + } + + const dispatchCtx = { getRun, syncRunNodesToDef, claimQueuedTask, blockQueuedTask, blocked }; + // 5. 逐条出队,每条独立 try/catch —— 一条失败绝不能中止整轮(那等于把后面的坑白白空着) - for (const t of candidates) { - // 本轮已经填满就收工。`active` 是进循环前的快照、`started.length` 是本轮的战果, - // 两者都只会低估空位(跑着的 run 可能中途收敛让出坑),保守一点即可: - // 少起的那条由漏斗钩子上的下一次 scheduleTick 接手,而不是在这里赌一个更新的计数。 - if (active + started.length >= max) break; - const runId = t.current_run_id; - try { - // 5a. 入队时必须已经建好 queued 的 run 行;没有就是入队做了一半, - // 这条任务永远起不来。标成 blocked(不带 upstream_failed 前缀 → 不会被自动重排) - // 让人看见,而不是让它继续假装在排队。 - if (!runId || !getRun(runId)) { - if (blockQueuedTask(t.id, `enqueue_incomplete:missing_run${runId ? `:${runId}` : ''}`)) { - blocked.push({ taskId: t.id, upstream: null, reason: 'missing_run' }); + if (active < max) { + for (const t of ordinaryCandidates) { + // 本轮已经填满就收工。`active` 是进循环前的快照、`started.length` 是本轮的战果, + // 两者都只会低估空位(跑着的 run 可能中途收敛让出坑),保守一点即可: + // 少起的那条由漏斗钩子上的下一次 scheduleTick 接手,而不是在这里赌一个更新的计数。 + if (active + started.length >= max) break; + const runId = t.current_run_id; + try { + const result = dispatchOneCandidate(t, dispatchCtx); + if (result) started.push(result); + } catch (err) { + // 坑满:这一刻公司真的没位置了,后面的候选一个都进不去 —— 继续试只是白白多抛几次。 + // 与下面那种「这一条没抢到」区分开:那种要接着试下一条,否则 max>1 时一条被别人 + // 推走就会让剩下的坑空着(正是本轮不 slice 去掉的那类空转)。 + if (err instanceof wdb().RunSlotBusyError) break; + if (isRaceError(err)) continue; // 这一条没抢到(已被别人从 queued 推走),试下一条 + // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run, + // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。 + if (runId && (getRun(runId) || {}).run_status === 'ready') { + abandonRun(runId, `startRun 失败: ${err.message}`); + } else { + console.error(`[task-scheduler] 出队 task=${t.id} 失败: ${err.stack || err.message}`); } - console.error(`[task-scheduler] task=${t.id} 是 queued 但找不到 run(current_run_id=${runId})`); - continue; - } - - // 5b. 排队期间 org graph 可能被重新规划过:节点行是入队那一刻的快照,定义是读时取最新。 - // 不对齐的话新增节点没有 run 行,出队后永远点不亮,而且全程无报错(§四.3 末)。 - syncRunNodesToDef(runId); - - // 5c. 原子出队:`WHERE id=? AND run_status='queued'` 的条件更新,整段(数坑 + 占坑) - // 由 updateRunStatus 的 BEGIN IMMEDIATE 事务保护,超发的那条拿不到坑。 - // 走 transitionRun 而不是裸的 updateRunStatus,是为了让这一跳同样落 timeline、 - // 推 SSE(控制台要看见任务起跑);`to='ready'` 在活跃集里,所以它不会反手再触发一次调度。 - // 两种「没抢到」的形态(批 4 DROP 唯一索引后,兜底那一层没有了): - // · changes=0 → transitionRun 返回 null(这条 run 已被别人推走)→ 试下一条; - // · RunSlotBusyError → 主守卫在事务里判出坑满(**不是** changes=0)→ 整轮到此为止。 - // 抛出来的那种落到下面的 catch。 - if (!sse().transitionRun(runId, 'queued', 'ready', {}, { - summary: `出队:queued → ready(task ${t.id})`, - task_id: t.id, - })) continue; - - // 5d. 认领 task。抢不到(被取消 / 被别的 tick 抢走 / 已归档)就把 run 收回去, - // 否则会留下一条 ready 却没有任务的 run 白占着坑。 - if (!claimQueuedTask(t.id, runId)) { - abandonRun(runId, `task=${t.id} 认领失败(已不是 queued 或 current_run_id 已改指)`); - continue; } + } + } - // 5e. 起跑。mode 恒为 auto(候选已过滤),description 从 task 行读回 —— - // 排队之后请求闭包早就不在了(§四.7 禁令二说的就是这件事)。 - // startRunSync 内部会回读确认真的转成了 running,没起来会抛,落到下面的 catch。 - engine().startRun(runId, { mode: t.execution_mode, task: t.description }); - started.push({ taskId: t.id, runId, goalId: t.goal_id }); + // decomposed_work 候选:独立坑位池,与上面的 `max`/`active` 完全不占同一个额度, + // 所以不做「坑满即 break」的预判——每条候选各自去闯 `updateRunStatus` 里那道同父上限 3 + // 的准入事务,抢不到只影响**这一个父任务**,别的候选(含别的父任务的子任务)继续试, + // 因此 RunSlotBusyError 在这里必须是 `continue` 而不是 `break`(与上面普通候选相反的语义, + // 混在同一个循环里用同一套 break/continue 判断会两头都错,所以拆成两个循环)。 + for (const t of decomposedCandidates) { + const runId = t.current_run_id; + try { + const result = dispatchOneCandidate(t, dispatchCtx); + if (result) started.push(result); } catch (err) { - // 坑满:这一刻公司真的没位置了,后面的候选一个都进不去 —— 继续试只是白白多抛几次。 - // 与下面那种「这一条没抢到」区分开:那种要接着试下一条,否则 max>1 时一条被别人 - // 推走就会让剩下的坑空着(正是本轮 slice 去掉的那类空转)。 - if (err instanceof wdb().RunSlotBusyError) break; - if (isRaceError(err)) continue; // 这一条没抢到(已被别人从 queued 推走),试下一条 - // 到这儿说明 run 可能已经出队但没能起跑:task 已标 running 而没有活的 run, - // 这正是要消灭的那类「写了但没生效还不报错」。收回 run,让 task 落到可见的终态。 + if (err instanceof wdb().RunSlotBusyError) continue; // 只是这个父任务的 3 个槽位满了 + if (isRaceError(err)) continue; if (runId && (getRun(runId) || {}).run_status === 'ready') { abandonRun(runId, `startRun 失败: ${err.message}`); } else { diff --git a/server/services/task-settle.js b/server/services/task-settle.js index e49f6ca..edb3d24 100644 --- a/server/services/task-settle.js +++ b/server/services/task-settle.js @@ -201,7 +201,50 @@ function settleRootRun(run, to) { } const task = wdb.getTask(run.task_id); - return { task: taskStatus, goal: task && task.goal_id ? settleGoalIfAllTasksDone(task.goal_id) : null }; + let goal = null; + if (task && task.parent_task_id && task.subtask_kind === 'decomposed_work') { + // 46 号文档 Phase 4:这条 task 是 `decomposed_work` 子任务——先看同父兄弟是不是全到 + // 终态了,全到了才把父任务结算掉;父任务和它自己共享同一个 goal_id,但父任务在此之前 + // 一直卡在 'decomposed'(非终态,TASK_TERMINAL_STATUS 不含它),goal 级判据天然不会 + // 提前满足。父任务这一刻转终态**不会自动**触发 goal 收敛(`settleDecomposedParentStatus` + // 是一条裸 UPDATE,不经过 settleTaskFromRun 这条会顺带调 goal 收敛的路),必须在这里 + // 补一次调用——否则子任务和父任务全部做完之后 goal 会永远停在 active,正是本仓库 + // 反复出现过的僵尸 goal 症状(settle 钩子漏触发那一类,见 memory「goal-settle-hook-no-backfill」)。 + const parentStatus = settleParentTaskIfAllChildrenDone(task.parent_task_id); + if (parentStatus) { + const parent = wdb.getTask(task.parent_task_id); + goal = parent && parent.goal_id ? settleGoalIfAllTasksDone(parent.goal_id) : null; + } + } else if (task && task.goal_id) { + goal = settleGoalIfAllTasksDone(task.goal_id); + } + return { task: taskStatus, goal }; +} + +/** + * 46 号文档 Phase 4:`decomposed_work` 子任务达终态时调用,检查同一父任务下的兄弟子任务 + * 是不是也全到终态了——是则把父任务结算成 completed/failed。是 `settleGoalIfAllTasksDone` + * 的父任务版本,判据从 `goal_id` 换成 `parent_task_id` + `subtask_kind='decomposed_work'`。 + * + * **不做** goal 级那套「≥2 条 task 再跑一次 org-validator 验收」——每条 `decomposed_work` + * 子任务已经在自己的轻量图里被 `org-validator` 验证过一次(48 号文档),父任务层面 + * 没有、也不需要对应的"父任务级验收"agent,再验一次纯属重复。 + * + * @returns {string|null} 本次结算成的状态;null = 还没到终态,或结算权被别人抢走 / 已结算过 + */ +function settleParentTaskIfAllChildrenDone(parentTaskId) { + const wdb = require('./work-graph-db'); + const parent = wdb.getTask(parentTaskId); + if (!parent) return null; + const children = wdb.listCompanyTasks(parent.company_id, { parentTaskId, subtaskKind: 'decomposed_work' }); + if (!children.length) return null; + if (children.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null; + + const allCompleted = children.every((t) => t.status === 'completed'); + const status = allCompleted ? 'completed' : 'failed'; + // CAS:多个子任务几乎同时到达终态时会并发调用本函数,只有第一个抢到的做后续副作用 + if (!wdb.settleDecomposedParentStatus(parentTaskId, status)) return null; + return status; } /** @@ -728,6 +771,7 @@ function stopCeoSweeper() { module.exports = { settleRootRun, settleGoalIfAllTasksDone, + settleParentTaskIfAllChildrenDone, resumePendingGoalValidations, sweepStuckActiveGoals, startZombieGoalSweeper, diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 990ab8b..f9fecdd 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -173,9 +173,16 @@ class RunSlotBusyError extends Error { * 一句人能看懂的 409(「被 run X 占着」),少了它界面上只剩「坑满」,人无从判断该等谁。 * @param {number} max */ - constructor(companyId, runId, heldBy, max) { + /** + * @param {'company'|'decomposed-sibling'} [scope] 46 号文档 Phase 4:`decomposed_work` + * 子任务走独立的「同父并发上限」坑位,不占公司级的坑——坑满时抛的还是这个类 + * (下游 instanceof 判据不用分叉),只是 scope 不同,调用方(task-scheduler)据此决定 + * 「这轮到此为止」还是「换个候选接着试」。默认 'company',兼容所有既有调用方。 + */ + constructor(companyId, runId, heldBy, max, scope = 'company') { const held = heldBy.length ? heldBy.join(', ') : '(未知)'; - super(`company ${companyId} 的执行坑已满(${heldBy.length}/${max},占用者 ${held}):` + const what = scope === 'decomposed-sibling' ? '同父子任务的并发坑' : `company ${companyId} 的执行坑`; + super(`${what}已满(${heldBy.length}/${max},占用者 ${held}):` + `run ${runId} 无法进入活跃集(active-root-run constraint)`); this.name = 'RunSlotBusyError'; this.companyId = companyId; @@ -183,6 +190,7 @@ class RunSlotBusyError extends Error { this.heldBy = heldBy; this.active = heldBy.length; this.max = max; + this.scope = scope; } } @@ -1542,21 +1550,30 @@ function countActiveRootRuns(companyId, db = getDb()) { * 占着坑的是**谁**。`countActiveRootRuns` 由它派生(`.length`),所以那条 WHERE 全仓库 * 仍然只有这一个持有者 —— 为了给 `RunSlotBusyError` 填 heldBy 而另抄一条 SELECT, * 就是给「守卫放行、索引拒绝」那类分叉再开一个口子。活跃集最多几条,取 id 与取 COUNT 同价。 + * + * 46 号文档 Phase 4:`decomposed_work` 子任务的 run **不算进公司级这个坑**——它们走 + * `updateRunStatus` 里单独的「同父并发上限(固定 3)」判据,两个坑位池完全独立, + * 不共用这个计数(决策见 46 号文档「同一父任务下的无依赖子任务开并发槽位」)。 */ function listActiveRootRunIds(companyId, db = getDb()) { const marks = ACTIVE_ROOT_RUN_STATUS.map(() => '?').join(','); return db .prepare(` - SELECT id FROM graph_runs - WHERE parent_run_id IS NULL - AND run_status IN (${marks}) - AND company_id = ? - ORDER BY created_at + SELECT gr.id FROM graph_runs gr + LEFT JOIN tasks t ON t.id = gr.task_id + WHERE gr.parent_run_id IS NULL + AND gr.run_status IN (${marks}) + AND gr.company_id = ? + AND (t.subtask_kind IS NULL OR t.subtask_kind != 'decomposed_work') + ORDER BY gr.created_at `) .all(...ACTIVE_ROOT_RUN_STATUS, companyId) .map((r) => r.id); } +/** 46 号文档 Phase 4:同一父任务下 `decomposed_work` 子任务的并发上限,写死不开配置项 */ +const DECOMPOSED_SIBLING_CONCURRENCY = 3; + /** * 条件更新 run 状态,语义与 updateNodeStatus 完全一致(同样返回受影响行数供幂等判定)。 * @@ -1612,6 +1629,34 @@ function updateRunStatus(runId, from, to, patch = {}) { && !ACTIVE_ROOT_RUN_STATUS.includes(expected); if (!admitting) return write(); + // 46 号文档 Phase 4:`decomposed_work` 子任务的 run 走**独立的**坑位池(同父任务下最多 + // `DECOMPOSED_SIBLING_CONCURRENCY` 条同时活跃),完全不占、也不受公司级 `max_parallel_tasks` + // 约束——两条判据都必须钉死在这唯一的准入事务里(本函数上方注释「6 条路径能推进活跃集, + // 枚举一次漏一次」同样适用于这第二个坑位池,不能只在 task-scheduler 一处判断)。 + // task_id/subtask_kind/parent_task_id 建库后不再变,读它不需要在事务里(同下面 max 那条的道理)。 + const task = cur.task_id ? getTask(cur.task_id) : null; + const isDecomposedWork = Boolean(task && task.subtask_kind === 'decomposed_work' && task.parent_task_id); + + if (isDecomposedWork) { + return db.transaction(() => { + const fresh = db.prepare(`SELECT run_status FROM graph_runs WHERE id = ?`).get(runId); + if (!fresh || fresh.run_status !== expected) return 0; + + const marks = ACTIVE_ROOT_RUN_STATUS.map(() => '?').join(','); + const heldBy = db.prepare(` + SELECT gr.id FROM graph_runs gr + JOIN tasks t ON t.id = gr.task_id + WHERE t.parent_task_id = ? AND t.subtask_kind = 'decomposed_work' + AND gr.run_status IN (${marks}) + `).all(task.parent_task_id, ...ACTIVE_ROOT_RUN_STATUS).map((r) => r.id); + + if (heldBy.length >= DECOMPOSED_SIBLING_CONCURRENCY) { + throw new RunSlotBusyError(cur.company_id, runId, heldBy, DECOMPOSED_SIBLING_CONCURRENCY, 'decomposed-sibling'); + } + return write(); + }).immediate(); + } + // 额度在**进事务之前**解析:它要读一次盘(company.json)+ JSON.parse,塞进 BEGIN IMMEDIATE // 就是拿着 SQLite 写锁去做 syscall,而且文件坏掉时会在事务里抛。 // 这一步不需要在事务里:需要原子的是「数坑 + 占坑」,不是「读配置」—— @@ -2194,6 +2239,11 @@ const GRAPH_TAKEOVER_ASSIGNEE = 'org-graph'; */ function settleTaskFromRun(taskId, runId, status) { const now = new Date().toISOString(); + // 46 号文档 Phase 4:'decomposed' 也要排除在外,不只是终态——subtask-planning 把父任务 + // 标成 'decomposed' 之后会紧接着 stopRun 取消这条 run,取消触发的正是本函数 + // (run 收敛的唯一回写口),status='cancelled' 会覆盖掉刚打上的 'decomposed'。 + // 父任务此刻的终态已经交给 settleParentTaskIfAllChildrenDone 全权负责, + // 这条 run 的收敛结果对它而言是噪声,不是权威来源。 return getDb() .prepare(` UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now, @@ -2201,6 +2251,7 @@ function settleTaskFromRun(taskId, runId, status) { WHERE id = @id AND current_run_id = @runId AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) + AND status != 'decomposed' `) .run({ id: taskId, runId, status, now }).changes; } @@ -2231,6 +2282,85 @@ function settleSubtaskResult(taskId, status, summary = null) { } /** + * 46 号文档 Phase 4:父任务被 `subtask-planning` 成功拆分出 `decomposed_work` 子任务后, + * 从自己排队中的完整 org graph run 手上把执行权交出去——`status` 转 `'decomposed'`, + * 之后终态完全靠 `settleParentTaskIfAllChildrenDone`(task-settle.js)收敛,不再走 + * `settleTaskFromRun` 那条 run 收敛路径(父 run 本身会被 `stopRun` 取消)。 + * + * ⚠️ **真实回归纠正过一次的设计**:本函数最初写成只允许从 `'queued'` 转,理由是 + * 「`enqueueTaskRun` 刚落的状态就是 queued」——这个假设在真实回归里当场证伪: + * task-scheduler 抢到空闲坑位后几乎是瞬间把任务从 `queued` 推进 `running`(毫秒级), + * 而 `subtask-planning` 是一次真实 LLM 调用(数十秒),等它拆完回来时父任务几乎必然 + * 已经是 `running`,不再是 `queued`——按旧条件 `markTaskDecomposed` 会**几乎每次** + * 拿不到锁,而 `stopRun` 依然会把这条正在真实执行的 run 取消掉,后果是父任务卡成 + * `cancelled`、物化好的子任务全部变成没人认领的孤儿(父任务不是 `decomposed`, + * `settleParentTaskIfAllChildrenDone` 永远不会被触发)。这不是小概率竞态,是几乎必现。 + * + * 修法:接管条件放宽成「还没到终态、也还没被接管过」(`NOT IN (终态, 'decomposed')`), + * 而不是死抠一个具体的中间状态——`queued`/`running`/`blocked` 都算「父 run 还没有效 + * 完成,可以被接管」,这才是 46 号文档「拆分决策时机保持异步…父 run 先创建,拆分成功 + * 后再补一步取消/作废」这句话真正的意思:只要父 run 没有真的跑完,就该让位。 + * 真正该挡住的只有两种:① 父 run 已经真的到了终态(completed/failed/cancelled, + * 赢了竞态,这次拆分来晚了);② 已经被接管过一次(status 已是 'decomposed',防重复)。 + * + * @returns {boolean} true = 成功接管;false = 没抢到(父任务已到终态或已被接管过) + */ +function markTaskDecomposed(taskId) { + const now = new Date().toISOString(); + return getDb() + .prepare(` + UPDATE tasks SET status = 'decomposed', updated_at = @now + WHERE id = @id + AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) + AND status != 'decomposed' + `) + .run({ id: taskId, now }).changes > 0; +} + +/** + * 46 号文档 Phase 4:全部 `decomposed_work` 子任务达终态后,把父任务结算成 completed/failed + * ——`settleGoalIfAllTasksDone`(task-settle.js)的父任务版本,判据从 `goal_id` 换成 + * `parent_task_id` + `subtask_kind='decomposed_work'`,调用方是 `settleParentTaskIfAllChildrenDone`。 + * + * 只允许从 `'decomposed'` 转终态:CAS 挡重复结算——多个子任务几乎同时到达终态时, + * `settleParentTaskIfAllChildrenDone` 会被并发调用多次,只有第一次真正生效。 + * + * @returns {boolean} true = 这次调用生效;false = 已经结算过(不是 'decomposed' 状态) + */ +function settleDecomposedParentStatus(parentTaskId, status) { + if (!['completed', 'failed'].includes(status)) { + throw new Error(`settleDecomposedParentStatus: status 只能是 'completed' 或 'failed',收到: ${status}`); + } + const now = new Date().toISOString(); + return getDb() + .prepare(` + UPDATE tasks SET status = @status, finished_at = @now, updated_at = @now + WHERE id = @id AND status = 'decomposed' + `) + .run({ id: parentTaskId, status, now }).changes > 0; +} + +/** 任务级总预算的默认值,逐字同 `server/routes/company.js` 的 `DEFAULT_TASK_BUDGET`—— + * 两处允许重复:这是一个 5 行的字面量,不值得为它开一个共享模块(§项目一贯的重复容忍度)。*/ +const DEFAULT_TASK_BUDGET_SHAPE = { + total_agent_calls: 100, + total_tokens: 5000000, + wallclock_ms: 7200000, + spent: { agent_calls: 0, tokens: 0 }, +}; + +/** 逐字同 `company.js` 的 `parseBudget`——一条脏 JSON 不该炸整个物化流程,退 null 让调用方兜底。 */ +function parseBudgetJson(raw) { + if (!raw) return null; + try { + const v = JSON.parse(raw); + return v && typeof v === 'object' ? v : null; + } catch { + return null; + } +} + +/** * 46 号文档 Phase 3(v2,2026-09-02 改版):把 `subtask-planning` 产出的 `steps[]` 物化成 * decomposed_work 子任务。**v1 曾经设计成"认领"(pull,`claimable`/`claimed` 两个专属状态 + * `claimSubtask`/`unclaimSubtask` 两个 CAS 函数),真实回归后拍板改掉**:认领要解决的是 @@ -2272,6 +2402,19 @@ function materializeDecomposedSubtasks({ task, steps }) { .get(task.id).n; if (already > 0) return []; + // 46 号文档 Phase 4:父任务的预算按「计划拆出的步数」(steps.length)均分给每条子任务—— + // 用计划步数而不是「事后实际建库成功的条数」,因为单步校验失败会被下面的 try/catch 丢弃, + // 按计划数均分最多是保守(有盈余),不会因为落库顺序/失败条数反过来影响每条子任务分到多少, + // 也不用等全部建完才能回填预算。 + const parentBudget = parseBudgetJson(task.budget_json) || DEFAULT_TASK_BUDGET_SHAPE; + const n = steps.length; + const splitBudget = { + total_agent_calls: Math.max(1, Math.floor(parentBudget.total_agent_calls / n)), + total_tokens: Math.max(1, Math.floor(parentBudget.total_tokens / n)), + wallclock_ms: Math.max(1, Math.floor(parentBudget.wallclock_ms / n)), + spent: { agent_calls: 0, tokens: 0 }, + }; + const seqToId = new Map(); const materialized = []; for (const step of steps) { @@ -2287,6 +2430,7 @@ function materializeDecomposedSubtasks({ task, steps }) { parentTaskId: task.id, subtaskKind: 'decomposed_work', acceptanceContract: step.acceptance, + budgetJson: splitBudget, }); } catch (e) { console.warn(`[work-graph-db] task ${task.id} 第 ${step.seq} 步物化失败,跳过(不影响同批其它步骤): ${e.message}`); @@ -2325,6 +2469,21 @@ function materializeDecomposedSubtasks({ task, steps }) { * 直接当 `agent_call` 裸调用会不会读不到预期上下文——没有实测过,需要真实回归验证。 * 用户的决定是"先跑轻量版看效果,不行再上完整 org graph"(46 号文档),这就是那次实验。 * + * ── 真实回归暴露的缺口(T-mto6lp1c,2026-09-05):validate 没有 fail 出边 ────── + * 上面那段"没有真实验证过的假设"应验了:`validate`(`org-validator`)在这张裸 `agent_call` + * 图里没有独立的 `validator-triager`/`benchmark-validator` 子节点可用(那是 `system/ + * work-graph-templates/validator.json` 定义的、只有 `org-validator` 是 `subgraph` 节点时 + * 才会展开的一张子图)——它只能在**同一次调用**里把归因也做了,产出即是 + * `validator-triager/v1` 那份 schema。归因判定 `verdict: "attributed"`(`FAILING_VERDICTS` + * 之一,见 `graph-engine.js`)时,这个节点本身就算失败,需要一条 fail 边才有地方去。 + * 原先这张图只有 `impl --success--> validate` 一条边,`validate` 判失败后 + * "没有可走的 fail 边",直接落 blocked 等人工——不管失败原因是契约本身不可执行 + * (`input_underspecified`,回去重新分解/重新实现都可能自愈)还是真的 impl 有 bug, + * 一次都翻不了身。补一条 `validate --fail--> impl` 回退边,走的是仓库里所有 + * 执行型骨架统一的样子(`system/work-graph-templates/executor.json` 的 + * `{orgNode}-validator --fail--> {orgNode}-impl`),熔断由引擎既有的 + * `LOOP_HARD_CAP_EDGE`/`LOOP_HARD_CAP_RUN` 兜底,不会死循环。 + * * @param {{companyId: string, task: object}} ctx task 是那条 decomposed_work 子任务本身 * @returns {string} 新建的 work graph id */ @@ -2338,7 +2497,10 @@ function createDecomposedWorkGraph({ companyId, task }) { { nodeName: 'impl', systemAgentId: 'org-coder', nodeKind: 'agent_call' }, { nodeName: 'validate', systemAgentId: 'org-validator', nodeKind: 'agent_call' }, ], - edges: [{ from: 'impl', to: 'validate', condition: 'success' }], + edges: [ + { from: 'impl', to: 'validate', condition: 'success' }, + { from: 'validate', to: 'impl', condition: 'fail', edgeKind: 'backward' }, + ], }); if (!g.ok) { throw new Error(`createDecomposedWorkGraph: 建图失败: ${JSON.stringify(g.errors)}`); @@ -2983,6 +3145,10 @@ module.exports = { // 46 号文档 Phase 3(v2):CEO 派发 decomposed_work 子任务 materializeDecomposedSubtasks, createDecomposedWorkGraph, + // 46 号文档 Phase 4:父任务让位给子任务执行 + 父子终态汇总 + markTaskDecomposed, + settleDecomposedParentStatus, + DECOMPOSED_SIBLING_CONCURRENCY, // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 claimQueuedTask, blockQueuedTask, diff --git a/server/tests/unit/goal-task-model.test.js b/server/tests/unit/goal-task-model.test.js index 839768a..f83dd17 100644 --- a/server/tests/unit/goal-task-model.test.js +++ b/server/tests/unit/goal-task-model.test.js @@ -24,6 +24,7 @@ import { RUN_TERMINAL_TO_TASK_STATUS, queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText, dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, readSlots, + decomposedProgressLabel, } from '../../../web/src/components/graph-console/goal-task-model.js'; const T0 = Date.parse('2026-08-18T10:00:00.000Z'); @@ -42,6 +43,10 @@ describe('状态样式表', () => { expect(taskStyle(undefined).label).toBe('—'); expect(goalStyle('whatever').hollow).toBe(true); }); + + it('46 号文档 Phase 4:decomposed 复用 running 同款蓝色 + 脉冲', () => { + expect(taskStyle('decomposed')).toEqual({ color: '#0069d9', hollow: false, label: '拆分执行中', pulse: true }); + }); }); describe('墙钟', () => { @@ -86,6 +91,32 @@ describe('goal 行进度', () => { }); }); +describe('46 号文档 Phase 5:decomposed 父任务的子任务进度', () => { + const parent = { taskId: 'T1', status: 'decomposed' }; + + it('按 parentTaskId + subtaskKind 过滤兄弟任务,只数 decomposed_work 子任务', () => { + const siblings = [ + parent, + { taskId: 'T2', parentTaskId: 'T1', subtaskKind: 'decomposed_work', status: 'completed' }, + { taskId: 'T3', parentTaskId: 'T1', subtaskKind: 'decomposed_work', status: 'running' }, + // 同一个 goal 里的无关任务,不该被数进去 + { taskId: 'T4', parentTaskId: 'T1', subtaskKind: 'acceptance_item', status: 'completed' }, + { taskId: 'T5', status: 'completed' }, + ]; + expect(decomposedProgressLabel(parent, siblings)).toBe('1/2 子任务完成'); + }); + + it('父任务不是 decomposed 状态时不显示(避免终态之后还挂着这行)', () => { + const siblings = [{ taskId: 'T2', parentTaskId: 'T1', subtaskKind: 'decomposed_work', status: 'completed' }]; + expect(decomposedProgressLabel({ taskId: 'T1', status: 'completed' }, siblings)).toBe(''); + }); + + it('没有子任务 / 空输入时不显示', () => { + expect(decomposedProgressLabel(parent, [])).toBe(''); + expect(decomposedProgressLabel(null, [])).toBe(''); + }); +}); + describe('依赖文案', () => { const sibs = [ { taskId: 'T-1', seq: 1, title: '搭骨架' }, diff --git a/server/tests/unit/graph-engine.test.js b/server/tests/unit/graph-engine.test.js index 1831928..d018d56 100644 --- a/server/tests/unit/graph-engine.test.js +++ b/server/tests/unit/graph-engine.test.js @@ -1259,4 +1259,78 @@ describe('graph-engine', () => { expect(wdb.getRun(runId).pause_requested).toBe(1); }); }); + + // ────────────────────────────────────────────────────────────── + // 46 号文档 Phase 4 追加发现:decomposed_work 轻量图的 validate→impl 退回边 + // + // 背景:真实回归撞见 org-validator 对同一种 benchmark-only 契约判断不稳定——4/5 次 + // 正确按 agent.md 指示自己生成 attempt-n.scores.json 再判,1 次误把源码路径当 --target + // 导致 target_not_json、反手把责任推给 subtask-planning。`createDecomposedWorkGraph` + // (work-graph-db.js)随后补了 `validate --fail--> impl`(edgeKind:'backward')这条边, + // 让这类判断失误能拿到"回退+带着上一次失败原因重试"的机会,而不是永久卡死在 blocked。 + // 这条边完全复用引擎已有的回退基础设施(本文件前面「回退熔断」系列已经验证过的同一套 + // 机制:backward_count 双层硬顶 + remediation_note 自动喂给下一轮执行者),不是新代码路径, + // 这里只验证 `createDecomposedWorkGraph` 产出的这张具体图确实正确接上了它。 + // ────────────────────────────────────────────────────────────── + describe('46 号文档 Phase 4 追加发现:createDecomposedWorkGraph 的 validate→impl 退回边', () => { + function buildDecomposedRun(companyId) { + ensureAgents(['org-coder', 'org-validator']); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, companyId, title: 't', description: 'd' }); + const graphId = wdb.createDecomposedWorkGraph({ companyId, task }); + const run = wdb.createRun({ + graphKind: 'work', graphId, companyId, goalId: goal.id, taskId: task.id, executionMode: 'auto', + }); + engine.markRunReady(run.id); + return { graphId, runId: run.id }; + } + + it('图定义里确实有 validate --fail--> impl 这条 backward 边(不止节点形状,边也要对)', () => { + const { graphId } = buildDecomposedRun(nextCompany()); + const def = wdb.readDefGraph('work', graphId); + const back = def.edges.filter((e) => e.edgeKind === 'backward'); + expect(back).toHaveLength(1); + expect(back[0].condition).toBe('fail'); + const nodeName = (id) => def.nodes.find((n) => n.defNodeId === id).name; + expect(nodeName(back[0].from)).toBe('validate'); + expect(nodeName(back[0].to)).toBe('impl'); + }); + + it('validate 判 fail 后真的退回 impl 重跑,remediation_note 带给下一轮 impl,第二轮 validate 判过后 run 收敛成 completed', async () => { + const companyId = nextCompany(); + const { runId } = buildDecomposedRun(companyId); + const calls = fakeExecutor({ + impl: { status: 'success' }, + validate: (nth) => (nth === 1 + ? { status: 'fail', remediationNote: '把源码当 target 了,请自己生成 scores.json 再判' } + : { status: 'success' }), + }); + + await engine.startRun(runId, { mode: 'auto' }).driver; + + expect(wdb.getRun(runId).run_status).toBe('completed'); + expect(wdb.getRun(runId).backward_count).toBe(1); + expect(calls.filter((c) => c.node === 'impl')).toHaveLength(2); + expect(calls.filter((c) => c.node === 'validate')).toHaveLength(2); + // 反思闭环的关键一环:第二次 impl 执行真的读到了上一轮 validate 的失败原因 + expect(calls.filter((c) => c.node === 'impl' && c.remediationNote + === '把源码当 target 了,请自己生成 scores.json 再判')).toHaveLength(1); + }); + + it('validate 连续判 fail 撞双层硬顶时收敛成 blocked,不会无限重试(复用引擎已有的回退熔断,不是新安全网)', async () => { + const companyId = nextCompany(); + const { runId } = buildDecomposedRun(companyId); + const calls = fakeExecutor({ + impl: { status: 'success' }, + validate: { status: 'fail', remediationNote: '一直判不过' }, + }); + + await engine.startRun(runId, { mode: 'auto' }).driver; + + expect(wdb.getRun(runId).run_status).toBe('blocked'); + // 单边硬顶:同一条 backward 边第 4 次不再调用 agent,直接 force_terminated + expect(calls.filter((c) => c.node === 'validate')).toHaveLength(4); + expect(calls.filter((c) => c.node === 'impl')).toHaveLength(4); + }); + }); }); diff --git a/server/tests/unit/subtask-planning-materialize.test.js b/server/tests/unit/subtask-planning-materialize.test.js index 21a14e7..06ff2b7 100644 --- a/server/tests/unit/subtask-planning-materialize.test.js +++ b/server/tests/unit/subtask-planning-materialize.test.js @@ -225,6 +225,136 @@ describe('46 号文档 Phase 1:subtask-plan.json 共享区落地 + 任务级å… expect(content.experience_refs).toEqual([]); expect(content.research_used).toBe(false); }); + + // ── 46 号文档 Phase 4:拆分成功后接管父任务的执行权 ─────────────────── + + describe('Phase 4:拆分成功后取消父 run + markTaskDecomposed', () => { + const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }; + + it('materialize 出 ≥1 条子任务后,父 run 被 stopRun 取消,父任务转 decomposed', async () => { + const companyId = 'co-subplan-p4-1'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const run = createRunRow({ + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued', + }); + wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id }); + + subtaskPlanning.setPlanSubtasks(async () => ({ + steps: [ + { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + ], + tokens: 10, + })); + try { + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); + } finally { + subtaskPlanning.setPlanSubtasks(null); + } + + expect(wdb.getRun(run.id).run_status).toBe('stopped'); + expect(wdb.getTask(task.id).status).toBe('decomposed'); + + const children = wdb.getDb() + .prepare(`SELECT id FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) + .all(task.id); + expect(children).toHaveLength(2); + }); + + // 真实回归当场撞见的场景:task-scheduler 抢坑几乎是瞬间的,subtask-planning 是真实 + // LLM 调用(数十秒),等它拆完回来时父任务/父 run 几乎必然已经是 running 了,不是 + // queued——这是最常见的路径,不是边缘情形,第一版实现(只认 queued)在这里当场翻车: + // markTaskDecomposed 拿不到锁,但 stopRun 依然把真实在跑的 run 取消掉,父任务卡成 + // cancelled、5 个子任务变成没人认领的孤儿。修复后必须在这个场景下正确接管。 + it('父 run 已经真实跑到 running(最常见的时序):依然能正确接管,不会卡成 cancelled', async () => { + const companyId = 'co-subplan-p4-4'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const run = createRunRow({ + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued', + }); + // 模拟 task-scheduler 已经把它推进了活跃集(queued → ready → running) + wdb.updateRunStatus(run.id, 'queued', 'ready'); + wdb.updateRunStatus(run.id, 'ready', 'running'); + wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: new Date().toISOString() }); + + subtaskPlanning.setPlanSubtasks(async () => ({ + steps: [ + { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + ], + tokens: 10, + })); + try { + await subtaskPlanning.triggerSubtaskPlanning({ run: wdb.getRun(run.id), task }); + } finally { + subtaskPlanning.setPlanSubtasks(null); + } + + // 关键断言:不是 cancelled(旧 bug 的症状),是 decomposed + expect(wdb.getTask(task.id).status).toBe('decomposed'); + expect(wdb.getRun(run.id).run_status).toBe('stopped'); + + const children = wdb.getDb() + .prepare(`SELECT id FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`) + .all(task.id); + expect(children).toHaveLength(2); + }); + + it('拆分产出 0 条有效子任务时(全部缺 acceptance)不接管:父 run/父任务原样不动', async () => { + const companyId = 'co-subplan-p4-2'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const run = createRunRow({ + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued', + }); + wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id }); + + // 缺 acceptance → materializeDecomposedSubtasks 里 createTask 会拒,单步跳过,整批为空 + subtaskPlanning.setPlanSubtasks(async () => ({ + steps: [{ seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }], + tokens: 5, + })); + try { + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); + } finally { + subtaskPlanning.setPlanSubtasks(null); + } + + expect(wdb.getRun(run.id).run_status).toBe('queued'); // 没被取消 + expect(wdb.getTask(task.id).status).toBe('queued'); // 没被接管 + }); + + it('父任务在拆分成功之前就已经赢了竞态跑完:不覆盖已有的终态', async () => { + const companyId = 'co-subplan-p4-3'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const run = createRunRow({ + graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued', + }); + // 模拟「父 run 抢跑赢了竞态」:run 与 task 都已经落到终态 + wdb.updateRunStatus(run.id, 'queued', 'ready'); + wdb.updateRunStatus(run.id, 'ready', 'running'); + wdb.updateRunStatus(run.id, 'running', 'completed'); + wdb.updateTask(task.id, { status: 'completed', currentRunId: run.id }); + + subtaskPlanning.setPlanSubtasks(async () => ({ + steps: [{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }], + tokens: 3, + })); + try { + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); + } finally { + subtaskPlanning.setPlanSubtasks(null); + } + + // stopRun 对已终态 run 是 no-op;markTaskDecomposed 的 CAS(WHERE status='queued') + // 拿不到锁——父任务保持它已经赢下的 completed,不会被拆分结果覆盖 + expect(wdb.getRun(run.id).run_status).toBe('completed'); + expect(wdb.getTask(task.id).status).toBe('completed'); + }); + }); }); describe('46 号文档 Phase 2:defaultPlanSubtasks 编排(经验检索 + 按需 deep research)', () => { diff --git a/server/tests/unit/task-decomposition-phase4.test.js b/server/tests/unit/task-decomposition-phase4.test.js new file mode 100644 index 0000000..bbbbf7f --- /dev/null +++ b/server/tests/unit/task-decomposition-phase4.test.js @@ -0,0 +1,356 @@ +/** + * Unit Tests — 46 号文档 Phase 4:预算均分 + 父子终态汇总 + 同父子任务独立并发坑位 + * + * 覆盖三块新机制: + * 1. `materializeDecomposedSubtasks` 把父任务预算按步数均分给每条子任务 + * 2. `markTaskDecomposed` / `settleDecomposedParentStatus` 的 CAS 语义 + * 3. `updateRunStatus` 里新增的「同父 decomposed_work 子任务最多 3 并行」坑位, + * 与公司级 `max_parallel_tasks` 完全独立 + * 4. `task-settle.settleParentTaskIfAllChildrenDone`(父子终态汇总) + * + * 隔离手法同 `task-decomposition-dispatch.test.js`:先设 GRAPH_DB_PATH 再 require。 + */ +import { + describe, it, expect, beforeAll, afterAll, beforeEach, +} from 'vitest'; +import fs from 'fs'; + +const TEST_DB_PATH = '/tmp/test-task-decomp-phase4.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const MODULES = [ + '../../services/graph-db', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/task-settle', +]; + +const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }; + +describe('46 号文档 Phase 4', () => { + let wdb; + let settle; + + beforeAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + wdb = require('../../services/work-graph-db'); + settle = require('../../services/task-settle'); + }); + + afterAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + delete process.env.GRAPH_DB_PATH; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + beforeEach(() => { + const db = wdb.getDb(); + db.pragma('foreign_keys = OFF'); + db.exec(` + DELETE FROM graph_run_nodes; + DELETE FROM graph_runs; + DELETE FROM tasks; + DELETE FROM goals; + DELETE FROM system_agents; + `); + db.pragma('foreign_keys = ON'); + }); + + let seq = 0; + function nextCompany() { return `p4-co-${++seq}`; } + + // ── 1. 预算均分 ────────────────────────────────────────────────────── + + describe('materializeDecomposedSubtasks:预算均分', () => { + it('父任务有 budget_json 时,按步数均分给每条子任务', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ + goalId: goal.id, title: 'p', description: 'd', + budgetJson: { total_agent_calls: 100, total_tokens: 5000000, wallclock_ms: 7200000 }, + }); + const steps = [ + { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 4, title: '第四步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + ]; + const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps }); + expect(ids).toHaveLength(4); + for (const id of ids) { + const budget = JSON.parse(wdb.getTask(id).budget_json); + expect(budget.total_agent_calls).toBe(25); // 100 / 4 + expect(budget.total_tokens).toBe(1250000); // 5000000 / 4 + expect(budget.wallclock_ms).toBe(1800000); // 7200000 / 4 + expect(budget.spent).toEqual({ agent_calls: 0, tokens: 0 }); + } + }); + + it('父任务没有 budget_json 时,退到 DEFAULT_TASK_BUDGET 的形状再均分', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' }); + const steps = [ + { seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 2, title: '二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + ]; + const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps }); + expect(ids).toHaveLength(2); + const budget = JSON.parse(wdb.getTask(ids[0]).budget_json); + expect(budget.total_agent_calls).toBe(50); // 100(默认)/ 2 + }); + + it('均分结果保底至少 1,不会因为步数多而算出 0', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ + goalId: goal.id, title: 'p', description: 'd', + budgetJson: { total_agent_calls: 1, total_tokens: 1, wallclock_ms: 1 }, + }); + const steps = [ + { seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 2, title: '二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + { seq: 3, title: '三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, + ]; + const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps }); + for (const id of ids) { + const budget = JSON.parse(wdb.getTask(id).budget_json); + expect(budget.total_agent_calls).toBeGreaterThanOrEqual(1); + expect(budget.total_tokens).toBeGreaterThanOrEqual(1); + expect(budget.wallclock_ms).toBeGreaterThanOrEqual(1); + } + }); + }); + + // ── 2. markTaskDecomposed / settleDecomposedParentStatus ──────────── + + describe('markTaskDecomposed / settleDecomposedParentStatus:CAS 语义', () => { + it('从 queued 转:成功后 status 变 decomposed', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + wdb.updateTask(task.id, { status: 'queued' }); + + expect(wdb.markTaskDecomposed(task.id)).toBe(true); + expect(wdb.getTask(task.id).status).toBe('decomposed'); + + // 已经是 decomposed 了,重复调用拿不到锁 + expect(wdb.markTaskDecomposed(task.id)).toBe(false); + }); + + // 真实回归纠正过的行为:task-scheduler 抢坑几乎是瞬间的(毫秒级),而 subtask-planning + // 是真实 LLM 调用(数十秒),等它拆完回来时父任务几乎必然已经是 running 而不是 queued + // 了——这不是小概率分支,是最常见的那条路径,必须能从 running 接管,否则拆分结果 + // 几乎每次都接管失败、白白物化出一堆没人认领的孤儿子任务(真实回归当场撞见过一次)。 + it('从 running 转:真实场景里最常见的路径,也能成功接管', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + wdb.updateTask(task.id, { status: 'running' }); + + expect(wdb.markTaskDecomposed(task.id)).toBe(true); + expect(wdb.getTask(task.id).status).toBe('decomposed'); + }); + + it('从 blocked 转:同样还没到终态,可以接管', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + wdb.updateTask(task.id, { status: 'blocked' }); + + expect(wdb.markTaskDecomposed(task.id)).toBe(true); + expect(wdb.getTask(task.id).status).toBe('decomposed'); + }); + + it.each(['completed', 'failed', 'cancelled'])( + '父任务已经是终态(%s)时天然拿不到锁(真赢了竞态的情形,不覆盖)', + (terminalStatus) => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + wdb.updateTask(task.id, { status: terminalStatus }); // 模拟父 run 已经先跑完/失败/取消了 + expect(wdb.markTaskDecomposed(task.id)).toBe(false); + expect(wdb.getTask(task.id).status).toBe(terminalStatus); // 没被覆盖 + }, + ); + + it('settleDecomposedParentStatus 只能从 decomposed 转终态,CAS 挡重复结算', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + wdb.updateTask(task.id, { status: 'decomposed' }); + + expect(wdb.settleDecomposedParentStatus(task.id, 'completed')).toBe(true); + expect(wdb.getTask(task.id).status).toBe('completed'); + expect(wdb.getTask(task.id).finished_at).toBeTruthy(); + + // 已经结算过,第二次调用(哪怕传别的状态)拿不到锁,不会覆盖 + expect(wdb.settleDecomposedParentStatus(task.id, 'failed')).toBe(false); + expect(wdb.getTask(task.id).status).toBe('completed'); + }); + + it('settleDecomposedParentStatus 只接受 completed/failed,别的值直接抛', () => { + expect(() => wdb.settleDecomposedParentStatus('whatever-id', 'blocked')).toThrow(/只能是/); + }); + }); + + // ── 3. 同父 decomposed_work 子任务的独立并发坑位(上限 3) ────────── + + describe('updateRunStatus:同父 decomposed_work 子任务并发上限 3', () => { + function makeChild(companyId, parentId, i) { + return wdb.createTask({ + goalId: wdb.getTask(parentId).goal_id, companyId, title: `子任务 ${i}`, description: 'd', + parentTaskId: parentId, subtaskKind: 'decomposed_work', acceptanceContract: MIN_ACCEPTANCE, + }); + } + + it('前 3 个同父子任务的 run 都能进 ready,第 4 个撞 RunSlotBusyError(scope=decomposed-sibling)', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' }); + + const runs = []; + for (let i = 1; i <= 4; i += 1) { + const child = makeChild(companyId, parent.id, i); + const run = wdb.createRun({ + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued', + }); + runs.push(wdb.getRun(run.id)); + } + + for (let i = 0; i < 3; i += 1) { + expect(wdb.updateRunStatus(runs[i].id, 'queued', 'ready')).toBe(1); + } + + let caught = null; + try { + wdb.updateRunStatus(runs[3].id, 'queued', 'ready'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(wdb.RunSlotBusyError); + expect(caught.scope).toBe('decomposed-sibling'); + expect(caught.max).toBe(wdb.DECOMPOSED_SIBLING_CONCURRENCY); + expect(caught.heldBy).toHaveLength(3); + + // 第 4 条依旧是 queued(没被误占) + expect(wdb.getRun(runs[3].id).run_status).toBe('queued'); + }); + + it('decomposed_work 子任务的 run 不计入公司级 countActiveRootRuns', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' }); + const child = makeChild(companyId, parent.id, 1); + const run = wdb.createRun({ + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued', + }); + wdb.updateRunStatus(run.id, 'queued', 'ready'); + expect(wdb.countActiveRootRuns(companyId)).toBe(0); + }); + + it('公司级坑位(默认 1)被普通任务占满时,decomposed_work 子任务仍能独立进坑(互不占用)', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + + // 一条普通任务占满公司级坑位(默认 max_parallel_tasks=1) + const ordinary = wdb.createTask({ goalId: goal.id, companyId, title: 'o', description: 'd' }); + const ordinaryRun = wdb.createRun({ + graphKind: 'org', graphId: companyId, companyId, taskId: ordinary.id, runStatus: 'queued', + }); + expect(wdb.updateRunStatus(ordinaryRun.id, 'queued', 'ready')).toBe(1); + expect(wdb.countActiveRootRuns(companyId)).toBe(1); + + // decomposed_work 子任务照样能进坑——不共用这个池子 + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' }); + const child = makeChild(companyId, parent.id, 1); + const childRun = wdb.createRun({ + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued', + }); + expect(wdb.updateRunStatus(childRun.id, 'queued', 'ready')).toBe(1); + }); + + it('不同父任务的 decomposed_work 子任务各自独立计数,互不占用彼此的 3 个坑位', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parentA = wdb.createTask({ goalId: goal.id, companyId, title: 'pa', description: 'd' }); + const parentB = wdb.createTask({ goalId: goal.id, companyId, title: 'pb', description: 'd' }); + + // parentA 占满 3 个坑位 + for (let i = 1; i <= 3; i += 1) { + const child = makeChild(companyId, parentA.id, i); + const run = wdb.createRun({ + graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued', + }); + expect(wdb.updateRunStatus(run.id, 'queued', 'ready')).toBe(1); + } + + // parentB 的第一个子任务不受影响 + const childB = makeChild(companyId, parentB.id, 1); + const runB = wdb.createRun({ + graphKind: 'work', graphId: 'placeholder', companyId, taskId: childB.id, runStatus: 'queued', + }); + expect(wdb.updateRunStatus(runB.id, 'queued', 'ready')).toBe(1); + }); + }); + + // ── 4. settleParentTaskIfAllChildrenDone(父子终态汇总) ──────────── + + describe('task-settle.settleParentTaskIfAllChildrenDone', () => { + function setupParentWithChildren(statuses) { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' }); + wdb.updateTask(parent.id, { status: 'decomposed' }); + const children = statuses.map((status, i) => { + const c = wdb.createTask({ + goalId: goal.id, companyId, title: `c${i}`, description: 'd', + parentTaskId: parent.id, subtaskKind: 'decomposed_work', acceptanceContract: MIN_ACCEPTANCE, + }); + wdb.updateTask(c.id, { status }); + return c; + }); + return { companyId, goal, parent, children }; + } + + it('还有子任务未到终态时返回 null,父任务保持 decomposed', () => { + const { parent } = setupParentWithChildren(['completed', 'running']); + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull(); + expect(wdb.getTask(parent.id).status).toBe('decomposed'); + }); + + it('全部子任务 completed → 父任务结算成 completed', async () => { + const { parent, goal } = setupParentWithChildren(['completed', 'completed']); + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('completed'); + expect(wdb.getTask(parent.id).status).toBe('completed'); + // goal 收敛的 CAS 占位值也应该已经写下去(不再是 active)——见函数注释里 + // "父任务转终态不会自动触发 goal 收敛,必须补一次调用" 那段 + expect(wdb.getGoal(goal.id).status).not.toBe('active'); + // 让可能的异步验收(若被触发)跑完,避免测试进程留下悬空的 unhandled work + await new Promise((r) => setImmediate(r)); + }); + + it('有子任务 failed → 父任务结算成 failed', async () => { + const { parent } = setupParentWithChildren(['completed', 'failed']); + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('failed'); + expect(wdb.getTask(parent.id).status).toBe('failed'); + await new Promise((r) => setImmediate(r)); + }); + + it('没有子任务时返回 null', () => { + const companyId = nextCompany(); + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' }); + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull(); + }); + + it('并发调用只有一次生效(CAS):第二次调用返回 null', async () => { + const { parent } = setupParentWithChildren(['completed']); + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('completed'); + expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull(); + await new Promise((r) => setImmediate(r)); + }); + }); +}); diff --git a/system/agents/ceo/heartbeat.md b/system/agents/ceo/heartbeat.md index 8606a84..1e205bf 100644 --- a/system/agents/ceo/heartbeat.md +++ b/system/agents/ceo/heartbeat.md @@ -21,8 +21,11 @@ `draft` 的子任务全部 `enqueue` 是安全的,先后顺序、并发几个都是调度器的活,不是你的。 - 派一条:`POST /tasks//enqueue`(跟你派自己提的草稿任务是同一个端点,同一条权限)。 - 这批子任务会走一张**轻量图**(只有实现+验证两步,不是公司完整的组织架构那套), - 成本比一条顶层 task 低很多,但目前还没有正式跑过真实验证——如果发现同一个 task 下的 + 成本比一条顶层 task 低很多,已经做过真实端到端回归(48 号文档)——如果发现同一个 task 下的 子任务频繁失败或者结果质量不行,在回复里如实说,不要自己想办法绕过去。 +- **父任务会变成 `decomposed` 状态**:一条 task 一旦被 `subtask-planning` 成功拆出子任务, + 它自己那条完整组织架构流程会被取消,状态显示 `decomposed`,正常现象,不是卡住/异常, + 终态会在全部子任务做完后自动汇总回父任务,**不需要你手动做任何事**。 ## 发现问题时 diff --git a/system/agents/org-validator/agent.md b/system/agents/org-validator/agent.md index 99bea1d..5ed2232 100644 --- a/system/agents/org-validator/agent.md +++ b/system/agents/org-validator/agent.md @@ -55,6 +55,53 @@ validator-triager → benchmark-validator → testdata-validator → … --- +## ⚠️ 一种你可能被裸调用的情况:没有 subgraph,只有你自己 + +上面整节"你自己的 work graph"说的是**公司完整 org graph**里的你——那里你是 `subgraph` 节点, +`validator-triager → benchmark-validator → …` 是引擎真的展开出来的独立子节点,各自单独调用。 + +**但 `subtask-planning` 拆出的 `decomposed_work` 子任务不走完整 org graph**(48 号文档), +起的是一张只有两个节点的裸图:`impl(org-coder) --success--> validate(org-validator)`, +**你在这张图里是 `agent_call`,不是 `subgraph`**——没有人会替你另起一个 `validator-triager` +调用,`benchmark-validator`/`testdata-validator` 更是压根不存在。判断"这次调用有没有那张 +子图"看提示词里给的节点类型/上下文,拿不准就看契约来源:`acceptance.json` 的 +`created_by` 是 `subtask-planning` 时,十有八九是这种裸调用场景。 + +**这种时候,`validator-triager` + `benchmark-validator` 这几个角色的活,都要你一个人在同一次 +调用里做完**——不是"跳过不做",也不是"自己认定过了就完事": + +1. **仍然先归因(扮演 validator-triager 的职责)**:提示词里如果带着上一轮的 + `remediation_note`/失败上下文,先判"这次退回来是谁的锅";没有上一轮上下文就是首轮, + 等价于 `no_prior_failure`,直接进入第 2 步。 +2. **`benchmark` 段仍然必须走执行器判、不许绕过(扮演 benchmark-validator 的职责)**: + 契约里的 `benchmark` 段要求 `--target` 是一份自报分数 JSON(`{"scores": {...}}`), + 但这张裸图里**没有任何节点会替你产出这份文件**——`org-coder` 不产出、也不该产出 + (标准和打分本该分离,见 `benchmark-validator/agent.md`"为什么要这么切")。 + 正确做法:**你自己**按 `system/agents/benchmark-validator/agent.md` Step 2 的方法 + (逐维度找证据、对照 `pass_threshold` 给分,找不到证据给 0 分,不许因为"看起来做了不少" + 就估分),把分数写成 `attempt-.scores.json` 落到你自己的节点目录, + 再跑 `node server/services/goal-contract.js run --target <这份文件> --run-id <本次 run_id>` + 拿到**真正由执行器算出来**的 `verdict_id`。 + 🚫 **不要把 `impl` 交付物本身(代码文件/项目目录)当 `--target` 喂给执行器**—— + 那必然读不出 JSON,只会拿到 `target_not_json`,这正是本次两条任务链卡死的直接原因 + (T-mto6lp1c,2026-09-05:两次都是把源码路径/项目根目录当 target,退出码 2)。 + 🚫 **也不要因为"暂时跑不出判决"就跳过执行器、自己写个 `audit_verdict: pass` 收尾**—— + Step 2 的红线("契约不全时绝不许自己发明标准再判 pass")在这里同样成立: + 你可以自己**供证据/供分数**,但让分数达不达标这件事必须仍然经过 `goal-contract.js` + 算出来,不能自由心证。 +3. **契约本身真的不可执行时(`validate` 退出 1/2,且换哪个证据来源都补不出分数)**, + 才归为 `input_underspecified`:这时你没有 `org-planner` 可退,只有 `impl` 一个上游节点—— + `fail_reason: "input_underspecified"` 正常写,`action: "backward"`,**不用**填 + `target_node`/`target_org_node`(这张裸图里只有你和 `impl` 两个节点,没有更细的粒度可指), + 引擎会退回 `impl` 重跑一轮(`createDecomposedWorkGraph` 已经补了这条 `validate --fail--> impl` + 回退边)。`remediation_note` 照常写清楚缺什么、下一轮谁该做什么。 + +一句话概括:**裸调用时你不比平时少做事,只是把平时分给几个子节点的活攒在一次调用里做**—— +归因照做、按契约取证据打分照做、走执行器拿真判决照做,唯独不产出 `target_node` 这类跨图地址 +(这张裸图没有能指的更细粒度)。 + +--- + ## ⚡ 强制工作流(六步) ### Step 1 · 读 task-goal 和验收契约 diff --git a/system/agents/org-validator/tools.md b/system/agents/org-validator/tools.md index d92e384..9672690 100644 --- a/system/agents/org-validator/tools.md +++ b/system/agents/org-validator/tools.md @@ -5,6 +5,12 @@ > 🚨 **发 JSON 的铁律**:一律用文件写入工具直接写文件,**绝不在 bash 命令里内联 JSON 字符串** > (多重双引号会让工具调用崩溃并陷入死循环)。JSON 里不许有真实换行符,`\n` 要转义。 +> ⚠️ **被裸调用(`agent_call`,不是 `subgraph`)时**,`benchmark` 段不能拿 `impl` 的交付物 +> 路径(代码文件/项目目录)直接当 `--target` 跑——那必然 `target_not_json`。你要先自己按 +> `benchmark-validator/agent.md` Step 2 的方法取证据、打分,把分数写成 +> `$D/attempt-.scores.json`(`$D` 是你自己的节点目录),再拿**这份文件**当 `--target`。 +> 详细判断依据与步骤见 `agent.md`「一种你可能被裸调用的情况」一节。 + --- ## 1. 读验收契约(`artifacts/`,只读) diff --git a/web/src/components/graph-console/GoalTaskPane.vue b/web/src/components/graph-console/GoalTaskPane.vue index 4912918..755a3fc 100644 --- a/web/src/components/graph-console/GoalTaskPane.vue +++ b/web/src/components/graph-console/GoalTaskPane.vue @@ -101,6 +101,7 @@ :title="queueBadge(t).title" >{{ queueBadge(t).text }} {{ taskStyle(t.status).label }} + · {{ decomposedProgressLabel(t, g.tasks || []) }} · {{ taskSubtitle(t, now) }} @@ -155,6 +156,7 @@ import NewGoalForm from './NewGoalForm.vue'; import { goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel, queueBadge, blockedLabel, parseBlockedReason, taskActions, queueStat, dispatchedTaskId, + decomposedProgressLabel, } from './goal-task-model.js'; const props = defineProps({ diff --git a/web/src/components/graph-console/goal-task-model.js b/web/src/components/graph-console/goal-task-model.js index c26fba9..3b4bb9d 100644 --- a/web/src/components/graph-console/goal-task-model.js +++ b/web/src/components/graph-console/goal-task-model.js @@ -23,6 +23,10 @@ export const TASK_STYLE = { failed: { color: '#d33', hollow: false, label: '失败' }, blocked: { color: '#e08a00', hollow: false, label: '挂起' }, cancelled: { color: '#bbb', hollow: false, label: '已取消' }, + // 46 号文档 Phase 4:父任务被 subtask-planning 拆出子任务后,自己那条完整 org graph + // 被取消,终态改由子任务汇总决定——业务语义上父任务仍"活着",只是执行体换了, + // 复用 running 同款蓝色+脉冲,不新造一个颜色。 + decomposed:{ color: '#0069d9', hollow: false, label: '拆分执行中', pulse: true }, }; /** goal 状态 → 圆点样式 + 中文标签。planning 借 running 的蓝并且脉冲:它是个真会持续几十秒的状态 */ @@ -436,6 +440,20 @@ export function goalProgressLabel(goal) { } /** + * 46 号文档 Phase 5:`decomposed` 状态的父任务旁边显示"N/M 子任务完成"——只读展示, + * 不做交互(认领/拖拽留给以后)。`siblings` 传同一个 goal 下的兄弟任务(跟 + * `parentTitle`/`blockedLabel` 同款用法),子任务在物化时就是同一个 goal_id, + * 不需要跨 goal 查。 + */ +export function decomposedProgressLabel(task, siblings = []) { + if (!task || task.status !== 'decomposed') return ''; + const children = siblings.filter((t) => t.parentTaskId === task.taskId && t.subtaskKind === 'decomposed_work'); + if (!children.length) return ''; + const done = children.filter((t) => t.status === 'completed').length; + return `${done}/${children.length} 子任务完成`; +} + +/** * `⇢` 的 hover 文案:「等 T1 完成」。 * 只认同 goal 内的兄弟 task;引用到已归档 / 已删的 id 时原样显示 id,不静默吞掉 —— * 「依赖一个不存在的 task」正是 dispatch 会 409 的原因,藏起来只会让人查不到。 diff --git a/web/src/components/task-board/TaskTable.vue b/web/src/components/task-board/TaskTable.vue index ed34338..0ba5aeb 100644 --- a/web/src/components/task-board/TaskTable.vue +++ b/web/src/components/task-board/TaskTable.vue @@ -48,6 +48,7 @@ {{ taskStyle(t.status).label }} {{ queueBadge(t).text }} + {{ decomposedProgressLabel(t, siblingsOf(t)) }}