p3c-recovery-blind-spots.test.js
25.3 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
/**
* Unit Tests — 批 3 三处修复里**还没有人钉过的那几格**(27-P3C 批 3 对抗复核 F1 / F2 / F3)
*
* `graph-run-slot-recovery.test.js` 已经钉住了「人点启动 → 退回队列 → 自己被接回来」和
* 「retryNode 干净拒绝」;这个文件补的是同一批修复里**没有测试看着**的那几条路径,
* 每一条都对应一种「写不进去但不报错 / 注释承诺了却看不见」的老病:
*
* F1-C `reviveParentFromChild` 撞坑满 —— 它挂在救援方那条 run 的 `runAuto(...).catch` 下面,
* 抛出去只会被吞成一行 console。所以「不静默」必须是**可查的痕迹**:timeline 事件、
* task 行上的原因、以及退得回队列时的自愈。三种结局各钉一条。
* F1-B `completed` + **有 task** 的 run —— 已有用例用的是裸 run(没 task),走的是
* `requeueBlockedRootRun` 第一道 `!run.task_id` 闸。有 task 的那条走的是另一道
* (`run_status !== 'blocked'`),而它才是「对一条跑完的任务点重试」的真实形态。
* F2 `isUntouchedNode` 的五个合取项 —— 已有用例只动了 `attempt`。剩下三个
* (agent_run_id / child_run_id / output_ref)任何一个漏进 DELETE 的 WHERE,
* 丢的都是审计轨迹,而且**丢了不报错**。外加白名单对 `completed` 同样成立。
* F3 `sweep()` 的取值域 —— 「一条 queued 都没有、只剩 blocked」的公司是它扩宽取值域的
* 全部理由。旧口径下这家公司在重启后再也不会被 tick,上游修好了下游也永远解不开。
* 强制放行 —— 数据层那道 `DEPENDS_ON_EDITABLE_STATUS` 闸门 + 下一次 tick 真的起跑,
* 路由层的端到端用例在另一个文件,这里钉的是它依赖的那两块地基。
*
* 隔离手法与 graph-run-slot-recovery.test.js 一致:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再
* require(graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR,反过来会往仓库 data/ 里写且不报错)。
* 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('批 3 恢复路径的盲区', () => {
let wdb;
let engine;
let sse;
let scheduler;
let runsDir;
const TEST_DB_PATH = '/tmp/test-p3c-recovery-blind-spots.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
'../../services/graph-db',
'../../services/graph-service',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../services/task-settle',
'../../services/task-scheduler',
];
/** 挂起的 executor promise,afterAll 统一放掉,免得留下永不 settle 的 driver */
const pendingResolvers = [];
beforeAll(() => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-blind-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of MODULES) 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');
});
/** advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些 */
function cleanupLeakedCompanyDirs() {
const root = path.resolve(__dirname, '../../../data/companies');
if (!fs.existsSync(root)) return;
for (const cid of fs.readdirSync(root)) {
if (!cid.startsWith('p3cblind-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 () => {
for (const r of pendingResolvers) r();
await new Promise((r) => setTimeout(r, 100));
scheduler.stopSweeper();
cleanupLeakedCompanyDirs();
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* 同上 */ }
delete process.env.GRAPH_DB_PATH;
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);
engine.setPlanWorkGraph(null);
});
let seq = 0;
const nextCompany = () => `p3cblind-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, 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);
}
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;
}
const newGoal = (companyId) => wdb.createGoal({ companyId, title: 'g', description: 'g' }).id;
/**
* 把公司的那个坑占满。直接写 running 而不是真跑一条 run:这里要的只是「坑上躺着人」,
* 而准入守卫数的正是 ACTIVE_ROOT_RUN_STATUS 里的 root run。
*/
function occupySlot(companyId) {
const run = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId, ['holder']), companyId });
wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(run.id);
return run.id;
}
const nodeByName = (runId, name) => (wdb.getRunState(runId).nodes || []).find((n) => n.name === name);
const timeline = (runId) => fs.readFileSync(sse.timelinePath(runId), 'utf8')
.split('\n').filter(Boolean).map((l) => JSON.parse(l));
/**
* 造出「父 run 一个 subgraph 节点 + 一张会失败的子图」,父图入口节点没有 fail 出边
* (27-P3B 界面走查里走到 blocked 的正是这个形状,见 graph-engine-revive-parent.test.js)。
* @param {object} [opt]
* @param {boolean} [opt.withTask] 给父 run 挂一条 task(决定坑满时退不退得回队列)
* @param {boolean} [opt.archiveTask] task 建完就归档(退不回队列,但 task 行上仍要留痕)
*/
function buildParentWithFailingChild({ withTask = false, archiveTask = false } = {}) {
const companyId = nextCompany();
ensureAgents(['org-planner', 'wg-only']);
const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] });
const parentG = wdb.createWorkGraph({
companyId,
nodes: [{ nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' }],
edges: [],
});
expect(childG.errors || []).toEqual([]);
expect(parentG.errors || []).toEqual([]);
let goalId = null;
let taskId = null;
if (withTask) {
goalId = newGoal(companyId);
taskId = wdb.createTask({
goalId, companyId, title: 'parent-task', description: '父 run 对应的任务', executionMode: 'auto',
}).id;
}
const parentRun = wdb.createRun({
graphKind: 'work', graphId: parentG.id, companyId, goalId, taskId, executionMode: 'manual',
});
if (taskId) {
// current_run_id 必须指着这条 run:task-settle / requeue / 留痕的 WHERE 全靠它认亲
wdb.updateTask(taskId, { status: 'running', currentRunId: parentRun.id, queuedAt: new Date().toISOString() });
if (archiveTask) wdb.updateTask(taskId, { archivedAt: new Date().toISOString() });
}
engine.markRunReady(parentRun.id);
engine.startRun(parentRun.id, { mode: 'manual' });
engine.setPlanWorkGraph(() => ({ workGraphId: childG.id }));
engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点失败' }));
return { companyId, parentRunId: parentRun.id, taskId };
}
/** 推到「子 run 失败、父节点 blocked、父 run blocked」 */
async function driveToBlocked(parentRunId) {
for (let i = 0; i < 12; i += 1) {
const r = await engine.advanceTree(parentRunId);
if (!r || r.ok === false) break;
}
const parent = nodeByName(parentRunId, 'org-planner');
expect(parent.status).toBe('blocked');
expect(wdb.getRun(parentRunId).run_status).toBe('blocked');
return parent;
}
/** 把子 run 修好并跑到 completed —— 这一跳会触发 propagateToParent → reviveParentFromChild */
async function rescueChild(childRunId) {
engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' }));
const childNode = wdb.getRunState(childRunId).nodes[0];
engine.retryNode(childRunId, childNode.id);
for (let i = 0; i < 6; i += 1) {
const r = await engine.advanceOne(childRunId);
if (!r || r.ok === false) break;
}
expect(wdb.getRun(childRunId).run_status).toBe('completed');
}
// ────────────────────────────────────────────────────────────
// F1-C:reviveParentFromChild 撞坑满
// ────────────────────────────────────────────────────────────
it('F1-C 救援撞坑满且退不回队列(裸 run)→ timeline 上留得下痕迹,父节点一个字都不动', async () => {
const { companyId, parentRunId } = buildParentWithFailingChild();
const blockedNode = await driveToBlocked(parentRunId);
const holderRunId = occupySlot(companyId);
await rescueChild(blockedNode.childRunId);
// 父节点/父 run 保持原样:救不回来就别留半截状态(节点 success 而 run 还 blocked 是死局)
const after = nodeByName(parentRunId, 'org-planner');
expect(after.status).toBe('blocked');
expect(after.attempt).toBe(blockedNode.attempt);
expect(wdb.getRun(parentRunId).run_status).toBe('blocked');
// 痕迹:本函数挂在 runAuto(...).catch 底下,没有事件就等于这件事从没发生过
const trace = timeline(parentRunId)
.find((l) => l.kind === 'node.error' && l.payload && l.payload.error_type === 'RunSlotBusyError');
expect(trace, 'reviveParentFromChild 坑满时必须发事件,否则控制台上什么都看不出来').toBeTruthy();
expect(trace.payload.held_by).toEqual([holderRunId]);
expect(trace.payload.child_run_id).toBe(blockedNode.childRunId);
expect(trace.payload.company_id).toBe(companyId);
// ref 必须挂在那个卡住的节点上:控制台的事件流是按 ref 归位的,挂错地方等于发进真空
expect(trace.ref).toBe(blockedNode.id);
});
it('F1-C 退不回队列但 task 还在时,原因写到 task 行上,且刻意不带 upstream_failed: 前缀', async () => {
// 归档过的 task 退不回队列(requeue 的 WHERE 有 archived_at IS NULL),但留痕照旧要有
const { companyId, parentRunId, taskId } = buildParentWithFailingChild({ withTask: true, archiveTask: true });
const blockedNode = await driveToBlocked(parentRunId);
const holderRunId = occupySlot(companyId);
await rescueChild(blockedNode.childRunId);
const task = wdb.getTask(taskId);
expect(task.status).toBe('blocked'); // 父 run 转 blocked 时由 task-settle 写的
expect(task.blocked_reason).toContain('revive_slot_busy:');
expect(task.blocked_reason).toContain(holderRunId);
// 带上 upstream_failed: 前缀就会被调度器当成「推导出来的 blocked」自动推回 queued,
// 而这条 run 还等着人处置 —— 那会让任务在 queued/blocked 之间抖动却没人真的在跑
expect(task.blocked_reason.startsWith(wdb.UPSTREAM_BLOCK_PREFIX)).toBe(false);
expect(nodeByName(parentRunId, 'org-planner').status).toBe('blocked');
});
it('F1-C 退得回队列时链路自愈:父 run 与 task 一起回 queued,节点照常复活', async () => {
const { companyId, parentRunId, taskId } = buildParentWithFailingChild({ withTask: true });
const blockedNode = await driveToBlocked(parentRunId);
expect(wdb.getTask(taskId).status).toBe('blocked');
const holderRunId = occupySlot(companyId);
await rescueChild(blockedNode.childRunId);
// 两行必须一起动:只动一半的两种死法见 requeueBlockedRootRun 的注释
expect(wdb.getRun(parentRunId).run_status).toBe('queued');
expect(wdb.getTask(taskId).status).toBe('queued');
expect(wdb.getTask(taskId).blocked_reason).toBeNull();
// 节点照常复活 —— 调度器把这条 run 出队时链路接着往下走,救援没白做
expect(nodeByName(parentRunId, 'org-planner').status).toBe('success');
// requeueBlockedRootRun 不走 transitionRun(它要把 task 那行包进同一个事务),
// 事件得由 admitOrRequeue 自己补。少了它就是「状态变了却没有事件」,控制台不会重拉快照。
const ev = timeline(parentRunId)
.find((l) => l.kind === 'run.status' && l.payload && l.payload.requeued === true);
expect(ev, '退回队列必须补一条 run.status 事件').toBeTruthy();
expect(ev.payload.to).toBe('queued');
expect(ev.payload.held_by).toEqual([holderRunId]);
// 「调度器把它出队时链路接着往下走」不能只写在注释里:坑一空就得真的收敛掉,
// 否则救回来的成果只是换了个姿势卡住(这次卡在 queued 上,界面上还显示「排队中」)。
sse.transitionRun(holderRunId, 'running', 'completed', {}, { summary: '用例直推终态,腾出坑' });
for (let i = 0; i < 40 && wdb.getRun(parentRunId).run_status === 'queued'; i += 1) {
await new Promise((r) => setTimeout(r, 10));
}
expect(wdb.getRun(parentRunId).run_status).toBe('completed');
expect(wdb.getTask(taskId).status).toBe('completed');
// 已经 success 的那个 subgraph 节点不许被重新点亮:那等于把刚救回来的子 run 再跑一遍
expect(nodeByName(parentRunId, 'org-planner').status).toBe('success');
});
// ────────────────────────────────────────────────────────────
// F1-B:completed + 有 task 的 run 走的是另一道闸
// ────────────────────────────────────────────────────────────
it('F1-B 对一条已跑完的任务点重试撞坑满 → 干净拒绝:节点、attempt、task 全都没动', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
const holderRunId = occupySlot(companyId);
// 一条正常收敛过的任务:run completed、task completed(`current_run_id` 指着它)
const task = wdb.createTask({ goalId, companyId, title: 'done', description: '跑完了的任务' });
const run = wdb.createRun({
graphKind: 'work', graphId: buildGraph(companyId, ['z1']), companyId, goalId, taskId: task.id,
});
const db = wdb.getDb();
db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(run.id);
wdb.updateTask(task.id, { status: 'completed', currentRunId: run.id });
const node = wdb.getRunState(run.id).nodes[0];
db.prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(node.id);
let caught = null;
try { engine.retryNode(run.id, node.id); } catch (err) { caught = err; }
expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
expect(caught.heldBy).toEqual([holderRunId]);
// completed 的 run 退不回队列(把终态任务塞回队列是批 4 的 rerun 语义,不是这里的事)
expect(wdb.getRun(run.id).run_status).toBe('completed');
expect(wdb.getTask(task.id).status).toBe('completed');
const after = wdb.getRunNode(node.id);
expect(after.status).toBe('fail');
expect(after.attempt).toBe(0);
});
// ────────────────────────────────────────────────────────────
// F2:白名单 + isUntouchedNode 的其余三个合取项
// ────────────────────────────────────────────────────────────
it('F2 白名单不是「非 running 就放行」:completed 的 run 同样一行不删', () => {
const companyId = nextCompany();
const graphId = buildGraph(companyId, ['c1', 'c2']);
const run = wdb.createRun({ graphKind: 'work', graphId, companyId });
const db = wdb.getDb();
db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(run.id);
db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId);
db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = 'c2'`).run(graphId);
expect(wdb.syncRunNodesToDef(run.id)).toBeNull();
expect(wdb.getRunState(run.id).nodes.length).toBe(2); // 历史 run 的节点行是审计轨迹,不许被改图删掉
});
it('F2 「没跑过」的判据是五个合取项:agent_run_id / child_run_id / output_ref 任一非空都留着', () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
const graphId = buildGraph(companyId, ['k0', 'k1', 'k2', 'k3']);
const task = wdb.createTask({ goalId, companyId, title: 'q', description: 'q', executionMode: 'auto' });
const run = wdb.createRun({
graphKind: 'work', graphId, companyId, goalId, taskId: task.id, runStatus: 'queued',
});
// 三种「跑过但被写回 pending」的痕迹各造一条(回退重置 / reopenChildRun 都会留下这种行)
const db = wdb.getDb();
// child_run_id 有外键,得指着一条真存在的子 run(queued 不占坑,不影响别的断言)
const childRun = wdb.createRun({
graphKind: 'work', graphId, companyId, runStatus: 'queued',
});
const nodes = Object.fromEntries(wdb.getRunState(run.id).nodes.map((n) => [n.name, n]));
db.prepare(`UPDATE graph_run_nodes SET status='pending', agent_run_id='ar_x' WHERE id=?`).run(nodes.k1.id);
db.prepare(`UPDATE graph_run_nodes SET status='pending', child_run_id=? WHERE id=?`).run(childRun.id, nodes.k2.id);
db.prepare(`UPDATE graph_run_nodes SET status='pending', output_ref='out/x.md' WHERE id=?`).run(nodes.k3.id);
// 排队期间「重新规划组织」:定义里只剩 k0
db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId);
db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ? AND node_name != 'k0'`).run(graphId);
const diff = wdb.syncRunNodesToDef(run.id);
expect(diff.removed).toEqual([]); // 一条都不许删
expect(diff.kept.sort()).toEqual([nodes.k1, nodes.k2, nodes.k3].map((n) => n.defNodeId).sort());
for (const n of [nodes.k1, nodes.k2, nodes.k3]) expect(wdb.getRunNode(n.id)).toBeTruthy();
});
// ────────────────────────────────────────────────────────────
// F3:sweep 的取值域
// ────────────────────────────────────────────────────────────
it('F3 一条 queued 都没有、只剩 blocked(upstream_failed:*) 的公司,sweep 仍要捡起来', () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
// 上游死了(进程重启前 A 就失败了),下游只剩这一条 blocked —— 队里空无一人
const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' });
wdb.updateTask(up.id, { status: 'failed' });
const down = wdb.createTask({ goalId, companyId, title: 'B', description: '下游', dependsOn: [up.id] });
wdb.updateTask(down.id, { status: 'blocked', blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` });
// 前提:旧口径(只扫 status='queued')下这家公司根本进不了扫描结果
const queued = wdb.listCompanyTasks(companyId, { status: 'queued' });
expect(queued.length).toBe(0);
expect(scheduler.sweep().map((r) => r.companyId)).toContain(companyId);
expect(wdb.getTask(down.id).status).toBe('blocked'); // 上游还是死的,这一轮不该动它
// 人把上游 rerun 好了 → 下一次 sweep 自己把下游放回队列(blocked 是推导结果,不落死)
wdb.updateTask(up.id, { status: 'completed' });
const results = scheduler.sweep();
expect(results.find((r) => r.companyId === companyId).requeued).toContain(down.id);
expect(wdb.getTask(down.id).status).toBe('queued');
expect(wdb.getTask(down.id).blocked_reason).toBeNull();
});
// ────────────────────────────────────────────────────────────
// 强制放行:数据层闸门 + 下一次 tick 真的起跑
// ────────────────────────────────────────────────────────────
it('强制放行:blocked 的 task 能改 depends_on,删掉死上游后下一次 tick 就起跑', async () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
// executor 永不 resolve:起跑之后 run 稳稳停在 running,断言不受异步驱动影响
engine.setAgentExecutor(() => new Promise((resolve) => {
pendingResolvers.push(() => resolve({ status: 'success' }));
}));
const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' });
wdb.updateTask(up.id, { status: 'cancelled' }); // 上游被人取消了,永远等不到 completed
const down = wdb.createTask({
goalId, companyId, title: 'B', description: '下游', executionMode: 'auto', dependsOn: [up.id],
});
const run = wdb.createRun({
graphKind: 'work', graphId: buildGraph(companyId), companyId, goalId, taskId: down.id, runStatus: 'queued',
});
wdb.updateTask(down.id, { status: 'queued', currentRunId: run.id, queuedAt: new Date().toISOString() });
scheduler.tick(companyId); // 依赖推导:queued → blocked
expect(wdb.getTask(down.id).status).toBe('blocked');
expect(wdb.getTask(down.id).blocked_reason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}`);
// 闸门放行 blocked:挡掉它,界面上那个「强制放行」按钮就必然 4xx,这条任务再无自救出路
expect(() => wdb.updateTask(down.id, { dependsOn: [] })).not.toThrow();
const res = scheduler.tick(companyId);
expect(res.requeued).toContain(down.id);
expect(res.started.map((s) => s.taskId)).toContain(down.id);
expect(wdb.getTask(down.id).status).toBe('running');
expect(wdb.getTask(down.id).blocked_reason).toBeNull();
expect(wdb.getRun(run.id).run_status).toBe('running');
// 收尾:放掉挂起的 executor 并等驱动真的收工。不等的话 beforeEach 会在它跑到一半时清库,
// 驱动随后打一行「graph_run_nodes 不存在」的异常 —— 与被测行为无关的噪声,别留给下一个人查。
for (const r of pendingResolvers.splice(0)) r();
await new Promise((r) => setTimeout(r, 60));
});
it('强制放行**不许**顺手清 blocked_reason:清了那条回队列的条件更新就永远命中不了', () => {
const companyId = nextCompany();
const goalId = newGoal(companyId);
const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' });
wdb.updateTask(up.id, { status: 'failed' });
const down = wdb.createTask({
goalId, companyId, title: 'B', description: '下游', executionMode: 'manual', dependsOn: [up.id],
});
wdb.updateTask(down.id, { status: 'blocked', blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` });
// 「改依赖 + 顺手把 reason 清成 NULL」——看着更干净,实际是死锁:
// requeueDerivedBlockedTask 的 WHERE 里有 blocked_reason LIKE 'upstream_failed:%'
wdb.updateTask(down.id, { dependsOn: [], blockedReason: null });
scheduler.tick(companyId);
expect(wdb.getTask(down.id).status).toBe('blocked'); // 卡死,且全程不报错
expect(wdb.requeueDerivedBlockedTask(down.id)).toBe(0);
// 反过来:reason 留着的那条就能被推回队列 —— 差别只在这一列
wdb.updateTask(down.id, { blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` });
scheduler.tick(companyId);
expect(wdb.getTask(down.id).status).toBe('queued');
});
});