32-hire-body-fill-reliability.md 14.9 KB

32. 招聘装配的时间窗 —— 修法评估(✅ 方案 B 已实施,2026-08-21)

日期: 2026-08-21(评估) / 2026-08-21(实施,同一天) 性质: 承接 30-task-delegation-reliability-gaps §2.1(那份是"现状调研,不代表已拍板要改")。 本文档原本只是修法评估(比较两条候选路径、给推荐),现在方案 B 已经落地—— agent-session-lock.js、agent-body-filler.js、company.js/agent-chat.js 的改动, 见提交 6a18379。§三"推荐方案与要点"、§3.1 对照表原样保留(描述的仍是方案本身, 没有过期);§四"风险/未决问题"里 1/2/3 三条已经在实施时拍板,逐条标了结论。 触发:本体装配静默坑那一轮修完读侧(work 节点缺 soul/identity 等、org node 角色定义截断不可见, 见 graph-engine.js/prompt-builder.js 那次提交)之后,回头看写侧(招聘时)同一类问题,发现 更严重——读侧顶多是"给的上下文不全",写侧是"这个角色永久卡死,没人知道"。


一、问题:招聘装配这段异步调用没有任何可靠性保证

文件:server/routes/company.js:1005-1024(POST /:companyId/hire 路由内联的自执行异步块)

fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: buildAgentDefinitionBrief({...}), conversationId: `create-...` })
}).catch(e => console.error('[Hire] Agent-Creator 请求错误:', e.message));

hireWithTraining() 建完空壳目录后,fire-and-forget 发一个 HTTP 请求给 agent-creator,让它把 agent.md/soul.md/技能文件填成角色专属内容。这一发请求成不成功,调用方完全不关心: .catch 只接网络层错误(连不上、超时),不检查响应状态码。

1.1 真正的失败点:agent-creator 是单会话,撞车直接丢

server/routes/agent-chat.js:2496,2510-2525:

const agentChatLocks = new Map(); // agentId → { convId, startedAt }
...
const existingLock = agentChatLocks.get(agentId);
if (existingLock && Date.now() - existingLock.startedAt < 30 * 60 * 1000) {
  return res.status(429).json({ error: `Agent ${agentId} 正在执行另一个会话`, activeConvId: existingLock.convId });
}

/api/agent/:agentId/chat 路由用一个进程内 Map 做每 agent 的互斥锁,撞车返回 429。而 company.js 那发 fetch 的 .catch 不检查 res.ok/res.status——429 被当成"请求成功"直接吞掉。 按拓扑序连着招 N 个模板库外的新角色时,只有第一个会被 agent-creator 真正处理,其余全部 永久停在 base 空壳 + status=training,唯一的痕迹是一行 console.error(而且这一行在 429 场景下根本不会触发——429 是正常 HTTP 响应,不是网络错误,.catch 都摸不到它)。

1.2 body_not_ready 网关只挡了"重复招聘",没挡"卡住不动"

company-service.js:430,500-511 现在会在 hire() 时现场校验角色定义是否就绪,挡住别家公司 在定义写完前抢招同一角色(org-coder-has-no-shared-body 记忆记录的那次修复,2026-08-21)。 这道网关解决的是"别人趁乱抢招",不解决"这个角色本身卡住了"——被卡住的那个招聘请求, 其发起方(第一个招它的公司)拿到的仍然是一个永远填不满的 training 空壳,没有任何信号。


二、评估过的两条修法

方案 A:把这发请求改走 POST /api/agent/task/dispatch(任务队列)

org-coder-has-no-shared-body 记忆里记过一条"更省事的补救"——人工手动发一次 task/dispatch(autoExecute:true)确实能让卡住的空壳在约 2 分钟内被填好。看起来像是 "把 fire-and-forget 换成排队",但深入代码后发现不是:

  • task/dispatch(agent-chat.js:1816-1970)最终还是走 selfChat()(agent-chat.js:44-63)—— 一个 http.request 回本进程的 /api/agent/:agentId/chat,跟 company.js 现在用的 fetch 是同一条执行路径,不是另一套调度器。task-scheduler.js:115 的注释也确认 dispatch 和 graph 引擎的调度器是两个互不相干的"执行载体"。
  • selfChat() 同样不检查响应状态码,/api/agent/:agentId/chat 撞上 agentChatLocks 时同样 返回 429 且同样被静默吞掉——task/dispatch 在这条失败路径上没有比现在的代码多做任何事。
  • 它真正带来的价值只有一样:往 tasks 表写一条持久化行(status 字段可查),所以"卡住了" 这件事变得可观测,但不会自动恢复,也不是它本身有重试。
  • 语义不合适:dispatch 会在保留字公司 _global(DISPATCH_COMPANY,agent-chat.js:1721) 下建一条 goals 行(标题: "来自 X 的派单")+ tasks 行——"填自己的定义文件"这种 系统内部的引导动作会被包装成一条面向用户的可见任务,扫全局任务列表的地方都会看到它, 语义上是错位的(穿着用户任务的外衣做系统内部的事)。
  • 没有先例:全仓库找不到任何内部调用方直接调用(不经过 HTTP)dispatch 的实现函数, 想用它只能还是绕一圈 HTTP 回自己——没有省掉现在这次 HTTP 往返,只是多了一层。

