Commit 8c54cffa5ac362fc53b023f520f226d54dad086b

Authored by yibo
1 parent 507ec3fa

docs(32): 招聘装配时间窗的修法评估——task/dispatch 不成立,推荐同进程调用+互斥锁+重试

承接 30-task-delegation-reliability-gaps §2.1(现状调研,未拍板)。评估了两条候选:

方案 A(改走 POST /api/agent/task/dispatch)不成立:深挖发现它最终也是走
selfChat() 回环 HTTP 自己,撞上 agent-creator 单会话锁时同样吃 429、同样不
检查响应状态码——跟现在的 fire-and-forget fetch 是同一条失败路径,没有多任何
重试保护,唯一价值是任务表里多一条可查询的行(可观测性不是可靠性),且语义
错位(内部装配动作会被包装成用户可见的派单任务)。

方案 B(company.js 直接同进程调 AgentManager.callAgent)方向对,但评估中发现
一个原始方向没提到的真实风险:SessionPool.getOrCreate() 只判断连接在不在,
不判断上一次调用有没有跑完——绕开 HTTP 直接并发调用会被复用到同一个还在处理
中的会话上,两条 prompt 塞进一条 stdio 管道,行为未定义,比现在"第二个请求被
429 丢弃"更危险。因此推荐方案明确写成「同进程调用 + 自己的互斥锁 + 有限重试 +
失败落地为可查询状态」,不是简单的「同进程调用 + 重试」。

