Commit 8c54cffa5ac362fc53b023f520f226d54dad086b
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>
Showing
1 changed file
with
159 additions
and
0 deletions
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` 网关落地背景 | ... | ... |