graph-run-route-slot-busy.test.js 6.63 KB
/**
 * Unit Test — RunSlotBusyError 在 `routes/graph-run.js` 里翻成 409(27-P3C 批 3 对抗复核)
 *
 * 批 3 之后「公司的执行坑被占着」是**正常的业务拒绝**,不是服务器错误:换个时间同一个请求
 * 会成功。而 `sendError` 此前只认 `InvalidTransitionError`,其余一律 500 + 一句原文 ——
 * 调用方(前端、外部 agent 的 curl)看到 500 只会去重试或报故障,而这里唯一有用的动作是
 * 「等占坑那条收敛」或「去停掉它」。所以这条测试钉三件事,少一件这个 409 就等于没修:
 *   1. 状态码是 409,不是 500;
 *   2. 体里点得出**谁**占着坑(run id + task id)—— 少了它界面上只剩「坑满」,人无从判断等谁;
 *   3. hint 指向的那条路此刻真的走得通(占坑的是 running → /stop 可用,所以要出现它)。
 *
 * 走**真入口**:router 挂真 express、listen(0) 再 fetch(同 graph-run-route-io.test.js),
 * 不导出内部函数、不打桩 res.json —— sendError 是私有的,只有从 HTTP 这一头才测得到。
 *
 * 为什么用「对一条裸 run 的失败节点点重试」这个场景:`engine.startRun` 对**有 task 的** run
 * 撞上坑满时会退回队列(不抛),只有回不了队列的裸 run 才会把 RunSlotBusyError 抛到路由层
 * (见 graph-run-slot-recovery.test.js 的两条)。这就是 409 在生产里唯一到得了的路径。
 */
const fs = require('fs');
const os = require('os');
const path = require('path');
const express = require('express');

const TEST_DB_PATH = '/tmp/test-graph-run-slot-busy.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
  '../../services/graph-db',
  '../../services/org-graph-service',
  '../../services/work-graph-db',
  '../../services/company-paths',
  '../../services/graph-run-sse',
  '../../services/graph-engine',
  '../../services/task-settle',
  '../../services/task-scheduler',
  '../../routes/graph-run',
];

const CID = 'slotbusy-co';

describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => {
  let wdb;
  let server;
  let origin;
  let runsDir;
  let holderRunId;
  let holderTaskId;
  let deadRunId;
  let deadNodeId;

  beforeAll(async () => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    // 不设的话任何走到默认路径的写入都会往仓库 data/ 里堆
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slotbusy-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');

    const db = wdb.getDb();
    const now = new Date().toISOString();
    const insAgent = db.prepare(`
      INSERT OR IGNORE INTO system_agents
        (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
    `);
    for (const id of ['solo', 'solo2']) insAgent.run(id, id, `system/agents/${id}`, now, now);

    const graphA = wdb.createWorkGraph({ companyId: CID, nodes: ['solo'], edges: [] });
    const graphB = wdb.createWorkGraph({ companyId: CID, nodes: ['solo2'], edges: [] });
    expect(graphA.errors || []).toEqual([]);
    expect(graphB.errors || []).toEqual([]);

    // ① 占坑的那条:一条有 task 的 root run,直推 running(用例不需要真跑 agent)
    const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'g' });
    const task = wdb.createTask({ goalId: goal.id, companyId: CID, title: 't', description: 't' });
    holderTaskId = task.id;
    const holder = wdb.createRun({
      graphKind: 'work', graphId: graphA.id, companyId: CID, goalId: goal.id, taskId: task.id,
    });
    holderRunId = holder.id;
    db.prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(holderRunId);

    // ② 被挡的那条:**没有 task** 的已收敛 run(裸 run 退不回队列,所以错误会一路抛到路由层)
    const dead = wdb.createRun({ graphKind: 'work', graphId: graphB.id, companyId: CID });
    deadRunId = dead.id;
    db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(deadRunId);
    deadNodeId = wdb.getRunState(deadRunId).nodes[0].id;
    db.prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(deadNodeId);

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

  afterAll(() => {
    if (server) server.close();
    try { require('../../services/task-scheduler').stopSweeper(); } catch { /* 没起过就算了 */ }
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
    try { fs.rmSync(runsDir, { 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)];
  });

  it('坑满 → 409 + 点名谁占着坑 + 一条此刻真走得通的下一步', async () => {
    const r = await fetch(`${origin}/api/graph-runs/${deadRunId}/nodes/${deadNodeId}/retry`, { method: 'POST' });
    const body = await r.json();

    expect(r.status).toBe(409); // 修之前:500 + 一句 "company … 的执行坑已满" 原文
    expect(body.reason).toBe('run-slot-busy');
    expect(body.companyId).toBe(CID);
    // 批 4 起额度是 per-company 的(company.json.max_parallel_tasks,这家没配 → 缺省 1)。
    // 钉的是「409 里报的额度 = 准入真的用的那个额度」,不是钉某个常量。
    expect(body.max).toBe(wdb.maxActiveRootRuns(CID));
    expect(body.max).toBe(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS);

    // 「挡我的是谁」:run id 与 task id 都要给得出来,否则界面上只剩「坑满」
    expect(body.holders).toEqual([
      expect.objectContaining({ runId: holderRunId, taskId: holderTaskId, runStatus: 'running' }),
    ]);

    // hint 必须指向一条**此刻真的存在**的行为:占坑那条是 running,/stop 的可用条件里有它
    expect(body.hint).toContain(`/api/graph-runs/${holderRunId}/stop`);
  });

  it('坑满不该动到节点:这次拒绝是干净的(半生效比失败更难查)', () => {
    const node = wdb.getRunNode(deadNodeId);
    expect(node.status).toBe('fail');
    expect(node.attempt).toBe(0);
    expect(wdb.getRun(deadRunId).run_status).toBe('completed');
  });
});