Commit 6a18379a784f645c4231978973b02996d1c40d85
1 parent
b9695c9c
fix(hire): 招聘装配改走同进程调用 + 共享互斥锁 + 有限重试(32 号文档方案 B)
company.js 的 hire 路由原来用一发 fetch(...).catch(...) 回环自己去触发 agent-creator 填新角色的定义文件——fire-and-forget,.catch 只接网络层错误,agent-creator 撞车时 (另一次调用正占着它)/api/agent/:agentId/chat 返回 429,被原样当成"成功"吞掉。 新角色因此永久停在 base 空壳 + status=training,唯一痕迹是一行 console.error(且这行 在 429 场景下根本不会触发)。 新增两个模块: - agent-session-lock.js:从 agent-chat.js 抽出的共享互斥锁存储(纯数据结构,Map + 超时常量),agent-chat.js 的路由与 hire 路由现在用同一把锁。不能各起一把—— SessionPool.getOrCreate() 不检查上一次调用有没有跑完,两条路径分开维护锁的话, 各自都以为自己没有并发,会把第二轮 prompt 发进一个还在处理中的 CLI 会话 (未定义行为,比现在的"被拒绝丢弃"更危险,见 32 号文档 §二方案 B)。 - agent-body-filler.js:同进程直接调 AgentManager.callAgent,替掉 HTTP 回环。 拿到锁才发起调用,锁被占用时等一等重试(最多 3 次,5s/15s 退避),重试耗尽在 本体目录写一个哨兵文件 .body_fill_failed.json(不动 system_agents.status / config.json.status——那两个字段还有 draft→active 毕业逻辑在读,改取值域风险 大于收益)。 company.js 的 hire 路由第 1 步从 fire-and-forget 换成 await,顺带让第 2/3 步 (技能注入、5 秒后触发 Innovator 训练)拿到"agent-creator 真的写完了"这个保证—— 第 3 步原来的 setTimeout(5000) 靠的是"假设 5 秒内写完",现在这个假设不再需要, 但没有顺手删掉那个 setTimeout(不在这次修复范围内,留着无害)。hire 接口本身 仍是异步的,调用方还是立即拿到 training 状态,没有改成同步等待完成。 agent-chat.js 的锁声明改成从共享模块 require,行为逐字不变——用现有 5 个 agent-chat 相关测试文件(36 条)验证过零回归。 新增 agent-body-filler.test.js(6 条):首次成功、重试后成功、锁被占用时等待 重试、重试耗尽写失败标记、成功后清掉上一轮的失败标记、只释放自己抢到的锁 (不误删第三方在途期间抢到的新锁)。用 vi.useFakeTimers() 控制重试等待, 不用真等 5s/15s。 全量 npx vitest run:151 文件 / 1384 用例,零回归。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
5 changed files
with
330 additions
and
23 deletions
server/routes/agent-chat.js
| ... | ... | @@ -2493,8 +2493,9 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { |
| 2493 | 2493 | |
| 2494 | 2494 | // 🔒 Agent 级并发互斥锁 — 同一 Agent 同时只允许一个活跃 chat session |
| 2495 | 2495 | // 同一 convId 的多轮对话不受限制(ACP 进程复用) |
| 2496 | -const agentChatLocks = new Map(); // agentId → { convId, startedAt } | |
| 2497 | -const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀 | |
| 2496 | +// 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配 | |
| 2497 | +// 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。 | |
| 2498 | +const { agentChatLocks, AGENT_LOCK_TIMEOUT } = require('../src/core/agent-session-lock'); | |
| 2498 | 2499 | |
| 2499 | 2500 | router.post('/:agentId/chat', async (req, res) => { |
| 2500 | 2501 | const { message, conversationId: reqConvId } = req.body; | ... | ... |
server/routes/company.js
| ... | ... | @@ -13,6 +13,10 @@ const { ScheduleService } = require('../services/schedule-service'); |
| 13 | 13 | const orgGraphService = require('../services/org-graph-service'); |
| 14 | 14 | // 28 批次 4:公司不再持有 agent 实体 —— 名册查 company_agents,目录一律经 agent-locator 推导 |
| 15 | 15 | const locator = require('../services/agent-locator'); |
| 16 | +// 32 号文档方案 B:招聘装配(agent-creator 填 agent.md/soul.md)改走同进程调用 + 互斥锁 + | |
| 17 | +// 重试,不再是裸 fetch 回环自己。 | |
| 18 | +const AgentManager = require('../src/core/agent-manager'); | |
| 19 | +const { fillAgentBodyReliably } = require('../services/agent-body-filler'); | |
| 16 | 20 | |
| 17 | 21 | const companyService = new CompanyService(); |
| 18 | 22 | const scheduleService = new ScheduleService(); |
| ... | ... | @@ -1002,27 +1006,33 @@ router.post('/:companyId/hire', async (req, res) => { |
| 1002 | 1006 | const bodyPath = locator.bodyDir(role); |
| 1003 | 1007 | const PORT = config.port || 3000; |
| 1004 | 1008 | |
| 1005 | - // 1. 发起 Agent-Creator 编写骨架定义 (异步不等待) | |
| 1006 | - // ⚠️ 给的是**绝对路径**:agent-creator 的 cwd 现在是它自己的任务态目录 | |
| 1007 | - // (system/agents/agent-creator/_global/{taskId}/),原来那个 `../{公司}/agents/{角色}` | |
| 1008 | - // 相对路径从那里数出去指向的是一个不存在的位置 —— 它会照着写,且不报错。 | |
| 1009 | - fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, { | |
| 1010 | - method: 'POST', | |
| 1011 | - headers: { 'Content-Type': 'application/json' }, | |
| 1012 | - body: JSON.stringify({ | |
| 1013 | - // 产出契约(28 批次 5-23)逐字点名三个必填文件:本体填不满就停在 | |
| 1014 | - // status='draft'、别家公司雇不走。以前只写「补充完整的定义文件(…等)」, | |
| 1015 | - // 「等」里那份 SKILL.md 谁也没保证过 —— org-coder 就是这么变成空定义的。 | |
| 1016 | - // 契约文本(含运行期布局那半段)单点定义在 company-service,见其注释。 | |
| 1017 | - message: buildAgentDefinitionBrief({ | |
| 1018 | - agentId: role, | |
| 1019 | - bodyPath, | |
| 1020 | - description: description || name || role, | |
| 1021 | - intro: `新员工 ${namespacedId} 已由脚手架创建空白目录在 \`${bodyPath}\`。请你按照标准模板,使用 Write 工具为该员工补充定义文件。写完后请答复。`, | |
| 1022 | - }), | |
| 1023 | - conversationId: `create-${req.params.companyId}-${role}-${Date.now().toString(36)}` | |
| 1024 | - }) | |
| 1025 | - }).catch(e => console.error('[Hire] Agent-Creator 请求错误:', e.message)); | |
| 1009 | + // 1. 发起 Agent-Creator 编写骨架定义(32 号文档方案 B:同进程调用 + 互斥锁 + | |
| 1010 | + // 有限重试,替掉原来那发裸 fetch。原注释里"异步不等待"已经不准确—— | |
| 1011 | + // 这里 await 了,下面第 2/3 步因此天然拿到"agent-creator 真的写完了"这个 | |
| 1012 | + // 保证,不再是"assume 5 秒内写完"(第 3 步 setTimeout(5000) 那条注释)。 | |
| 1013 | + // ⚠️ 给的是**绝对路径**:agent-creator 的 cwd 是它自己的任务态目录 | |
| 1014 | + // (system/agents/agent-creator/_global/{taskId}/),相对路径从那里数出去 | |
| 1015 | + // 指向的是一个不存在的位置 —— 它会照着写,且不报错。 | |
| 1016 | + const fillResult = await fillAgentBodyReliably({ | |
| 1017 | + agentManager: new AgentManager({}, REPO_ROOT), | |
| 1018 | + targetAgentId: role, | |
| 1019 | + // 产出契约(28 批次 5-23)逐字点名三个必填文件:本体填不满就停在 | |
| 1020 | + // status='draft'、别家公司雇不走。以前只写「补充完整的定义文件(…等)」, | |
| 1021 | + // 「等」里那份 SKILL.md 谁也没保证过 —— org-coder 就是这么变成空定义的。 | |
| 1022 | + // 契约文本(含运行期布局那半段)单点定义在 company-service,见其注释。 | |
| 1023 | + message: buildAgentDefinitionBrief({ | |
| 1024 | + agentId: role, | |
| 1025 | + bodyPath, | |
| 1026 | + description: description || name || role, | |
| 1027 | + intro: `新员工 ${namespacedId} 已由脚手架创建空白目录在 \`${bodyPath}\`。请你按照标准模板,使用 Write 工具为该员工补充定义文件。写完后请答复。`, | |
| 1028 | + }), | |
| 1029 | + }); | |
| 1030 | + if (!fillResult.ok) { | |
| 1031 | + // 重试耗尽——不是把招聘流程一起判失败(目录/skill 注入照常做,参见 32 号文档 | |
| 1032 | + // §三「不做保证自动跑到底」),只是把这个失败从"一行日志"变成可查询的哨兵文件 | |
| 1033 | + // (agent-body-filler.js 已经写好),这里再喊一嗓子留个显眼的日志。 | |
| 1034 | + console.error(`[Hire] ⚠️ agent-creator 装配 ${role} 失败(重试 ${fillResult.attempts} 次):${fillResult.error}`); | |
| 1035 | + } | |
| 1026 | 1036 | |
| 1027 | 1037 | // 2. 去网上/Arena搜索并注入 3 个基础 Skill。 |
| 1028 | 1038 | // 落 `.claude/skills/` 而不是 `skills/`:只有前者会被 CLI(和 agent-manager | ... | ... |
server/services/agent-body-filler.js
0 → 100644
| 1 | +/** | |
| 2 | + * 招聘后同进程调 agent-creator 填自己的定义文件——带互斥锁 + 有限重试 + 失败落地。 | |
| 3 | + * | |
| 4 | + * 32 号文档方案 B 的落地。修的是 `company.js` hire 路由原来那发 | |
| 5 | + * `fetch('http://127.0.0.1:${PORT}/api/agent/agent-creator/chat', ...).catch(...)`: | |
| 6 | + * fire-and-forget,`.catch` 只接网络层错误,不检查响应状态码;agent-creator 是单会话, | |
| 7 | + * 撞车时 `/api/agent/:agentId/chat` 返回 429,被原样当成"成功"吞掉——新招的角色永久停在 | |
| 8 | + * base 空壳 + `status=training`,唯一痕迹是一行 console.error(撞车走的是 429 正常响应, | |
| 9 | + * 连这行都不会触发)。 | |
| 10 | + * | |
| 11 | + * 这里改成同进程直接调 `AgentManager.callAgent`,绕开那次多余的 HTTP 回环,换来: | |
| 12 | + * - 真实的调用结果/异常,而不是网络层 `.catch`; | |
| 13 | + * - 撞车时**等一等再试**(有限次数 + 退避),不是当场丢弃; | |
| 14 | + * - 重试耗尽后在本体目录写一个哨兵文件,而不是一行日志——不动 | |
| 15 | + * `system_agents.status`/`config.json.status`(那两个字段还有 draft→active 的 | |
| 16 | + * 毕业逻辑在读,改它们的取值域风险和收益不对等,见 32 号文档 §四.3)。 | |
| 17 | + * | |
| 18 | + * ⚠️ 互斥锁复用的是 `agent-session-lock.js`,跟 `routes/agent-chat.js` 的 | |
| 19 | + * `POST /:agentId/chat` 共享同一个 Map——不能各起一把。原因见该模块头注: | |
| 20 | + * `SessionPool.getOrCreate()` 不检查上一次调用有没有跑完,两条路径各自以为自己 | |
| 21 | + * 没有并发的话,会把第二轮 prompt 发进一个还在处理中的 CLI 会话,比现在的 | |
| 22 | + * "被拒绝丢弃"更危险(协议层内容串台,未定义行为)。 | |
| 23 | + */ | |
| 24 | +const fs = require('fs'); | |
| 25 | +const path = require('path'); | |
| 26 | +const locator = require('./agent-locator'); | |
| 27 | +const { agentChatLocks, AGENT_LOCK_TIMEOUT } = require('../src/core/agent-session-lock'); | |
| 28 | + | |
| 29 | +const MAX_ATTEMPTS = 3; | |
| 30 | +// 重试之间的等待——不是精算出来的数字,是"给撞车的那一方一点时间跑完"的量级; | |
| 31 | +// 真正的硬顶是 MAX_ATTEMPTS,不是靠拉长等待时间去覆盖更长的撞车窗口。 | |
| 32 | +const RETRY_DELAY_MS = [5_000, 15_000]; | |
| 33 | + | |
| 34 | +const FAILURE_MARKER_NAME = '.body_fill_failed.json'; | |
| 35 | + | |
| 36 | +function sleep(ms) { | |
| 37 | + return new Promise((resolve) => { setTimeout(resolve, ms); }); | |
| 38 | +} | |
| 39 | + | |
| 40 | +function failureMarkerPath(bodyDir) { | |
| 41 | + return path.join(bodyDir, FAILURE_MARKER_NAME); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** 重试耗尽后落地——**必须能被查询/扫描到**,这是 32 号文档方案 B 的唯一硬要求。 */ | |
| 45 | +function writeFailureMarker(bodyDir, info) { | |
| 46 | + try { | |
| 47 | + fs.writeFileSync(failureMarkerPath(bodyDir), JSON.stringify({ | |
| 48 | + ...info, | |
| 49 | + failedAt: new Date().toISOString(), | |
| 50 | + }, null, 2)); | |
| 51 | + } catch (e) { | |
| 52 | + // 连哨兵文件都写不出去(目录权限/磁盘满)——这一步再失败也不能让调用方炸, | |
| 53 | + // 招聘流程本身(scaffoldNewAgent 已经建好目录)不该被这个次要写入拖下水。 | |
| 54 | + console.error(`[BodyFill] ⚠️ 写失败标记本身也失败了: ${e.message}`); | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** 成功之后必须清掉上一轮可能留下的失败标记——不然人工重试成功了,哨兵文件还在骗人。 */ | |
| 59 | +function clearFailureMarker(bodyDir) { | |
| 60 | + try { | |
| 61 | + fs.unlinkSync(failureMarkerPath(bodyDir)); | |
| 62 | + } catch { | |
| 63 | + // 本来就没有这个文件(多数情况)——不是错误 | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * @param {object} a | |
| 69 | + * @param {object} a.agentManager `AgentManager` 实例(调用方传入,不在这里 new—— | |
| 70 | + * 测试时可以注入假实现,不用真起 Claude CLI) | |
| 71 | + * @param {string} a.targetAgentId 要填定义的那个新招角色 id(== 本体目录名) | |
| 72 | + * @param {string} a.message 发给 agent-creator 的完整 brief(调用方已经拼好, | |
| 73 | + * 这个函数不关心内容,只关心「发出去、等结果、失败重试」) | |
| 74 | + * @returns {Promise<{ok: boolean, attempts: number, error?: string}>} | |
| 75 | + */ | |
| 76 | +async function fillAgentBodyReliably({ agentManager, targetAgentId, message }) { | |
| 77 | + const bodyDir = locator.bodyDir(targetAgentId); | |
| 78 | + let lastError = null; | |
| 79 | + | |
| 80 | + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | |
| 81 | + const existingLock = agentChatLocks.get('agent-creator'); | |
| 82 | + if (existingLock && Date.now() - existingLock.startedAt < AGENT_LOCK_TIMEOUT) { | |
| 83 | + // 忙——等一等,不占位、不硬抢、不当场放弃。这是原来那发 fetch 唯一缺的东西。 | |
| 84 | + console.log(`[BodyFill] 🔒 agent-creator 正忙(conv ${existingLock.convId}),` + | |
| 85 | + `第 ${attempt}/${MAX_ATTEMPTS} 次尝试改为等待`); | |
| 86 | + lastError = new Error(`agent-creator 正忙(活跃会话 ${existingLock.convId})`); | |
| 87 | + if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS[attempt - 1] ?? RETRY_DELAY_MS.at(-1)); | |
| 88 | + continue; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const convId = `body-fill-${targetAgentId}-a${attempt}-${Date.now().toString(36)}`; | |
| 92 | + agentChatLocks.set('agent-creator', { convId, startedAt: Date.now() }); | |
| 93 | + try { | |
| 94 | + await agentManager.callAgent('agent-creator', message, { | |
| 95 | + metadata: { taskId: convId }, | |
| 96 | + }); | |
| 97 | + clearFailureMarker(bodyDir); | |
| 98 | + return { ok: true, attempts: attempt }; | |
| 99 | + } catch (err) { | |
| 100 | + lastError = err; | |
| 101 | + console.error(`[BodyFill] ⚠️ agent-creator 第 ${attempt}/${MAX_ATTEMPTS} 次调用失败: ${err.message}`); | |
| 102 | + if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS[attempt - 1] ?? RETRY_DELAY_MS.at(-1)); | |
| 103 | + } finally { | |
| 104 | + // 只释放"自己那一把"——万一在途期间锁已经被别的机制清过又被第三方占了, | |
| 105 | + // 不能凭空把第三方的锁摘掉(同 agent-chat.js releaseLock 的判据)。 | |
| 106 | + const cur = agentChatLocks.get('agent-creator'); | |
| 107 | + if (cur && cur.convId === convId) agentChatLocks.delete('agent-creator'); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + writeFailureMarker(bodyDir, { | |
| 112 | + targetAgentId, | |
| 113 | + attempts: MAX_ATTEMPTS, | |
| 114 | + lastError: lastError ? lastError.message : null, | |
| 115 | + }); | |
| 116 | + return { ok: false, attempts: MAX_ATTEMPTS, error: lastError ? lastError.message : null }; | |
| 117 | +} | |
| 118 | + | |
| 119 | +module.exports = { fillAgentBodyReliably, FAILURE_MARKER_NAME }; | ... | ... |
server/src/core/agent-session-lock.js
0 → 100644
| 1 | +/** | |
| 2 | + * Agent 级并发互斥锁的共享存储(32 号文档方案 B)。 | |
| 3 | + * | |
| 4 | + * 从 `routes/agent-chat.js` 抽出来:那边的 `POST /:agentId/chat` 路由用一个进程内 | |
| 5 | + * `Map` 挡「同一个 agent 同时只允许一个活跃会话」,`company.js` 的招聘装配重试逻辑 | |
| 6 | + * (`services/agent-body-filler.js`)现在也要用到同一个判据——如果两条路径各自维护 | |
| 7 | + * 一份锁,各自都以为自己没有并发,实际上会同时往同一个 `SessionPool` 会话发两轮请求。 | |
| 8 | + * `SessionPool.getOrCreate()`(`session-pool.js:160`)只看连接在不在,不看上一次调用有没有 | |
| 9 | + * 跑完,两条路径不共享锁的话,这个坑照样在,只是换了个触发组合。 | |
| 10 | + * | |
| 11 | + * 纯数据结构,不含业务逻辑——acquire/release/超时判断仍然各自在调用方写,行为跟 | |
| 12 | + * agent-chat.js 原来内联声明时逐字一致,这次只是换了个文件。 | |
| 13 | + */ | |
| 14 | +const agentChatLocks = new Map(); // agentId → { convId, startedAt } | |
| 15 | +const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀,与原实现一致 | |
| 16 | + | |
| 17 | +module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT }; | ... | ... |
server/tests/unit/agent-body-filler.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — agent-body-filler.js(32 号文档方案 B 的落地) | |
| 3 | + * | |
| 4 | + * 锁的是:撞车时等一等重试(不是当场丢弃)、重试耗尽落地为可查询的哨兵文件(不是一行 | |
| 5 | + * 日志)、成功后清掉上一轮可能留下的失败标记、互斥锁只释放自己那一把(不误删别人的)、 | |
| 6 | + * 以及最要紧的一条——这把锁跟 `agent-chat.js` 的 `POST /:agentId/chat` 共享同一个 Map | |
| 7 | + * (用真实 `agent-session-lock.js` 模块,不 mock 它,直接验证两边操作的是同一个对象)。 | |
| 8 | + * | |
| 9 | + * 用 vi.useFakeTimers() 控制重试之间的等待——RETRY_DELAY_MS 是 5s/15s 量级,真等出来 | |
| 10 | + * 这个测试文件就要跑 20+ 秒。 | |
| 11 | + * | |
| 12 | + * locator.bodyDir() 没有 env 覆盖点,跟 identity-context.test.js / | |
| 13 | + * describe-role-definition-truncation.test.js 同款手法:起一个 zz- 前缀的真实临时本体 | |
| 14 | + * 目录,afterEach 清理不留痕迹。 | |
| 15 | + */ | |
| 16 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | |
| 17 | +import fs from 'fs'; | |
| 18 | +import path from 'path'; | |
| 19 | + | |
| 20 | +const REPO_ROOT = path.resolve(__dirname, '../../..'); | |
| 21 | +const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents'); | |
| 22 | + | |
| 23 | +describe('agent-body-filler.fillAgentBodyReliably', () => { | |
| 24 | + const targetAgentId = 'zz-test-body-filler-target'; | |
| 25 | + const bodyDir = path.join(AGENTS_ROOT, targetAgentId); | |
| 26 | + const markerPath = path.join(bodyDir, '.body_fill_failed.json'); | |
| 27 | + | |
| 28 | + let fillAgentBodyReliably; | |
| 29 | + let agentChatLocks; | |
| 30 | + | |
| 31 | + beforeEach(() => { | |
| 32 | + fs.mkdirSync(bodyDir, { recursive: true }); | |
| 33 | + vi.useFakeTimers(); | |
| 34 | + | |
| 35 | + delete require.cache[require.resolve('../../src/core/agent-session-lock.js')]; | |
| 36 | + delete require.cache[require.resolve('../../services/agent-body-filler.js')]; | |
| 37 | + ({ agentChatLocks } = require('../../src/core/agent-session-lock.js')); | |
| 38 | + ({ fillAgentBodyReliably } = require('../../services/agent-body-filler.js')); | |
| 39 | + agentChatLocks.clear(); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + afterEach(() => { | |
| 43 | + vi.useRealTimers(); | |
| 44 | + agentChatLocks.clear(); | |
| 45 | + fs.rmSync(bodyDir, { recursive: true, force: true }); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + /** 跑 fillAgentBodyReliably 的同时把假定时器一路推走,让内部的 sleep() 立刻"过去" */ | |
| 49 | + async function runWithTimers(promise) { | |
| 50 | + let settled = false; | |
| 51 | + promise.finally(() => { settled = true; }); | |
| 52 | + for (let i = 0; i < 10 && !settled; i++) { | |
| 53 | + await vi.advanceTimersByTimeAsync(20_000); | |
| 54 | + } | |
| 55 | + return promise; | |
| 56 | + } | |
| 57 | + | |
| 58 | + it('第一次就成功:attempts=1,不写失败标记', async () => { | |
| 59 | + const agentManager = { callAgent: vi.fn().mockResolvedValue({ output: 'ok' }) }; | |
| 60 | + const result = await runWithTimers(fillAgentBodyReliably({ | |
| 61 | + agentManager, targetAgentId, message: 'brief', | |
| 62 | + })); | |
| 63 | + expect(result).toEqual({ ok: true, attempts: 1 }); | |
| 64 | + expect(agentManager.callAgent).toHaveBeenCalledTimes(1); | |
| 65 | + expect(agentManager.callAgent).toHaveBeenCalledWith('agent-creator', 'brief', expect.any(Object)); | |
| 66 | + expect(fs.existsSync(markerPath)).toBe(false); | |
| 67 | + // 锁用完必须释放,不能一直占着 | |
| 68 | + expect(agentChatLocks.has('agent-creator')).toBe(false); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it('第一次调用抛错、第二次成功:attempts=2,不写失败标记', async () => { | |
| 72 | + const agentManager = { | |
| 73 | + callAgent: vi.fn() | |
| 74 | + .mockRejectedValueOnce(new Error('transient')) | |
| 75 | + .mockResolvedValueOnce({ output: 'ok' }), | |
| 76 | + }; | |
| 77 | + const result = await runWithTimers(fillAgentBodyReliably({ | |
| 78 | + agentManager, targetAgentId, message: 'brief', | |
| 79 | + })); | |
| 80 | + expect(result).toEqual({ ok: true, attempts: 2 }); | |
| 81 | + expect(agentManager.callAgent).toHaveBeenCalledTimes(2); | |
| 82 | + expect(fs.existsSync(markerPath)).toBe(false); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it('agent-creator 被占用时不当场放弃,等锁释放后重试成功', async () => { | |
| 86 | + // 模拟"agent-creator 正忙":先手工占上锁,跟 agent-chat.js 的 POST /:agentId/chat | |
| 87 | + // 会做的事一模一样——这就是两边共享同一个 Map 才成立的场景。 | |
| 88 | + agentChatLocks.set('agent-creator', { convId: 'someone-elses-chat', startedAt: Date.now() }); | |
| 89 | + | |
| 90 | + const agentManager = { callAgent: vi.fn().mockResolvedValue({ output: 'ok' }) }; | |
| 91 | + const resultPromise = fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief' }); | |
| 92 | + | |
| 93 | + // 第一次尝试应该看到锁被占用,完全不调用 agentManager | |
| 94 | + await vi.advanceTimersByTimeAsync(1); | |
| 95 | + expect(agentManager.callAgent).not.toHaveBeenCalled(); | |
| 96 | + | |
| 97 | + // 锁释放(比如占用它的那次 chat 结束了),推进定时器让重试发生 | |
| 98 | + agentChatLocks.delete('agent-creator'); | |
| 99 | + const result = await runWithTimers(resultPromise); | |
| 100 | + | |
| 101 | + expect(result.ok).toBe(true); | |
| 102 | + expect(agentManager.callAgent).toHaveBeenCalledTimes(1); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it('重试耗尽:ok:false,写出可查询的失败标记,内容带上尝试次数与最后一次错误', async () => { | |
| 106 | + const agentManager = { callAgent: vi.fn().mockRejectedValue(new Error('agent-creator 挂了')) }; | |
| 107 | + const result = await runWithTimers(fillAgentBodyReliably({ | |
| 108 | + agentManager, targetAgentId, message: 'brief', | |
| 109 | + })); | |
| 110 | + | |
| 111 | + expect(result.ok).toBe(false); | |
| 112 | + expect(result.attempts).toBe(3); // MAX_ATTEMPTS | |
| 113 | + expect(agentManager.callAgent).toHaveBeenCalledTimes(3); | |
| 114 | + | |
| 115 | + expect(fs.existsSync(markerPath)).toBe(true); | |
| 116 | + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf-8')); | |
| 117 | + expect(marker.targetAgentId).toBe(targetAgentId); | |
| 118 | + expect(marker.attempts).toBe(3); | |
| 119 | + expect(marker.lastError).toContain('agent-creator 挂了'); | |
| 120 | + expect(marker.failedAt).toBeTruthy(); | |
| 121 | + | |
| 122 | + // 锁不能因为失败就永久占着 | |
| 123 | + expect(agentChatLocks.has('agent-creator')).toBe(false); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it('上一轮留下失败标记后,这一轮成功了要清掉——不能让哨兵文件继续骗人', async () => { | |
| 127 | + fs.writeFileSync(markerPath, JSON.stringify({ stale: true })); | |
| 128 | + const agentManager = { callAgent: vi.fn().mockResolvedValue({ output: 'ok' }) }; | |
| 129 | + const result = await runWithTimers(fillAgentBodyReliably({ | |
| 130 | + agentManager, targetAgentId, message: 'brief', | |
| 131 | + })); | |
| 132 | + expect(result.ok).toBe(true); | |
| 133 | + expect(fs.existsSync(markerPath)).toBe(false); | |
| 134 | + }); | |
| 135 | + | |
| 136 | + it('只释放自己抢到的那把锁:调用期间锁被第三方抢先改写不会被这里误删', async () => { | |
| 137 | + // agentManager.callAgent 在途期间,模拟第三方(比如超时安全阀强制释放后又被 | |
| 138 | + // 另一次调用抢到)把锁换成了别的 convId——finally 里的判据必须是 | |
| 139 | + // "convId 匹配才删",不能是"只要有锁就删"。 | |
| 140 | + let resolveCall; | |
| 141 | + const agentManager = { | |
| 142 | + callAgent: vi.fn(() => new Promise((r) => { resolveCall = r; })), | |
| 143 | + }; | |
| 144 | + const resultPromise = fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief' }); | |
| 145 | + await vi.advanceTimersByTimeAsync(1); | |
| 146 | + | |
| 147 | + const mine = agentChatLocks.get('agent-creator'); | |
| 148 | + expect(mine).toBeTruthy(); | |
| 149 | + // 第三方悄悄把锁换成了自己的 | |
| 150 | + agentChatLocks.set('agent-creator', { convId: 'third-party-conv', startedAt: Date.now() }); | |
| 151 | + | |
| 152 | + resolveCall({ output: 'ok' }); | |
| 153 | + await runWithTimers(resultPromise); | |
| 154 | + | |
| 155 | + // 第三方的锁必须还在——不能被这次调用的 finally 顺手清掉 | |
| 156 | + expect(agentChatLocks.get('agent-creator')).toEqual( | |
| 157 | + expect.objectContaining({ convId: 'third-party-conv' }) | |
| 158 | + ); | |
| 159 | + }); | |
| 160 | +}); | ... | ... |
-
mentioned in commit 3d5db4b1abe98deeb65eb7cdc0d92cd09adb52e7