task-pool-claim.test.js 14.9 KB
/**
 * 46 号文档 Phase 3:任务池认领机制。
 *
 * 三层各测各的:
 *   1. `createTask` 的 subtaskKind/status 校验(纯 DB 层参数校验)
 *   2. `materializeClaimableSubtasks`(steps[] → claimable_work 行,含 depends_on 回填)
 *   3. `claimSubtask`/`unclaimSubtask` 的 CAS(全仓库第一个"认领"语义的并发保护)
 *   4. 路由层 claim/unclaim 端点(角色白名单 + CAS 的 HTTP 翻译)
 *
 * DB 层沿用 `task-decomposition-validator-gate.test.js` 的隔离手法;路由层沿用
 * `company-goal-task-routes.test.js` 的「真起 express、没有 supertest 用 fetch 打」手法。
 */
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-pool-claim.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = ['../../services/graph-db', '../../services/work-graph-db'];

describe('46 号文档 Phase 3: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');
  });

  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 校验 ──────────────────────────────────────────────

  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: 'claimable_work',
    })).toThrow(/只对子任务.*有意义/);
  });

  it('status="claimable" 但 subtaskKind 不是 claimable_work 时直接拒', () => {
    const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
    const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
    expect(() => wdb.createTask({
      goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id,
      subtaskKind: 'acceptance_item', status: 'claimable',
    })).toThrow(/status='claimable' 只对/);
  });

  it('status 非 draft/claimable 直接拒', () => {
    const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' });
    expect(() => wdb.createTask({
      goalId: goal.id, title: 't', description: 'd', status: 'running',
    })).toThrow(/status 只能是/);
  });

  it('正常创建 claimable_work 子任务:subtask_kind/status 落库正确', () => {
    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: 'claimable_work', status: 'claimable',
    });
    expect(child.subtask_kind).toBe('claimable_work');
    expect(child.status).toBe('claimable');
    expect(child.executor).toBe('graph'); // 默认值,认领时才会翻成 'agent'
    expect(child.assignee_agent_id).toBeNull();
  });

  // ── materializeClaimableSubtasks ─────────────────────────────────

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

    it('按 steps 顺序物化,depends_on 正确回填成真实 task id(含前向引用)', () => {
      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.materializeClaimableSubtasks({ task, steps });
      expect(ids).toHaveLength(3);

      const rows = ids.map((id) => wdb.getTask(id));
      expect(rows.every((r) => r.subtask_kind === 'claimable_work')).toBe(true);
      expect(rows.every((r) => r.status === 'claimable')).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.materializeClaimableSubtasks({ 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.materializeClaimableSubtasks({ task, steps });
      expect(first).toHaveLength(1);
      const second = wdb.materializeClaimableSubtasks({ task, steps });
      expect(second).toEqual([]);

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

    it('空 steps / 无 task 时返回 [],不报错', () => {
      const task = setupTask('co-p3-5');
      expect(wdb.materializeClaimableSubtasks({ task, steps: [] })).toEqual([]);
      expect(wdb.materializeClaimableSubtasks({ 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.materializeClaimableSubtasks({
        task, steps: [{ seq: 1, title: 'c1', depends_on_hint: [] }],
      });
      expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住
    });
  });

  // ── claimSubtask / unclaimSubtask CAS ────────────────────────────

  describe('claimSubtask / unclaimSubtask', () => {
    function makeClaimable(companyId) {
      const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
      const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
      return wdb.createTask({
        goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id,
        subtaskKind: 'claimable_work', status: 'claimable',
      });
    }

    it('认领成功:status/executor/assignee/claimed_at 全部正确落库', () => {
      const t = makeClaimable('co-p3-7');
      expect(wdb.claimSubtask(t.id, 'org-coder')).toBe(1);
      const after = wdb.getTask(t.id);
      expect(after.status).toBe('claimed');
      expect(after.executor).toBe('agent');
      expect(after.assignee_agent_id).toBe('org-coder');
      expect(after.claimed_at).toBeTruthy();
    });

    it('并发认领同一条:只有一方成功(changes CAS)', () => {
      const t = makeClaimable('co-p3-8');
      const r1 = wdb.claimSubtask(t.id, 'org-coder');
      const r2 = wdb.claimSubtask(t.id, 'org-coder'); // 模拟第二次竞争(同一行为不同调用)
      expect([r1, r2].sort()).toEqual([0, 1]); // 一个 1 一个 0,不是两个都 1
    });

    it('非 claimable 状态(比如已经是 claimed)认领失败,changes=0', () => {
      const t = makeClaimable('co-p3-9');
      wdb.claimSubtask(t.id, 'org-coder');
      expect(wdb.claimSubtask(t.id, 'someone-else')).toBe(0);
      expect(wdb.getTask(t.id).assignee_agent_id).toBe('org-coder'); // 没被顶掉
    });

    it('不是 claimable_work 的行认领失败', () => {
      const goal = wdb.createGoal({ companyId: 'co-p3-10', title: 'g', description: 'd' });
      const t = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); // 普通 task
      expect(wdb.claimSubtask(t.id, 'org-coder')).toBe(0);
    });

    it('放弃认领:claimed → claimable,executor 退回 graph,assignee 清空', () => {
      const t = makeClaimable('co-p3-11');
      wdb.claimSubtask(t.id, 'org-coder');
      expect(wdb.unclaimSubtask(t.id, 'org-coder')).toBe(1);
      const after = wdb.getTask(t.id);
      expect(after.status).toBe('claimable');
      expect(after.executor).toBe('graph');
      expect(after.assignee_agent_id).toBeNull();
      expect(after.claimed_at).toBeNull();
    });

    it('放弃认领:不是认领者本人放弃失败', () => {
      const t = makeClaimable('co-p3-12');
      wdb.claimSubtask(t.id, 'org-coder');
      expect(wdb.unclaimSubtask(t.id, 'someone-else')).toBe(0);
      expect(wdb.getTask(t.id).status).toBe('claimed'); // 没被动
    });
  });
});

describe('46 号文档 Phase 3:路由层 claim/unclaim', () => {
  const CID = 'route-p3-co';
  let wdb;
  let server;
  let base;
  let origCompanyGet;

  function hireInto(companyId, agentId, roleName) {
    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(agentId, agentId, `system/agents/${agentId}`, now, now);
    db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
        VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, roleName, now);
  }

  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(), 'p3-claim-routes-'));
    for (const m of [...MODULES, '../../routes/company']) {
      try { delete require.cache[require.resolve(m)]; } catch {}
    }
    wdb = require('../../services/work-graph-db');

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

    hireInto(CID, 'org-coder', 'org-coder');
    hireInto(CID, 'ceo', 'ceo'); // 用来验证"雇了但不在白名单"这条 403 分支

    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;
    for (const m of [...MODULES, '../../routes/company']) {
      try { delete require.cache[require.resolve(m)]; } catch {}
    }
  });

  function makeClaimable() {
    const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' });
    const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
    return wdb.createTask({
      goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id,
      subtaskKind: 'claimable_work', status: 'claimable',
    });
  }

  it('白名单角色认领成功', async () => {
    const t = makeClaimable();
    const [status, body] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' });
    expect(status).toBe(200);
    expect(body.task.status).toBe('claimed');
    expect(body.task.assigneeAgentId).toBe('org-coder');
    expect(body.task.subtaskKind).toBe('claimable_work');
  });

  it('非白名单角色认领:403', async () => {
    const t = makeClaimable();
    const [status, body] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'ceo' });
    expect(status).toBe(403);
    expect(body.error).toMatch(/白名单/);
  });

  it('缺 agentId:400', async () => {
    const t = makeClaimable();
    const [status] = await call('POST', `/tasks/${t.id}/claim`, {});
    expect(status).toBe(400);
  });

  it('不是可认领子任务(普通 task):400', async () => {
    const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'd' });
    const t = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
    const [status] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' });
    expect(status).toBe(400);
  });

  it('已被认领的再认领:409', async () => {
    const t = makeClaimable();
    await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' });
    const [status] = await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' });
    expect(status).toBe(409);
  });

  it('认领后放弃:状态正确回退', async () => {
    const t = makeClaimable();
    await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' });
    const [status, body] = await call('POST', `/tasks/${t.id}/unclaim`, { agentId: 'org-coder' });
    expect(status).toBe(200);
    expect(body.task.status).toBe('claimable');
    expect(body.task.assigneeAgentId).toBeNull();
  });

  it('不是认领者本人放弃:409', async () => {
    const t = makeClaimable();
    await call('POST', `/tasks/${t.id}/claim`, { agentId: 'org-coder' });
    const [status] = await call('POST', `/tasks/${t.id}/unclaim`, { agentId: 'ceo' });
    expect(status).toBe(409);
  });
});