/** * goal / task 两级模型的路由层(27-P3C §六,批 1)。 * * 这里只验**不需要真起 run** 的那一半:列表 / 详情 / 改 / 归档,外加两条闸门的 HTTP 翻译 * (描述冻结 → 409、depends_on 非法 → 400 且库里不留脏数据)。起 run 的那条路要真公司 + * 真 org graph + Claude CLI,属于 §八 验收清单里在浏览器上点的部分,不在单测射程内。 * * 进度那条断言(2/3)是本文件的重点:验收 10 点名要挡的就是「列表页逐行查进度」的 N+1, * 路由这边走的是 taskProgress 的一条 GROUP BY —— 结果对不对必须有人盯着, * 否则改成逐行查也一样是绿的。 */ const fs = require('fs'); const os = require('os'); const path = require('path'); const TEST_DB_PATH = '/tmp/test-company-goal-task-routes.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; // 这几个模块在加载时快照 GRAPH_DB_PATH / GRAPH_RUNS_DIR,必须先设 env 再 require(见 §八 验收 12b) const MODULES = ['../../services/graph-db', '../../services/work-graph-db', '../../routes/company']; const CID = 'route-test-co'; let wdb; let server; let base; let origin; let origCompanyGet; /** 一次请求 → [status, json](没有 supertest,express 起在随机端口上用 fetch 打) */ async function call(method, url, body) { const r = await fetch(base + url, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); return [r.status, await r.json()]; } /** 同上,但公司 id 自己指定(验「打错公司 id → 404」那条要打到别的公司上) */ async function callCompany(cid, method, url) { const r = await fetch(`${origin}/api/company/${cid}${url}`, { method }); return [r.status, await r.json()]; } describe('company goal/task routes(27-P3C §六)', () => { let goal; let t1; let t2; beforeAll(async () => { for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } process.env.GRAPH_DB_PATH = TEST_DB_PATH; // 产物根挪到临时目录:不设的话任何走到默认路径的写入都会往仓库 data/companies/ 里堆 process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-routes-')); for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); // 公司实体:`company-service` 的 DATA_DIR 是硬编码的仓库 data/companies/,不认任何 env, // 真建一家公司就是往用户仓库里落目录。这里只让 CID 这一家存在 —— 别的 id 一律 null, // 「打错公司 id → 404」(缺陷 F9)那条断言要靠它。 const { CompanyService } = require('../../services/company-service'); origCompanyGet = CompanyService.prototype.get; CompanyService.prototype.get = (companyId) => (companyId === CID ? { id: CID, name: CID, status: 'running' } : null); const express = require('express'); const app = express(); app.use(express.json()); app.use('/api/company', require('../../routes/company')); await new Promise((resolve) => { server = app.listen(0, resolve); }); origin = `http://127.0.0.1:${server.address().port}`; base = `${origin}/api/company/${CID}`; goal = wdb.createGoal({ companyId: CID, title: '做个官网', description: '做个官网,含首页和后台' }); t1 = wdb.createTask({ goalId: goal.id, title: '首页', description: '写首页' }); t2 = wdb.createTask({ goalId: goal.id, title: '后台', description: '写后台', dependsOn: [t1.id] }); // 给 t1 挂一条 run + 3 个节点行(2 success / 1 pending),用来验进度口径 const db = wdb.getDb(); const now = new Date().toISOString(); db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at) VALUES ('run_rt','org',?,?, 'run_rt','running',?,?)`).run(CID, CID, now, now); ['success', 'success', 'pending'].forEach((status, i) => { db.prepare(`INSERT INTO graph_run_nodes (id, run_id, def_node_id, status, created_at, updated_at) VALUES (?, 'run_rt', ?, ?, ?, ?)`).run(`rn_rt${i}`, `d${i}`, status, now, now); }); wdb.updateTask(t1.id, { currentRunId: 'run_rt', status: 'running' }); }); 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(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ } delete process.env.GRAPH_DB_PATH; delete process.env.GRAPH_RUNS_DIR; for (const m of MODULES) delete require.cache[require.resolve(m)]; }); it('GET /goals?withTasks=1 一次带出两级树,进度走一条 GROUP BY', async () => { const [status, body] = await call('GET', '/goals?withTasks=1'); expect(status).toBe(200); expect(body.goals).toHaveLength(1); const tasks = body.goals[0].tasks; expect(tasks.map((t) => t.taskId)).toEqual([t1.id, t2.id]); // 树的顺序是 seq,不是队列位次 expect(tasks[0].progress).toEqual({ done: 2, total: 3 }); // 没有 run 的 task 是 null 而不是 0/0:前端据此画「未开始」,画成 0/0 会像是「跑了但没进展」 expect(tasks[1].progress).toBeNull(); expect(tasks[1].dependsOn).toEqual([t1.id]); }); it('GET /tasks 支持按 status 过滤,且默认不含已归档', async () => { const [status, body] = await call('GET', '/tasks?status=running'); expect(status).toBe(200); expect(body.tasks.map((t) => t.taskId)).toEqual([t1.id]); }); it('GET /goals/:goalId 带出每条 task 的当前 run 摘要', async () => { const [status, body] = await call('GET', `/goals/${goal.id}`); expect(status).toBe(200); expect(body.tasks[0].run).toMatchObject({ runId: 'run_rt', runStatus: 'running' }); expect(body.tasks[1].run).toBeNull(); }); it('不属于本公司 / 不存在的 goal 一律 404', async () => { const [status] = await call('GET', '/goals/G-nope'); expect(status).toBe(404); }); it('描述冻结:未下达可改,dispatched_at 一非空就 409 + hint(§四.4)', async () => { const [ok, before] = await call('PATCH', `/goals/${goal.id}`, { description: '换个需求' }); expect(ok).toBe(200); expect(before.description).toBe('换个需求'); wdb.updateGoal(goal.id, { dispatchedAt: new Date().toISOString() }); const [conflict, body] = await call('PATCH', `/goals/${goal.id}`, { description: '再换一次' }); expect(conflict).toBe(409); expect(body.frozenFields).toEqual(['description']); expect(body.hint).toBeTruthy(); // 「取消后新建」这句必须给出来,否则用户只知道被拒不知道该怎么办 // title 是列表上的标签、不进 agent 上下文,冻结之后仍可改 const [stillOk] = await call('PATCH', `/goals/${goal.id}`, { title: '改个名' }); expect(stillOk).toBe(200); }); it('depends_on 非法 → 400,且库里不留半条脏数据(§六.3)', async () => { const [status, body] = await call('PATCH', `/tasks/${t2.id}`, { depends_on: [t2.id] }); expect(status).toBe(400); expect(body.error).toMatch(/依赖自己/); expect(JSON.parse(wdb.getTask(t2.id).depends_on)).toEqual([t1.id]); }); it('跑起来之后不许改依赖(判据在数据层,路由只负责翻成 409)', async () => { const [status] = await call('PATCH', `/tasks/${t1.id}`, { depends_on: [] }); expect(status).toBe(409); }); it('归档:正在跑的 task 拦下来并指出该先停哪条 run', async () => { const [status, body] = await call('DELETE', `/goals/${goal.id}`); expect(status).toBe(409); expect(body.runningTasks).toEqual([t1.id]); expect(body.hint).toContain('run_rt'); }); it('归档只是隐藏:默认列表不出现,includeArchived=1 查得回来', async () => { const [status] = await call('DELETE', `/tasks/${t2.id}`); expect(status).toBe(200); const [, plain] = await call('GET', '/tasks'); expect(plain.tasks.map((t) => t.taskId)).toEqual([t1.id]); const [, all] = await call('GET', '/tasks?includeArchived=1'); expect(all.tasks).toHaveLength(2); }); it('OKR 的 /goals 别名已退役:这条路径下只剩 goal 两级模型(§六.1)', async () => { // 别名还在的话,GET /goals 会被 OkrService 接走、返回 objective 数组而不是 { goals: [...] } const [status, body] = await call('GET', '/goals'); expect(status).toBe(200); expect(Array.isArray(body.goals)).toBe(true); expect(body.goals[0].goalId).toBe(goal.id); }); // ────────────────────────────────────────────────────────────── // 复核缺陷 F3 / F4 / F5 / F7 / F9 // ────────────────────────────────────────────────────────────── it('打错公司 id 的两个新 GET 端点回 404,不拿空列表冒充「没有目标」(缺陷 F9)', async () => { for (const p of ['/goals', '/tasks']) { const [status, body] = await callCompany('route-test-c0', 'GET', p); expect(status).toBe(404); expect(body.error).toMatch(/不存在/); } }); it('建 goal 时 tasks[].depends_on 可按 seq 引用同批兄弟,响应回真 id(缺陷 F5)', async () => { const [status, body] = await call('POST', '/goals', { description: '上线一个服务', tasks: [ { description: '构建镜像' }, { description: '部署到测试环境', depends_on: [1] }, // 1 = 本次第 1 条的 seq ], }); expect(status).toBe(201); const [build, deploy] = body.tasks; expect(deploy.seq).toBe(2); // 翻译成真 id 才算数:回 [1] 或 [] 都会让批 3 的调度器把这条依赖当不存在 expect(deploy.dependsOn).toEqual([build.taskId]); expect(JSON.parse(wdb.getTask(deploy.taskId).depends_on)).toEqual([build.taskId]); // 环仍然要被数据层挡住(seq 引用不是绕过校验的后门) const [cyclic, err] = await call('POST', '/goals', { description: '互相依赖', tasks: [ { description: 'A', depends_on: [2] }, { description: 'B', depends_on: [1] }, ], }); expect(cyclic).toBe(400); expect(err.error).toMatch(/环/); }); it('tasks[k] 非法 → 400,且 goals / tasks 两张表都不留残行(缺陷 F4)', async () => { const before = wdb.listCompanyGoals(CID, { includeArchived: true }).length; // task 也要数:只数 goal 的话,「goal 回滚了但第 0 条 task 已提交」这种半回滚是绿的 const beforeTasks = wdb.listCompanyTasks(CID, { includeArchived: true }).length; const [status, body] = await call('POST', '/goals', { description: '两条活,第二条写错了', tasks: [{ description: '合法的第一条' }, { description: '第二条', mode: 'AUTO' }], }); expect(status).toBe(400); expect(body.error).toMatch(/tasks\[1\]\.mode/); // 修之前:goal 已提交、第 0 条 task 已 INSERT,客户端改完重发就是第二个 goal, // 第一个孤儿 goal 带着孤儿 task 永远躺在列表里 expect(wdb.listCompanyGoals(CID, { includeArchived: true }).length).toBe(before); expect(wdb.listCompanyTasks(CID, { includeArchived: true }).length).toBe(beforeTasks); }); it('建 goal 时申报的 budget 落库到 task 行(缺陷 F3 的写侧)', async () => { const budget = { total_agent_calls: 7, total_tokens: 123456 }; const [status, body] = await call('POST', '/goals', { description: '带预算的需求', budget, split: false }); expect(status).toBe(201); // 只有写侧没有读侧等于没写;读侧(dispatch 起 run 时用它)在 goal-task-acceptance.test.js 里验 expect(JSON.parse(wdb.getTask(body.tasks[0].taskId).budget_json)).toEqual(budget); // 也要回得出来:查不到自己填的额度,用户就只能等任务 blocked 才发现不对 expect(body.tasks[0].budget).toEqual(budget); }); it('PATCH 的 mode 走同一道信任边界:非法值 400 而不是 500 泄 SQLite 原文(缺陷 F7)', async () => { const [, created] = await call('POST', '/goals', { description: '改 mode 用的 goal', split: false }); const taskId = created.tasks[0].taskId; const [bad, err] = await call('PATCH', `/tasks/${taskId}`, { mode: 'AUTO' }); expect(bad).toBe(400); // 修之前:直送 DB 撞 CHECK 约束 → 500 + "CHECK constraint failed: …" expect(err.error).toMatch(/manual/); // null 既不是「没传」也不是合法值:修之前被 `!== undefined` 过滤掉 → 200 但什么都没改 const [nulled] = await call('PATCH', `/tasks/${taskId}`, { mode: null }); expect(nulled).toBe(400); const [ok, patched] = await call('PATCH', `/tasks/${taskId}`, { mode: 'auto' }); expect(ok).toBe(200); expect(patched.mode).toBe('auto'); }); it('goal 级 mode 是每条 task 的兜底,task 自己写的优先(三级继承,本轮复核缺陷 2)', async () => { const [status, body] = await call('POST', '/goals', { description: '整批都要自己跑的需求', mode: 'auto', split: false, tasks: [{ description: '继承 goal 的 auto' }, { description: '自己压回手动', mode: 'manual' }], }); expect(status).toBe(201); // 继承这一层是批 2 加的(此前显式 tasks[] 恒 manual),是**行为变化**,所以必须有用例钉住: // 没有它,谁把这行改回 `'manual'` 都照样全绿。保留继承的理由是反过来更荒谬 —— // 用户在表单上把整个 goal 选成 auto,建出来的 task 却一条都不会自己跑。 expect(body.tasks.map((t) => t.mode)).toEqual(['auto', 'manual']); // 三级都没给才落到最终兜底 manual(别让「默认自动跑」这种事悄悄发生) const [, plain] = await call('POST', '/goals', { description: '没声明 mode 的需求', split: false, tasks: [{ description: '一件事' }], }); expect(plain.tasks[0].mode).toBe('manual'); }); // ═══════════════════════════════════════ // 批 2 · 自动拆分(27-P3C §四.9) // ═══════════════════════════════════════ // // 拆分本身是一次真 LLM 调用,单测一律走 setPlanTasks 注入 —— 真起 Claude CLI 既慢又要钱, // 而且产出不确定,测出来的绿是运气。这里验的是**链路**:状态怎么推、失败怎么降级、账怎么记。 const tp = () => require('../../services/task-planning'); /** 轮询到 goal 离开 planning 为止(拆分是后台 setImmediate,响应先回) */ async function waitSplitDone(goalId, tries = 100) { 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('默认 split:true → 202 + planning,后台拆完落 N 条 draft task 并记 planning_tokens', async () => { const seen = []; tp().setPlanTasks(async (ctx) => { seen.push(ctx.goal.id); return { specs: [ { seq: 1, title: '写后端', description: '实现后端接口', depends_on: [] }, { seq: 2, title: '写教程', description: '基于后端接口写上手教程', depends_on: [1] }, ], tokens: 4242, }; }); try { const [status, body] = await call('POST', '/goals', { description: '做个工具,含后端和教程' }); // 202 而不是 201:这次调用**没有**创造出完整的资源,task 还在路上 expect(status).toBe(202); expect(body.split).toBe(true); expect(body.status).toBe('planning'); expect(body.tasks).toEqual([]); // 空数组而不是缺字段:调用方才分得清「还没拆」和「拆出 0 条」 const g = await waitSplitDone(body.goalId); expect(seen).toEqual([body.goalId]); expect(g.status).toBe('draft'); // 产出一律 draft,不自动入队(§四.9) expect(g.planning_tokens).toBe(4242); // 不记这一笔,goal 层的花费就是隐形的 expect(g.planning_error).toBeNull(); const tasks = wdb.listGoalTasks(body.goalId); expect(tasks.map((t) => t.status)).toEqual(['draft', 'draft']); expect(tasks.map((t) => t.created_by)).toEqual(['task-planning', 'task-planning']); // seq 引用必须翻成真 id:回 [1] 或 [] 都会让批 3 的调度器把这条依赖当不存在 expect(JSON.parse(tasks[1].depends_on)).toEqual([tasks[0].id]); } finally { tp().setPlanTasks(null); } }); it('三轮拆不出来 → 降级成 1 条同描述 task + planning_error,绝不卡在 planning(验收 20)', async () => { tp().setPlanTasks(async () => { const e = new Error('task-planning 连续 3 轮未产出合法任务 DAG'); e.tokens = 999; // 失败的那几轮是真花了钱的 throw e; }); try { const [status, body] = await call('POST', '/goals', { description: '一个拆不动的需求' }); expect(status).toBe(202); const g = await waitSplitDone(body.goalId); expect(g.status).toBe('draft'); expect(g.planning_error).toMatch(/3 轮/); expect(g.planning_tokens).toBe(999); // 抛出路径上的 token 一样要记账 const tasks = wdb.listGoalTasks(body.goalId); expect(tasks).toHaveLength(1); expect(tasks[0].description).toBe('一个拆不动的需求'); // 同描述 expect(tasks[0].seq).toBe(1); expect(JSON.parse(tasks[0].depends_on)).toEqual([]); // 降级出来的这条不是它拆的,记成 task-planning 会让「拆分质量」统计从一开始就是脏的 expect(tasks[0].created_by).toBe('human'); // 界面要看得见:goalView 得把这个标记透出去,否则降级后的 goal 与「本来就一条」无从区分 const [, view] = await call('GET', `/goals/${body.goalId}`); expect(view.planningError).toMatch(/3 轮/); } finally { tp().setPlanTasks(null); } }); it('split:false 与显式 tasks 都不调 agent —— 存量调用方不该突然多花一次 LLM 调用', async () => { let called = 0; tp().setPlanTasks(async () => { called += 1; throw new Error('不该被调到'); }); try { const [a] = await call('POST', '/goals', { description: '同步建的 goal', split: false }); expect(a).toBe(201); const [b] = await call('POST', '/goals', { description: '自带拆分结果', tasks: [{ description: '第一件' }, { description: '第二件', depends_on: [1] }], }); expect(b).toBe(201); // 传了 tasks = 拆分结果已经在请求体里,再调一次纯浪费 expect(called).toBe(0); } finally { tp().setPlanTasks(null); } }); it('写入前校验:rule 取值域与本体 agent.md 那张「被打回来怎么办」的表一致', () => { const { validatePlannedTasks, extractPlannedTasks } = tp(); const ok = validatePlannedTasks([{ seq: 1, title: 'a', description: 'A', depends_on: [] }]); expect(ok.ok).toBe(true); // 43 号文档:acceptance_mode 没写就缺省 'holistic' expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [], acceptance_mode: 'holistic' }); const rules = (raw) => validatePlannedTasks(raw).errors.map((e) => e.rule); // seq 与数组顺序错位:错位后 depends_on 引的是另一条 task,而那条错误依赖完全合法, // 落库后没有任何人再能发现它 —— 所以必须在这里挡住 expect(rules([{ seq: 2, description: 'A', depends_on: [] }])).toContain('schema'); expect(rules([{ seq: 1, description: 'A', depends_on: ['T-abc'] }])).toContain('reference'); expect(rules([{ seq: 1, description: 'A', depends_on: [1] }])).toContain('cycle'); expect(rules([ { seq: 1, description: 'A', depends_on: [2] }, { seq: 2, description: 'B', depends_on: [1] }, ])).toContain('cycle'); expect(rules(Array.from({ length: 9 }, (_, i) => ({ seq: i + 1, description: 'x', depends_on: [] })))) .toContain('limit'); expect(rules([{ seq: 1, description: 'A', depends_on: [], mode: 'auto' }])).toContain('schema'); // 43 号文档:acceptance_mode 是允许出现的第五个键,itemized 合法、乱写值按 schema 打回 expect(validatePlannedTasks([{ seq: 1, description: 'A', depends_on: [], acceptance_mode: 'itemized' }]).ok).toBe(true); expect(rules([{ seq: 1, description: 'A', depends_on: [], acceptance_mode: 'bogus' }])).toContain('schema'); // 正向引用(第 1 条依赖第 2 条)不成环,是合法输入 —— 批 1 缺陷 4 就是被它咬的 expect(validatePlannedTasks([ { seq: 1, description: 'A', depends_on: [2] }, { seq: 2, description: 'B', depends_on: [] }, ]).ok).toBe(true); // 取**最后一个** JSON 块:正文里举的反例常常也是合法 JSON,从前往后会认错那一份 const out = '反例:```json\n[{"seq":1,"description":"错的"}]\n```\n最终产出:\n```json\n[{"seq":1,"title":"t","description":"对的","depends_on":[]}]\n```'; expect(extractPlannedTasks(out)[0].description).toBe('对的'); expect(extractPlannedTasks('什么都没有')).toBeNull(); }); // ═══════════════════════════════════════ // 批 2 · 默认实现本身(不走 setPlanTasks 注入) // ═══════════════════════════════════════ // // 上面几条全用注入桩验「链路」,代价是**默认实现整段被跳过**:轮次上限、errors 回喂、 // token 累加这三件事只活在 defaultPlanTasks 里,注入桩绿了它们照样可以是坏的。 // 所以这几条只把最底下那次 `AgentManager.callAgent` 换掉,中间的循环真跑。 /** * 换掉 `AgentManager.callAgent`(绝不真起 Claude CLI),顺手挡住 `CompanyService.hire`: * `resolvePlanningAgentName` 查不到雇佣记录就会按需雇一次,而 hire 会往用户仓库 * `data/companies/` 里落目录 —— 单测不该在别人的工作区里留东西。 * * @param {(string|Error)[]} outputs 逐轮的假回复;轮数超出时一直用最后一条 * @returns {{prompts: string[], metas: object[], restore: () => void}} */ function stubAgent(outputs, usage = { input: 100, output: 20 }) { const AgentManager = require('../../src/core/agent-manager'); const { CompanyService } = require('../../services/company-service'); const origCall = AgentManager.prototype.callAgent; const origHire = CompanyService.prototype.hire; const prompts = []; const metas = []; AgentManager.prototype.callAgent = async function stubCallAgent(agentName, prompt, options) { prompts.push(prompt); metas.push({ agentName, metadata: (options && options.metadata) || null }); // usage 只有这一条来源(`session/result`),planning_tokens 的账全靠它 if (options && options.onAcpEvent) options.onAcpEvent({ method: 'session/result', params: { usage } }); const out = outputs[Math.min(prompts.length - 1, outputs.length - 1)]; if (out instanceof Error) throw out; return { output: out }; }; CompanyService.prototype.hire = () => ({ status: 'stubbed-by-test' }); return { prompts, metas, restore() { AgentManager.prototype.callAgent = origCall; CompanyService.prototype.hire = origHire; }, }; } it('默认实现:连续 3 轮非法产出 → 轮次用满、errors 原样回喂、已烧 token 挂在 error 上(验收 20 / 21)', async () => { const stub = stubAgent(['我觉得可以分三步做,但我不打算写 JSON']); try { const goal = wdb.createGoal({ companyId: CID, title: '拆不动', description: '一个拆不动的需求' }); let err = null; try { await tp().planTasks({ companyId: CID, goal }); } catch (e) { err = e; } expect(err).toBeTruthy(); expect(err.message).toMatch(/连续 3 轮/); expect(stub.prompts).toHaveLength(3); // 不是 1 轮就放弃,也不是无限重试到把钱烧光 expect(err.planErrors.map((x) => x.rule)).toEqual(['output']); // 抛出路径上的 token 一样是真花掉的。不挂在 error 上,这半边花费在账上直接蒸发 expect(err.tokens).toBe(3 * 120); // 第 1 轮不带回喂段;第 2 轮起必须把上一轮的 errors 原样贴回去 —— 不贴的话 // agent 不知道错在哪,三轮就是同一个错误重复三次,等于只试了一轮还花了三份钱 expect(stub.prompts[0]).not.toContain('上一轮没通过'); expect(stub.prompts[1]).toContain('上一轮没通过'); expect(stub.prompts[1]).toContain('"rule": "output"'); // rule 名要能对上 agent.md 那张表 expect(stub.prompts[2]).toContain('第 3 轮'); // agent.md Step 1 点名的三项输入,一项都不能少(缺哪项它就自己编哪项) for (const h of ['① goal 描述', '② 公司 org graph 摘要', '③ 可用角色清单']) { expect(stub.prompts[0]).toContain(h); } // 写入边界段头是 PromptBuilder 判分叉的**精确字面量**:出现在任务描述里会让坐标声明 // 不再给交付物归宿,而本体正文里的 $W 正是从那一行读的 —— 两边同时落空且不报错 expect(stub.prompts[0]).not.toContain('[写入边界'); // taskId 是运行态目录的第三层键。不传的话所有公司所有拆分叠进 _global/_no-task 一个坑 expect(stub.metas[0].metadata.taskId).toBe(goal.id); expect(stub.metas[0].agentName).toBe('task-planning'); // 没雇过就用裸名,不该拼出脏地址 } finally { stub.restore(); } }); it('默认实现拆不出来时,POST /goals 端到端照样把 goal 推离 planning 并记账(验收 20)', async () => { const stub = stubAgent(['没有 JSON,只有废话']); try { const [status, body] = await call('POST', '/goals', { description: '端到端拆不动的需求' }); expect(status).toBe(202); const g = await waitSplitDone(body.goalId); expect(g.status).toBe('draft'); // 绝不卡在 planning —— 那是个没人会把它推走的死态 expect(g.planning_error).toMatch(/连续 3 轮/); expect(g.planning_tokens).toBe(3 * 120); const tasks = wdb.listGoalTasks(body.goalId); expect(tasks).toHaveLength(1); expect(tasks[0].description).toBe('端到端拆不动的需求'); expect(tasks[0].created_by).toBe('human'); // 这条不是它拆的 } finally { stub.restore(); } }); it('默认实现:第 1 轮 seq 错位被打回、第 2 轮改对 → 只调 2 次,被打回那轮的 token 照样记账', async () => { // 这条守的是「回喂到底有没有用」:只验失败路径的话,一个永远返回同一份 errors 的实现也是绿的。 const bad = '```json\n[{"seq":2,"title":"a","description":"A","depends_on":[]}]\n```'; const good = '最终产出:\n```json\n[{"seq":1,"title":"a","description":"A","depends_on":[]},' + '{"seq":2,"title":"b","description":"B","depends_on":[1]}]\n```'; const stub = stubAgent([bad, good]); try { const [status, body] = await call('POST', '/goals', { description: '第二轮才交对的需求' }); expect(status).toBe(202); const g = await waitSplitDone(body.goalId); expect(stub.prompts).toHaveLength(2); // 过了就停,不会把三轮跑满 expect(stub.prompts[1]).toContain('"rule": "schema"'); // seq 与数组下标错位 expect(g.planning_error).toBeNull(); expect(g.planning_tokens).toBe(2 * 120); // 被打回的那一轮一样花了钱 const tasks = wdb.listGoalTasks(body.goalId); expect(tasks.map((t) => t.created_by)).toEqual(['task-planning', 'task-planning']); expect(JSON.parse(tasks[1].depends_on)).toEqual([tasks[0].id]); // seq 引用翻成了真 id } finally { stub.restore(); } }); // ═══════════════════════════════════════ // 批 2 · 对抗复核缺陷 1 / 4 / 5 / 6:拆分那几十秒里 goal 被别人动了怎么办 // ═══════════════════════════════════════ // // 这四条的共同现场是同一个:`planning` 是个**真会持续几十秒**的状态,而这期间 goal // 完全可能被别的请求推走(表单自己推荐的逃生口、左栏的归档)。此前拆分收工是一条 // **无条件** UPDATE,谁最后写谁说了算 —— 而最后写的恰好是那个已经过时了几十秒的后台任务。 /** 一个能由用例决定何时放行的假拆分:拿到 gate 才返回,好在拆分「进行中」时做手脚 */ function gatedPlan(result) { let release; const gate = new Promise((r) => { release = r; }); tp().setPlanTasks(async () => { await gate; if (result instanceof Error) throw result; return result; }); return release; } /** 轮询到 planning_tokens 记上为止(放弃落库那条路不改 status,waitSplitDone 等不到) */ async function waitTokens(goalId, tokens, tries = 100) { for (let i = 0; i < tries; i += 1) { const g = wdb.getGoal(goalId); if (g && g.planning_tokens === tokens) return g; await new Promise((r) => setTimeout(r, 10)); } throw new Error(`goal ${goalId} 的 planning_tokens 一直没记上(当前 ${wdb.getGoal(goalId).planning_tokens})`); } it('拆分期间走了逃生口 → 收工时放弃落库,不把正在跑的 goal 写回草稿(缺陷 1)', async () => { const release = gatedPlan({ specs: [ { seq: 1, title: '孤儿一', description: '不该被插进去的一', depends_on: [] }, { seq: 2, title: '孤儿二', description: '不该被插进去的二', depends_on: [1] }, ], tokens: 777, }); try { const [status, body] = await call('POST', '/goals', { description: '一个会被抢走的需求' }); expect(status).toBe(202); const gid = body.goalId; // 逃生口 `POST /:cid/tasks {goal_id}` 的两步(这里不真起 run:那要真 org graph, // 见文件头。抢走 goal 的是 markGoalDispatched 这一句,run 起没起不改变判据) const escaped = wdb.createTask({ goalId: gid, title: '单任务', description: '一个会被抢走的需求' }); wdb.updateGoal(gid, { status: 'active', dispatchedAt: new Date().toISOString() }); release(); const g = await waitTokens(gid, 777); // 写回 draft = 一个正在烧钱跑 run 的 goal 在界面上变成「草稿」, // 而多插的那 N 条 draft 让 settleGoalIfAllTasksDone 永远看不到「全部终态」→ 永不收敛 expect(g.status).toBe('active'); expect(g.dispatched_at).toBeTruthy(); expect(g.planning_error).toBeNull(); // 拆分没失败,别在界面上说它失败了 expect(wdb.listGoalTasks(gid).map((t) => t.id)).toEqual([escaped.id]); // 一条孤儿都没留 } finally { tp().setPlanTasks(null); } }); it('降级用的是收工那一刻的描述,不是拆分开始时的旧快照(本轮复核缺陷 1)', async () => { const err = new Error('task-planning 连续 3 轮未产出合法任务 DAG'); err.tokens = 21; const release = gatedPlan(err); try { const [status, body] = await call('POST', '/goals', { description: '旧的需求原文' }); expect(status).toBe(202); const gid = body.goalId; // 拆分期间改 description 是**被允许的**:goal 还在 planning、dispatched_at 还空, // 冻结规则(§四.4)此刻还没生效,所以这是一条正常用户路径,不是竞态滥用 const [pt, patched] = await call('PATCH', `/goals/${gid}`, { description: '改过的需求原文' }); expect(pt).toBe(200); expect(patched.description).toBe('改过的需求原文'); release(); const g = await waitSplitDone(gid); expect(g.description).toBe('改过的需求原文'); // 用函数顶部那份旧快照的话:goal 上写着新需求、它唯一的那条 task 写着旧需求, // 而两段文字都是验收边界(org-validator 判的就是这段描述)—— 一份目标两个口径 expect(wdb.listGoalTasks(gid).map((t) => t.description)).toEqual(['改过的需求原文']); } finally { tp().setPlanTasks(null); } }); it('拆分产出的 seq 以 goal 现有最大号为基准偏移,依赖引用跟着偏移(缺陷 4)', async () => { const release = gatedPlan({ specs: [ { seq: 1, title: '甲', description: '先做甲', depends_on: [] }, { seq: 2, title: '乙', description: '基于甲做乙', depends_on: [1] }, ], tokens: 5, }); try { const [, body] = await call('POST', '/goals', { description: '会撞号的需求' }); const gid = body.goalId; // 逃生口撞上唯一索引那条路:task 建出来当草稿,但 409 在 markGoalDispatched 之前就返回了 —— // goal **仍停在 planning**,所以拆分收工时条件更新会命中,产出照插。seq 就是在这里撞的。 const first = wdb.createTask({ goalId: gid, title: '先手', description: '先手任务' }); expect(first.seq).toBe(1); release(); const g = await waitSplitDone(gid); expect(g.status).toBe('draft'); const tasks = wdb.listGoalTasks(gid); // 1,1,2 的话:列表里两行都显示 #1,依赖 chip 的「依赖 #1」有两个同名候选, // dispatch 挑「第一条跑得动的 draft」顺序不定 —— 不报错,只是从此对不上号 expect(tasks.map((t) => t.seq)).toEqual([1, 2, 3]); const jia = tasks.find((t) => t.seq === 2); const yi = tasks.find((t) => t.seq === 3); expect(yi.title).toBe('乙'); // 翻译表用的必须是偏移后的号:用原号会翻不出来,depends_on 里留一个字面量 "1" expect(JSON.parse(yi.depends_on)).toEqual([jia.id]); expect(JSON.parse(tasks[0].depends_on)).toEqual([]); } finally { tp().setPlanTasks(null); } }); it('拆分期间不许归档:DELETE 直接 409,硬归档时收工也不插孤儿(缺陷 5)', async () => { const release = gatedPlan({ specs: [{ seq: 1, title: '孤儿', description: '归档后不该被插进去', depends_on: [] }], tokens: 42, }); try { const [, body] = await call('POST', '/goals', { description: '会被归档的需求' }); const gid = body.goalId; // 此刻 task 列表是空的,「有 task 攥着 run 就 409」那道闸门必过 —— 所以要单独挡 const [st, err] = await call('DELETE', `/goals/${gid}`); expect(st).toBe(409); expect(err.error).toMatch(/正在自动拆分/); expect(err.hint).toMatch(/逃生口/); // 端点挡住了,但库里那一行仍可能被别的写入方盖上 archived_at(归档不改 status)。 // 条件更新的 `archived_at IS NULL` 是第二道:插进去的 task 会归属一个左栏看不见的 goal。 wdb.updateGoal(gid, { archivedAt: new Date().toISOString() }); release(); await waitTokens(gid, 42); // 账照记:那几轮 LLM 是真花了钱的 expect(wdb.listGoalTasks(gid, { includeArchived: true })).toEqual([]); } finally { tp().setPlanTasks(null); } }); it('拆分超过总时限 → 按降级路径收工,绝不卡在 planning(缺陷 6 / 验收 20)', async () => { const orig = process.env.GOAL_SPLIT_TIMEOUT_MS; process.env.GOAL_SPLIT_TIMEOUT_MS = '30'; // 永不 resolve = 真实世界里那条「callAgent 的 idle timeout 30 分钟 × 3 轮」的路: // 只要还有流数据进来,idle timer 永远不触发,服务端此前对此没有任何时限 tp().setPlanTasks(() => new Promise(() => {})); try { const [status, body] = await call('POST', '/goals', { description: '一个拆到天荒地老的需求' }); expect(status).toBe(202); const g = await waitSplitDone(body.goalId); expect(g.status).toBe('draft'); expect(g.planning_error).toMatch(/总时限/); const tasks = wdb.listGoalTasks(body.goalId); expect(tasks.map((t) => t.description)).toEqual(['一个拆到天荒地老的需求']); // 降级出来的这条不是 task-planning 拆的,记成它的产出会让「拆分质量」统计从一开始就是脏的 expect(tasks[0].created_by).toBe('human'); } finally { if (orig === undefined) delete process.env.GOAL_SPLIT_TIMEOUT_MS; else process.env.GOAL_SPLIT_TIMEOUT_MS = orig; tp().setPlanTasks(null); } }); it('task-planning 本体过得了毕业闸门,不会停在 draft(§七 / 验收 24)', () => { const { missingBodyDefinition } = require('../../services/agent-locator'); const { isUngraduatedStatus, isNonOrgAgentType } = require('../../services/graph-service'); const bodyRoot = path.resolve(__dirname, '../../../system/agents'); const cfg = JSON.parse(fs.readFileSync(path.join(bodyRoot, 'task-planning/config.json'), 'utf-8')); // 「缺」有三种形态:读不到 / 空白 / 仍带脚手架哨兵。少一份 soul.md 本体就永远停在 draft, // 而 hire() 报的理由是 body_not_ready —— 症状离原因很远,只能靠这里挡 expect(missingBodyDefinition('task-planning')).toEqual([]); // status 不是封闭取值域,判据的唯一持有者是 isUngraduatedStatus //(自己写 `!== 'active'` 会把库里 36 个合法本体全判成没毕业,且报的理由还是错的) expect(isUngraduatedStatus(cfg.status)).toBe(false); // meta-planning 命中 NON_ORG_AGENT_TYPES = 永不作为 org 节点混进用户的组织图 expect(isNonOrgAgentType(cfg.type)).toBe(true); // seedAgents 扫的是目录(不以 _/. 开头 + 有 agent.md),registry 只做 enrich; // 但 §七 要求登记,缺了它 benchmark_score 一列永远是 null 且没人知道为什么 const registry = JSON.parse(fs.readFileSync(path.join(bodyRoot, '_registry.json'), 'utf-8')); expect(registry.some((e) => e && e.id === 'task-planning')).toBe(true); }); // ────────────────────────────────────────────────────────────── // 批 3 对抗复核 F1:blocked 的任务必须有走得通的出路 // // 这四条盯的是同一件事的四个面:改完依赖有没有**当场**重算、放行是不是真的放得掉、 // 走投无路时还剩什么、以及 409 里那句 hint 指的地方存不存在。 // 它们全部是「接口回 200 / 回了 hint,但什么都没发生 / 那条路根本不通」的静默形态。 // ────────────────────────────────────────────────────────────── describe('强制放行与保底出路(复核 F1)', () => { /** 一条已经 failed 的上游 + 一条排队中的下游;下游的依赖留给各条用例自己接 */ function pair(tag) { const dead = wdb.createTask({ goalId: goal.id, title: `死上游-${tag}`, description: '已经失败的上游' }); wdb.updateTask(dead.id, { status: 'failed' }); const down = wdb.createTask({ goalId: goal.id, title: `下游-${tag}`, description: '等上游' }); wdb.updateTask(down.id, { status: 'queued', queuedAt: new Date().toISOString() }); return { dead, down }; } it('PATCH depends_on 当场重算队列:新依赖已经死了 → 响应里就是 blocked,不是过期的 queued', async () => { const { dead, down } = pair('a'); const [status, body] = await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] }); expect(status).toBe(200); // 少了 PATCH 里那次 tick,这里回的是 'queued':库里要等 60s 兜底扫描才变 blocked, // 而界面拿的就是这个响应 —— 用户看到的是「改了没反应」 expect(body.status).toBe('blocked'); expect(body.blockedReason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${dead.id}`); }); it('强制放行:删掉那条死依赖 → 200,且当场回到队列(reason 一起清掉)', async () => { const { dead, down } = pair('b'); await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] }); // 先卡住 const [status, body] = await call('PATCH', `/tasks/${down.id}`, { depends_on: [] }); expect(status).toBe(200); // 这一条就是前端「强制放行」按钮打的那一枪。它此前必然 409(数据层闸门只认 draft/queued), // 按钮却一定是亮的 —— 「不给能点却必然 4xx 的入口」当时只是一句注释 expect(body.status).toBe('queued'); // blocked_reason 由 requeueDerivedBlockedTask 和 status 在**同一条**条件更新里清掉。 // 谁抢在 tick 前面单独把它清成 NULL,那条 UPDATE 就再也命中不了,任务永远卡在 blocked expect(body.blockedReason).toBeFalsy(); expect(wdb.getTask(down.id).blocked_reason).toBeFalsy(); }); it('blocked 的 409 hint 不许指向不存在的行为(旧版是 POST /api/graph-runs/null/stop)', async () => { const { dead, down } = pair('c'); await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] }); const [status, body] = await call('POST', `/tasks/${down.id}/rerun`); expect(status).toBe(409); // 重跑仍要挡:taskHoldsRun 把 blocked 一律算成攥着 run expect(body.hint).not.toMatch(/graph-runs\/(null|undefined)\//); // 给得出的下一步必须真的能走:这条 task 根本没有 run,能动它的只有 cancel expect(body.hint).toContain(`/tasks/${down.id}/cancel`); }); it('保底出路:上游已归档/删号时,取消一定走得通(不再是两条 409 互相指着对方)', async () => { const { dead, down } = pair('d'); await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] }); expect(wdb.getTask(down.id).status).toBe('blocked'); const [status, body] = await call('POST', `/tasks/${down.id}/cancel`); expect(status).toBe(200); expect(body.status).toBe('cancelled'); // 已经取消了还挂着「上游未完成」,列表上会一直显示一个假原因 expect(wdb.getTask(down.id).blocked_reason).toBeFalsy(); }); }); });