task-queue-acceptance.test.js
33.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
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
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
/**
* 批 3 · 排队 —— §八 验收清单的单测投影(27-P3C)
*
* 与 `task-scheduler.test.js` 的分工:那份钉的是**单个原语**的形状(准入守卫的判据、
* CAS 的 WHERE、唯一索引的取值域);这一份钉的是**端到端的那句话**——
* 「人不再干预,队伍自己往前走」。所以这里几乎不直接调 `updateRunStatus` / `blockQueuedTask`,
* 一律推真实入口(`engine.stopRun` / 真的烧完预算 / `sse.transitionRun` 这个唯一漏斗),
* 然后只看结果:下一条起没起来。
*
* 覆盖的验收条目(编号同 §八):
* 2 T2/T3 依赖 T1 → T1 收敛后 T2 自动起跑,全程无人工干预
* 3 max=1 下第二条排队而不是 409
* 7 取消 queued → run queued→stopped,后面的顶上来
* 8 failed 的 task rerun → attempt+1、新 run、旧 run 仍在历史里
* 9 进程重启后 sweep 把队列捡起来
* 13 stopRun(不走 settleRun)后队列自动前进,不靠 60s 兜底
* 14 预算耗尽 → blocked → 同样自动前进
* 15 排队期间 replan → 出队后按新图跑(tick 里真的调了 syncRunNodesToDef)
* 16 manual 不被自动拉起、不堵队;人点「开始」才占坑
* 23 上游 failed → 下游 blocked:upstream_failed;上游 rerun 成功后自动回 queued 并起跑
* ── 唯一索引的 WHERE 里没有 queued(出队原子性的全部依赖)
*
* 隔离手法与 graph-engine.test.js / task-scheduler.test.js 一致:**先设 env 再 require**
* (graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR,先 require 再设会往仓库 data/companies/ 写
* 而且不报错)。DB 路径与那两份**故意不同**:vitest 默认按文件并行,共用一个 sqlite 文件
* 会让两份用例互相 DELETE 对方的行。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('批 3 排队 · §八 验收', () => {
let wdb;
let engine;
let sse;
let scheduler;
let validation;
let runsDir;
const TEST_DB_PATH = '/tmp/test-task-queue-acceptance.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(), 'task-queue-acc-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of [
'../../services/graph-db',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../services/task-settle',
'../../services/task-scheduler',
'../../services/goal-validation',
]) {
delete require.cache[require.resolve(m)];
}
wdb = require('../../services/work-graph-db');
sse = require('../../services/graph-run-sse');
engine = require('../../services/graph-engine');
scheduler = require('../../services/task-scheduler');
validation = require('../../services/goal-validation');
// goal 级验收默认会**真起 Claude CLI**(§四.10)。本文件有几条用例会让一个 goal 下的
// 多条 task 全部 completed,那正是它的触发条件 —— 不注入就会在单测里起子进程。
validation.setValidateGoal(async () => ({ pass: true, verdict: '用例注入:pass', tokens: 0 }));
});
/**
* `advanceOne` 的 ensureProjectRoot 会在**仓库的** data/companies/ 下给每家公司建项目根
* (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认里面没有 company.json
* —— 那是真公司,删了就是删用户数据。
*/
function cleanupLeakedCompanyDirs() {
const root = path.resolve(__dirname, '../../../data/companies');
if (!fs.existsSync(root)) return;
for (const cid of fs.readdirSync(root)) {
if (!cid.startsWith('qacc-co-')) continue;
const dir = path.join(root, cid);
if (fs.existsSync(path.join(dir, 'company.json'))) continue;
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ }
}
}
afterAll(async () => {
// 等一拍再清:末尾那条用例的 setImmediate 钩子可能还没出栈,
// 它一跑 advanceOne 就会经 ensureProjectRoot 把刚删掉的公司目录原样建回来
await new Promise((r) => setTimeout(r, 120));
scheduler.stopSweeper();
validation.setValidateGoal(null);
cleanupLeakedCompanyDirs();
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 tasks;
DELETE FROM goals;
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);
// 每条用例都从「没有兜底定时器」开始:本文件要证明的正是「队列靠漏斗钩子前进,
// 不是靠 60s 扫描」。定时器开着的话,验收 13 / 14 就算钩子漏了也可能蒙混过关。
scheduler.stopSweeper();
});
// ── 夹具 ──────────────────────────────────────────────────────
let seq = 0;
/** 每个用例一家独立公司:唯一索引是按 company 约束的 */
const nextCompany = () => `qacc-co-${++seq}`;
function ensureAgents(ids) {
const now = new Date().toISOString();
const ins = wdb.getDb().prepare(`
INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
`);
for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
}
/** 建一张链式 work graph 定义(校验要求单入口 + 全可达,所以多节点时串起来) */
function buildGraph(companyId, nodes = ['solo']) {
ensureAgents(nodes);
const edges = nodes.slice(1).map((to, i) => ({ from: nodes[i], to }));
const g = wdb.createWorkGraph({ companyId, nodes, edges });
expect(g.errors || []).toEqual([]);
return g.id;
}
/**
* 「重新规划组织」落库后的样子:把这张图的节点整批换掉。
*
* 必须是真的删行 + 插行,改个 node_name 不算 —— `graph_run_nodes.def_node_id` 认的是
* 节点**行 id**,只有换行才会造出「run 上的节点行指向已不存在的定义」这种孤儿,
* 而那正是 `syncRunNodesToDef` 要修的东西。
*/
function rewriteGraph(graphId, names) {
ensureAgents(names);
const db = wdb.getDb();
const now = new Date().toISOString();
db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId);
db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ?`).run(graphId);
const ids = names.map((name) => {
const id = wdb.newId('wgn');
db.prepare(`INSERT INTO work_graph_nodes (id, work_graph_id, node_name, system_agent_id, node_kind, created_at)
VALUES (?, ?, ?, ?, 'agent_call', ?)`).run(id, graphId, name, name, now);
return id;
});
for (let i = 1; i < ids.length; i++) {
db.prepare(`INSERT INTO work_graph_edges (id, work_graph_id, from_node_id, to_node_id, condition, edge_kind, created_at)
VALUES (?, ?, ?, ?, 'success', 'forward', ?)`)
.run(wdb.newId('wge'), graphId, ids[i - 1], ids[i], now);
}
}
const newGoal = (companyId) =>
wdb.createGoal({ companyId, title: 'g', description: 'g' }).id;
/**
* 入队 —— 端点还没接上(`company.js` 里 `enqueueTaskRun` 目前零调用方),这里手工拼出
* 它承诺的那三步:建 task 行 → 建 **`runStatus:'queued'`** 的 root run(不占坑)
* → task 翻 queued + 挂 current_run_id。顺序与 `enqueueTaskRun` 逐字一致
* (先 run 后 task:反过来的话中间窗口里的 tick 会把一次正常入队判成 enqueue_incomplete)。
*/
function enqueue(companyId, goalId, { title, mode = 'auto', dependsOn = [], priority = 'P1', graphId, budget } = {}) {
const task = wdb.createTask({
goalId, companyId, title, description: `${title} 的描述`,
executionMode: mode, dependsOn, priority,
});
const run = wdb.createRun({
graphKind: 'work',
graphId: graphId || buildGraph(companyId),
companyId,
goalId,
taskId: task.id,
executionMode: mode,
runStatus: 'queued',
budget,
});
wdb.updateTask(task.id, {
status: 'queued',
currentRunId: run.id,
queuedAt: new Date().toISOString(),
});
return { taskId: task.id, runId: run.id, graphId: run.graphId };
}
/**
* 任务级重跑(§六.3 的 `POST /:cid/tasks/:taskId/rerun`,端点未落地时的等价三步)。
* **新起一条 run**,旧 run 一个字段都不动 —— 旧 run 留在历史里可点开正是验收 8 的要求。
*/
function rerun(companyId, taskRow, { graphId } = {}) {
const run = wdb.createRun({
graphKind: 'work',
graphId: graphId || buildGraph(companyId),
companyId,
goalId: taskRow.goal_id,
taskId: taskRow.id,
executionMode: taskRow.execution_mode,
runStatus: 'queued',
});
wdb.updateTask(taskRow.id, {
status: 'queued',
currentRunId: run.id,
startedAt: null,
finishedAt: null,
blockedReason: null,
});
return run.id;
}
/**
* 人在控制台上点「开始」—— `company.js:promoteQueuedTask` 的四步,逐字同序。
* manual 任务只有这一条起跑路径(调度器**故意**不碰它们,§五.2bis)。
*/
function humanStart(taskRow) {
const runId = taskRow.current_run_id;
wdb.syncRunNodesToDef(runId);
if (!sse.transitionRun(runId, 'queued', 'ready', {}, { summary: '人工出队', task_id: taskRow.id })) return null;
if (!wdb.claimQueuedTask(taskRow.id, runId)) return null;
return engine.startRun(runId, { mode: taskRow.execution_mode, task: taskRow.description });
}
/**
* 永不 settle 的 executor:让 run 稳稳停在 running,好观察队列。
*
* **故意不留放行钩子**。放行会让 driver 在用例结束后继续跑一轮 advanceOne,而那时
* beforeEach 已经把 graph_run_nodes 清空了 —— 于是每条用例收尾都刷一行
* 「auto 驱动异常: graph_run_nodes 不存在」。悬着的 promise 不会拖住进程退出,
* 也不会再碰库,比放行干净。
*/
function hangingExecutor() {
const calls = [];
engine.setAgentExecutor((ctx) => {
calls.push(ctx.nodeName);
return new Promise(() => {});
});
return calls;
}
/**
* 等一个条件成立。**不用固定次数的 flush**:从「漏斗钩子」到「下一条真的 running」中间
* 隔着 setImmediate + 一整条 async driver,拍脑袋数几拍出来的用例是不稳的。
* 超时就把最后一次的实际值一起报出来,免得只看到一句 timeout。
*/
async function waitFor(fn, label, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
let last;
for (;;) {
last = fn();
if (last) return last;
if (Date.now() > deadline) throw new Error(`等待超时:${label}(最后一次取到 ${JSON.stringify(last)})`);
await new Promise((r) => setImmediate(r));
}
}
const taskStatus = (id) => wdb.getTask(id).status;
const runStatus = (id) => wdb.getRun(id).run_status;
// ──────────────────────────────────────────────────────────────
// 唯一索引:queued 不在 WHERE 里
// ──────────────────────────────────────────────────────────────
it('唯一索引的 WHERE 里没有 queued —— 出队原子性的全部依赖,直接对着建表 SQL 断言', () => {
const row = wdb.getDb()
.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_graph_runs_one_active_root'`)
.get();
expect(row).toBeTruthy();
// 断言的是**索引自己的 SQL**,不是 JS 常量:常量与索引一旦分叉,只看常量的用例照样绿
expect(row.sql).toMatch(/parent_run_id IS NULL/);
expect(row.sql).not.toMatch(/queued/);
for (const s of ['ready', 'running', 'paused']) expect(row.sql).toContain(`'${s}'`);
// 反过来也要成立:JS 侧那份取值域与索引逐字同源
expect(wdb.ACTIVE_ROOT_RUN_STATUS).toEqual(['ready', 'running', 'paused']);
expect(wdb.MAX_ACTIVE_ROOT_RUNS).toBe(1);
});
// ──────────────────────────────────────────────────────────────
// 验收 3:第二条排队而不是 409
// ──────────────────────────────────────────────────────────────
it('验收 3:max=1 时第二条任务入队不报错(这就是 409 消失的地方),两条都躺在队里各占位次', () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
const a = enqueue(companyId, goalId, { title: 'a' });
const b = enqueue(companyId, goalId, { title: 'b' });
// 建两条 queued root run 本身就不该撞唯一索引 —— 旧代码正是在这一步 409 的
expect(runStatus(a.runId)).toBe('queued');
expect(runStatus(b.runId)).toBe('queued');
expect(scheduler.queuePosition(companyId, a.taskId)).toBe(1);
expect(scheduler.queuePosition(companyId, b.taskId)).toBe(2);
scheduler.tick(companyId);
expect(taskStatus(a.taskId)).toBe('running');
expect(taskStatus(b.taskId)).toBe('queued');
// 已经有一条在跑时**再入队第三条**:这才是「下达第二个任务」的真实时序。
// 旧代码在这里抛 UNIQUE constraint → 路由翻 409;现在必须一声不响地排上。
let c;
expect(() => { c = enqueue(companyId, goalId, { title: 'c' }); }).not.toThrow();
expect(runStatus(c.runId)).toBe('queued');
expect(wdb.countActiveRootRuns(companyId)).toBe(1); // 排队的一条都不占坑
// ⚠️ 位次口径:listQueue 只数 `status='queued'` 的行,跑着的那条不在队里。
// 所以此刻 b 是第 1、c 是第 2 —— §八.3 写的「queuePosition=2」是把正在跑的那条也数了进去。
// 这里钉的是**实现现在的口径**(端点回的就是这个数),口径分歧登记在返回结论里。
expect(scheduler.queuePosition(companyId, b.taskId)).toBe(1);
expect(scheduler.queuePosition(companyId, c.taskId)).toBe(2);
});
// ──────────────────────────────────────────────────────────────
// 验收 2:依赖链自动前进
// ──────────────────────────────────────────────────────────────
it('验收 2:T2/T3 依赖 T1 → T1 跑时两条都 queued;T1 收敛后 T2 自动起跑,全程无人工干预', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
const t1 = enqueue(companyId, goalId, { title: 't1' });
const t2 = enqueue(companyId, goalId, { title: 't2' });
const t3 = enqueue(companyId, goalId, { title: 't3' });
wdb.updateTask(t2.taskId, { dependsOn: [t1.taskId] });
wdb.updateTask(t3.taskId, { dependsOn: [t1.taskId] });
// 唯一的一次人工干预就是这一下(等价于用户点「下达」);之后一次 tick 都不许再调
scheduler.tick(companyId);
expect(taskStatus(t1.taskId)).toBe('running');
expect(taskStatus(t2.taskId)).toBe('queued');
expect(taskStatus(t3.taskId)).toBe('queued');
// 依赖未满足的照样在队里占位次(界面上要显示「排队 #1 / #2」),只是不够格出队
expect(scheduler.queuePosition(companyId, t2.taskId)).toBe(1);
expect(scheduler.queuePosition(companyId, t3.taskId)).toBe(2);
// T1 正常收敛:推唯一漏斗,等价于 auto driver 跑完最后一个节点那一跳
expect(sse.transitionRun(t1.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => taskStatus(t2.taskId) === 'running', 'T1 收敛后 T2 自动起跑');
expect(taskStatus(t1.taskId)).toBe('completed');
expect(runStatus(t2.runId)).toBe('running');
// max=1:T3 依赖也满足了,但坑只有一个,它继续排
expect(taskStatus(t3.taskId)).toBe('queued');
expect(scheduler.queuePosition(companyId, t3.taskId)).toBe(1);
// 再收敛 T2 → T3 接着自己起来(依赖链一路走到底,中间没有人碰过)
expect(sse.transitionRun(t2.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => taskStatus(t3.taskId) === 'running', 'T2 收敛后 T3 自动起跑');
});
// ──────────────────────────────────────────────────────────────
// 验收 7:取消排队中的任务
// ──────────────────────────────────────────────────────────────
it('验收 7:取消一个 queued task → run queued→stopped、task cancelled,后面那条顶上来', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
const a = enqueue(companyId, goalId, { title: 'a', priority: 'P0' });
const b = enqueue(companyId, goalId, { title: 'b', priority: 'P1' });
const c = enqueue(companyId, goalId, { title: 'c', priority: 'P2' });
scheduler.tick(companyId);
expect(taskStatus(a.taskId)).toBe('running');
expect(scheduler.queuePosition(companyId, c.taskId)).toBe(2);
// 取消 = `company.js:cancelQueuedRun` 那一跳(走漏斗,不裸写库)。
// task 状态**不在这里写**:由漏斗钩子上的 task-settle 按 RUN_TO_TASK_STATUS 收成 cancelled,
// 「取消排队」在库里 / 时间线上 / 界面上因此是同一件事。
expect(sse.transitionRun(b.runId, 'queued', 'stopped', {}, { summary: '取消排队' })).toBeTruthy();
expect(runStatus(b.runId)).toBe('stopped');
await waitFor(() => taskStatus(b.taskId) === 'cancelled', '取消后 task 收成 cancelled');
// 后面那条顶上来:位次 2 → 1
expect(scheduler.queuePosition(companyId, c.taskId)).toBe(1);
// A 让出坑之后,接班的是 C 而不是被取消的 B
expect(sse.transitionRun(a.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => taskStatus(c.taskId) === 'running', 'C 顶上来起跑');
expect(runStatus(b.runId)).toBe('stopped'); // 被取消的那条一动不动
});
// ──────────────────────────────────────────────────────────────
// 验收 8:任务级 rerun
// ──────────────────────────────────────────────────────────────
it('验收 8:failed 的 task rerun → attempt 累加、新起一条 run,旧 run 仍在历史里可点开', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
const a = enqueue(companyId, goalId, { title: 'a' });
scheduler.tick(companyId);
expect(wdb.getTask(a.taskId).attempt).toBe(1); // attempt 由 claimQueuedTask 在出队时 +1
// 第一次跑失败(走漏斗 → task-settle 回写)
expect(sse.transitionRun(a.runId, 'running', 'failed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => taskStatus(a.taskId) === 'failed', 'run 失败回写 task');
const oldRun = wdb.getRun(a.runId);
const oldNodeCount = wdb.getRunState(a.runId).nodes.length;
expect(oldNodeCount).toBeGreaterThan(0);
// rerun:新 run + task 回 queued
const newRunId = rerun(companyId, wdb.getTask(a.taskId));
expect(newRunId).not.toBe(a.runId);
scheduler.tick(companyId);
const fresh = wdb.getTask(a.taskId);
expect(fresh.status).toBe('running');
expect(fresh.current_run_id).toBe(newRunId);
expect(fresh.attempt).toBe(2); // 第二代次
expect(runStatus(newRunId)).toBe('running');
// 旧 run 一个字段都没被动过 —— 「点开历史」靠的就是它还在、节点行还在
const stillThere = wdb.getRun(a.runId);
expect(stillThere.run_status).toBe('failed');
expect(stillThere.created_at).toBe(oldRun.created_at);
expect(wdb.getRunState(a.runId).nodes.length).toBe(oldNodeCount);
// 两条 run 都挂在同一条 task 下,历史列表里能同时看到
const roots = wdb.listCompanyRuns(companyId, { rootOnly: true }).filter((r) => r.task_id === a.taskId);
expect(roots.map((r) => r.id).sort()).toEqual([a.runId, newRunId].sort());
// 进度也是按 run 分别算的,rerun 不会把旧 run 的进度算到新 run 头上
const prog = wdb.taskProgress([a.runId, newRunId]);
expect(prog.has(a.runId)).toBe(true);
expect(prog.has(newRunId)).toBe(true);
});
// ──────────────────────────────────────────────────────────────
// 验收 9:进程重启后队列被捡起来
// ──────────────────────────────────────────────────────────────
it('验收 9:sweep 把「所有队里有人的公司」各推一次 —— 进程重启丢掉的唤醒靠它补回来', () => {
const co1 = nextCompany();
const co2 = nextCompany();
hangingExecutor();
const a = enqueue(co1, newGoal(co1), { title: 'a' });
const b = enqueue(co2, newGoal(co2), { title: 'b' });
// 模拟「重启后没有任何钩子会被触发」:谁都没调过 tick,两条就那么躺着
expect(taskStatus(a.taskId)).toBe('queued');
expect(taskStatus(b.taskId)).toBe('queued');
const results = scheduler.sweep();
// 跨公司各起各的:坑是按 company 算的
expect(taskStatus(a.taskId)).toBe('running');
expect(taskStatus(b.taskId)).toBe('running');
expect(results.map((r) => r.companyId).sort()).toEqual([co1, co2].sort());
});
it('startSweeper 幂等且 unref —— 兜底定时器绝不该拖着进程不退出', () => {
const t1 = scheduler.startSweeper(50);
const t2 = scheduler.startSweeper(50);
expect(t2).toBe(t1); // 重复调只留一个,否则每次热重载都多一个扫描器
expect(typeof t1.hasRef).toBe('function');
expect(t1.hasRef()).toBe(false);
scheduler.stopSweeper();
// 停完再起要能起来(不能因为 sweepTimer 没清干净就永远起不了第二个)
const t3 = scheduler.startSweeper(50);
expect(t3).not.toBe(t1);
scheduler.stopSweeper();
});
// ──────────────────────────────────────────────────────────────
// 验收 13 / 14:两条不走 settleRun 的释放路径
// ──────────────────────────────────────────────────────────────
it('验收 13:stopRun 不走 settleRun —— 队列靠 transitionRun 漏斗自己前进,不是等 60s 兜底', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
const a = enqueue(companyId, goalId, { title: 'a', priority: 'P0' });
const b = enqueue(companyId, goalId, { title: 'b', priority: 'P1' });
scheduler.tick(companyId);
expect(taskStatus(a.taskId)).toBe('running');
// beforeEach 里已经 stopSweeper —— 这条用例全程没有任何定时器,
// 队列还能前进就只可能是漏斗钩子干的(这正是「不是等 60s」的证明方式)
engine.stopRun(a.runId);
await waitFor(() => taskStatus(b.taskId) === 'running', 'stopRun 后 B 自动起跑');
expect(taskStatus(a.taskId)).toBe('cancelled');
expect(runStatus(a.runId)).toBe('stopped');
});
it('验收 14:预算耗尽 → root run blocked → 下一条同样自动起跑(这条路径也不走 settleRun)', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
const calls = hangingExecutor();
// 额度直接给 0:advanceOne 拾到第一个 ready 节点、真要花钱那一刻就判耗尽
const a = enqueue(companyId, goalId, { title: 'a', priority: 'P0', budget: { total_agent_calls: 0, spent: {} } });
const b = enqueue(companyId, goalId, { title: 'b', priority: 'P1' });
scheduler.tick(companyId);
await waitFor(() => runStatus(a.runId) === 'blocked', '预算耗尽 → root run blocked');
// 一次 agent 都没起:预算闸门在发起调用之前
expect(calls).toEqual([]);
// blocked 不是失败,是挂起等人加预算 —— task 跟着是 blocked 而不是 failed
expect(taskStatus(a.taskId)).toBe('blocked');
await waitFor(() => taskStatus(b.taskId) === 'running', '预算耗尽让出坑后 B 自动起跑');
// A 这种 blocked 不带 upstream_failed: 前缀,绝不会被下一次 tick 自动重排回 queued
expect(scheduler.tick(companyId).requeued).toEqual([]);
expect(taskStatus(a.taskId)).toBe('blocked');
});
// ──────────────────────────────────────────────────────────────
// 验收 15:排队期间 replan
// ──────────────────────────────────────────────────────────────
it('验收 15:排队期间图被重新规划 → 出队后按新图跑,没有孤儿节点也不卡死', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
const calls = hangingExecutor();
const graphId = buildGraph(companyId, ['old-entry']);
const a = enqueue(companyId, goalId, { title: 'a', priority: 'P0' }); // 占着坑,让 b 真的排队
const b = enqueue(companyId, goalId, { title: 'b', priority: 'P1', graphId });
scheduler.tick(companyId);
expect(taskStatus(b.taskId)).toBe('queued');
// 入队那一刻的节点行快照
expect(wdb.getRunState(b.runId).nodes.map((n) => n.name)).toEqual(['old-entry']);
// 「重新规划组织」:整批换节点。定义是读时取最新,节点行还停在旧快照上。
rewriteGraph(graphId, ['new-entry']);
// 让出坑,b 自动出队 —— tick 内部必须先 syncRunNodesToDef 再出队,
// 漏了这一句:run 上只有一条指向已删定义的孤儿节点行,seedEntryNodes 点不亮任何东西,
// run 会一路 running 到 settleRun 判「没有活跃节点」,任务无声无息地假收敛。
expect(sse.transitionRun(a.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => taskStatus(b.taskId) === 'running', 'b 出队起跑');
const names = wdb.getRunState(b.runId).nodes.map((n) => n.name);
expect(names).toEqual(['new-entry']); // 按新图跑,旧节点行已被删干净(无孤儿)
await waitFor(() => calls.includes('new-entry'), '新图的入口节点真的被点亮并调用');
expect(calls).not.toContain('old-entry');
expect(runStatus(b.runId)).toBe('running'); // 没卡死:真的停在新节点上跑着
});
// ──────────────────────────────────────────────────────────────
// 验收 16:manual 不堵队
// ──────────────────────────────────────────────────────────────
it('验收 16:manual 排在队头也不被自动拉起(不堵队),auto 照常起跑;人点「开始」才占坑', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
// manual 优先级更高、排在队头 —— 它要是被自动拉起,就会占着坑停在第一个节点上等人,
// 后面所有任务无限期堵死,而队列界面看不出任何异常(§五.2bis 的原话)
const m = enqueue(companyId, goalId, { title: 'm', mode: 'manual', priority: 'P0' });
const a = enqueue(companyId, goalId, { title: 'a', mode: 'auto', priority: 'P1' });
scheduler.tick(companyId);
expect(taskStatus(m.taskId)).toBe('queued'); // 队头的 manual 被跳过
expect(taskStatus(a.taskId)).toBe('running'); // 后面的 auto 照样起跑 = 没被堵住
expect(scheduler.queuePosition(companyId, m.taskId)).toBe(1); // 但位次照占,界面要显示
// auto 收敛让出坑,再 tick 多少次 manual 也不会自己起来
expect(sse.transitionRun(a.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => wdb.countActiveRootRuns(companyId) === 0, 'auto 收敛让出坑');
scheduler.tick(companyId);
scheduler.tick(companyId);
expect(taskStatus(m.taskId)).toBe('queued');
// 人点「开始」才起跑、才占坑
expect(humanStart(wdb.getTask(m.taskId))).toBeTruthy();
expect(taskStatus(m.taskId)).toBe('running');
expect(runStatus(m.runId)).toBe('running');
expect(wdb.countActiveRootRuns(companyId)).toBe(1);
});
// ──────────────────────────────────────────────────────────────
// 验收 23:blocked 是推导出来的,不是落死的
// ──────────────────────────────────────────────────────────────
it('验收 23:上游 failed → 下游 blocked:upstream_failed;上游 rerun 成功后下游自己回 queued 并起跑', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
hangingExecutor();
const a = enqueue(companyId, goalId, { title: 'a', priority: 'P0' });
const b = enqueue(companyId, goalId, { title: 'b', priority: 'P1' });
wdb.updateTask(b.taskId, { dependsOn: [a.taskId] });
scheduler.tick(companyId);
expect(taskStatus(a.taskId)).toBe('running');
// A 真的失败(走漏斗:settle 回写 + 释放坑各一次,次序由 afterRunTransition 保证)
expect(sse.transitionRun(a.runId, 'running', 'failed', {}, { summary: '用例直推终态' })).toBeTruthy();
await waitFor(() => taskStatus(b.taskId) === 'blocked', '上游失败后下游被推导成 blocked');
expect(wdb.getTask(b.taskId).blocked_reason).toBe(`upstream_failed:${a.taskId}`);
// 关键:B **不是**躺在队里假装排队 —— 它已经不在队列里了,界面上是标红的 blocked
expect(scheduler.queuePosition(companyId, b.taskId)).toBe(0);
// 重跑上游(§五.1.2 给的两条出路之一)
const aRun2 = rerun(companyId, wdb.getTask(a.taskId));
scheduler.tick(companyId);
expect(taskStatus(a.taskId)).toBe('running');
// ⚠️ 这一步的口径容易想反:B 在**上游刚被重跑**时就回 queued 了,不用等 A 跑成功。
// 因为「回 queued」的判据是 `deadDependency === null`(上游不再处于 failed/cancelled/blocked),
// 而不是「上游已 completed」—— 后者是**够不够格出队**的判据(dependenciesSatisfied),两者正交。
// 分开是对的:A 一重跑,B 在界面上就该从标红的 blocked 变回「排队中,等 A」,
// 而不是等 A 跑完几分钟里一直标红说「上游失败了」。
expect(taskStatus(b.taskId)).toBe('queued');
expect(wdb.getTask(b.taskId).blocked_reason).toBeNull();
// 回了队列但**不许被起跑**:坑此刻被 A 占着,且 A 还没 completed
expect(runStatus(b.runId)).toBe('queued');
expect(sse.transitionRun(aRun2, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
// 上游恢复 → B 自己回 queued 并被同一轮 tick 拉起来。
// 这一整段就是「blocked 每次 tick 重算、不落成不可逆状态」的兑现证明:
// 没有 requeueDerivedBlockedTask 的话,B 会永远躺在 blocked 里。
await waitFor(() => taskStatus(b.taskId) === 'running', '上游 rerun 成功后下游自动起跑');
expect(wdb.getTask(b.taskId).blocked_reason).toBeNull();
expect(runStatus(b.runId)).toBe('running');
});
});