diff --git a/docs/todo/system-design/42-org-work-graph-reliability-followups.md b/docs/todo/system-design/42-org-work-graph-reliability-followups.md index 42fe969..77bf804 100644 --- a/docs/todo/system-design/42-org-work-graph-reliability-followups.md +++ b/docs/todo/system-design/42-org-work-graph-reliability-followups.md @@ -1,6 +1,6 @@ # 42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办 -> **状态**:Phase 1 ✅ 已实现(2026-08-30);Phase 2-4 📝 待开工。 +> **状态**:Phase 1-2 ✅ 已实现(2026-08-30);Phase 3-4 📝 待开工。 > **日期**:2026-08-30 > **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 的 P1-P5 落地后,用它做了一次真实端到端测试(建公司 → 下 goal → `ceo` 监督 → 真实跑出一个可玩的贪吃蛇游戏,Playwright 5/5 验证通过)。**1469 个单测全绿,这次真跑却测出至少 3 个单测完全没覆盖到的真问题**——这份文档是那次复盘沉淀下来的待办,按开工顺序分四个 phase。 > **读者须知**:每个 phase 的现状描述都来自那次真实测试里实际撞到的场景,不是推测;具体文件/行号落地前按惯例重新核实。 @@ -51,7 +51,16 @@ Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改å --- -## 三、Phase 2:§四.4 闸门穿透性回归测试 +## 三、Phase 2:§四.4 闸门穿透性回归测试 —— ✅ 已实现(2026-08-30) + +**落地结果**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,3 例全部走 `ceo` 实际会用的通路(`POST /tasks` 的 `draft:true` + `created_by:'ceo'` → `/enqueue`,不抄近道直接摆数据库行): +1. `max_parallel_tasks=1` 时坑已占满,`ceo` 建的第二条任务落 `queued`,不插队。 +2. 预算给 `total_agent_calls:0`,`engine.checkBudget()` 照样判耗尽,`created_by='ceo'` 不改变结果。 +3. `ceo` 建的两条 task 挂在同一个 goal 下,全部 `completed` 后 `goal-validation` 的 `validateGoal` 钩子只被调一次(用 `validation.setValidateGoal()` 注入假验收,照抄 `goal-task-acceptance.test.js` 的既有手法)。 + +全量测试 1486/1490,零回归(剩余 4 个是核实过的 pre-existing 失败)。 + +**过程小插曲**:驱动第二条 task 的 run 出队时,先试了直接调 `task-scheduler.tick()`——撞上一个跟这次改动无关的既有工具链毛刺(`require.cache` 手工失效对 vitest 的 CJS 模块图不完全生效,`task-scheduler.js` 惰性 `require` 的 `work-graph-db` 引用有时会绑定到清缓存之前的实例,触发 `graph-db.js` 的"测试进程别碰生产库"守卫)。绕开的办法是不依赖 `scheduleTick` 的 `setImmediate` 时序,坑空出来后直接调 `updateRunStatus(runB,'queued','ready')`——这条路径本来就是准入事务的真实实现,只是不经过调度器这层编排,测试意图("闸门拦不拦得住")不受影响。 **现状**:39 号文档 §四.4 已拍板"`ceo` 全权自主 `enqueue`,但 `max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门必须照样拦住它",落地时没有补这条测试(§六"已知遗留"记过一笔)。理论上这几道闸门挂在数据层/调度器层,不认"谁创建的",天然覆盖,但没有测试钉死就不能说"已确认"。 diff --git a/server/tests/unit/ceo-created-task-gates.test.js b/server/tests/unit/ceo-created-task-gates.test.js new file mode 100644 index 0000000..7d11b24 --- /dev/null +++ b/server/tests/unit/ceo-created-task-gates.test.js @@ -0,0 +1,228 @@ +/** + * Unit Tests — ceo 自主创建的 task 仍然受既有闸门约束(42 号文档 Phase 2,39 号文档 §四.4) + * + * 39 号文档 §四.4 拍板:"ceo 全权自主 enqueue,但 max_parallel_tasks / DEFAULT_TASK_BUDGET / + * goal 级 org-validator 这几道闸门必须照样拦住它"——这几道闸门挂在数据层/调度器层,理论上 + * 不认"谁创建的",天然覆盖,但落地时没有补一条测试钉死这一点(§六"已知遗留"记过一笔)。 + * 这份文件补上,走 ceo 实际会用的通路(POST /tasks draft:true + created_by:'ceo' → /enqueue), + * 不抄近道直接摆数据库行。 + * + * 隔离手法与 task-entry-draft-mode.test.js / goal-task-acceptance.test.js 一致。 + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const TEST_DB_PATH = '/tmp/test-ceo-created-task-gates.sqlite'; +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies'); +const MODULES = [ + '../../services/graph-db', + '../../services/work-graph-db', + '../../services/org-graph-service', + '../../services/graph-run-sse', + '../../services/graph-engine', + '../../services/task-settle', + '../../services/goal-validation', + '../../routes/company', +]; + +let wdb; +let orgSvc; +let engine; +let server; +let base; +const createdCompanies = []; + +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()]; +} + +function ensureAgents(ids) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); +} + +/** 真实写一份 company.json(不是 mock)——maxActiveRootRuns() 直接读盘,绕不过去。 */ +function makeCompany(companyId, extra = {}) { + const dir = path.join(COMPANIES_DIR, companyId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...extra })); + createdCompanies.push(companyId); + + ensureAgents(['org-planner', 'org-coder', 'org-validator']); + expect( + orgSvc.writeGraph(companyId, { + nodes: ['org-planner', 'org-coder', 'org-validator'], + edges: [ + { from: 'org-planner', to: 'org-coder', condition: 'success' }, + { from: 'org-coder', to: 'org-validator', condition: 'success' }, + ], + }).ok + ).toBe(true); +} + +describe('ceo 自主创建的 task 仍然受既有闸门约束(39 号文档 §四.4)', () => { + beforeAll(async () => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-task-gates-runs-')); + 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'); + + 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); }); + base = `http://127.0.0.1:${server.address().port}/api/company`; + }); + + afterAll(() => { + if (server) server.close(); + for (const cid of createdCompanies) { + fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true }); + } + 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)]; + }); + + // ────────────────────────────────────────────────────────────── + // 1. max_parallel_tasks:坑满了,ceo 建的任务一样排队,不能插队 + // ────────────────────────────────────────────────────────────── + it('max_parallel_tasks=1 时坑已被占,ceo 走 draft+enqueue 建的第二条任务落 queued,不会插队执行', async () => { + const cid = 'ceo-gate-slots'; + makeCompany(cid, { max_parallel_tasks: 1 }); + + // 人类下达第一条,占满唯一的执行位 + const [s1, taskA] = await call('POST', `/${cid}/tasks`, { task: '人类下达的任务,占坑' }); + expect(s1).toBe(201); + expect(wdb.getTask(taskA.taskId).status).toBe('running'); + + // ceo 走它自己的通路:draft 建 → enqueue + const [s2, taskB] = await call('POST', `/${cid}/tasks`, { + task: 'ceo 自主提的第二条任务', draft: true, created_by: 'ceo', + }); + expect(s2).toBe(201); + expect(taskB.status).toBe('draft'); + + const [s3, enqueued] = await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`); + expect(s3).toBe(201); + + // 坑满:这条任务必须落 queued,不能因为是 ceo 建的就获得一个额外的执行位 + const row = wdb.getTask(taskB.taskId); + expect(row.status).toBe('queued'); + expect(enqueued.status).toBe('queued'); + const run = wdb.getRun(row.current_run_id); + expect(run.run_status).toBe('queued'); + }); + + // ────────────────────────────────────────────────────────────── + // 2. DEFAULT_TASK_BUDGET:预算给得再小,ceo 建的任务也不会被特殊放宽 + // ────────────────────────────────────────────────────────────── + it('ceo 建的任务预算耗尽照样判 budget-exhausted,不会因为创建者是 agent 就获得额外预算', async () => { + const cid = 'ceo-gate-budget'; + makeCompany(cid); + + const tinyBudget = { total_agent_calls: 0, total_tokens: 0, wallclock_ms: 7_200_000 }; + const [s1, task] = await call('POST', `/${cid}/tasks`, { + task: 'ceo 自主提的任务,预算给得极小', + draft: true, + created_by: 'ceo', + budget: tinyBudget, + }); + expect(s1).toBe(201); + + const [s2] = await call('POST', `/${cid}/tasks/${task.taskId}/enqueue`); + expect(s2).toBe(201); + + const run = wdb.getRun(wdb.getTask(task.taskId).current_run_id); + expect(run.budget_json).toBeTruthy(); + const parsed = JSON.parse(run.budget_json); + expect(parsed.total_agent_calls).toBe(0); // 存进去的确实是那份小预算,没有被谁悄悄改大 + + // checkBudget 不认「谁建的」,只认预算数字本身 + const gate = engine.checkBudget(run); + expect(gate.ok).toBe(false); + expect(gate.exhausted).toContain('total_agent_calls'); + }); + + // ────────────────────────────────────────────────────────────── + // 3. goal 级 org-validator:ceo 建的多 task goal 全部 completed 后,收敛验收照样跑一次 + // ────────────────────────────────────────────────────────────── + it('ceo 建的两条 task 全 completed 后,goal 级 org-validator 照样被调一次(不因创建者是 agent 而跳过)', async () => { + const cid = 'ceo-gate-goal-validation'; + makeCompany(cid); + + const goal = wdb.createGoal({ companyId: cid, title: '两步都由 ceo 建的目标', description: '测试用' }); + + const [s1, taskA] = await call('POST', `/${cid}/tasks`, { + task: 'ceo 建的第一条子任务', draft: true, created_by: 'ceo', goal_id: goal.id, + }); + expect(s1).toBe(201); + await call('POST', `/${cid}/tasks/${taskA.taskId}/enqueue`); + + const [s2, taskB] = await call('POST', `/${cid}/tasks`, { + task: 'ceo 建的第二条子任务', draft: true, created_by: 'ceo', goal_id: goal.id, + }); + expect(s2).toBe(201); + await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`); + + expect(wdb.getTask(taskA.taskId).created_by).toBe('ceo'); + expect(wdb.getTask(taskB.taskId).created_by).toBe('ceo'); + + const validation = require('../../services/goal-validation'); + const seen = []; + validation.setValidateGoal(async (ctx) => { + seen.push(ctx); + return { pass: true, verdict: '两条子任务合起来确实达成了目标', tokens: 42 }; + }); + + try { + const sse = require('../../services/graph-run-sse'); + const runA = wdb.getTask(taskA.taskId).current_run_id; + let runB = wdb.getTask(taskB.taskId).current_run_id; + expect(wdb.getRun(runB).run_status).toBe('queued'); // 同公司同一时间只有一个执行位 + + // 先让 runA 收尾,空出执行位。调度器出队走的是 setImmediate(挂在 transitionRun 钩子上), + // 这条用例不测调度时序本身(那是 task-slots-parallel.test.js 的职责)——坑空之后直接 + // 用 updateRunStatus 把 runB 推 ready,走的还是真实的准入事务,只是不等 setImmediate。 + sse.transitionRun(runA, wdb.getRun(runA).run_status, 'completed', {}, { summary: '用例直推终态' }); + wdb.updateRunStatus(runB, 'queued', 'ready'); + expect(wdb.getRun(runB).run_status).toBe('ready'); // 坑已空出,推 ready 成功 + sse.transitionRun(runB, 'ready', 'running', {}, { summary: '用例直推终态' }); + sse.transitionRun(runB, 'running', 'completed', {}, { summary: '用例直推终态' }); + + const { isPendingVerdict } = require('../../services/task-settle'); + for (let i = 0; i < 50; i += 1) { + const v = wdb.getGoal(goal.id).verdict; + if (v && !isPendingVerdict(v)) break; + await new Promise((r) => setImmediate(r)); + } + + expect(seen).toHaveLength(1); // 一个 goal 只验一次,即便两条子任务都是 ceo 建的 + expect(seen[0].goal.id).toBe(goal.id); + expect(seen[0].tasks.map((t) => t.created_by).sort()).toEqual(['ceo', 'ceo']); + expect(wdb.getGoal(goal.id).status).toBe('done'); + } finally { + validation.setValidateGoal(null); + } + }); +});