graph-run-route-slot-busy.test.js
6.6 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
/**
* Unit Test — RunSlotBusyError 在 `routes/graph-run.js` 里翻成 409(27-P3C 批 3 对抗复核)
*
* 批 3 之后「公司的执行坑被占着」是**正常的业务拒绝**,不是服务器错误:换个时间同一个请求
* 会成功。而 `sendError` 此前只认 `InvalidTransitionError`,其余一律 500 + 一句原文 ——
* 调用方(前端、外部 agent 的 curl)看到 500 只会去重试或报故障,而这里唯一有用的动作是
* 「等占坑那条收敛」或「去停掉它」。所以这条测试钉三件事,少一件这个 409 就等于没修:
* 1. 状态码是 409,不是 500;
* 2. 体里点得出**谁**占着坑(run id + task id)—— 少了它界面上只剩「坑满」,人无从判断等谁;
* 3. hint 指向的那条路此刻真的走得通(占坑的是 running → /stop 可用,所以要出现它)。
*
* 走**真入口**:router 挂真 express、listen(0) 再 fetch(同 graph-run-route-io.test.js),
* 不导出内部函数、不打桩 res.json —— sendError 是私有的,只有从 HTTP 这一头才测得到。
*
* 为什么用「对一条裸 run 的失败节点点重试」这个场景:`engine.startRun` 对**有 task 的** run
* 撞上坑满时会退回队列(不抛),只有回不了队列的裸 run 才会把 RunSlotBusyError 抛到路由层
* (见 graph-run-slot-recovery.test.js 的两条)。这就是 409 在生产里唯一到得了的路径。
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const express = require('express');
const TEST_DB_PATH = '/tmp/test-graph-run-slot-busy.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
'../../services/graph-db',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/company-paths',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../services/task-settle',
'../../services/task-scheduler',
'../../routes/graph-run',
];
const CID = 'slotbusy-co';
describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => {
let wdb;
let server;
let origin;
let runsDir;
let holderRunId;
let holderTaskId;
let deadRunId;
let deadNodeId;
beforeAll(async () => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
// 不设的话任何走到默认路径的写入都会往仓库 data/ 里堆
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slotbusy-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of MODULES) delete require.cache[require.resolve(m)];
wdb = require('../../services/work-graph-db');
const db = wdb.getDb();
const now = new Date().toISOString();
const insAgent = 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 ['solo', 'solo2']) insAgent.run(id, id, `system/agents/${id}`, now, now);
const graphA = wdb.createWorkGraph({ companyId: CID, nodes: ['solo'], edges: [] });
const graphB = wdb.createWorkGraph({ companyId: CID, nodes: ['solo2'], edges: [] });
expect(graphA.errors || []).toEqual([]);
expect(graphB.errors || []).toEqual([]);
// ① 占坑的那条:一条有 task 的 root run,直推 running(用例不需要真跑 agent)
const goal = wdb.createGoal({ companyId: CID, title: 'g', description: 'g' });
const task = wdb.createTask({ goalId: goal.id, companyId: CID, title: 't', description: 't' });
holderTaskId = task.id;
const holder = wdb.createRun({
graphKind: 'work', graphId: graphA.id, companyId: CID, goalId: goal.id, taskId: task.id,
});
holderRunId = holder.id;
db.prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(holderRunId);
// ② 被挡的那条:**没有 task** 的已收敛 run(裸 run 退不回队列,所以错误会一路抛到路由层)
const dead = wdb.createRun({ graphKind: 'work', graphId: graphB.id, companyId: CID });
deadRunId = dead.id;
db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(deadRunId);
deadNodeId = wdb.getRunState(deadRunId).nodes[0].id;
db.prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(deadNodeId);
const app = express();
app.use(express.json());
app.use('/api/graph-runs', require('../../routes/graph-run'));
await new Promise((resolve) => { server = app.listen(0, resolve); });
origin = `http://127.0.0.1:${server.address().port}`;
});
afterAll(() => {
if (server) server.close();
try { require('../../services/task-scheduler').stopSweeper(); } catch { /* 没起过就算了 */ }
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
try { fs.rmSync(runsDir, { 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)];
});
it('坑满 → 409 + 点名谁占着坑 + 一条此刻真走得通的下一步', async () => {
const r = await fetch(`${origin}/api/graph-runs/${deadRunId}/nodes/${deadNodeId}/retry`, { method: 'POST' });
const body = await r.json();
expect(r.status).toBe(409); // 修之前:500 + 一句 "company … 的执行坑已满" 原文
expect(body.reason).toBe('run-slot-busy');
expect(body.companyId).toBe(CID);
// 批 4 起额度是 per-company 的(company.json.max_parallel_tasks,这家没配 → 缺省 1)。
// 钉的是「409 里报的额度 = 准入真的用的那个额度」,不是钉某个常量。
expect(body.max).toBe(wdb.maxActiveRootRuns(CID));
expect(body.max).toBe(wdb.DEFAULT_MAX_ACTIVE_ROOT_RUNS);
// 「挡我的是谁」:run id 与 task id 都要给得出来,否则界面上只剩「坑满」
expect(body.holders).toEqual([
expect.objectContaining({ runId: holderRunId, taskId: holderTaskId, runStatus: 'running' }),
]);
// hint 必须指向一条**此刻真的存在**的行为:占坑那条是 running,/stop 的可用条件里有它
expect(body.hint).toContain(`/api/graph-runs/${holderRunId}/stop`);
});
it('坑满不该动到节点:这次拒绝是干净的(半生效比失败更难查)', () => {
const node = wdb.getRunNode(deadNodeId);
expect(node.status).toBe('fail');
expect(node.attempt).toBe(0);
expect(wdb.getRun(deadRunId).run_status).toBe('completed');
});
});