/** * 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'); }); });