Commit 514ec3d53cf528860ace40d61d0926ce5faea0e4

Authored by yibo
1 parent 5cd91c3b

feat(27-P3C): 批 5 第二步 —— system/tasklog 回填进 tasks 表

两类回填合在同一个脚本里(§十二.5 的要求):同一次数据形态变更分两个
脚本,只会让「跑了哪个没跑哪个」变成新问题。

**沿用原 taskId,不新发一个** —— 这是这一步最要紧的判断。conversations
目录名是 task-{taskId}、汇报链路按 taskId 找回这条派单、summary 里互相
引用。换 id 等于把这些引用全断掉,而回填的目的恰恰是「别让历史消失」。

落点:
- executor='agent'、assignee_agent_id ← dispatch 的 to、parent_task_id ← 派单链
- company_id 落 _global:tasklog 的行没有 company 这一维(from/to 都是本体 id),
  全局 agent 的会话本来就落在这个保留 id 下(28 §5.1)。不去猜某条派单
  「其实属于哪家公司」—— 猜错会让它出现在错误的公司列表里,比都归 _global 更难发现
- 每条派单自带一个 goal:tasks.goal_id 是 NOT NULL 真外键,而派单没有「人提的需求」
  那一层。与 §六.3 给 POST /:cid/tasks 设计的兼容路径同一条,不给 goal_id 开可空口子
- 标题把 <br> 还原成换行再取首行,否则整条正文糊成一长条
- dispatched → running(派出去了还没回话,不是终态)

实跑:4 条派单全部回填成功、零失败;再跑一次全部跳过、零新增。
回填后 executor='agent' 的行指向 hunter / digester / master / agent-creator,
原 taskId 一个没变。

守卫:server/tests/unit/tasklog-backfill.test.js(4 例)

顺带修掉自己踩的一个:新测试用 Date.now() 拼临时库路径,撞了
test-id-stability 那条守卫 —— 和另一个会话昨天踩的是同一个。改用
fs.mkdtempSync(仓库既有写法,graph-engine.test.js 的 runsDir 就是它)。

全量:131 文件 / 1251 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/migrate-runs-to-goal-task.js
... ... @@ -18,7 +18,11 @@
18 18 * node scripts/migrate-runs-to-goal-task.js --dry-run # 预演,事务回滚,不留任何行
19 19 * node scripts/migrate-runs-to-goal-task.js # 实际执行
20 20 *
21   - * 幂等: 见 alreadyMigrated()。重复跑不会产生第二份 goal/task。
  21 + * 【批 5 追加】同一个脚本还负责第二类回填:`system/tasklog/*.json`(agent 之间派单的旧存储)
  22 + * → `tasks` 表的 `executor='agent'` 行。两类回填放同一个脚本是 §十二.5 的要求:同一次数据形态
  23 + * 变更分两个脚本,只会让「跑了哪个没跑哪个」变成新问题。
  24 + *
  25 + * 幂等: 见 alreadyMigrated() / tasklogAlreadyMigrated()。重复跑不会产生第二份 goal/task。
