task-decomposition-dispatch.test.js 13.1 KB
/**
 * 46 号文档 Phase 3(v2,2026-09-02 改版):CEO 派发 decomposed_work 子任务。
 *
 * v1 做的是"认领"(pull,CAS 抢占),真实回归后拍板改成"CEO 直接派发"(push,不存在
 * 多方竞争,CAS 是在给不存在的问题找解)。这个文件替掉已删除的 task-pool-claim.test.js,
 * 覆盖新设计的三层:
 *   1. `materializeDecomposedSubtasks`(物化,depends_on 回填,幂等)
 *   2. `createDecomposedWorkGraph`(现造的轻量 impl→validate 图,形状对不对)
 *   3. 路由层 `enqueueTaskRun`——decomposed_work 走轻量 work graph,普通 task 走公司 org
 *      graph 不受影响(回归),以及 `triggerSubtaskPlanning` 的递归防护(子任务不会被再拆一层)
 */
import {
  describe, it, expect, beforeAll, afterAll,
} from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const TEST_DB_PATH = '/tmp/test-task-decomp-dispatch.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = ['../../services/graph-db', '../../services/work-graph-db'];

function ensureAgent(wdb, id) {
  const db = wdb.getDb();
  const now = new Date().toISOString();
  db.prepare(`INSERT OR IGNORE INTO system_agents
      (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
    .run(id, id, `system/agents/${id}`, now, now);
}

describe('46 号文档 Phase 3(v2):DB 层', () => {
  let wdb;

  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
    ensureAgent(wdb, 'org-coder');
    ensureAgent(wdb, 'org-validator');
  });

  afterAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    delete process.env.GRAPH_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  // ── createTask 校验(简化后:没有专属初始 status 了)─────────────────

  it('subtaskKind 非法值直接拒', () => {
    const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
    expect(() => wdb.createTask({
      goalId: goal.id, title: 't', description: 'd', subtaskKind: 'not-a-real-kind',
    })).toThrow(/subtaskKind 非法/);
  });

  it('subtaskKind 没有 parentTaskId 时直接拒', () => {
    const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
    expect(() => wdb.createTask({
      goalId: goal.id, title: 't', description: 'd', subtaskKind: 'decomposed_work',
    })).toThrow(/只对子任务.*有意义/);
  });

  it('正常创建 decomposed_work 子任务:就是一条普通 draft task,只多了 subtask_kind 标签', () => {
    const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
    const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
    const child = wdb.createTask({
      goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
    });
    expect(child.subtask_kind).toBe('decomposed_work');
    expect(child.status).toBe('draft'); // 没有专属状态了
    expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑
  });

  // ── materializeDecomposedSubtasks ─────────────────────────────────

  describe('materializeDecomposedSubtasks', () => {
    function setupTask(companyId) {
      const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
      return wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
    }

    it('按 steps 顺序物化,status=draft、executionMode=auto,depends_on 正确回填(含前向引用)', () => {
      const task = setupTask('co-p3-2');
      const steps = [
        { seq: 1, title: '第一步', depends_on_hint: [3] }, // 前向引用:依赖还没建出来的第三步
        { seq: 2, title: '第二步', depends_on_hint: [1] },
        { seq: 3, title: '第三步', depends_on_hint: [] },
      ];
      const ids = wdb.materializeDecomposedSubtasks({ task, steps });
      expect(ids).toHaveLength(3);

      const rows = ids.map((id) => wdb.getTask(id));
      expect(rows.every((r) => r.subtask_kind === 'decomposed_work')).toBe(true);
      expect(rows.every((r) => r.status === 'draft')).toBe(true);
      expect(rows.every((r) => r.execution_mode === 'auto')).toBe(true);
      expect(rows[0].title).toBe('第一步');
      expect(JSON.parse(rows[0].depends_on)).toEqual([ids[2]]); // 前向引用正确解析成第三步的真实 id
      expect(JSON.parse(rows[1].depends_on)).toEqual([ids[0]]);
      expect(JSON.parse(rows[2].depends_on)).toEqual([]);
    });

    it('depends_on_hint 指向不存在的 seq:丢那一条引用,不作废整批(宽松解析)', () => {
      const task = setupTask('co-p3-3');
      const steps = [{ seq: 1, title: '唯一一步', depends_on_hint: [99] }];
      const ids = wdb.materializeDecomposedSubtasks({ task, steps });
      expect(ids).toHaveLength(1);
      expect(JSON.parse(wdb.getTask(ids[0]).depends_on)).toEqual([]);
    });

    it('幂等:同一个 task 物化第二次直接跳过,不重复建行', () => {
      const task = setupTask('co-p3-4');
      const steps = [{ seq: 1, title: '一步', depends_on_hint: [] }];
      const first = wdb.materializeDecomposedSubtasks({ task, steps });
      expect(first).toHaveLength(1);
      const second = wdb.materializeDecomposedSubtasks({ task, steps });
      expect(second).toEqual([]);

      const rows = wdb.getDb()
        .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`)
        .get(task.id);
      expect(rows.n).toBe(1);
    });

    it('空 steps / 无 task 时返回 [],不报错', () => {
      const task = setupTask('co-p3-5');
      expect(wdb.materializeDecomposedSubtasks({ task, steps: [] })).toEqual([]);
      expect(wdb.materializeDecomposedSubtasks({ task: null, steps: [{ seq: 1, title: 'x' }] })).toEqual([]);
    });

    it('与 43 号文档的 acceptance_item 共存互不干扰:各自幂等判据只看自己的 subtask_kind', () => {
      const task = setupTask('co-p3-6');
      wdb.createTask({
        goalId: task.goal_id, title: 'a1', description: 'd', parentTaskId: task.id,
        subtaskKind: 'acceptance_item',
      });
      const ids = wdb.materializeDecomposedSubtasks({
        task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }],
      });
      expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住
    });
  });

  // ── createDecomposedWorkGraph ──────────────────────────────────────

  describe('createDecomposedWorkGraph', () => {
    it('建出一张两节点的轻量图:impl(org-coder) → validate(org-validator),都是 agent_call', () => {
      const goal = wdb.createGoal({ companyId: 'co-p3-7', title: 'g', description: 'd' });
      const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
      const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-7', task });
      expect(graphId).toBeTruthy();

      const nodes = wdb.getDb()
        .prepare(`SELECT node_name, system_agent_id, node_kind FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`)
        .all(graphId);
      expect(nodes).toEqual([
        { node_name: 'impl', system_agent_id: 'org-coder', node_kind: 'agent_call' },
        { node_name: 'validate', system_agent_id: 'org-validator', node_kind: 'agent_call' },
      ]);

      const wg = wdb.getDb().prepare(`SELECT task_id, goal_id FROM work_graphs WHERE id = ?`).get(graphId);
      expect(wg.task_id).toBe(task.id);
      expect(wg.goal_id).toBe(goal.id);
    });

    it('这张图能被 createRun 直接拿去起一条 work run(机制打通,不真的执行)', () => {
      const goal = wdb.createGoal({ companyId: 'co-p3-8', title: 'g', description: 'd' });
      const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
      const graphId = wdb.createDecomposedWorkGraph({ companyId: 'co-p3-8', task });
      const run = wdb.createRun({
        graphKind: 'work', graphId, companyId: 'co-p3-8', goalId: goal.id, taskId: task.id,
      });
      expect(run.ok).not.toBe(false);
      expect(wdb.getRun(run.id).graph_kind).toBe('work');
      expect(wdb.getRun(run.id).graph_id).toBe(graphId);
    });
  });
});

