/** * 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'); }); });