# 27-P3C. 公司目标与任务 —— goal / task 两级模型 + 控制台任务列表 > **所属**: [27 Graph Agent 架构](../../todo/system-design/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)。 > **日期**: 2026-08-18 > **状态**: ✅ **已完成并归档**(2026-08-20)。五批全部实施(§十三 ~ §十六),§15.5 / §16.8 的后续项也已全部收掉(§十七)。 > 138 文件 / 1292 测试全绿,`web:build` 通过。**真跑验收见 §17.5** —— 返工闭环第一次由真 agent(不是注入的假 executor)走通。 > **⚠️ 批次与 phase 的对应不是一一对齐的**,判断「这份能不能搬进 done」时按批看,别按文档名看: > · **批 1-3 = 真正的 P3 收尾**(goal/task 两级模型 + `task-planning` + 排队),本文档的主体; > · **批 4(并行)归 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块); > · **批 5(两套任务体系统一)不属于任何既有 phase** —— 是跨阶段的技术债清理,风险面也与前四批完全不同(坏了是所有 agent 互相派不了活),见 §十二.6。 > **交付物**: ① `goals` / `tasks` 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的**排队与并行**;③ 执行控制台(`/:id/graph-console`)最左侧新增**目标·任务两级列表栏**。 > **命名**: 全文遵循 [27-NAMING](../../todo/system-design/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)。 > > **第四轮(2026-08-18,五条口径由用户定案 → 27-REQ R-20.6)**:① 退役 `/goals` 两个 OKR 别名**确认执行**;② **goal 级验收仍用 `org-validator`**,在 goal 层再调一次(新增 §四.10,goal 状态机加 `failed`);③ **预算先不考虑**,不做 goal 级闸门,但保留 `planning_tokens` / `validation_tokens` 两个记账字段;④ **`queued` 开放改 `depends_on`**(§六.3);⑤ **两套任务体系统一到 `tasks` 表,删掉会话反推那套**(新增 §十二 = 批 5)。 > 定案 ⑤ 核实后有个反直觉的前提必须先说清:**两套不是并列的,A 的回调链路长在 B 身上** —— `reportRunOutcome` 收敛后调的正是 `/api/agent/task/report`。所以「删掉另一套」= 删**存储与会话反推**,**不能**删入口与回调(`/task/dispatch` 的 URL 与请求体写在 `tools-base.md` 里,改它等于同时改所有 agent 的提示词)。开放问题 2 / 4 / 6 / 7 全部就此关闭。 --- ## 一、问题 用户的原话是「一个 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 §四](../../todo/system-design/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 | 人想要什么?做到了没有? | 人 + **goal 级 `org-validator`**(§四.10)| `draft → planning → active → done / failed / 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 | failed | cancelled | archived -- planning = task-planning 正在拆分(§四.9),是个真会持续几十秒的状态 -- failed = goal 级验收没过,或有 task 未能 completed(§四.10);可回 active 补 task 重下 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) validation_tokens INTEGER NOT NULL DEFAULT 0, -- goal 级 org-validator 烧掉的 token(§四.10) verdict TEXT, -- goal 级验收判词(org-validator 的原话),§四.10 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 收敛汇报的两条实现禁令(照现有代码顺手写就会踩) > ⚠️ **本节写于批 1 实施之前,`reportRunOutcome` 已在批 1 中删除**(见 §十三)。下面对它的描述保留为「当时的现状」,两条禁令的结论不变,只是落点从 `company.js:reportRunOutcome` 变成了 `task-settle.js:reportGoalOutcome`。 `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. **本体落点是 `system/agents/task-planning/`**(`system/templates/` 已在 2026-08-18 的 agent 目录重构里**彻底取消**,50 个本体全部 `git mv` 进 `system/agents/`,`base` → `_base`)。形态照 `workgraph-planning`:`agent.md` / `config.json` / `identity.md` / `soul.md` / `tools.md`。**不要用 `_` 开头命名** —— `_` 是保留前缀,扫描时会被跳过。 4. **`agent.md` + `soul.md` 必须一次写完,不能先建骨架后填**。本体现在有**毕业闸门**:`agent.md` 与 `soul.md` 非空、且不带 `_base` 的骨架哨兵,才会从 `system_agents.status='draft'` 毕业;否则 `hire()` 拒绝并返回 `body_not_ready`。「从 `_base` 复制骨架 → 以后再填」的中间态**雇不进公司**。(`.claude/skills/main_skill/SKILL.md` 是可选的,57 个本体里 54 个没有。) 三个权威判据(真到建本体时以它们为准,别照文档复述):`agent-locator.js` 的 **`missingBodyDefinition()`**(判什么算没填完)、`graph-service.js` 的 **`promoteDraftIfComplete()`**(转正动作)、以及 `hire()` 用的 **`isUngraduatedStatus()`**。 > ⚠️ **`isUngraduatedStatus()` 只挡 `draft` / `training` 两个状态,不是「≠ active 就挡」。** 这个坑已经被踩过一次:库里有 36 个本体的 status 是 `enabled` / `idle` / `ready` / `trained`,按「不等于 active」判会被全部误判成「没填完」。取值域的唯一持有者是 `graph-service`,**不要在别处复述它** —— 本文档 §六.3 已经因为 `canDispatch` 被前端复述过一次取值域而记过同类教训。 5. **`_registry.json` 登记是可选的,建目录才是关键动作** —— 这条与初稿写反了,留档纠正:`graph-seed-service.seedAgents()` 现在直接 `readdirSync(system/agents/)` 把**目录当真相**,`_registry.json`(已随重构挪到 `system/agents/_registry.json`,数组格式不变)只用于按 id 补 `benchmark_score`,registry 缺条目照样 seed。初稿写的「不登记会把 CI 跑红」引的是 27-P3/P4 §八.21 的前科,那条前科属于 `template-refs.test.js` 硬编码 `system/templates` 路径的时代,**该测试正随重构一起改**。仍建议顺手登记(`benchmark_score` 要用),但它不是闸门。 6. **本体正文里的路径有三条硬规矩,写错不报错、只静默降级**(agent 运行时 cwd 是 `system/agents/{aid}/{cid}/{taskId}/`,深度 3): - 引用本体自己的文件 → `../../`(如 `../../tools.md`); - 引用仓库根 → `../../../../../`; - **交付物路径绝不能写裸相对**(`workspace/x.md` 会落进任务态目录,之后被清理)。必须用提示词开头「运行时坐标」里「你的交付物必须写到」那一行给的**绝对路径**,本体正文里统一记作 `$W`。 照 `system/agents/orggraph-planning/agent.md`(重构后版本)抄写法。这三条错了全都**不报错**,只是产物落在没人看的地方 —— 与本文档反复点名的那类静默故障同源。 #### 跳过拆分 `POST /goals` 带 `split: false` 时不调 agent,直接建 1 条同描述的 task。存量调用方(`POST /:cid/tasks`)走的就是这条路 —— **它们不该因为这次改动突然多花一次 LLM 调用**。 ### 4.10 goal 级验收 —— 仍然是 `org-validator`(用户定案) **不新造验收 agent**:goal 级用的还是 `org-validator` 这个本体,只是**在 goal 层再调一次**。 #### 为什么是「再调一次」而不是复用 task 级那次的结论 因为两次验的是**基数不同的东西**,这正是 27-NAMING §四 那张表的论证: | | 验什么 | 谁问的 | |---|---|---| | task 级(既有,org graph 末端节点)| 这**一个 task** 的 `task-goal` 达成了没有 | 引擎,每个 task 各一次 | | **goal 级(本次新增)** | **N 个 task 合起来,人提的那个需求达成了没有** | goal 收敛时,一次 | 5 个 task 全 `completed`,只说明每一件事都按各自的描述做完了 —— 不说明拼起来是人要的东西。这个缺口正是 goal 层存在的理由,用别的东西替代不了。 #### 触发与判定 ``` goal 下所有 task 到终态 ├─ 有任何一条 ≠ completed → goal 直接 failed,不调 agent(省一次调用:显然没交付) ├─ 全 completed 且只有 1 条 task → 跳过 goal 级验收,直接采用该 task 的验收结论 │ (单 task 的 goal,两级验收是同一件事,再调一次纯浪费) └─ 全 completed 且 ≥2 条 task → 调一次 org-validator 输入:goal.description(= 验收边界)+ 该 goal 全部 task 的产物索引 (`graph_files` 按 task_id 查,§P4 §4.6 的表已经能给出这份清单) 产出:pass / fail + 判词 → goals.status = done / failed,判词落 goals.verdict ``` 调用形态与 `task-planning` 相同:`AgentManager` 直调本体,不在任何 graph run 里,token 记 `goals.validation_tokens`。 #### 挂在哪 和调度器同一个位置:task 转终态时顺手判一次「这个 goal 的 task 是不是都到齐了」。**`settleGoal(goalId)` 必须幂等**(并行完成的多个 task 会同时触发它),用 `goals.status` 的条件更新做闸门 —— 与 §五.1 出队用的是同一招。 > goal 收敛之后才轮到 §四.7 的两件事:写 OKR 节点(一次,用 goal 的判定结果)、唤醒 `report_to`。**这两件事从 run 级上移到 goal 级**,禁令一说的「task 的 run 一律不带 `okr_node_id`」在这里闭环。 --- ## 五、排队与并行 ### 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 并发上限与唯一索引 **定案(2026-08-18):配置放 `company.json`,不建 `company_settings` 表。** `companyService.get` 已经在读这个文件,零新增读路径;建表要多一张表、一套读写、一次迁移,而目前公司级配置项只有这一个。公司级配置项涨到 3 个以上时再考虑建表(届时连 `company.json` 里已有的那些一起收)。 `company.json` 加一个字段: ```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 兜底,这个动作不该和「界面上能看到任务列表」混在一次上线里。 > ⚠️ **批 4 与 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3 是同一件事,不要各做一遍。** 那份文档的目标 3 逐字写着「**解除「一家公司同时只有一个 active root run」约束** —— P3 v1 用一个唯一索引强制它(§三.3),并行能力具备后应放开」。也就是说:**放开约束的设计归 27-P4,本文档只负责在它之上定义「谁来排队、按什么序出队」**(§五.1)。真到实施时以 27-P4 的并行方案为准,本文档的 §五.2 服从它;两边都写一版会产生两套并发守卫。 --- ## 六、API 全部挂在 `server/routes/company.js`。错误响应沿用现有 `{ error, hint, ... }` 形态。 > **⚠️ 批 1 必须修两处路径缺陷 —— 一处就在本文档的核心链路上。** > > **缺陷 A(会分叉,在核心链路上)**:`task-goal.json` 的写入方与读取方用的不是同一套路径规则。 > > | | 位置 | 认不认 `GRAPH_RUNS_DIR` | > |---|---|---| > | **写** | `company.js` 的 `writeTaskGoalArtifact` → `path.resolve(__dirname,'../..','data','companies',cid,'runs',rid,'artifacts')` | ❌ **不认** | > | **读** | `graph-engine.js` 的 `runRootDir`(`describeTask` 与 `goal-contract.deriveRunDir` 都走它)| ✅ 认 | > > `GRAPH_RUNS_DIR` 一旦被设上(目前只有测试在用),写进 A 处、从 B 处读 —— `describeTask` 拿到 null,**静默退回兜底文案「执行图节点任务」**,agent 从此拿不到真实任务描述,而**没有任何报错**。`goal-contract.test.js` 的注释佐证读取方那套形状才是约定:`{GRAPH_RUNS_DIR}/{rootRunId}/artifacts/task-goal.json`。 > > 生产环境两边恰好都退到同一个默认分支,所以今天不发作 —— 但**批 1 会把 artifact 的落盘点从「下达时」前移到「入队时」**(§四.3 / §四.4 的描述冻结点),正好要动这个函数。**修法是一行级:写侧改调读侧的 `runRootDir`。** > > > ⚠️ **本文档初稿在这里归因错了,留档纠正**:初稿写成「`runRootDir` / `companyRootDir` / 写入方三种拼法争权威,实施前得先定哪个权威」。**那是错的,而且会让实施者去做一个根本不存在的设计决策。** `runRootDir` 与 `companyRootDir` 是**两个不同的事实**,不是同一事实的两种拼法: > > - `runRootDir` → **run 目录**,覆盖态是 `{RUNS_ROOT}/{rootRunId}`。扁平即可,因为 `rootRunId` 全局唯一; > > - `companyRootDir` → **公司根**,覆盖态是 `{RUNS_ROOT}/_companies/{cid}`。多出来的 `_companies/` **是必须的** —— 公司 id 不是全局唯一坐标,扁平放会和 run 目录撞名。 > > > > `company-paths.js` 的注释本身就写着「理由与 `runRootDir` 那处 override 一致」,作者是有意区分的。**真正错的只有一处:写入方自己拼路径。** > > **缺陷 B(不会分叉,因为它根本没有调用方 —— 但更会骗人)**:`goal-contract.js:75` 声明了 > > ```js > /** run 目录根的覆盖,与 graph-engine.RUNS_ROOT / graph-run-sse 是同一个变量(测试隔离在用) */ > const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null; > ``` > > **然后一次都没用过**(全文件只有注释 + 声明两行),同文件的 `resolveRunDir()` 照旧自己拼 `DATA_DIR/companies/…`。而且 `resolveRunDir` **全仓库零调用方**(只有定义、`module.exports` 和 `27-goal-contract-format.md` 里的一行文档)—— 又一个「实现在、零调用方」。 > > 所以它今天不会出错,危害是**注释在撒谎**:白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量、测试隔离在用」,读代码的人会据此认为这个文件认 `GRAPH_RUNS_DIR`。**一个声明了意图、代码却不兑现的常量,比没有这个常量更容易骗人** —— 第一个把 `resolveRunDir` 接上去的人会直接踩。 > > 批 1 一并处理:要么让它真的用 `RUNS_ROOT`,要么连函数带常量一起删(零调用方,删是更省的一侧)。 > > **`system/agents` 那一族已经不用管了** —— 2026-08-18 的 `e120645` 已把全仓库 13 处(跨 5 个文件,含 `company.js` 里 6 个 `TEMPLATES_DIR` + 3 个同义异名的 `DATA_DIR`)收敛到 `locator.AGENTS_ROOT`,并新增 `server/tests/unit/agents-root-single-holder.test.js` 锁住不变式。**批 1 新增的代码必须遵守它**:不许自己拼「以 `../` 开头、以 `system/agents` 结尾」的字面量,否则这条测试会红。 > > 这些全是**同一个病**的不同实例,本文档已经攒到第五个:`canDispatch` 的取值域被前端复述(§六.3)、`ACTIVE_ROOT_RUN_STATUS` 在两文件逐字重复(§九.1)、`isUngraduatedStatus()` 的取值域(§四.9)、`system/agents` 的 13 份路径、以及上面这三种 run 目录拼法。**同一个事实只能有一处持有者 —— 路径也是事实。** > > 方法论也记一条:我第一次只 grep 了 `TEMPLATES_DIR` 这个**名字**,因此漏掉了同一文件里语义相同但叫 `DATA_DIR` 的 3 处。**同义异名的副本按名字 grep 永远找不到,要按值 / 按路径字面量 grep。** > > (一条**刻意不做**的:`company-service.js` 的 `const TEMPLATES_DIR = locator.AGENTS_ROOT` 取值已正确、只是名字过期,读的人会以为它指 `system/templates`。改名要动 50 处、跨 6 个文件、零功能收益 —— 不值得。留着这条记录,是为了说明它**是已知的,不是没人看见**。) ### 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` 都可改**(用户定案,见下);`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 节点详情里的「重试这一步」)三处同时区分。 **`queued` 状态开放改依赖**(用户定案)。真实场景是「B 已经排上了,才发现它得等 C」,此前只能取消重建、丢掉排队位次。三条细则: - 环检测照跑(只能引用同 goal 内的 task),有环 `400`,库里不留半条脏数据; - **位次不受影响**:`queuePosition` 只按 `priority → seq → created_at` 排,`depends_on` 决定的是「够不够格出队」,两者正交 —— 当初担心的「改完位次怎么算」不成立; - 新加的依赖若已经 `failed` / `cancelled`,下一次 `tick` 会自然把它推导成 `blocked`(§五.1.2),不需要额外处理。 **`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 让历史任务更看不见了。 12b. **路径缺陷 A 已修**:在 `GRAPH_RUNS_DIR` 指向临时目录的条件下走一遍「下达 → 入队 → 读回描述」,`describeTask` 拿到的是**真实任务描述**而不是兜底文案「执行图节点任务」。 > ⚠️ 这条验收**必须先设 env 再 `require`**:`graph-engine.js:84` / `goal-contract.js:75` / `graph-run-sse.js:34` 三处都是**模块加载时快照** `GRAPH_RUNS_DIR`,而 `company-paths.js:14` 的文件头注释恰恰记着相反的教训(「每次调用时读,不在模块加载时快照 —— 镜像时代两个模块各自快照,加载顺序不同就会算出不同的根,测试先 require 再设 env 时尤其明显」)。既有测试已经在为此做变通(`graph-engine.test.js` 注释:「`graph-run-sse` 在模块顶部快照 `GRAPH_RUNS_DIR`,必须连它一起重新 require」)。**教训在一个模块里学会了,没传出去** —— 这是本清单的第六个实例,形态还不一样:不是事实被复述,是**「事实该怎么读取」的约定只传了一半**。 12c. **路径缺陷 B 已处理**:`goal-contract.js` 里那个声明了却从未使用的 `RUNS_ROOT` 要么真的接上、要么连 `resolveRunDir` 一起删;不留「注释承诺了、代码不兑现」的常量。 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 登记**(§四.9 约束 4/5 已纠正初稿的反向结论)。`agent.md` + `soul.md` 没写满就停在 `draft`,`hire()` 返回 `body_not_ready`;而 `_registry.json` 缺条目只是少个 `benchmark_score`。另外本体正文的相对路径写错**不报错只静默降级**(§四.9 约束 6)—— 这是本文档点名的静默故障家族里最新的一个成员。 ### 4quater. 本文档有三处要求属于同一个惯犯模式:「实现在、零调用方」 §四.1 砍掉 `goals.budget_json`、§四.7 禁令二要求 `report_to` 落库、验收 12c 处理 `goal-contract.js` 那个声明了却零使用的 `RUNS_ROOT` —— 三条都是同一件事:**别留下「代码写了、但没有任何活的调用方/消费方」的东西。** 这不是洁癖,仓库里有完整的证据链,而且证据本身就说明问题: | 出处 | 原话 | |---|---| | `graph-engine.js`(`indexNodeOutputs` 注释)| 「凡『靠 agent 自觉』的环节几乎全部落空过(**goal-contract 零调用**、`setPlanWorkGraph` 零调用、三值透传只接了读那一半)」| | `graph-engine.js`(跨层判据处)| 「(『实现在、零调用方』的**第五次**)」| | `graph-run-route-io.test.js` 文件头 | 「就是这一路踩了**十几次**的『实现在、零调用方』换个方向再来一次(这次是前端在、后端没喂)」| | `graph-edge-contracts.test.js` 文件头 | 「`declareEdgeContract` **零调用方**」| | `graph-run.js:243` | 「契约声明与产物索引都在库里,界面上却看不见,等于**又一次**『实现在、零调用方』」| | `27-goal-contract-format.md:101` | 「T0 交付后有四轮时间里,全仓库唯一提到 `goal-contract.js` 的是一句注释 —— 实现完整、零调用方、零单测,§六 验收 1 **从未被执行过**」| | 27-P3/P4 §八 已关闭项 6 | `ProcessGuard` 生产代码里从未被实例化、`RunRegistry.add()` 从未被调用、杀进程那条链同样零调用方 | **这些记录证明的不是「有这个问题」,而是「发现了也没人回来收」** —— 有人数到第五次,有人写「十几次」,然后它继续复发。所以本文档凡是写「必须落库 / 必须接上调用方 / 零调用方就删掉」的地方,都请当成硬要求,而不是可以留到下一轮的整洁度问题。 **批 1 的落点**:验收 12c 就是收其中一个(`resolveRunDir` + 那个死常量)。同时按并行会话的建议加一条不变式测试 —— 「凡是声明了 `GRAPH_RUNS_DIR` 覆盖的模块,该覆盖必须真的被用到」,形状照抄 `server/tests/unit/agents-root-single-holder.test.js`(**「样本非空」前置断言别省** —— 扫不到文件时会静默全绿,那正是这类测试自己变成「零调用方」的方式)。 ### 5. 两套 `T-` id —— 由 §十二 的统一消解,但在那之前是真风险 `agent-chat.js` 的 `/task/dispatch` 也发 `T-` 开头的 id 并写 `system/tasklog/`。**批 1-4 期间两套并存**,长得像但不同源,读日志时别混(本文档的 id 一律带 `-{4hex}` 后缀,肉眼可分)。批 5(§十二)把存储统一到 `tasks` 表之后,这个风险随之消失 —— 那时 `T-` 只有一个来源。 ### 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。**与 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3 同一件事**,实施以那份为准 | 另立验收 | | **5 · 任务体系统一**(单独排期)| 见 §十二:`executor` / `assignee_agent_id` / `parent_task_id` 三字段 + `/task/dispatch` 换存储 + 删会话反推 + `tasklog` 回填 | 25-30 | > 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。 ### 与并行进行中的「agent 目录结构重构」的关系(2026-08-18 核实) 写本文档期间,另一条工作线正在做 **agent 目录结构重构**(`system/templates/` 并入 `system/agents/`,两份 `_registry.json` 合一),工作区约 900 个变更**尚未提交**。它与本文档有实质交集,逐条核实结果: | 交集点 | 结论 | |---|---| | 本文档逐字引用的全部函数 / 常量(`settleRun` / `markRunReady` / `stopRun` / `defaultPlanWorkGraph` / `PLAN_MAX_ROUNDS` / `writeTaskGoalArtifact` / `reportRunOutcome` / `ACTIVE_ROOT_RUN_STATUS` / `saveTaskMeta` / `getAggregatedTasks`)| **全部存活**。重构改了约 650 行、5 个文件,但没有动这些锚点 —— 27-P3/P4 那条「只写函数名不写行号」的体例在这里直接兑现了价值 | | 本文档的五条**行为性**结论(`markRunReady` 写死 `planning`、`settleRun` 只在 `advanceOne` 内被调、`stopRun` 不走 `settleRun`、`reportRunOutcome` 读 `run.okr_node_id` 与闭包里的 `ctx.reportTo`)| **逐条复验仍然成立** | | `work-graph-db.js`(`createRun` 建节点行、`updateRunStatus` 条件更新、`RUN_TRANSITIONS`、唯一索引)| **完全未被改动** —— 第二轮复核里最关键的那批发现不受影响 | | 新建本体的做法(§四.9)| **初稿有一条写反了,已纠正**:`system/templates/` 彻底取消(本体落 `system/agents/`);`_registry.json` 已不是数据源头(`seedAgents` 扫目录当真相),登记是可选的;真正的闸门是**毕业**(`agent.md` + `soul.md` 非空且无 `_base` 哨兵,否则 `hire()` 返回 `body_not_ready`);本体正文的相对路径另有三条硬规矩,写错静默降级。详见 §四.9 约束 3-6 | | `agent-chat.js`(+455 行)、`company.js`、`agent-manager.js` | 都是本文档批 1 / 批 5 要改的文件。**新端点是新增代码,不冲突**;但 §十二 的前提「`/task/dispatch` 的 URL 与请求体一个字都不能动」需要在批 5 开工前**基于重构后的版本重新核一遍**(`tools-base.md` 目前未被改动,是个好迹象) | **对排期的影响**:批 1 可照常开工(`work-graph-db.js` 无人触碰,建表 + 回填是纯新增)。**批 2 的 `task-planning` 本体应当等 agent 重构落地后再建** —— 否则本体目录与登记方式要按旧形态做一遍、再按新形态改一遍。批 5 在那之后重新核 `agent-chat.js`。 批 1+2 上线就已经解决用户提的核心痛点(历史任务看不见、需求无法分解);批 3 才是「不再只能有一个」。 --- ## 十一、开放问题 1. ~~谁把 goal 拆成 task?~~ **已定案(2026-08-18):`task-planning` agent 进 v1**,产出 `draft` 任务清单由人确认后下达,详见 §四.9。 2. ~~goal 级的验收谁做?~~ **已定案(2026-08-18):仍然是 `org-validator`**,在 goal 层再调一次同一个本体,输入是该 goal 全部 task 的产物合集。详见 §四.10。 3. ~~失败的 task 挡不挡住依赖它的下游?~~ **已定案(2026-08-18):自动转 `blocked` 并在界面标红**,给「重跑上游」与「强制放行」两个出路;`blocked` 每次 `tick` 重算而不落死,上游恢复后下游自己回 `queued`。详见 §五.1.2。 4. ~~跨公司任务总览 / 两套任务树并存~~ **已定案(2026-08-18):统一到 `tasks` 表一套,删掉会话反推那套。** 详见 §十二(批 5)。 5. ~~`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?~~ **已定案(2026-08-18):放 `company.json`。** 详见 §五.2。 > **本文档的开放问题至此全部关闭。** 后续新增的疑问按惯例继续往这一节追加,不要在正文里留疑问句。 6. ~~goal 级总预算怎么定口径?~~ **已定案(2026-08-18):预算先不考虑,不做 goal 级闸门。** `goals.budget_json` 保持砍掉。**但 `planning_tokens` / `validation_tokens` 两个记账字段保留** —— 它们不是闸门,是让 goal 层那两次 agent 调用的花费**可见**;不记就是 `defaultPlanWorkGraph` 注释点名的「隐形的一半」,而且界面上「本次拆分消耗 ~N tokens」这个确认步骤要用它。 7. ~~排队中的任务能不能改依赖?~~ **已定案(2026-08-18):可以改。** `queued` 状态开放 `depends_on` 的 `PATCH`,见 §六.3。当初担心的「改完位次怎么算」不成立:`queuePosition` 只按 `priority → seq → created_at` 排,`depends_on` 决定的是**够不够格出队**,两者正交,改依赖不动位次。 --- ## 十二、批 5 · 任务体系统一(用户定案:统一到一套,删掉另一套) ### 12.1 现状:两套并存,而且**新体系已经依赖旧体系的一半** | | A · 本文档的体系 | B · 会话反推的体系 | |---|---|---| | 存储 | `goals` / `tasks` 表(SQLite) | `system/tasklog/{taskId}.json` 一任务一文件 | | 入口 | `POST /api/company/:cid/goals`、`/tasks` | `POST /api/agent/task/dispatch`(**agent 之间派单**,`tools-base.md` 里写着让 agent curl 它)| | 收敛 | `settleGoal` → OKR + `report_to` | `POST /api/agent/task/report`(更新 todo/plan → 更新 OKR → 推进 Pipeline → 唤醒上级)| | 任务树 | `goal_id` / `depends_on` 显式存 | `TaskService.getAggregatedTasks()` **扫全部 agent 的 conversations 反推**父子关系(391 行)| | 读接口 | `GET /:cid/goals`、`/tasks` | `GET /api/tasks`、`/api/tasks/:id/timeline`(59 行)| | 前端 | `GoalTaskPane`(§七)| `TaskTimeline.vue`(736 行,唯一消费方)| | id | `T-{base36}-{4hex}` | `T-{base36}`(同前缀不同源,§九.5)| **关键事实:A 的收敛出口调的正是 B 的 `/api/agent/task/report`。** 批 1 之前这个出口是 `company.js:reportRunOutcome`(run 级),批 1 把它删掉、换成了 `task-settle.js:reportGoalOutcome`(goal 级,挂在 `transitionRun` 漏斗上)——**函数换了,依赖关系没变**。两套不是并列关系,A 的回调链路仍然长在 B 身上。所以「删掉另一套」不能理解成整片删除 —— 要删的是**存储与反推**,不是入口与回调。 ### 12.2 删什么、留什么 **删**: - `system/tasklog/` 目录及其读写(`agent-chat.js` 的 `saveTaskMeta` / `loadTaskMeta`,`task-service.js` 的 `_loadTaskMetas`); - `TaskService.getAggregatedTasks()` —— **从会话记录反推任务树**这整套启发式。父子关系改为显式存字段,不再猜。 **留(改数据源)**: - `POST /api/agent/task/dispatch` **URL 与请求体形态一个字都不能动** —— 它写在 `packages/agent-core/base/common/tools-base.md` 里,是所有 agent 的工具说明;改了等于同时改所有 agent 的提示词。只把它的存储从写 JSON 文件改成写 `tasks` 表。 - `POST /api/agent/task/report` 同上(A 的回调依赖它)。 - `TaskService.getTaskTimeline()` —— 它读的是 agent 的 `conversations/`,那是**真实的一手数据**,不是反推。留着,只把「有哪些 task、父子是谁」换成查表。 - `TaskTimeline.vue` 保留,换数据源。 ### 12.3 `tasks` 表要吸收 B,需要三个字段 B 的 task 与 A 的 task 有一处真实差异:**执行载体不同**。A 的 task 跑一张 org graph(走调度器),B 的 task 是直接派给某个 agent 起一次对话(`autoExecute`)。硬说成一回事会骗自己,所以显式建模: ```sql executor TEXT NOT NULL DEFAULT 'graph', -- 'graph'(跑 org graph,走 §五 调度器) -- 'agent'(直接派给某个 agent 对话,不进队列) assignee_agent_id TEXT, -- executor='agent' 时派给谁(B 的 `to`) parent_task_id TEXT REFERENCES tasks(id), -- B 的任务链;A 的 depends_on 是同级依赖,两者不同 ``` **调度器只管 `executor='graph'`**(§五.1 的候选筛选加这一条),`executor='agent'` 的 task 由 `/task/dispatch` 自己起对话,进表只为了「可见、可查、可串成树」。 `goal_id NOT NULL` 怎么办:**agent 派单时自动建一个 goal**(`title = '来自 {from} 的派单'`、`created_by = from`),与 §六.3 已经为 `POST /:cid/tasks` 设计的兼容路径**同一条**。模型保持干净,不给 `goal_id` 开可空的口子。 ### 12.4 顺带修好的两件事 1. **30 分钟去重从「扫全目录 JSON」变成一条 SQL**。现在 `/task/dispatch` 每次派单都 `readdirSync` + 逐个 `JSON.parse` 整个 `tasklog/` 来找相似任务 —— 任务一多就是 O(n) 次磁盘读,且相似度判断本身也没索引可用。改成 `WHERE company_id=? AND assignee_agent_id=? AND status='queued' AND created_at > ?` 一条查询。 2. **两套 `T-` id 合一**(§九.5 那条风险就此消失)。id 生成统一走 §四.5 的 `T-{base36}-{4hex}`。 ### 12.5 迁移 `system/tasklog/*.json` 回填进 `tasks` 表,与 §四.8 的 root run 回填**放在同一个脚本**(同一次数据形态变更,分两个脚本只会让「跑了哪个没跑哪个」变成新问题)。字段映射:`to → assignee_agent_id`、`parentTaskId → parent_task_id`、`objectiveId/goalId → 所属 goal 的 okr_node_id`、`status` 按 `dispatched/done/failed/blocked` 映射。**回填后目录改名为 `system/tasklog.bak-unify/` 而不是直接删**,跑顺一个版本再清。 ### 12.6 排在批 5 的理由 它依赖 `tasks` 表存在(批 1)、依赖 `executor` 语义在调度器里被正确排除(批 3)。而且它**改的是 agent 的生产链路**(派单 / 汇报 / 唤醒),风险面与前四批完全不同 —— 前四批坏了是控制台不好用,这一批坏了是**所有 agent 互相派不了活**。必须单独一批、单独验收。 ### 12.7 验收 25. `POST /api/agent/task/dispatch` 请求体不变,派单成功后 `tasks` 表出现 `executor='agent'` 的行,`system/tasklog/` 不再产生新文件。 26. 派单的 task **不进调度队列**(`executor='agent'` 被 §五.1 排除),不占 `max_parallel_tasks` 的坑。 27. 30 分钟去重仍然拦得住重复派单,且不再读目录(改成 SQL)。 28. `POST /api/agent/task/report` 汇报链路四步(todo/plan → OKR → Pipeline → 唤醒上级)全部照常;**`task-settle.reportGoalOutcome` 那条依赖它的路也照常**(批 1 之前这条依赖挂在已删除的 `reportRunOutcome` 上)。 29. `TaskTimeline.vue` 任务列表与时间线都正常,父子关系来自 `parent_task_id` 而非会话反推。 30. 存量 `system/tasklog/*.json` 全部回填进表,目录改名保留。 --- ## 十三、批 1 实施记录(2026-08-18,commit `7f24ef0`) > 体例照 [27-P3/P4](./27-p3-p4-work-graph-console.md) §十:**设计正文不改写,只在这里与实现对账**。 > 凡本节与前文冲突,**以本节为准** —— 前文是设计时的判断,本节是跑出来的事实。 ### 13.1 落地了什么 | 交付 | 落点 | |---|---| | `goals` / `tasks` 两张表 + CRUD + `depends_on` 环检测 | `work-graph-db.js` | | ID 生成器 `G-/T-{base36}-{4hex}` + 主键碰撞重试 | 同上(`insertWithFreshId`:4hex 只有 65536 种取值,实测同毫秒建 2000 条会撞 5 次) | | goal / task 全套端点 + `POST /:cid/tasks` 向后兼容 | `company.js` | | 退役 `/api/goals` 与 `/:cid/goals` 两个 OKR 别名 | `index.js` / `agent-server.js` / `company.js` | | **终态回写 + goal 收敛 + OKR 写一次** | `graph-run-sse.transitionRun` 钩子 → `task-settle.js`(新) | | 三处「模块加载时快照 `GRAPH_RUNS_DIR`」改调用时读 | `graph-engine.js` / `graph-run-sse.js` / `goal-contract.js` | | 存量回填脚本(幂等 + `--dry-run`) | `scripts/migrate-runs-to-goal-task.js` | | 4 个测试文件,含不变式测试 | `server/tests/unit/` | 全量:**116 文件 / 1057 测试全绿**。 ### 13.2 与设计正文的四处偏离(**以本节为准**) **① `blocked` 在 task / goal 两层都不是终态。** 正文 §四.3 的状态机把 `blocked` 与 `completed`/`failed` 并列画成终态。实现时发现那是错的,而且**实跑复现出了后果**: ``` 预算耗尽 → task=blocked, goal=failed, OKR=failed 人加预算恢复 → run=running 跑完 → run=completed, task=blocked, goal=failed, OKR=failed ← 永久对不上 ``` `RUN_TRANSITIONS` 里 `blocked: ['running','stopped']` —— 对 run 它是**可恢复态**,`reviveParentFromChild` 还会自动把它拉回 running。若 task 层把它当终态,恢复后跑完时 `settleTaskFromRun` 与 `settleGoalStatus` 两次条件更新**静默命中 0 行**,无日志无事件。 定案:`TASK_TERMINAL_STATUS` 只含 `completed / failed / cancelled`;**只要有一条 task 处于 `blocked`,goal 就不收敛**(不写 OKR、不唤醒上级)。`blocked` 的语义是「挂起等人介入」,与那三个不同类。run 转 blocked 时**仍然**把 task 写成 blocked(界面要看得见卡住),只是不触发收敛。 连带:归档守卫从「过严」翻到「过松」过一次 —— 恢复后 task 停在 `blocked`、run 却在 running,用户能归档掉一条**正在烧预算**的 run。现由 `taskHoldsRun()` 兜住(`ACTIVE_ROOT_RUN_STATUS` **派生**出 `RUN_HOLDING_STATUS`,不复述第二份字面量)。 **② goal 级是唯一汇报出口,`reportRunOutcome` 已删除。** 正文 §四.7 把它写成「root run 收敛后的唯一出口」。批 1 的唯一形态是 1 goal : 1 task,而 `createTask` 继承 `goal.report_to`,于是 run 级与 goal 级两条汇报会**就同一件事把上级 agent 唤醒两次**(其中一次带的是它从没见过的 `G-` id)。 grep 确认 `reportRunOutcome` 无其它调用方(连它自己那行 `module.exports` 都零人 require),整体删除。理由:run 级只在 auto 模式触发(manual 收敛不走 driver),本来就是半条链路;goal 级挂在唯一漏斗上,manual / auto / stopped / blocked 全覆盖。 同时 `from` 的口径修正为**实际交付者**:`terminalOrgAgent` 从 `company.js` 搬到 `org-graph-service.terminalAgentId()`(唯一持有者),不再用 `goal.created_by` —— 那是「谁下的单」,默认字面量 `'human'`,下游 `readStateFile('human','todo.md')` 会静默落空。 **③ dispatch 挑第一条「可跑的」draft,不是恒取 seq 最小那条。** `validateDependsOn` 只拒环、不拒正向引用,所以「第 1 条依赖第 2 条」是合法输入;而恒取 `drafts[0]` 会让这种 goal **永远下达不了**(`blockers` 恒非空)。批 2 的 `task-planning` 产出契约正是「`depends_on` 只引用本次产出的 seq」,产出乱序完全在射程内。 连带:`PATCH /:cid/tasks/:taskId` 补上了改 `seq` 的入参翻译 —— 原先 409 的 hint 写着「改依赖或 seq」,而那个端点从不读 `body.seq`,**两条自救路径只有一条通**。 **④ `POST /:cid/goals` 批 1 只做同步路径。** `task-planning`(§四.9)与 goal 级 `org-validator` 验收(§四.10)属批 2,`verdict` / `validation_tokens` 两列目前只有定义、无写侧。 ### 13.3 过程:三轮编排,19 条缺陷 | 轮次 | agent | 产出 | |---|---|---| | 1 · 实现 | 11 | 建表 + 端点 + 回填 + 测试;对抗复核报 **10 条缺陷** | | 2 · 修复 | 6 | 修完 10 条;复核确认真修好,但报出 **4 条修复自己引入的新缺陷** | | 3 · 再修 | 4 | 修完 4 条;全绿 | **最值得记的一条**是②那类:*第二轮的修复引入了第三轮才发现的缺陷*,而且是同一个形状——「界面撒谎」的镜像版本。这说明**对抗复核不能只做一轮**:修复本身和实现一样会出错,而且更隐蔽(它长得像在解决问题)。 判定为**非问题**的 5 条也记一笔(复核给了证据):进度查询确实是一条 `GROUP BY` 不 N+1;`LIMIT` 的字符串插值前置了 `Number.isInteger` 守死;`tasks.goal_id` 的真外键在 `foreign_keys=ON` 下不与软删冲突;回填脚本的两条幂等判据覆盖了「脚本重跑」与「新链路建出来的 run」;`/goals` 别名确为零调用方(`OkrDashboard.vue` / `CompanyDashboard.vue` / `bin/lemon.js` 全走 `/okr`)。 ### 13.4 一条对外可见的破坏性变更 `POST /api/company/:cid/tasks` 的 `goal_id`:旧代码原样存进 `graph_runs.goal_id` **不校验**,新代码要求它是 `goals` 表里本公司的真行,否则 **404**。仓库内无此调用方,但外部 agent 的 curl 若一直往这里传 OKR 节点 id 之类的值,会从 201 变 404。**404 是对的**(那种调用方本来就在制造悬空外键),但属于对外可见的行为收紧。 ### 13.5 留给下一轮的两条 1. **run 转 `blocked` 时仍写 `finished_at`** —— 「挂起 ≠ 结束」,界面会显示一个随后被恢复覆盖掉的结束时间。改它要动 `settleTaskFromRun` 的签名。 2. **run 从 `blocked` 恢复成 `running` 时不把 task 写回 `running`** —— task 行会在恢复期停在 `blocked`。归档守卫已经兜住了它的风险(§13.2 ①),要消掉需要在 `afterRunTransition` 上加「离开终态时反向回写」,与批 3 的调度器一起做更合适。 --- ## 十四、批 2 实施记录(2026-08-18) > 体例同 §十三:**设计正文不改写,只在这里对账;冲突处以本节为准。** ### 14.1 落地了什么 | 交付 | 落点 | |---|---| | `task-planning` 本体(毕业判据实测通过) | `system/agents/task-planning/` + `_registry.json` | | 拆分链路(3 轮 errors 回喂 / 降级 / token 记账 / 注入缝 `setPlanTasks`) | `server/services/task-planning.js`(新) | | goal 级 `org-validator` 验收(注入缝 `setValidateGoal`) | `server/services/goal-validation.js`(新)+ `task-settle.js` | | 目标·任务两级栏 + 四栏布局 + 两步表单 | `GoalTaskPane.vue` / `NewGoalForm.vue` / `goal-task-model.js` / `GraphConsole.vue` | | 中断恢复:启动时补跑验收与汇报 | `task-settle.resumePendingGoalValidations` + `server/index.js` | 全量:**118 文件 / 1112 测试全绿**,`npm run web:build` 通过。 ### 14.2 与设计正文的六处偏离(**以本节为准**) **① 拆分完成/降级后 goal 落 `draft`,不是 `active`。** §四.9 约束 2 与 §八 验收 20 都写「降级后 goal 转 `active`」。实现落 `draft`,理由成立且已复核确认:`active` 的语义已被 `markGoalDispatched` 钉死为「**已下达**」(连带盖 `dispatched_at` = 描述冻结点),而 §四.9/§六.2 同时要求「产出一律 draft、不自动入队」。两者只能取一个 —— 拆分失败却标 `active` 且一条 task 都没起,界面上就是一个「进行中」却什么都没跑的 goal,正是本文档反复点名的「界面撒谎」。硬要求「绝不能卡在 planning」两种落点都满足。 **② `POST /:cid/goals` 默认异步(202 + `status:'planning'`)**,`split:false` 或显式 `tasks[]` 走同步 201。存量 `POST /:cid/tasks` 走同步路径,**不会因为这次改动多花一次 LLM 调用**。 **③ `mode` 是三级继承**:`tasks[i].mode` > goal 级 `mode` > `'manual'`,**goal 级对显式 `tasks[]` 同样生效**。这是对存量调用方的行为变化(原先显式 `tasks[]` 恒 manual),已写进端点 JSDoc + 用例钉住 —— 从「静默变化」变成「明写约定」。 **④ 占位判词不出 API。** 验收窗口里 `goals.verdict` 挂的是内部认领 token(含进程随机段与时间戳),`goalView` 对外只回 `(验收进行中)`。判定走 `task-settle.isPendingVerdict`,不在路由层比字符串。 **⑤ 认领带租约。** 见 §14.3 第 3 条。 **⑥ 新增两列**:`goals.planning_error`(降级原因,界面展示)、`goals.reported_at`(汇报可查性)。`reported_at` 的迁移**带回填** —— 不回填的话加完列第一次启动会把库里所有历史终态 goal 当成「汇报丢了」重报一遍。 ### 14.3 三条跨进程竞态(全部用真探针实跑复现后才判定修好) **1 · `runGoalSplit` 无条件把 goal 写回 `draft`,抹掉并发发生的「已下达」。** 触发它的正是表单自己推荐的逃生口:用户点「跳过,按单任务下达」→ goal 变 `active` 且 run 在跑 → 拆分收工把它写回「草稿」,并多插 N 条 draft task → `settleGoalIfAllTasksDone` 看到 `[T1 completed, T2..TN draft]`,draft 不是终态 → **永不收敛**,OKR 不写、上级不唤醒。 修法:条件更新 `WHERE status='planning' AND archived_at IS NULL`,没命中就一条 task 都不插(但**钱照记**)。 **2 · CAS 把最终态提前写进库,验收窗口内重启即丢,且查不出来。** `verdict=NULL` 也正是「1 条 task 跳过验收」的合法形态,**没有任何查询能把「验收丢了」和「本来就没验」分开**。 修法:占位判词 + 启动重推。三态可分:`NULL`(没验)/ 占位前缀(进行中或丢了)/ 真判词(已定案)。 **3 · 认领语句自己构成了它点名要避开的形态。** 第一版重推的认领是 `SET verdict=RESUMING WHERE verdict IN (PENDING, RESUMING)` —— **写进去的值就在 WHERE 里**,已被认领的行照样匹配,`changes` 恒为 1。而函数注释白纸黑字写着「认领写的是**另一个**占位值,别的扫描者就不会再选中它」。**注释是对的,代码没兑现。** 实测后果:`org-validator 调 2 次 / OKR 写 2 条 / token 双记`。 修法:claim 值 = 前缀 + 进程随机段 + 进程内自增 + **时间戳**;认领的 WHERE 只认上一个态;回写改成 `WHERE verdict = @claim` 的条件更新决胜;**主路径也走同一套闸门**。 > 第三轮之后仍剩一条同款:`reported_at` 只有认领一道闸,且接受任意占位值当旧值 —— B 实例会把 A 实例**手上还活着**的汇报抢走,上级被叫醒两次。第四轮补上**租约**(`CLAIM_LEASE_MS`,默认 10 分钟):活着的认领不许抢,过期的才准抢。**只认 `null` 不行** —— 那会把「可恢复的洞」换成「不可恢复的洞」。 ### 14.4 过程:四轮,22 条缺陷,其中 6 条是修复自己引入的 | 轮次 | agent | 结果 | |---|---|---| | 1 · 实现 | 11 | 全绿;复核报 8 条 | | 2 · 修复 | 5 | 修 6 条;复核报 1 条**新引入**的高危(认领竞态,实跑复现) | | 3 · 再修 | 5 | 竞态真消灭(跨进程探针验证);复核又报 4 条 | | 4 · 收尾 | 人工 | 租约 / 归档不重报 / 输家记账 / 判词不出 API | **第四轮修完挂了 3 条测试,全都是测试在钉住缺陷本身**:断言「抢跑输家的 token 为 0」(= 钱静默蒸发)、断言「重推能抢走新鲜占位」(= 跨进程重复汇报)。其中一条的注释写着「这里模拟的是进程死后重启」,而它构造的占位值是**新鲜的** —— 注释描述 A 场景、代码构造 B 场景、断言按 A 场景写。按注释的语义把占位值做旧,测试就对了。 > **这是本文档第二次记录同一条规律**(§十三.3 是第一次):修复与实现一样会出错,而且更隐蔽 —— 它长得像在解决问题。批 1 三轮、批 2 四轮,两次都不是一轮收敛的。 ### 14.5 留给下一轮 1. **跨进程判活要租约 + 心跳**:现在靠固定租约窗口,抢跑者仍可能多烧一次 LLM(副作用一个都不会多做)。 2. **汇报语义是 at-least-once**:exactly-once 要 `/api/agent/task/report` 认幂等键(goal id + 收敛时间),属接收侧改动。 3. **拆分超时只在本进程存活期间成立**:进程中途被杀会留下 `status='planning'` 的 goal,需要启动时扫描重推(与批 3 的中断恢复一起做)。 4. `io-model.check.js` 仍是零调用方(同款前科,批 2 已把 `goal-task-model.check.js` 转成真测试)。 --- ## 十五、批 3 + 批 4 实施记录(2026-08-19) > 体例同 §十三 / §十四:**设计正文不改写,只在这里对账;冲突处以本节为准。** ### 15.1 落地了什么 **批 3 · 排队**(commit `87b776c`,**该提交由第三方会话在对抗复核完成前误提,见 §15.4**) `run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler.js`(tick / sweep / 依赖推导)+ 入队/取消/rerun 端点 + 控制台队列显示与三处可见性修复。 **批 4 · 并行**(本次) `idx_graph_runs_one_active_root` **已 DROP**;`max_parallel_tasks` 从 `company.json` **调用时读** (缺省 1、非法回落 1 并留声、硬上限 20);准入判据「从活跃集外进入活跃集」按额度计数。 全量:**128 文件 / 1240 测试全绿**,`npm run web:build` 通过。 ### 15.2 批 4 的生死线:超发(真进程实跑,不是纸面论证) 删掉唯一索引 = 删掉最后一道 DB 兜底,此后全靠 `updateRunStatus` 的准入事务。**用 8 个真 node 进程 并发抢坑实测**(每进程最多重试 40 次),四组: | max | 抢到坑的进程 | 库里实际活跃 root run | 判定 | |---|---|---|---| | 1 | 1 | 1 | ✅ | | 2 | 2 | 2 | ✅ | | 3 | 3 | 3 | ✅ | | 5 | 5 | 5 | ✅ | 落选进程全部吃 `RunSlotBusyError`(各 40 次全被拒),零 `other` 错误。准入的四步缺一不可: `BEGIN IMMEDIATE` → 事务内重读 → 事务内计数 → 同事务条件更新。 ### 15.3 批 3 复核的 4 条遗留(本次修完) **N1 🔴 `cancelQueuedRun` 的前提被批 3 自己改没了** 它的注释说「对一条还没开跑、没有子 run 的排队 run,`stopRun` 是多余的一圈」—— 而 `requeueBlockedRootRun` 落地后,`queued` 的 run **可以是跑过一半的**(有 success 节点、 有活着的子 run;预算撞顶时引擎只把 root 置 blocked,子 run 要到节点边界才落 paused)。 探针实测:取消后节点残留 `ready`+`success`、子 run 残留 `paused / stop_requested=0` —— **那条 running 的子 run 继续调 agent 烧 token**。改为无条件走 `engine.stopRun`。 **L1 ⚠️ 恢复路径对 manual 只兑现一半** `graph-engine.js` 写着「『T1 永远回不来』这个后果因此消失」,但调度器 `filter(execution_mode === 'auto')` 故意不碰 manual → 人点「启动」→ 退回队列 → 坑空了 → **没人接**。 定案:**「manual 不自动出队」约束的是首次起跑的授权**;一条已被人亲手起过、因坑满被退回队列的 manual,授权早就给过了,让它自己接回来是**兑现人刚才那次点击**,不是新授权。 判据用 **`attempt > 0`** 而不是 `started_at` —— 后者看着更贴切,但 `requeueBlockedRootRun` 退回队列时会把它清成 NULL,**恰好在需要它的那一刻是空的**(实测踩过)。 **N2 🟠「退回队列」对用户完全不可见** `startRunSync` 早返回 200 + `{ok:true, requeued:true}`,而 `GraphRunPane.act()` 只在 `!r.ok` 时写 `err` → 界面零提示;随后按钮全灰、run 面板换成定义视图、提示语说「节点状态一律 pending」(**假的**)。 `requeued` 字段全前端零处读取。现加 `notice` 一行明说「已退回队列排队,坑一空会自动接着跑」。 **N3 🟠 `isSlotRaceError` 的判据被探针证伪** 注释称 `InvalidTransitionError` = 「已被别人从 queued 推走」。实测:被推走时条件更新 **返回 0、不抛**;真正抛它的只有「**调用方声明了非法转换**」。那一支把调用方的 bug 当竞态吞掉 且不留日志。已删除该分支(与批 4 删 UNIQUE 那支同一理由:让它吵起来)。 > **4 条里有 3 条是同一个模式:注释承诺的前提被本轮自己的改动改没了,注释还留着。** > §十三.3 / §十四.4 已经就这个模式立过两次案,这是第三、四、五次。 ### 15.4 一次流程事故:代码在复核完成前被第三方提交 `87b776c` 由另一个会话(`lemonboss-68`)在 16:56 提交,**而批 3 的对抗复核 17:00 才出结果** (就是 §15.3 那 4 条)。提交说明里的「1201 用例全绿」准确描述了当时那次测试运行,但它压在一份 尚未通过复核的代码上,读起来像质量背书。该会话已写交接文档 `27-p3c-batch3-handover.md` 并更正记录。 **教训**:多会话共用同一个 worktree 时,「工作区里的改动」不等于「可以提交的改动」—— 未提交本身可能就是一种状态声明(这里是「在等复核」)。提交前问一句的成本,远低于把一份 已知有缺陷的代码写进 main 的成本。 ### 15.5 留给下一轮 1. **边界守卫会误判并行会话的改动**:它比对整仓 `git status` 差集,那里面**没有「谁写的」这一维**, 多会话共用 worktree 时必然把开发会话的改动算到当时在跑的图节点头上(实测已导致一条 run 被判 `boundary_escape`)。批 3 修的是这条日志的**可见性**(列出文件清单),**误判本身没修**。 2. **机器级闸门仍然不存在**:`SessionPool` 没有 `maxSessions`,`task-queue.js` 另有个 `MAX_CONCURRENT=3` 抢同一台机器。批 4 只放开了公司级额度(硬上限 20),机器压不压得住没有任何守卫。 3. 批 5(两套任务体系统一)未开工,预研方案已就绪。 --- ## 十六、批 5 实施记录(2026-08-19/20,进行中) > 体例同 §十三~§十五。**本节随批 5 推进增量补写**,未做的步骤明确标出来。 ### 16.1 进度 | 步 | 内容 | 状态 | |---|---|---| | 1 | `tasks` 表吸收 `executor` / `assignee_agent_id` / `parent_task_id` | ✅ `5cd91c3` | | 2 | `system/tasklog/*.json` 回填进 `tasks` 表 | ✅ `514ec3d` | | 3 | `/task/dispatch` 改写 `tasks` 表(**URL 与请求体一个字不动**) | ✅ `12880b8` | | 4 | 30 分钟去重从「扫目录」改成一条 SQL | ✅ 同上 | | 5 | `GET /api/tasks` 改读表;删 `TaskService.getAggregatedTasks()` 的会话反推 | ✅ `71cf45e` | | 6 | 调度器只管 `executor='graph'` | ✅ 同上 | **批 5 完成,P3C 五批全部实施完毕。** 全量:**132 文件 / 1257 测试全绿**。 ### 16.2 第 1 步踩到的:一个会让**所有存量库开不了**的顺序问题 第一版把 `CREATE INDEX ... ON tasks(assignee_agent_id)` 写进了建表块。**建表块整体先于迁移块执行**, 而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op —— 表还是批 4 的形态、没有那一列, 于是这句索引直接抛 `no such column`,`getDb()` 起不来。 **新建库完全正常**(单测默认走的就是这条路),所以只测新建库根本看不见这个洞。 是专门去跑「批 4 形态的库重启一次」才撞出来的。索引已挪进迁移块、排在 ALTER 之后, 守卫见 `tasks-executor-migration.test.js`。 > 由此得到一条可复用的规矩:**凡是加列,必须同时测「存量库重启」这条路**。 > 新建库的路径由每个单测天然覆盖,存量库那条没人走 —— 而生产上只有存量库。 已知不对称(写在代码注释里):`ALTER TABLE ADD COLUMN` 加不了 CHECK 也加不了外键, 所以存量库上 `executor` 的取值域**只由应用层守**,与新建库的 CHECK 不同源。 ### 16.3 第 2 步的关键判断:沿用原 `taskId` 回填时**不新发 id**。理由是引用面:`conversations/task-{taskId}` 是目录名、汇报链路按 taskId 找回这条派单、summary 里互相引用。换 id 等于把这些引用全断掉,**而回填的目的恰恰是 「别让历史消失」**。 `company_id` 落保留 id `_global`:tasklog 的行**没有 company 这一维**(`from`/`to` 都是本体 id), 全局 agent 的会话本来就落在这个 id 下(28 §5.1)。刻意不去猜某条派单「其实属于哪家公司」—— 猜错会让它出现在错误的公司任务列表里,那比「都归 `_global`」更难发现。 实跑:4 条派单全部回填成功、零失败;再跑一次全部跳过、零新增。 ### 16.4 第 3 步(下一步)的前提,动手前必须先核实 `POST /api/agent/task/dispatch` 与 `/task/report` 的 **URL 与请求体形态一个字都不能改** —— 它们印在 `packages/agent-core/base/common/tools-base.md` 里,是**所有 agent 的工具说明**, 改它等于同时改所有 agent 的提示词。 而 `server/routes/agent-chat.js` 在 2026-08-19 被另一个会话大改过(会话回收 / `IdleTimer` / `CALL_KINDS` / 进程泄漏修复,见 `310a2b2` / `2092a2e` / `b0b2b62`)。**预研是在那之前写的**, 所以第 3 步动手前要重新读一遍现状,而不是照预研的描述改。 > 顺带记一处过期:`tools-base.md` 里印的是 `http://localhost:3000`,而实际端口是 **3010** > (`server/config.js`)。已经真咬过人 —— 回填出来的派单正文里有人手写了「端口 3010,不是 3000」 > 来纠正它。这一处该在第 3 步一起改(改的是端口不是契约形态)。 ### 16.5 第 3 步(最危险的一步)怎么做到不碰契约 `/task/dispatch` 的副作用有九步,**只有第 ⑧ 步是「存储」**: ``` ① 校验 from/to/task + 两个本体目录存在 ② 去重(30 分钟内同接收方 + 正文互相包含) ③ 推断 parentTaskId ④ 正文净化(\n →
,| → |) ⑤ 写目标 agent 的 todo.md ⑥ 写发送者的 plan.md ⑦ 更新 OKR 节点 → in_progress ⑧ saveTaskMeta(taskId, {...}) ← 只换这一步的内脏 ⑨ autoExecute → POST /api/agent/{to}/chat ``` **把 `saveTaskMeta` / `loadTaskMeta` 变成 tasks 表的适配器**,其余八步一行不动 —— URL 与请求体形态天然不可能变。②③ 顺带从「扫目录」改成带索引的 SQL,**判据一个字没改**, 换的只是从哪儿把候选捞出来。 字段落点的三个判断: - **`objectiveId` 不单独存**:它恒等于 okr node id 的首段(`OkrService.parseNodeId` 与 `reportRunOutcome` 同一口径),且两处读取方都是 `if (objId && meta.nodeId)` —— 从不单独用。 - **`sourceConvId` 删掉**:全仓零读取方。正是本文档 §九.4quater 立过案的「实现在、零调用方」, 不往新表里再搬一份。 - **`summary` / `execution_conv_id` 加列**(用户定案 A):前者当前只写不读但语义清晰、界面迟早要它; 后者有真读取方。**没有选「加一个 legacy meta 的 JSON blob 列」** —— §十二 引 R-19.2 明确否过 「建表而不是 JSON」,那会把一个已被否掉的形态重新引进来。 验证方式是**真起服务打一遍**,不是只看测试绿:照 `tools-base.md` 印的请求体原样 POST → 200 + 老形态响应;再打一次 → 409 且 `existingTaskId` 正确;`/task/report` 之后 `status→completed`、 `summary` 与 `finished_at` 都在;`loadTaskMeta` 往返出来的形状与老 JSON 逐字一致。 ### 16.6 第 5 步:删掉的是「猜」,不是「记录」 原 `getAggregatedTasks` 是 122 行的启发式:扫全部 agent 的 `conversations/`,按会话 id 里的 `T-xxx` 归组,再用三条策略(meta 的 `parentTaskId` / from agent 的活跃会话时间包含 / master conv 的标题相似)**猜**父子关系。猜出来的树没法验证对错,而且每次请求都要遍历所有 agent 的会话目录 —— 路由那 10 秒缓存就是为它加的。 现在父子关系是 `tasks.parent_task_id`,派单时就显式写下来了。`task-service.js` **391 行 → 236 行**。 **`getTaskTimeline` 保留**,但来源拆清楚: - 任务树(有哪些子任务)→ `tasks` 表,显式、可验证; - 时间线正文 → agent 的 `conversations/`,那是**一手记录,没有替代品**。 两处口径变化(已写进代码注释):`agents` 从「会话里出现过的 agent 集合」变成「谁派的 + 派给谁」; `conversationCount` 从「扫出来的文件数」变成「有没有起过执行会话」(0/1)—— 为一个列表字段 遍历所有 agent 的会话目录,正是这次要去掉的那笔开销。 ### 16.7 两次「测试全绿 ≠ 没坏」 这一批里有两处只有**真跑**才会暴露的问题,测试一条都没红: 1. **`routes/tasks.js` 漏改**:它自己还在算 `t.conversations.length`,而 `conversations` 是 会话反推那套的产物、已随之删除 → `GET /api/tasks` 直接 500。没有任何测试走那条路。 2. **控制台 org graph 栏宽**(§十五 之外,`81629b7`):320px 减去 padding 只剩 292px, 而两个节点加一条边要 380px+ —— 节点名被从中间切断。构建、单测、探针全都不会报。 > 与之对照,§十六.2 那个「存量库一启动就炸」是**测试能覆盖但没人写**的类型。 > 三者合起来是一条经验:**新建库的路径、纯逻辑的路径由单测天然覆盖; > 「存量数据」「真实接口」「肉眼可见」这三类必须另外安排人去走一遍。** ### 16.8 留给下一轮 1. `system/tasklog/` 目录**还在**(4 个历史文件,已回填进表)。按 §十二.5 应改名为 `system/tasklog.bak-unify/` 而不是直接删,跑顺一个版本再清。本轮未做。 2. `tools-base.md` 里印的端口仍是 `localhost:3000`,实际是 **3010**。已经真咬过人 —— 回填出来的派单正文里有人手写「端口 3010,不是 3000」在纠正它。**改端口不改契约形态**, 但它是所有 agent 的工具说明,值得单独一次改动 + 通告。 3. `TaskTimeline.vue` 未动。它按字段名取值,本轮保住了全部 12 个字段,但 `agents` / `conversationCount` 的语义变了(见 §16.6),界面上的措辞可能要跟着调。 --- ## 十七、收尾之后(2026-08-20)—— §16.8 与 §15.5 全部收掉,外加真跑挖出来的三条 > 体例同 §十三~§十六。这一节记的是 P3C 五批之后的清尾,**以及一次真跑**: > 它才是这批工作真正的验收,也是三条缺陷的出处。 ### 17.1 §16.8 / §15.5 的欠账(commit `8f4c1b6`) | 来源 | 项 | 结果 | |---|---|---| | §16.8-1 | `system/tasklog/` 改名保留 | ✅ 改成 `system/tasklog.bak-unify/`;回填脚本**两处都认**(先原位置再备份位置,顺序不能反:万一有人从备份恢复出一份新的 tasklog,那份才是要回填的) | | §16.8-2 | agent 提示词里的端口 3000 → 3010 | ✅ 守卫 `agent-docs-port.test.js`。第一版扫出 5 条误报,全是某 agent 目录下 Python venv 里 playwright 的 ThirdPartyNotices —— 第三方文件不是提示词,已跳过 `venv/site-packages/workspace/conversations/train` | | §16.8-3 | `TaskTimeline.vue` 的措辞 | ✅ `conversationCount` 批 5 之后只会是 0/1,界面却写 `Convs: N` —— 读的人会以为这条任务只有一次对话。改成有/无的表达 | | §15.5-1 | 边界守卫误判并行会话的改动 | ✅ 见 17.2 | | §15.5-2 | 机器级闸门 | ✅ `SessionPool.maxSessions()` + `SessionPoolFullError` + `_evictForRoom()`,守卫 `session-pool-max.test.js` | | §15.5-3 | 批 5 | ✅ §十六 | ### 17.2 边界守卫:从「乱扣帽子」改成「留证据、不判罚」 守卫的前提是「窗口内新增的仓库改动 = 这个节点写的」。**多会话共用 worktree 时这个前提是假的** —— `git status` 的差集里没有「谁写的」这一维。实测已发生:批 3 的开发改动把一条 run 判成 `boundary_escape` 卡住(就是 §17.4 那条)。 可检测的信号:节点**开跑那一刻**仓库就已经有源码目录的未提交改动 → 有人正在这个 worktree 上开发 → 窗口内新增的源码改动无法归属 → **降级为只记证据、不判罚**(与既有的 `unattributable` 桶同一语义), 而不是关掉守卫:证据照留,人一眼看得出「这次没判罚,因为当时仓库是脏的」。 `BOUNDARY_GUARD=strict` 强制判罚(CI / 干净环境),`=record` 一律只记。守卫 8 例,含关键对照: **仓库脏时不判、干净时同样一处越界仍判 fail** —— 守卫没被削弱,只是不再乱扣帽子。 ### 17.3 测试在往生产图库写(commit `20fed0a`) `agent-chat-tenant-isolation.test.js` 每跑一次往共享的 `data/graph.sqlite` 落 2 条 goal + 2 条 task。 库里 31 条 goal 里 **22 条是它的残留**,而测试一直全绿。 根因是**批 5 的存储迁移留下的半截清理**:`/task/dispatch` 的元数据从 `system/tasklog/*.json` 换进了 `tasks` 表(§十六 第 3 步),而这个用例的 `afterAll` 只会清那个目录 —— 清理代码还在、清的东西已经不在那儿了。**这是这次迁移唯一没被任何测试照到的一面。** > **它是本仓库第四次踩「测试污染共享库」,但换了个方向进来。** > 前三次(`BENCHMARK_DB_PATH` / `USERS_DB_PATH` / `SKILLS_DIR`,记在 `test-isolation-env.test.js` > 文件头)都是「设了 env,生产代码不读」,那条守卫查的正是这个形态。 > 这次是**一个字都没设** —— 没有可查的东西,守卫自然查不出来。 > 静态扫源码也判不出「这个测试会不会开库」(要跨 require 链推导)。 修法两层:用例自己开一次性库;`graph-db.getDb()` 加**运行期闸门**(`VITEST` 为真且没设 `GRAPH_DB_PATH` → 抛,报错里点名 env、点明「要在 require 之前设」,因为 `DB_PATH` 是模块级 const)。 闸门放在 `getDb()` 不放模块顶层:只为取常量而 require 是合法用法,顶层抛会误伤。 守卫 `graph-db-prod-guard.test.js` 4 例(含「没有 VITEST 时不生效」—— 防止把生产一起锁死)。 存量清理:删掉 `created_by LIKE 'zz-tenant-%'` 的 22 goal + 22 task,其余 9 条真实数据一条没动。 ### 17.4 那条卡了两天的 run:真正卡住它的不是边界守卫,是墙钟预算 `run_9168bd24f46c`(`e2e-demo` 做 lemonai 官网)2026-08-18 16:19 判 `blocked` 之后再没能起来。 17.2 修完误判之后它**仍然跑不动** —— 第二道锁在预算上: > 墙钟按 `now - spent.started_at` 算,而 `started_at` 是「第一次花钱的绝对时刻」。 > 这与 `blocked` 的语义**直接对撞**:预算耗尽、节点判罚都会把 run 打到 `blocked`, > 而 `blocked` 的定义就是**挂起等人工**。人隔夜来处置 → 等待那段被算成「已用」→ > 一起跑就报 `wallclock_ms` 耗尽 → 再 `blocked` → 再等人。**过夜的 run 永远醒不过来。** 而且报出来的原因具有误导性:写着 `wallclock_ms`(跑太久),实际是**等人等太久**。 这条 run 只花了 7/100 次调用、69,961/5,000,000 tokens —— **预算用掉不到 2%**。 改法:`spent` 改记 `active_ms`(累计活跃时长),`started_at` 退化成「当前这一段的起点」, 不在 `running` 时为 null。起止结算挂在 `graph-run-sse.afterRunTransition` 这个**单一漏斗**上。 ⚠️ 钩子必须放在那个 early return **之前**:进 `running` 既不是收敛、也不空坑,两个条件都不满足, 放后面就变成「只结算停下来、从不开始计时」,症状是 `active_ms` 恒 0、墙钟形同虚设,**而且照样全绿**。 **第一版的洞是真起服务、真点重试才撞出来的**(commit `9891c3b`):预算行挂在 root, 我就只认 root 的转换;而人工重试一个 work 层节点动的是**子 run**(`blocked → running`), root 全程 `blocked` —— 子 run 跑整场、墙钟一次都没开始计。**单测当时全绿,因为它们只喂了 root。** 存量重锚必须放在**开库路径**里:钩子只在「进 / 出 running」时触发,而升级那一刻正停在 `running` 的 run 不会再进一次 `running`。⚠️ 第一版把这段迁移写进了 `graph-db.js` 的 `migrate()`, 而 `graph_runs` 是 `work-graph-db` 建的表、graph-db 的迁移块跑在它之前 —— 新库上直接 `no such table: graph_runs`,`getDb()` 起不来。**和 §16.2 那次「加索引写进建表块」是同一类顺序问题,这是第二次。** ### 17.5 真跑结果(2026-08-20) 重启服务(迁移在真库上跑到)→ 重试 `org-planner-sdd` → 逐节点 `/next`: | 节点 | 结果 | |---|---| | `org-planner-sdd` | ✅ success(attempt 2) | | `org-planner-benchmark` | ✅ success | | `org-planner-validator` | ✅ success → 整张 work graph 五节点全 success,work run `completed` | | (父节点自动拉回) | ✅ `reviveParentFromChild` 把 root 从 `blocked` 拉回 `running` | | `org-coder` | → 起实现层子图 `run_91c4e38492dd` | | `org-coder-impl` | ✅ success | | `org-coder-validator` | 判 fail → **走回退边打回 `org-coder-impl`**(`loopCount: 1`),判词:项目根下只有一份计划文档,没有任何交付文件 | **返工闭环在真跑里成立了** —— 这是 P3 的核心机制第一次由真 agent(不是注入的假 executor)走通。 预算 13/100 次调用、114,847/5,000,000 tokens。 ### 17.6 这一轮的三条经验 1. **「修完 A 就能跑」是个假设,不是结论。** 17.2 修完误判之后那条 run 仍然跑不动, 第二道锁(墙钟)只有真去点一次才露出来。**卡住的东西可以有不止一把锁。** 2. **单测喂什么形状,就只能守住什么形状。** 墙钟第一版的 8 条用例全绿, 因为它们全都直接喂 root run —— 而生产里触发计时的是**子 run**。 §16.7 说「存量数据 / 真实接口 / 肉眼可见三类要另外走一遍」,这里要补第四类: **树形结构的机制,测试必须喂非根节点**。 3. **同一类顺序问题第二次了**(迁移写错模块 vs §16.2 加索引写进建表块)。 判据可以固化成一句话:**凡是写迁移,先问「这张表是谁建的」,答案就是这段迁移该待的文件。**