Commit 2abb08205a3683f6866ce3401b7ab7e50665bf30

Authored by yibo
1 parent f5e1a220

docs(27-P3C): 五条收尾定案 —— goal 验收用 org-validator,两套任务树统一

R-20.6:
1. 退役 /api/goals 与 /api/company/:cid/goals 两个 OKR 别名,确认执行
2. goal 级验收仍用 org-validator,在 goal 层再调一次同一个本体。
   不复用 task 级结论 —— 两次验的是基数不同的东西:task 级问
   「这一个 task 的 task-goal 达成没有」,goal 级问「N 个 task
   合起来人要的东西做出来没有」。单 task 的 goal 跳过这次调用。
   goal 状态机加 failed,判词落 goals.verdict
3. 预算先不考虑,不做 goal 级闸门;但 planning_tokens /
   validation_tokens 两个记账字段保留 —— 它们不是闸门,是让
   goal 层那两次 agent 调用的花费可见
4. queued 开放改 depends_on。当初担心的「改完位次怎么算」不成立:
   queuePosition 只按 priority→seq→created_at 排,depends_on 决定
   的是够不够格出队,两者正交
5. 两套任务体系统一到 tasks 表,新增 §十二(批 5)

定案 5 核实出一个反直觉的前提:两套不是并列的,新体系的回调链路
长在旧体系身上 —— reportRunOutcome 收敛后调的正是
/api/agent/task/report。所以「删掉另一套」= 删存储与会话反推
(system/tasklog + TaskService.getAggregatedTasks),不能删入口
与回调:/task/dispatch 的 URL 与请求体写在 tools-base.md 里,
改它等于同时改所有 agent 的提示词。

tasks 表为此吸收三个字段(executor / assignee_agent_id /
parent_task_id):agent 派单与 graph 执行的载体确实不同,
硬说成一回事是骗自己;调度器只管 executor='graph'。
顺带修好:派单去重从扫目录变一条 SQL,两套 T- id 合一。

开放问题 2/4/6/7 全部关闭,只剩 5(max_parallel 放哪)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3c-goal-task-model.md
@@ -18,6 +18,9 @@ @@ -18,6 +18,9 @@
18 > 18 >
19 > **第三轮(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)。 19 > **第三轮(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)。
20 > 定案 ① 连带牵出一处新缺陷:**`task-planning` 跑在 goal 层、任何 run 存在之前,它烧的 token 没有预算池可扣** —— 正是 `defaultPlanWorkGraph` 注释里点名的「隐形的一半花费」的新实例。上一轮刚以「零消费方」为由砍掉的 goal 级预算字段,因此以记账字段 `planning_tokens` 的形式回来(§四.1)。 20 > 定案 ① 连带牵出一处新缺陷:**`task-planning` 跑在 goal 层、任何 run 存在之前,它烧的 token 没有预算池可扣** —— 正是 `defaultPlanWorkGraph` 注释里点名的「隐形的一半花费」的新实例。上一轮刚以「零消费方」为由砍掉的 goal 级预算字段,因此以记账字段 `planning_tokens` 的形式回来(§四.1)。
  21 +>
  22 +> **第四轮(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)。
  23 +> 定案 ⑤ 核实后有个反直觉的前提必须先说清:**两套不是并列的,A 的回调链路长在 B 身上** —— `reportRunOutcome` 收敛后调的正是 `/api/agent/task/report`。所以「删掉另一套」= 删**存储与会话反推**,**不能**删入口与回调(`/task/dispatch` 的 URL 与请求体写在 `tools-base.md` 里,改它等于同时改所有 agent 的提示词)。开放问题 2 / 4 / 6 / 7 全部就此关闭。
