/** * 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); }); });