/** * Agent 级并发互斥锁的共享存储(32 号文档方案 B)。 * * 从 `routes/agent-chat.js` 抽出来:那边的 `POST /:agentId/chat` 路由用一个进程内 * `Map` 挡「同一个 agent 同时只允许一个活跃会话」,`company.js` 的招聘装配重试逻辑 * (`services/agent-body-filler.js`)现在也要用到同一个判据——如果两条路径各自维护 * 一份锁,各自都以为自己没有并发,实际上会同时往同一个 `SessionPool` 会话发两轮请求。 * `SessionPool.getOrCreate()`(`session-pool.js:160`)只看连接在不在,不看上一次调用有没有 * 跑完,两条路径不共享锁的话,这个坑照样在,只是换了个触发组合。 * * 纯数据结构,不含业务逻辑——acquire/release/超时判断仍然各自在调用方写,行为跟 * agent-chat.js 原来内联声明时逐字一致,这次只是换了个文件。 */ const agentChatLocks = new Map(); // agentId → { convId, startedAt } const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀,与原实现一致 /** * 40 号文档「打断-排队」用:中止一个 turn 之后,轮询等这把锁真正被清掉 * (即原请求的 handler 走到 finally),而不是前端凭感觉睡一个固定时长。 * 查不到锁本身也算"已清空"——用于 stop 端点在锁已经因为别的原因释放时仍能返回 cleaned:true。 */ function waitForLockRelease(agentId, budgetMs = 3000, pollMs = 100) { return new Promise((resolve) => { const deadline = Date.now() + budgetMs; const check = () => { if (!agentChatLocks.has(agentId)) return resolve(true); if (Date.now() >= deadline) return resolve(false); setTimeout(check, pollMs); }; check(); }); } /** * 判断某个 agent 此刻是不是真被占着(僵尸锁——占用超过 AGENT_LOCK_TIMEOUT——不算占用)。 * 占用中返回锁本身(供调用方取 convId/startedAt 用于提示),否则返回 null。 * 供路由层在真正排队前先 peek 一眼,决定要不要提前给前端发"排队中"信号。 */ function isLockBusy(agentId) { const existing = agentChatLocks.get(agentId); if (!existing) return null; if (Date.now() - existing.startedAt >= AGENT_LOCK_TIMEOUT) return null; return existing; } /** * 排队等待版加锁:锁被占用时不立刻让调用方判定失败,而是按 pollMs 轮询, * 在 budgetMs 窗口内锁一空出来就同步 check-and-set(中间没有 await, * 多个等待者不会同时都以为自己抢到),谁先摸到谁进;budget 耗尽仍抢不到才 * 报"没抢到",调用方按原来的 429 逻辑处理。 * 没人占、或占用已超过 AGENT_LOCK_TIMEOUT(视为僵尸锁)都算"没被占",直接抢。 */ function acquireLockWithWait(agentId, convId, budgetMs, pollMs = 200) { return new Promise((resolve) => { const deadline = Date.now() + budgetMs; const tryAcquire = () => { const existing = agentChatLocks.get(agentId); const busy = isLockBusy(agentId); if (!busy) { agentChatLocks.set(agentId, { convId, startedAt: Date.now() }); return resolve({ acquired: true, staleLock: existing && !busy ? existing : null }); } if (Date.now() >= deadline) return resolve({ acquired: false, lock: busy }); setTimeout(tryAcquire, pollMs); }; tryAcquire(); }); } module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease, acquireLockWithWait, isLockBusy };