22 26 * 库位置: 走 work-graph-db.getDb()(即 data/graph.sqlite,可被 GRAPH_DB_PATH 覆盖)——
23 27 * 必须走它而不是自己 new Database:goals / tasks 两张表的 DDL 就在它的 getDb() 里,
24 28 * 别的入口连表都建不出来。
... ... @@ -32,7 +36,8 @@ const REPO_ROOT = path.resolve(__dirname, &#39;..&#39;);
32 36 const DRY = process.argv.includes('--dry-run');
33 37  
34 38 const log = (...a) => console.log(DRY ? '[dry-run]' : '[migrate]', ...a);
35   -const stats = { roots: 0, goals: 0, tasks: 0, runsPatched: 0, skipped: 0, noArtifact: 0 };
  39 +const stats = { roots: 0, goals: 0, tasks: 0, runsPatched: 0, skipped: 0, noArtifact: 0,
  40 + tasklogFiles: 0, tasklogGoals: 0, tasklogTasks: 0, tasklogSkipped: 0, tasklogBad: 0 };
36 41  
37 42 /**
38 43 * root run 的产物目录 —— 形状必须与 graph-engine.runRootDir 逐字一致:
... ... @@ -195,6 +200,7 @@ function main() {
195 200 try {
196 201 db.transaction(() => {
197 202 for (const run of roots) migrateRun(db, run);
  203 + migrateAllTasklog(db);
198 204 if (DRY) throw ROLLBACK;
199 205 })();
200 206 } catch (e) {
... ... @@ -209,6 +215,115 @@ function main() {
209 215 else console.log('完成。');
210 216 }
211 217  
  218 +
  219 +// ════════════════════════════════════════════════════════════════
  220 +// 批 5:system/tasklog/*.json → tasks 表(executor='agent')
  221 +// ════════════════════════════════════════════════════════════════
  222 +
  223 +const TASKLOG_DIR = path.join(REPO_ROOT, 'system', 'tasklog');
  224 +
  225 +/**
  226 + * 派单任务落在哪家公司名下。
  227 + *
  228 + * tasklog 的行**没有 company 这一维** —— 它记的是「谁派给谁」,而 from/to 都是本体 id。
  229 + * 全局 agent 的会话本来就落在保留公司 id `_global` 下(28 §5.1),这里沿用同一个约定,
  230 + * 不去猜某条派单「其实属于哪家公司」:猜错会让它出现在错误的公司任务列表里,
  231 + * 而那比「都归到 _global」更难发现。
  232 + */
  233 +const DISPATCH_COMPANY = '_global';
  234 +
  235 +/** tasklog 的 status → tasks.status。dispatched = 派出去了还没回话,落 running。 */
  236 +const TASKLOG_STATUS = {
  237 + dispatched: 'running',
  238 + done: 'completed',
  239 + failed: 'failed',
  240 + blocked: 'blocked',
  241 + cancelled: 'cancelled',
  242 +};
  243 +
  244 +/** tasklog 的正文把换行写成了 <br>,标题要还原一下才不会糊成一长条 */
  245 +function tasklogTitle(task) {
  246 + const plain = String(task || '').replace(/<br\s*\/?>/gi, '\n');
  247 + return deriveTitle(plain) || '(无标题派单)';
  248 +}
  249 +
  250 +/**
  251 + * 幂等:这条 tasklog 是不是已经回填过。
  252 + * 判据用 **tasks.id === taskId**(回填时刻意沿用原 taskId,见 migrateTasklog 的理由)。
  253 + */
  254 +function tasklogAlreadyMigrated(db, taskId) {
  255 + return Boolean(db.prepare('SELECT 1 AS ok FROM tasks WHERE id = ?').get(taskId));
  256 +}
  257 +
  258 +function migrateTasklog(db, file) {
  259 + let meta;
  260 + try {
  261 + meta = JSON.parse(fs.readFileSync(path.join(TASKLOG_DIR, file), 'utf-8'));
  262 + } catch (e) {
  263 + stats.tasklogBad += 1;
  264 + log(`⚠️ ${file} 解析失败,跳过: ${e.message}`);
  265 + return;
  266 + }
  267 + const taskId = meta.taskId || path.basename(file, '.json');
  268 + if (tasklogAlreadyMigrated(db, taskId)) { stats.tasklogSkipped += 1; return; }
  269 +
  270 + const now = new Date().toISOString();
  271 + const createdAt = meta.createdAt || now;
  272 + const title = tasklogTitle(meta.task);
  273 +
  274 + // 每条派单建一个 goal:`tasks.goal_id` 是 NOT NULL 的真外键,而派单没有「人提的需求」那一层。
  275 + // 与 §六.3 给 `POST /:cid/tasks` 设计的兼容路径同一条 —— 模型保持干净,不给 goal_id 开可空的口子。
  276 + const goalId = wdb.genGoalId();
  277 + db.prepare(`
  278 + INSERT INTO goals (id, company_id, title, description, status, priority,
  279 + okr_node_id, created_by, report_to, created_at, updated_at, dispatched_at)
  280 + VALUES (@id, @cid, @title, @desc, @status, @prio, @okr, @by, @to, @at, @at, @at)
  281 + `).run({
  282 + id: goalId, cid: DISPATCH_COMPANY, title: `来自 ${meta.from || '?'} 的派单`,
  283 + desc: String(meta.task || ''), status: meta.status === 'done' ? 'done' : 'active',
  284 + prio: meta.priority || 'P1', okr: meta.nodeId || null,
  285 + by: meta.from || null, to: meta.from || null, at: createdAt,
  286 + });
  287 + stats.tasklogGoals += 1;
  288 +
  289 + // **沿用原 taskId 而不是新发一个**:conversations 目录名是 `task-{taskId}`、
  290 + // 汇报链路按 taskId 找回这条派单、summary 里也互相引用。换 id 等于把这些引用全断掉,
  291 + // 而回填的目的正是「别让历史消失」。
  292 + db.prepare(`
  293 + INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status,
  294 + execution_mode, depends_on, attempt, priority, created_by,
  295 + executor, assignee_agent_id, parent_task_id,
  296 + created_at, updated_at, queued_at, started_at, finished_at)
  297 + VALUES (@id, @gid, @cid, 1, @title, @desc, @status,
  298 + 'manual', '[]', 1, @prio, @by,
  299 + 'agent', @to, @parent,
  300 + @at, @at, @at, @at, @done)
  301 + `).run({
  302 + id: taskId, gid: goalId, cid: DISPATCH_COMPANY, title,
  303 + desc: String(meta.task || ''), status: TASKLOG_STATUS[meta.status] || 'running',
  304 + prio: meta.priority || 'P1', by: meta.from || null,
  305 + to: meta.to || null, parent: meta.parentTaskId || null,
  306 + at: createdAt, done: meta.completedAt || null,
  307 + });
  308 + stats.tasklogTasks += 1;
  309 +}
  310 +
  311 +function migrateAllTasklog(db) {
  312 + if (!fs.existsSync(TASKLOG_DIR)) { log('system/tasklog/ 不存在,跳过派单回填'); return; }
  313 + const files = fs.readdirSync(TASKLOG_DIR).filter((f) => f.endsWith('.json')).sort();
  314 + stats.tasklogFiles = files.length;
  315 + log(`system/tasklog/ 共 ${files.length} 条派单`);
  316 + // ⚠️ parent_task_id 是**自引用外键**:父派单必须先入库。文件名按 taskId 排序 ≈ 按时间排序
  317 + // (T-{base36 毫秒}),父一定早于子;真有乱序时下面那条 catch 会点名,不会静默丢。
  318 + for (const f of files) {
  319 + try { migrateTasklog(db, f); } catch (e) {
  320 + stats.tasklogBad += 1;
  321 + log(`⚠️ ${f} 落库失败(多半是 parent_task_id 还没入库): ${e.message}`);
  322 + }
  323 + }
  324 +}
  325 +
212 326 if (require.main === module) main();
213 327  
214   -module.exports = { runRootDir, readTaskGoal, deriveTitle, TASK_STATUS, GOAL_STATUS };
  328 +module.exports = { runRootDir, readTaskGoal, deriveTitle, TASK_STATUS, GOAL_STATUS,
  329 + migrateAllTasklog, tasklogTitle, TASKLOG_STATUS, DISPATCH_COMPANY };
... ...
server/tests/unit/tasklog-backfill.test.js 0 → 100644
  1 +/**
  2 + * 批 5 · `system/tasklog/*.json` → tasks 表(executor='agent')的回填。
  3 + *
  4 + * 钉三件事:
  5 + * 1. **原 taskId 必须被沿用**。conversations 目录名是 `task-{taskId}`、汇报链路按 taskId
  6 + * 找回这条派单、summary 里互相引用。换一个新 id 等于把这些引用全断掉,而回填的目的
  7 + * 恰恰是「别让历史消失」。
  8 + * 2. 幂等 —— 重复跑不产生第二份。
  9 + * 3. 每条派单自带一个 goal(tasks.goal_id 是 NOT NULL 真外键,而派单没有「人提的需求」那一层)。
  10 + */
  11 +import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  12 +import { createRequire } from 'module';
  13 +import fs from 'fs';
  14 +import os from 'os';
  15 +import path from 'path';
  16 +const require_ = createRequire(import.meta.url);
  17 +
  18 +describe('tasklog 回填', () => {
  19 + let wdb, mig, dir, dbPath;
  20 +
  21 + beforeEach(() => {
  22 + // 见 tasks-executor-migration.test.js 同处注释:不自己拼时间戳,用 mkdtempSync
  23 + dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'tasklog-bf-')), 'graph.sqlite');
  24 + process.env.GRAPH_DB_PATH = dbPath;
  25 + for (const m of ['../../services/work-graph-db', '../../services/graph-db', '../../../scripts/migrate-runs-to-goal-task']) {
  26 + delete require_.cache[require_.resolve(m)];
  27 + }
  28 + wdb = require_('../../services/work-graph-db');
  29 + mig = require_('../../../scripts/migrate-runs-to-goal-task');
  30 + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tasklog-'));
  31 + });
  32 + afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
  33 +
  34 + /** 脚本读的是仓库里的 system/tasklog;这里直接调它的单条逻辑不现实,改为验它对真目录的结果。
  35 + * 所以这条用例走「真目录已被回填过」的幂等面 + 形态断言。 */
  36 + it('回填出来的行形态对:executor=agent、assignee 指向 to、沿用原 taskId、自带 goal', () => {
  37 + const db = wdb.getDb();
  38 + mig.migrateAllTasklog(db);
  39 + const rows = db.prepare("SELECT * FROM tasks WHERE executor = 'agent'").all();
  40 + if (rows.length === 0) return; // 仓库里没有 tasklog 时这条不适用
  41 +
  42 + for (const r of rows) {
  43 + expect(r.id).toMatch(/^T-/); // 沿用原 taskId(不是新发的 T-xxx-4hex)
  44 + expect(r.assignee_agent_id).toBeTruthy(); // 派给谁必须记下来
  45 + expect(r.company_id).toBe(mig.DISPATCH_COMPANY);
  46 + const goal = db.prepare('SELECT * FROM goals WHERE id = ?').get(r.goal_id);
  47 + expect(goal).toBeTruthy(); // NOT NULL 真外键,每条派单自带一个 goal
  48 + expect(goal.title).toMatch(/来自 .* 的派单/);
  49 + }
  50 + });
  51 +
  52 + it('幂等:连跑两次不产生第二份', () => {
  53 + const db = wdb.getDb();
  54 + mig.migrateAllTasklog(db);
  55 + const n1 = db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE executor='agent'").get().n;
  56 + const g1 = db.prepare('SELECT COUNT(*) AS n FROM goals').get().n;
  57 + mig.migrateAllTasklog(db);
  58 + expect(db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE executor='agent'").get().n).toBe(n1);
  59 + expect(db.prepare('SELECT COUNT(*) AS n FROM goals').get().n).toBe(g1);
  60 + });
  61 +
  62 + it('标题把 <br> 还原成换行再取首行 —— 否则整条正文糊成一长条', () => {
  63 + expect(mig.tasklogTitle('第一行<br>第二行')).toBe('第一行');
  64 + expect(mig.tasklogTitle('第一行<BR/>第二行')).toBe('第一行');
  65 + expect(mig.tasklogTitle('')).toBe('(无标题派单)');
  66 + });
  67 +
  68 + it('dispatched 落 running —— 派出去了还没回话,不是终态', () => {
  69 + expect(mig.TASKLOG_STATUS.dispatched).toBe('running');
  70 + expect(mig.TASKLOG_STATUS.done).toBe('completed');
  71 + });
  72 +});
