/** * 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}$/); }); // ────────────────────────────────────────────────────────────── // T-mt5dkvjm · assignee_agent_id 不再是永远的 NULL // ────────────────────────────────────────────────────────────── it('POST /:cid/tasks 入队后 assignee_agent_id 不是 NULL —— COO 心跳巡检靠 to 字段判「有没有人接」,一直是 null 会被误报成孤儿任务(T-mt5dkvjm)', async () => { const cid = newCompany(); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '起个图跑任务' }); expect(status).toBe(201); // 入队当下就该有占位 assignee —— 这正是 task-service.getAggregatedTasks 回给 COO 的 `to` 字段 expect(wdb.getTask(body.taskId).assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE); // run 收敛之后也不能被冲回 null:settleTaskFromRun 的 COALESCE 只在为空时兜底,不覆盖已有值 const sse = require('../../services/graph-run-sse'); expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy(); const settled = wdb.getTask(body.taskId); expect(settled.status).toBe('completed'); expect(settled.assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE); }); // ────────────────────────────────────────────────────────────── // 验收 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, legacy_path, status, created_at, updated_at) VALUES ('acc-worker', 'acc-worker', 'public', NULL, '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'); // 界面上那句「卡在哪」的小字(真实链路里由 describeRunBlock + noteTaskBlockedReason 写) expect(wdb.noteTaskBlockedReason(body.taskId, body.runId, '节点 xxx 卡住(budget_exhausted)')).toBe(1); expect(wdb.getTask(body.taskId).blocked_reason).toBeTruthy(); // 但 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(); // 33 号文档同批修的另一个真实 bug:settleTaskFromRun 只写 status,不清 blocked_reason, // 于是任务列表在「已完成」绿色状态旁边,还会一直挂着上一次卡住时留下的那句红字 // ——真实回归任务 T-mt2r6fz0-125b 复现过(org-coder 恢复跑完后,界面仍显示 // 「节点 org-coder 卡住(missing_required_input)」)。 expect(task.blocked_reason).toBeNull(); 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 收敛。 // 批 3 起**不能再调一次 dispatch**:那一次已经把 goal 下所有 draft 一起入队了(§六.2), // 现在 t2 是 queued 而不是 draft。而它是 manual,调度器故意不自动出队(§五.2bis), // 所以由人点「开始」把它从队里拉起来 —— 这正是那个端点存在的理由。 const [d2, r2] = await call('POST', `/api/company/${cid}/tasks/${t2.taskId}/start`); 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; } }); // ────────────────────────────────────────────────────────────── // §四.10 · goal 级验收(org-validator 在 goal 层再调一次) // ────────────────────────────────────────────────────────────── /** 异步验收跑在 setImmediate 里;等到条件成立再断言,别靠数固定几个 tick */ async function waitFor(pred, label) { for (let i = 0; i < 50; i += 1) { if (pred()) return; await new Promise((r) => setImmediate(r)); } throw new Error(`等不到:${label}`); } /** * 等一个**最终**判词。 * * 不能直接等 `verdict` 非空:CAS 抢到收敛权时会同步认领一个占位判词(`VERDICT_PENDING_PREFIX` 打头, * 见 task-settle 常量处 —— 没有它,验收窗口里一重启就静默丢一次验收,且在库里和 * 「1 条 task 跳过验收」完全同形)。等非空就会在占位值上立刻返回,后面对判词内容的断言 * 变成一场竞态。所以判据是「非空**且**不是占位值」。 */ async function waitVerdict(goalId, label = 'goal 级验收回写') { const { isPendingVerdict } = require('../../services/task-settle'); await waitFor(() => { const v = wdb.getGoal(goalId).verdict; return v && !isPendingVerdict(v); }, label); } /** 建一个两条 task 的 goal,逐条 dispatch → 推到指定终态。返回建出来的 goal 视图 */ async function runTwoTaskGoal(cid, [s1, s2], extra = {}) { const [created, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '把注册流程重做一遍:后端发验证码,前端收验证码', tasks: [{ description: '后端加发验证码的接口' }, { description: '前端表单接上验证码' }], ...extra, }); expect(created).toBe(201); const sse = require('../../services/graph-run-sse'); // 批 3:一次 dispatch 把两条都入队,队头那条立刻起跑 const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(d1).toBe(201); sse.transitionRun(r1.started.runId, 'running', s1, {}, { summary: '用例直推终态' }); // 第二条是 manual,调度器不碰它(§五.2bis),由人点「开始」 const second = goal.tasks.find((t) => t.taskId !== r1.started.taskId); const [d2, r2] = await call('POST', `/api/company/${cid}/tasks/${second.taskId}/start`); expect(d2).toBe(201); sse.transitionRun(r2.started.runId, 'running', s2, {}, { summary: '用例直推终态' }); return goal; } it('≥2 条 task 全 completed → 调一次 org-validator;判 fail 时 goal 翻成 failed,OKR 写的是最终结论', async () => { // 这条用例守的是 §四.10 的整个理由:每条 task 都按自己的描述做完了,不等于拼起来是人要的东西。 // 也守住约束 5 —— OKR / report_to 必须拿**验收后**的状态,验收前就汇报等于对外说了个会被改的结论。 const cid = newCompany(); const validation = require('../../services/goal-validation'); 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 }; }; const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx); return { pass: false, verdict: '后端发的是 6 位码,前端只收 4 位,两条 task 各自都对,合起来跑不通', tokens: 1234 }; }); try { const goal = await runTwoTaskGoal(cid, ['completed', 'completed'], { okr_node_id: 'O-010.1' }); await waitVerdict(goal.goalId); const row = wdb.getGoal(goal.goalId); expect(seen).toHaveLength(1); // 一个 goal 只验一次:CAS 之后才调,不是每条 task 各调一次 expect(seen[0].goal.id).toBe(goal.goalId); expect(seen[0].tasks.map((t) => t.status)).toEqual(['completed', 'completed']); expect(row.status).toBe('failed'); // CAS 先占位写 done,验收判 fail 后改写 expect(row.verdict).toContain('前端只收 4 位'); expect(row.validation_tokens).toBe(1234); // 验收烧的 token 记在 goal 上(没有 run 的预算池可扣) // 汇报只有一次,且带的是最终状态;验收前抢跑就会看到一条 done expect(okrCalls).toEqual([{ nodeId: 'O-010.1', status: 'failed' }]); // 幂等:收敛权已被拿走,再推一次不会二次验收(org-validator 是真花钱的) const settle = require('../../services/task-settle'); expect(settle.settleGoalIfAllTasksDone(goal.goalId)).toBeNull(); expect(seen).toHaveLength(1); } finally { validation.setValidateGoal(null); OkrService.prototype.updateNode = origUpdateNode; } }); it('有一条 task 没 completed → 直接 failed,不调 org-validator(显然没交付,省一次调用)', async () => { const cid = newCompany(); const validation = require('../../services/goal-validation'); const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx); return { pass: true }; }); try { const goal = await runTwoTaskGoal(cid, ['failed', 'completed']); await new Promise((r) => setImmediate(r)); expect(wdb.getGoal(goal.goalId).status).toBe('failed'); expect(seen).toEqual([]); } finally { validation.setValidateGoal(null); } }); it('只有 1 条 task 时跳过 goal 级验收(两级验收问的是同一件事,再调一次纯浪费)', async () => { const cid = newCompany(); const validation = require('../../services/goal-validation'); const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx); return { pass: false, verdict: '不该被调到' }; }); try { const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '把周报发出去' }); expect(status).toBe(201); require('../../services/graph-run-sse') .transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); await new Promise((r) => setImmediate(r)); expect(seen).toEqual([]); expect(wdb.getGoal(body.goalId).status).toBe('done'); } finally { validation.setValidateGoal(null); } }); it('验收本身炸了 → 保守判 done 且在 verdict 里写明没验过,绝不把 goal 卡在半路(约束 4)', async () => { // 「读不到判词就判 fail」是最自然的错法,而 failed 是终态、会写 OKR、会唤醒上级 —— // 误杀一次真交付不可撤销。反过来 done 至少有「全部 task 都 completed」这个既成事实兜着。 const cid = newCompany(); const validation = require('../../services/goal-validation'); 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 }; }; validation.setValidateGoal(async () => { const e = new Error('org-validator 起不来'); e.tokens = 77; // 炸之前已经烧掉的那部分,照样要记账 throw e; }); try { const goal = await runTwoTaskGoal(cid, ['completed', 'completed'], { okr_node_id: 'O-011.1' }); await waitVerdict(goal.goalId, '兜底判词回写'); const row = wdb.getGoal(goal.goalId); expect(row.status).toBe('done'); expect(row.verdict).toContain('验收未执行'); expect(row.validation_tokens).toBe(77); expect(okrCalls).toEqual([{ nodeId: 'O-011.1', status: 'done' }]); // 卡不住:照样汇报 } finally { validation.setValidateGoal(null); OkrService.prototype.updateNode = origUpdateNode; } }); // ────────────────────────────────────────────────────────────── // 第三轮复核 缺陷 2 / 3 · 验收窗口内重启不丢、汇报炸了不杀进程 // ────────────────────────────────────────────────────────────── /** * 造一个「另一个进程留下的」占位判词。 * 占位值现在是「前缀 + 唯一 token」而不是一个常量 —— 常量形态下认领语句的 WHERE 里必然 * 含有它自己要写的值,闸门当场失效(见 task-settle 常量处那段)。这里的 token 故意与 * 本进程的 PROC_TAG 无关:模拟的就是别的实例写下的那一个。 */ let stuckSeq = 0; const foreignPending = () => `${require('../../services/task-settle').VERDICT_PENDING_PREFIX}otherproc-${++stuckSeq}`; /** 把一条 goal 摆成「CAS 已写终态、验收还没回来」的形状 —— 也就是重启前一瞬间的库 */ function stuckGoal(cid, verdict = foreignPending()) { const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' }); const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' }); const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' }); wdb.updateTask(a.id, { status: 'completed' }); wdb.updateTask(b.id, { status: 'completed' }); expect(wdb.settleGoalStatus(goal.id, 'done')).toBe(1); return wdb.updateGoal(goal.id, { verdict }); } it('CAS 同步写占位判词:验收在跑 ≠ 验收丢了 ≠ 本来就没验,三者在库里分得开(缺陷 2)', async () => { // 占位值存在的全部理由:`verdict IS NULL` 同时也是「1 条 task 跳过验收」那条合法路径的形态。 // 不写占位,窗口里一重启就没有任何查询能把「丢了」从「本来就没验」里挑出来。 const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); let release; validation.setValidateGoal(() => new Promise((r) => { release = r; })); // 卡住不回,模拟验收在跑 try { const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' }); const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' }); const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' }); wdb.updateTask(a.id, { status: 'completed' }); wdb.updateTask(b.id, { status: 'completed' }); expect(settle.settleGoalIfAllTasksDone(goal.id)).toBe('done'); // 同步可见:占位值和 CAS 在同一个 tick 里落库,不是等验收起来才写 const row = wdb.getGoal(goal.id); expect(row.status).toBe('done'); expect(settle.isPendingVerdict(row.verdict)).toBe(true); // 验收本体是在 setImmediate 里才被调到的,先让出一次,release 才拿得到 await waitFor(() => release, '验收本体被调起'); release({ pass: true, verdict: '合起来对上了', tokens: 9 }); await waitVerdict(goal.id); expect(wdb.getGoal(goal.id).verdict).toBe('合起来对上了'); // 真判词覆盖占位值 } finally { validation.setValidateGoal(null); } }); it('启动重推:窗口内重启丢掉的验收会被补跑,且补跑本身幂等(缺陷 2)', async () => { const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); 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 }; }; const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return { pass: false, verdict: '补跑后判 fail:少了退款流程', tokens: 42 }; }); try { const goal = stuckGoal(cid); wdb.updateGoal(goal.id, { okrNodeId: 'O-099.1' }); expect(await settle.resumePendingGoalValidations()).toBeGreaterThanOrEqual(1); const row = wdb.getGoal(goal.id); expect(seen).toContain(goal.id); expect(row.status).toBe('failed'); // 补跑判 fail → 终态被改写成真结论 expect(row.verdict).toBe('补跑后判 fail:少了退款流程'); expect(row.validation_tokens).toBe(42); expect(okrCalls).toContainEqual({ nodeId: 'O-099.1', status: 'failed' }); // 补的不只是判词,还有那次没做的汇报 // 幂等:占位值已被真判词覆盖,再推一次连认领都拿不到,不会二次烧钱 / 二次汇报 const before = seen.length; await settle.resumePendingGoalValidations(); expect(seen.length).toBe(before); expect(okrCalls.filter((c) => c.nodeId === 'O-099.1')).toHaveLength(1); } finally { validation.setValidateGoal(null); OkrService.prototype.updateNode = origUpdateNode; } }); it('重推不覆盖别处已定案的判词(人已经在界面上看到那句话了)', async () => { const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); const { OkrService } = require('../../services/okr-service'); const origUpdateNode = OkrService.prototype.updateNode; const okrCalls = []; OkrService.prototype.updateNode = function (o, nodeId, u) { okrCalls.push(nodeId); return { id: nodeId }; }; let target; validation.setValidateGoal(async (ctx) => { // 认领之后、回写之前的那几十秒里,别人(人工 / 另一个进程)把这条定了案 if (ctx.goal.id === target) wdb.updateGoal(target, { status: 'done', verdict: '人工判 pass' }); return { pass: false, verdict: '重推的结论,不该落库', tokens: 5 }; }); try { const goal = stuckGoal(cid); wdb.updateGoal(goal.id, { okrNodeId: 'O-098.1' }); target = goal.id; await settle.resumePendingGoalValidations(); const row = wdb.getGoal(goal.id); expect(row.verdict).toBe('人工判 pass'); // ⚠️ 第四轮改动:判词输了,但**账要记** —— 这一次 org-validator 是真跑了、token 是真烧了。 // 原断言钉住的正是「输家的钱静默蒸发」那个缺陷本身(与 settleGoalPlanning 那条 // 「没命中也补记账」的分支自相矛盾)。现在改成断言它被记上。 expect(row.validation_tokens).toBe(5); expect(okrCalls).not.toContain('O-098.1'); // 也不能再汇报一次:OKR 写两次、上级叫醒两次都不可重放 } finally { validation.setValidateGoal(null); OkrService.prototype.updateNode = origUpdateNode; } }); it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => { // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。 // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async // 函数里,同一个异常会逃成 unhandledRejection —— Node ≥15 默认直接杀掉整个服务。 const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); const { OkrService } = require('../../services/okr-service'); const origUpdateNode = OkrService.prototype.updateNode; OkrService.prototype.updateNode = function () { throw new Error('ENOENT: OKR 目录被删了'); }; validation.setValidateGoal(async () => ({ pass: true, verdict: '合起来对上了', tokens: 1 })); const rejections = []; const onRejection = (e) => rejections.push(e); process.on('unhandledRejection', onRejection); try { const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' }); wdb.updateGoal(goal.id, { okrNodeId: 'O-097.1' }); const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' }); const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' }); wdb.updateTask(a.id, { status: 'completed' }); wdb.updateTask(b.id, { status: 'completed' }); settle.settleGoalIfAllTasksDone(goal.id); await waitVerdict(goal.id); // 判词照样落了库(汇报炸在回写之后),进程也没被一个逃逸的 rejection 掀翻 expect(wdb.getGoal(goal.id).verdict).toBe('合起来对上了'); await new Promise((r) => setTimeout(r, 20)); // 给 unhandledRejection 事件一个真到达的机会 expect(rejections).toEqual([]); } finally { process.off('unhandledRejection', onRejection); validation.setValidateGoal(null); OkrService.prototype.updateNode = origUpdateNode; } }); // ────────────────────────────────────────────────────────────── // 第二轮复核(批 2)· 多实例并发下的认领与回写,以及「汇报丢了」的可查性 // ────────────────────────────────────────────────────────────── /** * 三个用例守的是同一件事:goal 收敛带着两个**不可重放**的副作用(写一次 OKR 节点、 * 唤醒一次上级 agent,后者真起一轮 LLM)。它们各盯一层闸门: * ① 认领 —— 唯一 token + WHERE 只认上一个态(上一版把自己要写的值也放进了 WHERE, * changes 恒为 1,闸门当场失效); * ② in-flight —— 本进程还在飞的验收不是窟窿,重推不许把它当「丢了」抢走; * ③ 回写 —— `WHERE verdict = @claim` 决出唯一赢家,输的一个副作用都不做。 * 三层缺一层,实测的结果就是复核给出的那三个数字:验收调 2 次、OKR 写 2 条、token 记 100。 */ /** 装一副 OKR 打桩,返回 [收集数组, 还原函数](真 updateNode 会往仓库 data/ 里落文件) */ function stubOkr() { const { OkrService } = require('../../services/okr-service'); const orig = OkrService.prototype.updateNode; const calls = []; OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) { calls.push(`${nodeId}:${updates.status}`); return { id: nodeId }; }; return [calls, () => { OkrService.prototype.updateNode = orig; }]; } it('两个扫描者拿着同一份快照认领同一条 goal:只有一个赢,验收 / OKR / token 都只发生一次', async () => { // 复核实跑复现的就是这个形态:A、B 两个实例几乎同时 SELECT 出同一批占位值的 goal, // 然后各自 UPDATE 去认领。上一版的认领是 // `SET verdict = RESUMING WHERE verdict IN (PENDING, RESUMING)` // —— **写进去的值自己也在 WHERE 里**,已被 A 认领的行照样匹配 B 的条件,changes 恒为 1。 // 于是 org-validator 被调 2 次、同一个 OKR 节点写 2 条、validation_tokens 记成两倍。 const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); const [okrCalls, restoreOkr] = stubOkr(); const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return { pass: true, verdict: '合起来对上了', tokens: 50 }; }); try { const goal = stuckGoal(cid); wdb.updateGoal(goal.id, { okrNodeId: 'O-096.1' }); const snapshot = wdb.getGoal(goal.id).verdict; // 两个实例手里的那份快照 // 实例 A:走完整条重推 expect(await settle.resumePendingGoalValidations()).toBeGreaterThanOrEqual(1); // 实例 B:它的 SELECT 早于 A 的认领,手里还是那份旧快照 —— 跨进程时 inFlight 帮不上忙 // (那是进程内的知识),守门的只有这一句 SQL 本身。 const bClaim = wdb.claimGoalSlot(goal.id, 'verdict', { from: snapshot, to: `${settle.VERDICT_PENDING_PREFIX}instB-1`, }); expect(bClaim).toBe(0); // 修之前这里是 1 // B 认领不到,于是它这一轮什么都不做;再扫一次也只会看到 A 写下的真判词 await settle.resumePendingGoalValidations(); const row = wdb.getGoal(goal.id); expect(seen).toEqual([goal.id]); // org-validator 只起一次 expect(okrCalls.filter((c) => c.startsWith('O-096.1'))).toEqual(['O-096.1:done']); // OKR 只写一条 expect(row.validation_tokens).toBe(50); // token 不双记(不是 100) expect(row.verdict).toBe('合起来对上了'); expect(row.reported_at).toBeTruthy(); } finally { validation.setValidateGoal(null); restoreOkr(); } }); it('主路径验收在飞时撞上启动重推:本进程认得出「这条还活着」,不当窟窿抢走', async () => { // 上一版的判据是一句只对单进程成立的断言:「没有任何 in-flight 验收能活过一次重启, // 所以此刻挂着占位值的全是丢掉的」。pm2 cluster / 滚动重启下,B 起来时 A 还活着、 // 验收还在飞 —— 那些 goal 的 verdict 正是占位值,会被整批当成「丢了」抢走。 // 同进程这一半必须彻底堵死(跨进程那一半由回写决胜兜底,见下一条)。 const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); const [okrCalls, restoreOkr] = stubOkr(); const seen = []; let release; validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return new Promise((r) => { release = r; }); // 卡住,模拟分钟级的 LLM 调用 }); try { const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' }); wdb.updateGoal(goal.id, { okrNodeId: 'O-094.1' }); const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' }); const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' }); wdb.updateTask(a.id, { status: 'completed' }); wdb.updateTask(b.id, { status: 'completed' }); // 主路径:CAS → 认领占位判词 → 验收挂在 setImmediate 上 expect(settle.settleGoalIfAllTasksDone(goal.id)).toBe('done'); await waitFor(() => release, '主路径的验收本体被调起'); const claimed = wdb.getGoal(goal.id).verdict; expect(settle.isPendingVerdict(claimed)).toBe(true); // 重推撞上来:这条还在飞,不许碰 await settle.resumePendingGoalValidations(); expect(seen).toEqual([goal.id]); // 没有第二次 org-validator expect(wdb.getGoal(goal.id).verdict).toBe(claimed); // 占位 token 没被换掉 release({ pass: true, verdict: '合起来对上了', tokens: 50 }); await waitVerdict(goal.id); const row = wdb.getGoal(goal.id); expect(seen).toEqual([goal.id]); expect(row.validation_tokens).toBe(50); expect(okrCalls.filter((c) => c.startsWith('O-094.1'))).toEqual(['O-094.1:done']); // 收敛完再扫一遍:判词已定案、汇报已盖戳,连窟窿都不是了 await settle.resumePendingGoalValidations(); expect(seen).toEqual([goal.id]); expect(okrCalls.filter((c) => c.startsWith('O-094.1'))).toEqual(['O-094.1:done']); } finally { validation.setValidateGoal(null); restoreOkr(); } }); it('跨进程被抢走时靠回写决胜:输的那一方判词写不进去,也绝不汇报', async () => { // 跨进程判活要租约 + 心跳(另排),所以真有别的实例把在飞的这条抢走时,多烧的那一次 // org-validator 是拦不住的。拦得住的是**副作用**:回写是 `WHERE verdict = @claim` 的 // 条件更新,抢跑者换掉了 token,主路径的 changes = 0 —— 判词不覆盖、token 不记、不汇报。 // 上一版这里是「getGoal 读一下 + updateGoal 写回去」的 check-then-act,读写之间隔着 // 一整轮事件循环,跨进程根本不是原子的。 const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); const [okrCalls, restoreOkr] = stubOkr(); const seen = []; let release; validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); if (seen.length === 1) return new Promise((r) => { release = r; }); return { pass: false, verdict: '抢跑者的结论:少了退款流程', tokens: 30 }; }); try { const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' }); wdb.updateGoal(goal.id, { okrNodeId: 'O-093.1' }); const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' }); const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' }); wdb.updateTask(a.id, { status: 'completed' }); wdb.updateTask(b.id, { status: 'completed' }); expect(settle.settleGoalIfAllTasksDone(goal.id)).toBe('done'); await waitFor(() => release, '主路径的验收本体被调起'); const claimed = wdb.getGoal(goal.id).verdict; // 另一个实例把它抢走(它没有本进程的 inFlight 这份知识) const stolen = `${settle.VERDICT_PENDING_PREFIX}instB-2`; expect(wdb.claimGoalSlot(goal.id, 'verdict', { from: claimed, to: stolen })).toBe(1); // 主路径这才拿到结论 —— 回写必须整段落空 release({ pass: true, verdict: '主路径的结论,不该落库', tokens: 50 }); await new Promise((r) => setTimeout(r, 20)); const mid = wdb.getGoal(goal.id); expect(mid.verdict).toBe(stolen); // 没被覆盖 // ⚠️ 第四轮改动:判词输了,但**账要记** —— 这一次 org-validator 是真跑了、token 是真烧了。 // 原断言钉住的正是「输家的钱静默蒸发」那个缺陷本身(与 settleGoalPlanning 那条 // 「没命中也补记账」的分支自相矛盾)。现在改成断言它被记上。 expect(mid.validation_tokens).toBe(50); // 输家的 token 现在记账(原为 0 = 静默蒸发) expect(mid.reported_at).toBeFalsy(); // **一次都没汇报**:汇报不可重放,宁可等抢跑者报 expect(okrCalls.filter((c) => c.startsWith('O-093.1'))).toEqual([]); // 抢跑者跑完自己那一轮:这时候才轮到唯一的一次汇报 await settle.resumePendingGoalValidations(); const row = wdb.getGoal(goal.id); expect(row.verdict).toBe('抢跑者的结论:少了退款流程'); // 总账 = 输家 50 + 赢家 30。两次 org-validator 是真跑了两次(跨进程抢跑拦不住多烧一次, // 设计已明写认账),拦住的是**不可重放的副作用**:判词只写一次、汇报只发一次。 expect(row.validation_tokens).toBe(80); expect(row.status).toBe('failed'); expect(okrCalls.filter((c) => c.startsWith('O-093.1'))).toEqual(['O-093.1:failed']); } finally { validation.setValidateGoal(null); restoreOkr(); } }); it('汇报丢了在库里查得出来,启动重推只补汇报、不重跑验收(半修残留)', async () => { // 判词一落库,这条 goal 就永远退出了**验收**重推的扫描面(那边只认占位判词), // 而紧接着的汇报原来是发射后不管的 fetch。进程在这几十毫秒到几秒里被杀、或端点回 5xx, // OKR 永不写、上级永不醒,**而库里这条 goal 长得和一次完美收敛一模一样**。 // reported_at 就是把这两者分开的那一格。 const cid = newCompany(); const settle = require('../../services/task-settle'); const validation = require('../../services/goal-validation'); const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return { pass: true, verdict: '不该被调到' }; }); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '给上级交一份周报', report_to: 'ceo', okr_node_id: 'O-092.1', }); expect(status).toBe(201); const origFetch = globalThis.fetch; const posted = []; let httpOk = false; globalThis.fetch = (url, init) => { posted.push(JSON.parse(init.body)); return Promise.resolve({ ok: httpOk, status: httpOk ? 200 : 500 }); }; try { require('../../services/graph-run-sse') .transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); await waitFor(() => posted.length >= 1, '第一次汇报打出去'); // 等这次汇报**真的结束**(拿到 500 才算完)。还没拿到响应之前它是「在飞」而不是「窟窿」, // 重推会正确地跳过它 —— 这里模拟的是进程死后重启,那时候没有任何东西还在飞。 await new Promise((r) => setTimeout(r, 10)); // 可查性:这条 goal 只有 1 条 task(跳过验收 → verdict 恒为 NULL,那是合法形态), // 唯一能把「汇报丢了」认出来的就是 reported_at 上停着的占位值 const row = wdb.getGoal(body.goalId); expect(row.status).toBe('done'); expect(row.verdict).toBeFalsy(); expect(String(row.reported_at).startsWith(settle.REPORT_PENDING_PREFIX)).toBe(true); // 把占位值**做旧**再重推 —— 上面那句注释说的就是这件事:「模拟的是进程死后重启」。 // 认领带租约(CLAIM_LEASE_MS)之后,活着的认领不许抢、过期的才准抢;死掉的那个进程 // 留下的占位值在重启时必然已经过期。不做旧就等于在模拟「另一个实例正在汇报」, // 那时候正确行为恰恰是**跳过**(第三轮复核实跑复现的跨进程重复汇报就是这么来的)。 const stale = String(wdb.getGoal(body.goalId).reported_at).replace(/@\d+$/, `@${Date.now() - 3600_000}`); wdb.getDb().prepare('UPDATE goals SET reported_at = ? WHERE id = ?').run(stale, body.goalId); // 重推:补的是**汇报**那一步,一次 org-validator 都不该起(重跑验收 = 白烧一轮 LLM, // 还可能给出和库里那句不一样的判词) httpOk = true; await settle.resumePendingGoalValidations(); expect(seen).toEqual([]); expect(posted).toHaveLength(2); expect(posted[1].taskId).toBe(body.goalId); expect(posted[1].status).toBe('done'); // 报成了才盖真时间戳;从此这条不再是窟窿 expect(wdb.getGoal(body.goalId).reported_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); await settle.resumePendingGoalValidations(); expect(posted).toHaveLength(2); // 绝不重复汇报 } finally { globalThis.fetch = origFetch; validation.setValidateGoal(null); } }); it('判词解析:取最后一个 JSON 块、认得 audit_verdict、读不出结论时返回 null', () => { const { extractAuditVerdict } = require('../../services/goal-validation'); // 本体正文和分析过程里常常引用判决样例,从前往后取会认错那一份 const twoBlocks = '前面举个例子:\n```json\n{"audit_verdict":"pass"}\n```\n结论:\n' + '```json\n{"audit_verdict":"fail","verdict":"少了退款流程"}\n```'; expect(extractAuditVerdict(twoBlocks)).toEqual({ pass: false, verdict: '少了退款流程' }); // JSON 写坏了(尾逗号)但结论还在原文里:捞结论、判词退回摘要,好过整次验收作废 const broken = '```json\n{"audit_verdict": "pass", "verdict": "都对上了",}\n```'; expect(extractAuditVerdict(broken).pass).toBe(true); // 连结论都没有 → null,调用方走保守收敛。绝不能默认判 fail expect(extractAuditVerdict('我觉得还行吧')).toBeNull(); expect(extractAuditVerdict('')).toBeNull(); }); it('goal 级验收的提示词必须自带「不在任何 run 里」的覆盖声明,否则本体会照 run 级流程误杀(§四.10)', () => { // org-validator/agent.md 的六步工作流整个建立在 run 级寻址上(runs//、 // acceptance.json、goal-contract.js run --run-id)。goal 级调用里那些路径一个都不存在, // 不明说它会按「契约不全 → input_underspecified」立刻判 fail —— 一次正常交付被误杀, // 而且理由看上去很有道理。这段声明是整条链路上最容易被人当客套话删掉的东西。 const { buildGoalValidationPrompt } = require('../../services/goal-validation'); const cid = newCompany(); const goal = wdb.createGoal({ companyId: cid, title: '注册流程', description: '注册要能发验证码并校验' }); const t1 = wdb.createTask({ goalId: goal.id, companyId: cid, title: '后端', description: '发验证码接口' }); const t2 = wdb.createTask({ goalId: goal.id, companyId: cid, title: '前端', description: '表单校验验证码' }); const empty = buildGoalValidationPrompt({ companyId: cid, goal, tasks: [t1, t2] }); expect(empty).toContain('本次调用不在任何 graph run 里'); expect(empty).toContain('input_underspecified'); // 点名那条误判路径,不是泛泛说「注意」 expect(empty).toContain('注册要能发验证码并校验'); // ① 验收边界 = 需求原文 expect(empty).toContain('发验证码接口'); // ② 每条 task 的描述 // 写入边界段头出现在这里会翻转 PromptBuilder 的分叉(同 task-planning 那条) expect(empty).not.toContain('[写入边界'); // 空索引是一条真信号而不是「这段就别写了」:不写出来,agent 会拿 task 描述当产出想象 expect(empty).toContain('产物索引为空'); // 有产物时给的必须是绝对路径:agent 的 cwd 在 system/agents/org-validator/{cid}/{goalId}/, // 裸相对路径它一个都打不开,而且不报错 wdb.recordFile({ companyId: cid, taskId: t1.id, relPath: 'tasks/x/code/sms.js', kind: 'code' }); const withFiles = buildGoalValidationPrompt({ companyId: cid, goal, tasks: [t1, t2] }); expect(withFiles).not.toContain('产物索引为空'); expect(withFiles).toMatch(/- \[task \d+\] code:\//); }); it('并行收敛只有一个赢家:CAS 在验收之前,org-validator 恰好被调一次(§四.10)', async () => { // 上面那条验的是「验收跑完之后再推一次不会二次验收」,挡不住真正要防的那个形态: // N 条 task 在**同一个 tick 里**各自收敛,各自都算出「全都到终态了」。闸门放在验收之后的话 // 这里会烧 N 份 org-validator 的钱,而其中 N-1 份的结论最后连库都写不进去。 const cid = newCompany(); const validation = require('../../services/goal-validation'); const settle = require('../../services/task-settle'); const seen = []; validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return { pass: true, verdict: '两条合起来对上了', tokens: 5 }; }); try { const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' }); const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' }); const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' }); wdb.updateTask(a.id, { status: 'completed' }); wdb.updateTask(b.id, { status: 'completed' }); // 同步连调两次 = 两条 task 的收敛钩子挤在同一个 tick 上(真实形态就是这样: // 钩子是 transitionRun 尾巴上的同步调用,异步的只有验收本身) const r1 = settle.settleGoalIfAllTasksDone(goal.id); const r2 = settle.settleGoalIfAllTasksDone(goal.id); expect([r1, r2]).toEqual(['done', null]); // 'done' 是 CAS 占位值,验收判 fail 时会被改写 await waitVerdict(goal.id); expect(seen).toEqual([goal.id]); expect(wdb.getGoal(goal.id).validation_tokens).toBe(5); } finally { validation.setValidateGoal(null); } }); // ────────────────────────────────────────────────────────────── // 验收 19 / 22 · 拆分产出是草稿清单,人确认后才按依赖顺序跑 // ────────────────────────────────────────────────────────────── /** 轮询到 goal 离开 planning(拆分是后台 setImmediate,202 先回) */ async function waitSplitDone(goalId, tries = 200) { for (let i = 0; i < tries; i += 1) { const g = wdb.getGoal(goalId); if (g && g.status !== 'planning') return g; await new Promise((r) => setTimeout(r, 10)); } throw new Error(`goal ${goalId} 一直停在 planning —— 正是 §四.9 约束 2 要消灭的那个卡死态`); } it('拆分产出全是 draft、可改可删,「全部下达」按依赖顺序一条条起(验收 19)', async () => { // 拆分不自动下达是这条的全部重点:产出是**草稿清单**,人看过改过才开跑。 // 直接入队的话,一次拆错就是 N 条真跑起来的 run,每条都在烧钱。 const cid = newCompany(); const tp = require('../../services/task-planning'); const validation = require('../../services/goal-validation'); const sse = require('../../services/graph-run-sse'); validation.setValidateGoal(async () => ({ pass: true, verdict: '合起来对上了', tokens: 3 })); tp.setPlanTasks(async () => ({ specs: [ { seq: 1, title: '后端', description: '实现发验证码的接口', depends_on: [] }, { seq: 2, title: '前端', description: '注册表单接上验证码', depends_on: [1] }, { seq: 3, title: '文档', description: '写接口文档', depends_on: [1] }, ], tokens: 321, })); try { const [status, body] = await call('POST', `/api/company/${cid}/goals`, { description: '把注册流程重做一遍' }); expect(status).toBe(202); const goalId = body.goalId; const g = await waitSplitDone(goalId); expect(g.planning_tokens).toBe(321); // 验收 21:goal 层的花费不再隐形 let tasks = wdb.listGoalTasks(goalId); expect(tasks.map((t) => t.status)).toEqual(['draft', 'draft', 'draft']); expect(tasks.map((t) => t.current_run_id)).toEqual([null, null, null]); // 一条 run 都没起 const [t1, t2, t3] = tasks; expect(JSON.parse(t2.depends_on)).toEqual([t1.id]); // 可改:draft 上 queued_at 还是空的,描述这类冻结字段随便改 const [patched, view] = await call('PATCH', `/api/company/${cid}/tasks/${t2.id}`, { description: '注册表单接上验证码,并加倒计时', }); expect(patched).toBe(200); expect(view.description).toContain('倒计时'); // 可删:归档只是隐藏,行还在 const [deleted] = await call('DELETE', `/api/company/${cid}/tasks/${t3.id}`); expect(deleted).toBe(200); expect(wdb.listGoalTasks(goalId).map((t) => t.id)).toEqual([t1.id, t2.id]); // 「全部下达」:批 3 起它把 goal 下**所有** draft 一起推进队列(§六.2), // 起跑的仍然是跑得动的那条 —— 依赖没就绪不再是拒绝的理由,是「先排着」 const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${goalId}/dispatch`); expect(d1).toBe(201); expect(r1.started.taskId).toBe(t1.id); // 两条都入了队;t2 没起跑,如实带着它的队列位次回来 expect(r1.queued.map((q) => q.taskId).sort()).toEqual([t1.id, t2.id].sort()); expect(r1.queued.find((q) => q.taskId === t2.id).queuePosition).toBe(1); expect(wdb.getTask(t2.id).status).toBe('queued'); expect(wdb.getGoal(goalId).status).toBe('active'); // 首条下达 = goal 的描述冻结点 // t1 没跑完之前 t2 起不来:现在挡它的是**依赖**(上游没 completed), // 而不是「公司已有任务在跑」那条已经被批 3 删掉的 409。报的是谁挡的它 const [d2, e2] = await call('POST', `/api/company/${cid}/tasks/${t2.id}/start`); expect(d2).toBe(409); expect(e2.blockedBy).toEqual([t1.id]); sse.transitionRun(r1.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); expect(wdb.getTask(t1.id).status).toBe('completed'); // 上游 completed 了,下游才轮到。manual 不参与自动出队,所以由人点「开始」 const [d3, r3] = await call('POST', `/api/company/${cid}/tasks/${t2.id}/start`); expect(d3).toBe(201); expect(r3.started.taskId).toBe(t2.id); sse.transitionRun(r3.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); // 两条都完了 → goal 收敛,归档掉的那条不参与判定(它已经不在需求里了) await waitVerdict(goalId); expect(wdb.getGoal(goalId).status).toBe('done'); } finally { tp.setPlanTasks(null); validation.setValidateGoal(null); } }); it('存量 POST /:cid/tasks 与 split:false 一次 LLM 都不调(验收 22)', async () => { // 计数不能只数注入桩:链路绕过 planTasks 直接 new AgentManager 也一样是花钱, // 所以把最底下那层 callAgent 一并挡住 —— 它被调到就说明有人偷偷起了 CLI。 const cid = newCompany(); const AgentManager = require('../../src/core/agent-manager'); const tp = require('../../services/task-planning'); const origCall = AgentManager.prototype.callAgent; let llm = 0; let planned = 0; AgentManager.prototype.callAgent = async function stubCallAgent() { llm += 1; throw new Error('这条路径不该起 agent'); }; tp.setPlanTasks(async () => { planned += 1; throw new Error('不该被调到'); }); try { const [a, ta] = await call('POST', `/api/company/${cid}/tasks`, { task: '把周报发出去' }); expect(a).toBe(201); // 起完 run 再收掉,免得占着唯一索引让下一条 409(这里验的是花不花钱,不是并发约束) require('../../services/graph-run-sse') .transitionRun(ta.runId, 'running', 'completed', {}, { summary: '用例直推终态' }); const [b] = await call('POST', `/api/company/${cid}/goals`, { description: '同步建的 goal', split: false }); expect(b).toBe(201); await new Promise((r) => setImmediate(r)); // 给后台路径一个开口,真调了就会被数到 expect({ llm, planned }).toEqual({ llm: 0, planned: 0 }); } finally { AgentManager.prototype.callAgent = origCall; tp.setPlanTasks(null); } }); 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 那条路上被丢掉和被违反的两个约定 // ────────────────────────────────────────────────────────────── /** * 只比「申报的额度」,剥掉 spent。 * * spent 是**运行期账本**:run 一进 running,墙钟就往里写 active_ms / started_at * (见 budget-wallclock-active.test.js)。整个对象 deepEqual 会让这条用例在账本 * 新增字段时红,而它守的是「用户填的额度没被静默换成默认值」—— 跟账本长什么样无关。 * 账本本身另有断言:起跑那一刻还没花钱,agent_calls / tokens 必须仍是 0。 */ const limitsOf = (o) => { const { spent, ...limits } = typeof o === 'string' ? JSON.parse(o) : o; return limits; }; const spentOf = (o) => (typeof o === 'string' ? JSON.parse(o) : o).spent || {}; 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, split: false }); 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,而没有任何地方说过预算被换掉了 const dispatchedBudget = wdb.getRun(dispatched.started.runId).budget_json; expect(limitsOf(dispatchedBudget)).toEqual(limitsOf(budget)); expect(spentOf(dispatchedBudget)).toMatchObject({ agent_calls: 0, tokens: 0 }); // 起跑那一刻还没花钱 // 本次 dispatch 的 body 仍然覆盖草稿上的申报值(优先级:body > 草稿 > 默认) const cid2 = newCompany(); const [, goal2] = await call('POST', `/api/company/${cid2}/goals`, { description: '同样带预算', budget, split: false }); 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(limitsOf(wdb.getRun(d2.started.runId).budget_json)).toEqual(limitsOf(override)); }); it('坑被占着时 POST /tasks 不再 409 而是入队,申报的 budget 一路带到出队(批 3 · 缺陷 F3 的现场)', async () => { // **这条用例本身就是批 3 的分界线。** 批 1/2 时它验的是「撞唯一索引 → 409 + 存成草稿 + // hint 让你稍后 dispatch」。批 3 把那条 409 删了:坑被占着不再是错误,是排队。 // 保住的不变式没变 —— 用户填的 budget 必须一路带到真正起跑那一刻, // 不落库的话他只会看到任务跑到一半 blocked,而没有任何地方说过预算被换掉了。 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 now = new Date().toISOString(); // 先把这家公司唯一的执行坑占掉(一条 running 的 root run) 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); const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一件排在后面的活', budget }); // 201,不是 409 —— 这就是「409 消失」的地方 expect(status).toBe(201); expect(body.status).toBe('queued'); expect(body.queuePosition).toBe(1); // 排第一位:告诉用户他在等什么,而不是让他猜 const queuedTask = wdb.getTask(body.taskId); expect(queuedTask.status).toBe('queued'); // 入队即建 run(queued 的 run 不占唯一索引的坑),budget 当场就挂在它身上 const queuedRun = wdb.getRun(body.runId); expect(queuedRun.run_status).toBe('queued'); expect(JSON.parse(queuedRun.budget_json)).toEqual(budget); // 申报值同样落在 task 行上:/enqueue 与 /rerun 读的是它 expect(JSON.parse(queuedTask.budget_json)).toEqual(budget); // 前一条任务收敛,坑空出来 → 人点「开始」,起的还是同一条 run、同一份预算 db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(intruderId); const [ok, started] = await call('POST', `/api/company/${cid}/tasks/${body.taskId}/start`); expect(ok).toBe(201); expect(started.started.runId).toBe(body.runId); // 不是新起一条,是把排着的那条拉起来 const runBudget = JSON.parse(wdb.getRun(body.runId).budget_json); expect(limitsOf(runBudget)).toEqual(limitsOf(budget)); expect(spentOf(runBudget)).toMatchObject({ agent_calls: 0, tokens: 0 }); 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); // 跳过被挡住的部署,起构建 // 批 3:被挡住的那条不再留在 draft 干等下一次 dispatch,而是**进队排着** expect(res1.queued.map((q) => q.taskId).sort()).toEqual([build.taskId, deploy.taskId].sort()); expect(wdb.getTask(deploy.taskId).status).toBe('queued'); // 依赖仍是硬约束,一条都没抢跑 // 构建跑完 → 部署的上游就绪,人点「开始」就轮到它(manual 不参与自动出队) 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}/tasks/${deploy.taskId}/start`); expect(ok2).toBe(201); expect(res2.started.taskId).toBe(deploy.taskId); }); it('一条都跑不动时不再 409,而是全部入队并就地推导成 blocked,级联一次算到底(批 3 · §五.1.2)', async () => { // 批 1/2 这里是 409「一条都跑不动」。批 3 把它换成了排队 + 依赖推导,理由是 409 之后 // 那两条 task 仍是 draft,界面上和「还没下达」一模一样;而 blocked + blocked_reason // 说得出**是谁挡着它**,`⋯` 菜单据此给出「重跑上游 / 强制放行」两条出路。 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 的上游,冒烟又等着部署 wdb.updateTask(build.taskId, { status: 'failed' }); const [ok, res] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); expect(ok).toBe(201); expect(res.started).toBeUndefined(); // 谁都跑不动,一条 run 都没起 const { UPSTREAM_BLOCK_PREFIX } = wdb; // 部署:直接被 failed 的上游挡住 const d = wdb.getTask(deploy.taskId); expect(d.status).toBe('blocked'); expect(d.blocked_reason).toBe(`${UPSTREAM_BLOCK_PREFIX}${build.taskId}`); // 冒烟:级联一次算到底 —— blocked 同样不是 completed,所以它也永远等不到 const sm = wdb.getTask(smoke.taskId); expect(sm.status).toBe('blocked'); expect(sm.blocked_reason).toBe(`${UPSTREAM_BLOCK_PREFIX}${deploy.taskId}`); // **级联要解得开**:blocked 只是每轮 tick 推导出来的,不落成不可逆状态。 // 上游恢复 → 下一次 tick 把两条依次放回队列(这是「注释承诺的防线真的存在」那条) wdb.updateTask(build.taskId, { status: 'completed' }); require('../../services/task-scheduler').tick(cid); expect(wdb.getTask(deploy.taskId).status).toBe('queued'); expect(wdb.getTask(deploy.taskId).blocked_reason).toBeNull(); require('../../services/task-scheduler').tick(cid); expect(wdb.getTask(smoke.taskId).status).toBe('queued'); }); 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 就完事 // 另一条也入了队(批 3:dispatch 把 goal 下所有 draft 一起推进队列),排在第 1 位 expect(res.queued.map((q) => q.taskId).sort()).toEqual([first.taskId, second.taskId].sort()); expect(res.queued.find((q) => q.taskId === first.taskId).queuePosition).toBe(1); }); 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 —— 活跃 root run 是要占公司执行坑的(批 4 前还有一条同名唯一索引), // 而这里要的是 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['"]/); } }); });