# 27-P3C. 公司目标与任务 —— goal / task 两级模型 + 控制台任务列表 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 P3 收尾,直接续 [27-P3/P4 执行控制台](./27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](./27-p3b-real-run-runway.md)。 > **日期**: 2026-08-18 > **交付物**: ① `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)。 > > **第三轮(2026-08-18,四条口径由用户定案 → 27-REQ R-20.5)**:① **`task-planning` agent 提进 v1**(原计划 v2),新增 §四.9,批 2 范围与工作量随之变化;② 并行仍按批 4 单独排期,v1 只做排队(`max_parallel_tasks = 1`,唯一索引不动);③ manual 任务不参与自动出队(§五.2bis);④ 上游失败 → 下游自动推导为 `blocked` 并标红(§五.1.2)。 > 定案 ① 连带牵出一处新缺陷:**`task-planning` 跑在 goal 层、任何 run 存在之前,它烧的 token 没有预算池可扣** —— 正是 `defaultPlanWorkGraph` 注释里点名的「隐形的一半花费」的新实例。上一轮刚以「零消费方」为由砍掉的 goal 级预算字段,因此以记账字段 `planning_tokens` 的形式回来(§四.1)。 --- ## 一、问题 用户的原话是「一个 company 在控制台下达任务只能有一个」。拆开是**三件事**: | # | 现象 | 真实成因 | 本次 | |---|---|---|---| | A | 历史任务**看不见、切不了、管不了** | 纯界面缺失。`GraphConsole.loadRuns()` 拿到了全部 root run,却只取 `roots.find(active) \|\| roots[0]`,其余全丢 | ✅ §七 | | B | 一家公司**同时**只能跑一个,下第二个吃 409 | v1 硬约束:`work-graph-db` 的 `idx_graph_runs_one_active_root` 唯一索引 + `company.js` 的预检查。当时选「拒绝」而不是「排队」的理由写在代码注释里 —— **仓库里没有任何出队 worker,排进去的任务不会被消费,那是一条会静默丢任务的路** | ✅ §五 补上调度器,约束从「拒绝」改成「排队」 | | C | 「人类提的需求」和「机器执行的单元」是**同一个东西** | `POST /api/company/:cid/tasks` 一次调用同时创造了需求、执行单元和执行实例三层语义,压在一条 root run 上。于是需求无法分解,也就无从并行 | ✅ §三 / §四 | C 是根因,A 和 B 是它的两个表征。**先拆两级模型,A/B 才有干净的解法。** --- ## 二、与既有命名的对齐(先读这一节) ### 2.1 这不是新概念,是给一个已存在的悬空外键补上它的表 [27-NAMING §四](./27-naming-conventions.md) 早已定义好基数: | 概念 | 基数 | 定义(原文) | |---|---|---| | **goal** | 一个人类需求描述 = **1 个** | 验收的边界。回答「这次交付到底算不算满足了人提的要求」 | | **task** | 一个 goal 分解出 **N 个** | 图中的执行单元。**可并行**、可下钻 sub agent | | **okr** | 跨多个 goal 的长周期度量树 | Objective / Key Result | 而 `graph_runs` 里: ```sql goal_id TEXT, -- 全仓库**没有任何写入方**,只有 body.goal_id 透传 task_id TEXT, -- 由 POST /:cid/tasks 现生成,无表、无唯一性保证 ``` `company.js` 那条注释写得明明白白:`goal_id?, okr_node_id?, 三者基数不同不可混用(goal 1 : task N : okr 跨 goal 度量树)`。**字段在、语义在、表不在。** 本文档做的就是把这两张缺席的表补上,不是发明新词。 ### 2.2 「goal」一词的六个用法消歧(实现前必须分清) | 写法 | 指什么 | 载体 | 本次是否新增 | |---|---|---|---| | `company-goal` | 建公司时那句定性目标 | `company.json.goal` | 否 | | **`goal`(本文档)** | **人类下达的一个需求 = 验收边界** | **新 `goals` 表,`G-xxx`** | ✅ 新增 | | `task-goal` | **一个 task** 的验收边界(= 该 task 的描述) | `runs/{runId}/artifacts/task-goal.json` | 否,位置不变 | | `subtask-goal` | work graph 内分支的验收边界 | `workgraph-planning` 拆分时产生 | 否 | | OKR `Objective` | 长周期度量树,`O-xxx` | `data/companies/{cid}/okr/` | 否 | | `goal-benchmark` / `goal-test-data` / `goal-test-script` | 三种**验收产物**(agent / 节点名) | work graph 节点 | 否 | > **注意 `task-goal` 的归属没变**:它是「一次 task 执行的验收边界」,artifact 本来就落在 `runs/{runId}/` 下 —— 也就是**每个 task 一份**。引入 goal 表后它的位置和语义都不用动,goal 的描述是它的**上级**,不是它的替代。 ### 2.3 `G-` 前缀可以用,`/api/company/:cid/goals` 可以要回来 两处历史包袱都已经清干净,实地核实过: 1. **`G-` 前缀已被 OKR 释放**。27-NAMING §六 的迁移把 OKR 第 1 层 `G-001` 正名为 `O-001`(`npm run okr:migrate` 已执行,且当时存量数据「几乎为空」)。所以新 `goals` 表用 `G-` 不与任何活数据冲突。 2. **`/api/goals` 与 `/api/company/:cid/goals` 目前是 OKR 的向后兼容别名,但全仓库零调用方**。grep 的结果只有三行,全是别名注册语句本身(`index.js` / `agent-server.js` / `okr.js` 的注释)。**本次连带退役这两个别名**,把路径要回来给新概念 —— 一个词在同一套 API 里指两件事,是比破坏兼容更贵的债。 --- ## 三、模型:goal → task → run 三级 ``` goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边界 │ 描述一经下达不可改(§四.4) └─ tasks (T-xxx) N 条。lemonboss 的执行单元,可排队、可并行、可有依赖 │ 描述一经下达不可改(§四.4) └─ graph_runs root run,M 条。一次**执行实例**(重试会产生第 2 条) └─ 子 run 每个 org node 现规划出的 work graph,及更深的子图 ``` 三级各自回答一个问题,不重叠: | 层 | 回答的问题 | 谁改它 | 状态机 | |---|---|---|---| | goal | 人想要什么?做到了没有? | 人 | `draft → planning → active → done / cancelled / archived` | | task | 这个需求拆成哪几件事?谁先谁后? | **`task-planning` agent 拆、人可改**(§四.9)| `draft → queued → running → completed / failed / blocked / cancelled` | | run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) | **为什么 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 末)。 > ⚠️ **初稿在这里的第一条理由是错的**,留档以免再犯:初稿写「排队态没有 run,硬塞一条 run 行进去 `graph_run_nodes` 是空的」。核实 `createRun` 后 —— 它的事务里就按 `readDefGraph` 把节点行全插好了,**queued run 有一整套 `pending` 节点**,与 ready run 的唯一差别就是 `run_status`。结论(两级必须分开)不变,理由换成上面三条。 --- ## 四、数据模型 DB 文件复用 `data/graph.sqlite`(`graph-db.js` 的 `getDb()`,WAL + `foreign_keys=ON` 已设好),与 `graph_runs` 同库同连接。迁移写法照抄 `work-graph-db.js` 现有的「存量库迁移」块(`hasColumn` 判断 + `ALTER TABLE ADD COLUMN`)。 ### 4.1 `goals` 表(新) ```sql CREATE TABLE IF NOT EXISTS goals ( id TEXT PRIMARY KEY, -- G-{base36}-{4hex},见 §四.5 company_id TEXT NOT NULL, title TEXT NOT NULL, -- 短标题,列表显示用;可改 description TEXT NOT NULL, -- 人类需求原文 = 验收边界;下达后不可改 status TEXT NOT NULL DEFAULT 'draft', -- draft | planning | active | done | cancelled | archived -- planning = task-planning 正在拆分(§四.9),是个真会持续几十秒的状态 priority TEXT NOT NULL DEFAULT 'P1', okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1) created_by TEXT, -- 'human' | agent id(Master/CEO 代下单) report_to TEXT, -- 收敛后要唤醒的上级 agent,见 §四.7 planning_tokens INTEGER NOT NULL DEFAULT 0, -- task-planning 这一次拆分烧掉的 token,见 §四.9 created_at TEXT NOT NULL, updated_at TEXT NOT NULL, dispatched_at TEXT, -- 首次下达时间;非空 = 描述已冻结 archived_at TEXT ); CREATE INDEX IF NOT EXISTS idx_goals_company ON goals(company_id, status); ``` **没有 `budget_json`,但有 `planning_tokens`**:预算**闸门**是按 root run 算的(`DEFAULT_TASK_BUDGET` 只挂 root run,子 run 一律上溯 `root_run_id` 扣同一个池),goal 级预算没有任何执行机制,加一个没人消费的额度字段就是这个仓库反复被咬的「实现在、零调用方」。 但 `task-planning` 定案进 v1 之后,**goal 层出现了一笔真实、且没有池子可扣的花费**:它跑在任何 run 存在之前。`defaultPlanWorkGraph` 的注释已经把这个病点过名 ——「规划调用也是真花钱的一次 agent 调用,token 必须计入同一个 root run 预算池,否则『一层规划 + 一层执行』里有一半的花费是隐形的」。goal 级拆分正是那个「隐形的一半」的新实例,所以**必须记账**(`planning_tokens`),哪怕暂时不设闸门。goal 级总额**控制**仍留到 §十一.6。 `okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度 —— 具体的实现禁令见 §四.7,那不是一句设计倾向,是照现有代码顺手写就会踩的坑。 ### 4.2 `tasks` 表(新) ```sql CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, -- T-{base36}-{4hex} goal_id TEXT NOT NULL REFERENCES goals(id), company_id TEXT NOT NULL, -- 冗余一份:调度器按公司选任务,避免每次 JOIN seq INTEGER NOT NULL, -- goal 内序号,列表排序用(1,2,3…) title TEXT NOT NULL, description TEXT NOT NULL, -- 落进 task-goal.json 的那句;下达后不可改 status TEXT NOT NULL DEFAULT 'draft', -- draft | queued | running | completed | failed | blocked | cancelled execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), 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(任务级重跑,≠ 节点级 retry) report_to TEXT, -- 从 goal 继承或调用方指定;见 §四.7 created_by TEXT, -- 'task-planning' | 'human':这一条是拆出来的还是人加的 blocked_reason TEXT, -- status='blocked' 时的原因,如 upstream_failed:T-xxx priority TEXT NOT NULL DEFAULT 'P1', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, queued_at TEXT, -- 入队时间;非空 = 描述已冻结 started_at TEXT, finished_at TEXT, archived_at TEXT ); CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks(goal_id, seq); CREATE INDEX IF NOT EXISTS idx_tasks_queue ON tasks(company_id, status, priority, created_at); ``` **`depends_on` 一个字段同时表达串行与并行**,不另设 `parallel_group`: - `[]` → 无前置,可立即入队 → 同一 goal 下多个空依赖的 task **天然并行**(能不能真并行由 §五.2 的并发上限决定)。 - `["T-a"]` → 等 `T-a` 到 `completed` 才可调度 → **串行**。 - 多个元素 → 汇聚点(fan-in)。 也就是说 goal 内部就是一张 task DAG。校验:写入时查环(`depends_on` 只能引用同 goal 内已存在的 task),有环 `400`。 ### 4.3 `graph_runs` 新增一个 run status:`queued` **这是本次唯一动到既有运行时表的地方。** `run_status` 列上没有 CHECK 约束(只有 `graph_kind` / `execution_mode` 有),所以加值不需要改表,只要给 `work-graph-db` 的 `RUN_TRANSITIONS` 加一行: ```js queued: ['ready', 'stopped'], // 出队 = queued → ready;取消 = queued → stopped ``` **为什么有了 tasks 表还需要它**(否则确实是冗余的第二套排队状态)—— 只有一个理由,但足够硬: > **它把「出队」这个并发临界区交给已有的唯一索引兜底。** > > 调度器出队时,如果先 `INSERT` 一条 `ready` 的 run,两个并发的 tick 会各插一条,超发。而 `idx_graph_runs_one_active_root` 的 `WHERE` 覆盖 `ready/running/paused`、**不覆盖 `queued`** —— 于是:入队时创建 run 行为 `queued`(不占坑,可以有任意多条),出队时做一次 `UPDATE ... SET run_status='ready' WHERE id=? AND run_status='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` 已有那套四分类)。 状态机全貌: ``` task: draft ──提交──▶ queued ──调度器出队──▶ running ──┬─▶ completed │ ├─▶ failed cancelled ├─▶ blocked ──重试──▶ queued └─▶ cancelled run: queued ──▶ ready ──▶ running ──┬─▶ completed / failed / blocked / stopped │ └─▶ paused ──▶ running stopped (既有转换全部不变) ``` ### 4.4 描述冻结(用户要求 3) | 对象 | 可改字段 | 冻结点 | 冻结后改会怎样 | |---|---|---|---| | goal | `title` / `priority` / `okr_node_id` | `dispatched_at IS NOT NULL`(首个 task 入队)后 **`description` 冻结** | `409` | | task | `title` / `priority`(`depends_on` 仅 draft) | `queued_at IS NOT NULL` 后 **`description` / `execution_mode` / `budget` 冻结** | `409` | **这不是保守,是数据一致性的硬要求**:task 的 description 在入队时就写进了 `runs/{runId}/artifacts/task-goal.json`,而 `describeTask` 等 agent 侧链路读的是 artifact。改库不改 artifact 会让两份记录对不上;两份都改则等于篡改已被 agent 消费过的审计输入。**要改就取消后新建**,错误响应里直接给这句 hint。 `title` 留着可改,因为它只是列表上的一个标签,不进任何 agent 的上下文。 ### 4.5 ID 生成 —— 现有格式在本次会真的撞 现在两处都写 `` `T-${Date.now().toString(36)}` ``(`company.js` 与 `agent-chat.js`)。**同一毫秒内生成的两个 id 完全相同** —— 今天没炸是因为一次只建一个;本次「一个 goal 一次性建 N 个 task」是**循环建行**,同毫秒撞是必然事件,而 `tasks.id` 是主键,撞了就是 500。 改成仓库既有约定(`_spec/CONVENTIONS.md` §2:*时间戳部分不保证全局唯一 —— 靠后缀随机位避免碰撞*): ```js const genId = (p) => `${p}-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`; // G-mabc123-7f3a / T-mabc123-9e21 ``` `agent-chat.js` 那处 tasklog 的 `T-` 生成**本次不动**(那是另一套存储),但要在 §九 记一笔:两套 `T-` id 从此可能长得像但不同源,读的时候别混。 ### 4.6 逻辑外键,不重建表 `graph_runs.goal_id` / `task_id` 保持现状不加 `REFERENCES`:SQLite 不支持给已有列追加外键,要加只能重建表 —— 而 `graph_runs` 上挂着 `graph_run_nodes` / `graph_run_loop_records` 的真外键,重建代价远大于收益。约束由服务层保证,文档里标注为**逻辑外键**。 `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`),所以回填本身成本极低 —— 但**开发机与线上库的数据量不一样**,脚本必须写,不能靠「反正没几条」跳过。 ### 4.9 `task-planning` —— 把 goal 拆成 task DAG(v1 就做,用户定案) 命名依 27-NAMING §二「`{产出物}-planning`」:产出物是 task 列表,故 **`task-planning`**。它是继 `orggraph-planning` / `workgraph-planning` 之后的**第三个规划类 agent**,形态与后者对齐。 #### 链路照抄 `defaultPlanWorkGraph`,不另造 `graph-engine.defaultPlanWorkGraph` 已经把「调规划 agent → 校验产物 → 不过就把 `errors` 原样回给它再来一轮」这条链路跑通了(`PLAN_MAX_ROUNDS = 3`)。`task-planning` 用**同一个形状**: ``` AgentManager 调 task-planning(输入:goal.description + 公司 org graph 摘要 + 可用角色清单) → 产出 JSON: [{ title, description, depends_on: [], seq }] → 校验:字段齐全 / depends_on 只引用本次产出的 seq / 无环 / 条数 ≤ 上限 → 不过 → errors 原样回喂,最多 3 轮 → 过 → 落 tasks 表,全部 status='draft' ``` **产出的 task 一律是 `draft`,不自动入队。** 拆完在界面上是一份可编辑的清单(改描述、删、加、调依赖),人点「全部下达」才转 `queued`。理由:拆分是一次 LLM 猜测,直接把 5 条任务塞进队列跑起来,等于让一次猜错的拆分直接烧掉 5 份预算;而这一步的编辑成本几乎为零。 #### 三条必须写进实现的约束 1. **token 记账**:复用 `defaultPlanWorkGraph` 那个 `onAcpEvent` 累加 `usage` 的写法,结果写进 `goals.planning_tokens`(§四.1)。这是 goal 层唯一一笔花费,不记就是隐形的。 2. **失败必须降级,不能把 goal 卡在 `planning`**:3 轮都没产出合法 DAG → **退化成 1 条同描述的 task**(`created_by='human'`、`seq=1`、`depends_on=[]`),goal 转 `active`,并在界面标注「自动拆分失败,已按单任务下达」。绝不能留一个永远转圈的 `planning` goal —— 那正是这次要消灭的那类静默状态。 3. **`_registry.json` 必须同时登记**:新建本体目录而漏登记,`server/tests/unit/template-refs.test.js` 会直接把 CI 跑红 —— 27-P3/P4 §八.21 记着这个前科(六个本体建了目录没登记,718 passed / 1 failed)。本体文件按 `workgraph-planning` 的形态给齐:`agent.md` / `config.json` / `identity.md` / `soul.md` / `tools.md`。 #### 跳过拆分 `POST /goals` 带 `split: false` 时不调 agent,直接建 1 条同描述的 task。存量调用方(`POST /:cid/tasks`)走的就是这条路 —— **它们不该因为这次改动突然多花一次 LLM 调用**。 --- ## 五、排队与并行 ### 5.1 调度器 `server/services/task-scheduler.js`(新) 一个函数就是全部: ```js tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同步 API) ``` 1. 读公司并发上限 `max`(§五.2)。 2. 数当前活跃 root run 数 `n`(`run_status ∈ {ready,running,paused}`)。 3. `n >= max` → 直接返回(队伍照排,不动)。 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`。** 核实结论: - `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 触发:挂在状态变更漏斗上,而不是枚举路径 初稿列了四个触发点(入队 / `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.1.2 上游失败 → 下游自动转 `blocked`(用户定案) `depends_on` 只认 `completed`,所以上游一旦 `failed` / `cancelled`,依赖它的下游**永远满足不了条件**。放着不管,那条 task 会静默躺在队里,界面上和「正在正常排队」长得一模一样 —— 又是这次要消灭的那类症状。 `tick` 开头加一步(在选候选之前): ```js // 依赖已经不可能满足的 queued task,就地转 blocked,别让它假装在排队 for (const t of queuedTasks) { const dead = t.depends_on.find((id) => ['failed','cancelled'].includes(statusOf(id))); if (dead) setTaskBlocked(t.id, `upstream_failed:${dead}`); } ``` 界面上 `blocked` 的行标红并显示 `blocked_reason`,`⋯` 菜单给两个出路:**重跑上游**(上游 `rerun` 成功后,下游自动回 `queued`)或 **强制放行**(清掉那条依赖,人为承担风险)。 > **级联要一次算到底**:A 失败 → B blocked → 依赖 B 的 C 也应该 blocked(`blocked` 同样不是 `completed`)。上面那段按「依赖里有任意一个处于非 completed 终态」判定即可自然级联,但要注意**别把 `blocked` 本身当成终态去反向传播**,否则 A 重跑成功后 C 解不开。解法:`blocked` 只由这一步**推导**得出,不落成不可逆状态 —— 每次 `tick` 重算一遍,上游恢复了就自己回 `queued`。 ### 5.2bis manual 模式会永久堵住队头 `startRun(manual)` 只点亮入口节点就停下等 `/next`,而这时 run 已经是 `running` —— **占着 active 坑**。`max_parallel_tasks = 1` 时,一个没人点「下一步」的 manual 任务会把它后面所有任务**无限期堵死**,而队列界面上看不出任何异常。 定案:**manual 任务不参与自动出队**(§五.1 第 4 步的过滤条件)。它只能由人在控制台上显式点「开始」——那一步同样走 `queued → ready` 的原子出队,抢不到坑就提示「当前有任务在跑」。 理由:manual 的语义本来就是「人盯着一步步推」,让调度器在半夜自动把它拉起来、然后停在第一个节点上占着坑到天亮,是这个模式下最糟的行为。 ### 5.2 并发上限与唯一索引 `company.json` 加一个字段(不新建表,`companyService.get` 已经在读这个文件): ```jsonc { "max_parallel_tasks": 1 } // 缺省 1 = 与今天完全同行为 ``` - **`max = 1`(默认、批 2 上线时的形态)**:`idx_graph_runs_one_active_root` 保留不动,DB 层的守卫还在,行为与今天一致,只是「拒绝」变成了「排队」。 - **`max > 1`(批 4)**:这个唯一索引必须 `DROP`,否则第二条并行任务在 `queued → ready` 时被索引拒掉。替代守卫是 §五.1 第 2-5 步**整体包在一个 `better-sqlite3` 事务里**(`db.transaction(...)`)—— SQLite 是单写者,写事务天然序列化,「count 完再 admit」在这里是真原子的,不是应用层的乐观检查。 **批 4 单独排期、单独验收**,理由:删掉一个唯一索引 = 删掉最后一道 DB 兜底,这个动作不该和「界面上能看到任务列表」混在一次上线里。 --- ## 六、API 全部挂在 `server/routes/company.js`。错误响应沿用现有 `{ error, hint, ... }` 形态。 ### 6.1 先做一件清理:退役 OKR 的 `/goals` 别名 `index.js` / `agent-server.js` 里 `app.use('/api/goals', require('./routes/okr'))` 与 `company.js` 的 `okrPath()` 里那条 `/:companyId/goals${suffix}` 一并删除。§二.3 已核实**零调用方**。删完 `/goals` 这个词在整套 API 里只剩一个含义。 ### 6.2 goal | 方法 | 路径 | 说明 | |---|---|---| | `GET` | `/api/company/:cid/goals` | 列表。query: `status` / `includeArchived` / `withTasks=1`(带出每个 goal 的 task 摘要,列表页一次拉完不 N+1) | | `POST` | `/api/company/:cid/goals` | 新建。body `{ title, description, priority?, okr_node_id?, report_to?, split?: bool, tasks?: [...] }`。**默认 `split: true` → 异步调 `task-planning`**:立即 `202 { goalId, status: 'planning' }` 返回,拆分产物几十秒后才出现(前端轮询或走 SSE)。`split: false` 或显式传 `tasks` 则同步建行、不调 agent。**产出一律是 `draft`,不自动入队** —— 入队走下面的 `dispatch` | | `GET` | `/api/company/:cid/goals/:goalId` | 详情:goal + 全部 task + 每个 task 的最近一次 run 摘要 | | `PATCH` | `/api/company/:cid/goals/:goalId` | 改 `title` / `priority` / `okr_node_id`;`dispatched_at` 非空时带 `description` 一律 `409`(§四.4) | | `POST` | `/api/company/:cid/goals/:goalId/dispatch` | 把该 goal 下所有 `draft` task 入队,写 `dispatched_at` | | `DELETE` | `/api/company/:cid/goals/:goalId` | 归档。有 task 正在跑 → `409` + hint「先停止 T-xxx」;否则连带归档其下 task | ### 6.3 task | 方法 | 路径 | 说明 | |---|---|---| | `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/rerun` | 从 `failed` / `blocked` / `completed` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里)。**刻意不叫 `retry`** —— 见下 | | `DELETE` | `/api/company/:cid/tasks/:taskId` | 归档,同 goal 的规则 | **`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 不动的端点 `GET /api/company/:cid/graph-runs`(其它调用方在用)、全部 `/api/graph-runs/*` 控制端点、`/api/okr/*`。 --- ## 七、前端 ### 7.1 布局 ``` ┌──────────────┬──────────────┬───────────────────────┬──────────────┐ │ 目标 · 任务 │ Org Graph │ Work Graph │ 节点详情 │ │ 280px │ 320px │ 1fr │ 360px │ │ │ │ │ │ │ [+ 新目标] │ ○─○─○ │ ○──○──○ │ 四个 tab │ │ ▾ G 做个官网 │ │ │ │ │ │ │ │ ● T1 首页 ◀│ ○──○ │ ○ │ │ │ ◌ T2 后台 │ │ │ │ │ ⋯ T3 排队#2│ │ │ │ │ ▸ G 埋点改造 │ │ │ │ └──────────────┴──────────────┴───────────────────────┴──────────────┘ ``` `GraphConsole.vue` 的 `.cols` 从 `320px 1fr 360px` 改为 `280px 320px minmax(0,1fr) 360px`。 响应式:`≤1400px` 最左栏收成 44px 图标条 + 抽屉展开(org graph 那栏最不能压,它是全局导航);`≤1100px` 沿用既有堆叠,目标栏排最上。 ### 7.2 新组件 `web/src/components/graph-console/GoalTaskPane.vue` 两级树,goal 可折叠,**选中态永远落在 task 上**(org / work graph 是 task 级的): - **goal 行**:状态点 + 标题 + `3/5 任务完成` + `⋯` 菜单(编辑 / 下达全部 / 归档)。点标题展开收起;goal 行本身被选中时,中栏显示这个 goal 的任务概览(v1 就是一张简单的任务卡片列表,不画图)。 - **task 行**:状态点 + 标题(2 行截断)+ 第二排 `queued#2 · auto · 3/7 · 12min`。`depends_on` 非空时标题前加 `⇢` 并 hover 出「等 T1 完成」。 - 状态点配色**沿用画布节点的既有色板**,不新造:running 蓝 / completed 绿 / failed 红 / blocked 橙 / queued 灰蓝 / draft 空心圈 / cancelled 灰。 **「+ 新目标」表单是两步的**(承接现有 `DispatchTaskButton` 的内容,搬进这一栏内联展开): **第一步 · 写需求**:标题 + 描述 + 执行模式 + auto 模式的成本预警(原样保留)+ 一个「自动拆分任务」开关(默认开 = `split: true`)。主按钮「下一步」。 **第二步 · 确认拆分**:开关关掉就跳过这一步直接下达。开着则显示 `task-planning` 的产出——一份**可编辑**的任务清单(改描述、删行、加行、调依赖前置),带一行 `本次拆分消耗 ~N tokens`。主按钮「全部下达」,次按钮「存草稿」。 拆分进行中(goal `status='planning'`)显示骨架屏 + 「正在拆分…」,**并给一个「跳过,按单任务下达」的逃生口** —— 几十秒的 LLM 等待必须能被绕过,否则一次 agent 卡死就等于这个表单不能用了。拆分失败时这一步直接展示降级结果(1 条同描述的 task)+ 一句「自动拆分失败,已按单任务下达」,**不弹错误对话框**。 `canDispatch === false` 时**主按钮不再禁用** —— 排队之后它永远可点,按钮下方改为提示「当前 1 个任务在跑,新任务将排在第 2 位」。这是 B 类问题在界面上的最终消解。 ### 7.3 选中态与刷新 - 路由 `/:id/graph-console?goal=G-xxx&task=T-xxx`,刷新 / 分享可复原。无参数时默认选中最近一条 `running` 的 task,没有则最近创建的。 - 切 task 时 `orgNode / selectedNode / stack` 全清(不同任务的 def node 不可复用,串了会画出一张不存在的图 —— 与 `onReplanned` 的处理同理)。 - 选中 **draft / queued** task:org 栏画 org graph **定义**(现有 `hasOrgGraph && !rootRun` 分支已能画,全 pending),顶部一条「排队中,前面还有 1 个任务」或「草稿,尚未下达」+ 对应按钮。 - 刷新:选中 task 的 run 有 SSE(`GraphRunPane` 的 `@state`)→ 只更新那一行;整树 20s 轮询兜底。 > ponytail: 没有公司级 SSE 通道,所以未选中的行只能靠轮询。升级路径是 `graph-run-sse` 加一个 company topic,前端订一条就够。 --- ## 八、验收 每条都要在浏览器 `/e2e-probe/graph-console` 上真点出来,不是只跑单测。 1. 建一个 goal 不拆分 → 自动生成 1 条 task 并入队,左栏出现两级树,org 栏画出该 task 的 run。 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 被「服务启动」这个辅助触发捡起来,不需要人工干预。 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. **回填**:跑迁移脚本后,既存的每条 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. **拆分正常**:下达一个「做个官网」这类 goal → `task-planning` 产出 ≥2 条带依赖的 task,**全部是 `draft`**,界面上可改可删可加;点「全部下达」后按依赖顺序依次跑。 20. **拆分失败必须降级**:让 `task-planning` 连续 3 轮产出非法 JSON → goal 转 `active` + 1 条同描述 task + 界面标注「自动拆分失败」,**绝不能卡在 `planning`**。 21. **拆分记账**:拆分后 `goals.planning_tokens > 0`,且界面上看得到这一笔(验证 goal 层花费不再隐形)。 22. **不该花钱的路径不花钱**:`split: false` 与存量 `POST /:cid/tasks` 都**不触发任何 LLM 调用**(mock 层计数验)。 23. **上游失败 → 下游标红**:A 失败后依赖它的 B 转 `blocked` 且带 `upstream_failed:T-A`;**A 重跑成功后 B 自动回 `queued`**(验证 blocked 是每次 tick 推导出来的,不是落死的状态)。 24. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归 + 新本体的 `_registry.json` 登记(`template-refs.test.js`)。 --- ## 九、风险与已知取舍 ### 1. `queued` 混进 `run_status` 取值域 —— 本次最大的回归面 新增一个状态值,所有按 `run_status` 分支的地方都要逐个确认 `queued` 不被误当可执行。实现时必须挨个看过并补测试: **先纠正一个前提**:`queued` run **有一整套 `pending` 节点行**(`createRun` 建行时就按定义插全了),它和 `ready` run 在数据上唯一的差别就是 `run_status` 那一个字段。所以「靠没有节点行自然报错」这道想象中的防线**根本不存在** —— 唯一的闸门是各处对 `run_status` 的判断,必须逐个钉死。 | 位置 | 期望 | |---|---| | `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.1 的调度触发解除。所以调度器(尤其是挂在 `transitionRun` 上的那个漏斗钩子)是本次的**功能主体**,不是收尾工作;验收 2 / 9 / 13 / 14 专门验它。**初稿用「枚举四个触发点」的写法本身就漏了两条最常见的释放路径**(`stopRun` 与预算耗尽),这正说明枚举法在这里靠不住——症状是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。 ### 3. `description` 双写(DB + `task-goal.json`) 两份都在入队那一刻写、之后都不可变(§四.4)——结构上不可能不一致。这正是「已下达不给改」的真正理由,而不是保守。 ### 4. 归档只是隐藏 `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 / 闭包」的东西,在排队模型下都要问一句「几小时后它还在吗」。 ### 4ter. `task-planning` 引入的三个新风险 1. **多一次会失败的 LLM 调用挡在「下达任务」前面**。今天下达是一次同步 DB 写,现在中间插了一次几十秒、可能失败的 agent 调用。降级路径(§四.9 约束 2)和界面上的逃生口(§七.2)**不是可选项** —— 少了任何一个,一次 agent 卡死就等于整个下达入口不可用。 2. **拆分质量决定预算消耗**。拆出 5 条 task 就是 5 份 `DEFAULT_TASK_BUDGET`(各 100 次调用 / 500 万 token)。这是「产出一律 `draft`、必须人确认」的根本理由 —— 那一步是唯一一道成本闸门。界面上要显式提示「N 个任务 ≈ N × 单任务成本」。 3. **新本体不登记 `_registry.json` 会直接把 CI 跑红**。27-P3/P4 §八.21 有前科(六个本体建了目录没登记,`template-refs.test.js` 当场红)。建目录与登记必须在同一个 commit 里。 ### 5. 两套 `T-` id `agent-chat.js` 的 tasklog 也发 `T-` 开头的 id,本次不动它。两者从此**长得像但不同源**,读日志时别混;本文档的 task id 一律带 `-{4hex}` 后缀,肉眼可分。 ### 6. 并行是最后一批,且要单独验 批 4 会 `DROP` `idx_graph_runs_one_active_root` —— 删掉最后一道 DB 兜底。守卫改成事务内 count + admit(SQLite 单写者天然序列化)。不与界面改动同批上线。 --- ## 十、实施顺序 | 批 | 内容 | 验收 | |---|---|---| | **1 · 两级模型落库** | `goals` / `tasks` 建表 + **存量回填脚本(§四.8)** + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal、`report_to` 落库)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 / **12** | | **2 · 控制台左栏 + 拆分** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + **`task-planning` 本体(含 `_registry.json` 登记)+ 拆分链路 + 失败降级**+ 两步表单 | 1 / 19 / 20 / 21 / 22 | | **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler`(漏斗钩子 + 三个辅助触发 + 重入保护 + 上游失败推导 blocked)+ `depends_on` 环检测 + 入队/取消/rerun 端点 + §四.7 两条禁令 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 / 13-18 / 23 | | **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 | > 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。 批 1+2 上线就已经解决用户提的核心痛点(历史任务看不见、需求无法分解);批 3 才是「不再只能有一个」。 --- ## 十一、开放问题 1. ~~谁把 goal 拆成 task?~~ **已定案(2026-08-18):`task-planning` agent 进 v1**,产出 `draft` 任务清单由人确认后下达,详见 §四.9。 2. **goal 级的验收谁做?** 现在 `org-validator` 验的是 task 级(`task-goal`)。一个 goal 的 5 个 task 全 `completed`,不等于人的需求达成了。要不要在 goal 收敛时再跑一次 goal 级验收(`goal-benchmark` 天然就是干这个的)?倾向做,但需要先定「goal 级验收的输入是什么」—— 是 N 个 task 的产物合集吗?留到 P5。 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` 的口子?开的话要防「改出环」和「改完位次怎么算」。