agent-chat-session-reclaim.test.js 4.02 KB
/**
 * 锁的是 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-${Date.now()}`)打进来,于是每次心跳都新起一个进程且
 * 永不释放。cto / coo 两个本体的 intervalMinutes 都是 5,正好对上观测到的「每 5 分钟涨 2-3 个」。
 *
 * 探针用现成的 `/chat/:convId/stop`:它只在 sessions 里查得到条目时才返回 success:true,
 * 所以不用为测试往模块里加导出口。断言全是「会话还在不在」,不看 chat 自己的响应内容。
 */
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 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);

describe('agent-chat · 会话回收(心跳即时回收 + 空闲 TTL)', () => {
  let base, srv;

  beforeAll(async () => {
    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));
    // 本体定义(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 });
  });

  /** 跑完一轮对话(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;
  };

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  it('心跳会话跑完立刻回收(这是泄漏的正主)', async () => {
    const conv = `heartbeat-${Date.now().toString(36)}`;
    await chat(conv, { isHeartbeat: true });
    expect(await alive(conv)).toBe(false);
  });

  it('普通对话保留会话(复用能力不能被顺手砍掉)', async () => {
    const conv = `conv-keep-${Date.now().toString(36)}`;
    await chat(conv);
    expect(await alive(conv)).toBe(true);
  });

  it('普通对话空闲超过 TTL 后被回收', async () => {
    const conv = `conv-ttl-${Date.now().toString(36)}`;
    await chat(conv);
    await sleep(700); // > AGENT_SESSION_IDLE_MS
    expect(await alive(conv)).toBe(false);
  });
});