describe('46 号文档 Phase 3(v2):路由层 enqueueTaskRun 的图选择', () => {
  const CID = 'route-p3v2-co';
  let wdb;
  let org;
  let server;
  let base;
  let origCompanyGet;

  async function call(method, url, body) {
    const r = await fetch(base + url, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    return [r.status, await r.json()];
  }

  beforeAll(async () => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3v2-dispatch-routes-'));
    const allModules = [
      ...MODULES, '../../services/graph-service', '../../services/org-graph-service',
      '../../services/subtask-planning', '../../routes/company',
    ];
    for (const m of allModules) {
      try { delete require.cache[require.resolve(m)]; } catch {}
    }
    wdb = require('../../services/work-graph-db');
    org = require('../../services/org-graph-service');
    ensureAgent(wdb, 'org-planner');
    ensureAgent(wdb, 'org-coder');
    ensureAgent(wdb, 'org-validator');

    const companyService = require('../../services/company-service');
    origCompanyGet = companyService.CompanyService.prototype.get;
    companyService.CompanyService.prototype.get = (companyId) =>
      (companyId === CID ? { id: CID, name: CID, status: 'running' } : null);

    // 起 /enqueue 前置门禁要求公司有 org graph——最小起一张(org-planner→org-coder→org-validator)
    expect(org.writeGraph(CID, {
      nodes: ['org-planner', 'org-coder', 'org-validator'],
      edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' },
        { from: 'org-coder', to: 'org-validator', condition: 'success' }],
    }).ok).toBe(true);

    // subtask-planning 的真实调用不走这个测试(VITEST 环境下 planSubtasks 自动短路,见该模块),
    // 不用额外注入桩。

    const express = require('express');
    const app = express();
    app.use(express.json());
    app.use('/api/company', require('../../routes/company'));
    await new Promise((resolve) => { server = app.listen(0, resolve); });
    base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`;
  });

  afterAll(() => {
    if (server) server.close();
    const companyService = require('../../services/company-service');
    if (origCompanyGet) companyService.CompanyService.prototype.get = origCompanyGet;
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch {}
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
    const allModules = [
      ...MODULES, '../../services/graph-service', '../../services/org-graph-service',
      '../../services/subtask-planning', '../../routes/company',
    ];
    for (const m of allModules) {
      try { delete require.cache[require.resolve(m)]; } catch {}
    }
  });

  it('普通顶层 task:enqueue 后走公司 org graph(graph_kind=org, graph_id=companyId)——回归,行为不变', async () => {
    const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' });
    const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });

    const [status, body] = await call('POST', `/tasks/${task.id}/enqueue`);
    expect(status).toBe(201);
    const run = wdb.getRun(body.runId);
    expect(run.graph_kind).toBe('org');
    expect(run.graph_id).toBe(CID);
  });

  it('decomposed_work 子任务:enqueue 后走轻量 work graph(graph_kind=work,graph_id 不是 companyId)', async () => {
    const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' });
    const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
    const child = wdb.createTask({
      goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id, subtaskKind: 'decomposed_work',
    });

    const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`);
    expect(status).toBe(201);
    const run = wdb.getRun(body.runId);
    expect(run.graph_kind).toBe('work');
    expect(run.graph_id).not.toBe(CID);

    const nodes = wdb.getDb()
      .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE work_graph_id = ? ORDER BY node_name`)
      .all(run.graph_id);
    expect(nodes.map((n) => n.node_name)).toEqual(['impl', 'validate']);
  });
});