task-dispatch-store.test.js 7.33 KB
/**
 * 批 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);
  });
});