Commit e435f8e8f3edecb186354f64bdbaf5ee50091274
1 parent
80eccbc9
docs(27-P3C): 第二轮复核 —— 修 7 处照初稿实现就会出 bug 的地方
逐条对着代码核初稿,最要命的一条是基础事实搞错了: - createRun 在建行的事务里就按 readDefGraph 把 graph_run_nodes 全插好了。 初稿三处基于「queued run 没有节点行、进去就是空指针」的论证全部作废。 真实情况是 queued run 与 ready run 在数据上只差 run_status 一个字段, 所以「靠没有节点行自然报错」那道想象中的防线根本不存在, 唯一的闸门是 advanceOne 里 run_status !== 'running' 那一句,必须加断言钉死。 由此派生出一个既存、但被排队从毫秒窗口放大成必然事件的缺陷: 节点行在 createRun 时快照,而 getRunState 每次读都取当前定义。 排队期间点「重新规划组织」→ 新节点没有 run 行 → 出队即卡死。 补 syncRunNodesToDef,出队前对齐。 其余六处: - 出队原语写错:markRunReady 把 from 写死成 planning,复用不了; 正解是 updateRunStatus(runId,'queued','ready') 这一条条件更新 - 唯一索引冲突是 throw 不是 changes=0,初稿的错误处理只覆盖一半 - 触发点枚举本身是漏的:stopRun 与预算耗尽→blocked 两条最常见的 释放路径都不走 settleRun。改为挂在唯一漏斗 transitionRun 上, 一个钩子覆盖全部路径(枚举法在这里靠不住,而漏的症状是静默的) - report_to 只活在请求闭包里,排队后回调链路静默断掉 → 必须落库 - okr_node_id 逐 task 下发会反复覆写同一个 OKR 节点, done 会被后来的 failed 翻掉 → task 的 run 一律不带 okr_node_id - manual 任务占着 running 等人点「下一步」,会永久堵住队头 → 不参与自动出队 补漏:存量回填脚本(不做的话批 1 反而让历史任务更看不见)、 任务级 rerun 与节点级 retry 的命名撞车、queuePosition 排序口径 必须与调度器同源、砍掉没有消费方的 goals.budget_json。 错误结论留档不删(§三 ⚠️ 块 / §五.1 / §九.1 开头)—— 它们是最容易再犯一次的那类。验收从 12 条涨到 19 条。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
1 changed file
with
157 additions
and
28 deletions
docs/todo/system-design/27-p3c-goal-task-model.md
| ... | ... | @@ -5,6 +5,16 @@ |
| 5 | 5 | > **交付物**: ① `goals` / `tasks` 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的**排队与并行**;③ 执行控制台(`/:id/graph-console`)最左侧新增**目标·任务两级列表栏**。 |
| 6 | 6 | > **命名**: 全文遵循 [27-NAMING](./27-naming-conventions.md),本文档**落地**的正是它 §四 定义但代码里一直没有载体的 `goal 1 : task N` 基数关系。 |
| 7 | 7 | > **代码引用体例**: 沿用 27-P3/P4 头部约定 —— 只写 `文件名` + 函数名 / 常量名,**不写行号**。 |
| 8 | +> | |
| 9 | +> **第二轮(2026-08-18,逐条对着代码复核初稿)**:抓到 7 处「照初稿实现就会出 bug」和 6 处补漏,正文已改,关键的错误结论**留档不删**(§三 的 ⚠️ 块、§五.1、§九.1 开头),因为它们是最容易再犯一次的那类。 | |
| 10 | +> ① **`createRun` 在建行的事务里就把 `graph_run_nodes` 按定义全插好了** —— 初稿三处基于「queued run 没有节点行」的论证全部作废(§三 / §四.3 / §九.1); | |
| 11 | +> ② 由 ① 派生出一个**既存但被排队放大成必然事件**的缺陷:节点行在入队时快照、定义却是读时取最新,**排队期间 replan 会让 run 出队即卡死**(§四.3 末,新增 `syncRunNodesToDef`); | |
| 12 | +> ③ 出队原语写错了 —— `markRunReady` 把 `from` 写死成 `planning`,复用不了;正解是 `updateRunStatus(runId,'queued','ready')`(§五.1); | |
| 13 | +> ④ 唯一索引冲突是 **throw** 不是 `changes = 0`,初稿的错误处理只覆盖了一半(§五.1); | |
| 14 | +> ⑤ **触发点枚举本身是漏的**:`stopRun` 与「预算耗尽 → blocked」两条最常见的释放路径都**不走 `settleRun`**,改为挂在唯一漏斗 `transitionRun` 上(§五.1.1); | |
| 15 | +> ⑥ `report_to` 只活在请求闭包里,排队后**回调链路静默断掉**;`okr_node_id` 逐 task 下发会**反复覆写同一个 OKR 节点**(§四.7 两条禁令); | |
| 16 | +> ⑦ **manual 任务会永久堵住队头**(占着 `running` 等人点「下一步」),改为不参与自动出队(§五.2bis)。 | |
| 17 | +> 补漏:存量回填脚本(§四.8,不做的话批 1 反而让历史任务更看不见)、任务级 `rerun` 与节点级 `retry` 的命名撞车(§六.3)、`queuePosition` 排序口径必须与调度器同源、砍掉没有消费方的 `goals.budget_json`(§十一.6)。 | |
| 8 | 18 | |
| 9 | 19 | --- |
| 10 | 20 | |
| ... | ... | @@ -84,11 +94,13 @@ goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边 |
| 84 | 94 | | task | 这个需求拆成哪几件事?谁先谁后? | 人(v1)/ `task-planning` agent(v2,§十一) | `draft → queued → running → completed / failed / blocked / cancelled` | |
| 85 | 95 | | run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) | |
| 86 | 96 | |
| 87 | -**为什么 task 与 run 必须分开**(这是「不能直接把 task 当 root run」的三条硬理由): | |
| 97 | +**为什么 task 与 run 必须分开**(「不能直接把 task 当 root run」的三条硬理由): | |
| 98 | + | |
| 99 | +1. **重跑是 task 1 : run N**。今天「重跑一遍这个任务」只能新建一条毫不相干的 run,两次执行在数据上没有任何关联;有了 task 表,run 挂在 task 下,「这个任务试了 3 次、第 2 次卡在哪」才说得出来。 | |
| 100 | +2. **依赖与并行是 task 之间的关系**,不是 run 之间的。`depends_on` 只有落在稳定的 task id 上才有意义 —— run id 每次重跑都变。 | |
| 101 | +3. **run 是「某一版图的执行快照」,task 是「人要做这件事」的意图**。`createRun` 在建行那一刻就按当时的 org graph 把 `graph_run_nodes` 全部插好(§四.3 核实),一条 run 因此天然绑死一个图版本;而意图必须跨图版本存活 —— 排队期间 org graph 被重新规划,正是这个区分的用武之地(§四.3 末)。 | |
| 88 | 102 | |
| 89 | -1. **排队态没有 run**。一个排在第 3 位的 task 还没有图、没有节点、没有预算消耗 —— 硬塞一条 run 行进去,`graph_run_nodes` 是空的,所有按 run 遍历节点的代码都要加空判。 | |
| 90 | -2. **重试是 task 1 : run N**。今天「重跑一遍这个任务」只能新建一条 run,于是两次执行在数据上毫无关联;有了 task 表,`runs` 挂在 task 下,「这个任务试了 3 次」才说得出来。 | |
| 91 | -3. **依赖与并行是 task 之间的关系**,不是 run 之间的。`depends_on` 只有落在稳定的 task id 上才有意义 —— run id 每次重试都变。 | |
| 103 | +> ⚠️ **初稿在这里的第一条理由是错的**,留档以免再犯:初稿写「排队态没有 run,硬塞一条 run 行进去 `graph_run_nodes` 是空的」。核实 `createRun` 后 —— 它的事务里就按 `readDefGraph` 把节点行全插好了,**queued run 有一整套 `pending` 节点**,与 ready run 的唯一差别就是 `run_status`。结论(两级必须分开)不变,理由换成上面三条。 | |
| 92 | 104 | |
| 93 | 105 | --- |
| 94 | 106 | |
| ... | ... | @@ -108,8 +120,8 @@ CREATE TABLE IF NOT EXISTS goals ( |
| 108 | 120 | -- draft | active | done | cancelled | archived |
| 109 | 121 | priority TEXT NOT NULL DEFAULT 'P1', |
| 110 | 122 | okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1) |
| 111 | - budget_json TEXT, -- 整个 goal 的总预算;不填则每个 task 各用默认 | |
| 112 | 123 | created_by TEXT, -- 'human' | agent id(Master/CEO 代下单) |
| 124 | + report_to TEXT, -- 收敛后要唤醒的上级 agent,见 §四.7 | |
| 113 | 125 | created_at TEXT NOT NULL, |
| 114 | 126 | updated_at TEXT NOT NULL, |
| 115 | 127 | dispatched_at TEXT, -- 首次下达时间;非空 = 描述已冻结 |
| ... | ... | @@ -118,7 +130,9 @@ CREATE TABLE IF NOT EXISTS goals ( |
| 118 | 130 | CREATE INDEX IF NOT EXISTS idx_goals_company ON goals(company_id, status); |
| 119 | 131 | ``` |
| 120 | 132 | |
| 121 | -`okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度。task 级的进度靠 goal 汇总,不各自往 OKR 上写(会重复计数)。 | |
| 133 | +**没有 `budget_json`**:预算闸门是按 root run 算的(`DEFAULT_TASK_BUDGET` 只挂 root run,子 run 一律上溯 `root_run_id` 扣同一个池),goal 级预算**没有任何执行机制**。加一个没人消费的字段,就是这个仓库反复被咬的「实现在、零调用方」。goal 级总额控制留到 §十一.6 单独定口径。 | |
| 134 | + | |
| 135 | +`okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度 —— 具体的实现禁令见 §四.7,那不是一句设计倾向,是照现有代码顺手写就会踩的坑。 | |
| 122 | 136 | |
| 123 | 137 | ### 4.2 `tasks` 表(新) |
| 124 | 138 | |
| ... | ... | @@ -136,7 +150,8 @@ CREATE TABLE IF NOT EXISTS tasks ( |
| 136 | 150 | depends_on TEXT NOT NULL DEFAULT '[]', -- JSON 数组,同 goal 内的 task id |
| 137 | 151 | budget_json TEXT, |
| 138 | 152 | current_run_id TEXT, -- 最近一次 root run(逻辑外键,见 §四.6) |
| 139 | - attempt INTEGER NOT NULL DEFAULT 0, -- 起过几次 run | |
| 153 | + attempt INTEGER NOT NULL DEFAULT 0, -- 起过几次 run(任务级重跑,≠ 节点级 retry) | |
| 154 | + report_to TEXT, -- 从 goal 继承或调用方指定;见 §四.7 | |
| 140 | 155 | priority TEXT NOT NULL DEFAULT 'P1', |
| 141 | 156 | created_at TEXT NOT NULL, |
| 142 | 157 | updated_at TEXT NOT NULL, |
| ... | ... | @@ -173,6 +188,24 @@ queued: ['ready', 'stopped'], // 出队 = queued → ready;取消 = queued |
| 173 | 188 | |
| 174 | 189 | 顺带三个收益:入队即有稳定的 `run_id`(可分享的控制台链接)、`task-goal.json` 的落盘路径 `runs/{runId}/artifacts/` 在入队时就确定(描述冻结点与 artifact 落盘点重合,见 §四.4)、timeline 上能记下「何时入队」这条审计事实。 |
| 175 | 190 | |
| 191 | +#### 代价:入队即建行 = 节点行在入队时就被快照,出队前必须重新对齐 | |
| 192 | + | |
| 193 | +`createRun` 的事务里会按 `readDefGraph(graphKind, graphId)` 把 `graph_run_nodes` **一次插全**(每个 def node 一行 `pending`)。而 `getRunState` 是**每次读都重新读当前定义**,org run 的 `graph_id` 就是 `company_id` —— 也就是说定义那一侧永远是「此刻最新的 org graph」。 | |
| 194 | + | |
| 195 | +于是排队期间有人点了「重新规划组织」(`ReplanOrgGraphButton`,一个一等的用户操作): | |
| 196 | + | |
| 197 | +- 被删掉的节点 → 留下孤儿 run 行,`getRunState` 里 `defById.get()` 取到空对象,名字/类型全空; | |
| 198 | +- **新增的节点 → 根本没有 run 行 → 永远不会被拾取执行 → 这条 run 出队后必然卡死**。 | |
| 199 | + | |
| 200 | +> **这个洞今天就存在**(一条跑了一小时的 run 期间 replan,同样中招),只是今天 createRun 到 startRun 之间只隔几行代码,窗口是毫秒级。**排队把窗口放大到小时级,从理论缺陷变成必然事件。** | |
| 201 | + | |
| 202 | +定案两条,缺一不可: | |
| 203 | + | |
| 204 | +1. **出队时重新对齐节点行**:`queued → ready` 之前先按当前定义补齐缺失的 node 行、删掉孤儿行(都还是 `pending`,没有任何执行痕迹会丢)。这一步做成 `syncRunNodesToDef(runId)`,`stopped`/`completed` 的 run 一律不碰。 | |
| 205 | +2. **replan 时把该公司所有 `queued` run 标记为待对齐**(或直接就地跑一次上面的 sync)。理由是别让「出队时才发现图变了」成为唯一防线 —— 那时用户已经在等这条任务跑起来了。 | |
| 206 | + | |
| 207 | +> ponytail: 出队对齐是「定义变了就补行」的最小实现,它默认**新节点从 pending 开始**是安全的(queued run 本来一个节点都没跑过)。一旦将来允许对**跑到一半**的 run 做 replan,这个函数就不够用了,必须升级成带 `contract_hash` 判据的级联重置(`loops-l3.md` 已有那套四分类)。 | |
| 208 | + | |
| 176 | 209 | 状态机全貌: |
| 177 | 210 | |
| 178 | 211 | ``` |
| ... | ... | @@ -216,6 +249,31 @@ const genId = (p) => `${p}-${Date.now().toString(36)}-${crypto.randomBytes(2).to |
| 216 | 249 | |
| 217 | 250 | `tasks.goal_id → goals(id)` 是新表,可以是真外键,直接写上。 |
| 218 | 251 | |
| 252 | +### 4.7 收敛汇报的两条实现禁令(照现有代码顺手写就会踩) | |
| 253 | + | |
| 254 | +`reportRunOutcome`(`company.js`)是 root run 收敛后的唯一出口,它做两件事:有 `report_to` 就 POST 给 `/api/agent/task/report` 唤醒上级 agent,没有就直接 `OkrService.updateNode(objectiveId, okrNodeId, {status})`。两件事在 goal/task 两级下都会坏: | |
| 255 | + | |
| 256 | +**禁令一:task 的 run 一律不带 `okr_node_id`。** | |
| 257 | +现在 `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 终态)时写一次。 | |
| 258 | + | |
| 259 | +**禁令二:`report_to` 必须落库。** | |
| 260 | +今天 `report_to` / `reporterAgent` 只活在请求作用域的闭包里(`ctx`),随手挂给 auto 模式的 driver。**排队之后 driver 是调度器在几小时后创建的,那时请求体早已不存在** —— 结果是 Master / CEO 代下单的回调链路**静默断掉**:不报错、不重试,就是没人被唤醒。所以 `goals.report_to` / `tasks.report_to` 两列是必需的,调度器起 driver 时从 task 行读回。 | |
| 261 | + | |
| 262 | +> 这两条都属于「既有实现是对的,只是它假设了『下达即执行』」。排队打破了那个假设,而打破得非常安静。 | |
| 263 | + | |
| 264 | +### 4.8 存量回填(不做的话,批 1 会让历史任务更看不见) | |
| 265 | + | |
| 266 | +既有的 root run 在 `tasks` 表里没有任何行。左栏一旦改成读 `tasks`,**所有历史 run 会从界面上消失** —— 正好是本文档要修的 A 类问题的反向放大。 | |
| 267 | + | |
| 268 | +迁移脚本(一次性,幂等,`--dry-run` 预演,写法照 `scripts/migrate-goals-to-okr.js`): | |
| 269 | + | |
| 270 | +对每条 `parent_run_id IS NULL` 的 `graph_runs`: | |
| 271 | +1. 建一条 `goals` 行:`description` 从 `data/companies/{cid}/runs/{runId}/artifacts/task-goal.json` 的 `task` 字段读回;读不到就用 `run.task_id` 占位并标 `title = '(历史任务)'`。`status` 按 run 终态映射(`completed → done`,其余 → `active` / `cancelled`)。 | |
| 272 | +2. 建一条 `tasks` 行:`seq=1`、`depends_on=[]`、`current_run_id = run.id`、`attempt=1`、`status` 按 run 状态映射,`queued_at = run.created_at`(描述立即冻结,历史任务本来也不该再改)。 | |
| 273 | +3. 回填 `graph_runs.goal_id` / `task_id` 指向新行。 | |
| 274 | + | |
| 275 | +> 实测当前 `data/graph.sqlite` 里只有 1 条 root run(且卡在 `running`),所以回填本身成本极低 —— 但**开发机与线上库的数据量不一样**,脚本必须写,不能靠「反正没几条」跳过。 | |
| 276 | + | |
| 219 | 277 | --- |
| 220 | 278 | |
| 221 | 279 | ## 五、排队与并行 |
| ... | ... | @@ -231,19 +289,61 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 231 | 289 | 1. 读公司并发上限 `max`(§五.2)。 |
| 232 | 290 | 2. 数当前活跃 root run 数 `n`(`run_status ∈ {ready,running,paused}`)。 |
| 233 | 291 | 3. `n >= max` → 直接返回(队伍照排,不动)。 |
| 234 | -4. 选候选:`status='queued'` 且 `depends_on` 里每个 id 都已 `completed` 的 task,按 `priority` → `seq` → `created_at` 排序,取前 `max - n` 条。 | |
| 235 | -5. 逐条出队:`UPDATE graph_runs SET run_status='ready' WHERE id=? AND run_status='queued'` → 受影响行数为 0 说明被并发抢走或唯一索引拒绝,**跳过继续下一条**,不报错。成功则 `markRunReady` 建节点行 → `startRun(mode)`。 | |
| 292 | +4. 选候选:`status='queued'` 且 `depends_on` 里每个 id 都已 `completed`、且 `execution_mode='auto'`(manual 不自动出队,见 §五.3)的 task,按 `priority` → `seq` → `created_at` 排序,取前 `max - n` 条。 | |
| 293 | +5. 逐条出队,**整条包在 `try/catch` 里**: | |
| 294 | + ```js | |
| 295 | + syncRunNodesToDef(runId); // §四.3:定义可能已经变了 | |
| 296 | + const changed = updateRunStatus(runId, 'queued', 'ready'); // 原子出队,见下 | |
| 297 | + if (!changed) continue; // 被并发抢走,正常情况 | |
| 298 | + startRun(runId, { mode, task }); // manual 只点亮入口,auto 挂 driver | |
| 299 | + ``` | |
| 300 | + 捕获到 `UNIQUE constraint failed` 或 `InvalidTransitionError` 同样 `continue` —— 它们都是「这条没抢到」,不是错误。 | |
| 236 | 301 | |
| 237 | -**触发点四个**(少一个就会有任务永远排在队里 —— 这正是当初拒绝做排队的那条理由,必须逐个接上): | |
| 302 | +**出队原语就是 `updateRunStatus(runId, 'queued', 'ready')` 这一条,不经过 `markRunReady`。** 核实结论: | |
| 238 | 303 | |
| 239 | -| # | 触发 | 接在哪 | 补的是什么洞 | | |
| 240 | -|---|---|---|---| | |
| 241 | -| 1 | 任务入队时 | `POST /tasks` 尾部 | 队伍空时立即起跑 | | |
| 242 | -| 2 | **run 收敛时** | `graph-engine.settleRun` 转终态处 | 前一个跑完自动带起下一个 —— 队列的主动力 | | |
| 243 | -| 3 | 服务启动时 | `server/index.js` 现有的「中断任务恢复」旁边 | 进程重启丢掉的唤醒 | | |
| 244 | -| 4 | 60s 兜底定时器 | 同 3 | 前三条任意一条漏了都还能自愈 | | |
| 304 | +- `updateRunStatus` 本身就是条件更新(`WHERE id = @id AND run_status = @from`)并返回 `changes` —— 这正是要的 compare-and-swap; | |
| 305 | +- 它**先**跑 `canTransitionRun(from, to)`,非法转换直接 `throw InvalidTransitionError`; | |
| 306 | +- 撞唯一索引时 better-sqlite3 **抛 `SqliteError: UNIQUE constraint failed`,不是返回 `changes = 0`**。初稿只写了「受影响行数为 0 就跳过」,漏了这一半 —— 照初稿实现,一次正常的并发竞争会把整个 `tick` 抛出去; | |
| 307 | +- **`markRunReady` 不能复用**:它把 `transitionRun(runId, 'planning', 'ready')` 的 `from` **写死成 `planning`**,对一条 `queued` 的 run 是空转(`WHERE run_status='planning'` 匹配不到)。初稿写的「UPDATE 成 ready 之后再 markRunReady」既冗余又跑不通。 | |
| 308 | + | |
| 309 | +### 5.1.1 触发:挂在状态变更漏斗上,而不是枚举路径 | |
| 245 | 310 | |
| 246 | -> ponytail: 触发 2 挂在 `settleRun` 上是**唯一**能覆盖 manual / auto / blocked 三种收敛路径的位置(`reportRunOutcome` 只挂得上 auto 的 driver —— 27-P3/P4 已经记过这个坑)。升级路径:将来有跨公司的全局配额时,`tick` 要从「按公司」改成「按全局配额池」。 | |
| 311 | +初稿列了四个触发点(入队 / `settleRun` / 启动 / 60s 兜底)。**核实后发现这个枚举本身就是漏的**: | |
| 312 | + | |
| 313 | +| 路径 | 会不会释放 active 坑 | 走不走 `settleRun` | | |
| 314 | +|---|---|---| | |
| 315 | +| 正常收敛(manual `/next` / auto driver)| 会 | ✅ 走(`settleRun` 只在 `advanceOne` 里被调用) | | |
| 316 | +| **人工停止 `stopRun`** | 会(`stopped`)| ❌ **不走** —— 代码注释自己写着「子 run 被 stopRun 直接转 stopped,不经过 settleRun」 | | |
| 317 | +| **预算耗尽 → `blocked`** | 会(`blocked` 不在 active 取值域里)| ❌ 不走 | | |
| 318 | + | |
| 319 | +也就是说「用户停掉一个任务」和「任务烧完预算」这两条最常见的释放路径,队列都不会动,要干等 60s 兜底。**继续往表里加行不是解法 —— 枚举一次漏一次,而漏的症状是任务静默躺在队里、界面上跟「正在排队」一模一样,正是当初拒绝做排队的那条理由。** | |
| 320 | + | |
| 321 | +改成挂在**唯一的状态变更漏斗**上:`graph-run-sse.transitionRun` 是全仓库所有 run 状态转换的必经之路(引擎侧一律走它,因为它同时要落 timeline 和推 SSE)。 | |
| 322 | + | |
| 323 | +```js | |
| 324 | +// transitionRun 内,条件更新成功之后 | |
| 325 | +if (run.parent_run_id === null && !ACTIVE_ROOT_RUN_STATUS.has(to)) { | |
| 326 | + setImmediate(() => scheduler.tick(run.company_id)); // 出漏斗再调,避免重入 | |
| 327 | +} | |
| 328 | +``` | |
| 329 | + | |
| 330 | +一个钩子覆盖 settle / stop / blocked / failed / completed 全部路径,且**语义就是那句话本身**——「一个 root run 离开了活跃集,去看看队里有没有人能顶上」。 | |
| 331 | + | |
| 332 | +保留的另外三个触发点:**入队时**(队伍空时立即起跑)、**服务启动时**(挂在 `server/index.js` 现有的「中断任务恢复」旁边,补进程重启丢掉的唤醒)、**60s 兜底定时器**(前面全漏了也能自愈)。 | |
| 333 | + | |
| 334 | +两条必须写进实现的护栏: | |
| 335 | +- **重入保护**:`tick` 会起 run → 起 run 会转换状态 → 又触发 `tick`。用一个 per-company 的 in-flight 标志,或统一 `setImmediate` 出栈后再跑。 | |
| 336 | +- **`tick` 里不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的 run 收敛也带崩。 | |
| 337 | + | |
| 338 | +> ponytail: `tick` 目前按公司算配额。将来要跨公司全局配额时,改成「按配额池」——钩子位置不用动。 | |
| 339 | + | |
| 340 | +### 5.2bis manual 模式会永久堵住队头 | |
| 341 | + | |
| 342 | +`startRun(manual)` 只点亮入口节点就停下等 `/next`,而这时 run 已经是 `running` —— **占着 active 坑**。`max_parallel_tasks = 1` 时,一个没人点「下一步」的 manual 任务会把它后面所有任务**无限期堵死**,而队列界面上看不出任何异常。 | |
| 343 | + | |
| 344 | +定案:**manual 任务不参与自动出队**(§五.1 第 4 步的过滤条件)。它只能由人在控制台上显式点「开始」——那一步同样走 `queued → ready` 的原子出队,抢不到坑就提示「当前有任务在跑」。 | |
| 345 | + | |
| 346 | +理由:manual 的语义本来就是「人盯着一步步推」,让调度器在半夜自动把它拉起来、然后停在第一个节点上占着坑到天亮,是这个模式下最糟的行为。 | |
| 247 | 347 | |
| 248 | 348 | ### 5.2 并发上限与唯一索引 |
| 249 | 349 | |
| ... | ... | @@ -283,15 +383,17 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 283 | 383 | |
| 284 | 384 | | 方法 | 路径 | 说明 | |
| 285 | 385 | |---|---|---| |
| 286 | -| `GET` | `/api/company/:cid/tasks` | **改造既有语义**:现在返回 task 表的行(含 `goalId` / `status` / `progress` / `runId`),而不是隐式的 root run 列表 | | |
| 386 | +| `GET` | `/api/company/:cid/tasks` | **新端点**(核实过:今天这个路径下只有 `POST`,没有 `GET`,不存在兼容问题)。返回 task 表的行,含 `goalId` / `status` / `queuePosition` / `progress` / `runId` | | |
| 287 | 387 | | `POST` | `/api/company/:cid/tasks` | **保留、行为兼容**:不带 `goal_id` 调用时,**自动建一个同描述的 goal**(`created_by` 记调用方)再挂一条 task —— 存量调用方(含 `DispatchTaskButton`、外部 agent 的 curl)一行不用改就还能用,响应体里多出 `goalId` 字段。带 `goal_id` 则挂到既有 goal 下 | |
| 288 | 388 | | `PATCH` | `/api/company/:cid/tasks/:taskId` | 改 `title` / `priority`;`depends_on` 仅 `draft` 可改;`queued_at` 非空时带 `description` / `mode` / `budget` 一律 `409` | |
| 289 | 389 | | `POST` | `/api/company/:cid/tasks/:taskId/enqueue` | 单条入队:建 `queued` 的 run 行 + 写 `task-goal.json` + `tick()` | |
| 290 | 390 | | `POST` | `/api/company/:cid/tasks/:taskId/cancel` | `queued` → 撤出队列(run `queued → stopped`);`running` → 转调既有 `POST /api/graph-runs/:id/stop` | |
| 291 | -| `POST` | `/api/company/:cid/tasks/:taskId/retry` | 从 `failed` / `blocked` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里) | | |
| 391 | +| `POST` | `/api/company/:cid/tasks/:taskId/rerun` | 从 `failed` / `blocked` / `completed` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里)。**刻意不叫 `retry`** —— 见下 | | |
| 292 | 392 | | `DELETE` | `/api/company/:cid/tasks/:taskId` | 归档,同 goal 的规则 | |
| 293 | 393 | |
| 294 | -**`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段。 | |
| 394 | +**`rerun` ≠ `retry`,两者会撞车。** 既有的 `POST /api/graph-runs/:id/nodes/:nodeId/retry` 是**节点级重试**:同一条 run 内把某个节点打回 `ready` 重跑(`blocked` 的唯一出路,`NODE_TRANSITIONS` 里写死的)。新加的是**任务级重跑**:新起一条 run,整张图从头来。两个都叫 retry,实现时必然有人把新端点接到旧逻辑上(或反过来)。所以任务级一律叫 `rerun`,文档、API、界面按钮文案(「重跑这个任务」vs 节点详情里的「重试这一步」)三处同时区分。 | |
| 395 | + | |
| 396 | +**`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段 —— **它的排序口径必须与调度器第 4 步逐字一致**(`priority → seq → created_at`),两处各写一份的下场已经有先例:`canDispatch` 就是因为前端自己复述了一遍取值域、还多写了一个 `blocked`,导致公司失败一次后再也下不了任务。**口径只留调度器一处,端点调用它算位置。** | |
| 295 | 397 | |
| 296 | 398 | ### 6.4 不动的端点 |
| 297 | 399 | |
| ... | ... | @@ -347,17 +449,24 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 347 | 449 | 每条都要在浏览器 `/e2e-probe/graph-console` 上真点出来,不是只跑单测。 |
| 348 | 450 | |
| 349 | 451 | 1. 建一个 goal 不拆分 → 自动生成 1 条 task 并入队,左栏出现两级树,org 栏画出该 task 的 run。 |
| 350 | -2. 建一个 goal 拆 3 条 task,T2/T3 依赖 T1 → T1 跑时 T2/T3 显示 `queued`;**T1 收敛后 T2 自动起跑**(验证触发点 2),全程无人工干预。 | |
| 452 | +2. 建一个 goal 拆 3 条 task,T2/T3 依赖 T1 → T1 跑时 T2/T3 显示 `queued`;**T1 收敛后 T2 自动起跑**(验证漏斗钩子覆盖正常收敛),全程无人工干预。 | |
| 351 | 453 | 3. `max_parallel_tasks=1` 下建两条无依赖 task → 第二条排队而**不是 409**,`queuePosition=2`。 |
| 352 | 454 | 4. 存量调用方兼容:`curl POST /api/company/:cid/tasks -d '{"task":"…"}'`(不带 `goal_id`)仍返回 201,且自动生成了一个 goal。 |
| 353 | 455 | 5. 改已下达 goal 的 `description` → `409` + hint;改 `title` → 200。task 同理。 |
| 354 | 456 | 6. `depends_on` 写成环 → `400`,库里没有半条脏数据。 |
| 355 | 457 | 7. 取消一个 `queued` task → run `queued → stopped`,队列后面的顶上来。 |
| 356 | 458 | 8. `failed` 的 task 点重试 → `attempt=1`,新起一条 run,**旧 run 仍能在历史里点开**。 |
| 357 | -9. 杀掉服务再起(模拟进程重启)→ 队列里的 task 被触发点 3 捡起来,不需要人工干预。 | |
| 459 | +9. 杀掉服务再起(模拟进程重启)→ 队列里的 task 被「服务启动」这个辅助触发捡起来,不需要人工干预。 | |
| 358 | 460 | 10. 列表端点在 20 goal × 3 task 的公司上**不 N+1**(`graph_run_nodes` 的进度用一次 `GROUP BY` 出)。 |
| 359 | 461 | 11. `/api/goals` 与 `/api/company/:cid/goals` 的 OKR 别名退役后,`OkrDashboard` / `CompanyDashboard` / `bin/lemon.js` 全部功能正常(它们走的是 `/api/okr`)。 |
| 360 | -12. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归。 | |
| 462 | +12. **回填**:跑迁移脚本后,既存的每条 root run 在左栏都以 goal + task 的形式出现,描述从 `task-goal.json` 读回(§四.8)。**这条必须在批 1 就验** —— 不验就等于批 1 让历史任务更看不见了。 | |
| 463 | +13. **停止即出队**:`max=1`,任务 A 跑着、B 排队,对 A 点「停止」→ **B 在几秒内自动起跑**(验证 §五.1.1 的漏斗钩子覆盖了 `stopRun` 这条不走 `settleRun` 的路径),不是等 60s 兜底。 | |
| 464 | +14. **预算耗尽即出队**:把 A 的预算调到极小让它烧成 `blocked` → B 同样自动起跑。 | |
| 465 | +15. **排队期间 replan**:B 排队时点「重新规划组织」改动 org graph → B 出队后**按新图跑**,没有孤儿节点、没有卡死(验证 `syncRunNodesToDef`)。 | |
| 466 | +16. **manual 不堵队**:下一个 `manual` 任务 + 一个 `auto` 任务 → manual 不被自动拉起,auto 正常起跑;在控制台上对 manual 点「开始」才占坑。 | |
| 467 | +17. **`report_to` 穿过队列**:带 `report_to` 下达一个会排队的任务,等它出队跑完 → 上级 agent 确实收到 `/api/agent/task/report`(验证 §四.7 禁令二)。 | |
| 468 | +18. **OKR 只写一次**:一个挂了 `okr_node_id` 的 goal 下放 3 个 task,其中一个 failed → OKR 节点**只被写一次**,且不是被最后那个 failed 覆盖的(验证禁令一)。 | |
| 469 | +19. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归。 | |
| 361 | 470 | |
| 362 | 471 | --- |
| 363 | 472 | |
| ... | ... | @@ -367,18 +476,22 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 367 | 476 | |
| 368 | 477 | 新增一个状态值,所有按 `run_status` 分支的地方都要逐个确认 `queued` 不被误当可执行。实现时必须挨个看过并补测试: |
| 369 | 478 | |
| 479 | +**先纠正一个前提**:`queued` run **有一整套 `pending` 节点行**(`createRun` 建行时就按定义插全了),它和 `ready` run 在数据上唯一的差别就是 `run_status` 那一个字段。所以「靠没有节点行自然报错」这道想象中的防线**根本不存在** —— 唯一的闸门是各处对 `run_status` 的判断,必须逐个钉死。 | |
| 480 | + | |
| 370 | 481 | | 位置 | 期望 | |
| 371 | 482 | |---|---| |
| 372 | -| `graph-engine.advanceOne` / `startRun` | `queued` run 直接拒绝(没有 `graph_run_nodes`,进去就是空指针) | | |
| 483 | +| `graph-engine.advanceOne` | 已有 `if (run.run_status !== 'running') return result(..., 'run-not-running')` ✅ 天然挡住。**但要加断言测试钉死** —— 这是防止 `queued` run 被误推进的唯一一道闸 | | |
| 484 | +| `graph-engine.startRun` | 只接受 `ready`;对 `queued` 必须拒绝(出队顺序是先 `queued→ready` 再 `startRun`,不能反过来) | | |
| 373 | 485 | | `TERMINAL_RUN_STATUS` / `settleRun` / `propagateToParent` | `queued` 既非终态也非活跃态,收敛判定不该碰到它;且它只可能是 root run,不涉及向上传播 | |
| 374 | 486 | | `ACTIVE_ROOT_RUN_STATUS`(`graph-run.js` + `company.js` 两处) | 不含 `queued` ✅ 天然正确,但要加断言测试钉死 —— **这条正是 §四.3 出队原子性的全部依赖** | |
| 375 | 487 | | `GraphConsole.loadRuns` 的 `roots.find(active) \|\| roots[0]` | 会把 `queued` run 当成「最近的 root run」画图 —— **必须改**,否则入队一条任务就把 org 栏画崩 | |
| 376 | 488 | | 服务启动的中断任务恢复(`server/index.js`)| `queued` 不该被当成「被中断的 run」拉起,它该走调度器 | |
| 377 | 489 | | `company-heartbeat-service` | 同上 | |
| 490 | +| `GET /api/graph-runs/:id` / SSE | `queued` run 要能正常读(控制台要显示「排队中」),不能因为没有 `started_at` 之类的字段报错 | | |
| 378 | 491 | |
| 379 | 492 | ### 2. 「排队」这条路当初被明确否决过,现在要对它负责 |
| 380 | 493 | |
| 381 | -`company.js` 的注释原话:*选拒绝的理由是,仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路*。**这条判断在今天仍然成立** —— 它只是被 §五.1 的四个触发点解除。所以那四个触发点是本次的**功能主体**,不是收尾工作;验收 2 和 9 专门验它们。触发点接漏了,症状就是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。 | |
| 494 | +`company.js` 的注释原话:*选拒绝的理由是,仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路*。**这条判断在今天仍然成立** —— 它只是被 §五.1.1 的调度触发解除。所以调度器(尤其是挂在 `transitionRun` 上的那个漏斗钩子)是本次的**功能主体**,不是收尾工作;验收 2 / 9 / 13 / 14 专门验它。**初稿用「枚举四个触发点」的写法本身就漏了两条最常见的释放路径**(`stopRun` 与预算耗尽),这正说明枚举法在这里靠不住——症状是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。 | |
| 382 | 495 | |
| 383 | 496 | ### 3. `description` 双写(DB + `task-goal.json`) |
| 384 | 497 | |
| ... | ... | @@ -388,6 +501,18 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 388 | 501 | |
| 389 | 502 | `data/companies/{cid}/runs/{runId}/` 仍在磁盘上。本次不做 GC;需要时另立清理任务(按 `archived_at` 早于 N 天)。 |
| 390 | 503 | |
| 504 | +### 4bis. 三处「排队打破了『下达即执行』这个隐含假设」 | |
| 505 | + | |
| 506 | +本次复核抓到的三条同形缺陷,共性是**既有实现都没错,只是它们都默认了「请求进来 → 立刻执行」**,排队把那个假设拆了,而且拆得非常安静(都不报错): | |
| 507 | + | |
| 508 | +| # | 缺陷 | 症状 | | |
| 509 | +|---|---|---| | |
| 510 | +| 1 | `report_to` 只活在请求闭包里(§四.7 禁令二)| 上级 agent 永远等不到回调,没有任何日志 | | |
| 511 | +| 2 | 节点行在 `createRun` 时快照,排队期间 replan 就分叉(§四.3 末)| 出队后 run 卡死在「有新节点但没有 run 行」 | | |
| 512 | +| 3 | `okr_node_id` 逐 task 写同一个 OKR 节点(§四.7 禁令一)| 最后一个收敛的 task 说了算,done 会被后来的 failed 覆盖 | | |
| 513 | + | |
| 514 | +**实现时的通用检查法**:凡是「从 HTTP 请求体里取出、直接交给 driver / 闭包」的东西,在排队模型下都要问一句「几小时后它还在吗」。 | |
| 515 | + | |
| 391 | 516 | ### 5. 两套 `T-` id |
| 392 | 517 | |
| 393 | 518 | `agent-chat.js` 的 tasklog 也发 `T-` 开头的 id,本次不动它。两者从此**长得像但不同源**,读日志时别混;本文档的 task id 一律带 `-{4hex}` 后缀,肉眼可分。 |
| ... | ... | @@ -402,11 +527,13 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 402 | 527 | |
| 403 | 528 | | 批 | 内容 | 验收 | |
| 404 | 529 | |---|---|---| |
| 405 | -| **1 · 两级模型落库** | `goals` / `tasks` 建表 + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 | | |
| 406 | -| **2 · 控制台左栏** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + 新建表单(含拆分任务)| 1 / 12 | | |
| 407 | -| **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `task-scheduler` 四个触发点 + `depends_on` 环检测 + 入队/取消/重试端点 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 | | |
| 530 | +| **1 · 两级模型落库** | `goals` / `tasks` 建表 + **存量回填脚本(§四.8)** + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal、`report_to` 落库)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 / **12** | | |
| 531 | +| **2 · 控制台左栏** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + 新建表单(含拆分任务)| 1 / 19 | | |
| 532 | +| **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler`(漏斗钩子 + 三个辅助触发 + 重入保护)+ `depends_on` 环检测 + 入队/取消/rerun 端点 + §四.7 两条禁令 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 / **13-18** | | |
| 408 | 533 | | **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 | |
| 409 | 534 | |
| 535 | +> 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。 | |
| 536 | + | |
| 410 | 537 | 批 1+2 上线就已经解决用户提的核心痛点(历史任务看不见、需求无法分解);批 3 才是「不再只能有一个」。 |
| 411 | 538 | |
| 412 | 539 | --- |
| ... | ... | @@ -418,3 +545,5 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同 |
| 418 | 545 | 3. **失败的 task 挡不挡住依赖它的下游?** 当前设计:`depends_on` 只认 `completed`,所以上游 failed 会让下游**永远排队**。需要一个显式的「跳过依赖强制入队」操作,还是把这类 task 自动转 `blocked`?倾向后者(自动转 `blocked` 并在界面标红),但要防止级联误判。 |
| 419 | 546 | 4. **跨公司任务总览**。`/api/tasks` + `TaskTimeline` 是从会话反推的另一套任务树,与本文档的 `tasks` 表同名不同源。将来是把它改成读这张表,还是并存?本次不碰,但这是个明确待收的口子。 |
| 420 | 547 | 5. **`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?** v1 选前者(`companyService.get` 已经在读那个文件,零新增读路径)。公司级配置项超过 3 个时再考虑建表。 |
| 548 | +6. **goal 级总预算怎么定口径?** §四.1 砍掉了 `goals.budget_json`,因为预算闸门只认 root run,goal 级没有执行机制。真要做,三选一:(a) 建 task 时按条数平分 goal 额度;(b) 预算池上移到 goal,`spendBudget` 从 `root_run_id` 改成上溯 `goal_id`(改动最大但语义最正);(c) 不做 goal 级,只在界面上把 N 个 task 的消耗加起来显示。**倾向 (c)** —— 一个 goal 的 task 数会变(rerun、后补 task),平分和总池都会让"还剩多少"变成一个解释不清的数。 | |
| 549 | +7. **排队中的任务能不能改依赖?** 现在 `depends_on` 只有 `draft` 可改。但真实场景是「B 已经排上了,才发现它得等 C」—— 这时只能取消重建,会丢掉排队位次。要不要给 `queued` 开一个只改 `depends_on` 的口子?开的话要防「改出环」和「改完位次怎么算」。 | ... | ... |