27-p3c-goal-task-model.md 88.3 KB

27-P3C. 公司目标与任务 —— goal / task 两级模型 + 控制台任务列表

所属: 27 Graph Agent 架构 的 P3 收尾(P3C),直接续 27-P3/P4 执行控制台 §四.B 与 27-P3B 真跑跑道。 日期: 2026-08-18 状态: 批 1 已实施并全绿(2026-08-18,commit 7f24ef0,见 §十三 实施记录)。批 2-5 未开工。开放问题已全部关闭。 ⚠️ 批次与 phase 的对应不是一一对齐的,判断「这份能不能搬进 done」时按批看,别按文档名看: · 批 1-3 = 真正的 P3 收尾(goal/task 两级模型 + task-planning + 排队),本文档的主体; · 批 4(并行)归 27-P4 §一.3,那边是设计权威,本文档只服从(见 §五.2 的警示块); · 批 5(两套任务体系统一)不属于任何既有 phase —— 是跨阶段的技术债清理,风险面也与前四批完全不同(坏了是所有 agent 互相派不了活),见 §十二.6。 交付物: ① goals / tasks 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的排队与并行;③ 执行控制台(/:id/graph-console)最左侧新增目标·任务两级列表栏。 命名: 全文遵循 27-NAMING,本文档落地的正是它 §四 定义但代码里一直没有载体的 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) \ \
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 §四 早已定义好基数:

概念 基数 定义(原文)
goal 一个人类需求描述 = 1 个 验收的边界。回答「这次交付到底算不算满足了人提的要求」
task 一个 goal 分解出 N 个 图中的执行单元。可并行、可下钻 sub agent
okr 跨多个 goal 的长周期度量树 Objective / Key Result

而 graph_runs 里:

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 表(新)

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 表(新)

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 加一行:

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:时间戳部分不保证全局唯一 —— 靠后缀随机位避免碰撞):

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 被前端复述过一次取值域而记过同类教训。

  1. _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 要用),但它不是闸门。

  2. 本体正文里的路径有三条硬规矩,写错不报错、只静默降级(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(新)

一个函数就是全部:

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)。

// 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 开头加一步(在选候选之前):

// 依赖已经不可能满足的 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 加一个字段:

{ "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 §一.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 声明了

/** 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) \ \
服务启动的中断任务恢复(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 §一.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。

本文档的开放问题至此全部关闭。 后续新增的疑问按惯例继续往这一节追加,不要在正文里留疑问句。

  1. goal 级总预算怎么定口径? 已定案(2026-08-18):预算先不考虑,不做 goal 级闸门。 goals.budget_json 保持砍掉。但 planning_tokens / validation_tokens 两个记账字段保留 —— 它们不是闸门,是让 goal 层那两次 agent 调用的花费可见;不记就是 defaultPlanWorkGraph 注释点名的「隐形的一半」,而且界面上「本次拆分消耗 ~N tokens」这个确认步骤要用它。
  2. 排队中的任务能不能改依赖? 已定案(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)。硬说成一回事会骗自己,所以显式建模:

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 验收

  1. POST /api/agent/task/dispatch 请求体不变,派单成功后 tasks 表出现 executor='agent' 的行,system/tasklog/ 不再产生新文件。
  2. 派单的 task 不进调度队列(executor='agent' 被 §五.1 排除),不占 max_parallel_tasks 的坑。
  3. 30 分钟去重仍然拦得住重复派单,且不再读目录(改成 SQL)。
  4. POST /api/agent/task/report 汇报链路四步(todo/plan → OKR → Pipeline → 唤醒上级)全部照常;task-settle.reportGoalOutcome 那条依赖它的路也照常(批 1 之前这条依赖挂在已删除的 reportRunOutcome 上)。
  5. TaskTimeline.vue 任务列表与时间线都正常,父子关系来自 parent_task_id 而非会话反推。
  6. 存量 system/tasklog/*.json 全部回填进表,目录改名保留。

7f24ef0">十三、批 1 实施记录(2026-08-18,commit 7f24ef0)

体例照 27-P3/P4 §十:设计正文不改写,只在这里与实现对账。 凡本节与前文冲突,以本节为准 —— 前文是设计时的判断,本节是跑出来的事实。

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 的调度器一起做更合适。