From 80eccbc9979532bba15c66f8deac2baf1ec2b526 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 18 Aug 2026 13:59:42 +0800 Subject: [PATCH] docs(27-P3C): 公司任务拆 goal / task 两级 —— 排队、并行与控制台任务列表 --- docs/todo/system-design/27-p3c-goal-task-model.md | 420 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/todo/system-design/27-requirement.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 466 insertions(+), 0 deletions(-) create mode 100644 docs/todo/system-design/27-p3c-goal-task-model.md diff --git a/docs/todo/system-design/27-p3c-goal-task-model.md b/docs/todo/system-design/27-p3c-goal-task-model.md new file mode 100644 index 0000000..4110ef2 --- /dev/null +++ b/docs/todo/system-design/27-p3c-goal-task-model.md @@ -0,0 +1,420 @@ +# 27-P3C. 公司目标与任务 —— goal / task 两级模型 + 控制台任务列表 + +> **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 P3 收尾,直接续 [27-P3/P4 执行控制台](./27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](./27-p3b-real-run-runway.md)。 +> **日期**: 2026-08-18 +> **交付物**: ① `goals` / `tasks` 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的**排队与并行**;③ 执行控制台(`/:id/graph-console`)最左侧新增**目标·任务两级列表栏**。 +> **命名**: 全文遵循 [27-NAMING](./27-naming-conventions.md),本文档**落地**的正是它 §四 定义但代码里一直没有载体的 `goal 1 : task N` 基数关系。 +> **代码引用体例**: 沿用 27-P3/P4 头部约定 —— 只写 `文件名` + 函数名 / 常量名,**不写行号**。 + +--- + +## 一、问题 + +用户的原话是「一个 company 在控制台下达任务只能有一个」。拆开是**三件事**: + +| # | 现象 | 真实成因 | 本次 | +|---|---|---|---| +| A | 历史任务**看不见、切不了、管不了** | 纯界面缺失。`GraphConsole.loadRuns()` 拿到了全部 root run,却只取 `roots.find(active) \|\| roots[0]`,其余全丢 | ✅ §七 | +| B | 一家公司**同时**只能跑一个,下第二个吃 409 | v1 硬约束:`work-graph-db` 的 `idx_graph_runs_one_active_root` 唯一索引 + `company.js` 的预检查。当时选「拒绝」而不是「排队」的理由写在代码注释里 —— **仓库里没有任何出队 worker,排进去的任务不会被消费,那是一条会静默丢任务的路** | ✅ §五 补上调度器,约束从「拒绝」改成「排队」 | +| C | 「人类提的需求」和「机器执行的单元」是**同一个东西** | `POST /api/company/:cid/tasks` 一次调用同时创造了需求、执行单元和执行实例三层语义,压在一条 root run 上。于是需求无法分解,也就无从并行 | ✅ §三 / §四 | + +C 是根因,A 和 B 是它的两个表征。**先拆两级模型,A/B 才有干净的解法。** + +--- + +## 二、与既有命名的对齐(先读这一节) + +### 2.1 这不是新概念,是给一个已存在的悬空外键补上它的表 + +[27-NAMING §四](./27-naming-conventions.md) 早已定义好基数: + +| 概念 | 基数 | 定义(原文) | +|---|---|---| +| **goal** | 一个人类需求描述 = **1 个** | 验收的边界。回答「这次交付到底算不算满足了人提的要求」 | +| **task** | 一个 goal 分解出 **N 个** | 图中的执行单元。**可并行**、可下钻 sub agent | +| **okr** | 跨多个 goal 的长周期度量树 | Objective / Key Result | + +而 `graph_runs` 里: + +```sql +goal_id TEXT, -- 全仓库**没有任何写入方**,只有 body.goal_id 透传 +task_id TEXT, -- 由 POST /:cid/tasks 现生成,无表、无唯一性保证 +``` + +`company.js` 那条注释写得明明白白:`goal_id?, okr_node_id?, 三者基数不同不可混用(goal 1 : task N : okr 跨 goal 度量树)`。**字段在、语义在、表不在。** 本文档做的就是把这两张缺席的表补上,不是发明新词。 + +### 2.2 「goal」一词的六个用法消歧(实现前必须分清) + +| 写法 | 指什么 | 载体 | 本次是否新增 | +|---|---|---|---| +| `company-goal` | 建公司时那句定性目标 | `company.json.goal` | 否 | +| **`goal`(本文档)** | **人类下达的一个需求 = 验收边界** | **新 `goals` 表,`G-xxx`** | ✅ 新增 | +| `task-goal` | **一个 task** 的验收边界(= 该 task 的描述) | `runs/{runId}/artifacts/task-goal.json` | 否,位置不变 | +| `subtask-goal` | work graph 内分支的验收边界 | `workgraph-planning` 拆分时产生 | 否 | +| OKR `Objective` | 长周期度量树,`O-xxx` | `data/companies/{cid}/okr/` | 否 | +| `goal-benchmark` / `goal-test-data` / `goal-test-script` | 三种**验收产物**(agent / 节点名) | work graph 节点 | 否 | + +> **注意 `task-goal` 的归属没变**:它是「一次 task 执行的验收边界」,artifact 本来就落在 `runs/{runId}/` 下 —— 也就是**每个 task 一份**。引入 goal 表后它的位置和语义都不用动,goal 的描述是它的**上级**,不是它的替代。 + +### 2.3 `G-` 前缀可以用,`/api/company/:cid/goals` 可以要回来 + +两处历史包袱都已经清干净,实地核实过: + +1. **`G-` 前缀已被 OKR 释放**。27-NAMING §六 的迁移把 OKR 第 1 层 `G-001` 正名为 `O-001`(`npm run okr:migrate` 已执行,且当时存量数据「几乎为空」)。所以新 `goals` 表用 `G-` 不与任何活数据冲突。 +2. **`/api/goals` 与 `/api/company/:cid/goals` 目前是 OKR 的向后兼容别名,但全仓库零调用方**。grep 的结果只有三行,全是别名注册语句本身(`index.js` / `agent-server.js` / `okr.js` 的注释)。**本次连带退役这两个别名**,把路径要回来给新概念 —— 一个词在同一套 API 里指两件事,是比破坏兼容更贵的债。 + +--- + +## 三、模型:goal → task → run 三级 + +``` +goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边界 + │ 描述一经下达不可改(§四.4) + └─ tasks (T-xxx) N 条。lemonboss 的执行单元,可排队、可并行、可有依赖 + │ 描述一经下达不可改(§四.4) + └─ graph_runs root run,M 条。一次**执行实例**(重试会产生第 2 条) + └─ 子 run 每个 org node 现规划出的 work graph,及更深的子图 +``` + +三级各自回答一个问题,不重叠: + +| 层 | 回答的问题 | 谁改它 | 状态机 | +|---|---|---|---| +| goal | 人想要什么?做到了没有? | 人 | `draft → active → done / cancelled / archived` | +| task | 这个需求拆成哪几件事?谁先谁后? | 人(v1)/ `task-planning` agent(v2,§十一) | `draft → queued → running → completed / failed / blocked / cancelled` | +| run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) | + +**为什么 task 与 run 必须分开**(这是「不能直接把 task 当 root run」的三条硬理由): + +1. **排队态没有 run**。一个排在第 3 位的 task 还没有图、没有节点、没有预算消耗 —— 硬塞一条 run 行进去,`graph_run_nodes` 是空的,所有按 run 遍历节点的代码都要加空判。 +2. **重试是 task 1 : run N**。今天「重跑一遍这个任务」只能新建一条 run,于是两次执行在数据上毫无关联;有了 task 表,`runs` 挂在 task 下,「这个任务试了 3 次」才说得出来。 +3. **依赖与并行是 task 之间的关系**,不是 run 之间的。`depends_on` 只有落在稳定的 task id 上才有意义 —— run id 每次重试都变。 + +--- + +## 四、数据模型 + +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 | active | done | cancelled | archived + priority TEXT NOT NULL DEFAULT 'P1', + okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1) + budget_json TEXT, -- 整个 goal 的总预算;不填则每个 task 各用默认 + created_by TEXT, -- 'human' | agent id(Master/CEO 代下单) + 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); +``` + +`okr_node_id` 放在 goal 上而不是 task 上:OKR 度量的是「人的需求达成了没有」,那正是 goal 的粒度。task 级的进度靠 goal 汇总,不各自往 OKR 上写(会重复计数)。 + +### 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 + 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 上能记下「何时入队」这条审计事实。 + +状态机全貌: + +``` +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)` 是新表,可以是真外键,直接写上。 + +--- + +## 五、排队与并行 + +### 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` 的 task,按 `priority` → `seq` → `created_at` 排序,取前 `max - n` 条。 +5. 逐条出队:`UPDATE graph_runs SET run_status='ready' WHERE id=? AND run_status='queued'` → 受影响行数为 0 说明被并发抢走或唯一索引拒绝,**跳过继续下一条**,不报错。成功则 `markRunReady` 建节点行 → `startRun(mode)`。 + +**触发点四个**(少一个就会有任务永远排在队里 —— 这正是当初拒绝做排队的那条理由,必须逐个接上): + +| # | 触发 | 接在哪 | 补的是什么洞 | +|---|---|---|---| +| 1 | 任务入队时 | `POST /tasks` 尾部 | 队伍空时立即起跑 | +| 2 | **run 收敛时** | `graph-engine.settleRun` 转终态处 | 前一个跑完自动带起下一个 —— 队列的主动力 | +| 3 | 服务启动时 | `server/index.js` 现有的「中断任务恢复」旁边 | 进程重启丢掉的唤醒 | +| 4 | 60s 兜底定时器 | 同 3 | 前三条任意一条漏了都还能自愈 | + +> ponytail: 触发 2 挂在 `settleRun` 上是**唯一**能覆盖 manual / auto / blocked 三种收敛路径的位置(`reportRunOutcome` 只挂得上 auto 的 driver —— 27-P3/P4 已经记过这个坑)。升级路径:将来有跨公司的全局配额时,`tick` 要从「按公司」改成「按全局配额池」。 + +### 5.2 并发上限与唯一索引 + +`company.json` 加一个字段(不新建表,`companyService.get` 已经在读这个文件): + +```jsonc +{ "max_parallel_tasks": 1 } // 缺省 1 = 与今天完全同行为 +``` + +- **`max = 1`(默认、批 2 上线时的形态)**:`idx_graph_runs_one_active_root` 保留不动,DB 层的守卫还在,行为与今天一致,只是「拒绝」变成了「排队」。 +- **`max > 1`(批 4)**:这个唯一索引必须 `DROP`,否则第二条并行任务在 `queued → ready` 时被索引拒掉。替代守卫是 §五.1 第 2-5 步**整体包在一个 `better-sqlite3` 事务里**(`db.transaction(...)`)—— SQLite 是单写者,写事务天然序列化,「count 完再 admit」在这里是真原子的,不是应用层的乐观检查。 + +**批 4 单独排期、单独验收**,理由:删掉一个唯一索引 = 删掉最后一道 DB 兜底,这个动作不该和「界面上能看到任务列表」混在一次上线里。 + +--- + +## 六、API + +全部挂在 `server/routes/company.js`。错误响应沿用现有 `{ error, hint, ... }` 形态。 + +### 6.1 先做一件清理:退役 OKR 的 `/goals` 别名 + +`index.js` / `agent-server.js` 里 `app.use('/api/goals', require('./routes/okr'))` 与 `company.js` 的 `okrPath()` 里那条 `/:companyId/goals${suffix}` 一并删除。§二.3 已核实**零调用方**。删完 `/goals` 这个词在整套 API 里只剩一个含义。 + +### 6.2 goal + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/api/company/:cid/goals` | 列表。query: `status` / `includeArchived` / `withTasks=1`(带出每个 goal 的 task 摘要,列表页一次拉完不 N+1) | +| `POST` | `/api/company/:cid/goals` | 新建。body `{ title, description, priority?, okr_node_id?, budget?, tasks?: [...], dispatch?: bool }`。**不传 `tasks` 时自动生成 1 条同描述的 task**(`seq=1`,`depends_on=[]`)—— 保证「只想下达一句话」的最短路径仍是一次调用。`dispatch: true` 则连带把这些 task 入队 | +| `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` | **改造既有语义**:现在返回 task 表的行(含 `goalId` / `status` / `progress` / `runId`),而不是隐式的 root run 列表 | +| `POST` | `/api/company/:cid/tasks` | **保留、行为兼容**:不带 `goal_id` 调用时,**自动建一个同描述的 goal**(`created_by` 记调用方)再挂一条 task —— 存量调用方(含 `DispatchTaskButton`、外部 agent 的 curl)一行不用改就还能用,响应体里多出 `goalId` 字段。带 `goal_id` 则挂到既有 goal 下 | +| `PATCH` | `/api/company/:cid/tasks/:taskId` | 改 `title` / `priority`;`depends_on` 仅 `draft` 可改;`queued_at` 非空时带 `description` / `mode` / `budget` 一律 `409` | +| `POST` | `/api/company/:cid/tasks/:taskId/enqueue` | 单条入队:建 `queued` 的 run 行 + 写 `task-goal.json` + `tick()` | +| `POST` | `/api/company/:cid/tasks/:taskId/cancel` | `queued` → 撤出队列(run `queued → stopped`);`running` → 转调既有 `POST /api/graph-runs/:id/stop` | +| `POST` | `/api/company/:cid/tasks/:taskId/retry` | 从 `failed` / `blocked` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里) | +| `DELETE` | `/api/company/:cid/tasks/:taskId` | 归档,同 goal 的规则 | + +**`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段。 + +### 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 模式的成本预警(原样保留)+ **「拆成多个任务」折叠区**(v1 就是一个可增删的行列表,每行 `描述 + 依赖前一条 ☑`)。主按钮「下达」,次按钮「存草稿」。 + +`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 自动起跑**(验证触发点 2),全程无人工干预。 +3. `max_parallel_tasks=1` 下建两条无依赖 task → 第二条排队而**不是 409**,`queuePosition=2`。 +4. 存量调用方兼容:`curl POST /api/company/:cid/tasks -d '{"task":"…"}'`(不带 `goal_id`)仍返回 201,且自动生成了一个 goal。 +5. 改已下达 goal 的 `description` → `409` + hint;改 `title` → 200。task 同理。 +6. `depends_on` 写成环 → `400`,库里没有半条脏数据。 +7. 取消一个 `queued` task → run `queued → stopped`,队列后面的顶上来。 +8. `failed` 的 task 点重试 → `attempt=1`,新起一条 run,**旧 run 仍能在历史里点开**。 +9. 杀掉服务再起(模拟进程重启)→ 队列里的 task 被触发点 3 捡起来,不需要人工干预。 +10. 列表端点在 20 goal × 3 task 的公司上**不 N+1**(`graph_run_nodes` 的进度用一次 `GROUP BY` 出)。 +11. `/api/goals` 与 `/api/company/:cid/goals` 的 OKR 别名退役后,`OkrDashboard` / `CompanyDashboard` / `bin/lemon.js` 全部功能正常(它们走的是 `/api/okr`)。 +12. 全量 `npx vitest run` 不掉,重点是 §九.1 那批回归。 + +--- + +## 九、风险与已知取舍 + +### 1. `queued` 混进 `run_status` 取值域 —— 本次最大的回归面 + +新增一个状态值,所有按 `run_status` 分支的地方都要逐个确认 `queued` 不被误当可执行。实现时必须挨个看过并补测试: + +| 位置 | 期望 | +|---|---| +| `graph-engine.advanceOne` / `startRun` | `queued` run 直接拒绝(没有 `graph_run_nodes`,进去就是空指针) | +| `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` | 同上 | + +### 2. 「排队」这条路当初被明确否决过,现在要对它负责 + +`company.js` 的注释原话:*选拒绝的理由是,仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路*。**这条判断在今天仍然成立** —— 它只是被 §五.1 的四个触发点解除。所以那四个触发点是本次的**功能主体**,不是收尾工作;验收 2 和 9 专门验它们。触发点接漏了,症状就是任务静默躺在队里,而界面上它长得跟「正在排队」一模一样。 + +### 3. `description` 双写(DB + `task-goal.json`) + +两份都在入队那一刻写、之后都不可变(§四.4)——结构上不可能不一致。这正是「已下达不给改」的真正理由,而不是保守。 + +### 4. 归档只是隐藏 + +`data/companies/{cid}/runs/{runId}/` 仍在磁盘上。本次不做 GC;需要时另立清理任务(按 `archived_at` 早于 N 天)。 + +### 5. 两套 `T-` id + +`agent-chat.js` 的 tasklog 也发 `T-` 开头的 id,本次不动它。两者从此**长得像但不同源**,读日志时别混;本文档的 task id 一律带 `-{4hex}` 后缀,肉眼可分。 + +### 6. 并行是最后一批,且要单独验 + +批 4 会 `DROP` `idx_graph_runs_one_active_root` —— 删掉最后一道 DB 兜底。守卫改成事务内 count + admit(SQLite 单写者天然序列化)。不与界面改动同批上线。 + +--- + +## 十、实施顺序 + +| 批 | 内容 | 验收 | +|---|---|---| +| **1 · 两级模型落库** | `goals` / `tasks` 建表 + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 | +| **2 · 控制台左栏** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + 新建表单(含拆分任务)| 1 / 12 | +| **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `task-scheduler` 四个触发点 + `depends_on` 环检测 + 入队/取消/重试端点 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 | +| **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 | + +批 1+2 上线就已经解决用户提的核心痛点(历史任务看不见、需求无法分解);批 3 才是「不再只能有一个」。 + +--- + +## 十一、开放问题 + +1. **谁把 goal 拆成 task?** v1 是人手拆(表单里加行)。v2 建议加一个 **`task-planning`** agent(命名依 27-NAMING §二「`{产出物}-planning`」,产出物是 task 列表)—— 它读 goal 描述 + 公司 org graph,产出带 `depends_on` 的 task DAG。**本次不做,但表结构已经为它留好**(`depends_on` / `seq` / `created_by`)。 +2. **goal 级的验收谁做?** 现在 `org-validator` 验的是 task 级(`task-goal`)。一个 goal 的 5 个 task 全 `completed`,不等于人的需求达成了。要不要在 goal 收敛时再跑一次 goal 级验收(`goal-benchmark` 天然就是干这个的)?倾向做,但需要先定「goal 级验收的输入是什么」—— 是 N 个 task 的产物合集吗?留到 P5。 +3. **失败的 task 挡不挡住依赖它的下游?** 当前设计:`depends_on` 只认 `completed`,所以上游 failed 会让下游**永远排队**。需要一个显式的「跳过依赖强制入队」操作,还是把这类 task 自动转 `blocked`?倾向后者(自动转 `blocked` 并在界面标红),但要防止级联误判。 +4. **跨公司任务总览**。`/api/tasks` + `TaskTimeline` 是从会话反推的另一套任务树,与本文档的 `tasks` 表同名不同源。将来是把它改成读这张表,还是并存?本次不碰,但这是个明确待收的口子。 +5. **`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?** v1 选前者(`companyService.get` 已经在读那个文件,零新增读路径)。公司级配置项超过 3 个时再考虑建表。 diff --git a/docs/todo/system-design/27-requirement.md b/docs/todo/system-design/27-requirement.md index 0165adc..dfb4fb9 100644 --- a/docs/todo/system-design/27-requirement.md +++ b/docs/todo/system-design/27-requirement.md @@ -490,6 +490,48 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file --- +## R-20 · 公司任务是 goal / task 两级,可排队可并行 + +起于执行控制台走查。用户原话: + +> 我们看一下,一个 company 在控制台下达任务只能有一个?应该有一个任务列表,能够对任务的增删改查,然后点击任务后看到任务对应的 org graph,work graph 以及执行情况。所以需要在 `/:id/graph-console` 的最左侧再增加一列任务列表,新增任务即可。 + +第一版设计把「任务」直接等同于 root `graph_run`(理由是库里 `parent_run_id IS NULL` 的每一行本来就是一次任务,只是界面没入口),被用户否掉,改为下面四条。 + +### R-20.1 · 建 goal 表与 task 表,不复用 root run(原话) + +> 还是新建一个任务表吧,company 层面人类下达的任务应该是 goal 表,goal 可以分解为多个 task,让 lemonboss 执行。 + +**这不是新增概念,是补上一张缺席的表。** [27-NAMING §四](./27-naming-conventions.md) 早就定义了 `goal 1 : task N` 的基数(goal = 一个人类需求描述 = 验收边界;task = 图中的执行单元,可并行),`graph_runs` 里 `goal_id` / `task_id` 两列也早就在,`company.js` 的注释还写着「三者基数不同不可混用」——**但全仓库没有任何写入方,`goal_id` 是一个悬空外键**。 + +顺带确认两处历史包袱已清干净,新概念可以直接用回这两个名字:`G-` 前缀已被 R-11 的 OKR 正名释放(`G-001` → `O-001`,迁移已执行);`/api/goals` 与 `/api/company/:cid/goals` 目前是 OKR 的向后兼容别名,但**全仓库零调用方**,本次连带退役。 + +### R-20.2 · 两级结构之上做排队与并行(原话) + +> 有个 goal 表,task 表结构后,可以形成排队,或者并行 + +**这一条推翻了 P3 当初「拒绝而不排队」的定案。** 原定案的理由写在 `company.js` 的注释里 ——「仓库里没有任何出队/唤醒的 worker,排进去的任务不会被自动消费,那是一条会静默丢任务的路」。**这个判断本身今天仍然成立**,所以 R-20.2 的实质内容不是「加两张表」,而是**必须同时交付调度器**:入队时 / run 收敛时 / 服务启动时 / 60s 兜底,四个触发点少接一个,任务就会静默躺在队里,而界面上它长得跟「正在排队」一模一样。 + +并行是最后一批:`max_parallel_tasks > 1` 要 `DROP` `idx_graph_runs_one_active_root`,等于删掉最后一道 DB 兜底,守卫改成事务内 count + admit(SQLite 单写者天然序列化),单独排期单独验。 + +### R-20.3 · 已下达的描述不可改(原话) + +> 已下达的 goal 不给改描述,task 已下达也不给改。 + +**理由不是保守,是数据一致性的硬要求**:task 的描述在入队那一刻就写进 `runs/{runId}/artifacts/task-goal.json`,而 `describeTask` 等 agent 侧链路读的是 artifact。改库不改 artifact 会让两份记录对不上;两份都改则等于篡改**已被 agent 消费过的审计输入**。要改就取消后新建。`title` / `priority` 仍可改 —— 它们不进任何 agent 的上下文。 + +### R-20.4 · 新增一个 run status(原话) + +> 新增一个 run status + +定为 **`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'`」这一步,超发的那条会被唯一索引直接拒绝,不需要应用层的「先查后插」(那中间永远有竞态窗口)。 + +顺带三个收益:入队即有稳定 `run_id`(可分享的控制台链接)、`task-goal.json` 的落盘路径在入队时就确定(描述冻结点与 artifact 落盘点重合,正是 R-20.3 要的)、timeline 上记得下「何时入队」这条审计事实。 + +**落点**:[27-P3C goal / task 两级模型](./27-p3c-goal-task-model.md)(本条派生出的工作项文档)。 + +--- + ## 附 · 需求 → 设计落点 速查 | 需求 | 一句话 | 主要落点 | @@ -524,3 +566,7 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file | R-19.2 | **建 sqlite 表而不是 files.json**(JSON 会重演 _registry.json 的并发重写与 git 冲突) | P4 §4.6 | | R-19.3 | **两类数据流都要**(沿边传递 + 任务级共享);workspace 根**代码推导**不入库;路径走 `tasks/{taskId}/` | P4 §4.6 | | R-19.4 | 边存**契约(kind)**、files 表存**实例(path)**,规划/运行两阶段 → 引擎可在跑之前校验「声明要吃的输入没人产」 | P4 §4.6 | +| R-20.1 | **公司任务拆 goal / task 两级**,落地 NAMING §四 早已定义、代码里一直没有载体的 `goal 1 : task N` | P3C §三 / §四 | +| R-20.2 | 两级结构之上做**排队与并行**;推翻 P3「拒绝而不排队」,必须同时交付调度器(四个触发点)| P3C §五 | +| R-20.3 | goal / task 描述**一经下达不可改**(artifact 已被 agent 消费,改库等于篡改审计输入)| P3C §四.4 | +| R-20.4 | 新增 run status **`queued`**,出队原子性靠既有唯一索引兜底 | P3C §四.3 | -- libgit2 0.21.4