Commit 80eccbc9979532bba15c66f8deac2baf1ec2b526

Authored by yibo
1 parent ca468ab2

docs(27-P3C): 公司任务拆 goal / task 两级 —— 排队、并行与控制台任务列表

控制台走查暴露的「一个 company 只能有一个任务」,根因不是并发约束,
是「人类提的需求」和「机器执行的单元」被压在同一条 root graph_run 上。

三个实地核实的前提,让这次不是发明新概念:
- 27-NAMING §四 早就定义了 goal 1 : task N 的基数
- graph_runs.goal_id / task_id 两列早就在,company.js 注释也写着
  「三者基数不同不可混用」—— 但全仓库没有任何写入方,goal_id 是悬空外键
- G- 前缀已被 OKR 正名释放;/api/goals 与 /api/company/:cid/goals 这两个
  OKR 向后兼容别名全仓库零调用方,可以退役后把路径要回来

设计要点:
- goals / tasks 两张新表 + graph_runs 新增 run_status='queued'
- queued 的唯一理由是把出队的并发临界区交给既有唯一索引兜底:
  idx_graph_runs_one_active_root 覆盖 ready/running/paused 而不覆盖 queued,
  超发会被索引直接拒绝,不需要应用层的「先查后插」
- 排队推翻了 P3「拒绝而不排队」的定案,但那条定案的理由(没有出队 worker =
  静默丢任务)今天仍成立,所以调度器的四个触发点是功能主体而非收尾
- 已下达的描述冻结:入队即写 task-goal.json 并被 describeTask 消费,
  改库不改 artifact 会让两份对不上,两份都改等于篡改审计输入
- 现有 T-${Date.now().toString(36)} 在「一个 goal 批量建 N 个 task」下
  同毫秒必撞,且 tasks.id 是主键 —— 改用仓库既有约定的 4hex 后缀

