/** * 批 5 第 3 步:`/task/dispatch` 的存储从 system/tasklog/*.json 换成 tasks 表。 * * **这一步唯一不能坏的是契约**:`/task/dispatch` 与 `/task/report` 的 URL、请求体、响应体 * 印在 `packages/agent-core/base/common/tools-base.md` 里,是所有 agent 的工具说明 —— * 改一个字等于同时改所有 agent 的提示词。所以这份用例盯的是「形状没变」,不是「实现对不对」。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createRequire } from 'module'; import fs from 'fs'; import os from 'os'; import path from 'path'; const require_ = createRequire(import.meta.url); describe('派单存储换成 tasks 表', () => { let wdb, app, srv, base; beforeAll(async () => { process.env.GRAPH_DB_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-store-')), 'graph.sqlite'); for (const m of ['../../services/work-graph-db', '../../services/graph-db', '../../routes/agent-chat']) { delete require_.cache[require_.resolve(m)]; } wdb = require_('../../services/work-graph-db'); const express = require_('express'); app = express(); app.use(express.json()); app.use('/api/agent', require_('../../routes/agent-chat')); await new Promise((r) => { srv = app.listen(0, '127.0.0.1', r); }); base = `http://127.0.0.1:${srv.address().port}`; }); afterAll(() => { try { srv?.close(); } catch {} }); const post = async (p, body) => { const r = await fetch(base + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return { status: r.status, body: await r.json().catch(() => ({})) }; }; /** 用真实存在的两个本体(端点会校验目录存在) */ const FROM = 'ceo'; const TO = 'qa-judge'; it('派单落进 tasks 表,且响应体是老形态(agent 提示词里印着它)', async () => { const r = await post('/api/agent/task/dispatch', { from: FROM, to: TO, task: '守卫用例:形态检查', priority: 'P1', goalId: 'O-900', nodeId: 'O-900.1.1', autoExecute: false, }); expect(r.status).toBe(200); // 响应体的字段一个都不能少 —— 少了就是改了所有 agent 的工具说明 for (const k of ['success', 'taskId', 'from', 'to', 'task', 'priority', 'autoExecute', 'message']) { expect(r.body).toHaveProperty(k); } expect(r.body.success).toBe(true); const row = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(r.body.taskId); expect(row).toBeTruthy(); expect(row.executor).toBe('agent'); // 调度器据此跳过它(图任务才排队) expect(row.assignee_agent_id).toBe(TO); expect(row.created_by).toBe(FROM); expect(row.status).toBe('running'); // meta 的 dispatched ↔ 表的 running // 每条派单自带一个 goal:goal_id 是 NOT NULL 真外键,派单没有「人提的需求」那一层 const goal = wdb.getDb().prepare('SELECT * FROM goals WHERE id = ?').get(row.goal_id); expect(goal.title).toContain(FROM); expect(goal.okr_node_id).toBe('O-900.1.1'); // objectiveId 由它的首段推导,不单独存 }); it('去重仍然拦得住,且 409 的响应体形态没变', async () => { const body = { from: FROM, to: TO, task: '守卫用例:去重', autoExecute: false }; const first = await post('/api/agent/task/dispatch', body); expect(first.status).toBe(200); const dup = await post('/api/agent/task/dispatch', body); expect(dup.status).toBe(409); for (const k of ['success', 'error', 'existingTaskId', 'existingTask', 'message']) { expect(dup.body).toHaveProperty(k); } expect(dup.body.existingTaskId).toBe(first.body.taskId); }); it('汇报回路:状态 / summary / 完成时间都落到表上', async () => { const d = await post('/api/agent/task/dispatch', { from: FROM, to: TO, task: '守卫用例:汇报回路', autoExecute: false, }); const rep = await post('/api/agent/task/report', { taskId: d.body.taskId, from: TO, to: FROM, status: 'done', summary: '干完了', }); expect(rep.status).toBe(200); const row = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(d.body.taskId); expect(row.status).toBe('completed'); // meta 的 done ↔ 表的 completed expect(row.summary).toBe('干完了'); expect(row.finished_at).toBeTruthy(); }); it('派单不被调度器碰 —— 它没有 run 行,出队会把它误收成 cancelled', async () => { for (const m of ['../../services/task-scheduler']) delete require_.cache[require_.resolve(m)]; const scheduler = require_('../../services/task-scheduler'); const d = await post('/api/agent/task/dispatch', { from: FROM, to: TO, task: '守卫用例:调度器别碰我', autoExecute: false, }); const taskId = d.body.taskId; const row0 = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(taskId); expect(row0.executor).toBe('agent'); // 把它摆成「最像可出队」的样子:queued + auto + 无依赖。只有 executor 这一维拦得住它。 wdb.getDb().prepare("UPDATE tasks SET status='queued', execution_mode='auto' WHERE id=?").run(taskId); expect(scheduler.listQueue(row0.company_id).map((t) => t.id)).not.toContain(taskId); scheduler.tick(row0.company_id); scheduler.sweep(); // 关键断言:没有被出队、更没有被 abandonRun 收成 cancelled const row1 = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(taskId); expect(row1.status).toBe('queued'); expect(row1.current_run_id).toBeFalsy(); }); // 曾经的缺陷:report 的自唤醒回调写死 port 3010,跑一次测试就往线上发一条 CEO chat、 // 起一个常驻 claude CLI 会话、并留下 system/agents/ceo/_global/callback-T-*。 it('测试环境下汇报不发对外 HTTP,也不新建 callback 目录', async () => { const http = require_('http'); const orig = http.request; const calls = []; http.request = function (...args) { calls.push(args[0]); return orig.apply(this, args); }; const ceoState = path.resolve(process.cwd(), 'system', 'agents', 'ceo', '_global'); const cbBefore = fs.existsSync(ceoState) ? fs.readdirSync(ceoState).filter((n) => n.startsWith('callback-')) : []; try { const d = await post('/api/agent/task/dispatch', { from: FROM, to: TO, task: '守卫用例:回调不外发', autoExecute: false, }); await post('/api/agent/task/report', { taskId: d.body.taskId, from: TO, to: FROM, status: 'done', summary: '干完了', }); await new Promise((r) => setTimeout(r, 200)); // 回调走 setImmediate,给它跑的机会 expect(calls).toEqual([]); } finally { http.request = orig; } const cbAfter = fs.existsSync(ceoState) ? fs.readdirSync(ceoState).filter((n) => n.startsWith('callback-')) : []; expect(cbAfter).toEqual(cbBefore); }); it('不再往 system/tasklog/ 写新文件', async () => { const dir = path.resolve(process.cwd(), 'system', 'tasklog'); const before = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0; await post('/api/agent/task/dispatch', { from: FROM, to: TO, task: '守卫用例:不写文件', autoExecute: false }); const after = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0; expect(after).toBe(before); }); });