21 24
22 --- 25 ---
23 26
@@ -93,7 +96,7 @@ goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边 @@ -93,7 +96,7 @@ goals (G-xxx) 人类下达。一条 = 一个需求 = 一条验收边
93 96
94 | 层 | 回答的问题 | 谁改它 | 状态机 | 97 | 层 | 回答的问题 | 谁改它 | 状态机 |
95 |---|---|---|---| 98 |---|---|---|---|
96 -| goal | 人想要什么?做到了没有? | 人 | `draft → planning → active → done / cancelled / archived` | 99 +| goal | 人想要什么?做到了没有? | 人 + **goal 级 `org-validator`**(§四.10)| `draft → planning → active → done / failed / cancelled / archived` |
97 | task | 这个需求拆成哪几件事?谁先谁后? | **`task-planning` agent 拆、人可改**(§四.9)| `draft → queued → running → completed / failed / blocked / cancelled` | 100 | task | 这个需求拆成哪几件事?谁先谁后? | **`task-planning` agent 拆、人可改**(§四.9)| `draft → queued → running → completed / failed / blocked / cancelled` |
98 | run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) | 101 | run | 这一次跑成什么样? | 引擎 | 既有 `RUN_TRANSITIONS` + 新增 `queued`(§四.3) |
99 102
@@ -120,13 +123,16 @@ CREATE TABLE IF NOT EXISTS goals ( @@ -120,13 +123,16 @@ CREATE TABLE IF NOT EXISTS goals (
120 title TEXT NOT NULL, -- 短标题,列表显示用;可改 123 title TEXT NOT NULL, -- 短标题,列表显示用;可改
121 description TEXT NOT NULL, -- 人类需求原文 = 验收边界;下达后不可改 124 description TEXT NOT NULL, -- 人类需求原文 = 验收边界;下达后不可改
122 status TEXT NOT NULL DEFAULT 'draft', 125 status TEXT NOT NULL DEFAULT 'draft',
123 - -- draft | planning | active | done | cancelled | archived 126 + -- draft | planning | active | done | failed | cancelled | archived
124 -- planning = task-planning 正在拆分(§四.9),是个真会持续几十秒的状态 127 -- planning = task-planning 正在拆分(§四.9),是个真会持续几十秒的状态
  128 + -- failed = goal 级验收没过,或有 task 未能 completed(§四.10);可回 active 补 task 重下
125 priority TEXT NOT NULL DEFAULT 'P1', 129 priority TEXT NOT NULL DEFAULT 'P1',
126 okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1) 130 okr_node_id TEXT, -- 可选,挂到 OKR 度量树的哪个节点(O-001.1.1)
127 created_by TEXT, -- 'human' | agent id(Master/CEO 代下单) 131 created_by TEXT, -- 'human' | agent id(Master/CEO 代下单)
128 report_to TEXT, -- 收敛后要唤醒的上级 agent,见 §四.7 132 report_to TEXT, -- 收敛后要唤醒的上级 agent,见 §四.7
129 - planning_tokens INTEGER NOT NULL DEFAULT 0, -- task-planning 这一次拆分烧掉的 token,见 §四.9 133 + planning_tokens INTEGER NOT NULL DEFAULT 0, -- task-planning 拆分烧掉的 token(§四.9)
  134 + validation_tokens INTEGER NOT NULL DEFAULT 0, -- goal 级 org-validator 烧掉的 token(§四.10)
  135 + verdict TEXT, -- goal 级验收判词(org-validator 的原话),§四.10
130 created_at TEXT NOT NULL, 136 created_at TEXT NOT NULL,
131 updated_at TEXT NOT NULL, 137 updated_at TEXT NOT NULL,
132 dispatched_at TEXT, -- 首次下达时间;非空 = 描述已冻结 138 dispatched_at TEXT, -- 首次下达时间;非空 = 描述已冻结
@@ -311,6 +317,42 @@ AgentManager 调 task-planning(输入:goal.description + 公司 org graph @@ -311,6 +317,42 @@ AgentManager 调 task-planning(输入:goal.description + 公司 org graph
311 317
312 `POST /goals` 带 `split: false` 时不调 agent,直接建 1 条同描述的 task。存量调用方(`POST /:cid/tasks`)走的就是这条路 —— **它们不该因为这次改动突然多花一次 LLM 调用**。 318 `POST /goals` 带 `split: false` 时不调 agent,直接建 1 条同描述的 task。存量调用方(`POST /:cid/tasks`)走的就是这条路 —— **它们不该因为这次改动突然多花一次 LLM 调用**。
313 319
  320 +### 4.10 goal 级验收 —— 仍然是 `org-validator`(用户定案)
  321 +
  322 +**不新造验收 agent**:goal 级用的还是 `org-validator` 这个本体,只是**在 goal 层再调一次**。
  323 +
  324 +#### 为什么是「再调一次」而不是复用 task 级那次的结论
  325 +
  326 +因为两次验的是**基数不同的东西**,这正是 27-NAMING §四 那张表的论证:
  327 +
  328 +| | 验什么 | 谁问的 |
  329 +|---|---|---|
  330 +| task 级(既有,org graph 末端节点)| 这**一个 task** 的 `task-goal` 达成了没有 | 引擎,每个 task 各一次 |
  331 +| **goal 级(本次新增)** | **N 个 task 合起来,人提的那个需求达成了没有** | goal 收敛时,一次 |
  332 +
  333 +5 个 task 全 `completed`,只说明每一件事都按各自的描述做完了 —— 不说明拼起来是人要的东西。这个缺口正是 goal 层存在的理由,用别的东西替代不了。
  334 +
  335 +#### 触发与判定
  336 +
  337 +```
  338 +goal 下所有 task 到终态
  339 + ├─ 有任何一条 ≠ completed → goal 直接 failed,不调 agent(省一次调用:显然没交付)
  340 + ├─ 全 completed 且只有 1 条 task → 跳过 goal 级验收,直接采用该 task 的验收结论
  341 + │ (单 task 的 goal,两级验收是同一件事,再调一次纯浪费)
  342 + └─ 全 completed 且 ≥2 条 task → 调一次 org-validator
  343 + 输入:goal.description(= 验收边界)+ 该 goal 全部 task 的产物索引
  344 + (`graph_files` 按 task_id 查,§P4 §4.6 的表已经能给出这份清单)
  345 + 产出:pass / fail + 判词 → goals.status = done / failed,判词落 goals.verdict
  346 +```
  347 +
  348 +调用形态与 `task-planning` 相同:`AgentManager` 直调本体,不在任何 graph run 里,token 记 `goals.validation_tokens`。
  349 +
  350 +#### 挂在哪
  351 +
  352 +和调度器同一个位置:task 转终态时顺手判一次「这个 goal 的 task 是不是都到齐了」。**`settleGoal(goalId)` 必须幂等**(并行完成的多个 task 会同时触发它),用 `goals.status` 的条件更新做闸门 —— 与 §五.1 出队用的是同一招。
  353 +
  354 +> goal 收敛之后才轮到 §四.7 的两件事:写 OKR 节点(一次,用 goal 的判定结果)、唤醒 `report_to`。**这两件事从 run 级上移到 goal 级**,禁令一说的「task 的 run 一律不带 `okr_node_id`」在这里闭环。
  355 +
314 --- 356 ---
315 357
316 ## 五、排队与并行 358 ## 五、排队与并行
@@ -440,7 +482,7 @@ for (const t of queuedTasks) { @@ -440,7 +482,7 @@ for (const t of queuedTasks) {
440 |---|---|---| 482 |---|---|---|
441 | `GET` | `/api/company/:cid/tasks` | **新端点**(核实过:今天这个路径下只有 `POST`,没有 `GET`,不存在兼容问题)。返回 task 表的行,含 `goalId` / `status` / `queuePosition` / `progress` / `runId` | 483 | `GET` | `/api/company/:cid/tasks` | **新端点**(核实过:今天这个路径下只有 `POST`,没有 `GET`,不存在兼容问题)。返回 task 表的行,含 `goalId` / `status` / `queuePosition` / `progress` / `runId` |
442 | `POST` | `/api/company/:cid/tasks` | **保留、行为兼容**:不带 `goal_id` 调用时,**自动建一个同描述的 goal**(`created_by` 记调用方)再挂一条 task —— 存量调用方(含 `DispatchTaskButton`、外部 agent 的 curl)一行不用改就还能用,响应体里多出 `goalId` 字段。带 `goal_id` 则挂到既有 goal 下 | 484 | `POST` | `/api/company/:cid/tasks` | **保留、行为兼容**:不带 `goal_id` 调用时,**自动建一个同描述的 goal**(`created_by` 记调用方)再挂一条 task —— 存量调用方(含 `DispatchTaskButton`、外部 agent 的 curl)一行不用改就还能用,响应体里多出 `goalId` 字段。带 `goal_id` 则挂到既有 goal 下 |
443 -| `PATCH` | `/api/company/:cid/tasks/:taskId` | 改 `title` / `priority`;`depends_on` 仅 `draft` 可改;`queued_at` 非空时带 `description` / `mode` / `budget` 一律 `409` | 485 +| `PATCH` | `/api/company/:cid/tasks/:taskId` | 改 `title` / `priority`;**`depends_on` 在 `draft` 与 `queued` 都可改**(用户定案,见下);`queued_at` 非空时带 `description` / `mode` / `budget` 一律 `409` |
444 | `POST` | `/api/company/:cid/tasks/:taskId/enqueue` | 单条入队:建 `queued` 的 run 行 + 写 `task-goal.json` + `tick()` | 486 | `POST` | `/api/company/:cid/tasks/:taskId/enqueue` | 单条入队:建 `queued` 的 run 行 + 写 `task-goal.json` + `tick()` |
445 | `POST` | `/api/company/:cid/tasks/:taskId/cancel` | `queued` → 撤出队列(run `queued → stopped`);`running` → 转调既有 `POST /api/graph-runs/:id/stop` | 487 | `POST` | `/api/company/:cid/tasks/:taskId/cancel` | `queued` → 撤出队列(run `queued → stopped`);`running` → 转调既有 `POST /api/graph-runs/:id/stop` |
446 | `POST` | `/api/company/:cid/tasks/:taskId/rerun` | 从 `failed` / `blocked` / `completed` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里)。**刻意不叫 `retry`** —— 见下 | 488 | `POST` | `/api/company/:cid/tasks/:taskId/rerun` | 从 `failed` / `blocked` / `completed` 重新入队,`attempt + 1`,**新起一条 run**(旧 run 保留在历史里)。**刻意不叫 `retry`** —— 见下 |
@@ -448,6 +490,11 @@ for (const t of queuedTasks) { @@ -448,6 +490,11 @@ for (const t of queuedTasks) {
448 490
449 **`rerun` ≠ `retry`,两者会撞车。** 既有的 `POST /api/graph-runs/:id/nodes/:nodeId/retry` 是**节点级重试**:同一条 run 内把某个节点打回 `ready` 重跑(`blocked` 的唯一出路,`NODE_TRANSITIONS` 里写死的)。新加的是**任务级重跑**:新起一条 run,整张图从头来。两个都叫 retry,实现时必然有人把新端点接到旧逻辑上(或反过来)。所以任务级一律叫 `rerun`,文档、API、界面按钮文案(「重跑这个任务」vs 节点详情里的「重试这一步」)三处同时区分。 491 **`rerun` ≠ `retry`,两者会撞车。** 既有的 `POST /api/graph-runs/:id/nodes/:nodeId/retry` 是**节点级重试**:同一条 run 内把某个节点打回 `ready` 重跑(`blocked` 的唯一出路,`NODE_TRANSITIONS` 里写死的)。新加的是**任务级重跑**:新起一条 run,整张图从头来。两个都叫 retry,实现时必然有人把新端点接到旧逻辑上(或反过来)。所以任务级一律叫 `rerun`,文档、API、界面按钮文案(「重跑这个任务」vs 节点详情里的「重试这一步」)三处同时区分。
450 492
  493 +**`queued` 状态开放改依赖**(用户定案)。真实场景是「B 已经排上了,才发现它得等 C」,此前只能取消重建、丢掉排队位次。三条细则:
  494 +- 环检测照跑(只能引用同 goal 内的 task),有环 `400`,库里不留半条脏数据;
  495 +- **位次不受影响**:`queuePosition` 只按 `priority → seq → created_at` 排,`depends_on` 决定的是「够不够格出队」,两者正交 —— 当初担心的「改完位次怎么算」不成立;
  496 +- 新加的依赖若已经 `failed` / `cancelled`,下一次 `tick` 会自然把它推导成 `blocked`(§五.1.2),不需要额外处理。
  497 +
451 **`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段 —— **它的排序口径必须与调度器第 4 步逐字一致**(`priority → seq → created_at`),两处各写一份的下场已经有先例:`canDispatch` 就是因为前端自己复述了一遍取值域、还多写了一个 `blocked`,导致公司失败一次后再也下不了任务。**口径只留调度器一处,端点调用它算位置。** 498 **`POST /:cid/tasks` 的 409 消失了**:并发冲突不再是错误,是排队。响应体改为 `201 { taskId, goalId, runId, status: 'queued', queuePosition: 2 }`。`queuePosition` 是这次改动里对用户最直观的那个字段 —— **它的排序口径必须与调度器第 4 步逐字一致**(`priority → seq → created_at`),两处各写一份的下场已经有先例:`canDispatch` 就是因为前端自己复述了一遍取值域、还多写了一个 `blocked`,导致公司失败一次后再也下不了任务。**口径只留调度器一处,端点调用它算位置。**
452 499
453 ### 6.4 不动的端点 500 ### 6.4 不动的端点
@@ -585,9 +632,9 @@ for (const t of queuedTasks) { @@ -585,9 +632,9 @@ for (const t of queuedTasks) {
585 2. **拆分质量决定预算消耗**。拆出 5 条 task 就是 5 份 `DEFAULT_TASK_BUDGET`(各 100 次调用 / 500 万 token)。这是「产出一律 `draft`、必须人确认」的根本理由 —— 那一步是唯一一道成本闸门。界面上要显式提示「N 个任务 ≈ N × 单任务成本」。 632 2. **拆分质量决定预算消耗**。拆出 5 条 task 就是 5 份 `DEFAULT_TASK_BUDGET`(各 100 次调用 / 500 万 token)。这是「产出一律 `draft`、必须人确认」的根本理由 —— 那一步是唯一一道成本闸门。界面上要显式提示「N 个任务 ≈ N × 单任务成本」。
586 3. **新本体不登记 `_registry.json` 会直接把 CI 跑红**。27-P3/P4 §八.21 有前科(六个本体建了目录没登记,`template-refs.test.js` 当场红)。建目录与登记必须在同一个 commit 里。 633 3. **新本体不登记 `_registry.json` 会直接把 CI 跑红**。27-P3/P4 §八.21 有前科(六个本体建了目录没登记,`template-refs.test.js` 当场红)。建目录与登记必须在同一个 commit 里。
587 634
588 -### 5. 两套 `T-` id 635 +### 5. 两套 `T-` id —— 由 §十二 的统一消解,但在那之前是真风险
589 636
590 -`agent-chat.js` 的 tasklog 也发 `T-` 开头的 id,本次不动它。两者从此**长得像但不同源**,读日志时别混;本文档的 task id 一律带 `-{4hex}` 后缀,肉眼可分。 637 +`agent-chat.js` 的 `/task/dispatch` 也发 `T-` 开头的 id 并写 `system/tasklog/`。**批 1-4 期间两套并存**,长得像但不同源,读日志时别混(本文档的 id 一律带 `-{4hex}` 后缀,肉眼可分)。批 5(§十二)把存储统一到 `tasks` 表之后,这个风险随之消失 —— 那时 `T-` 只有一个来源。
591 638
592 ### 6. 并行是最后一批,且要单独验 639 ### 6. 并行是最后一批,且要单独验
593 640
@@ -603,6 +650,7 @@ for (const t of queuedTasks) { @@ -603,6 +650,7 @@ for (const t of queuedTasks) {
603 | **2 · 控制台左栏 + 拆分** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + **`task-planning` 本体(含 `_registry.json` 登记)+ 拆分链路 + 失败降级**+ 两步表单 | 1 / 19 / 20 / 21 / 22 | 650 | **2 · 控制台左栏 + 拆分** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + **`task-planning` 本体(含 `_registry.json` 登记)+ 拆分链路 + 失败降级**+ 两步表单 | 1 / 19 / 20 / 21 / 22 |
604 | **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 | 651 | **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 |
605 | **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 | 652 | **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit | 另立验收 |
  653 +| **5 · 任务体系统一**(单独排期)| 见 §十二:`executor` / `assignee_agent_id` / `parent_task_id` 三字段 + `/task/dispatch` 换存储 + 删会话反推 + `tasklog` 回填 | 25-30 |
606 654
607 > 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。 655 > 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。
608 656
@@ -613,9 +661,76 @@ for (const t of queuedTasks) { @@ -613,9 +661,76 @@ for (const t of queuedTasks) {
613 ## 十一、开放问题 661 ## 十一、开放问题
614 662
615 1. ~~谁把 goal 拆成 task?~~ **已定案(2026-08-18):`task-planning` agent 进 v1**,产出 `draft` 任务清单由人确认后下达,详见 §四.9。 663 1. ~~谁把 goal 拆成 task?~~ **已定案(2026-08-18):`task-planning` agent 进 v1**,产出 `draft` 任务清单由人确认后下达,详见 §四.9。
616 -2. **goal 级的验收谁做?** 现在 `org-validator` 验的是 task 级(`task-goal`)。一个 goal 的 5 个 task 全 `completed`,不等于人的需求达成了。要不要在 goal 收敛时再跑一次 goal 级验收(`goal-benchmark` 天然就是干这个的)?倾向做,但需要先定「goal 级验收的输入是什么」—— 是 N 个 task 的产物合集吗?留到 P5。 664 +2. ~~goal 级的验收谁做?~~ **已定案(2026-08-18):仍然是 `org-validator`**,在 goal 层再调一次同一个本体,输入是该 goal 全部 task 的产物合集。详见 §四.10。
617 3. ~~失败的 task 挡不挡住依赖它的下游?~~ **已定案(2026-08-18):自动转 `blocked` 并在界面标红**,给「重跑上游」与「强制放行」两个出路;`blocked` 每次 `tick` 重算而不落死,上游恢复后下游自己回 `queued`。详见 §五.1.2。 665 3. ~~失败的 task 挡不挡住依赖它的下游?~~ **已定案(2026-08-18):自动转 `blocked` 并在界面标红**,给「重跑上游」与「强制放行」两个出路;`blocked` 每次 `tick` 重算而不落死,上游恢复后下游自己回 `queued`。详见 §五.1.2。
618 -4. **跨公司任务总览**。`/api/tasks` + `TaskTimeline` 是从会话反推的另一套任务树,与本文档的 `tasks` 表同名不同源。将来是把它改成读这张表,还是并存?本次不碰,但这是个明确待收的口子。 666 +4. ~~跨公司任务总览 / 两套任务树并存~~ **已定案(2026-08-18):统一到 `tasks` 表一套,删掉会话反推那套。** 详见 §十二(批 5)。
619 5. **`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?** v1 选前者(`companyService.get` 已经在读那个文件,零新增读路径)。公司级配置项超过 3 个时再考虑建表。 667 5. **`max_parallel_tasks` 放 `company.json` 还是建 `company_settings` 表?** v1 选前者(`companyService.get` 已经在读那个文件,零新增读路径)。公司级配置项超过 3 个时再考虑建表。
620 -6. **goal 级总预算怎么定口径?** §四.1 砍掉了 `goals.budget_json`,因为预算闸门只认 root run,goal 级没有执行机制。真要做,三选一:(a) 建 task 时按条数平分 goal 额度;(b) 预算池上移到 goal,`spendBudget` 从 `root_run_id` 改成上溯 `goal_id`(改动最大但语义最正);(c) 不做 goal 级,只在界面上把 N 个 task 的消耗加起来显示。**倾向 (c)** —— 一个 goal 的 task 数会变(rerun、后补 task),平分和总池都会让"还剩多少"变成一个解释不清的数。  
621 -7. **排队中的任务能不能改依赖?** 现在 `depends_on` 只有 `draft` 可改。但真实场景是「B 已经排上了,才发现它得等 C」—— 这时只能取消重建,会丢掉排队位次。要不要给 `queued` 开一个只改 `depends_on` 的口子?开的话要防「改出环」和「改完位次怎么算」。 668 +6. ~~goal 级总预算怎么定口径?~~ **已定案(2026-08-18):预算先不考虑,不做 goal 级闸门。** `goals.budget_json` 保持砍掉。**但 `planning_tokens` / `validation_tokens` 两个记账字段保留** —— 它们不是闸门,是让 goal 层那两次 agent 调用的花费**可见**;不记就是 `defaultPlanWorkGraph` 注释点名的「隐形的一半」,而且界面上「本次拆分消耗 ~N tokens」这个确认步骤要用它。
  669 +7. ~~排队中的任务能不能改依赖?~~ **已定案(2026-08-18):可以改。** `queued` 状态开放 `depends_on` 的 `PATCH`,见 §六.3。当初担心的「改完位次怎么算」不成立:`queuePosition` 只按 `priority → seq → created_at` 排,`depends_on` 决定的是**够不够格出队**,两者正交,改依赖不动位次。
  670 +
  671 +---
  672 +
  673 +## 十二、批 5 · 任务体系统一(用户定案:统一到一套,删掉另一套)
  674 +
  675 +### 12.1 现状:两套并存,而且**新体系已经依赖旧体系的一半**
  676 +
  677 +| | A · 本文档的体系 | B · 会话反推的体系 |
  678 +|---|---|---|
  679 +| 存储 | `goals` / `tasks` 表(SQLite) | `system/tasklog/{taskId}.json` 一任务一文件 |
  680 +| 入口 | `POST /api/company/:cid/goals`、`/tasks` | `POST /api/agent/task/dispatch`(**agent 之间派单**,`tools-base.md` 里写着让 agent curl 它)|
  681 +| 收敛 | `settleGoal` → OKR + `report_to` | `POST /api/agent/task/report`(更新 todo/plan → 更新 OKR → 推进 Pipeline → 唤醒上级)|
  682 +| 任务树 | `goal_id` / `depends_on` 显式存 | `TaskService.getAggregatedTasks()` **扫全部 agent 的 conversations 反推**父子关系(391 行)|
  683 +| 读接口 | `GET /:cid/goals`、`/tasks` | `GET /api/tasks`、`/api/tasks/:id/timeline`(59 行)|
  684 +| 前端 | `GoalTaskPane`(§七)| `TaskTimeline.vue`(736 行,唯一消费方)|
  685 +| id | `T-{base36}-{4hex}` | `T-{base36}`(同前缀不同源,§九.5)|
  686 +
  687 +**关键事实:`reportRunOutcome` 收敛后调的正是 B 的 `/api/agent/task/report`。** 两套不是并列关系,A 的回调链路长在 B 身上。所以「删掉另一套」不能理解成整片删除 —— 要删的是**存储与反推**,不是入口与回调。
  688 +
  689 +### 12.2 删什么、留什么
  690 +
  691 +**删**:
  692 +- `system/tasklog/` 目录及其读写(`agent-chat.js` 的 `saveTaskMeta` / `loadTaskMeta`,`task-service.js` 的 `_loadTaskMetas`);
  693 +- `TaskService.getAggregatedTasks()` —— **从会话记录反推任务树**这整套启发式。父子关系改为显式存字段,不再猜。
  694 +
  695 +**留(改数据源)**:
  696 +- `POST /api/agent/task/dispatch` **URL 与请求体形态一个字都不能动** —— 它写在 `packages/agent-core/base/common/tools-base.md` 里,是所有 agent 的工具说明;改了等于同时改所有 agent 的提示词。只把它的存储从写 JSON 文件改成写 `tasks` 表。
  697 +- `POST /api/agent/task/report` 同上(A 的回调依赖它)。
  698 +- `TaskService.getTaskTimeline()` —— 它读的是 agent 的 `conversations/`,那是**真实的一手数据**,不是反推。留着,只把「有哪些 task、父子是谁」换成查表。
  699 +- `TaskTimeline.vue` 保留,换数据源。
  700 +
  701 +### 12.3 `tasks` 表要吸收 B,需要三个字段
  702 +
  703 +B 的 task 与 A 的 task 有一处真实差异:**执行载体不同**。A 的 task 跑一张 org graph(走调度器),B 的 task 是直接派给某个 agent 起一次对话(`autoExecute`)。硬说成一回事会骗自己,所以显式建模:
  704 +
  705 +```sql
  706 +executor TEXT NOT NULL DEFAULT 'graph', -- 'graph'(跑 org graph,走 §五 调度器)
  707 + -- 'agent'(直接派给某个 agent 对话,不进队列)
  708 +assignee_agent_id TEXT, -- executor='agent' 时派给谁(B 的 `to`)
  709 +parent_task_id TEXT REFERENCES tasks(id), -- B 的任务链;A 的 depends_on 是同级依赖,两者不同
  710 +```
  711 +
  712 +**调度器只管 `executor='graph'`**(§五.1 的候选筛选加这一条),`executor='agent'` 的 task 由 `/task/dispatch` 自己起对话,进表只为了「可见、可查、可串成树」。
  713 +
  714 +`goal_id NOT NULL` 怎么办:**agent 派单时自动建一个 goal**(`title = '来自 {from} 的派单'`、`created_by = from`),与 §六.3 已经为 `POST /:cid/tasks` 设计的兼容路径**同一条**。模型保持干净,不给 `goal_id` 开可空的口子。
  715 +
  716 +### 12.4 顺带修好的两件事
  717 +
  718 +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 > ?` 一条查询。
  719 +2. **两套 `T-` id 合一**(§九.5 那条风险就此消失)。id 生成统一走 §四.5 的 `T-{base36}-{4hex}`。
  720 +
  721 +### 12.5 迁移
  722 +
  723 +`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/` 而不是直接删**,跑顺一个版本再清。
  724 +
  725 +### 12.6 排在批 5 的理由
  726 +
  727 +它依赖 `tasks` 表存在(批 1)、依赖 `executor` 语义在调度器里被正确排除(批 3)。而且它**改的是 agent 的生产链路**(派单 / 汇报 / 唤醒),风险面与前四批完全不同 —— 前四批坏了是控制台不好用,这一批坏了是**所有 agent 互相派不了活**。必须单独一批、单独验收。
  728 +
  729 +### 12.7 验收
  730 +
  731 +25. `POST /api/agent/task/dispatch` 请求体不变,派单成功后 `tasks` 表出现 `executor='agent'` 的行,`system/tasklog/` 不再产生新文件。
  732 +26. 派单的 task **不进调度队列**(`executor='agent'` 被 §五.1 排除),不占 `max_parallel_tasks` 的坑。
  733 +27. 30 分钟去重仍然拦得住重复派单,且不再读目录(改成 SQL)。
  734 +28. `POST /api/agent/task/report` 汇报链路四步(todo/plan → OKR → Pipeline → 唤醒上级)全部照常;**`reportRunOutcome` 那条依赖它的路也照常**。
  735 +29. `TaskTimeline.vue` 任务列表与时间线都正常,父子关系来自 `parent_task_id` 而非会话反推。
  736 +30. 存量 `system/tasklog/*.json` 全部回填进表,目录改名保留。
docs/todo/system-design/27-requirement.md
@@ -541,6 +541,27 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file @@ -541,6 +541,27 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file
541 541
542 **定案 1 连带牵出一处新缺陷**:`task-planning` 跑在 **goal 层、任何 run 存在之前**,它烧的 token **没有预算池可扣**。这正是 `graph-engine.defaultPlanWorkGraph` 注释里点过名的那个病——「规划调用也是真花钱的一次 agent 调用,token 必须计入同一个 root run 预算池,否则『一层规划 + 一层执行』里有一半的花费是隐形的」——在 goal 层的新实例。复核第二轮刚以「零消费方」为由砍掉的 goal 级预算字段,因此以**记账**字段 `planning_tokens` 的形式回来(闸门仍不做,见 P3C §十一.6)。 542 **定案 1 连带牵出一处新缺陷**:`task-planning` 跑在 **goal 层、任何 run 存在之前**,它烧的 token **没有预算池可扣**。这正是 `graph-engine.defaultPlanWorkGraph` 注释里点过名的那个病——「规划调用也是真花钱的一次 agent 调用,token 必须计入同一个 root run 预算池,否则『一层规划 + 一层执行』里有一半的花费是隐形的」——在 goal 层的新实例。复核第二轮刚以「零消费方」为由砍掉的 goal 级预算字段,因此以**记账**字段 `planning_tokens` 的形式回来(闸门仍不做,见 P3C §十一.6)。
543 543
  544 +### R-20.6 · 五条收尾定案(2026-08-18)
  545 +
  546 +| # | 问题 | 定案 | 落点 |
  547 +|---|---|---|---|
  548 +| 1 | 退役 `/api/goals` 与 `/api/company/:cid/goals` 两个 OKR 兼容别名(仓库内零调用方,但属对外可见的破坏性变更) | **可以退役** | P3C §二.3 / §六.1 |
  549 +| 2 | goal 级验收谁做? | **仍然是 `org-validator`**,在 goal 层再调一次同一个本体 | P3C §四.10 |
  550 +| 3 | goal 级总预算口径? | **先不考虑**,不做 goal 级闸门 | P3C §十一.6 |
  551 +| 4 | 排队中的任务能不能改依赖? | **可以改**,`queued` 开放 `depends_on` 的 PATCH | P3C §六.3 |
  552 +| 5 | `/api/tasks` + `TaskTimeline` 那套同名不同源的任务树怎么收? | **统一到 `tasks` 表一套,删掉另一套** | P3C §十二(批 5)|
  553 +
  554 +**定案 2 的要点是「不新造验收 agent」**:task 级与 goal 级验的是**基数不同的东西**(R-12 / NAMING §四 那张表)——task 级问「这一个 task 的 `task-goal` 达成没有」,goal 级问「N 个 task 合起来,人提的需求达成没有」。5 个 task 全 `completed` 不等于人要的东西做出来了,这个缺口正是 goal 层存在的理由。所以是**同一个本体在 goal 层再调一次**,不是复用 task 级的结论。单 task 的 goal 跳过这次调用(两级验收此时是同一件事)。
  555 +
  556 +**定案 3 保留记账、只砍闸门**:`planning_tokens` / `validation_tokens` 两个字段留着。它们不是预算闸门,是让 goal 层那两次 agent 调用(拆分 + 验收)的花费**可见** —— 不记就是 `defaultPlanWorkGraph` 注释点名的「隐形的一半」,而且界面上「本次拆分消耗 ~N tokens」这个成本确认步骤要用它。
  557 +
  558 +**定案 5 有一个反直觉的前提,实现前必须先读**:核实后确认**两套体系不是并列的,新体系的回调链路长在旧体系身上** —— `reportRunOutcome` 收敛后调的正是 `POST /api/agent/task/report`。所以「删掉另一套」的准确含义是:
  559 +
  560 +- **删**:`system/tasklog/` 文件存储 + `TaskService.getAggregatedTasks()`(从会话记录**反推**任务树的那套启发式,391 行里的主体);
  561 +- **留但换数据源**:`POST /api/agent/task/dispatch` 与 `/task/report` —— 它们的 URL 与请求体形态**一个字都不能动**,因为写在 `packages/agent-core/base/common/tools-base.md` 里,是所有 agent 的工具说明,改了等于同时改所有 agent 的提示词;`getTaskTimeline()` 读的是 agent 真实会话记录(一手数据,不是反推),留。
  562 +
  563 +统一还顺带修好两件事:30 分钟派单去重从「`readdirSync` 整个 tasklog 目录 + 逐个 JSON.parse」变成一条 SQL;两套同前缀不同源的 `T-` id 合一。
  564 +
544 **落点**:[27-P3C goal / task 两级模型](./27-p3c-goal-task-model.md)(本条派生出的工作项文档)。 565 **落点**:[27-P3C goal / task 两级模型](./27-p3c-goal-task-model.md)(本条派生出的工作项文档)。
545 566
546 --- 567 ---
@@ -584,3 +605,4 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file @@ -584,3 +605,4 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file
584 | R-20.3 | goal / task 描述**一经下达不可改**(artifact 已被 agent 消费,改库等于篡改审计输入)| P3C §四.4 | 605 | R-20.3 | goal / task 描述**一经下达不可改**(artifact 已被 agent 消费,改库等于篡改审计输入)| P3C §四.4 |
585 | R-20.4 | 新增 run status **`queued`**,出队原子性靠既有唯一索引兜底 | P3C §四.3 | 606 | R-20.4 | 新增 run status **`queued`**,出队原子性靠既有唯一索引兜底 | P3C §四.3 |
586 | R-20.5 | 四条口径:**`task-planning` 进 v1** / 并行留批 4 / manual 不自动出队 / 上游失败下游自动 blocked | P3C §四.9 / §五.1.2 / §五.2bis | 607 | R-20.5 | 四条口径:**`task-planning` 进 v1** / 并行留批 4 / manual 不自动出队 / 上游失败下游自动 blocked | P3C §四.9 / §五.1.2 / §五.2bis |
  608 +| R-20.6 | 五条收尾:退役别名 / goal 验收用 `org-validator` / 预算不做 / `queued` 可改依赖 / **两套任务树统一删一套** | P3C §四.10 / §六.3 / §十二 |