Skip to content

fix(worker): 普通消息缺少提交确认时自动重试 - #741

Open
xiaoxueSunn wants to merge 1 commit into
deepcoldy:masterfrom
xiaoxueSunn:fix/im-worker-delivery-ack
Open

fix(worker): 普通消息缺少提交确认时自动重试#741
xiaoxueSunn wants to merge 1 commit into
deepcoldy:masterfrom
xiaoxueSunn:fix/im-worker-delivery-ack

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

普通飞书消息现在使用两阶段 ACK:Worker receipt 负责 daemon → Worker 传输确认,原有 commit 继续表示 Agent 输入队列已接收。稳态消息、transfer 回放和首轮 init 共用同一套 generation fence 与重试协议。当前 head 为 cbcdd1b8,已修复两轮 review 的 4 项 blocking;PR 仍为 Changes requested,等待复审,未合并。真实 daemon 故障注入尚未执行。

如何发现

@孙晓雪:「最近 botmux 总是漏我的消息 怎么回事?我刚刚发了两次 他没收到?」

用户随后要求继续定位:飞书原始消息。现场截图、两次消息的日志时序和完整代码路径收录在事故说明文档

定位确认:第一条消息已通过飞书接收、权限检查和事件去重,也进入了 daemon 的普通消息处理;Codex transcript 与 Worker 日志中都没有这条消息。第二条消息沿相同入口进入 Worker 并写入 PTY。由此排除了飞书未送达、@ 解析、事件去重、模型不回复和 Worker 重启,故障位于 daemon 向 Worker 交接输入的边界。

上一轮 review 继续发现:原实现只覆盖稳态 message,没有覆盖 transfer 回放和首轮 init.prompt;同时把 2 秒 watchdog 绑在 commit 上,慢冷重启会先误报失败、随后真实执行。

用户看到什么

触发方式 旧表现 修复后表现
稳态普通消息未到达 Worker 飞书事件已被认领,但 Agent 没收到;无重试、无提示 2 秒内无 Worker receipt 时,在同一 generation 重试一次
新话题首条消息或 worker-null 冷启动的 init.prompt 未到达 Worker 首轮走裸 worker.send(initMsg),仍可能静默丢失 init turn 使用同一 receipt watchdog 和 turn ID 去重
transfer 期间缓冲的普通消息回放失败 replacement 回放后立即移出 buffer,没有 ACK 或重试 回放到 replacement generation 时进入同一 tracked sender
crash-diagnostic 冷重启超过 4 秒 先提示「请重发」,原消息随后仍执行;人工重发可能执行两次 Worker 在任何慢启动 await 前回 receipt;慢启动只延迟 commit,不触发传输失败
Worker 收到消息但无法进入 CLI 输入队列 receipt 若直接结束追踪,可能再次静默丢失 Worker 回 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。
  • daemon 的 2 秒 watchdog 只等待 receipt;同一 Worker generation 最多发送两次,不跨进程重投。
  • Worker 在任何 renderer、adapter 或 PTY 副作用前按 turn ID 去重:inflight 重复包只补 receipt,committed 重复包补 receipt 与 commit,不再次写入 CLI。
  • init.prompt 在安装 Worker handler、绑定 generation 后通过同一 tracked sender 发送;重复 init 只接受相同 turn ID。
  • transfer gate 仍负责冻结顺序;replacement generation 建立后,普通 message 回放交给 tracked sender,raw/control 输入保持原协议。
  • 新增 turn_input_rejected。Worker 明确无法入队时释放本地 inflight fence,daemon 用原 payload 做同代安全重试。
  • init 并发窗口内,sendToPty 在检查 cliAdapter 前先按 !backend 将 follow-up 放入现有输入队列;init prompt 使用 unshift 保持在 follow-up 之前。
  • Worker 退出时清理该 generation 的内存记录;stale generation 的 receipt、reject 和 commit 都不能确认新 Worker。

