Commit c3dcf031108cce303d6944bdc36aa7094a083e0a
Merge remote-tracking branch origin/main into main (deploy 2026-09-08)
Conflicts: system/agents/ceo/agent.md system/agents/ceo/soul.md
Showing
30 changed files
with
1612 additions
and
88 deletions
docs/README.md
| @@ -32,12 +32,20 @@ | @@ -32,12 +32,20 @@ | ||
| 32 | | [system-design/38-auth-multi-tenant-isolation.md](./done/system-design/38-auth-multi-tenant-isolation.md) | 登录鉴权 + 公司级多租户隔离 —— ✅ Phase 0/1/2/3 全部完成(2026-08-25)。种子账号 `admin`/`hex1234` + 登录页;全站挂 `requireAuth`(`agent-chat.js` 用 `requireAuthOrInternal` 放行三处服务端自环调用);`company.json` 加 `ownerId`,`canAccessCompany` 是全仓库唯一归属判据,`company.js`/`graph-run.js`/`devloop.js` 分别接入(`graph-run.js` 双挂载点参数形状不同,设计阶段描述不准确,实现时才发现);`okr.js` 因是零导航入口的孤儿页面改挂 `requireAdmin` 而非归属校验。**Phase 3 验证阶段真查出一个漏洞**:`agent-chat.js` 靠 `:agentId` 的 `"companyId:role"` 前缀区分会话归属却从没做过归属校验,任意登录用户可跨租户读别家公司会话,已修复。全量 `vitest` 1433 passed | | 32 | | [system-design/38-auth-multi-tenant-isolation.md](./done/system-design/38-auth-multi-tenant-isolation.md) | 登录鉴权 + 公司级多租户隔离 —— ✅ Phase 0/1/2/3 全部完成(2026-08-25)。种子账号 `admin`/`hex1234` + 登录页;全站挂 `requireAuth`(`agent-chat.js` 用 `requireAuthOrInternal` 放行三处服务端自环调用);`company.json` 加 `ownerId`,`canAccessCompany` 是全仓库唯一归属判据,`company.js`/`graph-run.js`/`devloop.js` 分别接入(`graph-run.js` 双挂载点参数形状不同,设计阶段描述不准确,实现时才发现);`okr.js` 因是零导航入口的孤儿页面改挂 `requireAdmin` 而非归属校验。**Phase 3 验证阶段真查出一个漏洞**:`agent-chat.js` 靠 `:agentId` 的 `"companyId:role"` 前缀区分会话归属却从没做过归属校验,任意登录用户可跨租户读别家公司会话,已修复。全量 `vitest` 1433 passed | |
| 33 | | [system-design/01-26](./done/system-design/) | 系统设计正文 01~26 章(14、25-harness-engineering-improvements 例外,见 todo/future/;27 例外,见 todo/),各章节状态见文内「状态」标记 | | 33 | | [system-design/01-26](./done/system-design/) | 系统设计正文 01~26 章(14、25-harness-engineering-improvements 例外,见 todo/future/;27 例外,见 todo/),各章节状态见文内「状态」标记 | |
| 34 | | [system-design/29-dream-design.md](./done/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | 34 | | [system-design/29-dream-design.md](./done/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | |
| 35 | +| [system-design/31-work-graph-fail-fallback-coverage.md](./done/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | | ||
| 36 | +| [system-design/32-hire-body-fill-reliability.md](./done/system-design/32-hire-body-fill-reliability.md) | 招聘装配的时间窗——✅ 方案 B 已实施(2026-08-21,评估与实施同一天,提交 `6a18379`)。承接 30 号文档 §2.1:招聘时的本体异步装配没有任何可靠性保证,写侧比读侧更严重——"这个角色永久卡死,没人知道"。`agent-session-lock.js`/`agent-body-filler.js`/`company.js`/`agent-chat.js` 改动落地,§四「风险/未决问题」1/2/3 三条已在实施时逐条拍板 | | ||
| 35 | | [system-design/33-needs-gate-role-filter-bug.md](./done/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | | 37 | | [system-design/33-needs-gate-role-filter-bug.md](./done/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | |
| 38 | +| [system-design/34-validator-scored-without-real-deliverable.md](./done/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完,结论已被 35 号文档吸收为改进提案并实施**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./done/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | | ||
| 36 | | [system-design/35-validation-rigor-and-body-readiness-proposal.md](./done/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | | 39 | | [system-design/35-validation-rigor-and-body-readiness-proposal.md](./done/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | |
| 37 | | [system-design/40-agent-chat-streaming-interrupt.md](./done/system-design/40-agent-chat-streaming-interrupt.md) | ✅ **已实现**(commit `4d23f50`,2026-08-30)。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。三个独立缺口全部补上:① 打字机不流畅→后端按字符数切片 + 前端 `createTypewriterQueue` 匀速吐出;② 执行中无法打断并接上新消息→`stop` 端点改轮询确认锁真正释放,前端据此直接发送/强制发送兜底,顺带修了服务端对同 convId 并发缺闸门这个真实 bug;③ 串台加固→`handleEvent` 抽成纯函数并补齐 `9b1c3f1` 那条不变式的回归测试。已用真实 Claude CLI 会话在浏览器验证 | | 40 | | [system-design/40-agent-chat-streaming-interrupt.md](./done/system-design/40-agent-chat-streaming-interrupt.md) | ✅ **已实现**(commit `4d23f50`,2026-08-30)。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。三个独立缺口全部补上:① 打字机不流畅→后端按字符数切片 + 前端 `createTypewriterQueue` 匀速吐出;② 执行中无法打断并接上新消息→`stop` 端点改轮询确认锁真正释放,前端据此直接发送/强制发送兜底,顺带修了服务端对同 convId 并发缺闸门这个真实 bug;③ 串台加固→`handleEvent` 抽成纯函数并补齐 `9b1c3f1` 那条不变式的回归测试。已用真实 Claude CLI 会话在浏览器验证 | |
| 38 | | [system-design/39-boss-agent-task-supervision.md](./done/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 全部已实现**(2026-08-30,`a00590a`/`5108d67`/`d967ac0`/`14cbaf5`)。boss agent(复用 `ceo`)监督 goal/task 看板闭环:draft task 回写、事件驱动+低频兜底唤醒、独立任务看板页、公司级 `target_dir`。**真实端到端测试**(建公司→下 goal→跑出一个 Playwright 5/5 验证通过的贪吃蛇游戏)撞出并记录了三个单测没覆盖的真问题,见文内「七、真跑复盘」;沉淀的待办已在 [42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) 全部收口 | | 41 | | [system-design/39-boss-agent-task-supervision.md](./done/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 全部已实现**(2026-08-30,`a00590a`/`5108d67`/`d967ac0`/`14cbaf5`)。boss agent(复用 `ceo`)监督 goal/task 看板闭环:draft task 回写、事件驱动+低频兜底唤醒、独立任务看板页、公司级 `target_dir`。**真实端到端测试**(建公司→下 goal→跑出一个 Playwright 5/5 验证通过的贪吃蛇游戏)撞出并记录了三个单测没覆盖的真问题,见文内「七、真跑复盘」;沉淀的待办已在 [42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) 全部收口 | |
| 39 | | [system-design/42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) | ✅ **Phase 1-4 全部已实现**(2026-08-30,`6ae1f76`/`2a60fe1`/`ae7650e`/`2a0ffb8`)。39 号文档真跑复盘沉淀的四 phase 可靠性收尾:① 卡住恢复闭环(服务器重启僵尸节点检测 + 强制判失败端点)② `ceo` 自建 task 的闸门穿透性回归测试 ③ `target_dir` 的 API/UI 配置入口(Phase 3 前端缺一次真实浏览器点击验证,见该节说明,是唯一已知缺口)④ `timeline.ndjson` 承接 planning/reflection 轨迹——`--artifact` 路径本身编码 `rootRunId`/`nodeRunId`,不用改任何 agent.md 或塞新环境变量 | | 42 | | [system-design/42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) | ✅ **Phase 1-4 全部已实现**(2026-08-30,`6ae1f76`/`2a60fe1`/`ae7650e`/`2a0ffb8`)。39 号文档真跑复盘沉淀的四 phase 可靠性收尾:① 卡住恢复闭环(服务器重启僵尸节点检测 + 强制判失败端点)② `ceo` 自建 task 的闸门穿透性回归测试 ③ `target_dir` 的 API/UI 配置入口(Phase 3 前端缺一次真实浏览器点击验证,见该节说明,是唯一已知缺口)④ `timeline.ndjson` 承接 planning/reflection 轨迹——`--artifact` 路径本身编码 `rootRunId`/`nodeRunId`,不用改任何 agent.md 或塞新环境变量 | |
| 43 | +| [system-design/43-task-decomposition-granularity.md](./done/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) | | ||
| 40 | | [system-design/44-writes-artifacts-produces-gate-mismatch.md](./done/system-design/44-writes-artifacts-produces-gate-mismatch.md) | ✅ **已修复**(2026-08-31)。43 号文档真实回归中发现:`writesArtifacts:true` 节点(`goal-benchmark`)写进共享 `artifacts/` 目录的产物,产出闸门只查节点私有目录,永远判「没产出」。修法:`readDeclaredProduces` 的 `allowedRoots` 纳入共享区 + `goal-benchmark` 补一份 `contract.json` 显式声明 | | 44 | | [system-design/44-writes-artifacts-produces-gate-mismatch.md](./done/system-design/44-writes-artifacts-produces-gate-mismatch.md) | ✅ **已修复**(2026-08-31)。43 号文档真实回归中发现:`writesArtifacts:true` 节点(`goal-benchmark`)写进共享 `artifacts/` 目录的产物,产出闸门只查节点私有目录,永远判「没产出」。修法:`readDeclaredProduces` 的 `allowedRoots` 纳入共享区 + `goal-benchmark` 补一份 `contract.json` 显式声明 | |
| 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 条新增,用真实回归捕获的产物做测试数据) | | ||
| 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-07)。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,不占公司级槽位)——四项机制全部用真实数据验证过,包括最后补跑的父子终态汇总(两个真实子任务全部 `completed` 后父任务被正确自动结算成 `completed`)与第二次并发确认。过程中撞出并修复一个几乎必现的真缺陷(`markTaskDecomposed` 原本只认父任务还是 `queued`,但 task-scheduler 抢坑是毫秒级、`subtask-planning` 是数十秒的真实 LLM 调用,等它拆完时父任务几乎必然已经 `running`,接管必然失败)。**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 条单测),详见文末补跑记录 | | ||
| 41 | 49 | ||
| 42 | ## todo/(当前主线) | 50 | ## todo/(当前主线) |
| 43 | 51 | ||
| @@ -56,16 +64,11 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | @@ -56,16 +64,11 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | ||
| 56 | | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | | 64 | | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | |
| 57 | | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | | 65 | | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | |
| 58 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | 66 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | |
| 59 | -| [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | | ||
| 60 | | [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)这个口子已按最保守方案补上(2026-08-31,见文内清单)——但堵不住 30 号文档记录的那次真实事故,那次事故点名的目标是下游节点,图模型本身就不允许 backward 边指向非祖先,属于修复范围之外 | | 67 | | [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)这个口子已按最保守方案补上(2026-08-31,见文内清单)——但堵不住 30 号文档记录的那次真实事故,那次事故点名的目标是下游节点,图模型本身就不允许 backward 边指向非祖先,属于修复范围之外 | |
| 61 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | 68 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | |
| 62 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | 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(训练产物 → 雇佣本体转换)尚未开工 | |
| 63 | -| [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | | ||
| 64 | -| [45-graph-run-node-liveness-recovery.md](./todo/system-design/45-graph-run-node-liveness-recovery.md) | ✅ **Part A/B/C 已实现**(2026-08-31),**Part D 有意延后**。真实事故:h3video 公司一个 `agent_call` 节点卡在 `status=running`、背后 CLI 进程已死,服务器主进程没重启——心跳(纯 LLM 驱动)、开机一次性孤儿扫描、goal/task 巡检(判据是 `blocked` 不是 `running`)、wallclock 看门狗(按预算不按存活)四层机制全盖不到;`ceo` 被转发问题后自己绕过引擎拉会话,陷入死循环。**Part A**:调查发现 `SessionPool` 是每次调用现造即回收的一次性对象,没有可查询的存活注册表,原设计不可行,改用「运行时长超阈值(默认 25 分钟)+ 只扫 `agent_call` 节点」,新函数 `sweepStaleRunningAgentCalls`/`startStaleAgentCallWatchdog`(`graph-engine.js`),5 条新单测。**Part B**:`force-fail` 端点(42 号文档已实现)本身就够用,零新后端代码,只在 `ceo/tools.md` 补了使用文档 + 明确禁止绕过引擎自拉会话。**Part C**:`ceo/heartbeat.md` 同步更新。**Part D**(节点补显式心跳字段,消除 Part A 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | | ||
| 65 | -| [47-itemized-subtask-materialization-goal-benchmark-fallback.md](./todo/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md) | ✅ **已修复**(2026-09-02)。46 号文档 Phase 0 真实回归坐实:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状(prd/sdd/goal-benchmark/planner-validator)下结构性产不出来,`itemized` 子任务物化 0 命中。修法:物化逻辑新增兜底,`acceptance.md` 缺失时改读已被 44 号文档证实稳定产出的 `goal-benchmark.json` 的 `dimensions[]`。`task-decomposition-materialize.test.js` 5 条全过(2 条新增,用真实回归捕获的产物做测试数据) | | ||
| 66 | -| [46-mandatory-planning-and-task-decomposition.md](./todo/system-design/46-mandatory-planning-and-task-decomposition.md) | 📝 **Phase 0-3 v2 均已实现且做过真实回归**(2026-09-02)。承接 43 号文档的追问:任务规划应是每个任务默认前置步骤,来源覆盖模型自身能力/知识库经验/线上 deep research。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索(扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭);真实回归揪出一个关键词打分噪音压过信号的真缺陷并修复。**Phase 3 走过一轮推翻重来**:v1("认领"pull 语义 + CAS)做完真实回归才发现"认领后没有触发源",讨论后确认"多方竞争"场景根本不存在(只有 CEO 一人拍板)——**v2 改成 CEO 心跳直接发现+派发(push)**,子任务走一张现造的轻量 work graph(`org-coder`+`org-validator` 两节点,跳过公司完整 org graph 的规划仪式)。v2 真实回归:CEO 心跳真的发现并派发了子任务,`org-coder` 产出质量达标,但 `org-validator` 卡在硬依赖的 `acceptance.json`(只有 `org-planner` 会产出,轻量图没有这个节点)——**验证这一半有真实的结构性缺口,后续方向见 [48 号文档](./todo/system-design/48-task-acceptance-contract-binding.md)**。验证过程顺手修了一个更大范围的真实 bug:两套心跳系统的自环调用一直缺内部认证令牌、暗中 401。Phase 4(独立开发验证循环)、Phase 5(看板 UI)未开工 | | ||
| 67 | -| [48-task-acceptance-contract-binding.md](./todo/system-design/48-task-acceptance-contract-binding.md) | ✅ **已实现并完成真实端到端回归**(decomposed_work 链路,2026-09-02,回归补于 2026-09-03)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 decomposed_work 子任务创建时必须 1:1 带一份验收契约"(`tasks.acceptance_contract` + `createTask` 统一校验 + `enqueueTaskRun` 落盘)。真实起 `subtask-planning` agent 验证时推翻了设计稿里的字段形状假设(真实产出是 benchmark 段本身,不是三段式 wrapper)并已修正。**补跑的真实端到端回归**:真实 `org-coder` → `org-validator` 全程跑通,`org-validator` 判词 `fail_reason=null`,不再 `input_underspecified`——过程中撞见两个纯属验证方法论本身(非代码)的坑(没起真实 API 服务、agent 子进程与服务进程不同源导致内部令牌对不上),修法与教训见文档「真实端到端回归补跑」。`task-planning`/`org-planner`/人类快速派单三条创建路径本轮不动;安全网(发现时就地补契约)仍只是原则 | | ||
| 68 | -| [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) | | 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`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | |
| 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 目前代码层没有硬顶 | | ||
| 69 | 72 | ||
| 70 | ## todo/future/(暂缓,非当前重心) | 73 | ## todo/future/(暂缓,非当前重心) |
| 71 | 74 |
docs/todo/system-design/31-work-graph-fail-fallback-coverage.md renamed to docs/done/system-design/31-work-graph-fail-fallback-coverage.md
docs/todo/system-design/32-hire-body-fill-reliability.md renamed to docs/done/system-design/32-hire-body-fill-reliability.md
docs/todo/system-design/34-validator-scored-without-real-deliverable.md renamed to docs/done/system-design/34-validator-scored-without-real-deliverable.md
docs/todo/system-design/43-task-decomposition-granularity.md renamed to docs/done/system-design/43-task-decomposition-granularity.md
docs/done/system-design/44-writes-artifacts-produces-gate-mismatch.md
| @@ -5,7 +5,7 @@ | @@ -5,7 +5,7 @@ | ||
| 5 | > 的 `agent.md`/`tools.md` 补上"写完共享产物后也在自己节点私有目录声明一份 `contract.json`" | 5 | > 的 `agent.md`/`tools.md` 补上"写完共享产物后也在自己节点私有目录声明一份 `contract.json`" |
| 6 | > 这一步。回归测试见 `graph-files-index.test.js` 的用例 ⑭ | 6 | > 这一步。回归测试见 `graph-files-index.test.js` 的用例 ⑭ |
| 7 | > **日期**: 2026-08-31 | 7 | > **日期**: 2026-08-31 |
| 8 | -> **发现场景**:[43 号文档](../../todo/system-design/43-task-decomposition-granularity.md) Phase 2 的真实端到端验证——新建 `task-decomp-verify-co` 公司,走一条真实 `itemized` 任务,org-planner 展开成 `system/work-graph-templates/planner.json` 那张 5 节点子图(真实 Claude CLI,非 mock) | 8 | +> **发现场景**:[43 号文档](./43-task-decomposition-granularity.md) Phase 2 的真实端到端验证——新建 `task-decomp-verify-co` 公司,走一条真实 `itemized` 任务,org-planner 展开成 `system/work-graph-templates/planner.json` 那张 5 节点子图(真实 Claude CLI,非 mock) |
| 9 | > **与 43 号文档的关系**:**完全独立的缺陷**,跟 goal 分解粒度那件事没有任何关系,只是恰好在验证 43 号文档时撞见。43 号文档"实现记录"一节提到的"acceptance.md 未必可靠",这份文档是那句话背后更具体、更确定的根因之一 | 9 | > **与 43 号文档的关系**:**完全独立的缺陷**,跟 goal 分解粒度那件事没有任何关系,只是恰好在验证 43 号文档时撞见。43 号文档"实现记录"一节提到的"acceptance.md 未必可靠",这份文档是那句话背后更具体、更确定的根因之一 |
| 10 | 10 | ||
| 11 | --- | 11 | --- |
| @@ -94,7 +94,7 @@ system/work-graph-templates/planner.json:11: { ..., "writesArtifacts": true } | @@ -94,7 +94,7 @@ system/work-graph-templates/planner.json:11: { ..., "writesArtifacts": true } | ||
| 94 | 94 | ||
| 95 | **任何一家公司只要 org-planner 走 `planner.json` 骨架(当前的常态,见 43 号文档 Phase 2 的真实数据核实),`org-planner-benchmark` 这一步几乎注定会撞这堵墙。** 每次撞墙都沿兜底回退边退回 `org-planner-sdd` 重来一轮(烧一次 sdd-planning 的真实调用 + 一次 goal-benchmark 的真实调用),直到 `loop_count` 撞到 `planner.json` 兜底边的硬顶(`LOOP_HARD_CAP_RUN` 相关,全树 `backward_count` 硬顶 5),最终大概率被强制判 `blocked` 或者 `planner-validator` 跟着一起被跳过。 | 95 | **任何一家公司只要 org-planner 走 `planner.json` 骨架(当前的常态,见 43 号文档 Phase 2 的真实数据核实),`org-planner-benchmark` 这一步几乎注定会撞这堵墙。** 每次撞墙都沿兜底回退边退回 `org-planner-sdd` 重来一轮(烧一次 sdd-planning 的真实调用 + 一次 goal-benchmark 的真实调用),直到 `loop_count` 撞到 `planner.json` 兜底边的硬顶(`LOOP_HARD_CAP_RUN` 相关,全树 `backward_count` 硬顶 5),最终大概率被强制判 `blocked` 或者 `planner-validator` 跟着一起被跳过。 |
| 96 | 96 | ||
| 97 | -**这很可能正是本 session 全程调试的参考任务(snakegame-co,[43 号文档](../../todo/system-design/43-task-decomposition-granularity.md) 的触发场景)里 `goal-benchmark`/`planner-validator` 两个节点显示 `skipped` 的真实原因**——不是没跑到,是跑了、正确地写了产物,却被这道闸门反复判定「没写」,最终在某一轮被放弃跳过。 | 97 | +**这很可能正是本 session 全程调试的参考任务(snakegame-co,[43 号文档](./43-task-decomposition-granularity.md) 的触发场景)里 `goal-benchmark`/`planner-validator` 两个节点显示 `skipped` 的真实原因**——不是没跑到,是跑了、正确地写了产物,却被这道闸门反复判定「没写」,最终在某一轮被放弃跳过。 |
| 98 | 98 | ||
| 99 | 连带后果: | 99 | 连带后果: |
| 100 | 100 |
docs/todo/system-design/45-graph-run-node-liveness-recovery.md renamed to docs/done/system-design/45-graph-run-node-liveness-recovery.md
docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md renamed to 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 号文档](./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-07)。Phase 0-5 全部实现,Phase 4 全部四项机制(父任务让位/预算均分/父子终态汇总/同父并发坑位)均已用真实数据验证——父子终态汇总最后一块也已在 2026-09-07 补跑中拿到直接证据(见文末)。过程中牵出一个独立缺陷(`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 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 | 5 | > **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 |
| 6 | -> **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。 | 6 | +> **格式对齐** `27-requirement.md`/[`43-task-decomposition-granularity.md`](./43-task-decomposition-granularity.md):原话照搬 + 结构化复述,现状调研另起一节。 |
| 7 | 7 | ||
| 8 | --- | 8 | --- |
| 9 | 9 | ||
| @@ -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,73 @@ v1 真实回归跑完,用户追问"认领后没有触发源,谁来触发"— | @@ -488,3 +488,73 @@ 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这块代码时顺手做)。 | ||
| 553 | + | ||
| 554 | +### 最后一块补齐:父子终态汇总在真实条件下确认生效(2026-09-07) | ||
| 555 | + | ||
| 556 | +用一个全新 goal(三个互不依赖的独立字符串工具函数,刻意设计成容易并行)再跑一轮,拿到了此前一直没等到的直接证据:`capitalizeWords` 这条链被 `subtask-planning` 拆成两条子任务(实现 + 单元测试),两条子任务都真实 `completed` 后,**父任务被 `settleParentTaskIfAllChildrenDone` 正确自动结算成 `completed`**——父子终态汇总在真实链路上确认生效,不再只是单测层面的把握。同一批里 `reverseWordOrder` 的实现子任务与 `capitalizeWords` 的实现子任务一度真实同时 `running`(都是无依赖子任务),是继贪吃蛇那次 20+ 分钟并发之后,同父并发坑位的第二次真实确认。 | ||
| 557 | + | ||
| 558 | +**一个插曲,核实过与本次代码无关**:`reverseWordOrder` 那条链的两条子任务中途被同一个共享环境里另一个并行在跑的 agent(从 `memory/MEMORY.md` 被自动写入的记录看,是在排查"`execution_mode=manual` 的节点不会自动推进、别当成引擎卡死"这类问题时)取消掉了,顶层任务改由人工 `/next` 推完,走的不是 decomposed_work 路径。核实过 `decomposed_work` 子任务的 `execution_mode` 全程正确落成 `'auto'`(`materializeDecomposedSubtasks` 这段逻辑没有问题)——被取消的是顶层任务自己那条默认 `'manual'` 的普通 org graph run(因为这次 `POST /goals`/`dispatch` 没有显式传 `mode:'auto'`),跟本文档的任何机制无关,只是恰好撞上了同一批任务被另一个 agent 同时处理。 | ||
| 559 | + | ||
| 560 | +至此,Phase 4 四项机制(父任务让位、预算均分、父子终态汇总、同父并发坑位)以及顺带修复的 org-validator 退回边,全部在真实条件下拿到过至少一次直接证据,不再只是单测覆盖。测试公司 `p2verify0902` 里此前两次因脚本 bug 失败的回归数据(`G-mtn5q2r7-786b`、`G-mtn5tvih-bed4`)已通过正式归档接口清理(不是裸删库),本轮新数据(goal `G-mtrad3iw-5af8`)保留作为最新证据。 |
docs/todo/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md renamed to docs/done/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md
docs/todo/system-design/48-task-acceptance-contract-binding.md renamed to docs/done/system-design/48-task-acceptance-contract-binding.md
| 1 | # 48 · Task/Subtask 验收契约 1:1 强绑定 | 1 | # 48 · Task/Subtask 验收契约 1:1 强绑定 |
| 2 | 2 | ||
| 3 | -> **状态**: ✅ 已实现并完成真实端到端回归(decomposed_work 链路,含真实 `org-coder`/`org-validator` CLI 全程跑通,`org-validator` 判词 `fail_reason=null`,不再 `input_underspecified`)。安全网仍只是原则,未写代码。 | ||
| 4 | -> **日期**: 2026-09-02(补充真实回归:2026-09-03) | 3 | +> **状态**: ✅ 已实现并完成真实端到端回归(decomposed_work 链路,含真实 `org-coder`/`org-validator` CLI 全程跑通,`org-validator` 判词 `fail_reason=null`,不再 `input_underspecified`)。**本文档已收口**:安全网仍只是原则(不为不可达场景写代码,见「实现记录」);另外三条创建路径(`task-planning`/`acceptance_item` 物化/人类快速派单)经调研确认天然不受本文档标题所述死锁影响,不需要补强绑定(见「三条剩余路径调研结论」)。 |
| 4 | +> **日期**: 2026-09-02(补充真实回归:2026-09-03;三条剩余路径调研收口:2026-09-03) | ||
| 5 | > **触发场景**: 46 号文档 Phase 3 v2 真实回归时,`org-validator` 在轻量子任务图(`org-coder` 实现 → `org-validator` 验证,跳过公司完整 org graph 的 `org-planner` 规划仪式)里卡死——`goal-contract.js validate --run-dir` 报 `input_underspecified`(没有 `acceptance.json`),`org-validator` 按它自己的硬指令"退 `org-planner` 重跑",但这张轻量图里**压根没有 `org-planner` 节点**,退无可退。追问下去牵出一个更根本的问题:"验收契约由谁产出"这件事目前完全绑死在"公司完整 org graph 一定会跑到 `org-planner`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。 | 5 | > **触发场景**: 46 号文档 Phase 3 v2 真实回归时,`org-validator` 在轻量子任务图(`org-coder` 实现 → `org-validator` 验证,跳过公司完整 org graph 的 `org-planner` 规划仪式)里卡死——`goal-contract.js validate --run-dir` 报 `input_underspecified`(没有 `acceptance.json`),`org-validator` 按它自己的硬指令"退 `org-planner` 重跑",但这张轻量图里**压根没有 `org-planner` 节点**,退无可退。追问下去牵出一个更根本的问题:"验收契约由谁产出"这件事目前完全绑死在"公司完整 org graph 一定会跑到 `org-planner`"这个假设上,任何不满足这个假设的执行路径都会撞上同一类死锁。 |
| 6 | 6 | ||
| 7 | --- | 7 | --- |
| @@ -147,4 +147,42 @@ | @@ -147,4 +147,42 @@ | ||
| 147 | ### 仍未做的(遗留,按「明确不做」的既有边界) | 147 | ### 仍未做的(遗留,按「明确不做」的既有边界) |
| 148 | 148 | ||
| 149 | - 安全网("发现缺失时就地补一份")仍然只是原则,未写代码——`decomposed_work` 链路走主力机制已完全覆盖,原设计判断不变。 | 149 | - 安全网("发现缺失时就地补一份")仍然只是原则,未写代码——`decomposed_work` 链路走主力机制已完全覆盖,原设计判断不变。 |
| 150 | -- `task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径的契约强制——本轮范围外,未动。 | 150 | +- `task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径的契约强制——本轮范围外,未动。**2026-09-03 已调研并收口,见下方「三条剩余路径调研结论」——不是补做了忘记补的东西,是查完发现不该做。** |
| 151 | + | ||
| 152 | +--- | ||
| 153 | + | ||
| 154 | +## 三条剩余路径调研结论(2026-09-03,收口,不写代码) | ||
| 155 | + | ||
| 156 | +调研目的:本文档标题所指的死锁(`org-validator` 判卷时没契约、退无可退)根源是**轻量子任务图跳过了 `org-planner`**。核实剩下三条创建路径是否也有同类风险,若有则补同款强绑定;若没有,记录原因收口,不为不存在的风险写代码。 | ||
| 157 | + | ||
| 158 | +结论:**三条路径都不在这类死锁风险范围内**,强行加 `acceptance_contract` 反而会跟已有设计边界冲突。逐条: | ||
| 159 | + | ||
| 160 | +### `task-planning` 拆出的顶层 task | ||
| 161 | + | ||
| 162 | +调用链:`runGoalSplit`(`server/routes/company.js:1581`)→ `insertTasksForGoal`(`company.js:1517`)→ `wdb.createTask(...)`(`company.js:1518`,不传 `subtaskKind`/`acceptanceContract`)。人点"下达"后经 `enqueueTaskRun` 起图,`taskRow.subtask_kind` 为 `null` → `isDecomposedSubtask=false` → **走完整 org graph**,不是轻量图。 | ||
| 163 | + | ||
| 164 | +完整 org graph 里 `org-planner` 触发 `workgraph-planning` 现场展开 `planner.json` 骨架(`brd→prd→sdd→benchmark→validator`),`benchmark` 节点在裁剪判据表里标注**"不可裁"**(`system/agents/workgraph-planning/agent.md:216`:"产的 acceptance 被另一张图跨图消费,裁了是跨图断供")——只要走标准骨架,`goal-benchmark` 节点必然产出 `acceptance.json`,`org-validator` 判卷天然有契约可读,不依赖 `tasks.acceptance_contract` 这个字段。 | ||
| 165 | + | ||
| 166 | +若强行加:会撞上 46 号文档专门保护的 `task-planning` 设计边界——"职责不变,仍是粗粒度判断,**3 轮内、不碰磁盘**"(`docs/todo/system-design/46-*.md:93`,代码对应 `server/services/task-planning.js:37` `PLAN_MAX_ROUNDS=3`、`system/agents/task-planning/tools.md:1-9` "全程不碰磁盘、不发请求")。要求它额外产出结构化 `benchmark` 段,与这条职责边界直接冲突。 | ||
| 167 | + | ||
| 168 | +**结论:不做。** 天然已被 `workgraph-planning` 的"benchmark 不可裁"约定覆盖,且强行做会破坏 46 号文档定的边界。 | ||
| 169 | + | ||
| 170 | +### `org-planner` 的 `acceptance_item` 物化 | ||
| 171 | + | ||
| 172 | +这是与 48 号 `acceptance_contract` **不同的另一套机制**(43/47 号文档):`maybeMaterializeItemizedSubtasks`(`server/services/graph-engine.js:2428`)是运行时批量把 `acceptance.md`/`goal-benchmark.json` 的条目拆成多条 `subtask_kind='acceptance_item'` 的子任务行,供 `coder-validator` 逐条经 `/subtask-result` 端点上报完成状态——**这些子任务从不经过 `enqueueTaskRun`,根本不起图执行**(`graph-engine.js:1874-1898` 注释明确写了 `acceptance_item` 与图执行判定"互不干扰")。`acceptance_contract` 字段目前唯一的消费方是 `enqueueTaskRun` 起轻量图时的落盘逻辑(`company.js:1161`),这条路径不会走到那一步,加了字段也没有消费者会读。 | ||
| 173 | + | ||
| 174 | +**结论:不适用,不做。** 不是"漏做了",是这条路径跟"图执行时读取验收契约"这件事在机制上没有交集。 | ||
| 175 | + | ||
| 176 | +### 人类/CEO 快速派单 `POST /:companyId/tasks` | ||
| 177 | + | ||
| 178 | +`company.js:2243` 调 `wdb.createTask(...)`,同样不传 `subtaskKind`/`acceptanceContract`,`isDecomposedSubtask` 恒为 `false` → 走完整 org graph,与 `task-planning` 路径同理享有"`benchmark` 节点不可裁"的天然保护。 | ||
| 179 | + | ||
| 180 | +若强行加:这条端点明确定位"外部 agent 自主派单、零 LLM 调用"(`company.js:1105-1106` 注释,且有守卫测试 `goal-task-acceptance.test.js` 验收 22 盯着"零 LLM 调用"这条),创建路径上没有任何环节能现场生成一份符合 `goal-contract.js` `benchmark` 段形状的结构化契约——要求调用方(通常是别的 agent 顺手发的一句话指令)自己拼出这份契约,对使用场景是不合理的负担。且 `enqueueTaskRun` 目前落盘 `acceptance_contract` 的分支硬绑 `isDecomposedSubtask`(`company.js:1161`),这条路径永远不会命中,需要新开一条落盘分支才能接上,投入产出不成比例。 | ||
| 181 | + | ||
| 182 | +**结论:不做。** 天然已受保护,且没有 LLM 环节可以生产契约,强行要求调用方手写不合理。 | ||
| 183 | + | ||
| 184 | +### 真正的残留风险(不在本文档范围内) | ||
| 185 | + | ||
| 186 | +上面三条路径的"天然保护"全部建立在同一个前提上:**公司的 org graph 一定含 `org-planner` 节点、且 `workgraph-planning` 现场展开时选中含 `benchmark` 的标准 `planner.json` 骨架**。这只是 agent 提示词层面的约定(`orggraph-planning` 规划公司 org graph 时给出的是示例而非强制),不是代码强制的不变式——理论上一家公司可以被规划出不含 `org-planner` 的非标准拓扑,届时这三条路径会重演本文档标题所述的同一类死锁,只是触发条件从"轻量子任务图"换成了"非标准公司拓扑"。 | ||
| 187 | + | ||
| 188 | +这是 **org graph 拓扑保证**问题,跟"task 创建时该不该强绑契约"是不同维度,本文档不处理。留待以后真的出现非标准拓扑撞上这个问题时再讨论(可能与 37 号文档"org graph 按部门拆分"那类拓扑改造放在一起考虑),不为假设性场景预先写代码。 |
docs/todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md
0 → 100644
| 1 | +# 49 · 竞争式并行 + 执行级验证严谨度——参考 Antigravity Teamwork 的两条提案 | ||
| 2 | + | ||
| 3 | +> **状态**: 📝 **设计讨论,不代表已拍板**(同 41 号文档的定位)。本文档是参照 Google Antigravity「Teamwork」 | ||
| 4 | +> 多智能体框架(<https://antigravity.google/blog/teamwork-when-ai-becomes-a-research-partner>)与我们自己 | ||
| 5 | +> 系统做对比后,针对两个具体缺口给出的方案草稿,供下一步决定要不要立项、按什么优先级。 | ||
| 6 | +> **日期**: 2026-09-04 | ||
| 7 | +> **触发场景**: 用户读完 Antigravity Teamwork 的博客文章,要求对照我们的 org/work graph 体系找异同, | ||
| 8 | +> 随后指定「27 号 P4(真并行)」与「34/35 号文档指出的验证严谨度不稳定问题」这两项,要求参考文章 | ||
| 9 | +> 现在就产出一份 todo 方案。 | ||
| 10 | + | ||
| 11 | +--- | ||
| 12 | + | ||
| 13 | +## 一、先纠正一个前提:27 号 P4「真并行」现在剩的东西,比想象的少很多 | ||
| 14 | + | ||
| 15 | +写这份文档前重读了 [27-P4](./27-p4-parallel-and-artifacts.md) 全文——它虽然还挂在 `todo/`,但文档 | ||
| 16 | +自己 §五末尾已经写明**阶段 A(隔离)/ B(并行)/ C(产物索引)全部已实现**(2026-08-21,`advanceBatch` | ||
| 17 | +真并发、`Promise.allSettled` fan-out、`graph_files` 产物索引、控制台 fan-in 可视化都已落地并有单测/真跑 | ||
| 18 | +验收)。这是本仓库第二次撞见「文档落后于代码」这个模式(第一次是文档自己 §3.6/§五校准块记录的)。 | ||
| 19 | + | ||
| 20 | +**真正还没做的,只有四件小事**: | ||
| 21 | + | ||
| 22 | +| 项 | 现状 | 是否与本文档的新提案相关 | | ||
| 23 | +|---|---|---| | ||
| 24 | +| T2:DevLoop 规格 `(pid,agent)` → `(pid,agent,scope)` | 改 HTML 重跑 `spec:extract` 即可,纯机械改动 | 无关,独立小任务 | | ||
| 25 | +| T5:账户欠费门 | 桩子已落地,卡在计费系统接口确认,非本仓库能单方面推进 | 无关 | | ||
| 26 | +| `graph_edge_contracts.role` 列缺失 | `produces`/`needs` 落库后不可区分,只影响诊断展示 | 无关 | | ||
| 27 | +| **`advanceTree` 不会同时推「兄弟子 run」** | 一次只推最深的一个 active run;两个并行 `subgraph` 节点各自的子 run 靠外层循环轮流服务,不是真并发。**这是当前唯一还成立的"并行"缺口** | **相关**——本文档 §二 的新提案建立在"节点内部并发已具备"之上,兄弟子 run 并发是它的地基 | | ||
| 28 | + | ||
| 29 | +也就是说,「27 号 P4 真并行」作为独立议题基本可以**收口**(只剩 T2/T5/role 列三件机械/外部依赖的小事, | ||
| 30 | +不需要新设计)。真正值得用 Antigravity 这篇文章来对照、能长出新东西的,是**同一个 run 内部并行做完之后, | ||
| 31 | +"要不要做竞争式的多候选并行"**这个更高一层的问题——这是 Antigravity 有而我们没有的能力,不是把 27-P4 | ||
| 32 | +剩下的小尾巴做完就能自动获得的。下面 §二 是这条新提案。 | ||
| 33 | + | ||
| 34 | +--- | ||
| 35 | + | ||
| 36 | +## 二、新提案 A:竞争候选 + 对抗批评 + 综合(借鉴 Antigravity 长证明模式) | ||
| 37 | + | ||
| 38 | +### 2.1 我们现在的 fan-out 和 Antigravity 的竞争生成,是两种不同的东西 | ||
| 39 | + | ||
| 40 | +27-P4 的 fan-out 解决的是**任务可分解**场景——三个互不重叠的子任务并行跑,各自独立,fan-in 只是等齐。 | ||
| 41 | +Antigravity 长证明模式解决的是**任务不可分解、但存在多种解法**场景——同一个问题,N 个候选策略**并行 | ||
| 42 | +尝试同一件事**,每个配一个专门的「证伪者」(falsifier)批评,胜出的进入综合树,被驳斥的路线连反对 | ||
| 43 | +意见都保留供后续复用。 | ||
| 44 | + | ||
| 45 | +这两种并行的图形状完全不同: | ||
| 46 | + | ||
| 47 | +``` | ||
| 48 | +27-P4 fan-out(分解): Antigravity 竞争生成(不分解,多解法): | ||
| 49 | + entry entry | ||
| 50 | + / | \ / | \ | ||
| 51 | + a b c (各做不同的子任务) | ||
| 52 | + \ | / candA candB candC (各做同一件事,但方法不同) | ||
| 53 | + join | | | | ||
| 54 | + critic critic critic (对抗式批评,不是简单验收) | ||
| 55 | + \ | / | ||
| 56 | + synthesis (综合/择优,不是简单 join) | ||
| 57 | +``` | ||
| 58 | + | ||
| 59 | +我们目前的 `work-graph-templates/`(`planner.json`/`executor.json`/`executor-fanout.json`/ | ||
| 60 | +`validator.json`/`deliverable.json`)里,`executor-fanout.json` 的 fan-out 是**分解型**——`org-coder` | ||
| 61 | +把工作拆成互不重叠的几个 unit 各自实现。**没有一张骨架是竞争型**——同一个问题让多个 agent 各自独立 | ||
| 62 | +给出完整方案,再挑最好的。 | ||
| 63 | + | ||
| 64 | +### 2.2 什么场景值得引入竞争型骨架 | ||
| 65 | + | ||
| 66 | +不是所有任务都值得——多候选意味着 token/时间成本乘 N,只有在**验收标准明确、方案空间大、单次尝试 | ||
| 67 | +成功率不高**的场景才划算。参照 Antigravity 五个模式各自的适用边界,对应到我们系统里大概是: | ||
| 68 | + | ||
| 69 | +- **算法/方案选型类子任务**(比如「给这批数据设计一个去重策略」「优化这段热点代码」)——有客观可判定的 | ||
| 70 | + 验收标准(正确性 + 性能基准),但实现路径不唯一,容易一次性收敛到局部最优解。 | ||
| 71 | +- **不适合**:常规 CRUD/界面开发这类目标单一、路径也基本单一的任务——多候选纯粹浪费预算,不会有 fan-out | ||
| 72 | + 分解已经在做的收益。 | ||
| 73 | + | ||
| 74 | +判断该不该走竞争型骨架,理论上应该由 `workgraph-planning` 现场决定(它已经在按任务形状选骨架),但 | ||
| 75 | +这需要给它一条新的选型判据,不是本文档能单方面拍板的——**这正是要留给下一步讨论的开放问题**。 | ||
| 76 | + | ||
| 77 | +### 2.3 落地形状草稿(未定案) | ||
| 78 | + | ||
| 79 | +如果要做,大概率是新增一张 `work-graph-templates/candidate-council.json` 骨架(暂定名),形状: | ||
| 80 | + | ||
| 81 | +``` | ||
| 82 | +entry → {candidate-1, candidate-2, candidate-3}(并行,复用 27-P4 已有的 fan-out 能力) | ||
| 83 | + ↓ 各自 ↓ 各自 ↓ 各自 | ||
| 84 | + critic-1 critic-2 critic-3 (每个候选配一个批评者,不是自己批评自己) | ||
| 85 | + ↓ ↓ ↓ | ||
| 86 | + synthesis(综合/择优节点) | ||
| 87 | +``` | ||
| 88 | + | ||
| 89 | +**跟现有 `executor-fanout.json` 的关键区别不在图结构(两者拓扑相似),而在节点职责**: | ||
| 90 | + | ||
| 91 | +- `executor-fanout.json` 的 fan-in 节点做的是「集成」(把不重叠的产物拼起来)。 | ||
| 92 | +- `candidate-council.json` 的 synthesis 节点要做的是「择优或融合」——需要读三份**互相竞争、覆盖同一个 | ||
| 93 | + 目标**的候选方案 + 三份批评意见,给出取舍。这是一个新的节点职责,目前系统里没有对应的 agent 人设 | ||
| 94 | + (`org-coder-validator`/`integrator` 都不是干这个的)。 | ||
| 95 | + | ||
| 96 | +**被驳斥路线保留反对意见供复用**——这条在我们系统里有现成落点:`graph_files`/`timeline.ndjson` 已经 | ||
| 97 | +会记录每个节点的产出和结算事件,critic 节点的批评意见本来就会作为产物落盘,天然可被 synthesis 节点 | ||
| 98 | +读到。不需要新机制,只是要求 critic 产出结构化(不是自由文本吐槽)。 | ||
| 99 | + | ||
| 100 | +### 2.4 明确的开放问题(不在本文档拍板) | ||
| 101 | + | ||
| 102 | +1. `workgraph-planning` 按什么判据选「竞争型」而不是「分解型」fan-out?现在完全没有这条判据。 | ||
| 103 | +2. synthesis 节点的人设怎么写——它既不能是候选方案的作者(会有自证倾向,Antigravity 原文也强调了 | ||
| 104 | + 这一点:候选和批评者要分离),又要有足够能力做技术判断,这可能需要一个新角色而不是复用现有的。 | ||
| 105 | +3. 成本:N 个候选 = N 倍预算消耗,`checkBudget`(任务级共池)需不需要为这类节点单独提额度? | ||
| 106 | +4. 这条能力值不值得投入,取决于**当前系统里有没有真实撞上"单次尝试成功率低、需要多解法比较"的任务**—— | ||
| 107 | + 目前没有实证需求,是从 Antigravity 文章反推出来的假设性场景,需要先找一个真实案例验证价值, | ||
| 108 | + 不建议在没有真实痛点前直接立项写代码(同 46 号文档的方法论:真实回归优于凭空设计)。 | ||
| 109 | + | ||
| 110 | +--- | ||
| 111 | + | ||
| 112 | +## 三、新提案 B:验证严谨度——从"能不能查得到证据"扩展到"默认要求执行级证据" | ||
| 113 | + | ||
| 114 | +### 3.1 现状盘点(核实过代码,不是凭印象) | ||
| 115 | + | ||
| 116 | +`server/services/goal-contract.js` 的三段契约里,`test_script` 段**本来就是执行级验证**——真实子进程 | ||
| 117 | +跑测试文件,退出码即判决(`SECTIONS`/`RUNNERS` 见该文件 81/86 行,注释原话「不起 LLM:benchmark/ | ||
| 118 | +test_data 是纯声明式比对,test_script 走真实子进程」)。这条能力已经具备,跟 Antigravity「锁步共 | ||
| 119 | +模拟」的精神是一致的——**都是拿真实执行结果做判决,不靠 LLM 一次性读代码给印象分**。 | ||
| 120 | + | ||
| 121 | +问题是它**用得少**。48 号文档「待定」一节记录过原因:`subtask-planning` 在 `org-coder` 动手之前就要 | ||
| 122 | +交契约,那时候还不知道测试文件会落在哪个路径,所以当时选了更省事的 `benchmark` 段(LLM 按维度打分, | ||
| 123 | +不依赖具体文件路径)。这是一个合理的工程取舍,但代价是**这条链路上的验收退化成了纯 LLM 判分**—— | ||
| 124 | +`org-validator` 判卷时"自行取证判分"(48 号真实回归判词原话),有没有真的去跑代码、读文件、执行测试, | ||
| 125 | +取决于这次判卷的 agent 主不主动,跟 34 号文档发现的"验证严谨度完全不稳定"是同一个根因: | ||
| 126 | +**结构化契约本身不强制要求执行级证据,只是给了一个可以被认真对待、也可以被走过场的评分维度。** | ||
| 127 | + | ||
| 128 | +35 号文档的提案 D 已经缓解了一部分——`evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则 | ||
| 129 | +判不过。但这条判据的触发范围窄(只认浏览器类关键词),别的维度(比如"函数对边界输入的处理是否正确") | ||
| 130 | +不受约束。 | ||
| 131 | + | ||
| 132 | +### 3.2 提案:把"要求证据"从"浏览器关键词命中"泛化为"benchmark 段全部维度默认要求" | ||
| 133 | + | ||
| 134 | +**核心变化**:`goal-contract.js` 的 `benchmark` 段每个 `dimension` 增加一个必填/半必填的 `evidence` | ||
| 135 | +字段(校验维度打分是否真的对应一次可复核的动作),而不是像现在只挑浏览器关键词命中的维度查。具体 | ||
| 136 | +形状留待立项时定,方向上应该是「validator 写维度判分时,必须一并记录"我是怎么判的"——跑了什么命令 / | ||
| 137 | +读了哪个文件的哪一段 / 执行了什么断言,而不是空白」。这跟 35 号提案 D 是同一机制的泛化,不是另起 | ||
| 138 | +一套。**这是本节唯一建议现在就推进的部分**——理由见 §3.2b,`test_script` 引导方案成本远超预期。 | ||
| 139 | + | ||
| 140 | +### 3.2b 工作量评估:把验收路径引向 `test_script` 段——结论是"不建议现在做",撞上一个结构性冲突 | ||
| 141 | + | ||
| 142 | +2026-09-04 对代码做了一轮实地核实(不是估算),结论跟直觉不一样:这不是"给 `org-coder` 补一条测试 | ||
| 143 | +文件路径约定"这么轻的事,中间卡着一个**当前系统里没人处理过的目录结构冲突**。 | ||
| 144 | + | ||
| 145 | +**发现一:`org-coder` 现在完全没有测试文件路径约定**(`system/agents/org-coder/agent.md` 通读,无 | ||
| 146 | +`tools.md`)。硬约束只区分 `artifact`(路径按 sdd 指定,否则落"项目根")和 `log`(节点私有目录下的 | ||
| 147 | +`coder.log`),没有第三类"测试文件"。48 号文档真实回归里 `org-coder` 写出的 `test/convert.test.js` | ||
| 148 | +是 LLM 当场自己起的名字,不是系统约定——这一点原设计文档里已经点破。 | ||
| 149 | + | ||
| 150 | +**发现二:DevLoop 有"路径固定写死在提示词里"的先例,但执行机制是另一套,代码不能照搬,只能借鉴思路。** | ||
| 151 | +`devloop-tester` 走 `vitest --reporter=json` + typebox 校验产出 `result.json`;`devloop-integrator` | ||
| 152 | +起真实服务进程按 `spec.json` 里的用例列表打 HTTP 请求。两者都不是"退出码即判决",跟 `goal-contract.js` | ||
| 153 | +的 `runTestScript` 是完全不同的技术路线,唯一可迁移的只有"角色只能写自己私有子目录下固定相对路径" | ||
| 154 | +这条设计模式。 | ||
| 155 | + | ||
| 156 | +**发现三(阻塞项):`test_script.path` 的可解析根,天然不包含 `org-coder` 实际写代码的地方。** | ||
| 157 | +`resolveInsideArtifacts`(`goal-contract.js:184-213`)只认两个根——契约目录 `tasks/{taskId}/` 与 | ||
| 158 | +`runs/{rootRunId}/artifacts/`。但 `org-coder` 的写入边界(`graph-engine.js:5076-5090` | ||
| 159 | +`describeWriteBoundary`)规定它的产物必须落在**项目根**(`allocateRunProjectRoot`, | ||
| 160 | +`{RUNS_ROOT}/{rootRunId}/project/`,`company.json.target_dir` 配了 `linked-*` 时甚至可能在仓库外任意 | ||
| 161 | +目录),这是与 `runs/{rootRunId}/artifacts/` **同级但不同**的第三个目录,`resolveInsideArtifacts` 根本 | ||
| 162 | +不会去那里找。也就是说,**48 号文档真实回归里那份 `test/convert.test.js`,此刻物理上就无法被 | ||
| 163 | +`test_script.path` 引用到**——不是"这次运气不好没配合",是两个解析根的定义里压根没有项目根这一项, | ||
| 164 | +结构性地够不着。 | ||
| 165 | + | ||
| 166 | +**发现四(好消息,不是瓶颈):`org-validator`/`coder-validator` 已经是"段不可知"的消费方**,统一走 | ||
| 167 | +`goal-contract.js run --target ... --run-id ...`,退出码即判决,`test_script` 段不需要它们的 | ||
| 168 | +`agent.md`/`tools.md` 改一行。`org-validator/tools.md:50-52` 甚至已经明确禁止 agent 自己手拼 | ||
| 169 | +`npx vitest run` 绕开执行器——这条纪律已经就位,不用重建。 | ||
| 170 | + | ||
| 171 | +**发现五(次要限制):`RUNNERS` 白名单目前只有 `'node'`**(`goal-contract.js:82-88`),是实测撞过 | ||
| 172 | +"根 `vitest.config` 的 `include` 覆盖不到 `data/`,直接 `No test files found`"之后主动收窄的,注释里 | ||
| 173 | +留了升级路径(临时 `vitest.config` + `--config`)但没实现。如果 `org-coder` 写的是前端类测试(vitest | ||
| 174 | +等非 node 原生断言),这条也会成为障碍——但这是发现三解决之后才需要面对的**第二层**限制,暂不是 | ||
| 175 | +当前的瓶颈。 | ||
| 176 | + | ||
| 177 | +**结论与建议**:要让验收路径真的走通 `test_script`,最小改动面是——① 解决发现三的结构冲突(要么把 | ||
| 178 | +`resolveInsideArtifacts` 的搜索根扩展到项目根,需要重新审视逃逸校验、覆盖 `target_dir` 配到仓库外的 | ||
| 179 | +情形;要么反过来要求 `org-coder` 把测试文件额外镜像一份到 `artifacts/`,多一条写入规则但不动 | ||
| 180 | +`goal-contract.js`);② 给 `org-coder` 补测试文件路径约定(`agent.md` 新增一条硬约束);③ 给 | ||
| 181 | +`subtask-planning` 同步这条约定,让它在交 `acceptance` 时能提前引用这个固定路径。三步都不是"改一行 | ||
| 182 | +提示词"量级,且①本身还没有定案的方案,只有两条待权衡的路径。**不建议现在推进**——跟提案 A 同样的 | ||
| 183 | +判断标准:没有真实场景证明当前 `benchmark` 段判分不够用之前,不该为了"原则上更严谨"就承担这笔结构性 | ||
| 184 | +改造成本。§3.2 的证据泛化(改动小、不碰目录结构)已经能覆盖大部分"防止空手套白狼"的诉求,作为当前 | ||
| 185 | +唯一建议推进的部分。 | ||
| 186 | + | ||
| 187 | +### 3.3 提案:陷阱注册表(anti-lessons registry)——Dream 目前没有的一层 | ||
| 188 | + | ||
| 189 | +Antigravity 把验证器发现的缺陷提炼成一个**与具体答案无关的陷阱库**,后续尝试主动避坑。我们的 | ||
| 190 | +[29 号 Dream 机制](../../done/system-design/29-dream-design.md)目前做的是"把 agent 经验蒸馏进 | ||
| 191 | +`SKILL.md`/`soul.md`/`agent.md`",偏向沉淀**成功模式**,没有专门做"系统性收集验证失败原因"这一层。 | ||
| 192 | + | ||
| 193 | +**具体提案**(成本不高,可以独立于 §二/§3.2 单独立项): | ||
| 194 | + | ||
| 195 | +- 数据源:`org-validator`/`coder-validator` 等验收节点判卷失败(`fail_reason` 非空)时,除了走既有的 | ||
| 196 | + 归因/回退流程,额外把"为什么判不过"这条结构化理由(不是原始 transcript,是提炼过的、与具体任务 | ||
| 197 | + 内容无关的失败模式)追加进一个新的沉淀点。 | ||
| 198 | +- **落点决策(2026-09-04 已拍板)**:每个被雇佣本体目录下新增独立文件 `known_pitfalls.md`(类似 | ||
| 199 | + `_lessons.md` 但专门收"别人踩过的坑",不是这个本体自己的经验),由 Dream 的现有蒸馏批处理顺带写, | ||
| 200 | + 不需要新的定时任务。**不**复用 `memory.md` 加受管区块——`memory.md` 目前的语义是"这个本体自己积累 | ||
| 201 | + 的经验",混进"别人的教训"会损伤这条语义的纯度,且独立文件的装配成本(多读一个文件)远低于维护 | ||
| 202 | + 一个新受管区块的隔离逻辑。 | ||
| 203 | +- 消费方:同角色本体下次执行前,把这份陷阱库当成额外上下文的一部分注入(跟 `agent.md`/`soul.md` 同一 | ||
| 204 | + 个装配流程,不需要新的读取路径)。 | ||
| 205 | + | ||
| 206 | +### 3.4 明确不做 / 与既有文档的边界 | ||
| 207 | + | ||
| 208 | +- 不做 Antigravity 那种"连续锁步共模拟"式的领域特定深度验证(硬件周期级对表)——那是他们特定领域 | ||
| 209 | + (RISC-V 模拟器)的定制方案,我们没有对应场景,硬套没有意义。我们能借鉴的只是"默认要求执行级证据" | ||
| 210 | + 这条**原则**,不是具体机制。 | ||
| 211 | +- 35 号文档四条提案已实施的部分不重做,本提案是在它的基础上**泛化范围**,不是推翻。 | ||
| 212 | +- **`test_script` 引导方案不做**(§3.2b 已给出工作量评估与结论):撞上 `test_script.path` 解析根 | ||
| 213 | + 不含项目根这个结构性冲突,改造面远超"给 `org-coder` 补一条约定",没有真实场景压力前不值得做。 | ||
| 214 | + 48 号文档"待定"一节的既有结论(走 `benchmark` 段)维持不变,不重新拍板。 | ||
| 215 | + | ||
| 216 | +--- | ||
| 217 | + | ||
| 218 | +## 四、任务划分(粗粒度,立项前不细化) | ||
| 219 | + | ||
| 220 | +| 项 | 内容 | 依赖 | | ||
| 221 | +|---|---|---| | ||
| 222 | +| P4 收口 | T2(DevLoop 规格)、role 列、`advanceTree` 兄弟子 run 并发 | 无,可独立随时做 | | ||
| 223 | +| 提案 A(竞争候选) | 已拆分成独立的详细设计草稿,见 [50 号文档](./50-competing-candidates-critic-synthesis-design.md)——需要先找到一个真实任务撞上"单次成功率低、需要多解法比较"的场景,再决定要不要做骨架 + critic/synthesis 角色 | 无实证需求前不建议立项 | | ||
| 224 | +| 提案 B-1(证据泛化) | `goal-contract.js` schema 扩展 + `org-validator`/`coder-validator` agent.md 调整 | 35 号提案 D 已有的机制,扩大范围即可,成本可控 | | ||
| 225 | +| 提案 B-1b(`test_script` 引导) | **已评估,不建议做**——见 §3.2b,撞上 `test_script.path` 解析根不含项目根的结构性冲突,改造面远超预期 | 不立项 | | ||
| 226 | +| 提案 B-2(陷阱注册表) | Dream 蒸馏批处理新增一类输出 + 装配读取路径接上,落点已定案为独立文件 `known_pitfalls.md`(见 §3.3) | 与 29 号 Dream 机制集成,成本较低,可独立于 B-1 先做 | | ||
| 227 | + | ||
| 228 | +**建议的推进顺序**(如果拍板要做):B-2(陷阱注册表)成本最低、跟现有 Dream 机制正交,可以先单独验证; | ||
| 229 | +B-1(证据泛化)次之;提案 A(竞争候选)需要先看 50 号文档的具体设计,且仍然需要真实场景验证价值才 | ||
| 230 | +建议立项。 | ||
| 231 | + | ||
| 232 | +--- | ||
| 233 | + | ||
| 234 | +## 五、与既有文档的关系 | ||
| 235 | + | ||
| 236 | +- [27-P4](./27-p4-parallel-and-artifacts.md):本文档 §一 是对它当前状态的复核,不修改它的结论; | ||
| 237 | + §二的竞争候选提案建立在它已完成的 fan-out 能力之上。 | ||
| 238 | +- [50-competing-candidates-critic-synthesis-design](./50-competing-candidates-critic-synthesis-design.md): | ||
| 239 | + §二提案 A 的详细设计草稿(骨架 JSON、命名、critic/synthesis 分工、预算风险量化),本文档只保留结论。 | ||
| 240 | +- [34](../../done/system-design/34-validator-scored-without-real-deliverable.md) / | ||
| 241 | + [35](../../done/system-design/35-validation-rigor-and-body-readiness-proposal.md):本文档 §三是 | ||
| 242 | + 对 35 号提案 D 的泛化延伸,不推翻已实施的部分。 | ||
| 243 | +- [29-dream-design](../../done/system-design/29-dream-design.md):§3.3 陷阱注册表是在 Dream 现有 | ||
| 244 | + 批处理框架内新增一类输出,不改动 Dream 的核心机制(独立判定/四落点/fail-closed 等既有设计保留)。 | ||
| 245 | +- [48-task-acceptance-contract-binding](../../done/system-design/48-task-acceptance-contract-binding.md): | ||
| 246 | + §3.2 明确指出如果证据泛化立项,需要重新过一遍它"待定"一节的 `benchmark` vs `test_script` 取舍。 |
docs/todo/system-design/50-competing-candidates-critic-synthesis-design.md
0 → 100644
| 1 | +# 50 · 竞争候选 + 对抗批评 + 综合——新图骨架详细设计草稿 | ||
| 2 | + | ||
| 3 | +> **状态**: 📝 **设计草稿,不代表已拍板**。这是 [49 号文档](./49-antigravity-inspired-parallel-verification-proposal.md) | ||
| 4 | +> §二"提案 A"的展开——49 号只给了三节点拓扑草图,本文档补上骨架 JSON 形状、命名、critic/synthesis | ||
| 5 | +> 分工、预算风险的具体数字。**49 号文档已明确:没有真实场景验证价值前不建议立项写代码**,本文档只是 | ||
| 6 | +> 把"如果要做,该怎么做"想清楚,供下一步决定要不要排期时参考,不是开工指令。 | ||
| 7 | +> **日期**: 2026-09-04 | ||
| 8 | +> **前置阅读**: [49 号文档](./49-antigravity-inspired-parallel-verification-proposal.md) §二(提案动机与 | ||
| 9 | +> 场景边界)、[27-P4](./27-p4-parallel-and-artifacts.md)(并行执行引擎,本设计的地基)、 | ||
| 10 | +> [27-naming-conventions.md](./27-naming-conventions.md)(命名规则) | ||
| 11 | + | ||
| 12 | +--- | ||
| 13 | + | ||
| 14 | +## 一、跟现有骨架的本质区别(复述 49 号 §2.1,作为设计前提) | ||
| 15 | + | ||
| 16 | +`executor-fanout.json` 是**分解型** fan-out:`split` 把任务拆成互不重叠的子任务分给不同分支,`merge` | ||
| 17 | +做的是**拼接**(把互补的子产物合并成一个整体)。竞争候选是**同型** fan-out:N 个分支做的是**同一件事**, | ||
| 18 | +下游节点要做的是**评判 + 择一/融合**,不是拼接。两者拓扑相似、语义完全不同——这是本设计最容易踩错的 | ||
| 19 | +地方,写骨架 JSON 时必须在 `whenToUse`/`dataContractNotes` 里把这条区别说透,否则 `workgraph-planning` | ||
| 20 | +选型时会混淆(现有 `executor.json` vs `deliverable.json` 就发生过一次真实混淆事故,见 §三.2 引用)。 | ||
| 21 | + | ||
| 22 | +--- | ||
| 23 | + | ||
| 24 | +## 二、骨架 JSON 形状草稿 | ||
| 25 | + | ||
| 26 | +沿用 `system/work-graph-templates/README.md:129-165` 定义的字段结构,不发明新字段。 | ||
| 27 | + | ||
| 28 | +```jsonc | ||
| 29 | +// system/work-graph-templates/candidate-council.json(暂定名,未定案) | ||
| 30 | +{ | ||
| 31 | + "id": "candidate-council", | ||
| 32 | + "label": "候选方案竞争评审", | ||
| 33 | + "whenToUse": "验收标准客观可判定,但实现路径不唯一、单次尝试容易收敛到局部最优解的子任务。典型信号词:\ | ||
| 34 | +'设计一个策略/方案'、'优化/选型'、'有几种做法都值得试'。跟 executor-fanout 的区别:executor-fanout 是\ | ||
| 35 | +把一个任务拆成不重叠的子任务分给不同分支各自完成(分解型);本骨架是让多个分支各自独立给出同一个目标\ | ||
| 36 | +的完整方案,再由 critic 评判、synthesis 择优或融合(同型竞争)。不确定任务是该分解还是该竞争时,默认\ | ||
| 37 | +选分解型(executor-fanout)——竞争型成本是 N 倍,只在"真的不确定哪条路线更优"时才划算。", | ||
| 38 | + "ownerOrgNode": "{{orgNode}}", | ||
| 39 | + "nodes": [ | ||
| 40 | + { "nodeName": "{{orgNode}}-candidate-a", "systemAgentId": "{{candidateAgent}}" }, | ||
| 41 | + { "nodeName": "{{orgNode}}-candidate-b", "systemAgentId": "{{candidateAgent}}" }, | ||
| 42 | + { "nodeName": "{{orgNode}}-critic-a", "systemAgentId": "candidate-critic" }, | ||
| 43 | + { "nodeName": "{{orgNode}}-critic-b", "systemAgentId": "candidate-critic" }, | ||
| 44 | + { "nodeName": "{{orgNode}}-synthesis", "systemAgentId": "candidate-synthesis" }, | ||
| 45 | + { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } | ||
| 46 | + ], | ||
| 47 | + "dataContractNotes": "候选分支数按实际任务增减(本文件给 2 路起手式,同 executor-fanout 的先例)。\ | ||
| 48 | +每路候选各配一个独立的 critic 节点——critic 只评判、不产出最终方案,也不是候选自己的下游必经步骤,\ | ||
| 49 | +是平行的另一个节点。critic 产出的评语(kind=artifact)与候选产出(kind=artifact)都要能被 synthesis \ | ||
| 50 | +节点区分来源,建议候选各自产出走 contract kind(分支间不冲突,同 executor-fanout 对 split 节点的处理),\ | ||
| 51 | +critic 评语走 artifact kind。千万不要把 critic 和 synthesis 合并成一个节点——qa-judge 早期版本就是\ | ||
| 52 | +"既提修改意见又下终态判决"合一设计,导致过一次真实的无限循环事故(20 次调用/31 分钟才熔断,见\ | ||
| 53 | +deliverable.json 的反模式记录),这条教训必须在本骨架延续。", | ||
| 54 | + "edges": [ | ||
| 55 | + { "from": "{{orgNode}}-candidate-a", "to": "{{orgNode}}-critic-a", "condition": "success", "produces": ["contract"] }, | ||
| 56 | + { "from": "{{orgNode}}-candidate-b", "to": "{{orgNode}}-critic-b", "condition": "success", "produces": ["contract"] }, | ||
| 57 | + { "from": "{{orgNode}}-critic-a", "to": "{{orgNode}}-synthesis", "condition": "success", "produces": ["artifact"] }, | ||
| 58 | + { "from": "{{orgNode}}-critic-b", "to": "{{orgNode}}-synthesis", "condition": "success", "produces": ["artifact"] }, | ||
| 59 | + { "from": "{{orgNode}}-synthesis", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["artifact"] }, | ||
| 60 | + { "from": "{{orgNode}}-candidate-a", "to": "{{orgNode}}-candidate-a", "condition": "fail", "edgeKind": "backward" }, | ||
| 61 | + { "from": "{{orgNode}}-candidate-b", "to": "{{orgNode}}-candidate-b", "condition": "fail", "edgeKind": "backward" }, | ||
| 62 | + { "from": "{{orgNode}}-synthesis", "to": "{{orgNode}}-candidate-a", "condition": "fail", "edgeKind": "backward" } | ||
| 63 | + ] | ||
| 64 | +} | ||
| 65 | +``` | ||
| 66 | + | ||
| 67 | +⚠️ 以上是**未经 `validateGraph`/`work-graph-skeletons.test.js` 实测**的草稿,backward 边、fail 兜底边 | ||
| 68 | +的具体挂法(尤其 synthesis 失败该退回哪个候选,还是退回两个候选的公共上游)需要按 `workgraph-planning/ | ||
| 69 | +agent.md` 约束三(每个非入口节点至少一条不带 `failReason` 的兜底边)与约束五(backward 边判定)逐条 | ||
| 70 | +过一遍才能定案,本文档只给方向,不做最终结构校验。 | ||
| 71 | + | ||
| 72 | +### 关键设计决策:critic 和 synthesis 必须是两个不同节点 | ||
| 73 | + | ||
| 74 | +这不是本文档凭空要求,是仓库里**已有真实事故记录**——`system/agents/qa-judge/` 是仓库唯一一个"既提 | ||
| 75 | +修改意见又下终态判决"的合一设计先例,`system/work-graph-templates/deliverable.json` 的 | ||
| 76 | +`dataContractNotes`(第 6 行)明确记录了一次真实故障:`org-hunt` 套 `executor.json` 后误选中 | ||
| 77 | +`qa-judge`,critic/judge 合一导致无限重复确认,最终判 `infra_flake`,烧了 **20 次调用 / 31 分钟**才 | ||
| 78 | +被熔断拦下。同一份文档同时留了正面先例:`validator-triager`(只归因不判定)与 `benchmark-validator` | ||
| 79 | +(只打分不算总分,算术交给不跑 LLM 的 `goal-contract.js`)——这两个都是"评价类角色拆分"的既有范式, | ||
| 80 | +新的 critic/synthesis 分工应该照这个模式设计:**critic 只给评语和分数,不下终态;synthesis 只做择优/ | ||
| 81 | +融合决策,不重新评分**。 | ||
| 82 | + | ||
| 83 | +--- | ||
| 84 | + | ||
| 85 | +## 三、命名(按 27-naming-conventions.md §3.1/§3.2) | ||
| 86 | + | ||
| 87 | +| 层 | 名称 | 依据 | | ||
| 88 | +|---|---|---| | ||
| 89 | +| Work Graph 节点 | `{父org节点全名}-candidate-a`/`-candidate-b`、`-critic-a`/`-critic-b`、`-synthesis` | §3.1:Work Graph 节点带父节点全名前缀 | | ||
| 90 | +| System Agent 本体(candidate) | 不新增,复用宿主领域已有的实现类 agent(比如 `org-coder` 场景下就是 `org-coder` 本体本身,跑两次、走两个不同 session scope,见 27-P4 §4.2 的多实例隔离机制) | 候选不是新角色,是同一个实现角色跑两次不同方案,不需要新本体 | | ||
| 91 | +| System Agent 本体(critic) | `candidate-critic`(不带前缀) | §3.2:本体不带前缀,避免同一本体因前缀被迫复制成多份 | | ||
| 92 | +| System Agent 本体(synthesis) | `candidate-synthesis`(不带前缀) | 同上 | | ||
| 93 | + | ||
| 94 | +**候选节点为什么不需要新本体**:候选做的是"用不同方法解决同一个问题",这本质上是**同一个实现角色** | ||
| 95 | +(比如 `org-coder`)被调用两次、每次给不同的方法论提示("方案 A 用递归实现,方案 B 用迭代实现"这类 | ||
| 96 | +差异化指令可以放在节点的任务描述里,不需要两个不同的本体)。这与 27-P4 §4.2 已经落地的"同一 system | ||
| 97 | +agent 的多实例隔离"(`session-pool.js` 的 `scope` 参数)正好对上——两路候选各自拿到独立 session, | ||
| 98 | +互不污染上下文,机制已经具备,不需要新代码。 | ||
| 99 | + | ||
| 100 | +**避坑**:`system/work-graph-templates/README.md:210-212` 列出的 6 个命名冲突 id(`agent-creator`/ | ||
| 101 | +`digester`/`evolver`/`evolver-youtube-crawler`/`innovator-*` 系列)不能用;`critic`/`synthesis` 这两个 | ||
| 102 | +职能词目前在 27-NAMING 速查表里没有先例,需要在正式立项时补进 §七 速查表,避免未来又冒出"judge"/ | ||
| 103 | +"reviewer"这类近义词造成混淆(`triager` vs `validator` 已经因为语义邻近踩过一次坑,见 27-NAMING §3.5)。 | ||
| 104 | + | ||
| 105 | +--- | ||
| 106 | + | ||
| 107 | +## 四、`workgraph-planning` 选型判据(新增,未定案) | ||
| 108 | + | ||
| 109 | +参照 `planner.json` 五节点裁剪判据表(`workgraph-planning/agent.md:181-233`)的成功模式——那张表训练 | ||
| 110 | +前只给"你被允许裁剪"这句空泛授权时打分 34/100,训练出具体的信号+阈值判据表后才到 100/100。新骨架的 | ||
| 111 | +选型判据必须避免重蹈"同义反复式空泛授权"的覆辙,至少要在两处补齐: | ||
| 112 | + | ||
| 113 | +1. **骨架自身的 `whenToUse` 字段**(已在 §二草稿里写了初版,需要经过同款训练/评审流程收敛,不能只 | ||
| 114 | + 凭这份草稿直接上生产)。 | ||
| 115 | +2. **`workgraph-planning/agent.md` 新增一条硬规则**,给出"要不要开竞争候选、开几路"的具体信号词和 | ||
| 116 | + 阈值——候选参考信号: | ||
| 117 | + - S1:验收标准是否已经客观可判定(有明确的正确性/性能基准,不是主观描述)——`否` 则不该开竞争, | ||
| 118 | + 应该先把验收标准搞清楚,这是另一个问题。 | ||
| 119 | + - S2:该子任务此前是否已经因为"方案有问题需要打回重做"触发过 backward 边——如果是,说明单次尝试 | ||
| 120 | + 成功率确实低,竞争候选可能划算;**如果从未撞过**,不该凭空开竞争(这条呼应 49 号 §2.4 的开放 | ||
| 121 | + 问题 4:"目前没有实证需求")。 | ||
| 122 | + - S3:候选数 N——没有代码层硬顶(见 §五),判据必须给出建议范围(比如 2-3 路),避免 LLM 现场 | ||
| 123 | + 漫无边际地开出过多分支。 | ||
| 124 | + | ||
| 125 | +**这条判据表本身要不要现在写、写成什么样,属于"立项时才需要拍板"的内容,本文档只指出"必须有"和 | ||
| 126 | +"不能是空泛授权"这两条硬要求,不代本节写最终版本。** | ||
| 127 | + | ||
| 128 | +--- | ||
| 129 | + | ||
| 130 | +## 五、预算与并发风险(量化数字,来自 2026-09-04 代码核实) | ||
| 131 | + | ||
| 132 | +- `advanceBatch` 的并发上限取 `run.max_concurrency`,缺省 `DEFAULT_MAX_CONCURRENCY = 5` | ||
| 133 | + (`server/services/work-graph-db.js:62`)。 | ||
| 134 | +- `checkBudget`(`server/services/graph-engine.js:3029-3044`)只在**取出候选之后、真正 claim 之前** | ||
| 135 | + 查一次,判定的是"当前已花是否达到上限",**不是"这一批 claim 之后会不会超支"**。也就是说,如果预算 | ||
| 136 | + 只剩 3 次调用余量,但这一轮一次性取出了 5 个 ready 候选(默认并发上限),5 个都会被 claim 并真正 | ||
| 137 | + 跑起来——**实际消耗可能超出预算上限,超出幅度最多是 `maxConcurrency - 1`(默认最多多花 4 次调用)**, | ||
| 138 | + 下一轮 `checkBudget` 才会拦下后续批次。 | ||
| 139 | +- 对竞争候选设计的直接影响:如果一次性并行展开 N 路候选(N ≤ 5 会被一次性全部 claim),预算消耗是 | ||
| 140 | + N 倍(候选节点 + 各自的 critic 节点,粗算一次三节点竞争至少是 6 个 agent 调用:2 候选 + 2 critic + | ||
| 141 | + 1 synthesis + 后续 validator),且这个倍数消耗**不会被预算闸门提前拦住**。立项时如果要控制这个 | ||
| 142 | + 风险,需要考虑给竞争型子图单独设一个更保守的 `max_concurrency`,或者在 `workgraph-planning` 的 | ||
| 143 | + 选型判据里对候选数 N 直接设建议上限(见 §四 S3)。 | ||
| 144 | + | ||
| 145 | +--- | ||
| 146 | + | ||
| 147 | +## 六、明确不做(本文档范围外) | ||
| 148 | + | ||
| 149 | +- 不实现具体的 `candidate-critic`/`candidate-synthesis` agent 人设(`agent.md`/`tools.md`)——那是 | ||
| 150 | + 立项后的工作,本文档只给分工原则(critic/synthesis 分离)。 | ||
| 151 | +- 不修改 `work-graph-templates/README.md` 或 `27-naming-conventions.md` 补录新职能词——留给立项时做。 | ||
| 152 | +- 不实现 §四的选型判据表——只给了判据应该覆盖的信号维度,具体阈值/文案需要走跟 `planner.json` 五节点 | ||
| 153 | + 判据表同款的训练/评审流程才能定案,不能直接照抄草稿上生产。 | ||
| 154 | +- 不解决"候选数 N 没有代码层硬顶"这件事本身——如果立项,这可能是最先要补的安全阀,但本文档只指出 | ||
| 155 | + 风险,不给实现。 | ||
| 156 | + | ||
| 157 | +--- | ||
| 158 | + | ||
| 159 | +## 七、与既有文档的关系 | ||
| 160 | + | ||
| 161 | +- [49-antigravity-inspired-parallel-verification-proposal](./49-antigravity-inspired-parallel-verification-proposal.md): | ||
| 162 | + 本文档是它 §二 提案 A 的展开,结论一致——没有真实场景前不建议立项。 | ||
| 163 | +- [27-P4](./27-p4-parallel-and-artifacts.md):本设计完全建立在它已完成的并行执行能力(`advanceBatch`/ | ||
| 164 | + 多实例 session 隔离)之上,不需要 27-P4 补任何代码。 | ||
| 165 | +- `system/work-graph-templates/deliverable.json`:critic/synthesis 分离的设计依据直接来自它记录的 | ||
| 166 | + `qa-judge` 真实反模式事故,本文档延续同一条教训。 | ||
| 167 | +- `system/agents/validator-triager/`、`system/agents/benchmark-validator/`:评价类角色"归因/打分/ | ||
| 168 | + 算术判定三者分离"的既有范式,是设计 critic/synthesis 分工时的直接参照。 |
server/services/graph-engine.js
| @@ -4718,6 +4718,14 @@ function readDeclaredProduces(run, dir) { | @@ -4718,6 +4718,14 @@ function readDeclaredProduces(run, dir) { | ||
| 4718 | * 不看下面「私有目录里有没有任何候选文件」这条粗粒度兜底的脸色——声明校验不过就是不满足, | 4718 | * 不看下面「私有目录里有没有任何候选文件」这条粗粒度兜底的脸色——声明校验不过就是不满足, |
| 4719 | * 哪怕私有目录里凑巧有别的文件(那不该被当成这个 kind 的替代品)。 | 4719 | * 哪怕私有目录里凑巧有别的文件(那不该被当成这个 kind 的替代品)。 |
| 4720 | * | 4720 | * |
| 4721 | + * T-mtratkfb 补丁:`_` 前缀文件(`_research.md` / `_plan.md` / `_lessons.md` ……)是 | ||
| 4722 | + * `_base/agent.md` 四段式生命周期(RESEARCH→PLAN→EXECUTE→VERIFY)沿途留的阶段性私有笔记, | ||
| 4723 | + * 全仓库几十个角色都在用这个前缀(约定见各 agent.md,不是 org-coder 独有)。这类文件在 | ||
| 4724 | + * RESEARCH 阶段就会出现,比真正的交付物早得多——`hasCandidates` 兜底如果把它们也算作 | ||
| 4725 | + * 「候选文件」,节点只做到 RESEARCH 就能骗过这道闸门被判 success(复现见 run_fe5a50b4e836: | ||
| 4726 | + * org-coder 只写了 `_research.md` 就报 success,项目根仍是空目录)。真交付物 | ||
| 4727 | + * (`coder.log`、代码文件……)从不用这个前缀,排除它们不会误杀合法产出。 | ||
| 4728 | + * | ||
| 4721 | * @returns {string[]} 未满足的 kind 列表;全部满足 = 放行([]) | 4729 | * @returns {string[]} 未满足的 kind 列表;全部满足 = 放行([]) |
| 4722 | */ | 4730 | */ |
| 4723 | function unsatisfiedRequiredProduces(run, def, nodeRun) { | 4731 | function unsatisfiedRequiredProduces(run, def, nodeRun) { |
| @@ -4749,7 +4757,7 @@ function unsatisfiedRequiredProduces(run, def, nodeRun) { | @@ -4749,7 +4757,7 @@ function unsatisfiedRequiredProduces(run, def, nodeRun) { | ||
| 4749 | return []; // 目录读不了不该拖垮结算 | 4757 | return []; // 目录读不了不该拖垮结算 |
| 4750 | } | 4758 | } |
| 4751 | const candidates = files.filter( | 4759 | const candidates = files.filter( |
| 4752 | - (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE | 4760 | + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE && !f.startsWith('_') |
| 4753 | ); | 4761 | ); |
| 4754 | const hasCandidates = candidates.length > 0; | 4762 | const hasCandidates = candidates.length > 0; |
| 4755 | 4763 |
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/graph-files-index.test.js
| @@ -325,6 +325,33 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { | @@ -325,6 +325,33 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { | ||
| 325 | expect(impl.failReason).toBe('missing_required_input'); | 325 | expect(impl.failReason).toBe('missing_required_input'); |
| 326 | }); | 326 | }); |
| 327 | 327 | ||
| 328 | + it('④c T-mtratkfb:只落了阶段性私有笔记(_research.md)没有真交付物 → 仍判 produces_missing,' | ||
| 329 | + + '不被 hasCandidates 兜底放过', async () => { | ||
| 330 | + // 复现 run_fe5a50b4e836:org-coder 四段式生命周期第一阶段写完 _research.md 就直接 | ||
| 331 | + // 上报 success,产物目录里除了这份阶段性笔记什么都没有——闸门如果把 _research.md | ||
| 332 | + // 也算进候选文件,这个节点会被误判「已产出」放行,下游拿到的其实是空交付。 | ||
| 333 | + const companyId = 'co-files-private-note'; | ||
| 334 | + const taskId = 'task-private-note'; | ||
| 335 | + const b = buildRun({ | ||
| 336 | + companyId, taskId, | ||
| 337 | + nodes: [{ nodeName: 'coder' }, { nodeName: 'validate' }], | ||
| 338 | + edges: [{ from: 'coder', to: 'validate', condition: 'success' }], | ||
| 339 | + }); | ||
| 340 | + wdb.declareEdgeContract({ | ||
| 341 | + edgeLayer: 'work', edgeId: b.edgeId('coder', 'validate'), kind: 'artifact', required: true, | ||
| 342 | + }); | ||
| 343 | + | ||
| 344 | + const tasks = await advance(b.runId, 2, { | ||
| 345 | + writes: { coder: { '_research.md': '结论:可以直接进入 PLAN,无阻塞项' } }, | ||
| 346 | + }); | ||
| 347 | + | ||
| 348 | + const state = wdb.getRunState(b.runId); | ||
| 349 | + const coder = state.nodes.find((n) => n.name === 'coder'); | ||
| 350 | + expect(coder.status).toBe('blocked'); | ||
| 351 | + expect(coder.failReason).toBe('produces_missing'); | ||
| 352 | + expect(tasks.has('validate')).toBe(false); | ||
| 353 | + }); | ||
| 354 | + | ||
| 328 | // ── ⑤ 节点 fail 不登记 ────────────────────────────────────── | 355 | // ── ⑤ 节点 fail 不登记 ────────────────────────────────────── |
| 329 | 356 | ||
| 330 | it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => { | 357 | it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => { |
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/agent.md
| @@ -80,9 +80,13 @@ KR 必须**可度量**("日产 20 条合格短视频"),不能是"提升内 | @@ -80,9 +80,13 @@ KR 必须**可度量**("日产 20 条合格短视频"),不能是"提升内 | ||
| 80 | <!-- dream:begin --> | 80 | <!-- dream:begin --> |
| 81 | ## 跨公司经验提醒(dream) | 81 | ## 跨公司经验提醒(dream) |
| 82 | 82 | ||
| 83 | -_由 dream 于 2026-09-04 提炼自跨公司经验;本区块由机器维护,区块外的内容不会被改动。_ | ||
| 84 | - | ||
| 85 | -- **判断 blocked 任务是真卡住还是账面滞后,看底层 run 的 updated_at 是否还在前进** —— 兜底扫描反复对同一条 blocked 任务报警时,先独立核对底层 run 的 updated_at 与 budget 计数:若多轮之间逐字零变化、budget 也停在原值,说明它不是运行中被卡,而是在等外部裁决(如重判/放行)——属于账面滞后,维持原状即可,不需要任何动作。判断依据要落在底层 run 上,而不是任务的 blocked 状态本身。 | ||
| 86 | -- **空闲的执行坑位不构成对一条 blocked 任务采取动作的理由** —— slots 显示 used 0 / free 1 时,不要因为有空坑位就去重派或重跑一条实际在等裁决的 blocked 单:提示词层杠杆试尽后重派是确定性空转,还会把这条冒烟单的预算烧在新能力建设上。正确做法是确认 blockedReason 与前几轮一致后明确判「零新增工作」,并把结论留给下一轮免得重复排查。 | 83 | +_由 dream 于 2026-09-07 提炼自跨公司经验;本区块由机器维护,区块外的内容不会被改动。_ |
| 84 | + | ||
| 85 | +- **自相矛盾的状态字段(如完成时间已写但进度仍为0)说明是回写缺陷,不是任务真卡死** —— 巡检时如果发现一个任务的阻塞原因为空、进度字段仍显示未完成,但完成时间戳却已经被写入,这几个字段互相矛盾,最合理的解释是负责回写完成态的逻辑有缺陷,而不是任务真的卡住或调度漏单。遇到这种字段自相矛盾的模式,应判定为已知的写回问题复现,不必当新故障处理。 | ||
| 86 | +- **判定“预算耗尽”前先看节点是否真正执行过,暂停期间挂钟仍计时会造成假阳性** —— 任务被标记为预算耗尽时,先检查各执行节点的尝试次数和运行 id:如果全部为初始值、从未真正开始执行,但累计耗时却远超预算上限,很可能是任务在人工暂停期间挂钟时间仍在累积所致,而不是真的超支执行,也不是调度器出了新问题。 | ||
| 87 | +- **批量阻塞告警先回溯依赖链,同一根因的级联传导不算新问题** —— 收到一批任务同时转入阻塞状态时,先按依赖关系回溯:如果它们都是级联失败,最终指向同一个已经在排查中的根因任务,应判定为该根因的正常传导复现,不需要为每一次复现都新开任务或重新报告,避免制造重复噪音。 | ||
| 88 | +- **同一已知问题反复触发告警时,先查负责修复的代码是否已提交/重启生效,而不是每次都重新处理** —— 如果一个问题已经定位、修复代码也在开发,但反复看到同样的告警复现,应先确认修复所在的工作区是否仍处于未提交/未部署状态——没有落地生效之前问题会持续复现是预期内的,属于负责该模块的角色自己的工程节奏(提交、测试、部署),不需要越权催促或每次都当新故障上报。 | ||
| 89 | +- **协作方指明用它自己的工具修复时,转发给对方执行而不是越权代劳;系统性根因修复走正式排期,不当场自动执行** —— 当负责某个系统的角色明确指出应该用它自己才有的工具去做某个恢复动作时,应该把这个动作转发给对方去执行,而不是自己找替代方式绕过去做。同时,针对根因的系统性修复应该记录成一个正式的、可被后续排期的跟进任务,而不是当场直接自动执行——现场修复和系统性修复要分开处理。 | ||
| 90 | +- **父任务显示失败但其拆分出的子任务全部完成,是完成态汇总缺陷,不是真的失败** —— 遇到一个被拆分成多个子任务的父任务本身状态显示失败,但检查发现它拆出的全部子任务都已完成时,说明问题出在父任务没有正确地把子任务完成情况汇总回落到自己的状态上,而不是这个父任务真的执行失败了。 | ||
| 87 | 91 | ||
| 88 | <!-- dream:end --> | 92 | <!-- dream:end --> |
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/ceo/soul.md
| @@ -26,9 +26,13 @@ | @@ -26,9 +26,13 @@ | ||
| 26 | <!-- dream:begin --> | 26 | <!-- dream:begin --> |
| 27 | ## 从经验里长出来的倾向(dream) | 27 | ## 从经验里长出来的倾向(dream) |
| 28 | 28 | ||
| 29 | -_由 dream 于 2026-09-04 提炼自跨公司经验;本区块由机器维护,区块外的内容不会被改动。_ | ||
| 30 | - | ||
| 31 | -- **判断 blocked 任务是真卡住还是账面滞后,看底层 run 的 updated_at 是否还在前进** —— 兜底扫描反复对同一条 blocked 任务报警时,先独立核对底层 run 的 updated_at 与 budget 计数:若多轮之间逐字零变化、budget 也停在原值,说明它不是运行中被卡,而是在等外部裁决(如重判/放行)——属于账面滞后,维持原状即可,不需要任何动作。判断依据要落在底层 run 上,而不是任务的 blocked 状态本身。 | ||
| 32 | -- **空闲的执行坑位不构成对一条 blocked 任务采取动作的理由** —— slots 显示 used 0 / free 1 时,不要因为有空坑位就去重派或重跑一条实际在等裁决的 blocked 单:提示词层杠杆试尽后重派是确定性空转,还会把这条冒烟单的预算烧在新能力建设上。正确做法是确认 blockedReason 与前几轮一致后明确判「零新增工作」,并把结论留给下一轮免得重复排查。 | 29 | +_由 dream 于 2026-09-07 提炼自跨公司经验;本区块由机器维护,区块外的内容不会被改动。_ |
| 30 | + | ||
| 31 | +- **自相矛盾的状态字段(如完成时间已写但进度仍为0)说明是回写缺陷,不是任务真卡死** —— 巡检时如果发现一个任务的阻塞原因为空、进度字段仍显示未完成,但完成时间戳却已经被写入,这几个字段互相矛盾,最合理的解释是负责回写完成态的逻辑有缺陷,而不是任务真的卡住或调度漏单。遇到这种字段自相矛盾的模式,应判定为已知的写回问题复现,不必当新故障处理。 | ||
| 32 | +- **判定“预算耗尽”前先看节点是否真正执行过,暂停期间挂钟仍计时会造成假阳性** —— 任务被标记为预算耗尽时,先检查各执行节点的尝试次数和运行 id:如果全部为初始值、从未真正开始执行,但累计耗时却远超预算上限,很可能是任务在人工暂停期间挂钟时间仍在累积所致,而不是真的超支执行,也不是调度器出了新问题。 | ||
| 33 | +- **批量阻塞告警先回溯依赖链,同一根因的级联传导不算新问题** —— 收到一批任务同时转入阻塞状态时,先按依赖关系回溯:如果它们都是级联失败,最终指向同一个已经在排查中的根因任务,应判定为该根因的正常传导复现,不需要为每一次复现都新开任务或重新报告,避免制造重复噪音。 | ||
| 34 | +- **同一已知问题反复触发告警时,先查负责修复的代码是否已提交/重启生效,而不是每次都重新处理** —— 如果一个问题已经定位、修复代码也在开发,但反复看到同样的告警复现,应先确认修复所在的工作区是否仍处于未提交/未部署状态——没有落地生效之前问题会持续复现是预期内的,属于负责该模块的角色自己的工程节奏(提交、测试、部署),不需要越权催促或每次都当新故障上报。 | ||
| 35 | +- **协作方指明用它自己的工具修复时,转发给对方执行而不是越权代劳;系统性根因修复走正式排期,不当场自动执行** —— 当负责某个系统的角色明确指出应该用它自己才有的工具去做某个恢复动作时,应该把这个动作转发给对方去执行,而不是自己找替代方式绕过去做。同时,针对根因的系统性修复应该记录成一个正式的、可被后续排期的跟进任务,而不是当场直接自动执行——现场修复和系统性修复要分开处理。 | ||
| 36 | +- **父任务显示失败但其拆分出的子任务全部完成,是完成态汇总缺陷,不是真的失败** —— 遇到一个被拆分成多个子任务的父任务本身状态显示失败,但检查发现它拆出的全部子任务都已完成时,说明问题出在父任务没有正确地把子任务完成情况汇总回落到自己的状态上,而不是这个父任务真的执行失败了。 | ||
| 33 | 37 | ||
| 34 | <!-- dream:end --> | 38 | <!-- dream:end --> |
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 = []; |