diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index 18451b4..04ab0b7 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -2772,8 +2772,12 @@ ${resultsText.join('\n\n')} meta.error = err.message; saveMeta(agentId, convId, meta); - // 清理断开的 session - dropSession(`${agentId}:${convId}`); + // 出错的会话不留给下一轮复用 —— 但必须**连子进程一起杀**。 + // 这里原本是 dropSession(只摘出池子、不杀进程),而 finally 里的 closeSession + // 查不到条目就直接返回 false:子进程于是活着、却再没有任何引用指向它。 + // 而且错的通常是 API(529 / DNS / 证书),CLI 子进程好端端的,一个都不会自己退。 + // 实测 23 小时漏 38 个心跳进程,且失败的心跳**无一例外**是这条路径(status 全是 failed)。 + closeSession(`${agentId}:${convId}`, '会话出错'); sendSSE('error', { message: err.message }); console.log(`[SSE-DEBUG] 🚨 sendSSE('error') called:`, err.message.substring(0, 200)); diff --git a/server/tests/unit/agent-chat-session-reclaim.test.js b/server/tests/unit/agent-chat-session-reclaim.test.js index a5b7394..c98b75f 100644 --- a/server/tests/unit/agent-chat-session-reclaim.test.js +++ b/server/tests/unit/agent-chat-session-reclaim.test.js @@ -15,7 +15,7 @@ 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 } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; import express from 'express'; @@ -80,6 +80,30 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = 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 });