不做跨 Worker 自动重投。旧 Worker 是否已执行但来不及回 ACK 无法可靠判断,跨进程重投可能让外部写操作执行两次。

架构判断

两阶段 ACK 复用仓库已有的 Worker generation、transfer gate 和 durable commit 边界,没有为三条入口分别建立特殊超时:

  • receipt 的权威所有者是当前 Worker IPC handler,只回答「该 generation 已收到 turn」。
  • commit 的权威所有者仍是 Worker 输入队列,只回答「Agent 已接收 turn」。
  • transfer gate 只拥有转移期间的顺序和缓冲;回放完成后恢复普通投递协议。
  • adopt 的 ambiguity journal、durable dispatch 的 lease/receipt、VC receiver 与 silent schedule 的既有规则未改变。

观察到的修复结果

  • rebase 到最新 origin/master1854ee3b)后,组合回归:9 个文件,456/456 通过。
  • focused 覆盖 receipt 超时、IPC callback 失败、Worker reject、stale generation、init 首轮、10 秒慢启动、transfer 回放和 Worker ACK 顺序。新增真实 Worker init + message 并发探针,验证两条 turn 均 received + committed,且 follow-up 不再 rejected。
  • pnpm build、TypeScript、domain audit、dist audit 和 git diff --check 均通过。
  • 最新基线全量:807 个文件通过、2 个文件失败、3 个文件跳过;12,948 项通过、3 项失败、35 项跳过。2 项 codex-app-threads 启动时序用例与 1 项 adopted Pi 视口时序用例均不在本 PR 改动面;adopted Pi 隔离复跑已通过,codex-app-threads 的 timeout 断言在当前 master 上可独立复现。
  • 上一次全量的 4 个非相关失败也已按文件隔离复跑,51/51 通过。

仍未验证什么

剩余检查 需要的环境或原因 影响范围
真实 daemon 下分别阻断稳态、init 和 transfer receipt 会干扰当前正在使用的 Bot,PR 阶段未重启或注入故障 只阻塞发布,不阻塞 PR 审核
GitHub review 与 CI fork PR 暂无 checks,上一轮 review 状态仍为 Changes requested 阻塞合并

@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner August 5, 2026 07:11

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审结论: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 test791 文件通过、1 跳过;12,592/12,598 项通过、6 跳过
  • git diff --check:通过;探针临时改动已撤销,工作树干净

@xiaoxueSunn
xiaoxueSunn force-pushed the fix/im-worker-delivery-ack branch from ed8e445 to 9cbc9ce Compare August 5, 2026 13:52

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论:请求修改。双层 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 ×2turn_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-dedupeworker-ordinary-im-receipt-wiringsession-lifecycle-starttransfer-sessionevent-dispatcherdispatch
  • pnpm test:803 files / 12873 tests 通过;2 个 suite/test 在全量争用下 10s hook 超时(doc-comment-daemon-concurrencygroup-join-shared-routing),分别隔离复跑 17/17、14/14 通过,与本 PR 路径无关

@xiaoxueSunn
xiaoxueSunn force-pushed the fix/im-worker-delivery-ack branch from 9cbc9ce to cbcdd1b Compare August 5, 2026 15:21

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论:请求修改。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_INITflush 写入;
  • 约 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 入队分支移到 !cliAdapter guard 前,对目前两个 sendToPty 调用点未发现新的空引用、双写或错误入队路径;guard 仍在所有 cliAdapter 解引用之前。
  • pnpm build 通过。
  • 聚焦回归 8 个文件、502 个测试全部通过(含 receipt、init concurrency、transfer、dispatcher、dispatch、inflight tracker)。
  • 全量测试 809 个文件中 12965 个测试通过、20 个跳过;仅 group-join-shared-routingdoc-comment-daemon-concurrency 各有一次 10s hook 超时,二者隔离复跑分别 14/14、17/17 通过,属于全量资源争用,与本 PR 路径无关。
  • git diff --check 通过,工作树干净。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants