Commit e1cfc73d04afbc4d199ae9f05a77984e7c45f599

Authored by yibo
2 parents ad045ddf 9e26bea2

Merge branch 'main' of gitlab-wuwei.hexpm.com:yibo/lemonboss

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 +});
... ...