结论:方案 A 不成立——它解决的是可观测性,不是可靠性,而且要素与本次要修的问题不匹配。

方案 B:company.js 的 hire 路由直接同进程调 AgentManager.callAgent(推荐,但有一个前提要先补上)

company.js 本来就跑在同一个 Node 进程里,AgentManager 也是同进程对象——现在这发 fetch 回环到 127.0.0.1:${PORT} 纯属多余的一次 HTTP 往返(大概率是历史遗留:早期这段可能是独立 脚本/进程,现在已经不是)。直接 await agentManager.callAgent('agent-creator', prompt, opts) 可以:

  • 拿到真实的调用结果/异常,而不是网络层的 .catch;
  • 在结果显示"忙"的时候做真正的重试(有限次数 + 退避),而不是丢掉;
  • 重试耗尽时写一个明确、可查询的失败标记(而不是一行日志),供人工或后续的扫描发现。

但这里有一个方案 B 必须先解决的坑,是本次评估新发现的(评估最初的方向没提到):

server/src/core/session-pool.js:160-179 的 getOrCreate():

if (existing?.protocol?.isConnected) {
  ...
  return existing;   // 直接复用——不检查这个会话是不是「正在处理上一次请求」
}

SessionPool 没有"当前是否有一次调用正在途中"这个判据,它只看"连接还在不在"。 agentChatLocks 那把互斥锁只存在于 agent-chat.js 这一层路由代码里,不是 AgentManager/SessionPool 自身的保证。这意味着:如果绕开 HTTP、直接并发调用两次 AgentManager.callAgent('agent-creator', ...),SessionPool 会把第二次调用复用到同一个 还在处理第一次请求的 CLI 会话上——两条 prompt 同时往一个 stdio 管道里写,这不是"干净地 排队",是在一个进程上下文里塞进两轮对话,行为未定义(很可能是内容串台或协议层报错, 不是本文档能只凭读代码断言的,需要真跑验证),比现在"第二个请求被 429 丢掉"更危险—— 现在的行为好歹是可预测的(丢弃),换成方案 B 如果不额外加锁,可能变成不可预测的 (污染)。

所以方案 B 的正确形态是「同进程调用 + 自己的互斥锁 + 重试」,不是「同进程调用 + 重试」。 互斥锁具体怎么做(新建一个专用的 Map,还是把 agentChatLocks 从 agent-chat.js 导出 共用)是实现阶段要定的事,agentChatLocks 现在只是模块内变量、没有导出 (agent-chat.js 的 module.exports 只有 router),选哪条看实现时的耦合取舍。


三、推荐方案与要点

方案 B + 显式互斥锁 + 有限重试 + 失败落地为可查询状态,不做方案 A。

需要的改动(供后续实施时拆解,本文档不实施):

  1. 互斥:招 agent-creator 之前先拿一把按 agentId 的锁(复用或新建,见上);拿不到锁不是 立刻失败,是等一等再试(不是当场拒绝,因为发起方就是想让它填完,等待是合理语义)。
  2. 重试:有限次数 + 退避(不是无限重试——理由同图引擎的熔断哲学,无限重试等于把"卡住" 换成"一直在跑但看起来没卡住",一样发现不了)。
  3. 失败可见:重试耗尽后,写一个明确的标记——可以是本体目录下的一个哨兵文件,也可以是 company-service 已有的某个状态列(具体落点看实现时 company-service.js 现有 schema, 本文档不预设)。核心要求只有一条:不能只是一行 console.error,得是能被查询/扫描到的 东西,这样以后要做「主动喊人」那一层(30 号文档 §三点名的共性缺口)时,这里已经是 现成的数据源,不用回头补。
  4. 不做:不做「保证自动跑到底」——这跟 30 号文档 §三的结论一致,全系统没有一处做这个 保证,这里也不该单独破例发明一套。目标只是把"静默卡死"变成"卡死了但看得见"。

3.1 解决了什么 / 没解决什么(对照表,别读完方案又高估了它)

