agent-body-filler.js 6 KB
/**
 * 招聘后同进程调 agent-creator 填自己的定义文件——带互斥锁 + 有限重试 + 失败落地。
 *
 * 32 号文档方案 B 的落地。修的是 `company.js` hire 路由原来那发
 * `fetch('http://127.0.0.1:${PORT}/api/agent/agent-creator/chat', ...).catch(...)`:
 * fire-and-forget,`.catch` 只接网络层错误,不检查响应状态码;agent-creator 是单会话,
 * 撞车时 `/api/agent/:agentId/chat` 返回 429,被原样当成"成功"吞掉——新招的角色永久停在
 * base 空壳 + `status=training`,唯一痕迹是一行 console.error(撞车走的是 429 正常响应,
 * 连这行都不会触发)。
 *
 * 这里改成同进程直接调 `AgentManager.callAgent`,绕开那次多余的 HTTP 回环,换来:
 *   - 真实的调用结果/异常,而不是网络层 `.catch`;
 *   - 撞车时**等一等再试**(有限次数 + 退避),不是当场丢弃;
 *   - 重试耗尽后在本体目录写一个哨兵文件,而不是一行日志——不动
 *     `system_agents.status`/`config.json.status`(那两个字段还有 draft→active 的
 *     毕业逻辑在读,改它们的取值域风险和收益不对等,见 32 号文档 §四.3)。
 *
 * ⚠️ 互斥锁复用的是 `agent-session-lock.js`,跟 `routes/agent-chat.js` 的
 * `POST /:agentId/chat` 共享同一个 Map——不能各起一把。原因见该模块头注:
 * `SessionPool.getOrCreate()` 不检查上一次调用有没有跑完,两条路径各自以为自己
 * 没有并发的话,会把第二轮 prompt 发进一个还在处理中的 CLI 会话,比现在的
 * "被拒绝丢弃"更危险(协议层内容串台,未定义行为)。
 */
const fs = require('fs');
const path = require('path');
const locator = require('./agent-locator');
const { agentChatLocks, AGENT_LOCK_TIMEOUT } = require('../src/core/agent-session-lock');

const MAX_ATTEMPTS = 3;
// 重试之间的等待——不是精算出来的数字,是"给撞车的那一方一点时间跑完"的量级;
// 真正的硬顶是 MAX_ATTEMPTS,不是靠拉长等待时间去覆盖更长的撞车窗口。
const RETRY_DELAY_MS = [5_000, 15_000];

const FAILURE_MARKER_NAME = '.body_fill_failed.json';

function sleep(ms) {
  return new Promise((resolve) => { setTimeout(resolve, ms); });
}

function failureMarkerPath(bodyDir) {
  return path.join(bodyDir, FAILURE_MARKER_NAME);
}

/** 重试耗尽后落地——**必须能被查询/扫描到**,这是 32 号文档方案 B 的唯一硬要求。 */
function writeFailureMarker(bodyDir, info) {
  try {
    fs.writeFileSync(failureMarkerPath(bodyDir), JSON.stringify({
      ...info,
      failedAt: new Date().toISOString(),
    }, null, 2));
  } catch (e) {
    // 连哨兵文件都写不出去(目录权限/磁盘满)——这一步再失败也不能让调用方炸,
    // 招聘流程本身(scaffoldNewAgent 已经建好目录)不该被这个次要写入拖下水。
    console.error(`[BodyFill] ⚠️ 写失败标记本身也失败了: ${e.message}`);
  }
}

/** 成功之后必须清掉上一轮可能留下的失败标记——不然人工重试成功了,哨兵文件还在骗人。 */
function clearFailureMarker(bodyDir) {
  try {
    fs.unlinkSync(failureMarkerPath(bodyDir));
  } catch {
    // 本来就没有这个文件(多数情况)——不是错误
  }
}

/**
 * @param {object} a
 * @param {object} a.agentManager   `AgentManager` 实例(调用方传入,不在这里 new——
 *                                  测试时可以注入假实现,不用真起 Claude CLI)
 * @param {string} a.targetAgentId  要填定义的那个新招角色 id(== 本体目录名)
 * @param {string} a.message        发给 agent-creator 的完整 brief(调用方已经拼好,
 *                                  这个函数不关心内容,只关心「发出去、等结果、失败重试」)
 * @returns {Promise<{ok: boolean, attempts: number, error?: string}>}
 */
async function fillAgentBodyReliably({ agentManager, targetAgentId, message }) {
  const bodyDir = locator.bodyDir(targetAgentId);
  let lastError = null;

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    const existingLock = agentChatLocks.get('agent-creator');
    if (existingLock && Date.now() - existingLock.startedAt < AGENT_LOCK_TIMEOUT) {
      // 忙——等一等,不占位、不硬抢、不当场放弃。这是原来那发 fetch 唯一缺的东西。
      console.log(`[BodyFill] 🔒 agent-creator 正忙(conv ${existingLock.convId}),` +
        `第 ${attempt}/${MAX_ATTEMPTS} 次尝试改为等待`);
      lastError = new Error(`agent-creator 正忙(活跃会话 ${existingLock.convId})`);
      if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS[attempt - 1] ?? RETRY_DELAY_MS.at(-1));
      continue;
    }

    const convId = `body-fill-${targetAgentId}-a${attempt}-${Date.now().toString(36)}`;
    agentChatLocks.set('agent-creator', { convId, startedAt: Date.now() });
    try {
      await agentManager.callAgent('agent-creator', message, {
        metadata: { taskId: convId },
      });
      clearFailureMarker(bodyDir);
      return { ok: true, attempts: attempt };
    } catch (err) {
      lastError = err;
      console.error(`[BodyFill] ⚠️ agent-creator 第 ${attempt}/${MAX_ATTEMPTS} 次调用失败: ${err.message}`);
      if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS[attempt - 1] ?? RETRY_DELAY_MS.at(-1));
    } finally {
      // 只释放"自己那一把"——万一在途期间锁已经被别的机制清过又被第三方占了,
      // 不能凭空把第三方的锁摘掉(同 agent-chat.js releaseLock 的判据)。
      const cur = agentChatLocks.get('agent-creator');
      if (cur && cur.convId === convId) agentChatLocks.delete('agent-creator');
    }
  }

  writeFailureMarker(bodyDir, {
    targetAgentId,
    attempts: MAX_ATTEMPTS,
    lastError: lastError ? lastError.message : null,
  });
  return { ok: false, attempts: MAX_ATTEMPTS, error: lastError ? lastError.message : null };
}

module.exports = { fillAgentBodyReliably, FAILURE_MARKER_NAME };