graph-run-route-next-auto-guard.test.js 5.81 KB
/**
 * Unit Test — POST /api/graph-runs/:id/next 必须拒绝 auto 模式的 run(T-mtgxcnfz 复现)
 *
 * `execution_mode='auto'` 的 run 在 `startRun` 里已经挂了一个 `runAuto` 后台驱动循环
 * (graph-engine.js 的 `runAuto`),会自己不停调 `advanceTree` 往下推。此前 `/next` 路由
 * 只检查 `run_status ∈ {running, paused}`,不看 `execution_mode`——人在控制台上对一条正在
 * auto 跑的 run 手动点「下一步」,会和后台驱动循环并发推进同一条 run,两条路径互不知情。
 *
 * 真实事故:CEO 验证 44 号修复时手动反复调 /next 撞上这个口子,导致 task/goal 被误判
 * cancelled/failed,人工排查+纠正过数据(见 T-mtgxcnfz 任务描述)。
 *
 * 走真入口:router 挂真 express,listen(0) 再 fetch(与 graph-run-route-io.test.js 同法)。
 */
const fs = require('fs');
const os = require('os');
const path = require('path');
const express = require('express');

const TEST_DB_PATH = '/tmp/test-graph-run-route-next-auto-guard.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
  '../../services/graph-db',
  '../../services/graph-service',
  '../../services/org-graph-service',
  '../../services/work-graph-db',
  '../../services/company-paths',
  '../../services/graph-run-sse',
  '../../services/graph-engine',
  '../../routes/graph-run',
];

const CID = 'co-next-auto-guard';

describe('POST /api/graph-runs/:id/next — auto 模式必须 400,不许和后台驱动并发推进', () => {
  let wdb;
  let engine;
  let srv;
  let base;
  let runsDir;

  beforeAll(async () => {
    require('../helpers/ensure-test-company').ensureTestCompany(CID);
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-next-auto-guard-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
    engine = require('../../services/graph-engine');

    const app = express();
    app.use(express.json());
    app.use('/api/graph-runs', require('../../routes/graph-run'));
    await new Promise((resolve) => {
      srv = app.listen(0, '127.0.0.1', () => {
        base = `http://127.0.0.1:${srv.address().port}`;
        resolve();
      });
    });
  });

  afterAll(async () => {
    if (srv) await new Promise((r) => srv.close(r));
    require('../helpers/ensure-test-company').removeTestCompany(CID);
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  afterEach(() => {
    engine.setAgentExecutor(null);
  });

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

  it('run 处于 running 且 execution_mode=auto 时,/next 返回 400,不推进节点', async () => {
    ensureAgents(['w']);
    const g = wdb.createWorkGraph({ companyId: CID, nodes: ['w'], edges: [] });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CID, executionMode: 'auto' });
    engine.markRunReady(run.id);

    // executor 挂起不返回:让节点卡在 running,run 停在 running + auto,
    // 这正是「后台驱动循环还没跑完」的真实窗口——/next 必须在这个窗口里也拒绝。
    let releaseNode;
    engine.setAgentExecutor(() => new Promise((resolve) => { releaseNode = resolve; }));

    const { driver } = engine.startRun(run.id, { mode: 'auto' });
    // 等 advanceOne 真正把节点从 ready 推成 running 再发请求,避免时序竞态误判
    await new Promise((resolve) => {
      const check = () => {
        const node = wdb.getRunState(run.id).nodes.find((n) => n.name === 'w');
        if (node && node.status === 'running') return resolve();
        setImmediate(check);
      };
      check();
    });

    const before = wdb.getRun(run.id);
    expect(before.execution_mode).toBe('auto');
    expect(before.run_status).toBe('running');

    const r = await fetch(`${base}/api/graph-runs/${run.id}/next`, { method: 'POST' });
    expect(r.status).toBe(400);
    const body = await r.json();
    expect(body.error).toMatch(/auto/);

    // 节点没被 /next 动过——还是同一个 running 节点,没有被拾取第二次
    const after = wdb.getRunState(run.id).nodes.find((n) => n.name === 'w');
    expect(after.status).toBe('running');
    expect(after.attempt).toBe(before && 0); // 未被推进,attempt 维持初始值

    releaseNode({ status: 'success' });
    await driver;
    expect(wdb.getRun(run.id).run_status).toBe('completed');
  });

  it('manual 模式的 running run,/next 仍然放行(不误伤既有用法)', async () => {
    ensureAgents(['m']);
    const g = wdb.createWorkGraph({ companyId: CID, nodes: ['m'], edges: [] });
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CID, executionMode: 'manual' });
    engine.markRunReady(run.id);
    engine.setAgentExecutor(async () => ({ status: 'success' }));
    engine.startRun(run.id, { mode: 'manual' });

    const r = await fetch(`${base}/api/graph-runs/${run.id}/next`, { method: 'POST' });
    expect(r.status).toBe(200);
    expect(wdb.getRun(run.id).run_status).toBe('completed');
  });
});