From 5cd91c3b4124fd1f6d0e4371f2971e8356623c8d Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 19 Aug 2026 23:57:57 +0800 Subject: [PATCH] feat(27-P3C): 批 5 第一步 —— tasks 表吸收 executor / assignee / parent 三列 --- server/services/work-graph-db.js | 32 ++++++++++++++++++++++++++++++++ server/tests/unit/tasks-executor-migration.test.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 0 deletions(-) create mode 100644 server/tests/unit/tasks-executor-migration.test.js diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 64faa64..04dc34e 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -496,6 +496,17 @@ function getDb() { created_by TEXT, -- 'task-planning' | 'human':拆出来的还是人加的 blocked_reason TEXT, -- status='blocked' 时的原因,如 upstream_failed:T-xxx priority TEXT NOT NULL DEFAULT 'P1', + -- ── 批 5:两套任务体系统一(§十二)──────────────────────────── + -- agent 之间派单(POST /api/agent/task/dispatch)与「跑一张 org graph」是两种执行载体, + -- 硬说成一回事是骗自己:前者直接起一次 agent 对话,后者走调度器出队 + 图执行。 + -- 所以显式建模,而不是让调度器去猜。调度器只管 executor='graph'。 + -- ⚠️ 本段刻意不用反引号:这整块是 db.exec 的模板字符串,注释里的反引号会把它提前闭合。 + executor TEXT NOT NULL DEFAULT 'graph' + CHECK (executor IN ('graph','agent')), + assignee_agent_id TEXT, -- executor='agent' 时派给谁(dispatch 的 to) + -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— + -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 + parent_task_id TEXT REFERENCES tasks(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, queued_at TEXT, -- 入队时间;非空 = 描述已冻结(§四.4) @@ -533,6 +544,27 @@ function getDb() { // 吃一句 SQLite 原文的 500(isSlotRaceError 已不再吞它,就是为了让这一幕吵起来)。 db.exec(`DROP INDEX IF EXISTS idx_graph_runs_one_active_root`); + // ── 批 5:tasks 追加三列(§十二.3)────────────────────────────── + // 旧行的落点都对:executor 缺省 'graph'(批 1-4 建的全是图任务), + // assignee_agent_id / parent_task_id 为 NULL(图任务没有派单对象、也不在派单链上)。 + // ⚠️ `ALTER TABLE ADD COLUMN` 加不了 CHECK 也加不了外键(SQLite 限制),所以存量库上 + // `executor` 的取值域**只由应用层守**,与新建库的 CHECK 不同源 —— 这是已知的不对称, + // 写在这里免得下一个人以为库层拦得住。真要对齐得重建表,代价远大于收益。 + if (!hasColumn(db, 'tasks', 'executor')) { + db.exec(`ALTER TABLE tasks ADD COLUMN executor TEXT NOT NULL DEFAULT 'graph'`); + } + if (!hasColumn(db, 'tasks', 'assignee_agent_id')) { + db.exec(`ALTER TABLE tasks ADD COLUMN assignee_agent_id TEXT`); + } + if (!hasColumn(db, 'tasks', 'parent_task_id')) { + db.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT`); + } + // ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块: + // 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op —— + // 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛 + // 「no such column」,`getDb()` 起不来 = **所有存量库一启动就炸**。实测踩过。 + db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(company_id, assignee_agent_id, created_at)`); + // R4-F1:graph_run_nodes 追加 remediation_note,可空,旧行 NULL = 「没有留给下一轮的话」。 if (!hasColumn(db, 'graph_run_nodes', 'remediation_note')) { db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN remediation_note TEXT`); diff --git a/server/tests/unit/tasks-executor-migration.test.js b/server/tests/unit/tasks-executor-migration.test.js new file mode 100644 index 0000000..45e996a --- /dev/null +++ b/server/tests/unit/tasks-executor-migration.test.js @@ -0,0 +1,68 @@ +/** + * 批 5 · `tasks` 表吸收三列(executor / assignee_agent_id / parent_task_id)的**迁移路径**。 + * + * 为什么单独钉这一条:加列时我把 `CREATE INDEX ... ON tasks(assignee_agent_id)` 写进了建表块, + * 而建表块整体先于迁移块执行 —— 存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op, + * 表还是加列前的形态,那一句索引直接抛「no such column」,`getDb()` 起不来。 + * 后果不是少个功能,是**所有存量库一启动就炸**,而新建库(单测默认走的那条路)完全正常 —— + * 只测新建库的话这个洞看不见。 + */ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; +import os from 'os'; +import path from 'path'; +const require_ = createRequire(import.meta.url); + +const NEW_COLS = ['executor', 'assignee_agent_id', 'parent_task_id']; + +/** 用独立 DB 文件跑一遍 getDb(),返回该库的 work-graph-db 模块实例 */ +function freshDb(dbPath) { + process.env.GRAPH_DB_PATH = dbPath; + for (const m of ['../../services/work-graph-db', '../../services/graph-db']) { + delete require_.cache[require_.resolve(m)]; + } + const wdb = require_('../../services/work-graph-db'); + wdb.getDb(); + return wdb; +} + +describe('tasks 表的批 5 迁移', () => { + it('存量库(没有这三列)重启后能补上,且不炸', () => { + const dbPath = path.join(os.tmpdir(), `b5-mig-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`); + const wdb = freshDb(dbPath); + + // 退回加列之前的形态:索引先删(它引用了要删的列),再删三列 + const Database = require_('better-sqlite3'); + const raw = new Database(dbPath); + raw.exec('DROP INDEX IF EXISTS idx_tasks_assignee'); + for (const c of NEW_COLS) raw.exec(`ALTER TABLE tasks DROP COLUMN ${c}`); + const before = raw.pragma('table_info(tasks)').map((c) => c.name); + raw.close(); + for (const c of NEW_COLS) expect(before).not.toContain(c); + + // 重开进程 = 服务重启:迁移必须把三列和索引都补回来,且不抛 + const wdb2 = freshDb(dbPath); + const cols = wdb2.getDb().pragma('table_info(tasks)'); + const names = cols.map((c) => c.name); + for (const c of NEW_COLS) expect(names).toContain(c); + + // 旧行的落点:executor 缺省 'graph'(加列前建的全是图任务) + expect(cols.find((c) => c.name === 'executor').dflt_value).toBe("'graph'"); + + // 索引必须建在 ALTER 之后 —— 顺序反了就是上面注释里那个「存量库一启动就炸」 + const idx = wdb2.getDb() + .prepare("SELECT 1 AS ok FROM sqlite_master WHERE type='index' AND name='idx_tasks_assignee'") + .get(); + expect(idx?.ok).toBe(1); + + expect(wdb).toBeTruthy(); // 前一个实例只是用来建库,这里避免 lint 抱怨未使用 + }); + + it('新建库直接就是目标形态(三列 + 索引齐全)', () => { + const dbPath = path.join(os.tmpdir(), `b5-new-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`); + const wdb = freshDb(dbPath); + const names = wdb.getDb().pragma('table_info(tasks)').map((c) => c.name); + for (const c of NEW_COLS) expect(names).toContain(c); + expect(wdb.getDb().prepare("SELECT 1 AS ok FROM sqlite_master WHERE type='index' AND name='idx_tasks_assignee'").get()?.ok).toBe(1); + }); +}); -- libgit2 0.21.4