diff --git a/.env.example b/.env.example index 27af89b..b3f2cac 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,11 @@ export WHATSAPP_TRIGGER=@Andy export WHATSAPP_BOT_NAME=Andy export WHATSAPP_SELF_CHAT=true +# agent-chat 会话池:空闲多久回收一个常驻 claude CLI 子进程(毫秒,默认 30 分钟) +# 调小能更快释放内存,代价是多轮对话更容易冷启动重注入历史。 +# 一次性调用(心跳 / 日程 / 告警 / 日报)不受这个值影响,它们跑完即回收。 +# AGENT_SESSION_IDLE_MS=1800000 + # Open API(外部接入) # 逗号分隔的 API Key 列表 OPEN_API_KEYS=sk-your-key-1,sk-your-key-2 diff --git a/CLAUDE.md b/CLAUDE.md index fc45756..de3726f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,12 @@ auto-dev "" # bin 入口 → server/src/cli/auto-dev.js - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。 - `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。 - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。 +- **agent-chat 的 `sessions` 池存的是常驻 claude CLI 子进程**(stream-json 模式,不是一次性的 `claude -p`)。 + 新增一条会创建会话的调用路径时,必须同时想清楚它的回收路径 —— 判据只有一条:**convId 会不会被下一轮复用**。 + 不复用的(心跳 / 日程 / 告警 / 日报)在 `CALL_KINDS` 里标 `true`,跑完即回收;会复用的(pipeline 被驳回重做、 + 人类多轮对话)标 `false`,靠空闲 TTL 兜底(`AGENT_SESSION_IDLE_MS`,默认 30 分钟)。 + 漏标的症状是**静默的**:不报错、不写日志、`git status` 干净,只有内存一路涨(曾经 2h43m 漏出 82 个进程 / 17.5 GB)。 + 守卫:`server/tests/unit/agent-chat-session-reclaim.test.js`。 - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。 - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。 - DevLoop 详见上方「DevLoop 子系统」一节;需要 wireframe / SVG 图表 / 完整人类阅读版时才回查 `_spec/*.md` 同名的 `*.html`。 diff --git a/docs/todo/system-design/28-followups.md b/docs/todo/system-design/28-followups.md index e3ee9c5..bf88761 100644 --- a/docs/todo/system-design/28-followups.md +++ b/docs/todo/system-design/28-followups.md @@ -95,9 +95,12 @@ ## 五、已经立起来的守卫(别重复造,也别删) -这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1)。它们守的都是「同一个事实只能有一处持有者」, -而且都带**「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以「零违例」的形式全绿, -一条永远通过的不变式测试和没有这条测试是一回事。 +这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1,第五条出自 CLI 进程泄漏那一轮)。 +**前四条**守的都是「同一个事实只能有一处持有者」,而且都带**「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以 +「零违例」的形式全绿,一条永远通过的不变式测试和没有这条测试是一回事。 + +第五条形状不同:它守的不是「持有者唯一」而是**资源生命周期**(每一条创建路径都要有对应的回收路径), +判据也不靠扫源码,而是把回收行为当黑盒去探。列在一起是因为「别重复造,也别删」这条规矩对它同样适用。 | 测试 | 守什么 | 违例时 | |------|--------|--------| @@ -105,12 +108,23 @@ | `no-stale-templates-path.test.js` | 活定义里不许出现已死的 `system/templates`(判据:git 跟踪的才是定义,未跟踪的是运行态产物) | 同上 | | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 | | `runs-root-single-holder.test.js` | 凡是把 `process.env.GRAPH_RUNS_DIR` 绑到标识符上的模块,那个标识符必须在**代码里**(不是注释里)真正被用到 | 精确点名到文件行号 | +| `agent-chat-session-reclaim.test.js` | agent-chat 的 `sessions` 池:一次性 convId 的调用跑完必须回收,可复用的(pipeline / 人类对话)必须留下并受 TTL 兜底;顺带锁 `CALL_KINDS` 的标注 | 探针 `/chat/:convId/stop` 报告会话还在 | > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`, > 注释白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量(测试隔离在用)」,**全文件却零引用**。 > 一个声明了意图、代码却不兑现的常量,比没有这个常量更容易骗人 —— 读的人会据此认为该模块认这个覆盖。 > 它同时也是「实现在、零调用方」这个惯犯模式的又一例(本仓库有人数到第五次,有人写「十几次」)。 -**改动它们之前先想清楚**:这三条的价值全在于「红了才有用」。 +> 第五条的来历同样值得记:`sessions` 池存的是**常驻**的 claude CLI 子进程,回收路径只有四条 +> (协议 error / 子进程 disconnected / 手动删对话 / 手动 stop),**「正常跑完」不在其中**。 +> 两路心跳又都用一次性 convId 打进来,于是每 5 分钟漏一个进程,实测单次运行 2h43m 攒到 +> **82 个进程 / 17.5 GB RSS**。它不报错、不写日志、`git status` 干净,唯一的症状是机器变慢 —— +> 和第四条一样属于「读代码读不出来、要去看运行态才发现」的一类。 +> +> 回收策略的判据写在 `agent-chat.js` 的 `CALL_KINDS` 表里,只有一条:**convId 会不会被下一轮复用**。 +> 会复用的(pipeline 被驳回重做、人类多轮对话)留着走 TTL,不复用的跑完即回收。 +> 不要按「是不是后台调用」来分 —— 五类后台调用里 pipeline 就是要留的那一类。 + +**改动它们之前先想清楚**:这几条的价值全在于「红了才有用」。 放宽判据、加例外、或者把断言改成永远成立的形式,等于把守卫拆掉 —— 本仓库有过「隔离是表演」的测试污染前科。 diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index 53d9713..18451b4 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -772,6 +772,31 @@ function closeSession(key, reason) { } /** + * 后台调用的种类 → [对话标题, meta.type, 会话是否一次性] + * + * 在此之前后台五条调用(心跳巡检 / Pipeline 步骤 / 日程 / 告警 / 日报)无差别打 `isHeartbeat:true`, + * 于是对话列表里全都显示成「⏱️ 自主心跳巡检」—— 四类被错标成第五类。 + * + * 「一次性」那一列不是标签,是**回收策略**,判据只有一条:convId 会不会被下一轮复用。 + * - 心跳 / 日程 / 告警 / 日报:convId 带时间戳,用完即弃 → 跑完立刻回收 + * - Pipeline 步骤:convId 是 `pipeline-{runId}-step-{i}`,**被驳回重做时会复用同一个**, + * 留着会话能让重试直接接上下文(否则要走 buildPrompt 场景 C 冷启动重注入历史)。 + * 它不会变成泄漏 —— 有空闲 TTL 兜底。 + */ +const CALL_KINDS = { + heartbeat: ['⏱️ 自主心跳巡检', 'heartbeat', true], + pipeline: ['🔧 流水线步骤', 'pipeline', false], + schedule: ['📅 日程任务', 'schedule', true], + alert: ['🚨 告警', 'alert', true], + report: ['📊 日报', 'report', true], +}; + +/** 没带 kind 的老调用方(heartbeat-service 只发 isHeartbeat)按心跳算,行为不变 */ +function resolveKind(body) { + return CALL_KINDS[body.kind] || (body.isHeartbeat ? CALL_KINDS.heartbeat : null); +} + +/** * 请求结束后给会话上弦;请求开始时由 getOrCreateProtocol 停表。 * 计时器只在「没有在途请求」的间隙走,所以再长的单次生成也不会被 TTL 打断 * —— 这比在流式回调里 touch() 少一处需要维护的调用点。 @@ -2451,14 +2476,16 @@ router.post('/:agentId/chat', async (req, res) => { const sendSSE = setupSSE(res); const identity = loadIdentity(agentDir); + const kind = resolveKind(req.body); + // 确保 meta.json 存在 let meta = loadMeta(agentId, convId); if (!meta) { meta = { conversationId: convId, agentId, - title: req.body.isHeartbeat ? '⏱️ 自主心跳巡检' : message.slice(0, 50), - type: req.body.isHeartbeat ? 'heartbeat' : 'chat', + title: kind ? kind[0] : message.slice(0, 50), + type: kind ? kind[1] : 'chat', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: 'running', @@ -2753,10 +2780,9 @@ ${resultsText.join('\n\n')} } finally { agentChatLocks.delete(agentId); res.end(); - // 心跳会话是一次性的(heartbeat-service 每次都生成新 convId),留在池子里就是纯泄漏。 - // 普通对话保留会话复用能力,只上空闲 TTL。 + // convId 用完即弃的那几类立刻回收(留着就是纯泄漏);其余保留会话复用能力,只上空闲 TTL。 const sessionKey = `${agentId}:${convId}`; - if (req.body.isHeartbeat) closeSession(sessionKey, '心跳结束'); + if (kind?.[2]) closeSession(sessionKey, `${kind[1]} 结束`); else armSessionIdle(sessionKey); } }); diff --git a/server/services/company-heartbeat-service.js b/server/services/company-heartbeat-service.js index 8afaf74..f08bf27 100644 --- a/server/services/company-heartbeat-service.js +++ b/server/services/company-heartbeat-service.js @@ -256,7 +256,7 @@ class CompanyHeartbeatService { ].join('\n'); // 通过 HTTP 触发 Agent Chat (异步,不阻塞心跳) - this._sendAgentChat(companyId, task.agent, taskMessage, `pipeline-${runMeta.runId}-step-${task.stepIndex}`); + this._sendAgentChat(companyId, task.agent, taskMessage, `pipeline-${runMeta.runId}-step-${task.stepIndex}`, 'pipeline'); } } @@ -278,7 +278,7 @@ class CompanyHeartbeatService { `5. 无事则回复 "[心跳正常]"`, ].join('\n'); - this._sendAgentChat(companyId, ceoId, msg, `heartbeat-${Date.now().toString(36)}`); + this._sendAgentChat(companyId, ceoId, msg, `heartbeat-${Date.now().toString(36)}`, 'heartbeat'); } // ═══════════════════════════════════════ @@ -304,7 +304,7 @@ class CompanyHeartbeatService { ].join('\n'); console.log(`[Schedule] 📅 ${companyId} 触发日程: ${slot.time} → ${slot.agent} (${slot.task})`); - this._sendAgentChat(companyId, agentId, msg, `schedule-${slot.id}-${Date.now().toString(36)}`); + this._sendAgentChat(companyId, agentId, msg, `schedule-${slot.id}-${Date.now().toString(36)}`, 'schedule'); } } @@ -329,7 +329,7 @@ class CompanyHeartbeatService { ].join('\n'); // 发给 Master Agent - this._sendAgentChat(null, 'master', msg, `alert-${companyId}-${Date.now().toString(36)}`); + this._sendAgentChat(null, 'master', msg, `alert-${companyId}-${Date.now().toString(36)}`, 'alert'); console.log(`[Alert] 🚨 已向 Master 发送告警: ${companyId} - ${alertMessage.substring(0, 80)}`); } @@ -376,7 +376,7 @@ class CompanyHeartbeatService { `请查看以上日报。如需调整策略或设定新目标,请下发指令给 CEO。`, ].join('\n'); - this._sendAgentChat(null, 'master', msg, `daily-report-${companyId}-${today}`); + this._sendAgentChat(null, 'master', msg, `daily-report-${companyId}-${today}`, 'report'); console.log(`[DailyReport] 📊 ${companyId} 晨会日报已发送给 Master`); } @@ -467,12 +467,17 @@ class CompanyHeartbeatService { /** * 通过 HTTP 向 Agent 发送消息 (异步, 不阻塞) * companyId 为 null 时发给全局 Agent (如 master) + * + * kind 决定对方那边的对话标题 / meta.type / 会话回收策略(见 agent-chat.CALL_KINDS)。 + * 之前这里给五类调用无差别打 isHeartbeat:true,对话列表里全成了「自主心跳巡检」。 + * isHeartbeat 仍然发,但只对真心跳为真 —— 老的消费方(如果还有)行为不变。 */ - _sendAgentChat(companyId, agentId, message, conversationId) { + _sendAgentChat(companyId, agentId, message, conversationId, kind = 'heartbeat') { const postData = JSON.stringify({ message, conversationId, - isHeartbeat: true, + kind, + isHeartbeat: kind === 'heartbeat', }); const options = { diff --git a/server/tests/unit/agent-chat-session-reclaim.test.js b/server/tests/unit/agent-chat-session-reclaim.test.js index 1345b39..a5b7394 100644 --- a/server/tests/unit/agent-chat-session-reclaim.test.js +++ b/server/tests/unit/agent-chat-session-reclaim.test.js @@ -4,12 +4,14 @@ * agent-chat 的 `sessions` Map 存的是**常驻**的 claude CLI 子进程(stream-json 模式, * 不是一次性的 `claude -p`)。修之前只有四条回收路径:协议 error / 子进程 disconnected / * 手动删对话 / 手动 stop —— 「正常跑完的对话」不在其中。而 heartbeat-service 每 5 分钟 - * 用一个**全新** convId(`heartbeat-${Date.now()}`)打进来,于是每次心跳都新起一个进程且 + * 用一个**全新** 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 是模块级常量 @@ -24,10 +26,22 @@ 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`); @@ -41,9 +55,7 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = afterAll(async () => { if (srv) await new Promise((r) => srv.close(r)); - // 本体定义(config/identity/agent.md)留下,与 zz-tenant-* 同一约定:测试建出来的 agent 也是 - // 注册表条目,建了又删会让并行用例撞上「列到了名字、进去时已经没了」的 flaky。只清运行态。 - for (const d of ['conversations', '_global', 'memory']) fs.rmSync(path.join(BODY, d), { recursive: true, force: true }); + clearRuntimeState(); }); /** 跑完一轮对话(SSE 要读到流结束,否则 finally 还没执行) */ @@ -62,24 +74,56 @@ describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () = 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)); it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => { - const conv = `heartbeat-${Date.now().toString(36)}`; + const conv = 'heartbeat-fixed'; await chat(conv, { isHeartbeat: true }); expect(await alive(conv)).toBe(false); }); it('普通对话保留会话(复用能力不能被顺手砍掉)', async () => { - const conv = `conv-keep-${Date.now().toString(36)}`; + const conv = 'conv-keep'; await chat(conv); expect(await alive(conv)).toBe(true); }); it('普通对话空闲超过 TTL 后被回收', async () => { - const conv = `conv-ttl-${Date.now().toString(36)}`; + 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); + }); });