Commit 01135331711902f6b30dd3ff6f71e7348f8e1751

Authored by yibo
1 parent d6214f81

docs(27-P3C): 批 1 实施记录,并修正设计正文里已与实现分叉的三处

新增 §十三,体例照 27-P3/P4 §十:设计正文不改写,只在这里与实现对账,
冲突处以实施记录为准(正文是设计时的判断,本节是跑出来的事实)。

记了与设计的四处偏离,其中两处是设计正文写错了:

① blocked 在 task/goal 两层都不是终态。正文 §四.3 把它与
   completed/failed 并列画成终态,实现时实跑复现出后果:预算耗尽后
   加预算恢复、跑完,两次条件更新静默命中 0 行,最终 run=completed
   而 task=blocked/goal=failed/OKR=failed 永久对不上。RUN_TRANSITIONS
   里 blocked: ['running','stopped'] —— 对 run 它是可恢复态,
   reviveParentFromChild 还会自动拉回。

② goal 级是唯一汇报出口,reportRunOutcome 已删除(零其它调用方)。
   1 goal:1 task 形态下 run 级与 goal 级会把上级就同一件事唤醒两次。
   from 的口径同时修正为实际交付者(terminalAgentId 搬进
   org-graph-service 当唯一持有者),不再用 created_by ——
   那是「谁下的单」,默认字面量 'human',下游会静默落空。

③ dispatch 挑第一条可跑的 draft,连带补上 PATCH 改 seq —— 原先 409
   的 hint 写着「改依赖或 seq」,而那个端点从不读 body.seq。

正文里三处提到 reportRunOutcome 的地方一并标注:§四.7 加了「本节写于
实施之前」的前置说明,§十二(批 5)的前提改成「函数换了、依赖关系
没变」—— A 的回调链路仍长在 B 身上,只是出口从 company.js 的 run 级
换成了 task-settle 的 goal 级。

过程也记了:三轮编排 21 个 agent,对抗复核报 19 条,其中 4 条是
第二轮修复自己引入的。结论:对抗复核不能只做一轮 —— 修复和实现
一样会出错,而且更隐蔽,它长得像在解决问题。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3c-goal-task-model.md
... ... @@ -2,7 +2,7 @@
2 2  
3 3 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 **P3 收尾**(P3C),直接续 [27-P3/P4 执行控制台](./27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](./27-p3b-real-run-runway.md)。
4 4 > **日期**: 2026-08-18
5   -> **状态**: 📝 **设计完成,五批全部未开工**(0 行代码)。开放问题已全部关闭,批 1 可直接开工。
  5 +> **状态**: **批 1 已实施并全绿**(2026-08-18,commit `7f24ef0`,见 §十三 实施记录)。批 2-5 未开工。开放问题已全部关闭。
