/** * 27-P3C 批 1 的验收清单里,能在单测里真跑的那几条(§八 验收 4 / 5 / 6 / 10 / 11 / 12b)。 * * 与 `company-goal-task-routes.test.js` 的分工:那份验的是**不起 run** 的一半(列表 / 详情 / * 改 / 归档);这份专门走**起 run 那条路** —— 存量入口 `POST /:cid/tasks` 的兼容性、 * 起 run 之后才生效的 task 级描述冻结、以及写侧读侧是否落在同一个 run 目录上。 * * ⚠️ 先设 env 再 require(§八 验收 12b 的警示块):`graph-engine.js` / `graph-run-sse.js` * 都在**模块加载时**快照 `GRAPH_RUNS_DIR`,先 require 再设 env 会让它们拿着 null 往仓库 * `data/companies/` 里写 —— 而且不报错。 * * 公司实体用 `CompanyService.prototype.get` 打桩:`company-service` 的 DATA_DIR 是硬编码的 * 仓库 `data/companies/`,不认任何 env,真建一家公司就等于往用户仓库里落目录。 * agent 一律不真调:org node 是 subgraph 节点,规划走 `setPlanWorkGraph` 注入的假规划。 */ const fs = require('fs'); const os = require('os'); const path = require('path'); const TEST_DB_PATH = '/tmp/test-goal-task-acceptance.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; // 这几个模块在加载时快照 GRAPH_DB_PATH / GRAPH_RUNS_DIR,必须先设 env 再 require const MODULES = [ '../../services/graph-db', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', '../../services/goal-contract', '../../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 engine; let orgSvc; let goalContract; let runsDir; let server; let origCompanyGet; /** 一次请求 → [status, json](没有 supertest,express 起在随机端口上用 fetch 打) */ 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 = []; /** 每个用例一家独立公司:active root run 的唯一索引是按 company 约束的 */ function newCompany() { const cid = `acc-co-${++seq}`; companies.push(cid); expect(orgSvc.writeGraph(cid, { nodes: ORG_NODES, edges: ORG_EDGES }).ok).toBe(true); return cid; } /** * `OkrService` 的 DATA_DIR 是硬编码的仓库 `data/`,不认 `GRAPH_RUNS_DIR` 也不认任何 env, * 而它的构造函数就 mkdir —— 打一次 `GET /:cid/okr` 就在用户仓库里留一个 * `data/companies/{cid}/okr/`。测试自己收拾干净,别把残留留给下一个人。 * 只删本文件造出来的前缀目录,且必须确认里面没有 `company.json`(真公司一定有这个文件)。 */ function cleanupLeakedCompanyDirs() { const root = path.resolve(__dirname, '../../../data/companies'); 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 { /* 删不掉就留着,不让清理反过来炸测试 */ } } } describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { 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-acceptance-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); orgSvc = require('../../services/org-graph-service'); engine = require('../../services/graph-engine'); goalContract = require('../../services/goal-contract'); // 公司实体:只有 POST /:cid/tasks 的存在性检查在读它 const { CompanyService } = require('../../services/company-service'); origCompanyGet = CompanyService.prototype.get; CompanyService.prototype.get = function (companyId) { return { id: companyId, name: companyId, status: 'running' }; }; const express = require('express'); const app = express(); app.use(express.json()); app.use('/api/company', require('../../routes/company')); // 验收 11:OKR 只剩这一个挂载点,/api/goals 那条别名已退役 app.use('/api/okr', require('../../routes/okr')); await new Promise((resolve) => { server = app.listen(0, resolve); }); }); afterAll(() => { if (server) server.close(); if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet; 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)]; }); // ────────────────────────────────────────────────────────────── // 验收 4 · 存量调用方一行不用改 // ────────────────────────────────────────────────────────────── it('POST /:cid/tasks 不带 goal_id 仍 201,且自动建出一个 goal(验收 4)', async () => { const cid = newCompany(); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '做个官网' }); expect(status).toBe(201); // 存量响应字段一个都不能少 —— DispatchTaskButton 与外部 agent 的 curl 直接读它们 expect(body).toMatchObject({ success: true, companyId: cid, task: '做个官网', executionMode: 'manual' }); expect(body.runId).toMatch(/^run_/); expect(body.links.next).toBe(`/api/graph-runs/${body.runId}/next`); // 新增的那一个字段:goal 真的建出来了,描述与 task 同源(它就是验收边界) expect(body.goalId).toMatch(/^G-[0-9a-z]+-[0-9a-f]{4}$/); const goal = wdb.getGoal(body.goalId); expect(goal.description).toBe('做个官网'); expect(goal.company_id).toBe(cid); // 下达即冻结:dispatched_at 就是 goal 级描述冻结点(§四.4) expect(goal.dispatched_at).toBeTruthy(); expect(goal.status).toBe('active'); const tasks = wdb.listGoalTasks(goal.id); expect(tasks.map((t) => t.id)).toEqual([body.taskId]); expect(tasks[0].current_run_id).toBe(body.runId); // task id 带 4 位后缀,与 /task/dispatch 那套同毫秒会撞号的 `T-{base36}` 肉眼可分(§九.5) expect(body.taskId).toMatch(/^T-[0-9a-z]+-[0-9a-f]{4}$/); }); // ────────────────────────────────────────────────────────────── // 验收 12b · 写侧与读侧落在同一个 run 目录(路径缺陷 A) // ────────────────────────────────────────────────────────────── it('GRAPH_RUNS_DIR 覆盖下,task-goal.json 的写侧与读侧是同一个路径(验收 12b)', async () => { const cid = newCompany(); const description = '把首页的加载时间压到 1 秒以内'; const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: description }); expect(status).toBe(201); // 写侧:落在临时目录里,仓库 data/companies/ 一个字节都不该多 const written = path.join(runsDir, body.runId, 'artifacts', 'task-goal.json'); expect(fs.existsSync(written)).toBe(true); expect(JSON.parse(fs.readFileSync(written, 'utf-8')).task).toBe(description); expect(fs.existsSync(path.join(__dirname, '../../../data/companies', cid, 'runs', body.runId))).toBe(false); // 读侧之一:goal-contract 的 resolveRunDir(验收 12c 修的就是它这一半) expect(path.join(goalContract.resolveRunDir(cid, body.runId), 'artifacts', 'task-goal.json')).toBe(written); // 读侧之二 —— 真正会咬人的那个:引擎拼提示词时读得回真实描述。 // describeTask 没有导出,所以从 subgraph 节点的规划入口把 ctx.task 抓出来: // 读不到文件时它会**静默**退化成兜底文案「执行图节点任务」,agent 不知道要干什么却照样烧预算。 let plannedTask = null; engine.setPlanWorkGraph(async (ctx) => { plannedTask = ctx.task; const db = wdb.getDb(); const now = new Date().toISOString(); db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) VALUES ('acc-worker', 'acc-worker', 'public', NULL, 'global_agent', 'system/agents/acc-worker', 'active', ?, ?) `).run(now, now); const g = wdb.createWorkGraph({ companyId: cid, nodes: ['acc-worker'], edges: [] }); expect(g.errors || []).toEqual([]); return { workGraphId: g.id }; }); try { await engine.nextStep(body.runId); } finally { engine.setPlanWorkGraph(null); } expect(plannedTask).toBeTruthy(); expect(plannedTask.startsWith(description)).toBe(true); expect(plannedTask).not.toContain('执行图节点任务'); }); // ────────────────────────────────────────────────────────────── // 缺陷 F1 / F2 · run 收敛之后到底发生了什么 // ────────────────────────────────────────────────────────────── it('root run 转终态 → task 回写 + goal 收敛 + OKR 恰好写一次(缺陷 F1 / F2)', async () => { const cid = newCompany(); // OkrService 的 DATA_DIR 是硬编码的仓库 data/,真写会往用户仓库里落 objective 文件; // 这里只需要证明「写了、且只写了一次」,打桩 updateNode 即可(构造函数那次 mkdir 由 // cleanupLeakedCompanyDirs 收拾)。 const { OkrService } = require('../../services/okr-service'); const origUpdateNode = OkrService.prototype.updateNode; const okrCalls = []; OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) { okrCalls.push({ companyId: this.companyId, objectiveId, nodeId, status: updates.status }); return { id: nodeId }; }; try { const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '把注册转化率提到 20%', okr_node_id: 'O-001.1', }); expect(status).toBe(201); expect(wdb.getTask(body.taskId).status).toBe('running'); // §四.7 禁令一:度量目标只挂在 goal 上,run 上一律不带 —— 否则 N 个 task 反复覆写同一节点 expect(wdb.getRun(body.runId).okr_node_id).toBeFalsy(); expect(wdb.getGoal(body.goalId).okr_node_id).toBe('O-001.1'); // 直接推唯一漏斗。stopRun / 预算耗尽 → blocked 都不走 settleRun, // 钩子挂在 transitionRun 上才覆盖得全(§五.1.1)。 const sse = require('../../services/graph-run-sse'); expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy(); const task = wdb.getTask(body.taskId); expect(task.status).toBe('completed'); // 修之前这里永远是 running expect(task.finished_at).toBeTruthy(); expect(wdb.getGoal(body.goalId).status).toBe('done'); expect(okrCalls).toEqual([ { companyId: cid, objectiveId: 'O-001', nodeId: 'O-001.1', status: 'done' }, ]); // 幂等:再收敛一次不产生第二次 OKR 写(并行完成的多个 task 会同时走到这里) const taskSettle = require('../../services/task-settle'); expect(taskSettle.settleRootRun(wdb.getRun(body.runId), 'completed')).toBeNull(); expect(okrCalls).toHaveLength(1); // F1 的直接后果:归档不再永久 409(修之前 task 卡在 running,而 hint 给的 stop // 对一条 completed 的 run 会抛 InvalidTransitionError —— 自救路径本身是死路) const [del, archived] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`); expect(del).toBe(200); // 只看 200 不够:归档要真落到行上,否则下一次列表照样把它画出来(「历史任务看不见」的反面) expect(archived.goal.archivedAt).toBeTruthy(); expect(wdb.getGoal(body.goalId).archived_at).toBeTruthy(); expect(wdb.getTask(body.taskId).archived_at).toBeTruthy(); } finally { OkrService.prototype.updateNode = origUpdateNode; } }); it('人工停止 → task 记 cancelled、goal 判 failed(stopped 不是「执行失败」,但也不是达成)', async () => { const cid = newCompany(); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一个会被停掉的任务' }); expect(status).toBe(201); const sse = require('../../services/graph-run-sse'); sse.transitionRun(body.runId, 'running', 'stopped', {}, { summary: '用例模拟人工停止' }); const task = wdb.getTask(body.taskId); expect(task.status).toBe('cancelled'); // finished_at 是「这条 task 什么时候不再动了」的唯一时间戳:只改 status 不写它, // 列表上这条任务会显示成「已取消,但还没有结束时间」,耗时统计里也永远缺一条 expect(task.finished_at).toBeTruthy(); expect(wdb.getGoal(body.goalId).status).toBe('failed'); }); it('预算耗尽 → blocked → 人加预算恢复 → 跑完:task=completed / goal=done / OKR 恰好写一次(第二轮复核 缺陷 1 / 2)', async () => { // stopped 与 blocked 是 §五.1.1 点名的两条最常漏的路径(`stopRun` 的注释自己写着 // 「不经过 settleRun」,预算耗尽走的是 suspendRunTree → transitionRun(blocked))。 // // ⚠️ 这条用例**必须走完整条恢复链**。第一轮只验到「转 blocked 的那一瞬间」,于是 // 「blocked 被当成终态」这个错误口径全绿通过,实跑出来的却是:加完预算 run 跑到 completed, // 而 `settleTaskFromRun` 的终态闸门命中 0 行、`settleGoalStatus` 因 goal 已 failed 也命中 0 行, // 两次静默 no-op → run=completed / task=blocked / goal=failed **永久不一致**。 // 断言序列就是那条链本身:挂起 → 恢复 → 收敛。 const { OkrService } = require('../../services/okr-service'); const origUpdateNode = OkrService.prototype.updateNode; const okrCalls = []; OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) { okrCalls.push({ nodeId, status: updates.status }); return { id: nodeId }; }; try { const cid = newCompany(); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一个会烧完预算的任务', okr_node_id: 'O-002.1', }); expect(status).toBe(201); const sse = require('../../services/graph-run-sse'); // ① 预算耗尽:run → blocked expect(sse.transitionRun(body.runId, 'running', 'blocked', {}, { summary: '用例模拟预算耗尽' })).toBeTruthy(); // task 行要看得见「卡住了」—— 这是界面上唯一的信号 expect(wdb.getTask(body.taskId).status).toBe('blocked'); // 但 goal 不许收敛:挂起等人加预算 ≠ 需求失败。判 failed 会写 OKR、唤醒上级、 // 并把 goal 锁进终态,人加完预算跑完也再改不回 done(缺陷 2:误报 + 白烧一轮 LLM) expect(wdb.getGoal(body.goalId).status).toBe('active'); expect(okrCalls).toEqual([]); // ② 人加完预算恢复:blocked → running 是 RUN_TRANSITIONS 里的合法转换 // (`POST /api/graph-runs/:id/start` 与 `reviveParentFromChild` 走的都是这条) expect(sse.transitionRun(body.runId, 'blocked', 'running', {}, { summary: '用例模拟加预算恢复' })).toBeTruthy(); expect(wdb.getRun(body.runId).run_status).toBe('running'); // 恢复期间 task 行仍停在 blocked:RUN_TO_TASK_STATUS 只在收敛时回写,没有 running 这一档。 // 这半截不一致正是下面那条归档守卫要挡的东西 expect(wdb.getTask(body.taskId).status).toBe('blocked'); // ③ 真跑完 expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy(); const task = wdb.getTask(body.taskId); expect(task.status).toBe('completed'); // 修之前这里永远钉死在 blocked expect(task.finished_at).toBeTruthy(); expect(wdb.getGoal(body.goalId).status).toBe('done'); expect(okrCalls).toEqual([{ nodeId: 'O-002.1', status: 'done' }]); // 收敛完了才归档得掉 const [del] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`); expect(del).toBe(200); } finally { OkrService.prototype.updateNode = origUpdateNode; } }); it('blocked 期间不许归档:run 随时会被恢复,藏起来就等于让它在界面外继续烧钱', async () => { // 守卫改宽之后(task 不再因 blocked 进终态)必须同步补上这一条: // 只看 `task.status === 'running'` 的旧判据,会把「挂起等预算」和「恢复后 run 已 running // 但 task 行还停在 blocked」这两种状态一起放行。 const cid = newCompany(); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一个卡住的任务' }); expect(status).toBe(201); const sse = require('../../services/graph-run-sse'); sse.transitionRun(body.runId, 'running', 'blocked', {}, { summary: '用例模拟预算耗尽' }); const [del, err] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`); expect(del).toBe(409); expect(err.runningTasks).toEqual([body.taskId]); // 拒绝还不够,得给出走得通的自救路径:blocked → stopped 是合法转换 expect(err.hint).toContain(`/api/graph-runs/${body.runId}/stop`); // 单条 task 的归档口径必须与 goal 级一致,否则绕一下就穿了 const [delTask] = await call('DELETE', `/api/company/${cid}/tasks/${body.taskId}`); expect(delTask).toBe(409); // 恢复成 running 之后 task 行还停在 blocked —— 这正是复核抓到的「守卫过松」现场 sse.transitionRun(body.runId, 'blocked', 'running', {}, { summary: '用例模拟加预算恢复' }); expect(wdb.getTask(body.taskId).status).toBe('blocked'); const [stillBlocked] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`); expect(stillBlocked).toBe(409); // 真停掉之后才放行(stopped 不在 run 的活跃取值域里) sse.transitionRun(body.runId, 'running', 'stopped', {}, { summary: '用例模拟人工停止' }); const [ok] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`); expect(ok).toBe(200); }); it('goal 下有一条 failed 时收敛为 failed,OKR 节点在最后一条终态时才写、且只写一次(缺陷 F2)', async () => { const cid = newCompany(); const { OkrService } = require('../../services/okr-service'); const origUpdateNode = OkrService.prototype.updateNode; const okrCalls = []; OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) { okrCalls.push({ nodeId, status: updates.status }); return { id: nodeId }; }; try { const [created, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '把注册流程重做一遍', okr_node_id: 'O-002.1', tasks: [{ description: '改后端接口' }, { description: '改前端表单' }], }); expect(created).toBe(201); const [t1, t2] = goal.tasks; const sse = require('../../services/graph-run-sse'); // 第一条失败 const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(d1).toBe(201); expect(r1.started.taskId).toBe(t1.taskId); sse.transitionRun(r1.started.runId, 'running', 'failed', {}, { summary: '用例模拟执行失败' }); expect(wdb.getTask(t1.taskId).status).toBe('failed'); // 还有一条 draft 没跑,goal 不许提前收敛 —— 提前写 OKR 就是「一条失败的尾巴把节点翻掉」, // 而后面那条可能还会成功。§四.7 禁令一挪走 run 级 okr_node_id 要挡的就是这个 expect(wdb.getGoal(goal.goalId).status).toBe('active'); expect(okrCalls).toEqual([]); // 第二条成功;至此全部 task 到终态 → goal 收敛 const [d2, r2] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(d2).toBe(201); expect(r2.started.taskId).toBe(t2.taskId); sse.transitionRun(r2.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); expect(wdb.getTask(t2.taskId).status).toBe('completed'); // 判词口径:全部 completed 才 done。「最后一条成功了就算达成」会让半截需求被判合格 expect(wdb.getGoal(goal.goalId).status).toBe('failed'); expect(okrCalls).toEqual([{ nodeId: 'O-002.1', status: 'failed' }]); } finally { OkrService.prototype.updateNode = origUpdateNode; } }); it('goal 带 report_to 时只汇报、不再自己写 OKR(同一个节点不许写两次,缺陷 F2)', async () => { // reportGoalOutcome 是 either/or:`/api/agent/task/report` 那条链路自己会更新 OKR 节点, // 这边再写一次就是同一个节点被写两遍(两次的 summary 还不一样,后写的赢)。 // 「先写 OKR 再汇报」是最自然的错法,所以两条分支都得有人盯着。 const cid = newCompany(); const { OkrService } = require('../../services/okr-service'); const origUpdateNode = OkrService.prototype.updateNode; let okrWrites = 0; OkrService.prototype.updateNode = function (objectiveId, nodeId) { okrWrites += 1; return { id: nodeId }; }; const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '给上级交一份周报', okr_node_id: 'O-003.2', report_to: 'ceo', }); expect(status).toBe(201); expect(wdb.getGoal(body.goalId).report_to).toBe('ceo'); // fetch 只在这一小段里换掉:call() 自己也用它,换久了整个用例都没法发请求 const origFetch = globalThis.fetch; const posted = []; globalThis.fetch = (url, init) => { posted.push({ url: String(url), body: JSON.parse(init.body) }); return Promise.resolve({ ok: true, status: 200 }); }; try { const sse = require('../../services/graph-run-sse'); sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); } finally { globalThis.fetch = origFetch; OkrService.prototype.updateNode = origUpdateNode; } expect(posted).toHaveLength(1); expect(posted[0].url).toContain('/api/agent/task/report'); // 汇报的是 **goal id**:上级 agent 靠它把 goal 级验收和 run 级「这次跑成什么样」分开 expect(posted[0].body).toMatchObject({ taskId: body.goalId, to: 'ceo', status: 'done', nodeId: 'O-003.2' }); expect(okrWrites).toBe(0); }); // ────────────────────────────────────────────────────────────── // 缺陷 F3 / F6 · dispatch 那条路上被丢掉和被违反的两个约定 // ────────────────────────────────────────────────────────────── it('草稿上申报的 budget 能活到 dispatch,run 拿到的不是默认额度(缺陷 F3)', async () => { const cid = newCompany(); const budget = { total_agent_calls: 3, total_tokens: 5000000, wallclock_ms: 60000, spent: { agent_calls: 0, tokens: 0 } }; // 建 goal(草稿)→ 之后 dispatch,中间隔着一整个请求:请求体活不到那时候, // 所以 budget 必须落在 tasks.budget_json 上,dispatch 再读回来 const [s1, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '一件预算很紧的活', budget }); expect(s1).toBe(201); expect(JSON.parse(wdb.getTask(goal.tasks[0].taskId).budget_json)).toEqual(budget); const [s2, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(s2).toBe(201); // 修之前这里是 DEFAULT_TASK_BUDGET(100 次调用):用户填的 3 次被静默丢弃, // 他只会看到任务跑到一半 blocked,而没有任何地方说过预算被换掉了 expect(JSON.parse(wdb.getRun(dispatched.started.runId).budget_json)).toEqual(budget); // 本次 dispatch 的 body 仍然覆盖草稿上的申报值(优先级:body > 草稿 > 默认) const cid2 = newCompany(); const [, goal2] = await call('POST', `/api/company/${cid2}/goals`, { description: '同样带预算', budget }); const override = { total_agent_calls: 9, spent: { agent_calls: 0, tokens: 0 } }; const [s3, d2] = await call('POST', `/api/company/${cid2}/goals/${goal2.goalId}/dispatch`, { budget: override }); expect(s3).toBe(201); expect(JSON.parse(wdb.getRun(d2.started.runId).budget_json)).toEqual(override); }); it('POST /tasks 撞唯一索引存成草稿后,dispatch 不带 body 仍用当初申报的 budget(缺陷 F3 的原始现场)', async () => { // 这条走的是 hint 亲口推荐的那条路:「任务已存为草稿,等当前任务收敛后 dispatch」。 // 它只产生在**唯一索引**那条竞态路径上 —— 端点开头的 activeRootRun 预检查会在建 goal/task // **之前**就 409,那条路上根本没有草稿行。所以这里必须把竞态窗口造出来: // 让 createTask 返回后、launchTaskRun 起 run 前,另一条 active root run 冒出来。 // (不造窗口就只能验 POST /goals → dispatch 那条主路径,而复核点名的正是这条 hint 路径。) const cid = newCompany(); const budget = { total_agent_calls: 2, total_tokens: 1000, wallclock_ms: 30000, spent: { agent_calls: 0, tokens: 0 } }; const db = wdb.getDb(); const intruderId = `run_intruder_${cid}`; const origCreateTask = wdb.createTask; wdb.createTask = (args) => { const row = origCreateTask(args); const now = new Date().toISOString(); // 抢在同一家公司插一条 running 的 root run:随后的 markRunReady(planning → ready) // 会撞 idx_graph_runs_one_active_root db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at) VALUES (?, 'org', ?, ?, ?, 'running', ?, ?)`).run(intruderId, cid, cid, intruderId, now, now); return row; }; let status; let conflict; try { [status, conflict] = await call('POST', `/api/company/${cid}/tasks`, { task: '一件排在后面的活', budget }); } finally { wdb.createTask = origCreateTask; } expect(status).toBe(409); // taskId + 「dispatch」这两条一起把路径钉死:端点开头的预检查 409 回的是 activeTaskId // 且 hint 让你去 stop,两者都对不上 —— 断言写松了会验成另一条根本没有草稿的路径 expect(conflict.taskId).toBeTruthy(); expect(conflict.hint).toContain('dispatch'); const draft = wdb.getTask(conflict.taskId); expect(draft.status).toBe('draft'); // 请求体到这里就没了:不落库的话下面那次 dispatch 只能拿默认额度,而用户以为自己填的算数 expect(JSON.parse(draft.budget_json)).toEqual(budget); // 前一条任务收敛了,按 hint 下达 db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(intruderId); const [ok, dispatched] = await call('POST', `/api/company/${cid}/goals/${conflict.goalId}/dispatch`); expect(ok).toBe(201); expect(dispatched.started.taskId).toBe(conflict.taskId); const runBudget = JSON.parse(wdb.getRun(dispatched.started.runId).budget_json); expect(runBudget).toEqual(budget); expect(runBudget.total_agent_calls).not.toBe(100); // DEFAULT_TASK_BUDGET 的值,钉住「不是默认额度」 }); it('正向依赖(第 1 条依赖第 2 条)不再把 goal 卡死:dispatch 起的是跑得动的那条(第二轮复核 缺陷 4)', async () => { const cid = newCompany(); const [status, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '上线一个服务', tasks: [ { description: '部署到测试环境', depends_on: [2] }, // seq 最小的这条反而依赖后一条 { description: '构建镜像' }, ], }); expect(status).toBe(201); const [deploy, build] = goal.tasks; expect(deploy.dependsOn).toEqual([build.taskId]); // 修之前两版都不对: // v0 恒取 drafts[0] = 部署,直接起 run —— 收下了依赖又无声违反它; // v1 恒取 drafts[0] 且有阻塞就 409 —— 这个 goal 通过 API 就**永远下达不了** // (validateDependsOn 只拒环、不拒正向引用,而 dispatch 从不换人挑)。 const [ok, res1] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(ok).toBe(201); expect(res1.started.taskId).toBe(build.taskId); // 跳过被挡住的部署,起构建 expect(res1.pendingTasks).toEqual([deploy.taskId]); // 被跳过的那条如实留在待下达里 expect(wdb.getTask(deploy.taskId).status).toBe('draft'); // 依赖仍是硬约束,一条都没抢跑 // 构建跑完 → 部署的上游就绪,再 dispatch 一次就轮到它 const sse = require('../../services/graph-run-sse'); sse.transitionRun(res1.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); expect(wdb.getTask(build.taskId).status).toBe('completed'); const [ok2, res2] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(ok2).toBe(201); expect(res2.started.taskId).toBe(deploy.taskId); }); it('一条都跑不动时才 409,点名最先考察的那条和挡着它的人(缺陷 F6)', async () => { const cid = newCompany(); const [status, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '发一个版本', tasks: [ { description: '构建镜像' }, { description: '部署到测试环境', depends_on: [1] }, { description: '跑冒烟用例', depends_on: [2] }, ], }); expect(status).toBe(201); const [build, deploy, smoke] = goal.tasks; // 构建失败了(不是 completed,也不再是 draft)→ 剩下两条 draft 谁都跑不动: // 部署等着 failed 的构建,冒烟等着还是 draft 的部署。 wdb.updateTask(build.taskId, { status: 'failed' }); const [blocked, err] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(blocked).toBe(409); expect(err.taskId).toBe(deploy.taskId); // 按 seq 最先考察的那条 expect(err.blockedBy).toEqual([build.taskId]); expect(err.hint).toContain(deploy.taskId); // 得告诉用户去哪儿改,不然只知道被拒 expect(wdb.getTask(deploy.taskId).status).toBe('draft'); expect(wdb.getTask(smoke.taskId).status).toBe('draft'); }); it('409 hint 里的 seq 自救路径真的通:PATCH seq 能换下达顺序,非整数 400(缺陷 4)', async () => { // 修之前 hint 写着「顺序不对就改依赖或 seq:PATCH …/tasks/{id}」,而那个 PATCH 从不读 // body.seq —— 用户照着做完拿到 200(假确认),再 dispatch 还是原来那条。 const cid = newCompany(); const [status, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '写两篇稿子', tasks: [{ description: '写初稿' }, { description: '写终稿' }], }); expect(status).toBe(201); const [first, second] = goal.tasks; const [bad] = await call('PATCH', `/api/company/${cid}/tasks/${second.taskId}`, { seq: 'abc' }); expect(bad).toBe(400); // seq 直接进 ORDER BY,非整数必须挡在信任边界上 const [okPatch, patched] = await call('PATCH', `/api/company/${cid}/tasks/${second.taskId}`, { seq: 0 }); expect(okPatch).toBe(200); expect(patched.seq).toBe(0); const [okDispatch, res] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(okDispatch).toBe(201); expect(res.started.taskId).toBe(second.taskId); // 顺序真的换了,不是回个 200 就完事 expect(res.pendingTasks).toEqual([first.taskId]); }); it('auto 模式收敛只唤醒上级一次,且 from 是交付者不是 human(第二轮复核 缺陷 3)', async () => { // 修之前有两条汇报打同一个 POST /api/agent/task/report:goal 级(task-settle)+ run 级 // (company.js 挂在 auto driver 上的 reportRunOutcome)。批 1 是 1 goal : 1 task, // 于是同一件事把上级叫醒两次(那个端点第 5 步会 setImmediate 自调 /api/agent/{to}/chat)。 const cid = newCompany(); const engineMod = require('../../services/graph-engine'); const origStartRun = engineMod.startRun; // auto 的 driver 会真跑整张图(分钟级、要调 agent),这里换成一个手动兑现的 promise: // 关键是它必须在 run 已经收敛**之后**才 resolve —— 那正是真实时序,也正是双唤醒的现场。 let resolveDriver; const driver = new Promise((r) => { resolveDriver = r; }); engineMod.startRun = (runId, opts) => { origStartRun(runId, { ...opts, mode: 'manual' }); // 只标 running + 点亮入口节点 return { driver }; }; let created; try { const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '把周报发出去', mode: 'auto', report_to: 'ceo', okr_node_id: 'O-004.1', }); expect(status).toBe(201); created = body; } finally { engineMod.startRun = origStartRun; } const origFetch = globalThis.fetch; const posted = []; globalThis.fetch = (url, init) => { posted.push({ url: String(url), body: JSON.parse(init.body) }); return Promise.resolve({ ok: true, status: 200 }); }; try { const sse = require('../../services/graph-run-sse'); sse.transitionRun(created.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); resolveDriver(); // 让 driver 的 then 链(如果还有的话)跑完再数:不 flush 就等于没验 await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r)); } finally { globalThis.fetch = origFetch; } expect(posted).toHaveLength(1); expect(posted[0].body.taskId).toBe(created.goalId); // 留下的是 goal 级那条 // from = 交付者(org graph 末节点),不是下单人。created_by 默认就是字符串 'human', // 下游拿它去 readStateFile('human','todo.md') 全部落空且不报错。 expect(posted[0].body.from).toBe(`${cid}:org-validator`); expect(posted[0].body.from).not.toBe('human'); }); // ────────────────────────────────────────────────────────────── // 验收 5 · 描述冻结(goal 与 task 各一个冻结点) // ────────────────────────────────────────────────────────────── it('已下达的 goal 改 description → 409 + hint,改 title → 200(验收 5)', async () => { const cid = newCompany(); const [, created] = await call('POST', `/api/company/${cid}/tasks`, { task: '写一版 landing page' }); const [conflict, err] = await call('PATCH', `/api/company/${cid}/goals/${created.goalId}`, { description: '换个需求', }); expect(conflict).toBe(409); expect(err.frozenFields).toEqual(['description']); expect(err.hint).toBeTruthy(); // 只说「不许改」而不说「怎么办」,用户就卡在这儿了 expect(wdb.getGoal(created.goalId).description).toBe('写一版 landing page'); // title 只是列表上的标签,不进任何 agent 的上下文,冻结之后照样能改 const [ok, patched] = await call('PATCH', `/api/company/${cid}/goals/${created.goalId}`, { title: '改个名' }); expect(ok).toBe(200); expect(patched.title).toBe('改个名'); }); it('task 同理:queued_at 一非空就拒改 description,title 仍可改(验收 5)', async () => { const cid = newCompany(); const [, created] = await call('POST', `/api/company/${cid}/tasks`, { task: '接入支付' }); // 起 run 那一刻描述已经写进 task-goal.json 被 agent 读走了,再改就是篡改审计输入 expect(wdb.getTask(created.taskId).queued_at).toBeTruthy(); const [conflict, err] = await call('PATCH', `/api/company/${cid}/tasks/${created.taskId}`, { description: '改成接入退款', }); expect(conflict).toBe(409); expect(err.frozenFields).toEqual(['description']); expect(wdb.getTask(created.taskId).description).toBe('接入支付'); const [ok] = await call('PATCH', `/api/company/${cid}/tasks/${created.taskId}`, { title: '支付' }); expect(ok).toBe(200); }); // ────────────────────────────────────────────────────────────── // 验收 6 · depends_on 成环 // ────────────────────────────────────────────────────────────── it('depends_on 写成环 → 400,且库里没有半条脏数据(验收 6)', async () => { const cid = newCompany(); const goal = wdb.createGoal({ companyId: cid, title: '双周迭代', description: '双周迭代' }); const t1 = wdb.createTask({ goalId: goal.id, title: 'T1', description: '先做 T1' }); const t2 = wdb.createTask({ goalId: goal.id, title: 'T2', description: '再做 T2' }); const [ok] = await call('PATCH', `/api/company/${cid}/tasks/${t2.id}`, { depends_on: [t1.id] }); expect(ok).toBe(200); // T1 反过来依赖 T2 就闭环了。环不是「跑起来才发现」的问题:调度器会把两条都判成 // 「上游没完成」,于是谁都不出队 —— 表现是任务静默躺着,界面上跟正常排队一模一样。 const [status, err] = await call('PATCH', `/api/company/${cid}/tasks/${t1.id}`, { depends_on: [t2.id] }); expect(status).toBe(400); expect(err.error).toMatch(/环/); // 「建完再查一次」才算验完:校验必须发生在写之前,半条脏数据都不许留 expect(JSON.parse(wdb.getTask(t1.id).depends_on)).toEqual([]); expect(JSON.parse(wdb.getTask(t2.id).depends_on)).toEqual([t1.id]); }); // ────────────────────────────────────────────────────────────── // 验收 10 · 列表端点不 N+1 // ────────────────────────────────────────────────────────────── it('5 goal × 3 task 的列表只发一次 graph_run_nodes 查询(验收 10)', async () => { const cid = newCompany(); const db = wdb.getDb(); const now = new Date().toISOString(); for (let g = 0; g < 5; g++) { const goal = wdb.createGoal({ companyId: cid, title: `G${g}`, description: `目标 ${g}` }); for (let t = 0; t < 3; t++) { const task = wdb.createTask({ goalId: goal.id, title: `T${g}-${t}`, description: `活 ${g}-${t}` }); // 每条 task 挂一条 run + 2 个节点行:没有 run 的 task 走不到进度查询,挡不住 N+1。 // run_status 用 completed —— idx_graph_runs_one_active_root 只许一条活跃 root run, // 而这里要的是 15 条历史 run 同时出现在列表上(那正是 N+1 最疼的形态)。 const runId = `run_n1_${g}_${t}`; db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at) VALUES (?, 'org', ?, ?, ?, 'completed', ?, ?)`).run(runId, cid, cid, runId, now, now); for (const [i, st] of ['success', 'pending'].entries()) { db.prepare(`INSERT INTO graph_run_nodes (id, run_id, def_node_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`).run(`${runId}_n${i}`, runId, `d${i}`, st, now, now); } wdb.updateTask(task.id, { currentRunId: runId }); } } // 数的是 prepare 而不是 statement 执行:work-graph-db 每次调用都现 prepare 一条, // 改成逐行查进度必然体现为 15 次。 const origPrepare = db.prepare.bind(db); let nodeQueries = 0; db.prepare = (sql) => { if (/graph_run_nodes/.test(sql)) nodeQueries++; return origPrepare(sql); }; try { const [status, body] = await call('GET', `/api/company/${cid}/goals?withTasks=1`); expect(status).toBe(200); expect(body.goals).toHaveLength(5); expect(body.goals.flatMap((g) => g.tasks)).toHaveLength(15); // 顺带把口径钉住:只数 prepare 次数的话,一条恒返回空的查询也是 1 次 expect(body.goals[0].tasks[0].progress).toEqual({ done: 1, total: 2 }); expect(nodeQueries).toBe(1); nodeQueries = 0; const [taskStatus, taskBody] = await call('GET', `/api/company/${cid}/tasks`); expect(taskStatus).toBe(200); expect(taskBody.tasks).toHaveLength(15); expect(nodeQueries).toBe(1); } finally { delete db.prepare; // 还原成原型上的那个,别把桩留给同进程的后续用例 } }); // ────────────────────────────────────────────────────────────── // 验收 11 · /goals 的 OKR 别名退役 // ────────────────────────────────────────────────────────────── it('/:cid/goals 归 goal 两级模型,/:cid/okr 仍是 OKR(验收 11)', async () => { const cid = newCompany(); const goal = wdb.createGoal({ companyId: cid, title: '别名退役', description: '别名退役' }); // 别名还在的话这条会被 OkrService 接走,返回 objective 数组而不是 { goals: [...] } const [status, body] = await call('GET', `/api/company/${cid}/goals`); expect(status).toBe(200); expect(body.goals.map((g) => g.goalId)).toEqual([goal.id]); const [okrStatus, okrBody] = await call('GET', `/api/company/${cid}/okr`); expect(okrStatus).toBe(200); expect(Array.isArray(okrBody)).toBe(true); // OkrService.list() 的形状,没被 goal 模型顶掉 }); it('两个 server 入口都不再挂 /api/goals 别名(验收 11)', () => { // 这条是源码级断言:别名的注册点在 index.js / agent-server.js,路由测试够不着。 // 漏掉任何一个入口,agent-server.js 那条路上 /api/goals 就仍指向 OKR —— // 而两个入口的差异恰恰是最不容易被人想起来跑一遍的地方。 for (const entry of ['../../index.js', '../../agent-server.js']) { const src = fs.readFileSync(path.resolve(__dirname, entry), 'utf-8'); expect(src).not.toMatch(/app\.use\(\s*['"]\/api\/goals['"]/); expect(src).toMatch(/app\.use\(\s*['"]\/api\/okr['"]/); } }); });