# 32. 招聘装配的时间窗 —— 修法评估(✅ 方案 B 已实施,2026-08-21) > **日期**: 2026-08-21(评估) / 2026-08-21(实施,同一天) > **性质**: 承接 [30-task-delegation-reliability-gaps](./30-task-delegation-reliability-gaps.md) §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` 路由内联的自执行异步块) ```js 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`: ```js 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()`: ```js 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 号文档](./34-validator-scored-without-real-deliverable.md) §五.2), `coder/agent.md` 正常填充过,`unit-tester/agent.md` 却仍是原始 `_base` 骨架 (带 `LEMONBOSS_BASE_SKELETON` 标记)——本文档描述的正是这一类"装配没做完"的现象。 本次修复提交 `6a18379` 是同一天 **18:49** 才落地的,晚于这次招聘,所以这次撞见的是 **修复前的旧行为**,不代表方案 B 上线后仍有这个缺口——但也没有专门再跑一次去验证 新机制下这一步会不会正常完成,这条验证仍然缺失。 --- ## 参考 - [30-task-delegation-reliability-gaps](./30-task-delegation-reliability-gaps.md) §2.1 —— 本文档承接的现状调研 - [28-followups](./28-followups.md) —— `body_not_ready` 网关落地背景