/** * 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']; // 48 号文档:decomposed_work 子任务创建时必须带 acceptanceContract。这是一份最小合法契约 // (benchmark 段**本身**,不是三段式契约的 wrapper——真实起 agent 验证过这就是它交回的形状, // 见 work-graph-db.createTask 的注释),权重之和=1,测材料化/图选择等与契约本身无关的机制时 // 复用它,不重复拼。 const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] }; 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', acceptanceContract: MIN_ACCEPTANCE, }); expect(child.subtask_kind).toBe('decomposed_work'); expect(child.status).toBe('draft'); // 没有专属状态了 expect(child.executor).toBe('graph'); // 默认值不变,走 enqueueTaskRun 一样能跑 }); // ── 48 号文档:验收契约创建时 1:1 强绑定 ────────────────────────────── it('decomposed_work 不带 acceptanceContract 直接拒——创建时硬约束(48 号文档验收标准 1)', () => { 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: 'decomposed_work', })).toThrow(/必须带 acceptanceContract/); }); it('非 decomposed_work 的普通 task 传 acceptanceContract 直接拒——本轮范围只收这条链路', () => { const goal = wdb.createGoal({ companyId: 'co-p3-1', title: 'g', description: 'd' }); expect(() => wdb.createTask({ goalId: goal.id, title: 't', description: 'd', acceptanceContract: MIN_ACCEPTANCE, })).toThrow(/只对 decomposed_work 子任务有意义/); }); it('acceptanceContract 形状不合法(权重之和不为 1)被 goal-contract.validateContract 挡下', () => { 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: 'decomposed_work', acceptanceContract: { dimensions: [{ name: 'a', weight: 0.5, pass_threshold: 80 }] }, // 和不为 1 })).toThrow(/acceptanceContract 不合法/); }); it('acceptanceContract 合法时原样落库,读回后 JSON.parse 与传入等价', () => { 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', acceptanceContract: MIN_ACCEPTANCE, }); expect(JSON.parse(child.acceptance_contract)).toEqual(MIN_ACCEPTANCE); }); it('acceptance_contract 是冻结字段:queued_at 非空后 patch 会被 FrozenFieldError 拒', () => { 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', acceptanceContract: MIN_ACCEPTANCE, }); wdb.updateTask(child.id, { queuedAt: new Date().toISOString() }); expect(() => wdb.updateTask(child.id, { acceptanceContract: JSON.stringify(MIN_ACCEPTANCE) })) .toThrow(/acceptanceContract/); }); // ── 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], acceptance: MIN_ACCEPTANCE }, // 前向引用:依赖还没建出来的第三步 { seq: 2, title: '第二步', depends_on_hint: [1], acceptance: MIN_ACCEPTANCE }, { seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, ]; 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], acceptance: MIN_ACCEPTANCE }]; 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: [], acceptance: MIN_ACCEPTANCE }]; 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: [], acceptance: MIN_ACCEPTANCE }], }); expect(ids).toHaveLength(1); // 没有被 acceptance_item 那条已存在的行挡住 }); // ── 48 号文档:acceptance 缺失/不合法时单步跳过,不作废整批 ────────────── it('单步 acceptance 缺失:那一步跳过,同批其它合法步骤照常物化(宽松解析)', () => { const task = setupTask('co-p3-9'); const steps = [ { seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }, // 缺 acceptance,会被 createTask 拒 { seq: 2, title: '带 acceptance 的一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE }, ]; const ids = wdb.materializeDecomposedSubtasks({ task, steps }); expect(ids).toHaveLength(1); expect(wdb.getTask(ids[0]).title).toBe('带 acceptance 的一步'); }); it('全部 step 都没有 acceptance:整批跳过,返回 [](不是「无 steps」那条早退路径)', () => { const task = setupTask('co-p3-10'); const ids = wdb.materializeDecomposedSubtasks({ task, steps: [{ seq: 1, title: '唯一一步,没有 acceptance', depends_on_hint: [] }], }); expect(ids).toEqual([]); const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n; expect(n).toBe(0); }); }); // ── 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', acceptanceContract: MIN_ACCEPTANCE, }); 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']); }); // ── 48 号文档:验收契约随 enqueue 落进新 run 的 artifacts/acceptance.json ────── it('decomposed_work 子任务的 acceptanceContract 会被写进新 run 的 artifacts/acceptance.json,goal-contract 能读回并判为合法', async () => { const goalContract = require('../../services/goal-contract'); 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', acceptanceContract: MIN_ACCEPTANCE, }); const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); expect(status).toBe(201); // org-validator 在这条轻量链路上判卷的唯一依据来源:goal-contract 能读到、能验证通过, // 就是「不会再撞 input_underspecified」这件事的直接证据(46 号文档 Phase 3 v2 真实回归 // 撞见的那次死锁,见 48 号文档「触发场景」)。 const runDir = goalContract.resolveRunDir(CID, body.runId); const loaded = goalContract.readContract(runDir); expect(loaded).not.toBeNull(); expect(loaded.contract.benchmark).toEqual(MIN_ACCEPTANCE); expect(goalContract.validateContract(loaded.contract).ok).toBe(true); const files = wdb.findFiles({ companyId: CID, taskId: child.id, kinds: ['acceptance'] }); expect(files.length).toBeGreaterThan(0); }); it('decomposed_work 子任务没有 acceptanceContract(存量缺口)时 enqueue 仍然成功,只是不写 acceptance.json', async () => { // 走不到这里其实很难——createTask 现在强制要求 decomposed_work 带 acceptanceContract。 // 这条测的是「万一有一条存量行是列上线前物化的(acceptance_contract=NULL)」, // enqueueTaskRun 那段 best-effort 写入不该因为它是 NULL 就抛出、拖累入队本身。 const goalContract = require('../../services/goal-contract'); 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', acceptanceContract: MIN_ACCEPTANCE, }); // 模拟存量缺口:直接改列为 NULL(正常创建路径已经不允许这个状态) wdb.getDb().prepare(`UPDATE tasks SET acceptance_contract = NULL WHERE id = ?`).run(child.id); const [status, body] = await call('POST', `/tasks/${child.id}/enqueue`); expect(status).toBe(201); const runDir = goalContract.resolveRunDir(CID, body.runId); expect(goalContract.locateContract(runDir)).toBeNull(); }); });