graph-engine-advance-batch.test.js
12.8 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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
/**
* Unit Tests — advanceBatch(27-P4 T4/T6)
*
* 覆盖 27-P4 §六 验收标准:
* 2. 批量拾取幂等:候选里已经被别人抢先拾取的节点被跳过,不重复认领、不报错
* 3. fan-out 真并行:一张有 3 个并行分支的图,同一批 advanceBatch 里三个节点同时 running
* 4. 部分失败停等:3 个并行分支 1 个失败(无 fail 边 → blocked)时,fan-in 节点保持 pending,
* run 收敛到 blocked(不是 completed)
* 5. 额度生效:`max_concurrency` 建 run 时持久化,`advanceBatch` 不传 opts 时读它
* 7. 手动模式不变:`maxConcurrency<=1` 直接委托 `advanceOne`,一次只推进一个节点
*
* 「fan-out 真并行」这条不需要 setImmediate/sleep 等计时手段就能确定性断言:
* `advanceBatch` 在 `Promise.allSettled(...)` 之前的整段(run 级检查 → checkBudget →
* SELECT 候选 → 逐行 claim)全程同步,`.map()` 调 `runClaimedNode` 时,每个节点的
* ctx 构建/记账/事件都在各自 hit 到 `await agentExecutor(ctx)` 之前同步跑完——
* 所以调用 `advanceBatch(...)`(还没 await 它的返回值)这一行语句执行完,三个节点
* 已经全部处于 running、执行器也都已经被调用过一次。这正是 27-P4 §4.3 那条并发正确性
* 论证(Node 单线程事件循环)的直接体现,写成测试顺带验证了那条论证站得住。
*
* 隔离手法与 graph-engine.test.js 一致:独立 GRAPH_DB_PATH / GRAPH_RUNS_DIR,
* agent 调用全部走注入的假 executor,不起真实 Claude CLI。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('advanceBatch(27-P4 T4/T6)', () => {
let wdb;
let engine;
let sse;
let runsDir;
const TEST_DB_PATH = '/tmp/test-graph-engine-advance-batch.sqlite';
beforeAll(() => {
for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
try { fs.unlinkSync(f); } catch {}
}
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-advance-batch-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of [
'../../services/graph-db',
'../../services/graph-service',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/graph-run-sse',
'../../services/graph-engine',
]) {
delete require.cache[require.resolve(m)];
}
wdb = require('../../services/work-graph-db');
sse = require('../../services/graph-run-sse');
engine = require('../../services/graph-engine');
});
afterAll(() => {
for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
try { fs.unlinkSync(f); } catch {}
}
delete process.env.GRAPH_DB_PATH;
try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
delete process.env.GRAPH_RUNS_DIR;
});
beforeEach(() => {
const db = wdb.getDb();
db.pragma('foreign_keys = OFF');
db.exec(`
DELETE FROM graph_run_loop_records;
DELETE FROM graph_run_nodes;
DELETE FROM graph_runs;
DELETE FROM work_graph_edges;
DELETE FROM work_graph_nodes;
DELETE FROM work_graphs;
DELETE FROM system_agents;
`);
db.pragma('foreign_keys = ON');
engine.setAgentExecutor(null);
});
let seq = 0;
const nextCompany = () => `p4b-co-${++seq}`;
function ensureAgents(ids) {
const db = wdb.getDb();
const ts = 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}`, ts, ts);
}
/** entry → a,b,c(fan-out)→ join(fan-in),全部用 condition=success 的默认边 */
function buildDiamond({ maxConcurrency = null } = {}) {
const companyId = nextCompany();
const nodes = ['entry', 'a', 'b', 'c', 'join'];
ensureAgents(nodes);
const g = wdb.createWorkGraph({
companyId,
nodes,
edges: [
{ from: 'entry', to: 'a' },
{ from: 'entry', to: 'b' },
{ from: 'entry', to: 'c' },
{ from: 'a', to: 'join' },
{ from: 'b', to: 'join' },
{ from: 'c', to: 'join' },
],
});
expect(g.errors || []).toEqual([]);
const run = wdb.createRun({
graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual', maxConcurrency,
});
engine.markRunReady(run.id);
return { runId: run.id, companyId };
}
const nodeByName = (runId, name) =>
wdb.getRunState(runId).nodes.find((n) => n.name === name);
const statusMap = (runId) =>
Object.fromEntries(wdb.getRunState(runId).nodes.map((n) => [n.name, n.status]));
const deferred = () => {
let resolve;
const promise = new Promise((r) => { resolve = r; });
return { promise, resolve };
};
it('fan-out 真并行(验收 3):同一批里 a/b/c 同时处于 running,而不是依次', async () => {
const { runId } = buildDiamond();
const gates = { a: deferred(), b: deferred(), c: deferred() };
const calls = [];
engine.setAgentExecutor(async (ctx) => {
calls.push(ctx.nodeName);
if (gates[ctx.nodeName]) await gates[ctx.nodeName].promise;
return { status: 'success' };
});
engine.startRun(runId);
await engine.advanceOne(runId); // entry → success,点亮 a/b/c
calls.length = 0; // 只看这一批 advanceBatch 触发的调用,entry 那次不算
// 刻意不 await:advanceBatch 在自己第一次真正挂起(Promise.allSettled)之前全程同步,
// 所以这一行语句本身执行完,三个节点的 claim + executor 调用就都已经发生了。
const batchPromise = engine.advanceBatch(runId, { maxConcurrency: 3 });
expect(calls.sort()).toEqual(['a', 'b', 'c']);
expect(statusMap(runId)).toMatchObject({ a: 'running', b: 'running', c: 'running' });
gates.a.resolve();
gates.b.resolve();
gates.c.resolve();
const res = await batchPromise;
expect(res.ok).toBe(true);
expect(res.results).toHaveLength(3);
expect(statusMap(runId)).toMatchObject({
a: 'success', b: 'success', c: 'success', join: 'ready',
});
});
it('批量拾取跳过已被抢走的候选(验收 2):不重复认领、不报错', async () => {
const { runId } = buildDiamond();
engine.setAgentExecutor(async () => ({ status: 'success' }));
engine.startRun(runId);
await engine.advanceOne(runId); // entry → success
// 模拟「b 已经被别的调用抢先拾取」:直接做条件更新,绕开 advanceBatch
const b = nodeByName(runId, 'b');
expect(sse.transitionNode(runId, b.id, 'ready', 'running', {}, { node_name: 'b' })).toBeTruthy();
const res = await engine.advanceBatch(runId, { maxConcurrency: 3 });
// 只认领了 a、c 两个,b 被跳过——既没有重复认领,也没有报错
expect(res.ok).toBe(true);
expect(res.results.map((r) => r.node.name).sort()).toEqual(['a', 'c']);
// b 停在「别人」占着的 running,advanceBatch 完全没碰它(没有被结算成任何终态)
expect(statusMap(runId).b).toBe('running');
});
it('部分失败停等(验收 4):c 失败(无 fail 边 → blocked)时 join 保持 pending,run 收敛到 blocked', async () => {
const { runId } = buildDiamond();
engine.setAgentExecutor(async (ctx) => (
ctx.nodeName === 'c' ? { status: 'fail' } : { status: 'success' }
));
engine.startRun(runId);
await engine.advanceOne(runId); // entry → success
const res = await engine.advanceBatch(runId, { maxConcurrency: 3 });
expect(res.ok).toBe(true);
expect(statusMap(runId)).toMatchObject({
a: 'success', b: 'success', c: 'blocked', join: 'pending',
});
expect(res.runStatus).toBe('blocked');
expect(wdb.getRun(runId).run_status).toBe('blocked');
});
it('maxConcurrency<=1 直接委托 advanceOne(验收 7):一次只推进一个节点', async () => {
const { runId } = buildDiamond({ maxConcurrency: 1 });
const calls = [];
engine.setAgentExecutor(async (ctx) => { calls.push(ctx.nodeName); return { status: 'success' }; });
engine.startRun(runId);
const r1 = await engine.advanceBatch(runId);
expect(r1.results).toHaveLength(1);
expect(r1.results[0].node.name).toBe('entry');
expect(calls).toEqual(['entry']);
// 只推进了一个:a/b/c 还没被拾取(委托给 advanceOne 之后,一次只认领一个 ready 节点)
expect(statusMap(runId)).toMatchObject({ a: 'ready', b: 'ready', c: 'ready' });
});
it('额度生效(验收 5):run.max_concurrency 建 run 时持久化,不传 opts 时读它', async () => {
const { runId } = buildDiamond({ maxConcurrency: 2 });
expect(wdb.getRun(runId).max_concurrency).toBe(2);
const gates = { a: deferred(), b: deferred(), c: deferred() };
engine.setAgentExecutor(async (ctx) => {
if (gates[ctx.nodeName]) await gates[ctx.nodeName].promise;
return { status: 'success' };
});
engine.startRun(runId);
await engine.advanceOne(runId); // entry → success
const batchPromise = engine.advanceBatch(runId); // 不传 maxConcurrency,读 run.max_concurrency=2
const running = Object.entries(statusMap(runId)).filter(([, s]) => s === 'running');
expect(running).toHaveLength(2); // 三个 ready 里只拾取了两个,第三个仍是 ready
for (const g of Object.values(gates)) g.resolve();
await batchPromise;
});
it('ok:false 语义与 advanceOne 对齐:no-ready-node 不算「推进了」', async () => {
// no-ready-node:直接绕开引擎把节点写成 success(不经过 settleNode,run_status 就不会
// 被顺带收敛),构造出「run_status 还是 running,但已经没有 ready/running 节点」这个
// advanceOne 的 !picked 分支本来就要处理的状态。真实场景里这一般是重入/竞态的产物——
// 这里直接摆出结果状态,不追究怎么走到的(同款手法 graph-engine.test.js 234 行已经在用:
// `wdb.updateNodeStatus(a.id, 'ready', 'running')` 绕开引擎构造中间状态)。
const companyId1 = nextCompany();
ensureAgents(['solo']);
const g1 = wdb.createWorkGraph({ companyId: companyId1, nodes: ['solo'], edges: [] });
const run1 = wdb.createRun({ graphKind: 'work', graphId: g1.id, companyId: companyId1, executionMode: 'manual' });
engine.markRunReady(run1.id);
engine.startRun(run1.id);
const solo1 = nodeByName(run1.id, 'solo');
expect(wdb.updateNodeStatus(solo1.id, 'ready', 'running')).toBe(1);
expect(wdb.updateNodeStatus(solo1.id, 'running', 'success')).toBe(1);
// advanceOne 的 result() 惯例是 ok:false——advanceBatch 必须跟它一致,否则接进 runAuto
// 之后,一个已经收敛的 run 会被循环一路空转到 maxSteps 才停(2026-08-21 审 §4.3 时抓到的
// 真 bug,见 advanceBatch 里 no-ready-node 分支的注释)。
const drained = await engine.advanceBatch(run1.id, { maxConcurrency: 3 });
expect(drained.ok).toBe(false);
expect(drained.reason).toBe('no-ready-node');
// 顺带验证:这一次调用自己把 run 收敛掉了(settleRun 就挂在这个分支里)
expect(wdb.getRun(run1.id).run_status).toBe('completed');
// taken-by-other(!claimed.length 分支)分支上同一惯例——ok:false,不额外起用例:
// 单进程 + 同步拾取循环下这个分支在真实调用序列里不可达(§4.1 决策的论证本身就是
// 「单行条件更新在单线程下不会输给同进程的另一次调用」),要触发它得在 SELECT 和
// claim 之间人为插入另一次写,等价于对内部实现打桩,价值不大,不为了覆盖率硬凑。
});
it('runAuto/advanceTree 端到端也吃到并行(不是只有直接调 advanceBatch 才有效)', async () => {
const { runId } = buildDiamond(); // max_concurrency=null → 默认 DEFAULT_MAX_CONCURRENCY=5
let active = 0;
let maxActive = 0;
engine.setAgentExecutor(async () => {
active += 1;
maxActive = Math.max(maxActive, active);
// 真实的异步延迟(不是同步卡在 deferred 上):要证明的是 runAuto 的循环驱动下
// a/b/c 三个 executor 调用有时间窗口上的重叠,不是「反正都同步排到一起」的假象。
await new Promise((r) => { setTimeout(r, 15); });
active -= 1;
return { status: 'success' };
});
await engine.startRun(runId, { mode: 'auto' }).driver;
expect(wdb.getRun(runId).run_status).toBe('completed');
expect(maxActive).toBeGreaterThanOrEqual(3); // entry 之后 a/b/c 应该同时在跑,而不是依次
});
});