ceo-created-task-gates.test.js 10.7 KB
/**
 * Unit Tests — ceo 自主创建的 task 仍然受既有闸门约束(42 号文档 Phase 2,39 号文档 §四.4)
 *
 * 39 号文档 §四.4 拍板:"ceo 全权自主 enqueue,但 max_parallel_tasks / DEFAULT_TASK_BUDGET /
 * goal 级 org-validator 这几道闸门必须照样拦住它"——这几道闸门挂在数据层/调度器层,理论上
 * 不认"谁创建的",天然覆盖,但落地时没有补一条测试钉死这一点(§六"已知遗留"记过一笔)。
 * 这份文件补上,走 ceo 实际会用的通路(POST /tasks draft:true + created_by:'ceo' → /enqueue),
 * 不抄近道直接摆数据库行。
 *
 * 隔离手法与 task-entry-draft-mode.test.js / goal-task-acceptance.test.js 一致。
 */
const fs = require('fs');
const os = require('os');
const path = require('path');

const TEST_DB_PATH = '/tmp/test-ceo-created-task-gates.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies');
const MODULES = [
  '../../services/graph-db',
  '../../services/work-graph-db',
  '../../services/org-graph-service',
  '../../services/graph-run-sse',
  '../../services/graph-engine',
  '../../services/task-settle',
  '../../services/goal-validation',
  '../../routes/company',
];

let wdb;
let orgSvc;
let engine;
let server;
let base;
const createdCompanies = [];

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()];
}