解决了 没解决
撞车丢请求 ✅ 加了重试,绝大多数"招聘间隔太近撞车"场景自愈,不需要人工 ——
卡死后没人知道 ✅ 从一行 console.error 变成可查询的持久标记 ❌ 只是可查询,不是主动通知——没人去查还是发现不了;30 号文档 §三点名的"主动喊人"那一层没做
agent-creator 真的坏了(不是撞车、持续报错) —— ❌ 重试耗尽照样停在"失败等人工",跟 DevLoop retry_budget 耗尽、Graph blocked 是同一套哲学,不追求自动恢复到底
新引入的并发复用会话风险 ✅ 互斥锁从设计上直接避免触发这个场景 ⚠️ SessionPool 并发复用同一会话本身的真实后果仍未实测验证(§四.1)——只是这次修复不会踩到它,不代表这个坑对别处调用方也不存在
另外两处同构缺口(DevLoop / Graph) —— ❌ 完全不碰,本文档只动招聘装配这一处
hire 接口的调用体验 —— ❌ 接口仍是异步的,前端拿到的还是立即返回的 training 状态;装配完成/失败要另外查,跟今天一样,只是"失败"这个状态现在是真的会被写出来的

一句话版本:这个方案把"静默卡死、永远没人知道"变成"卡死了会重试,重试也不行就留下一个 能查到的信号"——它治的是可靠性的地板(从"完全没有保证"提到"有重试 + 失败可见"), 不是把整条链路变成"保证成功",也不负责把这个信号推给人看。


四、风险 / 未决问题

  1. SessionPool 并发复用同一会话的真实后果没有实测验证——实施时定了:没有另外 补一个"两个并发 callAgent 撞车"的复现测试去把具体报错形态钉死,改用更省事也更稳妥的 做法——agent-body-filler.js 从设计上保证永远不会触发这个场景(拿锁成功才调用, 没拿到锁就等,不会有第二个并发调用打进来)。所以"后果具体是什么"这个问题本身 仍然没有实测答案,只是这条路径不需要知道答案也是安全的。真要交代清楚,得去 session-pool.js 自己的测试里补,不在这份文档范围内。
  2. 互斥锁选型未定——实施时定了:抽共享模块,不新建一把。新增 agent-session-lock.js 承载原来 agent-chat.js 里的 agentChatLocks Map + 超时常量,agent-chat.js 与 agent-body-filler.js 都从这个模块 require。没有导出整个 agent-chat.js 模块的 其它内部状态——只挪了这一个 Map,agent-chat.js 的 module.exports 仍然只是 router (改了会破坏 app.use('/api/agent', require('./routes/agent-chat')) 这个挂载点)。
  3. 失败标记落在哪个字段/文件——实施时定了:本体目录下的哨兵文件,不碰 system_agents.status/config.json.status。新增 .body_fill_failed.json (agent-body-filler.js 的 FAILURE_MARKER_NAME),内容含 attempts/lastError/ failedAt;成功之后自动清掉。没有动任何现有 status 字段的取值域——那两个字段还有 draft→active 的毕业逻辑在读,风险和收益不对等(评估阶段就是这个理由,实施时原样照做)。
  4. 这只是三处同构缺口(30 号文档 §三)里的一处,仍然只做了这一处:DevLoop 的 retry_budget 耗尽和 Graph 执行的 blocked 收敛,是不是也该补同一层"失败可查询/ 主动喊人",这次实施没有碰、也没有顺手去改公共机制——三处要不要统一设计成一层公共 机制,还是各自打各自的补丁,是比这次修复更大的一个决策,留给以后。
  5. 新增:这次实施顺带修正了一个原来评估没预料到的连带效果——company.js hire 路由 第 1 步从 fire-and-forget 换成 await 之后,第 2 步(技能注入)/第 3 步(setTimeout(5000) 触发 Innovator 训练)现在拿到的是"第 1 步真的跑完了"的保证,不再是"假设 5 秒内跑完"。 那个 setTimeout(5000) 因此变成了多余的额外延迟(无害,只是白等 5 秒),没有顺手删掉—— 不在这次修复评估的范围内,属于"看到了但刻意不处理",留给以后要动第 3 步时再一起看。
  6. 新增:真实撞见的一次样本,时间点在方案 B 修复之前——e2e-demo 公司 2026-08-21 17:54 同一分钟内招聘了 coder/unit-tester 两个本体(真实回归任务 T-mt2r6fz0-125b 触发,见 34 号文档 §五.2), coder/agent.md 正常填充过,unit-tester/agent.md 却仍是原始 _base 骨架 (带 LEMONBOSS_BASE_SKELETON 标记)——本文档描述的正是这一类"装配没做完"的现象。 本次修复提交 6a18379 是同一天 18:49 才落地的,晚于这次招聘,所以这次撞见的是 修复前的旧行为,不代表方案 B 上线后仍有这个缺口——但也没有专门再跑一次去验证 新机制下这一步会不会正常完成,这条验证仍然缺失。

参考