graph-engine-orphan-sweep.test.js
6.55 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
/**
* Unit Tests — graph-engine 的开机孤儿节点清扫(42 号文档 Phase 1a)
*
* 场景:服务器进程被重启(比如换代码生效),正在跑某个 work graph 节点的真实 Claude CLI
* 子进程被连带杀掉,但 `graph_run_nodes.status` 永远停在 'running'——不会自己变成 'blocked',
* ceo 的两条唤醒路径都只认 'blocked',对这个状态完全不可见。
*
* `sweepOrphanedRunningNodes(cutoffIso)` 扫一遍 `started_at` 早于 cutoff 的 running 节点,
* 判定为孤儿,走 infra_flake 既有的原地重跑/熔断逻辑(不发明新状态)。
*
* 隔离手法与 graph-engine.test.js 一致:独立 GRAPH_DB_PATH + GRAPH_RUNS_DIR,真实
* data/graph.sqlite / data/companies/ 全程不被碰到。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('graph-engine 开机孤儿节点清扫', () => {
let wdb;
let engine;
let runsDir;
const TEST_DB_PATH = '/tmp/test-graph-engine-orphan-sweep.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-engine-orphan-sweep-'));
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');
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 = () => `co-orphan-${++seq}`;
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);
}
function buildWorkRun({ companyId, nodes, edges }) {
ensureAgents(nodes);
const g = wdb.createWorkGraph({ companyId, nodes, edges });
expect(g.errors || []).toEqual([]);
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
engine.markRunReady(run.id);
return { runId: run.id };
}
const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name);
/** 把某个节点直接摁成「孤儿」的静止态:status=running + 指定的 started_at,不走任何转换。
* 这就是真实场景本身——进程被杀时节点行不会经过任何转换,就是停在这个样子。 */
function markAsOrphan(runId, nodeName, startedAtIso) {
const node = nodeByName(runId, nodeName);
wdb.getDb()
.prepare(`UPDATE graph_run_nodes SET status = 'running', started_at = ?, updated_at = ? WHERE id = ?`)
.run(startedAtIso, startedAtIso, node.id);
return node.id;
}
it('started_at 早于 cutoff 的 running 节点被判定为孤儿,走 infra_flake 原地重跑', () => {
const companyId = nextCompany();
const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
expect(result.scanned).toBe(1);
expect(result.swept).toHaveLength(1);
expect(result.swept[0]).toMatchObject({ runId, infraRerun: true });
// infra_flake 的原地重跑:转回 ready,不烧 attempt(§四.D.2.1 末段既有语义)
const fresh = nodeByName(runId, 'w');
expect(fresh.status).toBe('ready');
expect(fresh.attempt).toBe(0);
// 不写回退记录、不加 run 级 backward_count —— infra_flake 分支的既有保证,孤儿清扫复用
// 的是同一条分支,这两条也该原样成立
expect(wdb.listLoopRecords(runId)).toHaveLength(0);
expect(wdb.getRun(runId).backward_count).toBe(0);
});
it('started_at 晚于 cutoff 的 running 节点不受影响(真的还在跑,不是孤儿)', () => {
const companyId = nextCompany();
const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
markAsOrphan(runId, 'w', '2026-06-01T00:00:00.000Z');
const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
expect(result.scanned).toBe(0);
expect(result.swept).toHaveLength(0);
expect(nodeByName(runId, 'w').status).toBe('running');
});
it('连续判定为孤儿超过 infra_flake 硬顶就转 blocked,不会无限原地重跑', () => {
const companyId = nextCompany();
const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
// 连续三次「摁成孤儿再扫」,模拟每次重启后又立刻被杀的极端情形
for (let i = 0; i < 3; i++) {
markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
}
// 第四次撞硬顶(INFRA_FLAKE_HARD_CAP = 3),应改判 blocked 而不是继续 ready
markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
expect(nodeByName(runId, 'w').status).toBe('blocked');
});
it('没有任何孤儿节点时是安静的 no-op', () => {
const companyId = nextCompany();
buildWorkRun({ companyId, nodes: ['w'], edges: [] }); // 节点是 ready,不是 running
const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
expect(result).toEqual({ scanned: 0, swept: [] });
});
});