文档状态:评估完成,未实施。互斥锁选型、失败标记落点等留给实施阶段决定。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/todo/system-design/32-hire-body-fill-reliability.md 0 → 100644
  1 +# 32. 招聘装配的时间窗 —— 修法评估(评估完成,未实施)
  2 +
  3 +> **日期**: 2026-08-21
  4 +> **性质**: 承接 [30-task-delegation-reliability-gaps](./30-task-delegation-reliability-gaps.md) §2.1(那份是"现状调研,不代表已拍板要改")。
  5 +> 本文档做的是**具体修法评估**:比较了两条候选路径,有明确推荐,但**代码还没有改一行**。
  6 +> **触发**:本体装配静默坑那一轮修完读侧(work 节点缺 soul/identity 等、org node 角色定义截断不可见,
  7 +> 见 `graph-engine.js`/`prompt-builder.js` 那次提交)之后,回头看写侧(招聘时)同一类问题,发现
  8 +> 更严重——读侧顶多是"给的上下文不全",写侧是"这个角色永久卡死,没人知道"。
  9 +
  10 +---
  11 +
  12 +## 一、问题:招聘装配这段异步调用没有任何可靠性保证
  13 +
  14 +**文件**:`server/routes/company.js:1005-1024`(`POST /:companyId/hire` 路由内联的自执行异步块)
  15 +
  16 +```js
  17 +fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, {
  18 + method: 'POST',
  19 + headers: { 'Content-Type': 'application/json' },
  20 + body: JSON.stringify({ message: buildAgentDefinitionBrief({...}), conversationId: `create-...` })
  21 +}).catch(e => console.error('[Hire] Agent-Creator 请求错误:', e.message));
  22 +```
  23 +
  24 +`hireWithTraining()` 建完空壳目录后,fire-and-forget 发一个 HTTP 请求给 `agent-creator`,让它把
  25 +`agent.md`/`soul.md`/技能文件填成角色专属内容。**这一发请求成不成功,调用方完全不关心**:
  26 +`.catch` 只接网络层错误(连不上、超时),**不检查响应状态码**。
  27 +
  28 +### 1.1 真正的失败点:`agent-creator` 是单会话,撞车直接丢
  29 +
  30 +`server/routes/agent-chat.js:2496,2510-2525`:
  31 +
  32 +```js
  33 +const agentChatLocks = new Map(); // agentId → { convId, startedAt }
  34 +...
  35 +const existingLock = agentChatLocks.get(agentId);
  36 +if (existingLock && Date.now() - existingLock.startedAt < 30 * 60 * 1000) {
  37 + return res.status(429).json({ error: `Agent ${agentId} 正在执行另一个会话`, activeConvId: existingLock.convId });
  38 +}
  39 +```
  40 +
  41 +`/api/agent/:agentId/chat` 路由用一个进程内 `Map` 做每 agent 的互斥锁,撞车返回 **429**。而
  42 +`company.js` 那发 `fetch` 的 `.catch` 不检查 `res.ok`/`res.status`——**429 被当成"请求成功"直接吞掉**。
  43 +按拓扑序连着招 N 个模板库外的新角色时,只有第一个会被 agent-creator 真正处理,其余全部
  44 +**永久停在 base 空壳 + `status=training`**,唯一的痕迹是一行 `console.error`(而且这一行在
  45 +429 场景下根本不会触发——429 是正常 HTTP 响应,不是网络错误,`.catch` 都摸不到它)。
  46 +
  47 +### 1.2 `body_not_ready` 网关只挡了"重复招聘",没挡"卡住不动"
  48 +
  49 +`company-service.js:430,500-511` 现在会在 `hire()` 时现场校验角色定义是否就绪,挡住别家公司
  50 +在定义写完前抢招同一角色(`org-coder-has-no-shared-body` 记忆记录的那次修复,2026-08-21)。
  51 +**这道网关解决的是"别人趁乱抢招",不解决"这个角色本身卡住了"**——被卡住的那个招聘请求,
  52 +其发起方(第一个招它的公司)拿到的仍然是一个永远填不满的 `training` 空壳,没有任何信号。
  53 +
  54 +---
  55 +
  56 +## 二、评估过的两条修法
  57 +
  58 +### 方案 A:把这发请求改走 `POST /api/agent/task/dispatch`(任务队列)
  59 +
  60 +`org-coder-has-no-shared-body` 记忆里记过一条"更省事的补救"——人工手动发一次
  61 +`task/dispatch`(`autoExecute:true`)确实能让卡住的空壳在约 2 分钟内被填好。看起来像是
  62 +"把 fire-and-forget 换成排队",但深入代码后发现**不是**:
  63 +
  64 +- `task/dispatch`(`agent-chat.js:1816-1970`)最终还是走 `selfChat()`(`agent-chat.js:44-63`)——
  65 + 一个 `http.request` 回本进程的 `/api/agent/:agentId/chat`,**跟 `company.js` 现在用的
  66 + `fetch` 是同一条执行路径**,不是另一套调度器。`task-scheduler.js:115` 的注释也确认
  67 + dispatch 和 graph 引擎的调度器是两个互不相干的"执行载体"。
  68 +- `selfChat()` 同样不检查响应状态码,`/api/agent/:agentId/chat` 撞上 `agentChatLocks` 时同样
  69 + 返回 429 且同样被静默吞掉——**task/dispatch 在这条失败路径上没有比现在的代码多做任何事**。
  70 +- 它真正带来的价值只有一样:往 `tasks` 表写一条持久化行(`status` 字段可查),所以"卡住了"
  71 + 这件事**变得可观测**,但**不会自动恢复**,也不是它本身有重试。
  72 +- 语义不合适:dispatch 会在保留字公司 `_global`(`DISPATCH_COMPANY`,`agent-chat.js:1721`)
  73 + 下建一条 `goals` 行(`标题: "来自 X 的派单"`)+ `tasks` 行——"填自己的定义文件"这种
  74 + 系统内部的引导动作会被包装成一条面向用户的可见任务,扫全局任务列表的地方都会看到它,
  75 + 语义上是错位的(穿着用户任务的外衣做系统内部的事)。
  76 +- 没有先例:全仓库找不到任何内部调用方**直接调用**(不经过 HTTP)dispatch 的实现函数,
  77 + 想用它只能还是绕一圈 HTTP 回自己——没有省掉现在这次 HTTP 往返,只是多了一层。
  78 +
  79 +**结论:方案 A 不成立**——它解决的是可观测性,不是可靠性,而且要素与本次要修的问题不匹配。
  80 +
  81 +### 方案 B:`company.js` 的 hire 路由直接同进程调 `AgentManager.callAgent`(推荐,但有一个前提要先补上)
  82 +
  83 +`company.js` 本来就跑在同一个 Node 进程里,`AgentManager` 也是同进程对象——现在这发 `fetch`
  84 +回环到 `127.0.0.1:${PORT}` 纯属多余的一次 HTTP 往返(大概率是历史遗留:早期这段可能是独立
  85 +脚本/进程,现在已经不是)。直接 `await agentManager.callAgent('agent-creator', prompt, opts)`
  86 +可以:
  87 +- 拿到真实的调用结果/异常,而不是网络层的 `.catch`;
  88 +- 在结果显示"忙"的时候做**真正的重试**(有限次数 + 退避),而不是丢掉;
  89 +- 重试耗尽时写一个**明确、可查询的失败标记**(而不是一行日志),供人工或后续的扫描发现。
  90 +
  91 +**但这里有一个方案 B 必须先解决的坑,是本次评估新发现的(评估最初的方向没提到):**
  92 +
  93 +`server/src/core/session-pool.js:160-179` 的 `getOrCreate()`:
  94 +
  95 +```js
  96 +if (existing?.protocol?.isConnected) {
  97 + ...
  98 + return existing; // 直接复用——不检查这个会话是不是「正在处理上一次请求」
  99 +}
  100 +```
  101 +
  102 +`SessionPool` **没有"当前是否有一次调用正在途中"这个判据**,它只看"连接还在不在"。
  103 +`agentChatLocks` 那把互斥锁**只存在于 `agent-chat.js` 这一层路由代码里**,不是
  104 +`AgentManager`/`SessionPool` 自身的保证。这意味着:**如果绕开 HTTP、直接并发调用两次
  105 +`AgentManager.callAgent('agent-creator', ...)`,`SessionPool` 会把第二次调用复用到同一个
  106 +还在处理第一次请求的 CLI 会话上**——两条 prompt 同时往一个 stdio 管道里写,这不是"干净地
  107 +排队",是**在一个进程上下文里塞进两轮对话**,行为未定义(很可能是内容串台或协议层报错,
  108 +不是本文档能只凭读代码断言的,需要真跑验证),比现在"第二个请求被 429 丢掉"更危险——
  109 +现在的行为好歹是可预测的(丢弃),换成方案 B 如果不额外加锁,可能变成不可预测的
  110 +(污染)。
  111 +
  112 +**所以方案 B 的正确形态是「同进程调用 + 自己的互斥锁 + 重试」,不是「同进程调用 + 重试」。**
  113 +互斥锁具体怎么做(新建一个专用的 `Map`,还是把 `agentChatLocks` 从 `agent-chat.js` 导出
  114 +共用)是实现阶段要定的事,`agentChatLocks` 现在只是模块内变量、没有导出
  115 +(`agent-chat.js` 的 `module.exports` 只有 `router`),选哪条看实现时的耦合取舍。
  116 +
  117 +---
  118 +
  119 +## 三、推荐方案与要点
  120 +
  121 +**方案 B + 显式互斥锁 + 有限重试 + 失败落地为可查询状态**,不做方案 A。
  122 +
  123 +需要的改动(供后续实施时拆解,本文档不实施):
  124 +
  125 +1. **互斥**:招 `agent-creator` 之前先拿一把按 agentId 的锁(复用或新建,见上);拿不到锁不是
  126 + 立刻失败,是**等一等再试**(不是当场拒绝,因为发起方就是想让它填完,等待是合理语义)。
  127 +2. **重试**:有限次数 + 退避(不是无限重试——理由同图引擎的熔断哲学,无限重试等于把"卡住"
  128 + 换成"一直在跑但看起来没卡住",一样发现不了)。
  129 +3. **失败可见**:重试耗尽后,写一个明确的标记——可以是本体目录下的一个哨兵文件,也可以是
  130 + `company-service` 已有的某个状态列(具体落点看实现时 `company-service.js` 现有 schema,
  131 + 本文档不预设)。**核心要求只有一条:不能只是一行 console.error**,得是能被查询/扫描到的
  132 + 东西,这样以后要做「主动喊人」那一层(30 号文档 §三点名的共性缺口)时,这里已经是
  133 + 现成的数据源,不用回头补。
  134 +4. **不做**:不做「保证自动跑到底」——这跟 30 号文档 §三的结论一致,全系统没有一处做这个
  135 + 保证,这里也不该单独破例发明一套。目标只是把"静默卡死"变成"卡死了但看得见"。
  136 +
  137 +---
  138 +
  139 +## 四、风险 / 未决问题
  140 +
  141 +1. **`SessionPool` 并发复用同一会话的真实后果没有实测验证**——本文档的论证是读代码推出来的
  142 + (`getOrCreate` 不检查在途状态),没有起真实两个并发请求去验证具体报错形态还是内容串台。
  143 + 实施前应该先补一个能复现这个场景的测试(两个并发 `callAgent` 调用同一个 agentId),
  144 + 把「后果具体是什么」钉死,而不是只停留在「读代码觉得危险」。
  145 +2. **互斥锁选型未定**:导出 `agentChatLocks` 共用,还是新建一把只给招聘装配用的锁——前者
  146 + 避免了「系统里有两把语义相同但互相不知道对方存在的锁」,后者改动面更小。留给实施时定。
  147 +3. **失败标记落在哪个字段/文件**:本文档故意不预设,需要先看 `company-service.js` 现有
  148 + `status`(`training`/`draft`/`active` 等)取值域,判断是新增一个值还是叠加一个独立字段。
  149 +4. **这只是三处同构缺口(30 号文档 §三)里的一处**:DevLoop 的 `retry_budget` 耗尽和 Graph
  150 + 执行的 `blocked` 收敛,是不是也该补同一层"失败可查询/主动喊人",本文档不展开——三处
  151 + 要不要统一设计成一层公共机制,还是各自打各自的补丁,是比这份文档更大的一个决策,
  152 + 留给以后。
  153 +
  154 +---
  155 +
  156 +## 参考
  157 +
  158 +- [30-task-delegation-reliability-gaps](./30-task-delegation-reliability-gaps.md) §2.1 —— 本文档承接的现状调研
  159 +- [28-followups](./28-followups.md) —— `body_not_ready` 网关落地背景
... ...