agent-chat-session-reclaim.test.js
7.05 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
/**
* 锁的是 claude CLI 子进程泄漏 —— 实测 2h40m 攒了 82 个进程 / 17.5 GB。
*
* agent-chat 的 `sessions` Map 存的是**常驻**的 claude CLI 子进程(stream-json 模式,
* 不是一次性的 `claude -p`)。修之前只有四条回收路径:协议 error / 子进程 disconnected /
* 手动删对话 / 手动 stop —— 「正常跑完的对话」不在其中。而 heartbeat-service 每 5 分钟
* 用一个**全新** convId(前缀 heartbeat- 后面拼时间戳)打进来,于是每次心跳都新起一个进程且
* 永不释放。cto / coo 两个本体的 intervalMinutes 都是 5,正好对上观测到的「每 5 分钟涨 2-3 个」。
*
* 探针用现成的 `/chat/:convId/stop`:它只在 sessions 里查得到条目时才返回 success:true,
* 所以不用为测试往模块里加导出口。断言全是「会话还在不在」,不看 chat 自己的响应内容。
*/
// convId 一律用固定字面量(不拼时间戳):见 test-id-stability —— 测试写进 system/agents/ 的东西
// 都是正式条目,「每跑一次换一个 id」会变成注册表泄漏。固定之后第二次跑还顺带走了「复用已有对话」那条分支。
process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须早于 require:acp-core 的 mock 开关是模块级常量
process.env.AGENT_SESSION_IDLE_MS = '400'; // 同理,SESSION_IDLE_MS 是模块级常量
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import express from 'express';
import { createRequire } from 'node:module';
const require_ = createRequire(import.meta.url);
const ROOT = path.resolve(__dirname, '../../..');
const AGENT = 'zz-session-reclaim';
const BODY = path.join(ROOT, 'system', 'agents', AGENT);
/** 只清运行态。本体定义(config/identity/agent.md)留下,与 zz-tenant-* 同一约定:
* 测试建出来的 agent 也是正式注册表条目,建了又删会让并行的全量遍历用例撞上
* 「列到了名字、进去时已经没了」的 flaky(见 test-id-stability 的头注)。 */
function clearRuntimeState() {
for (const d of ['conversations', '_global', 'memory']) {
fs.rmSync(path.join(BODY, d), { recursive: true, force: true });
}
}
describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () => {
let base, srv;
beforeAll(async () => {
// 固定 convId 之后,上一次跑(尤其是中途失败那次)残留的 meta.json 会被 loadMeta 命中,
// 标题就不再重算 —— 断言会验到历史值。所以进来先清一遍运行态。
clearRuntimeState();
fs.mkdirSync(BODY, { recursive: true });
fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2));
fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`);
fs.writeFileSync(path.join(BODY, 'agent.md'), '# 会话回收单测用的常驻本体\n');
const app = express();
app.use(express.json());
app.use('/api/agent', require_('../../routes/agent-chat'));
await new Promise((r) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; r(); }); });
});
afterAll(async () => {
if (srv) await new Promise((r) => srv.close(r));
clearRuntimeState();
});
/** 跑完一轮对话(SSE 要读到流结束,否则 finally 还没执行) */
const chat = async (convId, extra = {}) => {
const res = await fetch(`${base}/api/agent/${AGENT}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'ping', conversationId: convId, ...extra }),
});
await res.text();
};
/** 探针:会话是否还留在池子里 */
const alive = async (convId) => {
const res = await fetch(`${base}/api/agent/${AGENT}/chat/${convId}/stop`, { method: 'POST' });
return (await res.json()).success === true;
};
/** 探针:对话的 meta(标题 / type) */
const metaOf = async (convId) =>
(await fetch(`${base}/api/agent/${AGENT}/conversations/${convId}`)).json();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* 上一版修复漏掉的就是这条路径,而且**旧的探针查不出来**:
* 修之前 catch 里走 dropSession,会话同样从池子里消失,所以 alive() 照样返回 false ——
* 黑盒探针对「摘出池子」和「杀掉子进程」一视同仁,两者的差别只在子进程死没死。
* 所以这里破例盯一次 disconnect:它是「进程被杀」唯一可观测的落点。
*/
it('跑挂的心跳同样要杀子进程(不是只摘出池子)', async () => {
const conv = 'heartbeat-failed';
const { ClaudeCliProtocol } = require_('@mas/acp-core');
// 现实里的失败长这样:CLI 子进程活得好好的,错的是它背后的 API 调用。
const send = vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt')
.mockRejectedValue(new Error('API Error: 529 Overloaded'));
const disconnect = vi.spyOn(ClaudeCliProtocol.prototype, 'disconnect');
try {
await chat(conv, { isHeartbeat: true });
expect(disconnect).toHaveBeenCalled(); // ← 修之前这里是红的
expect(await alive(conv)).toBe(false); // ← 修之前这里就是绿的,所以没拦住
} finally {
send.mockRestore();
disconnect.mockRestore();
}
});
it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => {
const conv = 'heartbeat-fixed';
await chat(conv, { isHeartbeat: true });
expect(await alive(conv)).toBe(false);
});
it('普通对话保留会话(复用能力不能被顺手砍掉)', async () => {
const conv = 'conv-keep';
await chat(conv);
expect(await alive(conv)).toBe(true);
});
it('普通对话空闲超过 TTL 后被回收', async () => {
const conv = 'conv-ttl';
await chat(conv);
await sleep(700); // > AGENT_SESSION_IDLE_MS
expect(await alive(conv)).toBe(false);
});
// ── kind:四类一次性调用被错标成心跳,且 pipeline 不能跟它们同策略 ──
it.each([
['heartbeat', '⏱️ 自主心跳巡检'],
['schedule', '📅 日程任务'],
['alert', '🚨 告警'],
['report', '📊 日报'],
['pipeline', '🔧 流水线步骤'],
])('kind=%s 的对话按自己的类型标注,不再一律显示成心跳', async (kind, title) => {
const conv = `k-${kind}`;
await chat(conv, { kind });
const meta = await metaOf(conv);
expect(meta.type).toBe(kind);
expect(meta.title).toBe(title);
});
it.each(['schedule', 'alert', 'report'])('kind=%s(convId 用完即弃)跑完立刻回收', async (kind) => {
const conv = `r-${kind}`;
await chat(conv, { kind });
expect(await alive(conv)).toBe(false);
});
it('kind=pipeline 保留会话 —— 被驳回重做会复用同一个 convId,冷启动要重注入历史', async () => {
const conv = 'pipeline-run1-step-0';
await chat(conv, { kind: 'pipeline' });
expect(await alive(conv)).toBe(true);
});
});