/** * 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 () => { // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 // 真实存在的公司,CID 只是这份测试里的一个字符串标签,从没真的建过公司。 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; // 不设的话任何走到默认路径的写入都会往仓库 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, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, '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(); require('../helpers/ensure-test-company').removeTestCompany(CID); 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'); }); });