Commit e1cfc73d04afbc4d199ae9f05a77984e7c45f599
Merge branch 'main' of gitlab-wuwei.hexpm.com:yibo/lemonboss
Showing
2 changed files
with
239 additions
and
2 deletions
docs/todo/system-design/42-org-work-graph-reliability-followups.md
| 1 | 1 | # 42. Org/Work Graph å¯é 性收尾 —— 39 å·æ–‡æ¡£çœŸè·‘å¤ç›˜çš„待办 |
| 2 | 2 | |
| 3 | -> **状æ€**:Phase 1 ✅ 已实现(2026-08-30);Phase 2-4 📠待开工。 | |
| 3 | +> **状æ€**:Phase 1-2 ✅ 已实现(2026-08-30);Phase 3-4 📠待开工。 | |
| 4 | 4 | > **日期**:2026-08-30 |
| 5 | 5 | > **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) çš„ P1-P5 è½åœ°åŽï¼Œç”¨å®ƒåšäº†ä¸€æ¬¡çœŸå®žç«¯åˆ°ç«¯æµ‹è¯•ï¼ˆå»ºå…¬å¸ â†’ 下 goal → `ceo` ç›‘ç£ â†’ 真实跑出一个å¯çŽ©çš„è´ªåƒè›‡æ¸¸æˆï¼ŒPlaywright 5/5 验è¯é€šè¿‡ï¼‰ã€‚**1469 ä¸ªå•æµ‹å…¨ç»¿ï¼Œè¿™æ¬¡çœŸè·‘å´æµ‹å‡ºè‡³å°‘ 3 ä¸ªå•æµ‹å®Œå…¨æ²¡è¦†ç›–到的真问题**——这份文档是那次å¤ç›˜æ²‰æ·€ä¸‹æ¥çš„待办,按开工顺åºåˆ†å››ä¸ª phase。 |
| 6 | 6 | > **读者须知**:æ¯ä¸ª phase 的现状æè¿°éƒ½æ¥è‡ªé‚£æ¬¡çœŸå®žæµ‹è¯•é‡Œå®žé™…æ’žåˆ°çš„åœºæ™¯ï¼Œä¸æ˜¯æŽ¨æµ‹ï¼›å…·ä½“文件/行å·è½åœ°å‰æŒ‰æƒ¯ä¾‹é‡æ–°æ ¸å®žã€‚ |
| ... | ... | @@ -51,7 +51,16 @@ Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改å |
| 51 | 51 | |
| 52 | 52 | --- |
| 53 | 53 | |
| 54 | -## 三ã€Phase 2:§四.4 é—¸é—¨ç©¿é€æ€§å›žå½’测试 | |
| 54 | +## 三ã€Phase 2:§四.4 é—¸é—¨ç©¿é€æ€§å›žå½’测试 —— ✅ 已实现(2026-08-30) | |
| 55 | + | |
| 56 | +**è½åœ°ç»“æžœ**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,3 例全部走 `ceo` 实际会用的通路(`POST /tasks` çš„ `draft:true` + `created_by:'ceo'` → `/enqueue`ï¼Œä¸æŠ„è¿‘é“直接摆数æ®åº“行): | |
| 57 | +1. `max_parallel_tasks=1` æ—¶å‘å·²å æ»¡ï¼Œ`ceo` 建的第二æ¡ä»»åŠ¡è½ `queued`ï¼Œä¸æ’队。 | |
| 58 | +2. 预算给 `total_agent_calls:0`,`engine.checkBudget()` ç…§æ ·åˆ¤è€—å°½ï¼Œ`created_by='ceo'` 䏿”¹å˜ç»“果。 | |
| 59 | +3. `ceo` å»ºçš„ä¸¤æ¡ task 挂在åŒä¸€ä¸ª goal 下,全部 `completed` åŽ `goal-validation` çš„ `validateGoal` é’©ååªè¢«è°ƒä¸€æ¬¡ï¼ˆç”¨ `validation.setValidateGoal()` 注入å‡éªŒæ”¶ï¼Œç…§æŠ„ `goal-task-acceptance.test.js` 的既有手法)。 | |
| 60 | + | |
| 61 | +免釿µ‹è¯• 1486/1490,零回归(剩余 4 ä¸ªæ˜¯æ ¸å®žè¿‡çš„ pre-existing 失败)。 | |
| 62 | + | |
| 63 | +**è¿‡ç¨‹å°æ’曲**ï¼šé©±åŠ¨ç¬¬äºŒæ¡ 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')`——这æ¡è·¯å¾„本æ¥å°±æ˜¯å‡†å…¥äº‹åŠ¡çš„çœŸå®žå®žçŽ°ï¼Œåªæ˜¯ä¸ç»è¿‡è°ƒåº¦å™¨è¿™å±‚编排,测试æ„图("é—¸é—¨æ‹¦ä¸æ‹¦å¾—ä½")ä¸å—å½±å“。 | |
| 55 | 64 | |
| 56 | 65 | **现状**:39 å·æ–‡æ¡£ §四.4 å·²æ‹æ¿"`ceo` å…¨æƒè‡ªä¸» `enqueue`,但 `max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` è¿™å‡ é“é—¸é—¨å¿…é¡»ç…§æ ·æ‹¦ä½å®ƒ",è½åœ°æ—¶æ²¡æœ‰è¡¥è¿™æ¡æµ‹è¯•(§å…"已知é—ç•™"记过一笔)。ç†è®ºä¸Šè¿™å‡ é“闸门挂在数æ®å±‚/调度器层,ä¸è®¤"è°åˆ›å»ºçš„",天然覆盖,但没有测试钉æ»å°±ä¸èƒ½è¯´"已确认"。 |
| 57 | 66 | ... | ... |
server/tests/unit/ceo-created-task-gates.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — ceo 自主创建的 task 仍然受既有闸门约束(42 号文档 Phase 2,39 号文档 §四.4) | |
| 3 | + * | |
| 4 | + * 39 号文档 §四.4 拍板:"ceo 全权自主 enqueue,但 max_parallel_tasks / DEFAULT_TASK_BUDGET / | |
| 5 | + * goal 级 org-validator 这几道闸门必须照样拦住它"——这几道闸门挂在数据层/调度器层,理论上 | |
| 6 | + * 不认"谁创建的",天然覆盖,但落地时没有补一条测试钉死这一点(§六"已知遗留"记过一笔)。 | |
| 7 | + * 这份文件补上,走 ceo 实际会用的通路(POST /tasks draft:true + created_by:'ceo' → /enqueue), | |
| 8 | + * 不抄近道直接摆数据库行。 | |
| 9 | + * | |
| 10 | + * 隔离手法与 task-entry-draft-mode.test.js / goal-task-acceptance.test.js 一致。 | |
| 11 | + */ | |
| 12 | +const fs = require('fs'); | |
| 13 | +const os = require('os'); | |
| 14 | +const path = require('path'); | |
| 15 | + | |
| 16 | +const TEST_DB_PATH = '/tmp/test-ceo-created-task-gates.sqlite'; | |
| 17 | +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; | |
| 18 | +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies'); | |
| 19 | +const MODULES = [ | |
| 20 | + '../../services/graph-db', | |
| 21 | + '../../services/work-graph-db', | |
| 22 | + '../../services/org-graph-service', | |
| 23 | + '../../services/graph-run-sse', | |
| 24 | + '../../services/graph-engine', | |
| 25 | + '../../services/task-settle', | |
| 26 | + '../../services/goal-validation', | |
| 27 | + '../../routes/company', | |
| 28 | +]; | |
| 29 | + | |
| 30 | +let wdb; | |
| 31 | +let orgSvc; | |
| 32 | +let engine; | |
| 33 | +let server; | |
| 34 | +let base; | |
| 35 | +const createdCompanies = []; | |
| 36 | + | |
| 37 | +async function call(method, url, body) { | |
| 38 | + const r = await fetch(base + url, { | |
| 39 | + method, | |
| 40 | + headers: { 'Content-Type': 'application/json' }, | |
| 41 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 42 | + }); | |
| 43 | + return [r.status, await r.json()]; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function ensureAgents(ids) { | |
| 47 | + const db = wdb.getDb(); | |
| 48 | + const now = new Date().toISOString(); | |
| 49 | + const ins = db.prepare(` | |
| 50 | + INSERT OR IGNORE INTO system_agents | |
| 51 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 52 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) | |
| 53 | + `); | |
| 54 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** 真实写一份 company.json(不是 mock)——maxActiveRootRuns() 直接读盘,绕不过去。 */ | |
| 58 | +function makeCompany(companyId, extra = {}) { | |
| 59 | + const dir = path.join(COMPANIES_DIR, companyId); | |
| 60 | + fs.mkdirSync(dir, { recursive: true }); | |
| 61 | + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...extra })); | |
| 62 | + createdCompanies.push(companyId); | |
| 63 | + | |
| 64 | + ensureAgents(['org-planner', 'org-coder', 'org-validator']); | |
| 65 | + expect( | |
| 66 | + orgSvc.writeGraph(companyId, { | |
| 67 | + nodes: ['org-planner', 'org-coder', 'org-validator'], | |
| 68 | + edges: [ | |
| 69 | + { from: 'org-planner', to: 'org-coder', condition: 'success' }, | |
| 70 | + { from: 'org-coder', to: 'org-validator', condition: 'success' }, | |
| 71 | + ], | |
| 72 | + }).ok | |
| 73 | + ).toBe(true); | |
| 74 | +} | |
| 75 | + | |
| 76 | +describe('ceo 自主创建的 task 仍然受既有闸门约束(39 号文档 §四.4)', () => { | |
| 77 | + beforeAll(async () => { | |
| 78 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } | |
| 79 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 80 | + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-task-gates-runs-')); | |
| 81 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 82 | + | |
| 83 | + wdb = require('../../services/work-graph-db'); | |
| 84 | + orgSvc = require('../../services/org-graph-service'); | |
| 85 | + engine = require('../../services/graph-engine'); | |
| 86 | + | |
| 87 | + const express = require('express'); | |
| 88 | + const app = express(); | |
| 89 | + app.use(express.json()); | |
| 90 | + app.use('/api/company', require('../../routes/company')); | |
| 91 | + await new Promise((resolve) => { server = app.listen(0, resolve); }); | |
| 92 | + base = `http://127.0.0.1:${server.address().port}/api/company`; | |
| 93 | + }); | |
| 94 | + | |
| 95 | + afterAll(() => { | |
| 96 | + if (server) server.close(); | |
| 97 | + for (const cid of createdCompanies) { | |
| 98 | + fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true }); | |
| 99 | + } | |
| 100 | + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } | |
| 101 | + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ } | |
| 102 | + delete process.env.GRAPH_DB_PATH; | |
| 103 | + delete process.env.GRAPH_RUNS_DIR; | |
| 104 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 105 | + }); | |
| 106 | + | |
| 107 | + // ────────────────────────────────────────────────────────────── | |
| 108 | + // 1. max_parallel_tasks:坑满了,ceo 建的任务一样排队,不能插队 | |
| 109 | + // ────────────────────────────────────────────────────────────── | |
| 110 | + it('max_parallel_tasks=1 时坑已被占,ceo 走 draft+enqueue 建的第二条任务落 queued,不会插队执行', async () => { | |
| 111 | + const cid = 'ceo-gate-slots'; | |
| 112 | + makeCompany(cid, { max_parallel_tasks: 1 }); | |
| 113 | + | |
| 114 | + // 人类下达第一条,占满唯一的执行位 | |
| 115 | + const [s1, taskA] = await call('POST', `/${cid}/tasks`, { task: '人类下达的任务,占坑' }); | |
| 116 | + expect(s1).toBe(201); | |
| 117 | + expect(wdb.getTask(taskA.taskId).status).toBe('running'); | |
| 118 | + | |
| 119 | + // ceo 走它自己的通路:draft 建 → enqueue | |
| 120 | + const [s2, taskB] = await call('POST', `/${cid}/tasks`, { | |
| 121 | + task: 'ceo 自主提的第二条任务', draft: true, created_by: 'ceo', | |
| 122 | + }); | |
| 123 | + expect(s2).toBe(201); | |
| 124 | + expect(taskB.status).toBe('draft'); | |
| 125 | + | |
| 126 | + const [s3, enqueued] = await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`); | |
| 127 | + expect(s3).toBe(201); | |
| 128 | + | |
| 129 | + // 坑满:这条任务必须落 queued,不能因为是 ceo 建的就获得一个额外的执行位 | |
| 130 | + const row = wdb.getTask(taskB.taskId); | |
| 131 | + expect(row.status).toBe('queued'); | |
| 132 | + expect(enqueued.status).toBe('queued'); | |
| 133 | + const run = wdb.getRun(row.current_run_id); | |
| 134 | + expect(run.run_status).toBe('queued'); | |
| 135 | + }); | |
| 136 | + | |
| 137 | + // ────────────────────────────────────────────────────────────── | |
| 138 | + // 2. DEFAULT_TASK_BUDGET:预算给得再小,ceo 建的任务也不会被特殊放宽 | |
| 139 | + // ────────────────────────────────────────────────────────────── | |
| 140 | + it('ceo 建的任务预算耗尽照样判 budget-exhausted,不会因为创建者是 agent 就获得额外预算', async () => { | |
| 141 | + const cid = 'ceo-gate-budget'; | |
| 142 | + makeCompany(cid); | |
| 143 | + | |
| 144 | + const tinyBudget = { total_agent_calls: 0, total_tokens: 0, wallclock_ms: 7_200_000 }; | |
| 145 | + const [s1, task] = await call('POST', `/${cid}/tasks`, { | |
| 146 | + task: 'ceo 自主提的任务,预算给得极小', | |
| 147 | + draft: true, | |
| 148 | + created_by: 'ceo', | |
| 149 | + budget: tinyBudget, | |
| 150 | + }); | |
| 151 | + expect(s1).toBe(201); | |
| 152 | + | |
| 153 | + const [s2] = await call('POST', `/${cid}/tasks/${task.taskId}/enqueue`); | |
| 154 | + expect(s2).toBe(201); | |
| 155 | + | |
| 156 | + const run = wdb.getRun(wdb.getTask(task.taskId).current_run_id); | |
| 157 | + expect(run.budget_json).toBeTruthy(); | |
| 158 | + const parsed = JSON.parse(run.budget_json); | |
| 159 | + expect(parsed.total_agent_calls).toBe(0); // 存进去的确实是那份小预算,没有被谁悄悄改大 | |
| 160 | + | |
| 161 | + // checkBudget 不认「谁建的」,只认预算数字本身 | |
| 162 | + const gate = engine.checkBudget(run); | |
| 163 | + expect(gate.ok).toBe(false); | |
| 164 | + expect(gate.exhausted).toContain('total_agent_calls'); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + // ────────────────────────────────────────────────────────────── | |
| 168 | + // 3. goal 级 org-validator:ceo 建的多 task goal 全部 completed 后,收敛验收照样跑一次 | |
| 169 | + // ────────────────────────────────────────────────────────────── | |
| 170 | + it('ceo 建的两条 task 全 completed 后,goal 级 org-validator 照样被调一次(不因创建者是 agent 而跳过)', async () => { | |
| 171 | + const cid = 'ceo-gate-goal-validation'; | |
| 172 | + makeCompany(cid); | |
| 173 | + | |
| 174 | + const goal = wdb.createGoal({ companyId: cid, title: '两步都由 ceo 建的目标', description: '测试用' }); | |
| 175 | + | |
| 176 | + const [s1, taskA] = await call('POST', `/${cid}/tasks`, { | |
| 177 | + task: 'ceo 建的第一条子任务', draft: true, created_by: 'ceo', goal_id: goal.id, | |
| 178 | + }); | |
| 179 | + expect(s1).toBe(201); | |
| 180 | + await call('POST', `/${cid}/tasks/${taskA.taskId}/enqueue`); | |
| 181 | + | |
| 182 | + const [s2, taskB] = await call('POST', `/${cid}/tasks`, { | |
| 183 | + task: 'ceo 建的第二条子任务', draft: true, created_by: 'ceo', goal_id: goal.id, | |
| 184 | + }); | |
| 185 | + expect(s2).toBe(201); | |
| 186 | + await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`); | |
| 187 | + | |
| 188 | + expect(wdb.getTask(taskA.taskId).created_by).toBe('ceo'); | |
| 189 | + expect(wdb.getTask(taskB.taskId).created_by).toBe('ceo'); | |
| 190 | + | |
| 191 | + const validation = require('../../services/goal-validation'); | |
| 192 | + const seen = []; | |
| 193 | + validation.setValidateGoal(async (ctx) => { | |
| 194 | + seen.push(ctx); | |
| 195 | + return { pass: true, verdict: '两条子任务合起来确实达成了目标', tokens: 42 }; | |
| 196 | + }); | |
| 197 | + | |
| 198 | + try { | |
| 199 | + const sse = require('../../services/graph-run-sse'); | |
| 200 | + const runA = wdb.getTask(taskA.taskId).current_run_id; | |
| 201 | + let runB = wdb.getTask(taskB.taskId).current_run_id; | |
| 202 | + expect(wdb.getRun(runB).run_status).toBe('queued'); // 同公司同一时间只有一个执行位 | |
| 203 | + | |
| 204 | + // 先让 runA 收尾,空出执行位。调度器出队走的是 setImmediate(挂在 transitionRun 钩子上), | |
| 205 | + // 这条用例不测调度时序本身(那是 task-slots-parallel.test.js 的职责)——坑空之后直接 | |
| 206 | + // 用 updateRunStatus 把 runB 推 ready,走的还是真实的准入事务,只是不等 setImmediate。 | |
| 207 | + sse.transitionRun(runA, wdb.getRun(runA).run_status, 'completed', {}, { summary: '用例直推终态' }); | |
| 208 | + wdb.updateRunStatus(runB, 'queued', 'ready'); | |
| 209 | + expect(wdb.getRun(runB).run_status).toBe('ready'); // 坑已空出,推 ready 成功 | |
| 210 | + sse.transitionRun(runB, 'ready', 'running', {}, { summary: '用例直推终态' }); | |
| 211 | + sse.transitionRun(runB, 'running', 'completed', {}, { summary: '用例直推终态' }); | |
| 212 | + | |
| 213 | + const { isPendingVerdict } = require('../../services/task-settle'); | |
| 214 | + for (let i = 0; i < 50; i += 1) { | |
| 215 | + const v = wdb.getGoal(goal.id).verdict; | |
| 216 | + if (v && !isPendingVerdict(v)) break; | |
| 217 | + await new Promise((r) => setImmediate(r)); | |
| 218 | + } | |
| 219 | + | |
| 220 | + expect(seen).toHaveLength(1); // 一个 goal 只验一次,即便两条子任务都是 ceo 建的 | |
| 221 | + expect(seen[0].goal.id).toBe(goal.id); | |
| 222 | + expect(seen[0].tasks.map((t) => t.created_by).sort()).toEqual(['ceo', 'ceo']); | |
| 223 | + expect(wdb.getGoal(goal.id).status).toBe('done'); | |
| 224 | + } finally { | |
| 225 | + validation.setValidateGoal(null); | |
| 226 | + } | |
| 227 | + }); | |
| 228 | +}); | ... | ... |