/** * 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); } }); });