From e435f8e8f3edecb186354f64bdbaf5ee50091274 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 18 Aug 2026 14:14:32 +0800 Subject: [PATCH] docs(27-P3C): 第二轮复核 —— 修 7 处照初稿实现就会出 bug 的地方 --- docs/todo/system-design/27-p3c-goal-task-model.md | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------- 1 file changed, 157 insertions(+), 28 deletions(-) diff --git a/docs/todo/system-design/27-p3c-goal-task-model.md b/docs/todo/system-design/27-p3c-goal-task-model.md index 4110ef2..01d0bb1 100644 --- a/docs/todo/system-design/27-p3c-goal-task-model.md +++ b/docs/todo/system-design/27-p3c-goal-task-model.md @@ -5,6 +5,16 @@ > **交付物**: ① `goals` / `tasks` 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的**排队与并行**;③ 执行控制台(`/:id/graph-console`)最左侧新增**目标·任务两级列表栏**。 > **命名**: 全文遵循 [27-NAMING](./27-naming-conventions.md),本文档**落地**的正是它 §四 定义但代码里一直没有载体的 `goal 1 : task N` 基数关系。 > **代码引用体例**: 沿用 27-P3/P4 头部约定 —— 只写 `文件名` + 函数名 / 常量名,**不写行号**。 +> +> **第二轮(2026-08-18,逐条对着代码复核初稿)**:抓到 7 处「照初稿实现就会出 bug」和 6 处补漏,正文已改,关键的错误结论**留档不删**(§三 的 ⚠️ 块、§五.1、§九.1 开头),因为它们是最容易再犯一次的那类。 +> ① **`createRun` 在建行的事务里就把 `graph_run_nodes` 按定义全插好了** —— 初稿三处基于「queued run 没有节点行」的论证全部作废(§三 / §四.3 / §九.1); +> ② 由 ① 派生出一个**既存但被排队放大成必然事件**的缺陷:节点行在入队时快照、定义却是读时取最新,**排队期间 replan 会让 run 出队即卡死**(§四.3 末,新增 `syncRunNodesToDef`); +> ③ 出队原语写错了 —— `markRunReady` 把 `from` 写死成 `planning`,复用不了;正解是 `updateRunStatus(runId,'queued','ready')`(§五.1); +> ④ 唯一索引冲突是 **throw** 不是 `changes = 0`,初稿的错误处理只覆盖了一半(§五.1); +> ⑤ **触发点枚举本身是漏的**:`stopRun` 与「预算耗尽 → blocked」两条最常见的释放路径都**不走 `settleRun`**,改为挂在唯一漏斗 `transitionRun` 上(§五.1.1); +> ⑥ `report_to` 只活在请求闭包里,排队后**回调链路静默断掉**;`okr_node_id` 逐 task 下发会**反复覆写同一个 OKR 节点**(§四.7 两条禁令); +> ⑦ **manual 任务会永久堵住队头**(占着 `running` 等人点「下一步」),改为不参与自动出队(§五.2bis)。 +> 补漏:存量回填脚本(§四.8,不做的话批 1 反而让历史任务更看不见)、任务级 `rerun` 与节点级 `retry` 的命名撞车(§六.3)、`queuePosition` 排序口径必须与调度器同源、砍掉没有消费方的 `goals.budget_json`(§十一.6)。 --- @@ -84,11 +94,13 @@ goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边 | task | 这个需求拆成哪几件事?谁先谁后? | 人(v1)/ `task-planning` agent(v2,§十一) | `draft → queued → running → completed / failed / blocked / cancelled` | | run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) | -**为什么 task 与 run 必须分开**(这是「不能直接把 task 当 root run」的三条硬理由): +**为什么 task 与 run 必须分开**(「不能直接把 task 当 root run」的三条硬理由): + +1. **重跑是 task 1 : run N**。今天「重跑一遍这个任务」只能新建一条毫不相干的 run,两次执行在数据上没有任何关联;有了 task 表,run 挂在 task 下,「这个任务试了 3 次、第 2 次卡在哪」才说得出来。 +2. **依赖与并行是 task 之间的关系**,不是 run 之间的。`depends_on` 只有落在稳定的 task id 上才有意义 —— run id 每次重跑都变。 +3. **run 是「某一版图的执行快照」,task 是「人要做这件事」的意图**。`createRun` 在建行那一刻就按当时的 org graph 把 `graph_run_nodes` 全部插好(§四.3 核实),一条 run 因此天然绑死一个图版本;而意图必须跨图版本存活 —— 排队期间 org graph 被重新规划,正是这个区分的用武之地(§四.3 末)。 -1. **排队态没有 run**。一个排在第 3 位的 task 还没有图、没有节点、没有预算消耗 —— 硬塞一条 run 行进去,`graph_run_nodes` 是空的,所有按 run 遍历节点的代码都要加空判。 -2. **重试是 task 1 : run N**。今天「重跑一遍这个任务」只能新建一条 run,于是两次执行在数据上毫无关联;有了 task 表,`runs` 挂在 task 下,「这个任务试了 3 次」才说得出来。 -3. **依赖与并行是 task 之间的关系**,不是 run 之间的。`depends_on` 只有落在稳定的 task id 上才有意义 —— run id 每次重试都变。 +> ⚠️ **初稿在这里的第一条理由是错的**,留档以免再犯:初稿写「排队态没有 run,硬塞一条 run 行进去 `graph_run_nodes` 是空的」。核实 `createRun` 后 —— 它的事务里就按 `readDefGraph` 把节点行全插好了,**queued run 有一整套 `pending` 节点**,与 ready run 的唯一差别就是 `run_status`。结论(两级必须分开)不变,理由换成上面三条。 --- @@ -108,8 +120,8 @@ CREATE TABLE IF NOT EXISTS goals ( -- draft | active | done | cancelled | archived priority TEXT NOT NULL DEFAULT 'P1', okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1) - budget_json TEXT, -- 整个 goal 的总预算;不填则每个 task 各用默认 created_by TEXT, -- 'human' | agent id(Master/CEO 代下单) + report_to TEXT, -- 收敛后要唤醒的上级 agent,见 §四.7 created_at TEXT NOT NULL, updated_at TEXT NOT NULL, dispatched_at TEXT, -- 首次下达时间;非空 = 描述已冻结 @@ -118,7 +130,9 @@ CREATE TABLE IF NOT EXISTS goals ( CREATE INDEX IF NOT EXISTS idx_goals_company ON goals(company_id, status); ``` -`okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度。task 级的进度靠 goal 汇总,不各自往 OKR 上写(会重复计数)。 +**没有 `budget_json`**:预算闸门是按 root run 算的(`DEFAULT_TASK_BUDGET` 只挂 root run,子 run 一律上溯 `root_run_id` 扣同一个池),goal 级预算**没有任何执行机制**。加一个没人消费的字段,就是这个仓库反复被咬的「实现在、零调用方」。goal 级总额控制留到 §十一.6 单独定口径。 + +`okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度 —— 具体的实现禁令见 §四.7,那不是一句设计倾向,是照现有代码顺手写就会踩的坑。 ### 4.2 `tasks` 表(新) @@ -136,7 +150,8 @@ CREATE TABLE IF NOT EXISTS tasks ( depends_on TEXT NOT NULL DEFAULT '[]', -- JSON 数组,同 goal 内的 task id budget_json TEXT, current_run_id TEXT, -- 最近一次 root run(逻辑外键,见 §四.6) - attempt INTEGER NOT NULL DEFAULT 0, -- 起过几次 run + attempt INTEGER NOT NULL DEFAULT 0, -- 起过几次 run(任务级重跑,≠ 节点级 retry) + report_to TEXT, -- 从 goal 继承或调用方指定;见 §四.7 priority TEXT NOT NULL DEFAULT 'P1', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -173,6 +188,24 @@ queued: ['ready', 'stopped'], // 出队 = queued → ready;取消 = queued â 顺带三个收益:入队即有稳定的 `run_id`(可分享的控制台链接)、`task-goal.json` 的落盘路径 `runs/{runId}/artifacts/` 在入队时就确定(描述冻结点与 artifact 落盘点重合,见 §四.4)、timeline 上能记下「何时入队」这条审计事实。 +#### 代价:入队即建行 = 节点行在入队时就被快照,出队前必须重新对齐 + +`createRun` 的事务里会按 `readDefGraph(graphKind, graphId)` 把 `graph_run_nodes` **一次插全**(每个 def node 一行 `pending`)。而 `getRunState` 是**每次读都重新读当前定义**,org run 的 `graph_id` 就是 `company_id` —— 也就是说定义那一侧永远是「此刻最新的 org graph」。 + +于是排队期间有人点了「重新规划组织」(`ReplanOrgGraphButton`,一个一等的用户操作): + +- 被删掉的节点 → 留下孤儿 run 行,`getRunState` 里 `defById.get()` 取到空对象,名字/类型全空; +- **新增的节点 → 根本没有 run 行 → 永远不会被拾取执行 → 这条 run 出队后必然卡死**。 + +> **这个洞今天就存在**(一条跑了一小时的 run 期间 replan,同样中招),只是今天 createRun 到 startRun 之间只隔几行代码,窗口是毫秒级。**排队把窗口放大到小时级,从理论缺陷变成必然事件。** + +定案两条,缺一不可: + +1. **出队时重新对齐节点行**:`queued → ready` 之前先按当前定义补齐缺失的 node 行、删掉孤儿行(都还是 `pending`,没有任何执行痕迹会丢)。这一步做成 `syncRunNodesToDef(runId)`,`stopped`/`completed` 的 run 一律不碰。 +2. **replan 时把该公司所有 `queued` run 标记为待对齐**(或直接就地跑一次上面的 sync)。理由是别让「出队时才发现图变了」成为唯一防线 —— 那时用户已经在等这条任务跑起来了。 + +> ponytail: 出队对齐是「定义变了就补行」的最小实现,它默认**新节点从 pending 开始**是安全的(queued run 本来一个节点都没跑过)。一旦将来允许对**跑到一半**的 run 做 replan,这个函数就不够用了,必须升级成带 `contract_hash` 判据的级联重置(`loops-l3.md` 已有那套四分类)。 + 状态机全貌: ``` @@ -216,6 +249,31 @@ const genId = (p) => `${p}-${Date.now().toString(36)}-${crypto.randomBytes(2).to `tasks.goal_id → goals(id)` 是新表,可以是真外键,直接写上。 +### 4.7 收敛汇报的两条实现禁令(照现有代码顺手写就会踩) + +`reportRunOutcome`(`company.js`)是 root run 收敛后的唯一出口,它做两件事:有 `report_to` 就 POST 给 `/api/agent/task/report` 唤醒上级 agent,没有就直接 `OkrService.updateNode(objectiveId, okrNodeId, {status})`。两件事在 goal/task 两级下都会坏: + +**禁令一:task 的 run 一律不带 `okr_node_id`。** +现在 `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 终态)时写一次。 + +**禁令二:`report_to` 必须落库。** +今天 `report_to` / `reporterAgent` 只活在请求作用域的闭包里(`ctx`),随手挂给 auto 模式的 driver。**排队之后 driver 是调度器在几小时后创建的,那时请求体早已不存在** —— 结果是 Master / CEO 代下单的回调链路**静默断掉**:不报错、不重试,就是没人被唤醒。所以 `goals.report_to` / `tasks.report_to` 两列是必需的,调度器起 driver 时从 task 行读回。 + +> 这两条都属于「既有实现是对的,只是它假设了『下达即执行』」。排队打破了那个假设,而打破得非常安静。 + +### 4.8 存量回填(不做的话,批 1 会让历史任务更看不见) + +既有的 root run 在 `tasks` 表里没有任何行。左栏一旦改成读 `tasks`,**所有历史 run 会从界面上消失** —— 正好是本文档要修的 A 类问题的反向放大。 + +迁移脚本(一次性,幂等,`--dry-run` 预演,写法照 `scripts/migrate-goals-to-okr.js`): + +对每条 `parent_run_id IS NULL` 的 `graph_runs`: +1. 建一条 `goals` 行:`description` 从 `data/companies/{cid}/runs/{runId}/artifacts/task-goal.json` 的 `task` 字段读回;读不到就用 `run.task_id` 占位并标 `title = '(历史任务)'`。`status` 按 run 终态映射(`completed → done`,其余 → `active` / `cancelled`)。 +2. 建一条 `tasks` 行:`seq=1`、`depends_on=[]`、`current_run_id = run.id`、`attempt=1`、`status` 按 run 状态映射,`queued_at = run.created_at`(描述立即冻结,历史任务本来也不该再改)。 +3. 回填 `graph_runs.goal_id` / `task_id` 指向新行。 + +> 实测当前 `data/graph.sqlite` 里只有 1 条 root run(且卡在 `running`),所以回填本身成本极低 —— 但**开发机与线上库的数据量不一样**,脚本必须写,不能靠「反正没几条」跳过。 + --- ## 五、排队与并行 @@ -231,19 +289,61 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ 1. 读公司并发上限 `max`(§五.2)。 2. 数当前活跃 root run 数 `n`(`run_status ∈ {ready,running,paused}`)。 3. `n >= max` → 直接返回(队伍照排,不动)。 -4. 选候选:`status='queued'` 且 `depends_on` 里每个 id 都已 `completed` 的 task,按 `priority` → `seq` → `created_at` 排序,取前 `max - n` 条。 -5. 逐条出队:`UPDATE graph_runs SET run_status='ready' WHERE id=? AND run_status='queued'` → 受影响行数为 0 说明被并发抢走或唯一索引拒绝,**跳过继续下一条**,不报错。成功则 `markRunReady` 建节点行 → `startRun(mode)`。 +4. 选候选:`status='queued'` 且 `depends_on` 里每个 id 都已 `completed`、且 `execution_mode='auto'`(manual 不自动出队,见 §五.3)的 task,按 `priority` → `seq` → `created_at` 排序,取前 `max - n` 条。 +5. 逐条出队,**整条包在 `try/catch` 里**: + ```js + syncRunNodesToDef(runId); // §四.3:定义可能已经变了 + const changed = updateRunStatus(runId, 'queued', 'ready'); // 原子出队,见下 + if (!changed) continue; // 被并发抢走,正常情况 + startRun(runId, { mode, task }); // manual 只点亮入口,auto 挂 driver + ``` + 捕获到 `UNIQUE constraint failed` 或 `InvalidTransitionError` 同样 `continue` —— 它们都是「这条没抢到」,不是错误。 -**触发点四个**(少一个就会有任务永远排在队里 —— 这正是当初拒绝做排队的那条理由,必须逐个接上): +**出队原语就是 `updateRunStatus(runId, 'queued', 'ready')` 这一条,不经过 `markRunReady`。** 核实结论: -| # | 触发 | 接在哪 | 补的是什么洞 | -|---|---|---|---| -| 1 | 任务入队时 | `POST /tasks` 尾部 | 队伍空时立即起跑 | -| 2 | **run 收敛时** | `graph-engine.settleRun` 转终态处 | 前一个跑完自动带起下一个 —— 队列的主动力 | -| 3 | 服务启动时 | `server/index.js` 现有的「中断任务恢复」旁边 | 进程重启丢掉的唤醒 | -| 4 | 60s 兜底定时器 | 同 3 | 前三条任意一条漏了都还能自愈 | +- `updateRunStatus` 本身就是条件更新(`WHERE id = @id AND run_status = @from`)并返回 `changes` —— 这正是要的 compare-and-swap; +- 它**先**跑 `canTransitionRun(from, to)`,非法转换直接 `throw InvalidTransitionError`; +- 撞唯一索引时 better-sqlite3 **抛 `SqliteError: UNIQUE constraint failed`,不是返回 `changes = 0`**。初稿只写了「受影响行数为 0 就跳过」,漏了这一半 —— 照初稿实现,一次正常的并发竞争会把整个 `tick` 抛出去; +- **`markRunReady` 不能复用**:它把 `transitionRun(runId, 'planning', 'ready')` 的 `from` **写死成 `planning`**,对一条 `queued` 的 run 是空转(`WHERE run_status='planning'` 匹配不到)。初稿写的「UPDATE 成 ready 之后再 markRunReady」既冗余又跑不通。 + +### 5.1.1 触发:挂在状态变更漏斗上,而不是枚举路径 -> ponytail: 触发 2 挂在 `settleRun` 上是**唯一**能覆盖 manual / auto / blocked 三种收敛路径的位置(`reportRunOutcome` 只挂得上 auto 的 driver —— 27-P3/P4 已经记过这个坑)。升级路径:将来有跨公司的全局配额时,`tick` 要从「按公司」改成「按全局配额池」。 +初稿列了四个触发点(入队 / `settleRun` / 启动 / 60s 兜底)。**核实后发现这个枚举本身就是漏的**: + +| 路径 | 会不会释放 active 坑 | 走不走 `settleRun` | +|---|---|---| +| 正常收敛(manual `/next` / auto driver)| 会 | ✅ 走(`settleRun` 只在 `advanceOne` 里被调用) | +| **人工停止 `stopRun`** | 会(`stopped`)| ❌ **不走** —— 代码注释自己写着「子 run 被 stopRun 直接转 stopped,不经过 settleRun」 | +| **预算耗尽 → `blocked`** | 会(`blocked` 不在 active 取值域里)| ❌ 不走 | + +也就是说「用户停掉一个任务」和「任务烧完预算」这两条最常见的释放路径,队列都不会动,要干等 60s 兜底。**继续往表里加行不是解法 —— 枚举一次漏一次,而漏的症状是任务静默躺在队里、界面上跟「正在排队」一模一样,正是当初拒绝做排队的那条理由。** + +改成挂在**唯一的状态变更漏斗**上:`graph-run-sse.transitionRun` 是全仓库所有 run 状态转换的必经之路(引擎侧一律走它,因为它同时要落 timeline 和推 SSE)。 + +```js +// transitionRun 内,条件更新成功之后 +if (run.parent_run_id === null && !ACTIVE_ROOT_RUN_STATUS.has(to)) { + setImmediate(() => scheduler.tick(run.company_id)); // 出漏斗再调,避免重入 +} +``` + +一个钩子覆盖 settle / stop / blocked / failed / completed 全部路径,且**语义就是那句话本身**——「一个 root run 离开了活跃集,去看看队里有没有人能顶上」。 + +保留的另外三个触发点:**入队时**(队伍空时立即起跑)、**服务启动时**(挂在 `server/index.js` 现有的「中断任务恢复」旁边,补进程重启丢掉的唤醒)、**60s 兜底定时器**(前面全漏了也能自愈)。 + +两条必须写进实现的护栏: +- **重入保护**:`tick` 会起 run → 起 run 会转换状态 → 又触发 `tick`。用一个 per-company 的 in-flight 标志,或统一 `setImmediate` 出栈后再跑。 +- **`tick` 里不 throw**:它挂在状态转换的尾巴上,抛出去会把一次正常的 run 收敛也带崩。 + +> ponytail: `tick` 目前按公司算配额。将来要跨公司全局配额时,改成「按配额池」——钩子位置不用动。 + +### 5.2bis manual 模式会永久堵住队头 + +`startRun(manual)` 只点亮入口节点就停下等 `/next`,而这时 run 已经是 `running` —— **占着 active 坑**。`max_parallel_tasks = 1` 时,一个没人点「下一步」的 manual 任务会把它后面所有任务**无限期堵死**,而队列界面上看不出任何异常。 + +定案:**manual 任务不参与自动出队**(§五.1 第 4 步的过滤条件)。它只能由人在控制台上显式点「开始」——那一步同样走 `queued → ready` 的原子出队,抢不到坑就提示「当前有任务在跑」。 + +理由:manual 的语义本来就是「人盯着一步步推」,让调度器在半夜自动把它拉起来、然后停在第一个节点上占着坑到天亮,是这个模式下最糟的行为。 ### 5.2 并发上限与唯一索引 @@ -283,15 +383,17 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ | 方法 | 路径 | 说明 | |---|---|---| -| `GET` | `/api/company/:cid/tasks` | **改造既有语义**:现在返回 task 表的行(含 `goalId` / `status` / `progress` / `runId`),而不是隐式的 root run 列表 | +| `GET` | `/api/company/:cid/tasks` | **新端点**(核实过:今天这个路径下只有 `POST`,没有 `GET`,不存在兼容问题)。返回 task 表的行,含 `goalId` / `status` / `queuePosition` / `progress` / `runId` | | `POST` | `/api/company/:cid/tasks` | **保留、行为兼容**:不带 `goal_id` 调用时,**自动建一个同描述的 goal**(`created_by` 记调用方)再挂一条 task —— 存量调用方(含 `DispatchTaskButton`、外部 agent 的 curl)一行不用改就还能用,响应体里多出 `goalId` 字段。带 `goal_id` 则挂到既有 goal 下 | | `PATCH` | `/api/company/:cid/tasks/:taskId` | 改 `title` / `priority`;`depends_on` 仅 `draft` 可改;`queued_at` 非空时带 `description` / `mode` / `budget` 一律 `409` | | `POST` | `/api/company/:cid/tasks/:taskId/enqueue` | 单条入队:建 `queued` 的 run 行 + 写 `task-goal.json` + `tick()` | | `POST` | `/api/company/:cid/tasks/:taskId/cancel` | `queued` → 撤出队列(run `queued → stopped`);`running` → 转调既有 `POST /api/graph-runs/:id/stop` | -| `POST` | `/api/company/:cid/tasks/:taskId/retry` | 从 `failed` / `blocked` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里) | +| `POST` | `/api/company/:cid/tasks/:taskId/rerun` | 从 `failed` / `blocked` / `completed` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里)。**刻意不叫 `retry`** —— 见下 | | `DELETE` | `/api/company/:cid/tasks/:taskId` | 归档,同 goal 的规则 | -**`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段。 +**`rerun` ≠ `retry`,两者会撞车。** 既有的 `POST /api/graph-runs/:id/nodes/:nodeId/retry` 是**节点级重试**:同一条 run 内把某个节点打回 `ready` 重跑(`blocked` 的唯一出路,`NODE_TRANSITIONS` 里写死的)。新加的是**任务级重跑**:新起一条 run,整张图从头来。两个都叫 retry,实现时必然有人把新端点接到旧逻辑上(或反过来)。所以任务级一律叫 `rerun`,文档、API、界面按钮文案(「重跑这个任务」vs 节点详情里的「重试这一步」)三处同时区分。 + +**`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段 —— **它的排序口径必须与调度器第 4 步逐字一致**(`priority → seq → created_at`),两处各写一份的下场已经有先例:`canDispatch` 就是因为前端自己复述了一遍取值域、还多写了一个 `blocked`,导致公司失败一次后再也下不了任务。**口径只留调度器一处,端点调用它算位置。** ### 6.4 不动的端点 @@ -347,17 +449,24 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ 每条都要在浏览器 `/e2e-probe/graph-console` 上真点出来,不是只跑单测。 1. 建一个 goal 不拆分 → 自动生成 1 条 task 并入队,左栏出现两级树,org 栏画出该 task 的 run。 -2. 建一个 goal 拆 3 条 task,T2/T3 依赖 T1 → T1 跑时 T2/T3 显示 `queued`;**T1 收敛后 T2 自动起跑**(验证触发点 2),全程无人工干预。 +2. 建一个 goal 拆 3 条 task,T2/T3 依赖 T1 → T1 跑时 T2/T3 显示 `queued`;**T1 收敛后 T2 自动起跑**(验证漏斗钩子覆盖正常收敛),全程无人工干预。 3. `max_parallel_tasks=1` 下建两条无依赖 task → 第二条排队而**不是 409**,`queuePosition=2`。 4. 存量调用方兼容:`curl POST /api/company/:cid/tasks -d '{"task":"…"}'`(不带 `goal_id`)仍返回 201,且自动生成了一个 goal。 5. 改已下达 goal 的 `description` → `409` + hint;改 `title` → 200。task 同理。 6. `depends_on` 写成环 → `400`,库里没有半条脏数据。 7. 取消一个 `queued` task → run `queued → stopped`,队列后面的顶上来。 8. `failed` 的 task 点重试 → `attempt=1`,新起一条 run,**旧 run 仍能在历史里点开**。 -9. 杀掉服务再起(模拟进程重启)→ 队列里的 task 被触发点 3 捡起来,不需要人工干预。 +9. 杀掉服务再起(模拟进程重启)→ 队列里的 task 被「服务启动」这个辅助触发捡起来,不需要人工干预。 10. 列表端点在 20 goal × 3 task 的公司上**不 N+1**(`graph_run_nodes` 的进度用一次 `GROUP BY` 出)。 11. `/api/goals` 与 `/api/company/:cid/goals` 的 OKR 别名退役后,`OkrDashboard` / `CompanyDashboard` / `bin/lemon.js` 全部功能正常(它们走的是 `/api/okr`)。 -12. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归。 +12. **回填**:跑迁移脚本后,既存的每条 root run 在左栏都以 goal + task 的形式出现,描述从 `task-goal.json` 读回(§四.8)。**这条必须在批 1 就验** —— 不验就等于批 1 让历史任务更看不见了。 +13. **停止即出队**:`max=1`,任务 A 跑着、B 排队,对 A 点「停止」→ **B 在几秒内自动起跑**(验证 §五.1.1 的漏斗钩子覆盖了 `stopRun` 这条不走 `settleRun` 的路径),不是等 60s 兜底。 +14. **预算耗尽即出队**:把 A 的预算调到极小让它烧成 `blocked` → B 同样自动起跑。 +15. **排队期间 replan**:B 排队时点「重新规划组织」改动 org graph → B 出队后**按新图跑**,没有孤儿节点、没有卡死(验证 `syncRunNodesToDef`)。 +16. **manual 不堵队**:下一个 `manual` 任务 + 一个 `auto` 任务 → manual 不被自动拉起,auto 正常起跑;在控制台上对 manual 点「开始」才占坑。 +17. **`report_to` 穿过队列**:带 `report_to` 下达一个会排队的任务,等它出队跑完 → 上级 agent 确实收到 `/api/agent/task/report`(验证 §四.7 禁令二)。 +18. **OKR 只写一次**:一个挂了 `okr_node_id` 的 goal 下放 3 个 task,其中一个 failed → OKR 节点**只被写一次**,且不是被最后那个 failed 覆盖的(验证禁令一)。 +19. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归。 --- @@ -367,18 +476,22 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ 新增一个状态值,所有按 `run_status` 分支的地方都要逐个确认 `queued` 不被误当可执行。实现时必须挨个看过并补测试: +**先纠正一个前提**:`queued` run **有一整套 `pending` 节点行**(`createRun` 建行时就按定义插全了),它和 `ready` run 在数据上唯一的差别就是 `run_status` 那一个字段。所以「靠没有节点行自然报错」这道想象中的防线**根本不存在** —— 唯一的闸门是各处对 `run_status` 的判断,必须逐个钉死。 + | 位置 | 期望 | |---|---| -| `graph-engine.advanceOne` / `startRun` | `queued` run 直接拒绝(没有 `graph_run_nodes`,进去就是空指针) | +| `graph-engine.advanceOne` | 已有 `if (run.run_status !== 'running') return result(..., 'run-not-running')` ✅ 天然挡住。**但要加断言测试钉死** —— 这是防止 `queued` run 被误推进的唯一一道闸 | +| `graph-engine.startRun` | 只接受 `ready`;对 `queued` 必须拒绝(出队顺序是先 `queued→ready` 再 `startRun`,不能反过来) | | `TERMINAL_RUN_STATUS` / `settleRun` / `propagateToParent` | `queued` 既非终态也非活跃态,收敛判定不该碰到它;且它只可能是 root run,不涉及向上传播 | | `ACTIVE_ROOT_RUN_STATUS`(`graph-run.js` + `company.js` 两处) | 不含 `queued` ✅ 天然正确,但要加断言测试钉死 —— **这条正是 §四.3 出队原子性的全部依赖** | | `GraphConsole.loadRuns` 的 `roots.find(active) \|\| roots[0]` | 会把 `queued` run 当成「最近的 root run」画图 —— **必须改**,否则入队一条任务就把 org 栏画崩 | | 服务启动的中断任务恢复(`server/index.js`)| `queued` 不该被当成「被中断的 run」拉起,它该走调度器 | | `company-heartbeat-service` | 同上 | +| `GET /api/graph-runs/:id` / SSE | `queued` run 要能正常读(控制台要显示「排队中」),不能因为没有 `started_at` 之类的字段报错 | ### 2. 「排队」这条路当初被明确否决过,现在要对它负责 -`company.js` 的注释原话:*选拒绝的理由是,仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路*。**这条判断在今天仍然成立** —— 它只是被 §五.1 的四个触发点解除。所以那四个触发点是本次的**功能主体**,不是收尾工作;验收 2 和 9 专门验它们。触发点接漏了,症状就是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。 +`company.js` 的注释原话:*选拒绝的理由是,仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路*。**这条判断在今天仍然成立** —— 它只是被 §五.1.1 的调度触发解除。所以调度器(尤其是挂在 `transitionRun` 上的那个漏斗钩子)是本次的**功能主体**,不是收尾工作;验收 2 / 9 / 13 / 14 专门验它。**初稿用「枚举四个触发点」的写法本身就漏了两条最常见的释放路径**(`stopRun` 与预算耗尽),这正说明枚举法在这里靠不住——症状是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。 ### 3. `description` 双写(DB + `task-goal.json`) @@ -388,6 +501,18 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ `data/companies/{cid}/runs/{runId}/` 仍在磁盘上。本次不做 GC;需要时另立清理任务(按 `archived_at` 早于 N 天)。 +### 4bis. 三处「排队打破了『下达即执行』这个隐含假设」 + +本次复核抓到的三条同形缺陷,共性是**既有实现都没错,只是它们都默认了「请求进来 → 立刻执行」**,排队把那个假设拆了,而且拆得非常安静(都不报错): + +| # | 缺陷 | 症状 | +|---|---|---| +| 1 | `report_to` 只活在请求闭包里(§四.7 禁令二)| 上级 agent 永远等不到回调,没有任何日志 | +| 2 | 节点行在 `createRun` 时快照,排队期间 replan 就分叉(§四.3 末)| 出队后 run 卡死在「有新节点但没有 run 行」 | +| 3 | `okr_node_id` 逐 task 写同一个 OKR 节点(§四.7 禁令一)| 最后一个收敛的 task 说了算,done 会被后来的 failed 覆盖 | + +**实现时的通用检查法**:凡是「从 HTTP 请求体里取出、直接交给 driver / 闭包」的东西,在排队模型下都要问一句「几小时后它还在吗」。 + ### 5. 两套 `T-` id `agent-chat.js` 的 tasklog 也发 `T-` 开头的 id,本次不动它。两者从此**长得像但不同源**,读日志时别混;本文档的 task id 一律带 `-{4hex}` 后缀,肉眼可分。 @@ -402,11 +527,13 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ | 批 | 内容 | 验收 | |---|---|---| -| **1 · 两级模型落库** | `goals` / `tasks` 建表 + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 | -| **2 · 控制台左栏** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + 新建表单(含拆分任务)| 1 / 12 | -| **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `task-scheduler` 四个触发点 + `depends_on` 环检测 + 入队/取消/重试端点 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 | +| **1 · 两级模型落库** | `goals` / `tasks` 建表 + **存量回填脚本(§四.8)** + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal、`report_to` 落库)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 / **12** | +| **2 · 控制台左栏** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + 新建表单(含拆分任务)| 1 / 19 | +| **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler`(漏斗钩子 + 三个辅助触发 + 重入保护)+ `depends_on` 环检测 + 入队/取消/rerun 端点 + §四.7 两条禁令 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 / **13-18** | | **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 | +> 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。 + 批 1+2 上线就已经解决用户提的核心痛点(历史任务看不见、需求无法分解);批 3 才是「不再只能有一个」。 --- @@ -418,3 +545,5 @@ tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同æ­ 3. **失败的 task 挡不挡住依赖它的下游?** 当前设计:`depends_on` 只认 `completed`,所以上游 failed 会让下游**永远排队**。需要一个显式的「跳过依赖强制入队」操作,还是把这类 task 自动转 `blocked`?倾向后者(自动转 `blocked` 并在界面标红),但要防止级联误判。 4. **跨公司任务总览**。`/api/tasks` + `TaskTimeline` 是从会话反推的另一套任务树,与本文档的 `tasks` 表同名不同源。将来是把它改成读这张表,还是并存?本次不碰,但这是个明确待收的口子。 5. **`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?** v1 选前者(`companyService.get` 已经在读那个文件,零新增读路径)。公司级配置项超过 3 个时再考虑建表。 +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),平分和总池都会让"还剩多少"变成一个解释不清的数。 +7. **排队中的任务能不能改依赖?** 现在 `depends_on` 只有 `draft` 可改。但真实场景是「B 已经排上了,才发现它得等 C」—— 这时只能取消重建,会丢掉排队位次。要不要给 `queued` 开一个只改 `depends_on` 的口子?开的话要防「改出环」和「改完位次怎么算」。 -- libgit2 0.21.4