/** * 批 3 排队 —— **HTTP 层**(27-P3C §六.3) * * 与另外两份的分工: * · `task-scheduler.test.js` 单个原语的形状(准入守卫、CAS 的 WHERE、唯一索引的取值域) * · `task-queue-acceptance.test.js` 服务层端到端(推 `sse.transitionRun` 这个漏斗,看队伍动没动) * · **本文件** 真的打 HTTP,验「端点接上了没有」 * * 为什么非要有这一份:批 3 的前一轮把 `launchTaskRun` 改名成 `enqueueTaskRun`,**两个调用点 * 一个都没改** —— `POST /:cid/tasks` 与 `goals/:goalId/dispatch` 一打就是 * `ReferenceError: launchTaskRun is not defined`,而队列的服务层测试全绿:它们一律直接调 * services,从不经过路由。同时 `promoteQueuedTask` / `cancelQueuedRun` / `queueReply` / * `queuePositions` 四个函数**零调用方**躺在文件里,注释写着「本函数是人工出队那条路」, * 而那条路根本没有入口。这正是 §九.4quater 点名的惯犯模式:「实现在、零调用方」。 * * 所以这份的每一条用例都从 URL 打进去。**能被服务层测试覆盖的语义这里不重复验**, * 只验路由这一层独有的东西:端点存在、状态码、响应体形状、以及「谁在按钮上点得动」。 * * ⚠️ 先设 env 再 require(graph-engine / graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR, * 先 require 再设会往仓库 data/companies/ 里写,而且不报错)。 */ const fs = require('fs'); const os = require('os'); const path = require('path'); const TEST_DB_PATH = '/tmp/test-task-queue-routes.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/graph-run-sse', '../../services/graph-engine', '../../services/task-settle', '../../services/task-scheduler', '../../services/goal-validation', '../../routes/company', ]; const ORG_NODES = ['org-planner', 'org-coder', 'org-validator']; const ORG_EDGES = [ { from: 'org-planner', to: 'org-coder', condition: 'success' }, { from: 'org-coder', to: 'org-validator', condition: 'success' }, ]; let wdb; let sse; let scheduler; let orgSvc; let runsDir; let server; let origCompanyGet; async function call(method, url, body) { const r = await fetch(`http://127.0.0.1:${server.address().port}${url}`, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); return [r.status, await r.json()]; } let seq = 0; const companies = []; /** 每个用例一家独立公司:执行坑(唯一索引)是按 company 约束的 */ function newCompany() { const cid = `qroute-co-${++seq}`; companies.push(cid); expect(orgSvc.writeGraph(cid, { nodes: ORG_NODES, edges: ORG_EDGES }).ok).toBe(true); return cid; } /** * `OkrService` / `ensureProjectRoot` 的根是硬编码的仓库 `data/`,不认任何 env。 * 只删本文件造出来的前缀目录,且必须确认里面没有 `company.json`(真公司一定有那个文件)。 */ function cleanupLeakedCompanyDirs() { const root = path.resolve(__dirname, '../../../data/companies'); if (!fs.existsSync(root)) return; for (const cid of companies) { const dir = path.join(root, cid); if (!fs.existsSync(dir) || fs.existsSync(path.join(dir, 'company.json'))) continue; try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } } } /** 等一个条件成立:从漏斗钩子到「下一条真的 running」中间隔着 setImmediate,拍脑袋数拍数不稳 */ async function waitFor(fn, label, timeoutMs = 2000) { const deadline = Date.now() + timeoutMs; let last; for (;;) { last = fn(); if (last) return last; if (Date.now() > deadline) throw new Error(`等待超时:${label}(最后一次取到 ${JSON.stringify(last)})`); await new Promise((r) => setImmediate(r)); } } describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { beforeAll(async () => { 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(), 'p3c-queue-routes-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); sse = require('../../services/graph-run-sse'); orgSvc = require('../../services/org-graph-service'); scheduler = require('../../services/task-scheduler'); // goal 级验收默认会真起 Claude CLI(§四.10):本文件有用例会让一个 goal 下的 task 全终态 require('../../services/goal-validation') .setValidateGoal(async () => ({ pass: true, verdict: '用例注入:pass', tokens: 0 })); const { CompanyService } = require('../../services/company-service'); origCompanyGet = CompanyService.prototype.get; CompanyService.prototype.get = (companyId) => ({ id: companyId, name: companyId, status: 'running' }); 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); }); }); afterAll(async () => { // 等一拍:末尾用例的 setImmediate 钩子可能还没出栈,它一跑就会把刚删掉的目录建回来 await new Promise((r) => setTimeout(r, 120)); scheduler.stopSweeper(); if (server) server.close(); if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet; require('../../services/goal-validation').setValidateGoal(null); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ } cleanupLeakedCompanyDirs(); delete process.env.GRAPH_DB_PATH; delete process.env.GRAPH_RUNS_DIR; for (const m of MODULES) delete require.cache[require.resolve(m)]; }); beforeEach(() => { scheduler.stopSweeper(); // 队伍必须靠端点和漏斗钩子往前走,不是靠 60s 兜底蒙混过关 }); /** 建一个 N 条 task 的 goal(同步路径,不调 LLM),返回 goal 视图 */ async function newGoal(cid, tasks, extra = {}) { const [status, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '发一个版本', tasks, split: false, ...extra, }); expect(status).toBe(201); return goal; } // ──────────────────────────────────────────────────────────────── // 端点存在性:本文件存在的**全部理由** // ──────────────────────────────────────────────────────────────── it('五个队列端点都真的挂上了 —— 没接上时它们回 404,而不是 4xx/2xx', async () => { // 这一条挡的是「函数写好了、路由没接」。上一轮四个队列函数零调用方躺在 company.js 里, // 服务层测试全绿,因为它们从不经过路由。这里只看「路由认不认这个 URL」: // 业务上的拒绝(409)也算认识 —— 404 才是没接上。 const cid = newCompany(); const goal = await newGoal(cid, [{ description: '干活' }]); const taskId = goal.tasks[0].taskId; for (const p of ['enqueue', 'start', 'cancel', 'rerun']) { const [status] = await call('POST', `/api/company/${cid}/tasks/${taskId}/${p}`); expect({ path: p, status }).not.toMatchObject({ status: 404 }); expect({ path: p, status }).not.toMatchObject({ status: 500 }); } const [dispatchStatus] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(dispatchStatus).not.toBe(404); expect(dispatchStatus).not.toBe(500); }); // ──────────────────────────────────────────────────────────────── // 验收 3:409 消失 // ──────────────────────────────────────────────────────────────── it('坑被占着时 dispatch 与 POST /tasks 都回 201 + queuePosition,不再 409(验收 3)', async () => { const cid = newCompany(); // 先占掉这家公司唯一的执行坑 const first = await newGoal(cid, [{ description: '第一件活' }]); const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${first.goalId}/dispatch`); expect(d1).toBe(201); expect(r1.started.taskId).toBe(first.tasks[0].taskId); // 存量入口:以前这里是 409「已有进行中的任务」 const [s2, b2] = await call('POST', `/api/company/${cid}/tasks`, { task: '插队的活' }); expect(s2).toBe(201); expect(b2.status).toBe('queued'); expect(b2.queuePosition).toBe(1); // 存量响应字段一个都不能少:外部 agent 的 curl 直接读它们(URL 与请求体写在 tools-base.md 里) expect(b2.runId).toMatch(/^run_/); expect(b2.links.next).toBe(`/api/graph-runs/${b2.runId}/next`); // goal 入口同样不再 409 const third = await newGoal(cid, [{ description: '再插一件' }]); const [d3, r3] = await call('POST', `/api/company/${cid}/goals/${third.goalId}/dispatch`); expect(d3).toBe(201); expect(r3.started).toBeUndefined(); // 坑还占着,一条都没起跑 expect(r3.queued[0].queuePosition).toBe(2); // 排在插队那条后面 }); it('GET /tasks 与 GET /goals?withTasks=1 都带 queuePosition —— 少传一次不报错,只是位次凭空消失', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }, { description: 'c' }]); await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const [ls, list] = await call('GET', `/api/company/${cid}/tasks?status=queued`); expect(ls).toBe(200); expect(list.tasks.length).toBe(2); // 三条里起跑了一条 expect(list.tasks.map((t) => t.queuePosition).sort()).toEqual([1, 2]); const [gs, tree] = await call('GET', `/api/company/${cid}/goals?withTasks=1`); expect(gs).toBe(200); const all = tree.goals.flatMap((g) => g.tasks); // 跑着的那条不在队里 → null(不是 0,也不是缺字段) expect(all.find((t) => t.status === 'running').queuePosition).toBeNull(); expect(all.filter((t) => t.status === 'queued').every((t) => t.queuePosition > 0)).toBe(true); }); // ──────────────────────────────────────────────────────────────── // /start —— manual 任务唯一的起跑路径(§五.2bis) // ──────────────────────────────────────────────────────────────── it('/start 把排队中的 manual 拉起来;执行位满时回 409 + 当前位次,绝不假装已开始', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const runningId = dispatched.started.taskId; const queuedId = goal.tasks.find((t) => t.taskId !== runningId).taskId; // 执行位被占满:如实回 409。回 200 的话界面会显示「已开始」而实际什么都没跑 const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${queuedId}/start`); expect(busy).toBe(409); // 批 4:形状与 graph-run.js 的 run-slot-busy 逐字同源(那边 holders 从批 3 起就是数组)。 // 这里以前是 activeRunId 一个单数字段 —— N 个执行位时只点名一条,用户停掉它发现还是起不来。 expect(err.reason).toBe('run-slot-busy'); expect(err.max).toBe(wdb.maxActiveRootRuns(cid)); // 这家没配 max_parallel_tasks → 缺省 1 expect(err.active).toBe(1); expect(err.holders).toEqual([ expect.objectContaining({ runId: dispatched.started.runId, taskId: runningId, runStatus: 'running' }), ]); expect(err.queuePosition).toBe(1); expect(wdb.getTask(queuedId).status).toBe('queued'); // 状态一个字节都没动 // 坑空了就起得来 sse.transitionRun(dispatched.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${queuedId}/start`); expect(ok).toBe(201); expect(res.started.taskId).toBe(queuedId); expect(wdb.getRun(res.started.runId).run_status).toBe('running'); // 出队时 attempt 才 +1(由 claimQueuedTask 一条 UPDATE 自增,不是读改写) expect(wdb.getTask(queuedId).attempt).toBe(1); }); it('max_parallel_tasks=3:一次下达把 3 个执行位填满并全部报出来;满了才 409,空一个就起得来', async () => { const cid = newCompany(); // 额度是 per-company 的,写在 company.json 里(走 company-paths,认 GRAPH_RUNS_DIR)。 // 别用 CompanyService:那份 stub 不认这个字段,而准入读的也不是它。 const dir = path.join(runsDir, '_companies', cid); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: cid, max_parallel_tasks: 3 })); expect(wdb.maxActiveRootRuns(cid)).toBe(3); const goal = await newGoal(cid, [ { description: 'a' }, { description: 'b' }, { description: 'c' }, { description: 'd' }, ]); const [st, body] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(st).toBe(201); // 批 4 之前这里只会起 1 条(`if (activeRootRun) return null`),而且只报第一条。 // 两个毛病都是「不报错的少做 / 少说」,界面上与正常排队一模一样。 expect(body.startedTasks).toHaveLength(3); expect(body.started).toEqual(body.startedTasks[0]); // 单数字段与数组同源,不是另算一遍 expect(body.queued.filter((q) => q.queuePosition).map((q) => q.queuePosition)).toEqual([1]); // 左栏轮询的两个端点要给得出「几分之几个执行位」——前端 readSlots 读的就是这三个数, // 它**不自己算 free**(free 带着「max 被改小时截断到 0」的语义)。给不出 = 文案退回批 3。 const [gs, tree] = await call('GET', `/api/company/${cid}/goals?withTasks=1`); expect(gs).toBe(200); expect(tree.slots).toEqual({ max: 3, used: 3, free: 0 }); const [ts, list] = await call('GET', `/api/company/${cid}/tasks`); expect(ts).toBe(200); expect(list.slots).toEqual({ max: 3, used: 3, free: 0 }); const startedIds = body.startedTasks.map((s) => s.taskId); const waiting = goal.tasks.find((t) => !startedIds.includes(t.taskId)).taskId; // 位子真满了才 409,而且要说得出「几条在跑 / 上限几 / 谁占着」 const [busy, err] = await call('POST', `/api/company/${cid}/tasks/${waiting}/start`); expect(busy).toBe(409); expect(err.reason).toBe('run-slot-busy'); expect(err.active).toBe(3); expect(err.max).toBe(3); expect(err.holders.map((h) => h.runId).sort()).toEqual(body.startedTasks.map((s) => s.runId).sort()); // hint 要点名**所有**停得掉的那几条:只给一条的话,用户停掉它发现还是起不来 for (const s of body.startedTasks) expect(err.hint).toContain(`/api/graph-runs/${s.runId}/stop`); // 腾出一个位 → manual 立刻起得来(3 个位空着 1 个还拒绝,正是批 4 要消灭的那个 bug) sse.transitionRun(body.startedTasks[0].runId, 'running', 'completed', {}, { summary: '用例直推终态' }); const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${waiting}/start`); expect(ok).toBe(201); expect(res.started.taskId).toBe(waiting); expect(wdb.countActiveRootRuns(cid)).toBe(3); }); it('/start 对 draft 与「上游没做完」各给各的 409,并点名挡我的是谁', async () => { const cid = newCompany(); const goal = await newGoal(cid, [ { description: '构建' }, { description: '部署', depends_on: [1] }, ]); const [build, deploy] = goal.tasks; // 还没入队的 draft:告诉他走 /enqueue,而不是含糊地说「不能开始」 const [draftStatus, draftErr] = await call('POST', `/api/company/${cid}/tasks/${deploy.taskId}/start`); expect(draftStatus).toBe(409); expect(draftErr.status).toBe('draft'); expect(draftErr.hint).toContain('/enqueue'); await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(wdb.getTask(build.taskId).status).toBe('running'); // 构建先跑,部署排在后面 // 挡它的是**依赖**(上游还没 completed),不是坑 —— 报错必须点名这个。 // 依赖这道闸排在抢坑之前:回「公司已有任务在跑」会把人引去 stop 那条根本没用的路 const [depStatus, depErr] = await call('POST', `/api/company/${cid}/tasks/${deploy.taskId}/start`); expect(depStatus).toBe(409); expect(depErr.blockedBy).toEqual([build.taskId]); expect(depErr.reason).toBeUndefined(); // 不是 run-slot-busy:别把人引去 stop 那条没用的路 expect(depErr.holders).toBeUndefined(); // 上游被停掉(cancelled = 永远不会 completed)之后,下一轮 tick 把它推导成 blocked // 并写明是谁挡的 —— 不许让它继续以 queued 的样子躺在队里假装在排队(§五.1.2) sse.transitionRun(wdb.getTask(build.taskId).current_run_id, 'running', 'stopped', {}, { summary: '腾出坑' }); await waitFor(() => wdb.getTask(deploy.taskId).status === 'blocked', '上游作废 → 下游推导成 blocked'); expect(wdb.getTask(deploy.taskId).blocked_reason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${build.taskId}`); }); // ──────────────────────────────────────────────────────────────── // /enqueue —— 单条入队(在既有目标下补一条活) // ──────────────────────────────────────────────────────────────── it('/enqueue 把一条 draft 推进队列并给出位次;重复入队被挡住(否则旧 run 变成幽灵)', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); const [a, b] = goal.tasks; const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${b.taskId}/enqueue`); expect(ok).toBe(201); expect(res.taskId).toBe(b.taskId); expect(wdb.getTask(b.taskId).status).toBe('running'); // 队里没别人,当场就起跑了 expect(res.started.taskId).toBe(b.taskId); // 第二次:它已经不是 draft 了。放行会再建一条 run,旧的那条就没人管了 const [again, err] = await call('POST', `/api/company/${cid}/tasks/${b.taskId}/enqueue`); expect(again).toBe(409); expect(err.status).toBe('running'); // a 还是 draft,一条 run 都没起 expect(wdb.getTask(a.taskId).status).toBe('draft'); expect(wdb.getTask(a.taskId).current_run_id).toBeNull(); }); // ──────────────────────────────────────────────────────────────── // /cancel —— 撤出队列 / 停止 // ──────────────────────────────────────────────────────────────── it('/cancel 撤下排队中的那条:run queued→stopped、task cancelled,后面的顶上来(验收 7)', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }, { description: 'c' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const runningId = dispatched.started.taskId; const [q1, q2] = goal.tasks.filter((t) => t.taskId !== runningId).map((t) => t.taskId); expect(wdb.getTask(q2).status).toBe('queued'); const cancelledRunId = wdb.getTask(q1).current_run_id; const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${q1}/cancel`); expect(ok).toBe(200); expect(res.status).toBe('cancelled'); // 状态由漏斗钩子上的 task-settle 收,不是端点自己写 expect(wdb.getRun(cancelledRunId).run_status).toBe('stopped'); // 归档才会让它从列表里消失;取消只是撤出队列,行还在 expect(wdb.getTask(q1).archived_at).toBeNull(); // 后面那条顶上来:位次 2 → 1 expect(scheduler.queuePosition(cid, q2)).toBe(1); }); it('/cancel 对 running 转调 stopRun(软信号),对 draft / 终态一律 409 而不是假装取消成功', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const runningId = dispatched.started.taskId; const [ok] = await call('POST', `/api/company/${cid}/tasks/${runningId}/cancel`); expect(ok).toBe(200); // stopRun 是软信号:先落 stop_requested,到节点边界才真的 stopped。 // 这里只钉「信号发出去了」—— 断言 run 立刻 stopped 就是在钉一个假的即时性 expect(wdb.getRun(dispatched.started.runId).stop_requested).toBe(1); // 另建一家公司验 draft:draft 本来就没入队,说「取消成功」是界面撒谎 const cid2 = newCompany(); const g2 = await newGoal(cid2, [{ description: '还没下达' }]); const [draftStatus, draftErr] = await call('POST', `/api/company/${cid2}/tasks/${g2.tasks[0].taskId}/cancel`); expect(draftStatus).toBe(409); expect(draftErr.hint).toContain('归档'); }); // ──────────────────────────────────────────────────────────────── // /rerun —— 任务级重跑(验收 8) // ──────────────────────────────────────────────────────────────── it('/rerun 新起一条 run,旧 run 连同节点行原样留在历史里;attempt 累加(验收 8)', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: '会失败的活' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const taskId = dispatched.started.taskId; const oldRunId = dispatched.started.runId; sse.transitionRun(oldRunId, 'running', 'failed', {}, { summary: '用例直推终态' }); await waitFor(() => wdb.getTask(taskId).status === 'failed', 'run 失败回写 task'); const oldNodeCount = wdb.getRunState(oldRunId).nodes.length; expect(oldNodeCount).toBeGreaterThan(0); expect(wdb.getTask(taskId).attempt).toBe(1); const [ok, res] = await call('POST', `/api/company/${cid}/tasks/${taskId}/rerun`); expect(ok).toBe(201); expect(res.previousRunId).toBe(oldRunId); expect(res.started.runId).not.toBe(oldRunId); expect(res.attempt).toBe(2); // 第二代次(由出队时的 claimQueuedTask 自增) // 旧 run 一个字段都没被动过 —— 「点开历史」靠的就是它还在 expect(wdb.getRun(oldRunId).run_status).toBe('failed'); expect(wdb.getRunState(oldRunId).nodes.length).toBe(oldNodeCount); // 上一轮的时间痕迹被清掉,否则界面上会显示成「排队中,已耗时 3 小时」 expect(wdb.getTask(taskId).finished_at).toBeNull(); }); it('/rerun 拒绝还攥着 run 的任务:放行会让同一条 task 底下同时有两条活 run', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: '正在跑的活' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const [status, err] = await call('POST', `/api/company/${cid}/tasks/${dispatched.started.taskId}/rerun`); expect(status).toBe(409); expect(err.hint).toContain('/stop'); // 库里仍然只有一条 root run expect(wdb.listCompanyRuns(cid, { rootOnly: true }).length).toBe(1); // draft 也没得重跑:它压根没跑过 const cid2 = newCompany(); const g2 = await newGoal(cid2, [{ description: '没跑过' }]); const [ds, de] = await call('POST', `/api/company/${cid2}/tasks/${g2.tasks[0].taskId}/rerun`); expect(ds).toBe(409); expect(de.hint).toContain('/enqueue'); }); // ──────────────────────────────────────────────────────────────── // 队伍自己往前走(auto)—— 端到端,全程只打了一次 HTTP // ──────────────────────────────────────────────────────────────── it('auto 任务:上一条收敛后下一条自动起跑,全程无人工干预(验收 2 的 HTTP 版)', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }], { mode: 'auto' }); // agent 一律不真调:让 run 稳稳停在 running,好观察队列 require('../../services/graph-engine').setAgentExecutor(() => new Promise(() => {})); try { const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const firstId = dispatched.started.taskId; const secondId = goal.tasks.find((t) => t.taskId !== firstId).taskId; expect(wdb.getTask(secondId).status).toBe('queued'); // 停掉第一条。**stopRun 不走 settleRun**,队列靠 transitionRun 漏斗上的钩子前进, // 不是等 60s 兜底(本文件的 beforeEach 把兜底定时器关了,蒙混不过去) const [stopped] = await call('POST', `/api/company/${cid}/tasks/${firstId}/cancel`); expect(stopped).toBe(200); sse.transitionRun(dispatched.started.runId, 'running', 'stopped', {}, { summary: '用例直推终态' }); await waitFor(() => wdb.getTask(secondId).status === 'running', '第二条自动顶上来'); expect(wdb.getRun(wdb.getTask(secondId).current_run_id).run_status).toBe('running'); } finally { require('../../services/graph-engine').setAgentExecutor(null); } }); // ──────────────────────────────────────────────────────────────── // 归档:别留下一条没人管的排队 run // ──────────────────────────────────────────────────────────────── it('归档一条排队中的 task 会连它的 queued run 一起撤下 —— 否则库里留一条永远没人出队的幽灵', async () => { const cid = newCompany(); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); const [, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); const queuedId = goal.tasks.find((t) => t.taskId !== dispatched.started.taskId).taskId; const ghostRunId = wdb.getTask(queuedId).current_run_id; expect(wdb.getRun(ghostRunId).run_status).toBe('queued'); const [ok] = await call('DELETE', `/api/company/${cid}/tasks/${queuedId}`); expect(ok).toBe(200); expect(wdb.getTask(queuedId).archived_at).toBeTruthy(); // 归档把 task 从 listQueue 里藏起来了(它 WHERE archived_at IS NULL), // 那条 run 就再也没有人会出队它、也没有人会停它 —— 必须在归档时一起收掉 expect(wdb.getRun(ghostRunId).run_status).toBe('stopped'); }); it('归档一个 goal 会把它名下**每一条**排队中的 run 一起撤下 —— 漏掉就是一次留 N 条幽灵', async () => { const cid = newCompany(); // 先用别的 goal 占掉这家公司唯一的执行坑,好让下面那个 goal 的两条 task 都停在 queued const occupier = await newGoal(cid, [{ description: '占坑的活' }]); const [, occ] = await call('POST', `/api/company/${cid}/goals/${occupier.goalId}/dispatch`); expect(occ.started.taskId).toBe(occupier.tasks[0].taskId); const goal = await newGoal(cid, [{ description: 'a' }, { description: 'b' }]); const [ds, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(ds).toBe(201); expect(dispatched.started).toBeUndefined(); // 坑被占着,两条都只是排上了 const ghostRunIds = goal.tasks.map((t) => wdb.getTask(t.taskId).current_run_id); expect(ghostRunIds.every((id) => wdb.getRun(id).run_status === 'queued')).toBe(true); const [ok] = await call('DELETE', `/api/company/${cid}/goals/${goal.goalId}`); expect(ok).toBe(200); // 兄弟端点(DELETE /tasks/:taskId)的注释白纸黑字写着这不许发生:archived_at 一盖上, // listQueue 的 `archived_at IS NULL` 就再也看不见这条 task,而那条 queued 的 run // 没人会出队(没有 task 指向它)、也没人会停 —— 一条不报错的幽灵行。goal 级一次盖 N 条。 for (const id of ghostRunIds) expect(wdb.getRun(id).run_status).toBe('stopped'); for (const t of goal.tasks) expect(wdb.getTask(t.taskId).archived_at).toBeTruthy(); }); // ──────────────────────────────────────────────────────────────── // 409 的 hint 必须是一条**真打得通**的路(指向不存在的行为比不给提示更糟) // ──────────────────────────────────────────────────────────────── it('归档 blocked 任务被拒时,hint 给的是真能走通的那条 —— 不是必然 400 的 /stop', async () => { const cid = newCompany(); const goal = await newGoal(cid, [ { description: '构建' }, { description: '部署', depends_on: [1] }, ]); const [build, deploy] = goal.tasks; await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); // 上游作废 → 下游被推导成 blocked,而它手上那条 run 还老实躺在 queued sse.transitionRun(wdb.getTask(build.taskId).current_run_id, 'running', 'stopped', {}, { summary: '腾出坑' }); await waitFor(() => wdb.getTask(deploy.taskId).status === 'blocked', '上游作废 → 下游 blocked'); const blockedRunId = wdb.getTask(deploy.taskId).current_run_id; expect(wdb.getRun(blockedRunId).run_status).toBe('queued'); // 两个归档端点都会因为「blocked 算攥着 run」拒掉,它们此前都硬写 /api/graph-runs/:id/stop // —— 而 /stop 的可用条件里没有 queued,照着做就是一个 400;run 为空时更是字面量 null → 404 for (const url of [ `/api/company/${cid}/goals/${goal.goalId}`, `/api/company/${cid}/tasks/${deploy.taskId}`, ]) { const [status, err] = await call('DELETE', url); expect(status).toBe(409); expect(err.hint).not.toContain('/api/graph-runs/'); expect(err.hint).toContain(`/api/company/${cid}/tasks/${deploy.taskId}/cancel`); } // 证明它真打得通:照 hint 走一次,然后归档就过了 const [cancelled] = await call('POST', `/api/company/${cid}/tasks/${deploy.taskId}/cancel`); expect(cancelled).toBe(200); expect(wdb.getRun(blockedRunId).run_status).toBe('stopped'); const [archived] = await call('DELETE', `/api/company/${cid}/tasks/${deploy.taskId}`); expect(archived).toBe(200); }); });