budget-wallclock-active.test.js
10 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
/**
* Unit Tests — 墙钟预算只算「真的在跑」的时间,不算挂起等人的时间
*
* ── 挡的是什么 ─────────────────────────────────────────────────────────
* 原实现按 `now - spent.started_at` 算,而 started_at 是「第一次花钱的绝对时刻」。
* 这与 blocked 的语义直接对撞:预算耗尽、节点判罚都会把 run 打到 blocked,
* 而 blocked 的定义就是**挂起等人工**。人隔夜再来处置 →「等待」被算成「已用」→
* 一起跑就报 wallclock_ms 耗尽 → 再 blocked → 再等人。**过夜的 run 永远醒不过来。**
*
* 而且报出来的原因具有误导性:写着 `wallclock_ms`(跑太久),实际是等人等太久。
*
* 真事:run_9168bd24f46c(e2e-demo 做官网)2026-08-18 15:48 起跑、16:19 判 blocked,
* 只花了 7/100 次调用、69,961/5,000,000 tokens,两天后仍卡死 —— 用掉的预算不到 2%。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-wallclock-'));
process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');
const engine = require('../../services/graph-engine');
const wdb = require('../../services/work-graph-db');
const HOUR = 3600_000;
const budget = (spent) => JSON.stringify({ wallclock_ms: 2 * HOUR, total_agent_calls: 100, spent });
/** 直接落一行 root run,只为拿一个带 budget_json 的载体(不走图定义那一整套) */
function seedRun(id, spentObj, status = 'running') {
const db = wdb.getDb();
const t = '2026-08-18T15:48:00.000Z';
db.prepare(`INSERT INTO graph_runs
(id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at)
VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`)
.run(id, id, status, budget(spentObj), t, t);
return wdb.getRun(id);
}
describe('预算 · 墙钟只算活跃时长', () => {
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run());
it('挂起期间墙钟不走:两天前 blocked 的 run,余额照旧', () => {
// 这一条就是 run_9168bd24f46c 的形态:active_ms 只有 31 分钟,started_at 已断开
const run = seedRun('r_old', { agent_calls: 7, tokens: 69961, active_ms: 31 * 60_000, started_at: null }, 'blocked');
const gate = engine.checkBudget(run);
expect(gate.exhausted).toEqual([]);
expect(gate.ok).toBe(true);
});
it('旧算法下这条 run 必然耗尽 —— 证明这个测试真的在量东西', () => {
// 反例对照:起点是两天前的绝对时刻、没有 active_ms(存量行的原始形态)
const spent = { agent_calls: 7, tokens: 69961, started_at: '2026-08-18T15:48:17.630Z' };
const legacyElapsed = Date.now() - Date.parse(spent.started_at);
expect(legacyElapsed).toBeGreaterThan(2 * HOUR); // 旧判据:早就超了
// 新判据看的是 active_ms,started_at 断开后就不再累加
expect(engine.wallclockElapsedMs({ ...spent, started_at: null, active_ms: 31 * 60_000 })).toBeLessThan(2 * HOUR);
});
it('活跃段分次累加:三段各 40 分钟 → 第三段跑到一半就该耗尽', () => {
expect(engine.wallclockElapsedMs({ active_ms: 80 * 60_000, started_at: null })).toBe(80 * 60_000);
const run = seedRun('r_acc', { active_ms: 119 * 60_000, started_at: null }, 'blocked');
expect(engine.checkBudget(run).ok).toBe(true);
wdb.getDb().prepare('UPDATE graph_runs SET budget_json = ? WHERE id = ?')
.run(budget({ active_ms: 121 * 60_000, started_at: null }), 'r_acc');
expect(engine.checkBudget(wdb.getRun('r_acc')).exhausted).toEqual(['wallclock_ms']);
});
it('settleWallclock:进 running 开始计时,离开 running 把这一段并进 active_ms', () => {
seedRun('r_settle', { active_ms: 5 * 60_000, started_at: null }, 'running');
const started = engine.settleWallclock('r_settle', 'running');
expect(started.started_at).toBeTruthy();
expect(started.active_ms).toBe(5 * 60_000);
// 生产里转换先落库、钩子后跑,所以这里也得先把状态改掉 —— 不改的话
// treeHasRunningRun 仍看到 running,结算被正确地跳过(那是另一条用例在验的行为)
wdb.getDb().prepare(`UPDATE graph_runs SET run_status='blocked' WHERE id='r_settle'`).run();
const stopped = engine.settleWallclock('r_settle', 'blocked');
expect(stopped.started_at).toBeNull();
expect(stopped.active_ms).toBeGreaterThanOrEqual(5 * 60_000); // 只增不减
expect(stopped.active_ms).toBeLessThan(6 * 60_000); // 这一段是毫秒级,不该凭空多出一分钟
});
it('存量行(没有 active_ms)进 running 时按 0 起算,不把旧锚点当活跃时长', () => {
// 旧锚点里混着等待时间、复原不出来 —— 一笔勾销是有意的:真正兜住花钱的是调用次数与 tokens
seedRun('r_legacy', { agent_calls: 7, started_at: '2026-08-18T15:48:17.630Z' }, 'running');
const s = engine.settleWallclock('r_legacy', 'running');
expect(s.active_ms).toBe(0);
expect(Date.parse(s.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z'));
expect(engine.checkBudget(wdb.getRun('r_legacy')).ok).toBe(true);
});
/**
* 这一组是**真跑撞出来的**:第一版只认 root run 的转换。
* 而人工重试一个 work 层节点动的是**子 run**(blocked → running),root 全程 blocked ——
* 于是子 run 跑整场、墙钟一次都没开始计,旧锚点还留着,第一次 checkBudget 立刻判耗尽。
* 单测当时全绿:它们只喂了 root。
*/
const seedChild = (id, rootId, status) => {
wdb.getDb().prepare(`INSERT INTO graph_runs
(id, graph_kind, graph_id, company_id, root_run_id, parent_run_id, execution_mode, run_status, budget_json, created_at, updated_at)
VALUES (?,'work','wg1','co1',?,?,'manual',?, NULL, ?, ?)`)
.run(id, rootId, rootId, status, '2026-08-18T15:49:00.000Z', '2026-08-18T15:49:00.000Z');
};
it('子 run 进 running 也开始计时:预算行仍写 root,但触发的是子 run 的转换', () => {
seedRun('r_root', { active_ms: 0, started_at: null }, 'blocked'); // root 一直 blocked
seedChild('r_child', 'r_root', 'running');
const s = engine.settleWallclock('r_child', 'running');
expect(s).not.toBeNull();
expect(s.started_at).toBeTruthy();
expect(JSON.parse(wdb.getRun('r_root').budget_json).spent.started_at).toBeTruthy();
});
it('存量行由子 run 的转换重锚 —— 那条卡了两天的 run 就是这个形态', () => {
seedRun('r_root2', { agent_calls: 7, tokens: 69961, started_at: '2026-08-18T15:48:17.630Z' }, 'blocked');
seedChild('r_child2', 'r_root2', 'running');
engine.settleWallclock('r_child2', 'running');
const spent = JSON.parse(wdb.getRun('r_root2').budget_json).spent;
expect(spent.active_ms).toBe(0);
expect(Date.parse(spent.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z'));
expect(engine.checkBudget(wdb.getRun('r_child2')).ok).toBe(true); // 子 run 查的也是 root 的池子
});
it('一层停了不结算 —— 树里还有别的 run 在跑,计时不能被提前掐断', () => {
seedRun('r_root3', { active_ms: 60_000, started_at: '2026-08-20T00:00:00.000Z' }, 'running');
seedChild('r_child3', 'r_root3', 'blocked');
expect(engine.settleWallclock('r_child3', 'blocked')).toBeNull();
expect(JSON.parse(wdb.getRun('r_root3').budget_json).spent.started_at).toBe('2026-08-20T00:00:00.000Z');
// root 也停下来,树里再没有 running → 这才结算
wdb.getDb().prepare(`UPDATE graph_runs SET run_status='blocked' WHERE id='r_root3'`).run();
const s = engine.settleWallclock('r_root3', 'blocked');
expect(s.started_at).toBeNull();
expect(s.active_ms).toBeGreaterThan(60_000);
});
it('树里已经有 run 在跑时,另一条进 running 不重锚(否则前一段被抹掉)', () => {
seedRun('r_root4', { active_ms: 0, started_at: '2026-08-20T00:00:00.000Z' }, 'running');
seedChild('r_child4', 'r_root4', 'running');
expect(engine.settleWallclock('r_child4', 'running')).toBeNull();
expect(JSON.parse(wdb.getRun('r_root4').budget_json).spent.started_at).toBe('2026-08-20T00:00:00.000Z');
});
it('挂在漏斗上:走 sse.transitionRun 就会结算,不需要调用方记得调', () => {
// 这一条守的是钩子的**位置**:进 running 既不是收敛、也不空坑,
// 放在 afterRunTransition 那个 early return 后面就变成「只结算停下来、从不开始计时」——
// 症状是 active_ms 永远 0,墙钟形同虚设,而且照样全绿。
const sse = require('../../services/graph-run-sse');
seedRun('r_funnel', { active_ms: 0, started_at: null }, 'ready');
expect(sse.transitionRun('r_funnel', 'ready', 'running')).toBeTruthy();
expect(JSON.parse(wdb.getRun('r_funnel').budget_json).spent.started_at).toBeTruthy();
expect(sse.transitionRun('r_funnel', 'running', 'blocked')).toBeTruthy();
const spent = JSON.parse(wdb.getRun('r_funnel').budget_json).spent;
expect(spent.started_at).toBeNull();
expect(spent.active_ms).toBeGreaterThanOrEqual(0);
});
it('没配 wallclock_ms 的 run:结算是 no-op,不许凭空造出 active_ms 字段', () => {
const db = wdb.getDb();
db.prepare(`INSERT INTO graph_runs
(id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at)
VALUES ('r_nowc','org','g1','co1','r_nowc','manual','running', ?, ?, ?)`)
.run(JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } }), '2026-08-18T15:48:00.000Z', '2026-08-18T15:48:00.000Z');
expect(engine.settleWallclock('r_nowc', 'blocked')).toBeNull();
expect(JSON.parse(wdb.getRun('r_nowc').budget_json).spent).toEqual({ agent_calls: 1 });
});
});