Commit 6a18379a784f645c4231978973b02996d1c40d85

Authored by yibo
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>
server/routes/agent-chat.js
... ... @@ -2493,8 +2493,9 @@ router.get(&#39;/:agentId/uploads/:convId/:filename&#39;, (req, res) =&gt; {
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(&#39;../services/schedule-service&#39;);
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(&#39;/:companyId/hire&#39;, async (req, res) =&gt; {
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 +});
... ...