agent-manager-concurrent-scope.test.js 7.91 KB
/**
 * Unit Tests — 运行态坐标的**并发**安全 + registry 分支的隔离(28 号第二轮修复)
 *
 * 两个失败模式都不报错,所以断言全部落在「两次重叠调用算出来的东西相不相同」上:
 *
 *   1. `_applyRuntimeScope` 就地改写共享缓存对象:同一寻址名的两次调用只要在时间上重叠,
 *      后一次会把前一次的 taskId / workDir 覆盖掉 —— 前一次此刻还卡在 callAgent 的 await 上。
 *      结果是两次调用塌进同一个 cwd + 同一个 sessionKey:复用同一个长驻 CLI 子进程、
 *      上下文互串,两边照样成功返回。跨公司因缓存键含 cid 躲过,同公司跨任务必中。
 *   2. registry 分支在 `_applyRuntimeScope` 之前 return:companyId / taskId / companyDir /
 *      workDir 一个都不挂 → session key 恒为 `_global/_no-task`、cwd 退到仓库根、
 *      公司语境与 memory 一行不进提示词。生产里眼下没人传 options.registry,属于埋雷。
 *
 * 用真实的 system/agents/ 目录(agent-locator 的根是常量,没有 env 覆盖),所以本体是
 * 真的建在注册表里的:id 固定为 `zz-conc-agent`,**本体不删**(28 号裁决:测试建出来的
 * agent 也是正式注册表条目)。afterAll 只清公司态目录 —— 那才是这次跑留下的运行痕迹。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

const AgentManager = require('../../src/core/agent-manager');
const { SessionPool } = require('../../src/core/session-pool');
const locator = require('../../services/agent-locator');

describe('AgentManager 运行态坐标 — 并发不串台 / registry 不绕过隔离', () => {
  const AGENT_ID = 'zz-conc-agent'; // zz- 前缀 = 测试建的注册表条目,一眼可辨
  const BODY_DIR = locator.bodyDir(AGENT_ID);
  const CO = 'zz-conc-co';
  const CO_OTHER = 'zz-conc-co-2';
  const ADDR = `${CO}:${AGENT_ID}`;

  const newManager = (options) => {
    const m = new AgentManager({}, locator.REPO_ROOT, options);
    // 日志 / hook 是 callAgent 里与本用例无关的两段 await,桩掉即可
    m._resolveLogSession = async () => null;
    m.hookRunner = { run: async () => {} };
    return m;
  };

  beforeAll(() => {
    // 内容每次重写,保证跨次跑不漂;三份定义写齐 = 注册表里是一份完整本体
    fs.mkdirSync(BODY_DIR, { recursive: true });
    fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# 共享本体\n', 'utf-8');
    fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# zz-conc-agent 的人格\n\n单测用的常驻本体。\n', 'utf-8');
    fs.writeFileSync(path.join(BODY_DIR, 'config.json'),
      JSON.stringify({ id: AGENT_ID, name: AGENT_ID, type: 'skill', status: 'active' }, null, 2), 'utf-8');
  });

  afterAll(() => {
    // 只清公司态,本体留在注册表里
    for (const cid of [CO, CO_OTHER]) {
      fs.rmSync(locator.companyDir(AGENT_ID, cid), { recursive: true, force: true });
    }
  });

  it('第二次解析不能改掉第一次已经拿到的 agent 对象', () => {
    const manager = newManager();
    const first = manager.getAgentConfig(ADDR, { metadata: { taskId: 'task-1' } }).agent;
    const firstWorkDir = first.workDir;

    const second = manager.getAgentConfig(ADDR, { metadata: { taskId: 'task-2' } }).agent;

    expect(first).not.toBe(second);                 // 两次调用不能拿到同一个对象
    expect(first.workDir).toBe(firstWorkDir);       // 第一次的 cwd 不被第二次冲掉
    expect(first.taskId).toBe('task-1');
    expect(second.taskId).toBe('task-2');
    expect(second.workDir).toBe(locator.taskDir(AGENT_ID, CO, 'task-2'));
  });

  it('两次交错调用的 sessionKey 不同(key 撞上 = 复用同一个 CLI 子进程)', () => {
    const manager = newManager();
    const pool = new SessionPool();

    const a = manager.getAgentConfig(ADDR, { metadata: { taskId: 'task-1' } }).agent;
    const b = manager.getAgentConfig(ADDR, { metadata: { taskId: 'task-2' } }).agent;

    expect(pool.getKey(ADDR, a, a.workDir)).not.toBe(pool.getKey(ADDR, b, b.workDir));
  });

  it('两次 callAgent 真并发:各自的 cwd / sessionKey / 提示词坐标互不干扰', async () => {
    const manager = newManager();
    const pool = new SessionPool();
    const seen = [];
    const promptAgents = [];

    // 提示词也必须按**本次调用**的 agent 拼:按名字回查共享 config 拿到的是
    // 「最后一次解析」的坐标,并发时属于另一个任务。
    manager.buildFullPrompt = (name, task, agent) => {
      promptAgents.push(agent);
      return task;
    };
    manager._runViaProtocol = async (name, agent) => {
      // 多让一个 tick:模拟 pre-task hook / logAgentStart 期间另一次调用挤进来
      await new Promise((r) => setTimeout(r, 0));
      seen.push({
        taskId: agent.taskId,
        workDir: agent.workDir,
        key: pool.getKey(name, agent, agent.workDir),
      });
      return { agent: name, output: '', status: 'success' };
    };

    await Promise.all([
      manager.callAgent(ADDR, 'T1', { metadata: { taskId: 'conc-1' } }),
      manager.callAgent(ADDR, 'T2', { metadata: { taskId: 'conc-2' } }),
    ]);

    const byTask = Object.fromEntries(seen.map((s) => [s.taskId, s]));
    expect(Object.keys(byTask).sort()).toEqual(['conc-1', 'conc-2']);
    expect(byTask['conc-1'].workDir).toBe(locator.taskDir(AGENT_ID, CO, 'conc-1'));
    expect(byTask['conc-2'].workDir).toBe(locator.taskDir(AGENT_ID, CO, 'conc-2'));
    expect(byTask['conc-1'].key).not.toBe(byTask['conc-2'].key);

    // 每次调用拿到的坐标要和它自己的 taskId 对得上
    expect(promptAgents.map((a) => a?.workDir).sort()).toEqual(
      [locator.taskDir(AGENT_ID, CO, 'conc-1'), locator.taskDir(AGENT_ID, CO, 'conc-2')].sort()
    );
  });

  it('registry 命中:companyDir / companyContextPath / workDir / taskId 照样挂上', () => {
    const registryAgent = {
      type: 'orchestrator',
      protocol: 'claude-cli',
      client: 'claude-local',
      prompt: path.join(BODY_DIR, 'agent.md'),
    };
    const manager = newManager({
      registry: { on() {}, getAgent: () => ({ agent: registryAgent, client: { url: 'local' } }) },
    });

    const { agent } = manager.getAgentConfig(ADDR, { metadata: { taskId: 'reg-task' } });

    expect(agent.agentId).toBe(AGENT_ID);
    expect(agent.companyId).toBe(CO);
    expect(agent.taskId).toBe('reg-task');
    expect(agent.companyDir).toBe(locator.companyDir(AGENT_ID, CO));
    expect(agent.companyContextPath).toBe(path.join(locator.companyDir(AGENT_ID, CO), 'company-context.md'));
    expect(agent.workDir).toBe(locator.taskDir(AGENT_ID, CO, 'reg-task'));
    expect(fs.existsSync(agent.workDir)).toBe(true);

    // registry 的 _agents[name] 是进程级共享缓存,只读不写
    expect(registryAgent.workDir).toBeUndefined();
    expect(registryAgent.taskId).toBeUndefined();

    // 换一家公司必须换一个 session key(原来 registry 分支的 runtime 段恒是 `_global/_no-task`,
    // 两家公司复用同一个 CLI 会话、上下文互串且不报错)
    const otherAddr = `${CO_OTHER}:${AGENT_ID}`;
    const other = manager.getAgentConfig(otherAddr, { metadata: { taskId: 'reg-task' } }).agent;
    const pool = new SessionPool();
    expect(pool.getKey(ADDR, agent, '/same')).not.toBe(pool.getKey(ADDR, other, '/same'));
  });

  it('registry 里自带 workDir 的配置保留自己的 cwd,但仍挂 (companyId, taskId)', () => {
    const pinned = { type: 'executor', client: 'claude-local', workDir: '/tmp/pinned-agent' };
    const manager = newManager({
      registry: { on() {}, getAgent: () => ({ agent: pinned, client: { url: 'local' } }) },
    });

    const { agent } = manager.getAgentConfig(ADDR, { metadata: { taskId: 'reg-task' } });

    expect(agent.workDir).toBe('/tmp/pinned-agent');
    expect(agent.companyId).toBe(CO);
    expect(agent.taskId).toBe('reg-task');
  });
});