function ensureAgents(ids) {
  const db = wdb.getDb();
  const now = new Date().toISOString();
  const ins = 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', ?, ?)
  `);
  for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
}

/** 真实写一份 company.json(不是 mock)——maxActiveRootRuns() 直接读盘,绕不过去。 */
function makeCompany(companyId, extra = {}) {
  const dir = path.join(COMPANIES_DIR, companyId);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...extra }));
  createdCompanies.push(companyId);

  ensureAgents(['org-planner', 'org-coder', 'org-validator']);
  expect(
    orgSvc.writeGraph(companyId, {
      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);
}

describe('ceo 自主创建的 task 仍然受既有闸门约束(39 号文档 §四.4)', () => {
  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(), 'ceo-task-gates-runs-'));
    for (const m of MODULES) delete require.cache[require.resolve(m)];

    wdb = require('../../services/work-graph-db');
    orgSvc = require('../../services/org-graph-service');
    engine = require('../../services/graph-engine');

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

  afterAll(() => {
    if (server) server.close();
    for (const cid of createdCompanies) {
      fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true });
    }
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
    try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  // ──────────────────────────────────────────────────────────────
  // 1. max_parallel_tasks:坑满了,ceo 建的任务一样排队,不能插队
  // ──────────────────────────────────────────────────────────────
  it('max_parallel_tasks=1 时坑已被占,ceo 走 draft+enqueue 建的第二条任务落 queued,不会插队执行', async () => {
    const cid = 'ceo-gate-slots';
    makeCompany(cid, { max_parallel_tasks: 1 });

    // 人类下达第一条,占满唯一的执行位
    const [s1, taskA] = await call('POST', `/${cid}/tasks`, { task: '人类下达的任务,占坑' });
    expect(s1).toBe(201);
    expect(wdb.getTask(taskA.taskId).status).toBe('running');

    // ceo 走它自己的通路:draft 建 → enqueue
    const [s2, taskB] = await call('POST', `/${cid}/tasks`, {
      task: 'ceo 自主提的第二条任务', draft: true, created_by: 'ceo',
    });
    expect(s2).toBe(201);
    expect(taskB.status).toBe('draft');

    const [s3, enqueued] = await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`);
    expect(s3).toBe(201);

    // 坑满:这条任务必须落 queued,不能因为是 ceo 建的就获得一个额外的执行位
    const row = wdb.getTask(taskB.taskId);
    expect(row.status).toBe('queued');
    expect(enqueued.status).toBe('queued');
    const run = wdb.getRun(row.current_run_id);
    expect(run.run_status).toBe('queued');
  });

  // ──────────────────────────────────────────────────────────────
  // 2. DEFAULT_TASK_BUDGET:预算给得再小,ceo 建的任务也不会被特殊放宽
  // ──────────────────────────────────────────────────────────────
  it('ceo 建的任务预算耗尽照样判 budget-exhausted,不会因为创建者是 agent 就获得额外预算', async () => {
    const cid = 'ceo-gate-budget';
    makeCompany(cid);

    const tinyBudget = { total_agent_calls: 0, total_tokens: 0, wallclock_ms: 7_200_000 };
    const [s1, task] = await call('POST', `/${cid}/tasks`, {
      task: 'ceo 自主提的任务,预算给得极小',
      draft: true,
      created_by: 'ceo',
      budget: tinyBudget,
    });
    expect(s1).toBe(201);

    const [s2] = await call('POST', `/${cid}/tasks/${task.taskId}/enqueue`);
    expect(s2).toBe(201);

    const run = wdb.getRun(wdb.getTask(task.taskId).current_run_id);
    expect(run.budget_json).toBeTruthy();
    const parsed = JSON.parse(run.budget_json);
    expect(parsed.total_agent_calls).toBe(0); // 存进去的确实是那份小预算,没有被谁悄悄改大

    // checkBudget 不认「谁建的」,只认预算数字本身
    const gate = engine.checkBudget(run);
    expect(gate.ok).toBe(false);
    expect(gate.exhausted).toContain('total_agent_calls');
  });

  // ──────────────────────────────────────────────────────────────
  // 3. goal 级 org-validator:ceo 建的多 task goal 全部 completed 后,收敛验收照样跑一次
  // ──────────────────────────────────────────────────────────────
  it('ceo 建的两条 task 全 completed 后,goal 级 org-validator 照样被调一次(不因创建者是 agent 而跳过)', async () => {
    const cid = 'ceo-gate-goal-validation';
    makeCompany(cid);

    const goal = wdb.createGoal({ companyId: cid, title: '两步都由 ceo 建的目标', description: '测试用' });

    const [s1, taskA] = await call('POST', `/${cid}/tasks`, {
      task: 'ceo 建的第一条子任务', draft: true, created_by: 'ceo', goal_id: goal.id,
    });
    expect(s1).toBe(201);
    await call('POST', `/${cid}/tasks/${taskA.taskId}/enqueue`);

    const [s2, taskB] = await call('POST', `/${cid}/tasks`, {
      task: 'ceo 建的第二条子任务', draft: true, created_by: 'ceo', goal_id: goal.id,
    });
    expect(s2).toBe(201);
    await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`);

    expect(wdb.getTask(taskA.taskId).created_by).toBe('ceo');
    expect(wdb.getTask(taskB.taskId).created_by).toBe('ceo');

    const validation = require('../../services/goal-validation');
    const seen = [];
    validation.setValidateGoal(async (ctx) => {
      seen.push(ctx);
      return { pass: true, verdict: '两条子任务合起来确实达成了目标', tokens: 42 };
    });

    try {
      const sse = require('../../services/graph-run-sse');
      const runA = wdb.getTask(taskA.taskId).current_run_id;
      let runB = wdb.getTask(taskB.taskId).current_run_id;
      expect(wdb.getRun(runB).run_status).toBe('queued'); // 同公司同一时间只有一个执行位

      // 先让 runA 收尾,空出执行位。调度器出队走的是 setImmediate(挂在 transitionRun 钩子上),
      // 这条用例不测调度时序本身(那是 task-slots-parallel.test.js 的职责)——坑空之后直接
      // 用 updateRunStatus 把 runB 推 ready,走的还是真实的准入事务,只是不等 setImmediate。
      sse.transitionRun(runA, wdb.getRun(runA).run_status, 'completed', {}, { summary: '用例直推终态' });
      wdb.updateRunStatus(runB, 'queued', 'ready');
      expect(wdb.getRun(runB).run_status).toBe('ready'); // 坑已空出,推 ready 成功
      sse.transitionRun(runB, 'ready', 'running', {}, { summary: '用例直推终态' });
      sse.transitionRun(runB, 'running', 'completed', {}, { summary: '用例直推终态' });

      const { isPendingVerdict } = require('../../services/task-settle');
      for (let i = 0; i < 50; i += 1) {
        const v = wdb.getGoal(goal.id).verdict;
        if (v && !isPendingVerdict(v)) break;
        await new Promise((r) => setImmediate(r));
      }

      expect(seen).toHaveLength(1); // 一个 goal 只验一次,即便两条子任务都是 ceo 建的
      expect(seen[0].goal.id).toBe(goal.id);
      expect(seen[0].tasks.map((t) => t.created_by).sort()).toEqual(['ceo', 'ceo']);
      expect(wdb.getGoal(goal.id).status).toBe('done');
    } finally {
      validation.setValidateGoal(null);
    }
  });
});