Commit 5cd91c3b4124fd1f6d0e4371f2971e8356623c8d

Authored by yibo
1 parent 81629b7d

feat(27-P3C): 批 5 第一步 —— tasks 表吸收 executor / assignee / parent 三列

agent 之间派单(POST /api/agent/task/dispatch)与「跑一张 org graph」是
两种执行载体,硬说成一回事是骗自己:前者直接起一次 agent 对话,后者走
调度器出队 + 图执行。所以显式建模而不是让调度器去猜 ——
executor(graph|agent)、assignee_agent_id、parent_task_id(派单链,
与 depends_on 的「同级先后」不是一回事)。

迁移的落点对:旧行 executor 缺省 'graph'(批 1-4 建的全是图任务),
另两列 NULL。

## 加这一步时踩到并修掉的一个会让存量库开不了的 bug

第一版把 CREATE INDEX ... ON tasks(assignee_agent_id) 写进了建表块。
建表块整体先于迁移块执行,而存量库上 CREATE TABLE IF NOT EXISTS tasks
是 no-op —— 表还是批 4 的形态、没有那一列,于是这句索引直接抛
「no such column」,getDb() 起不来 = **所有存量库一启动就炸**。
而新建库(单测默认走的那条路)完全正常,只测新建库根本看不见这个洞。
已把索引挪进迁移块、排在 ALTER 之后。

守卫:server/tests/unit/tasks-executor-migration.test.js —— 真造一个
批 4 形态的库(删索引 + 删三列)再重开,断言三列与索引都补回、
executor 默认值是 'graph'。

另记一处自己踩的:SQL 模板字符串里的注释用了反引号,把 db.exec 的模板
提前闭合了。DDL 注释里一律不用反引号,已写进那段注释。

已知不对称:ALTER TABLE ADD COLUMN 加不了 CHECK 也加不了外键,所以存量库
上 executor 的取值域只由应用层守,与新建库的 CHECK 不同源。写在注释里,
免得下一个人以为库层拦得住。

