ceo-created-task-gates.test.js
10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
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);
}
});
});