fix(worker): 普通消息缺少提交确认时自动重试 - #741
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
复审结论:Request changes。同一 Worker generation 内的去重三态与 stale generation ACK 防线本身成立,但还有 3 个 ordinary IM 投递缺口。
[P2] crash-diagnostic 冷重启会在真实成功前误报提交失败
src/worker.ts:11783 先把 om_ 标成 inflight;src/worker.ts:11832-11844 随后在入队/ACK 前等待完整 spawnCli() 和 prepareCodexNativeTitleGeneration()。daemon 的预算只有 2 秒 × 2 次(src/core/worker-pool.ts:2863-2864)。Codex resume 标题基线读取自身就允许 7 秒(src/worker.ts:639-645)。
计时探针:用不响应的 fake Codex app-server 调用同一元数据读取,实测 7,647 ms 后才报 initialize timed out after 7000ms。因此 worker 首包仍在 await 时,重试包会命中 inflight 被丢弃;4 秒后 daemon 发“未提交”提示,而原 handler 之后仍会落到 sendToPty() 并正常执行。用户按提示重发会生成新 om_,存在二次执行外部写操作的风险。
建议在开始冷重启时先把该 turn 放进 worker 输入队列并 ACK,再由队列等待 backend 恢复;或让 daemon 对明确的 restart-in-progress 状态使用不同的非失败等待协议。需补一个 >4 秒冷启用例,断言不发失败提示且最终只入队一次。
[P2] transfer gate 回放绕过 watchdog,仍可静默丢普通消息
src/core/worker-pool.ts:3977 明确用 !transferGate 排除追踪;gate 释放后又在 src/core/worker-pool.ts:3196-3200 直接 worker.send(message) 并立即 shift()。这里既没有 callback 错误处理,也没有 ACK record,所以 replacement IPC 若出现本 PR 要修的“send 已调用但 worker 未 commit”,消息仍会永久消失。
探针把 transfer 期间缓冲的消息改为普通 om_(无 dispatchAttempt),replacement 回放后等待 2.1 秒,仍只有 1 次裸 send,没有 watchdog retry。建议回放 ordinary message 时绑定 replacement generation 并调用同一 tracked sender;raw/control 输入保持原协议。
[P2] cold-start / worker-null refork 的普通 om_ 仍未追踪
新话题首轮和 worker-null 恢复都通过 forkWorker(..., { turnId: om_... }) 把用户消息放进 initMsg.prompt,但 src/core/worker-pool.ts:4446 仍是裸 worker.send(initMsg);新 map 只记录 type: message。若 init IPC 丢失,child 会停在未初始化状态,不会 ready/error/exit,也没有超时重试或可见失败,仍是本 PR 描述的原始“事件已去重、Agent 永远看不到”。
fake-worker 探针推进 5 秒后确认:该 om_ 只有 1 次 init send,0 次 retry,0 次 sessionReply。建议明确扩大协议覆盖 init turn(需要 init duplicate re-ACK / 与真实慢启动相容的单独时限),或收窄 PR 的用户承诺并另开 blocking 修复;当前“普通群、话题或私聊消息均受保护”的描述不成立。
其余边界结论
case 'message'中除 crash-restart 外,没有其它成功路径在 ACK 前 await;sendToPty=false会 release,committed duplicate 只 re-ACK。- 未发现跨 generation 自动重投或 stale ACK 清掉新 generation record;现有 worker 对象 + 双 generation 检查能阻止这类双写。
- adopt、durable dispatch、VC receiver 的既有 authority gate 未被新追踪器侵入。
本地验证
pnpm build:通过(domain audit、tsc、dashboard bundle、dist audit 均绿)- 相关回归:7 文件,437/437 通过
pnpm test:791 文件通过、1 跳过;12,592/12,598 项通过、6 跳过git diff --check:通过;探针临时改动已撤销,工作树干净
ed8e445 to
9cbc9ce
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
结论:请求修改。双层 ACK 解决了慢 crash-restart 的误报,但 rejected 在 worker 初始化窗口内会把两次同代重试额度瞬间耗尽,仍可丢普通消息。
Blocker:初始化中的临时不可用被当成永久拒收
releaseTransferInputGate() 在 replacement fork 后立即回放普通 om_;worker 的 init handler 此时可能仍停在 startWebServer / Codex RPC / plugin gateway 等合法慢启动 await 中,而 cliAdapter 要到后续 spawnCli() 内才赋值。并发的 message handler 会先发 turn_input_received,随后 sendToPty() 在 if (!cliAdapter) return false 处短路并发 turn_input_rejected。daemon 收到 rejected 后没有等待 ready 或 backoff,立即同代重试;若 init 仍在进行,第二次继续 rejected,ORDINARY_IM_MAX_ATTEMPTS=2 被耗尽。之后 replacement 正常 ready,也没有任何机制重新入队这条消息。
我用真实 worker IPC 构造了确定性探针:空 prompt 的 transfer-style resume init,Codex RPC app-server 合法慢启动 15s 后回退到 paste;init 后立即发送普通 om_,收到首次 rejected 后按 daemon 行为立即原样重试。结果稳定为 turn_input_received ×2、turn_input_rejected ×2,约 16s 后 worker ready,全程没有 turn_input_committed。这不是坏 IPC 或坏 worker,而是暂未初始化完成。普通冷启动期间紧随首条消息到来的 follow-up 也走同一风险路径。
建议让 init 未完成时的普通消息在 worker 内排队(不要在 !cliAdapter 时 reject),或让 daemon 将 cli_input_unavailable 视为 transient,等待 ready / 延迟重试且不立即消耗固定两次额度。请补一条启动真实 worker、覆盖 init + concurrent message + slow startup 的回归;当前 transfer 测试只验证 fake worker 的 send(message, callback) 签名,无法覆盖这层并发。
其余复核:received/committed/rejected 的 generation fence 与 dedupe 三态在 adapter 已建立后未发现双写;旧的慢 crash-restart、cold-start init tracking、stale generation ACK 三项修复成立。
本地验证:
pnpm build:通过- 重点回归 487/487:
input-turn-dedupe、worker-ordinary-im-receipt-wiring、session-lifecycle-start、transfer-session、event-dispatcher、dispatch pnpm test:803 files / 12873 tests 通过;2 个 suite/test 在全量争用下 10s hook 超时(doc-comment-daemon-concurrency、group-join-shared-routing),分别隔离复跑 17/17、14/14 通过,与本 PR 路径无关
9cbc9ce to
cbcdd1b
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
结论:请求修改。sendToPty 的 guard 移位已经修复上一版在慢 init 窗口内连续 rejected 两次的问题,但新增的 unshift 仍不能保证首轮 prompt 先于 follow-up 写入 CLI。
Blocker:prompt-ready 回调可在首轮 prompt 入队前排空 follow-up
当前 init 路径先 await spawnCli(...),随后还会 await prepareCodexNativeTitleGeneration(...),到这两个 await 结束后才执行 pendingMessages.unshift(initialPrompt)。但 spawnCli 内部已经装好 idle detector;CLI 提前出现 prompt-ready 标记时会进入 markPromptReady() -> flushPending()。guard 调整后,init 期间到达的 follow-up 已经在 pendingMessages 中,因此这个 flush 不再是空队列,而会把 follow-up 先写入 PTY。
我在当前 head cbcdd1b8d 上用真实 worker IPC + Codex PTY 路径构造了可重复探针:CLI 在约 2.55s 发出 prompt-ready 标记,同时让 title metadata probe 持续到约 7.03s。日志与实际 stdin 捕获均显示:
- 约 2.55s:
FOLLOWUP_DURING_INIT被flush写入; - 约 7.03s:init prompt 才被
unshift后写入; - 捕获输入中的位置为 follow-up index 6、initial index 39。
这会让 follow-up 在缺少首轮上下文时先执行。turn_input_committed 只证明已进入输入队列,不能证明实际 PTY 写入顺序。
现有新增集成测试没有覆盖这条顺序不变量:它使用 Pi,而该适配器通过 passesInitialPromptViaArgs 把首轮 prompt 放进 argv,不会走这里的 unshift 分支;测试也只断言 received/committed 与无 rejected,没有捕获实际输入顺序。该测试在当前 head 通过、在上一 head 因 follow-up 无法 commit 而失败,能锁住上一版的拒收问题,但不能锁住本轮的反序。
建议在任何可能触发 prompt-ready/flush 的回调安装或执行前,先同步建立首轮 prompt 的队首占位/输入栅栏;或者让 flushPending 在首轮 prompt 尚未完成入队归属时不能越过该栅栏。同时补一条使用“非 argv-baked”适配器、捕获真实 PTY 写入顺序的 worker 集成测试,并确认它在修复前失败、修复后通过。
其他复核结果:
!backend入队分支移到!cliAdapterguard 前,对目前两个sendToPty调用点未发现新的空引用、双写或错误入队路径;guard 仍在所有cliAdapter解引用之前。pnpm build通过。- 聚焦回归 8 个文件、502 个测试全部通过(含 receipt、init concurrency、transfer、dispatcher、dispatch、inflight tracker)。
- 全量测试 809 个文件中 12965 个测试通过、20 个跳过;仅
group-join-shared-routing与doc-comment-daemon-concurrency各有一次 10s hook 超时,二者隔离复跑分别 14/14、17/17 通过,属于全量资源争用,与本 PR 路径无关。 git diff --check通过,工作树干净。
Important
普通飞书消息现在使用两阶段 ACK:Worker receipt 负责 daemon → Worker 传输确认,原有 commit 继续表示 Agent 输入队列已接收。稳态消息、transfer 回放和首轮 init 共用同一套 generation fence 与重试协议。当前 head 为
cbcdd1b8,已修复两轮 review 的 4 项 blocking;PR 仍为 Changes requested,等待复审,未合并。真实 daemon 故障注入尚未执行。如何发现
用户随后要求继续定位:飞书原始消息。现场截图、两次消息的日志时序和完整代码路径收录在事故说明文档。
定位确认:第一条消息已通过飞书接收、权限检查和事件去重,也进入了 daemon 的普通消息处理;Codex transcript 与 Worker 日志中都没有这条消息。第二条消息沿相同入口进入 Worker 并写入 PTY。由此排除了飞书未送达、@ 解析、事件去重、模型不回复和 Worker 重启,故障位于 daemon 向 Worker 交接输入的边界。
上一轮 review 继续发现:原实现只覆盖稳态
message,没有覆盖 transfer 回放和首轮init.prompt;同时把 2 秒 watchdog 绑在 commit 上,慢冷重启会先误报失败、随后真实执行。用户看到什么
init.prompt未到达 Workerworker.send(initMsg),仍可能静默丢失turn_input_rejected;同代安全重试,第二次仍拒绝才提示失败根因
原路径调用
ChildProcess.send()后立即返回true。这个返回值只表示执行了发送调用,不表示 Worker 已处理该 IPC。飞书事件此前已经被去重模块认领,这段内存交接一旦丢失,事件不会再次进入。首版修复又把「IPC 已到 Worker」和「已进入 Agent 输入队列」合成一个 ACK。两者的超时属性不同:IPC receipt 应同步返回;commit 可能受冷启动、插件准备和 Codex 元数据读取影响。用 2 秒 commit 超时判断传输失败,会把正常慢启动当成丢消息。
改了什么
turn_input_received。Worker 的 IPC handler 在认领普通om_turn 后同步发送,任何启动或恢复 await 都在其后。turn_input_committed的原语义。durable dispatch 仍只以 commit 作为输入队列权威状态,不使用 receipt。inflight重复包只补 receipt,committed重复包补 receipt 与 commit,不再次写入 CLI。init.prompt在安装 Worker handler、绑定 generation 后通过同一 tracked sender 发送;重复 init 只接受相同 turn ID。message回放交给 tracked sender,raw/control 输入保持原协议。turn_input_rejected。Worker 明确无法入队时释放本地 inflight fence,daemon 用原 payload 做同代安全重试。sendToPty在检查cliAdapter前先按!backend将 follow-up 放入现有输入队列;init prompt 使用unshift保持在 follow-up 之前。不做跨 Worker 自动重投。旧 Worker 是否已执行但来不及回 ACK 无法可靠判断,跨进程重投可能让外部写操作执行两次。
架构判断
两阶段 ACK 复用仓库已有的 Worker generation、transfer gate 和 durable commit 边界,没有为三条入口分别建立特殊超时:
观察到的修复结果
origin/master(1854ee3b)后,组合回归:9 个文件,456/456 通过。pnpm build、TypeScript、domain audit、dist audit 和git diff --check均通过。codex-app-threads启动时序用例与 1 项 adopted Pi 视口时序用例均不在本 PR 改动面;adopted Pi 隔离复跑已通过,codex-app-threads的 timeout 断言在当前 master 上可独立复现。仍未验证什么