... ...
server/tests/unit/tasks-executor-migration.test.js
... ... @@ -9,6 +9,7 @@
9 9 */
10 10 import { describe, it, expect } from 'vitest';
11 11 import { createRequire } from 'module';
  12 +import fs from 'fs';
12 13 import os from 'os';
13 14 import path from 'path';
14 15 const require_ = createRequire(import.meta.url);
... ... @@ -28,7 +29,10 @@ function freshDb(dbPath) {
28 29  
29 30 describe('tasks 表的批 5 迁移', () => {
30 31 it('存量库(没有这三列)重启后能补上,且不炸', () => {
31   - const dbPath = path.join(os.tmpdir(), `b5-mig-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`);
  32 + // 用 mkdtempSync 拿唯一目录,而不是自己拿 Date.now() 拼文件名 ——
  33 + // 后者会撞 test-id-stability 那条守卫(测试里不许用时间戳拼 id),而且 mkdtempSync
  34 + // 本来就是仓库既有写法(graph-engine.test.js 的 runsDir 就是它)。
  35 + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'b5-mig-')), 'graph.sqlite');
32 36 const wdb = freshDb(dbPath);
33 37  
34 38 // 退回加列之前的形态:索引先删(它引用了要删的列),再删三列
... ... @@ -59,7 +63,7 @@ describe(&#39;tasks 表的批 5 迁移&#39;, () =&gt; {
59 63 });
60 64  
61 65 it('新建库直接就是目标形态(三列 + 索引齐全)', () => {
62   - const dbPath = path.join(os.tmpdir(), `b5-new-${Date.now()}-${Math.floor(process.hrtime()[1] / 1e3)}.sqlite`);
  66 + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'b5-new-')), 'graph.sqlite');
63 67 const wdb = freshDb(dbPath);
64 68 const names = wdb.getDb().pragma('table_info(tasks)').map((c) => c.name);
65 69 for (const c of NEW_COLS) expect(names).toContain(c);
... ...