全量:129 文件 / 1245 测试全绿(+2 新守卫)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/services/work-graph-db.js
... ... @@ -496,6 +496,17 @@ function getDb() {
496 496 created_by TEXT, -- 'task-planning' | 'human':拆出来的还是人加的
497 497 blocked_reason TEXT, -- status='blocked' 时的原因,如 upstream_failed:T-xxx
498 498 priority TEXT NOT NULL DEFAULT 'P1',
  499 + -- ── 批 5:两套任务体系统一(§十二)────────────────────────────
  500 + -- agent 之间派单(POST /api/agent/task/dispatch)与「跑一张 org graph」是两种执行载体,
  501 + -- 硬说成一回事是骗自己:前者直接起一次 agent 对话,后者走调度器出队 + 图执行。
  502 + -- 所以显式建模,而不是让调度器去猜。调度器只管 executor='graph'。
  503 + -- ⚠️ 本段刻意不用反引号:这整块是 db.exec 的模板字符串,注释里的反引号会把它提前闭合。
  504 + executor TEXT NOT NULL DEFAULT 'graph'
  505 + CHECK (executor IN ('graph','agent')),
  506 + assignee_agent_id TEXT, -- executor='agent' 时派给谁(dispatch 的 to)
  507 + -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 ——
  508 + -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。
  509 + parent_task_id TEXT REFERENCES tasks(id),
499 510 created_at TEXT NOT NULL,
500 511 updated_at TEXT NOT NULL,
501 512 queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4)
... ... @@ -533,6 +544,27 @@ function getDb() {
533 544 // 吃一句 SQLite 原文的 500(isSlotRaceError 已不再吞它,就是为了让这一幕吵起来)。
534 545 db.exec(`DROP INDEX IF EXISTS idx_graph_runs_one_active_root`);
535 546  
  547 + // ── 批 5:tasks 追加三列(§十二.3)──────────────────────────────
  548 + // 旧行的落点都对:executor 缺省 'graph'(批 1-4 建的全是图任务),
  549 + // assignee_agent_id / parent_task_id 为 NULL(图任务没有派单对象、也不在派单链上)。
  550 + // ⚠️ `ALTER TABLE ADD COLUMN` 加不了 CHECK 也加不了外键(SQLite 限制),所以存量库上
  551 + // `executor` 的取值域**只由应用层守**,与新建库的 CHECK 不同源 —— 这是已知的不对称,
  552 + // 写在这里免得下一个人以为库层拦得住。真要对齐得重建表,代价远大于收益。
  553 + if (!hasColumn(db, 'tasks', 'executor')) {
  554 + db.exec(`ALTER TABLE tasks ADD COLUMN executor TEXT NOT NULL DEFAULT 'graph'`);
  555 + }
  556 + if (!hasColumn(db, 'tasks', 'assignee_agent_id')) {
  557 + db.exec(`ALTER TABLE tasks ADD COLUMN assignee_agent_id TEXT`);
  558 + }
  559 + if (!hasColumn(db, 'tasks', 'parent_task_id')) {
  560 + db.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT`);
  561 + }
  562 + // ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块:
  563 + // 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op ——
  564 + // 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛
  565 + // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。
  566 + db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`);
  567 +
536 568 // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。
537 569 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) {
538 570 db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`);
... ...
server/tests/unit/tasks-executor-migration.test.js 0 → 100644
  1 +/**
  2 + * 批 5 · `tasks` 表吸收三列(executor / assignee_agent_id / parent_task_id)的**迁移路径**。
  3 + *
  4 + * 为什么单独钉这一条:加列时我把 `CREATE INDEX ... ON tasks(assignee_agent_id)` 写进了建表块,
  5 + * 而建表块整体先于迁移块执行 —— 存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op,
  6 + * 表还是加列前的形态,那一句索引直接抛「no such column」,`getDb()` 起不来。
  7 + * 后果不是少个功能,是**所有存量库一启动就炸**,而新建库(单测默认走的那条路)完全正常 ——
  8 + * 只测新建库的话这个洞看不见。
  9 + */
  10 +import { describe, it, expect } from 'vitest';
  11 +import { createRequire } from 'module';
  12 +import os from 'os';
  13 +import path from 'path';
  14 +const require_ = createRequire(import.meta.url);
  15 +
  16 +const NEW_COLS = ['executor', 'assignee_agent_id', 'parent_task_id'];
  17 +
  18 +/** 用独立 DB 文件跑一遍 getDb(),返回该库的 work-graph-db 模块实例 */
  19 +function freshDb(dbPath) {
  20 + process.env.GRAPH_DB_PATH = dbPath;
  21 + for (const m of ['../../services/work-graph-db', '../../services/graph-db']) {
  22 + delete require_.cache[require_.resolve(m)];
  23 + }
  24 + const wdb = require_('../../services/work-graph-db');
  25 + wdb.getDb();
  26 + return wdb;
  27 +}
  28 +
  29 +describe('tasks 表的批 5 迁移', () => {
  30 + it('存量库(没有这三列)重启后能补上,且不炸', () => {
  31 + const dbPath = path.join(os.tmpdir(), `b5-mig-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`);
  32 + const wdb = freshDb(dbPath);
  33 +
  34 + // 退回加列之前的形态:索引先删(它引用了要删的列),再删三列
  35 + const Database = require_('better-sqlite3');
  36 + const raw = new Database(dbPath);
  37 + raw.exec('DROP INDEX IF EXISTS idx_tasks_assignee');
  38 + for (const c of NEW_COLS) raw.exec(`ALTER TABLE tasks DROP COLUMN ${c}`);
  39 + const before = raw.pragma('table_info(tasks)').map((c) => c.name);
  40 + raw.close();
  41 + for (const c of NEW_COLS) expect(before).not.toContain(c);
  42 +
  43 + // 重开进程 = 服务重启:迁移必须把三列和索引都补回来,且不抛
  44 + const wdb2 = freshDb(dbPath);
  45 + const cols = wdb2.getDb().pragma('table_info(tasks)');
  46 + const names = cols.map((c) => c.name);
  47 + for (const c of NEW_COLS) expect(names).toContain(c);
  48 +
  49 + // 旧行的落点:executor 缺省 'graph'(加列前建的全是图任务)
  50 + expect(cols.find((c) => c.name === 'executor').dflt_value).toBe("'graph'");
  51 +
  52 + // 索引必须建在 ALTER 之后 —— 顺序反了就是上面注释里那个「存量库一启动就炸」
  53 + const idx = wdb2.getDb()
  54 + .prepare("SELECT 1 AS ok FROM sqlite_master WHERE type='index' AND name='idx_tasks_assignee'")
  55 + .get();
  56 + expect(idx?.ok).toBe(1);
  57 +
  58 + expect(wdb).toBeTruthy(); // 前一个实例只是用来建库,这里避免 lint 抱怨未使用
  59 + });
  60 +
  61 + it('新建库直接就是目标形态(三列 + 索引齐全)', () => {
  62 + const dbPath = path.join(os.tmpdir(), `b5-new-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`);
  63 + const wdb = freshDb(dbPath);
  64 + const names = wdb.getDb().pragma('table_info(tasks)').map((c) => c.name);
  65 + for (const c of NEW_COLS) expect(names).toContain(c);
  66 + expect(wdb.getDb().prepare("SELECT 1 AS ok FROM sqlite_master WHERE type='index' AND name='idx_tasks_assignee'").get()?.ok).toBe(1);
  67 + });
  68 +});
... ...