R-20 四条定案回记至 27-requirement.md。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3c-goal-task-model.md 0 → 100644
  1 +# 27-P3C. 公司目标与任务 —— goal / task 两级模型 + 控制台任务列表
  2 +
  3 +> **所属**: [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)。
  4 +> **日期**: 2026-08-18
  5 +> **交付物**: ① `goals` / `tasks` 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的**排队与并行**;③ 执行控制台(`/:id/graph-console`)最左侧新增**目标·任务两级列表栏**。
  6 +> **命名**: 全文遵循 [27-NAMING](./27-naming-conventions.md),本文档**落地**的正是它 §四 定义但代码里一直没有载体的 `goal 1 : task N` 基数关系。
  7 +> **代码引用体例**: 沿用 27-P3/P4 头部约定 —— 只写 `文件名` + 函数名 / 常量名,**不写行号**。
  8 +
  9 +---
  10 +
  11 +## 一、问题
  12 +
  13 +用户的原话是「一个 company 在控制台下达任务只能有一个」。拆开是**三件事**:
  14 +
  15 +| # | 现象 | 真实成因 | 本次 |
  16 +|---|---|---|---|
  17 +| A | 历史任务**看不见、切不了、管不了** | 纯界面缺失。`GraphConsole.loadRuns()` 拿到了全部 root run,却只取 `roots.find(active) \|\| roots[0]`,其余全丢 | ✅ §七 |
  18 +| B | 一家公司**同时**只能跑一个,下第二个吃 409 | v1 硬约束:`work-graph-db` 的 `idx_graph_runs_one_active_root` 唯一索引 + `company.js` 的预检查。当时选「拒绝」而不是「排队」的理由写在代码注释里 —— **仓库里没有任何出队 worker,排进去的任务不会被消费,那是一条会静默丢任务的路** | ✅ §五 补上调度器,约束从「拒绝」改成「排队」 |
  19 +| C | 「人类提的需求」和「机器执行的单元」是**同一个东西** | `POST /api/company/:cid/tasks` 一次调用同时创造了需求、执行单元和执行实例三层语义,压在一条 root run 上。于是需求无法分解,也就无从并行 | ✅ §三 / §四 |
  20 +
  21 +C 是根因,A 和 B 是它的两个表征。**先拆两级模型,A/B 才有干净的解法。**
  22 +
  23 +---
  24 +
  25 +## 二、与既有命名的对齐(先读这一节)
  26 +
  27 +### 2.1 这不是新概念,是给一个已存在的悬空外键补上它的表
  28 +
  29 +[27-NAMING §四](./27-naming-conventions.md) 早已定义好基数:
  30 +
  31 +| 概念 | 基数 | 定义(原文) |
  32 +|---|---|---|
  33 +| **goal** | 一个人类需求描述 = **1 个** | 验收的边界。回答「这次交付到底算不算满足了人提的要求」 |
  34 +| **task** | 一个 goal 分解出 **N 个** | 图中的执行单元。**可并行**、可下钻 sub agent |
  35 +| **okr** | 跨多个 goal 的长周期度量树 | Objective / Key Result |
  36 +
  37 +而 `graph_runs` 里:
  38 +
  39 +```sql
  40 +goal_id TEXT, -- 全仓库**没有任何写入方**,只有 body.goal_id 透传
  41 +task_id TEXT, -- 由 POST /:cid/tasks 现生成,无表、无唯一性保证
  42 +```
  43 +
  44 +`company.js` 那条注释写得明明白白:`goal_id?, okr_node_id?, 三者基数不同不可混用(goal 1 : task N : okr 跨 goal 度量树)`。**字段在、语义在、表不在。** 本文档做的就是把这两张缺席的表补上,不是发明新词。
  45 +
  46 +### 2.2 「goal」一词的六个用法消歧(实现前必须分清)
  47 +
  48 +| 写法 | 指什么 | 载体 | 本次是否新增 |
  49 +|---|---|---|---|
  50 +| `company-goal` | 建公司时那句定性目标 | `company.json.goal` | 否 |
  51 +| **`goal`(本文档)** | **人类下达的一个需求 = 验收边界** | **新 `goals` 表,`G-xxx`** | ✅ 新增 |
  52 +| `task-goal` | **一个 task** 的验收边界(= 该 task 的描述) | `runs/{runId}/artifacts/task-goal.json` | 否,位置不变 |
  53 +| `subtask-goal` | work graph 内分支的验收边界 | `workgraph-planning` 拆分时产生 | 否 |
  54 +| OKR `Objective` | 长周期度量树,`O-xxx` | `data/companies/{cid}/okr/` | 否 |
  55 +| `goal-benchmark` / `goal-test-data` / `goal-test-script` | 三种**验收产物**(agent / 节点名) | work graph 节点 | 否 |
  56 +
  57 +> **注意 `task-goal` 的归属没变**:它是「一次 task 执行的验收边界」,artifact 本来就落在 `runs/{runId}/` 下 —— 也就是**每个 task 一份**。引入 goal 表后它的位置和语义都不用动,goal 的描述是它的**上级**,不是它的替代。
  58 +
  59 +### 2.3 `G-` 前缀可以用,`/api/company/:cid/goals` 可以要回来
  60 +
  61 +两处历史包袱都已经清干净,实地核实过:
  62 +
  63 +1. **`G-` 前缀已被 OKR 释放**。27-NAMING §六 的迁移把 OKR 第 1 层 `G-001` 正名为 `O-001`(`npm run okr:migrate` 已执行,且当时存量数据「几乎为空」)。所以新 `goals` 表用 `G-` 不与任何活数据冲突。
  64 +2. **`/api/goals` 与 `/api/company/:cid/goals` 目前是 OKR 的向后兼容别名,但全仓库零调用方**。grep 的结果只有三行,全是别名注册语句本身(`index.js` / `agent-server.js` / `okr.js` 的注释)。**本次连带退役这两个别名**,把路径要回来给新概念 —— 一个词在同一套 API 里指两件事,是比破坏兼容更贵的债。
  65 +
  66 +---
  67 +
  68 +## 三、模型:goal → task → run 三级
  69 +
  70 +```
  71 +goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边界
  72 + │ 描述一经下达不可改(§四.4)
  73 + └─ tasks (T-xxx) N 条。lemonboss 的执行单元,可排队、可并行、可有依赖
  74 + │ 描述一经下达不可改(§四.4)
  75 + └─ graph_runs root run,M 条。一次**执行实例**(重试会产生第 2 条)
  76 + └─ 子 run 每个 org node 现规划出的 work graph,及更深的子图
  77 +```
  78 +
  79 +三级各自回答一个问题,不重叠:
  80 +
  81 +| 层 | 回答的问题 | 谁改它 | 状态机 |
  82 +|---|---|---|---|
  83 +| goal | 人想要什么?做到了没有? | 人 | `draft → active → done / cancelled / archived` |
  84 +| task | 这个需求拆成哪几件事?谁先谁后? | 人(v1)/ `task-planning` agent(v2,§十一) | `draft → queued → running → completed / failed / blocked / cancelled` |
  85 +| run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) |
  86 +
  87 +**为什么 task 与 run 必须分开**(这是「不能直接把 task 当 root run」的三条硬理由):
  88 +
  89 +1. **排队态没有 run**。一个排在第 3 位的 task 还没有图、没有节点、没有预算消耗 —— 硬塞一条 run 行进去,`graph_run_nodes` 是空的,所有按 run 遍历节点的代码都要加空判。
  90 +2. **重试是 task 1 : run N**。今天「重跑一遍这个任务」只能新建一条 run,于是两次执行在数据上毫无关联;有了 task 表,`runs` 挂在 task 下,「这个任务试了 3 次」才说得出来。
  91 +3. **依赖与并行是 task 之间的关系**,不是 run 之间的。`depends_on` 只有落在稳定的 task id 上才有意义 —— run id 每次重试都变。
  92 +
  93 +---
  94 +
  95 +## 四、数据模型
  96 +
  97 +DB 文件复用 `data/graph.sqlite`(`graph-db.js` 的 `getDb()`,WAL + `foreign_keys=ON` 已设好),与 `graph_runs` 同库同连接。迁移写法照抄 `work-graph-db.js` 现有的「存量库迁移」块(`hasColumn` 判断 + `ALTER TABLE ADD COLUMN`)。
  98 +
  99 +### 4.1 `goals` 表(新)
  100 +
  101 +```sql
  102 +CREATE TABLE IF NOT EXISTS goals (
  103 + id TEXT PRIMARY KEY, -- G-{base36}-{4hex},见 §四.5
  104 + company_id TEXT NOT NULL,
  105 + title TEXT NOT NULL, -- 短标题,列表显示用;可改
  106 + description TEXT NOT NULL, -- 人类需求原文 = 验收边界;下达后不可改
  107 + status TEXT NOT NULL DEFAULT 'draft',
  108 + -- draft | active | done | cancelled | archived
  109 + priority TEXT NOT NULL DEFAULT 'P1',
  110 + okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1)
  111 + budget_json TEXT, -- 整个 goal 的总预算;不填则每个 task 各用默认
  112 + created_by TEXT, -- 'human' | agent id(Master/CEO 代下单)
  113 + created_at TEXT NOT NULL,
  114 + updated_at TEXT NOT NULL,
  115 + dispatched_at TEXT, -- 首次下达时间;非空 = 描述已冻结
  116 + archived_at TEXT
  117 +);
  118 +CREATE INDEX IF NOT EXISTS idx_goals_company ON goals(company_id, status);
  119 +```
  120 +
  121 +`okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度。task 级的进度靠 goal 汇总,不各自往 OKR 上写(会重复计数)。
  122 +
  123 +### 4.2 `tasks` 表(新)
  124 +
  125 +```sql
  126 +CREATE TABLE IF NOT EXISTS tasks (
  127 + id TEXT PRIMARY KEY, -- T-{base36}-{4hex}
  128 + goal_id TEXT NOT NULL REFERENCES goals(id),
  129 + company_id TEXT NOT NULL, -- 冗余一份:调度器按公司选任务,避免每次 JOIN
  130 + seq INTEGER NOT NULL, -- goal 内序号,列表排序用(1,2,3…)
  131 + title TEXT NOT NULL,
  132 + description TEXT NOT NULL, -- 落进 task-goal.json 的那句;下达后不可改
  133 + status TEXT NOT NULL DEFAULT 'draft',
  134 + -- draft | queued | running | completed | failed | blocked | cancelled
  135 + execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')),
  136 + depends_on TEXT NOT NULL DEFAULT '[]', -- JSON 数组,同 goal 内的 task id
  137 + budget_json TEXT,
  138 + current_run_id TEXT, -- 最近一次 root run(逻辑外键,见 §四.6)
  139 + attempt INTEGER NOT NULL DEFAULT 0, -- 起过几次 run
  140 + priority TEXT NOT NULL DEFAULT 'P1',
  141 + created_at TEXT NOT NULL,
  142 + updated_at TEXT NOT NULL,
  143 + queued_at TEXT, -- 入队时间;非空 = 描述已冻结
  144 + started_at TEXT,
  145 + finished_at TEXT,
  146 + archived_at TEXT
  147 +);
  148 +CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks(goal_id, seq);
  149 +CREATE INDEX IF NOT EXISTS idx_tasks_queue ON tasks(company_id, status, priority, created_at);
  150 +```
  151 +
  152 +**`depends_on` 一个字段同时表达串行与并行**,不另设 `parallel_group`:
  153 +
  154 +- `[]` → 无前置,可立即入队 → 同一 goal 下多个空依赖的 task **天然并行**(能不能真并行由 §五.2 的并发上限决定)。
  155 +- `["T-a"]` → 等 `T-a` 到 `completed` 才可调度 → **串行**。
  156 +- 多个元素 → 汇聚点(fan-in)。
  157 +
  158 +也就是说 goal 内部就是一张 task DAG。校验:写入时查环(`depends_on` 只能引用同 goal 内已存在的 task),有环 `400`。
  159 +
  160 +### 4.3 `graph_runs` 新增一个 run status:`queued`
  161 +
  162 +**这是本次唯一动到既有运行时表的地方。** `run_status` 列上没有 CHECK 约束(只有 `graph_kind` / `execution_mode` 有),所以加值不需要改表,只要给 `work-graph-db` 的 `RUN_TRANSITIONS` 加一行:
  163 +
  164 +```js
  165 +queued: ['ready', 'stopped'], // 出队 = queued → ready;取消 = queued → stopped
  166 +```
  167 +
  168 +**为什么有了 tasks 表还需要它**(否则确实是冗余的第二套排队状态)—— 只有一个理由,但足够硬:
  169 +
  170 +> **它把「出队」这个并发临界区交给已有的唯一索引兜底。**
  171 +>
  172 +> 调度器出队时,如果先 `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'`,**超发的那一条会被唯一索引直接拒绝**,而不是靠应用层的「先查后插」(那中间永远有竞态窗口)。
  173 +
  174 +顺带三个收益:入队即有稳定的 `run_id`(可分享的控制台链接)、`task-goal.json` 的落盘路径 `runs/{runId}/artifacts/` 在入队时就确定(描述冻结点与 artifact 落盘点重合,见 §四.4)、timeline 上能记下「何时入队」这条审计事实。
  175 +
  176 +状态机全貌:
  177 +
  178 +```
  179 +task: draft ──提交──▶ queued ──调度器出队──▶ running ──┬─▶ completed
  180 + │ ├─▶ failed
  181 + cancelled ├─▶ blocked ──重试──▶ queued
  182 + └─▶ cancelled
  183 +
  184 +run: queued ──▶ ready ──▶ running ──┬─▶ completed / failed / blocked / stopped
  185 + │ └─▶ paused ──▶ running
  186 + stopped (既有转换全部不变)
  187 +```
  188 +
  189 +### 4.4 描述冻结(用户要求 3)
  190 +
  191 +| 对象 | 可改字段 | 冻结点 | 冻结后改会怎样 |
  192 +|---|---|---|---|
  193 +| goal | `title` / `priority` / `okr_node_id` | `dispatched_at IS NOT NULL`(首个 task 入队)后 **`description` 冻结** | `409` |
  194 +| task | `title` / `priority`(`depends_on` 仅 draft) | `queued_at IS NOT NULL` 后 **`description` / `execution_mode` / `budget` 冻结** | `409` |
  195 +
  196 +**这不是保守,是数据一致性的硬要求**:task 的 description 在入队时就写进了 `runs/{runId}/artifacts/task-goal.json`,而 `describeTask` 等 agent 侧链路读的是 artifact。改库不改 artifact 会让两份记录对不上;两份都改则等于篡改已被 agent 消费过的审计输入。**要改就取消后新建**,错误响应里直接给这句 hint。
  197 +
  198 +`title` 留着可改,因为它只是列表上的一个标签,不进任何 agent 的上下文。
  199 +
  200 +### 4.5 ID 生成 —— 现有格式在本次会真的撞
  201 +
  202 +现在两处都写 `` `T-${Date.now().toString(36)}` ``(`company.js` 与 `agent-chat.js`)。**同一毫秒内生成的两个 id 完全相同** —— 今天没炸是因为一次只建一个;本次「一个 goal 一次性建 N 个 task」是**循环建行**,同毫秒撞是必然事件,而 `tasks.id` 是主键,撞了就是 500。
  203 +
  204 +改成仓库既有约定(`_spec/CONVENTIONS.md` §2:*时间戳部分不保证全局唯一 —— 靠后缀随机位避免碰撞*):
  205 +
  206 +```js
  207 +const genId = (p) => `${p}-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`;
  208 +// G-mabc123-7f3a / T-mabc123-9e21
  209 +```
  210 +
  211 +`agent-chat.js` 那处 tasklog 的 `T-` 生成**本次不动**(那是另一套存储),但要在 §九 记一笔:两套 `T-` id 从此可能长得像但不同源,读的时候别混。
  212 +
  213 +### 4.6 逻辑外键,不重建表
  214 +
  215 +`graph_runs.goal_id` / `task_id` 保持现状不加 `REFERENCES`:SQLite 不支持给已有列追加外键,要加只能重建表 —— 而 `graph_runs` 上挂着 `graph_run_nodes` / `graph_run_loop_records` 的真外键,重建代价远大于收益。约束由服务层保证,文档里标注为**逻辑外键**。
  216 +
  217 +`tasks.goal_id → goals(id)` 是新表,可以是真外键,直接写上。
  218 +
  219 +---
  220 +
  221 +## 五、排队与并行
  222 +
  223 +### 5.1 调度器 `server/services/task-scheduler.js`(新)
  224 +
  225 +一个函数就是全部:
  226 +
  227 +```js
  228 +tick(companyId) // 幂等,可重入,同步执行(better-sqlite3 是同步 API)
  229 +```
  230 +
  231 +1. 读公司并发上限 `max`(§五.2)。
  232 +2. 数当前活跃 root run 数 `n`(`run_status ∈ {ready,running,paused}`)。
  233 +3. `n >= max` → 直接返回(队伍照排,不动)。
  234 +4. 选候选:`status='queued'` 且 `depends_on` 里每个 id 都已 `completed` 的 task,按 `priority` → `seq` → `created_at` 排序,取前 `max - n` 条。
  235 +5. 逐条出队:`UPDATE graph_runs SET run_status='ready' WHERE id=? AND run_status='queued'` → 受影响行数为 0 说明被并发抢走或唯一索引拒绝,**跳过继续下一条**,不报错。成功则 `markRunReady` 建节点行 → `startRun(mode)`。
  236 +
  237 +**触发点四个**(少一个就会有任务永远排在队里 —— 这正是当初拒绝做排队的那条理由,必须逐个接上):
  238 +
  239 +| # | 触发 | 接在哪 | 补的是什么洞 |
  240 +|---|---|---|---|
  241 +| 1 | 任务入队时 | `POST /tasks` 尾部 | 队伍空时立即起跑 |
  242 +| 2 | **run 收敛时** | `graph-engine.settleRun` 转终态处 | 前一个跑完自动带起下一个 —— 队列的主动力 |
  243 +| 3 | 服务启动时 | `server/index.js` 现有的「中断任务恢复」旁边 | 进程重启丢掉的唤醒 |
  244 +| 4 | 60s 兜底定时器 | 同 3 | 前三条任意一条漏了都还能自愈 |
  245 +
  246 +> ponytail: 触发 2 挂在 `settleRun` 上是**唯一**能覆盖 manual / auto / blocked 三种收敛路径的位置(`reportRunOutcome` 只挂得上 auto 的 driver —— 27-P3/P4 已经记过这个坑)。升级路径:将来有跨公司的全局配额时,`tick` 要从「按公司」改成「按全局配额池」。
  247 +
  248 +### 5.2 并发上限与唯一索引
  249 +
  250 +`company.json` 加一个字段(不新建表,`companyService.get` 已经在读这个文件):
  251 +
  252 +```jsonc
  253 +{ "max_parallel_tasks": 1 } // 缺省 1 = 与今天完全同行为
  254 +```
  255 +
  256 +- **`max = 1`(默认、批 2 上线时的形态)**:`idx_graph_runs_one_active_root` 保留不动,DB 层的守卫还在,行为与今天一致,只是「拒绝」变成了「排队」。
  257 +- **`max > 1`(批 4)**:这个唯一索引必须 `DROP`,否则第二条并行任务在 `queued → ready` 时被索引拒掉。替代守卫是 §五.1 第 2-5 步**整体包在一个 `better-sqlite3` 事务里**(`db.transaction(...)`)—— SQLite 是单写者,写事务天然序列化,「count 完再 admit」在这里是真原子的,不是应用层的乐观检查。
  258 +
  259 +**批 4 单独排期、单独验收**,理由:删掉一个唯一索引 = 删掉最后一道 DB 兜底,这个动作不该和「界面上能看到任务列表」混在一次上线里。
  260 +
  261 +---
  262 +
  263 +## 六、API
  264 +
  265 +全部挂在 `server/routes/company.js`。错误响应沿用现有 `{ error, hint, ... }` 形态。
  266 +
  267 +### 6.1 先做一件清理:退役 OKR 的 `/goals` 别名
  268 +
  269 +`index.js` / `agent-server.js` 里 `app.use('/api/goals', require('./routes/okr'))` 与 `company.js` 的 `okrPath()` 里那条 `/:companyId/goals${suffix}` 一并删除。§二.3 已核实**零调用方**。删完 `/goals` 这个词在整套 API 里只剩一个含义。
  270 +
  271 +### 6.2 goal
  272 +
  273 +| 方法 | 路径 | 说明 |
  274 +|---|---|---|
  275 +| `GET` | `/api/company/:cid/goals` | 列表。query: `status` / `includeArchived` / `withTasks=1`(带出每个 goal 的 task 摘要,列表页一次拉完不 N+1) |
  276 +| `POST` | `/api/company/:cid/goals` | 新建。body `{ title, description, priority?, okr_node_id?, budget?, tasks?: [...], dispatch?: bool }`。**不传 `tasks` 时自动生成 1 条同描述的 task**(`seq=1`,`depends_on=[]`)—— 保证「只想下达一句话」的最短路径仍是一次调用。`dispatch: true` 则连带把这些 task 入队 |
  277 +| `GET` | `/api/company/:cid/goals/:goalId` | 详情:goal + 全部 task + 每个 task 的最近一次 run 摘要 |
  278 +| `PATCH` | `/api/company/:cid/goals/:goalId` | 改 `title` / `priority` / `okr_node_id`;`dispatched_at` 非空时带 `description` 一律 `409`(§四.4) |
  279 +| `POST` | `/api/company/:cid/goals/:goalId/dispatch` | 把该 goal 下所有 `draft` task 入队,写 `dispatched_at` |
  280 +| `DELETE` | `/api/company/:cid/goals/:goalId` | 归档。有 task 正在跑 → `409` + hint「先停止 T-xxx」;否则连带归档其下 task |
  281 +
  282 +### 6.3 task
  283 +
  284 +| 方法 | 路径 | 说明 |
  285 +|---|---|---|
  286 +| `GET` | `/api/company/:cid/tasks` | **改造既有语义**:现在返回 task 表的行(含 `goalId` / `status` / `progress` / `runId`),而不是隐式的 root run 列表 |
  287 +| `POST` | `/api/company/:cid/tasks` | **保留、行为兼容**:不带 `goal_id` 调用时,**自动建一个同描述的 goal**(`created_by` 记调用方)再挂一条 task —— 存量调用方(含 `DispatchTaskButton`、外部 agent 的 curl)一行不用改就还能用,响应体里多出 `goalId` 字段。带 `goal_id` 则挂到既有 goal 下 |
  288 +| `PATCH` | `/api/company/:cid/tasks/:taskId` | 改 `title` / `priority`;`depends_on` 仅 `draft` 可改;`queued_at` 非空时带 `description` / `mode` / `budget` 一律 `409` |
  289 +| `POST` | `/api/company/:cid/tasks/:taskId/enqueue` | 单条入队:建 `queued` 的 run 行 + 写 `task-goal.json` + `tick()` |
  290 +| `POST` | `/api/company/:cid/tasks/:taskId/cancel` | `queued` → 撤出队列(run `queued → stopped`);`running` → 转调既有 `POST /api/graph-runs/:id/stop` |
  291 +| `POST` | `/api/company/:cid/tasks/:taskId/retry` | 从 `failed` / `blocked` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里) |
  292 +| `DELETE` | `/api/company/:cid/tasks/:taskId` | 归档,同 goal 的规则 |
  293 +
  294 +**`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段。
  295 +
  296 +### 6.4 不动的端点
  297 +
  298 +`GET /api/company/:cid/graph-runs`(其它调用方在用)、全部 `/api/graph-runs/*` 控制端点、`/api/okr/*`。
  299 +
  300 +---
  301 +
  302 +## 七、前端
  303 +
  304 +### 7.1 布局
  305 +
  306 +```
  307 +┌──────────────┬──────────────┬───────────────────────┬──────────────┐
  308 +│ 目标 · 任务 │ Org Graph │ Work Graph │ 节点详情 │
  309 +│ 280px │ 320px │ 1fr │ 360px │
  310 +│ │ │ │ │
  311 +│ [+ 新目标] │ ○─○─○ │ ○──○──○ │ 四个 tab │
  312 +│ ▾ G 做个官网 │ │ │ │ │ │ │
  313 +│ ● T1 首页 ◀│ ○──○ │ ○ │ │
  314 +│ ◌ T2 后台 │ │ │ │
  315 +│ ⋯ T3 排队#2│ │ │ │
  316 +│ ▸ G 埋点改造 │ │ │ │
  317 +└──────────────┴──────────────┴───────────────────────┴──────────────┘
  318 +```
  319 +
  320 +`GraphConsole.vue` 的 `.cols` 从 `320px 1fr 360px` 改为 `280px 320px minmax(0,1fr) 360px`。
  321 +响应式:`≤1400px` 最左栏收成 44px 图标条 + 抽屉展开(org graph 那栏最不能压,它是全局导航);`≤1100px` 沿用既有堆叠,目标栏排最上。
  322 +
  323 +### 7.2 新组件 `web/src/components/graph-console/GoalTaskPane.vue`
  324 +
  325 +两级树,goal 可折叠,**选中态永远落在 task 上**(org / work graph 是 task 级的):
  326 +
  327 +- **goal 行**:状态点 + 标题 + `3/5 任务完成` + `⋯` 菜单(编辑 / 下达全部 / 归档)。点标题展开收起;goal 行本身被选中时,中栏显示这个 goal 的任务概览(v1 就是一张简单的任务卡片列表,不画图)。
  328 +- **task 行**:状态点 + 标题(2 行截断)+ 第二排 `queued#2 · auto · 3/7 · 12min`。`depends_on` 非空时标题前加 `⇢` 并 hover 出「等 T1 完成」。
  329 +- 状态点配色**沿用画布节点的既有色板**,不新造:running 蓝 / completed 绿 / failed 红 / blocked 橙 / queued 灰蓝 / draft 空心圈 / cancelled 灰。
  330 +
  331 +**「+ 新目标」表单**(承接现有 `DispatchTaskButton` 的内容,搬进这一栏内联展开):标题 + 描述 + 执行模式 + auto 模式的成本预警(原样保留)+ **「拆成多个任务」折叠区**(v1 就是一个可增删的行列表,每行 `描述 + 依赖前一条 ☑`)。主按钮「下达」,次按钮「存草稿」。
  332 +
  333 +`canDispatch === false` 时**主按钮不再禁用** —— 排队之后它永远可点,按钮下方改为提示「当前 1 个任务在跑,新任务将排在第 2 位」。这是 B 类问题在界面上的最终消解。
  334 +
  335 +### 7.3 选中态与刷新
  336 +
  337 +- 路由 `/:id/graph-console?goal=G-xxx&task=T-xxx`,刷新 / 分享可复原。无参数时默认选中最近一条 `running` 的 task,没有则最近创建的。
  338 +- 切 task 时 `orgNode / selectedNode / stack` 全清(不同任务的 def node 不可复用,串了会画出一张不存在的图 —— 与 `onReplanned` 的处理同理)。
  339 +- 选中 **draft / queued** task:org 栏画 org graph **定义**(现有 `hasOrgGraph && !rootRun` 分支已能画,全 pending),顶部一条「排队中,前面还有 1 个任务」或「草稿,尚未下达」+ 对应按钮。
  340 +- 刷新:选中 task 的 run 有 SSE(`GraphRunPane` 的 `@state`)→ 只更新那一行;整树 20s 轮询兜底。
  341 + > ponytail: 没有公司级 SSE 通道,所以未选中的行只能靠轮询。升级路径是 `graph-run-sse` 加一个 company topic,前端订一条就够。
  342 +
  343 +---
  344 +
  345 +## 八、验收
  346 +
  347 +每条都要在浏览器 `/e2e-probe/graph-console` 上真点出来,不是只跑单测。
  348 +
  349 +1. 建一个 goal 不拆分 → 自动生成 1 条 task 并入队,左栏出现两级树,org 栏画出该 task 的 run。
  350 +2. 建一个 goal 拆 3 条 task,T2/T3 依赖 T1 → T1 跑时 T2/T3 显示 `queued`;**T1 收敛后 T2 自动起跑**(验证触发点 2),全程无人工干预。
  351 +3. `max_parallel_tasks=1` 下建两条无依赖 task → 第二条排队而**不是 409**,`queuePosition=2`。
  352 +4. 存量调用方兼容:`curl POST /api/company/:cid/tasks -d '{"task":"…"}'`(不带 `goal_id`)仍返回 201,且自动生成了一个 goal。
  353 +5. 改已下达 goal 的 `description` → `409` + hint;改 `title` → 200。task 同理。
  354 +6. `depends_on` 写成环 → `400`,库里没有半条脏数据。
  355 +7. 取消一个 `queued` task → run `queued → stopped`,队列后面的顶上来。
  356 +8. `failed` 的 task 点重试 → `attempt=1`,新起一条 run,**旧 run 仍能在历史里点开**。
  357 +9. 杀掉服务再起(模拟进程重启)→ 队列里的 task 被触发点 3 捡起来,不需要人工干预。
  358 +10. 列表端点在 20 goal × 3 task 的公司上**不 N+1**(`graph_run_nodes` 的进度用一次 `GROUP BY` 出)。
  359 +11. `/api/goals` 与 `/api/company/:cid/goals` 的 OKR 别名退役后,`OkrDashboard` / `CompanyDashboard` / `bin/lemon.js` 全部功能正常(它们走的是 `/api/okr`)。
  360 +12. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归。
  361 +
  362 +---
  363 +
  364 +## 九、风险与已知取舍
  365 +
  366 +### 1. `queued` 混进 `run_status` 取值域 —— 本次最大的回归面
  367 +
  368 +新增一个状态值,所有按 `run_status` 分支的地方都要逐个确认 `queued` 不被误当可执行。实现时必须挨个看过并补测试:
  369 +
  370 +| 位置 | 期望 |
  371 +|---|---|
  372 +| `graph-engine.advanceOne` / `startRun` | `queued` run 直接拒绝(没有 `graph_run_nodes`,进去就是空指针) |
  373 +| `TERMINAL_RUN_STATUS` / `settleRun` / `propagateToParent` | `queued` 既非终态也非活跃态,收敛判定不该碰到它;且它只可能是 root run,不涉及向上传播 |
  374 +| `ACTIVE_ROOT_RUN_STATUS`(`graph-run.js` + `company.js` 两处) | 不含 `queued` ✅ 天然正确,但要加断言测试钉死 —— **这条正是 §四.3 出队原子性的全部依赖** |
  375 +| `GraphConsole.loadRuns` 的 `roots.find(active) \|\| roots[0]` | 会把 `queued` run 当成「最近的 root run」画图 —— **必须改**,否则入队一条任务就把 org 栏画崩 |
  376 +| 服务启动的中断任务恢复(`server/index.js`)| `queued` 不该被当成「被中断的 run」拉起,它该走调度器 |
  377 +| `company-heartbeat-service` | 同上 |
  378 +
  379 +### 2. 「排队」这条路当初被明确否决过,现在要对它负责
  380 +
  381 +`company.js` 的注释原话:*选拒绝的理由是,仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路*。**这条判断在今天仍然成立** —— 它只是被 §五.1 的四个触发点解除。所以那四个触发点是本次的**功能主体**,不是收尾工作;验收 2 和 9 专门验它们。触发点接漏了,症状就是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。
  382 +
  383 +### 3. `description` 双写(DB + `task-goal.json`)
  384 +
  385 +两份都在入队那一刻写、之后都不可变(§四.4)——结构上不可能不一致。这正是「已下达不给改」的真正理由,而不是保守。
  386 +
  387 +### 4. 归档只是隐藏
  388 +
  389 +`data/companies/{cid}/runs/{runId}/` 仍在磁盘上。本次不做 GC;需要时另立清理任务(按 `archived_at` 早于 N 天)。
  390 +
  391 +### 5. 两套 `T-` id
  392 +
  393 +`agent-chat.js` 的 tasklog 也发 `T-` 开头的 id,本次不动它。两者从此**长得像但不同源**,读日志时别混;本文档的 task id 一律带 `-{4hex}` 后缀,肉眼可分。
  394 +
  395 +### 6. 并行是最后一批,且要单独验
  396 +
  397 +批 4 会 `DROP` `idx_graph_runs_one_active_root` —— 删掉最后一道 DB 兜底。守卫改成事务内 count + admit(SQLite 单写者天然序列化)。不与界面改动同批上线。
  398 +
  399 +---
  400 +
  401 +## 十、实施顺序
  402 +
  403 +| 批 | 内容 | 验收 |
  404 +|---|---|---|
  405 +| **1 · 两级模型落库** | `goals` / `tasks` 建表 + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 |
  406 +| **2 · 控制台左栏** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + 新建表单(含拆分任务)| 1 / 12 |
  407 +| **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `task-scheduler` 四个触发点 + `depends_on` 环检测 + 入队/取消/重试端点 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 |
  408 +| **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 |
  409 +
  410 +批 1+2 上线就已经解决用户提的核心痛点(历史任务看不见、需求无法分解);批 3 才是「不再只能有一个」。
  411 +
  412 +---
  413 +
  414 +## 十一、开放问题
  415 +
  416 +1. **谁把 goal 拆成 task?** v1 是人手拆(表单里加行)。v2 建议加一个 **`task-planning`** agent(命名依 27-NAMING §二「`{产出物}-planning`」,产出物是 task 列表)—— 它读 goal 描述 + 公司 org graph,产出带 `depends_on` 的 task DAG。**本次不做,但表结构已经为它留好**(`depends_on` / `seq` / `created_by`)。
  417 +2. **goal 级的验收谁做?** 现在 `org-validator` 验的是 task 级(`task-goal`)。一个 goal 的 5 个 task 全 `completed`,不等于人的需求达成了。要不要在 goal 收敛时再跑一次 goal 级验收(`goal-benchmark` 天然就是干这个的)?倾向做,但需要先定「goal 级验收的输入是什么」—— 是 N 个 task 的产物合集吗?留到 P5。
  418 +3. **失败的 task 挡不挡住依赖它的下游?** 当前设计:`depends_on` 只认 `completed`,所以上游 failed 会让下游**永远排队**。需要一个显式的「跳过依赖强制入队」操作,还是把这类 task 自动转 `blocked`?倾向后者(自动转 `blocked` 并在界面标红),但要防止级联误判。
  419 +4. **跨公司任务总览**。`/api/tasks` + `TaskTimeline` 是从会话反推的另一套任务树,与本文档的 `tasks` 表同名不同源。将来是把它改成读这张表,还是并存?本次不碰,但这是个明确待收的口子。
  420 +5. **`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?** v1 选前者(`companyService.get` 已经在读那个文件,零新增读路径)。公司级配置项超过 3 个时再考虑建表。
docs/todo/system-design/27-requirement.md
@@ -490,6 +490,48 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file @@ -490,6 +490,48 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file
490 490
491 --- 491 ---
492 492
  493 +## R-20 · 公司任务是 goal / task 两级,可排队可并行
  494 +
  495 +起于执行控制台走查。用户原话:
  496 +
  497 +> 我们看一下,一个 company 在控制台下达任务只能有一个?应该有一个任务列表,能够对任务的增删改查,然后点击任务后看到任务对应的 org graph,work graph 以及执行情况。所以需要在 `/:id/graph-console` 的最左侧再增加一列任务列表,新增任务即可。
  498 +
  499 +第一版设计把「任务」直接等同于 root `graph_run`(理由是库里 `parent_run_id IS NULL` 的每一行本来就是一次任务,只是界面没入口),被用户否掉,改为下面四条。
  500 +
  501 +### R-20.1 · 建 goal 表与 task 表,不复用 root run(原话)
  502 +
  503 +> 还是新建一个任务表吧,company 层面人类下达的任务应该是 goal 表,goal 可以分解为多个 task,让 lemonboss 执行。
  504 +
  505 +**这不是新增概念,是补上一张缺席的表。** [27-NAMING §四](./27-naming-conventions.md) 早就定义了 `goal 1 : task N` 的基数(goal = 一个人类需求描述 = 验收边界;task = 图中的执行单元,可并行),`graph_runs` 里 `goal_id` / `task_id` 两列也早就在,`company.js` 的注释还写着「三者基数不同不可混用」——**但全仓库没有任何写入方,`goal_id` 是一个悬空外键**。
  506 +
  507 +顺带确认两处历史包袱已清干净,新概念可以直接用回这两个名字:`G-` 前缀已被 R-11 的 OKR 正名释放(`G-001` → `O-001`,迁移已执行);`/api/goals` 与 `/api/company/:cid/goals` 目前是 OKR 的向后兼容别名,但**全仓库零调用方**,本次连带退役。
  508 +
  509 +### R-20.2 · 两级结构之上做排队与并行(原话)
  510 +
  511 +> 有个 goal 表,task 表结构后,可以形成排队,或者并行
  512 +
  513 +**这一条推翻了 P3 当初「拒绝而不排队」的定案。** 原定案的理由写在 `company.js` 的注释里 ——「仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路」。**这个判断本身今天仍然成立**,所以 R-20.2 的实质内容不是「加两张表」,而是**必须同时交付调度器**:入队时 / run 收敛时 / 服务启动时 / 60s 兜底,四个触发点少接一个,任务就会静默躺在队里,而界面上它长得跟「正在排队」一模一样。
  514 +
  515 +并行是最后一批:`max_parallel_tasks > 1` 要 `DROP` `idx_graph_runs_one_active_root`,等于删掉最后一道 DB 兜底,守卫改成事务内 count + admit(SQLite 单写者天然序列化),单独排期单独验。
  516 +
  517 +### R-20.3 · 已下达的描述不可改(原话)
  518 +
  519 +> 已下达的 goal 不给改描述,task 已下达也不给改。
  520 +
  521 +**理由不是保守,是数据一致性的硬要求**:task 的描述在入队那一刻就写进 `runs/{runId}/artifacts/task-goal.json`,而 `describeTask` 等 agent 侧链路读的是 artifact。改库不改 artifact 会让两份记录对不上;两份都改则等于篡改**已被 agent 消费过的审计输入**。要改就取消后新建。`title` / `priority` 仍可改 —— 它们不进任何 agent 的上下文。
  522 +
  523 +### R-20.4 · 新增一个 run status(原话)
  524 +
  525 +> 新增一个 run status
  526 +
  527 +定为 **`queued`**。有了 `tasks` 表之后它仍然必要,唯一理由是**把出队的并发临界区交给既有唯一索引兜底**:`idx_graph_runs_one_active_root` 的 `WHERE` 覆盖 `ready/running/paused` 而**不覆盖 `queued`**,于是「入队时建 `queued` 行(不占坑,可有任意多条)→ 出队时 `UPDATE ... SET run_status='ready' WHERE id=? AND run_status='queued'`」这一步,超发的那条会被唯一索引直接拒绝,不需要应用层的「先查后插」(那中间永远有竞态窗口)。
  528 +
  529 +顺带三个收益:入队即有稳定 `run_id`(可分享的控制台链接)、`task-goal.json` 的落盘路径在入队时就确定(描述冻结点与 artifact 落盘点重合,正是 R-20.3 要的)、timeline 上记得下「何时入队」这条审计事实。
  530 +
  531 +**落点**:[27-P3C goal / task 两级模型](./27-p3c-goal-task-model.md)(本条派生出的工作项文档)。
  532 +
  533 +---
  534 +
493 ## 附 · 需求 → 设计落点 速查 535 ## 附 · 需求 → 设计落点 速查
494 536
495 | 需求 | 一句话 | 主要落点 | 537 | 需求 | 一句话 | 主要落点 |
@@ -524,3 +566,7 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file @@ -524,3 +566,7 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file
524 | R-19.2 | **建 sqlite 表而不是 files.json**(JSON 会重演 _registry.json 的并发重写与 git 冲突) | P4 §4.6 | 566 | R-19.2 | **建 sqlite 表而不是 files.json**(JSON 会重演 _registry.json 的并发重写与 git 冲突) | P4 §4.6 |
525 | R-19.3 | **两类数据流都要**(沿边传递 + 任务级共享);workspace 根**代码推导**不入库;路径走 `tasks/{taskId}/` | P4 §4.6 | 567 | R-19.3 | **两类数据流都要**(沿边传递 + 任务级共享);workspace 根**代码推导**不入库;路径走 `tasks/{taskId}/` | P4 §4.6 |
526 | R-19.4 | 边存**契约(kind)**、files 表存**实例(path)**,规划/运行两阶段 → 引擎可在跑之前校验「声明要吃的输入没人产」 | P4 §4.6 | 568 | R-19.4 | 边存**契约(kind)**、files 表存**实例(path)**,规划/运行两阶段 → 引擎可在跑之前校验「声明要吃的输入没人产」 | P4 §4.6 |
  569 +| R-20.1 | **公司任务拆 goal / task 两级**,落地 NAMING §四 早已定义、代码里一直没有载体的 `goal 1 : task N` | P3C §三 / §四 |
  570 +| R-20.2 | 两级结构之上做**排队与并行**;推翻 P3「拒绝而不排队」,必须同时交付调度器(四个触发点)| P3C §五 |
  571 +| R-20.3 | goal / task 描述**一经下达不可改**(artifact 已被 agent 消费,改库等于篡改审计输入)| P3C §四.4 |
  572 +| R-20.4 | 新增 run status **`queued`**,出队原子性靠既有唯一索引兜底 | P3C §四.3 |