6 6 > **⚠️ 批次与 phase 的对应不是一一对齐的**,判断「这份能不能搬进 done」时按批看,别按文档名看:
7 7 > · **批 1-3 = 真正的 P3 收尾**(goal/task 两级模型 + `task-planning` + 排队),本文档的主体;
8 8 > · **批 4(并行)归 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块);
... ... @@ -271,7 +271,9 @@ const genId = (p) =&gt; `${p}-${Date.now().toString(36)}-${crypto.randomBytes(2).to
271 271  
272 272 ### 4.7 收敛汇报的两条实现禁令(照现有代码顺手写就会踩)
273 273  
274   -`reportRunOutcome`(`company.js`)是 root run 收敛后的唯一出口,它做两件事:有 `report_to` 就 POST 给 `/api/agent/task/report` 唤醒上级 agent,没有就直接 `OkrService.updateNode(objectiveId, okrNodeId, {status})`。两件事在 goal/task 两级下都会坏:
  274 +> ⚠️ **本节写于批 1 实施之前,`reportRunOutcome` 已在批 1 中删除**(见 §十三)。下面对它的描述保留为「当时的现状」,两条禁令的结论不变,只是落点从 `company.js:reportRunOutcome` 变成了 `task-settle.js:reportGoalOutcome`。
  275 +
  276 +`reportRunOutcome`(`company.js`,**已删**)曾是 root run 收敛后的唯一出口,它做两件事:有 `report_to` 就 POST 给 `/api/agent/task/report` 唤醒上级 agent,没有就直接 `OkrService.updateNode(objectiveId, okrNodeId, {status})`。两件事在 goal/task 两级下都会坏:
275 277  
276 278 **禁令一:task 的 run 一律不带 `okr_node_id`。**
277 279 现在 `createRun({okrNodeId})` 把它写进 run,`reportRunOutcome` 读 `run.okr_node_id` 直接写 OKR 节点。一个 goal 挂一个 OKR 节点、下面 5 个 task 各带一份,就是**同一个 OKR 节点被写 5 次**,且没有合并逻辑 —— 最后一个收敛的 task 说了算,一个 failed 的尾巴能把已经 done 的节点翻成 failed。所以:**`okr_node_id` 只存在 `goals` 表上,不进 `graph_runs`**;OKR 由 goal 收敛(全部 task 终态)时写一次。
... ... @@ -787,7 +789,7 @@ for (const t of queuedTasks) {
787 789 | 前端 | `GoalTaskPane`(§七)| `TaskTimeline.vue`(736 行,唯一消费方)|
788 790 | id | `T-{base36}-{4hex}` | `T-{base36}`(同前缀不同源,§九.5)|
789 791  
790   -**关键事实:`reportRunOutcome` 收敛后调的正是 B 的 `/api/agent/task/report`。** 两套不是并列关系,A 的回调链路长在 B 身上。所以「删掉另一套」不能理解成整片删除 —— 要删的是**存储与反推**,不是入口与回调。
  792 +**关键事实:A 的收敛出口调的正是 B 的 `/api/agent/task/report`。** 批 1 之前这个出口是 `company.js:reportRunOutcome`(run 级),批 1 把它删掉、换成了 `task-settle.js:reportGoalOutcome`(goal 级,挂在 `transitionRun` 漏斗上)——**函数换了,依赖关系没变**。两套不是并列关系,A 的回调链路仍然长在 B 身上。所以「删掉另一套」不能理解成整片删除 —— 要删的是**存储与反推**,不是入口与回调。
791 793  
792 794 ### 12.2 删什么、留什么
793 795  
... ... @@ -834,6 +836,80 @@ parent_task_id TEXT REFERENCES tasks(id), -- B 的任务链;A 的 depends_
834 836 25. `POST /api/agent/task/dispatch` 请求体不变,派单成功后 `tasks` 表出现 `executor='agent'` 的行,`system/tasklog/` 不再产生新文件。
835 837 26. 派单的 task **不进调度队列**(`executor='agent'` 被 §五.1 排除),不占 `max_parallel_tasks` 的坑。
836 838 27. 30 分钟去重仍然拦得住重复派单,且不再读目录(改成 SQL)。
837   -28. `POST /api/agent/task/report` 汇报链路四步(todo/plan → OKR → Pipeline → 唤醒上级)全部照常;**`reportRunOutcome` 那条依赖它的路也照常**。
  839 +28. `POST /api/agent/task/report` 汇报链路四步(todo/plan → OKR → Pipeline → 唤醒上级)全部照常;**`task-settle.reportGoalOutcome` 那条依赖它的路也照常**(批 1 之前这条依赖挂在已删除的 `reportRunOutcome` 上)。
838 840 29. `TaskTimeline.vue` 任务列表与时间线都正常,父子关系来自 `parent_task_id` 而非会话反推。
839 841 30. 存量 `system/tasklog/*.json` 全部回填进表,目录改名保留。
  842 +
  843 +---
  844 +
  845 +## 十三、批 1 实施记录(2026-08-18,commit `7f24ef0`)
  846 +
  847 +> 体例照 [27-P3/P4](./27-p3-p4-work-graph-console.md) §十:**设计正文不改写,只在这里与实现对账**。
  848 +> 凡本节与前文冲突,**以本节为准** —— 前文是设计时的判断,本节是跑出来的事实。
  849 +
  850 +### 13.1 落地了什么
  851 +
  852 +| 交付 | 落点 |
  853 +|---|---|
  854 +| `goals` / `tasks` 两张表 + CRUD + `depends_on` 环检测 | `work-graph-db.js` |
  855 +| ID 生成器 `G-/T-{base36}-{4hex}` + 主键碰撞重试 | 同上(`insertWithFreshId`:4hex 只有 65536 种取值,实测同毫秒建 2000 条会撞 5 次) |
  856 +| goal / task 全套端点 + `POST /:cid/tasks` 向后兼容 | `company.js` |
  857 +| 退役 `/api/goals` 与 `/:cid/goals` 两个 OKR 别名 | `index.js` / `agent-server.js` / `company.js` |
  858 +| **终态回写 + goal 收敛 + OKR 写一次** | `graph-run-sse.transitionRun` 钩子 → `task-settle.js`(新) |
  859 +| 三处「模块加载时快照 `GRAPH_RUNS_DIR`」改调用时读 | `graph-engine.js` / `graph-run-sse.js` / `goal-contract.js` |
  860 +| 存量回填脚本(幂等 + `--dry-run`) | `scripts/migrate-runs-to-goal-task.js` |
  861 +| 4 个测试文件,含不变式测试 | `server/tests/unit/` |
  862 +
  863 +全量:**116 文件 / 1057 测试全绿**。
  864 +
  865 +### 13.2 与设计正文的四处偏离(**以本节为准**)
  866 +
  867 +**① `blocked` 在 task / goal 两层都不是终态。**
  868 +正文 §四.3 的状态机把 `blocked` 与 `completed`/`failed` 并列画成终态。实现时发现那是错的,而且**实跑复现出了后果**:
  869 +
  870 +```
  871 +预算耗尽 → task=blocked, goal=failed, OKR=failed
  872 +人加预算恢复 → run=running
  873 +跑完 → run=completed, task=blocked, goal=failed, OKR=failed ← 永久对不上
  874 +```
  875 +
  876 +`RUN_TRANSITIONS` 里 `blocked: ['running','stopped']` —— 对 run 它是**可恢复态**,`reviveParentFromChild` 还会自动把它拉回 running。若 task 层把它当终态,恢复后跑完时 `settleTaskFromRun` 与 `settleGoalStatus` 两次条件更新**静默命中 0 行**,无日志无事件。
  877 +
  878 +定案:`TASK_TERMINAL_STATUS` 只含 `completed / failed / cancelled`;**只要有一条 task 处于 `blocked`,goal 就不收敛**(不写 OKR、不唤醒上级)。`blocked` 的语义是「挂起等人介入」,与那三个不同类。run 转 blocked 时**仍然**把 task 写成 blocked(界面要看得见卡住),只是不触发收敛。
  879 +
  880 +连带:归档守卫从「过严」翻到「过松」过一次 —— 恢复后 task 停在 `blocked`、run 却在 running,用户能归档掉一条**正在烧预算**的 run。现由 `taskHoldsRun()` 兜住(`ACTIVE_ROOT_RUN_STATUS` **派生**出 `RUN_HOLDING_STATUS`,不复述第二份字面量)。
  881 +
  882 +**② goal 级是唯一汇报出口,`reportRunOutcome` 已删除。**
  883 +正文 §四.7 把它写成「root run 收敛后的唯一出口」。批 1 的唯一形态是 1 goal : 1 task,而 `createTask` 继承 `goal.report_to`,于是 run 级与 goal 级两条汇报会**就同一件事把上级 agent 唤醒两次**(其中一次带的是它从没见过的 `G-` id)。
  884 +
  885 +grep 确认 `reportRunOutcome` 无其它调用方(连它自己那行 `module.exports` 都零人 require),整体删除。理由:run 级只在 auto 模式触发(manual 收敛不走 driver),本来就是半条链路;goal 级挂在唯一漏斗上,manual / auto / stopped / blocked 全覆盖。
  886 +
  887 +同时 `from` 的口径修正为**实际交付者**:`terminalOrgAgent` 从 `company.js` 搬到 `org-graph-service.terminalAgentId()`(唯一持有者),不再用 `goal.created_by` —— 那是「谁下的单」,默认字面量 `'human'`,下游 `readStateFile('human','todo.md')` 会静默落空。
  888 +
  889 +**③ dispatch 挑第一条「可跑的」draft,不是恒取 seq 最小那条。**
  890 +`validateDependsOn` 只拒环、不拒正向引用,所以「第 1 条依赖第 2 条」是合法输入;而恒取 `drafts[0]` 会让这种 goal **永远下达不了**(`blockers` 恒非空)。批 2 的 `task-planning` 产出契约正是「`depends_on` 只引用本次产出的 seq」,产出乱序完全在射程内。
  891 +
  892 +连带:`PATCH /:cid/tasks/:taskId` 补上了改 `seq` 的入参翻译 —— 原先 409 的 hint 写着「改依赖或 seq」,而那个端点从不读 `body.seq`,**两条自救路径只有一条通**。
  893 +
  894 +**④ `POST /:cid/goals` 批 1 只做同步路径。** `task-planning`(§四.9)与 goal 级 `org-validator` 验收(§四.10)属批 2,`verdict` / `validation_tokens` 两列目前只有定义、无写侧。
  895 +
  896 +### 13.3 过程:三轮编排,19 条缺陷
  897 +
  898 +| 轮次 | agent | 产出 |
  899 +|---|---|---|
  900 +| 1 · 实现 | 11 | 建表 + 端点 + 回填 + 测试;对抗复核报 **10 条缺陷** |
  901 +| 2 · 修复 | 6 | 修完 10 条;复核确认真修好,但报出 **4 条修复自己引入的新缺陷** |
  902 +| 3 · 再修 | 4 | 修完 4 条;全绿 |
  903 +
  904 +**最值得记的一条**是②那类:*第二轮的修复引入了第三轮才发现的缺陷*,而且是同一个形状——「界面撒谎」的镜像版本。这说明**对抗复核不能只做一轮**:修复本身和实现一样会出错,而且更隐蔽(它长得像在解决问题)。
  905 +
  906 +判定为**非问题**的 5 条也记一笔(复核给了证据):进度查询确实是一条 `GROUP BY` 不 N+1;`LIMIT` 的字符串插值前置了 `Number.isInteger` 守死;`tasks.goal_id` 的真外键在 `foreign_keys=ON` 下不与软删冲突;回填脚本的两条幂等判据覆盖了「脚本重跑」与「新链路建出来的 run」;`/goals` 别名确为零调用方(`OkrDashboard.vue` / `CompanyDashboard.vue` / `bin/lemon.js` 全走 `/okr`)。
  907 +
  908 +### 13.4 一条对外可见的破坏性变更
  909 +
  910 +`POST /api/company/:cid/tasks` 的 `goal_id`:旧代码原样存进 `graph_runs.goal_id` **不校验**,新代码要求它是 `goals` 表里本公司的真行,否则 **404**。仓库内无此调用方,但外部 agent 的 curl 若一直往这里传 OKR 节点 id 之类的值,会从 201 变 404。**404 是对的**(那种调用方本来就在制造悬空外键),但属于对外可见的行为收紧。
  911 +
  912 +### 13.5 留给下一轮的两条
  913 +
  914 +1. **run 转 `blocked` 时仍写 `finished_at`** —— 「挂起 ≠ 结束」,界面会显示一个随后被恢复覆盖掉的结束时间。改它要动 `settleTaskFromRun` 的签名。
  915 +2. **run 从 `blocked` 恢复成 `running` 时不把 task 写回 `running`** —— task 行会在恢复期停在 `blocked`。归档守卫已经兜住了它的风险(§13.2 ①),要消掉需要在 `afterRunTransition` 上加「离开终态时反向回写」,与批 3 的调度器一起做更合适。
... ...