/** * 锁的是 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); }); });