Skip to content

fix(ask): 发卡瞬时失败不再作废 ask + 拉长默认超时,修原生 picker 卡死 - #771

Open
deepcoldy wants to merge 1 commit into
masterfrom
fix/ask-card-dispatch-resilience
Open

fix(ask): 发卡瞬时失败不再作废 ask + 拉长默认超时,修原生 picker 卡死#771
deepcoldy wants to merge 1 commit into
masterfrom
fix/ask-card-dispatch-resilience

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

飞书 AskUserQuestion 卡片答完、CLI 却仍挂在原生选择器、答案不生效(卡片报"此 ask 已失效")。

根因:runHook(cli.ts)只有 result.kind==='answered' 才回 directive;其余任何结局(timedOut / invalidated / postAsk 抛错)一律 passthrough → Claude 转而渲染原生 picker,而此后飞书回调已无阻塞的 hook 通道把答案送回 → picker 挂死。

其中一个高频触发:ask-broker 发卡是 fire-and-forget,发卡 Promise 一旦 reject 就立即 settle(kind:'invalidated')(ask-broker.ts card-dispatch .catch)。但飞书 API 抛错 ≠ 卡片没投出——500/超时常是 partial success(卡片已送达、仅响应报错),此时 ask 被误作废,而群里那张卡仍可点、却喂不回答案。生产 daemon 日志有反复的飞书 500 佐证此路径。

改动(对准共性:减少非 answered 的 settle)

  1. src/im/lark/ask-card.ts——发卡有界重试 + 幂等 uuid:createLarkAskCardDispatcher.send 改为最多 3 次退避重试,传稳定幂等 uuid ask-<askId>(飞书对 1h TTL 内重复请求返回原 message_id → 重试不会重复发卡,partial success 能拿回真实 message_id 而非作废整个 ask)。仅对瞬时错误(无 HTTP status 的网络错 / 429 / 5xx)重试;确定性 4xx(如 400 invalid_message_id、403)立即抛出,让死群尽快作废。
  2. src/cli.ts——runHook 默认超时 1h → 24h(DEFAULT_TIMEOUT_MS=86_400_000,对齐 hook 安装侧 settings.json 里的 timeout:86400s 进程上限):避免"人回复慢→超时→passthrough→picker 卡死";保留有限进程级兜底(永不超时会让一次 CLI turn 无限阻塞,是更糟的失败)。BOTMUX_ASK_TIMEOUT_MS 覆盖不变。

影响面

仅 ask 卡投递 + 超时默认;不动会话/CLI/后端逻辑。仅 claude 家族走 hook directive 受益;coco 仍走 native picker 按键驱动(daemon 独立分支,daemon.tscoco_drive_picker)不受影响。发卡 dispatcher 唯一 caller 是 daemon.tssetAskCardDispatcher(createLarkAskCardDispatcher()) + ask-broker.ts.send(snapshot(ask)),改动收敛。幂等 uuid ask-<randomUUID> 长 40 字符 < 飞书 50 限;对 interactive 消息类型生效。

验证

  • pnpm build 通过
  • 隔离跑 test/ask-card.test.ts + test/cmd-hook.test.ts 全绿(新增 4 例发卡韧性:瞬时 500 重试恢复 / 网络错重试 / 确定 4xx 不重试立即抛 / 超上限重抛;+ 幂等 uuid 断言 + 超时默认 24h/override 断言;顺带把 pre-existing"无效值→3600000"用例改为 86400000)
  • pnpm test 全量:唯一非通过为已知墙钟计时 flake(hook-runner 类)+ 无浏览器/CLI 环境的 e2e baseline(coco/codex/multi-bot),隔离复跑绿,与本改动零耦合

已知未覆盖(独立 follow-up)

"弹卡后重启 daemon"场景:broker 纯内存无持久化,重启即丢 pending ask → hook fetch reject → passthrough → picker。需 broker 持久化/重启重连,是更大的独立一步,不在本 PR 范围。

飞书 AskUserQuestion 卡片答完、CLI 却仍挂在原生选择器、答案不生效的根因:
runHook 只有 result.kind==='answered' 才回 directive,其余任何结局
(timedOut / invalidated / postAsk 抛错)一律 passthrough → Claude 渲染原生
picker,而此后飞书回调已无阻塞的 hook 通道把答案送回 → picker 挂死。

其中一个高频触发:ask-broker 发卡是 fire-and-forget,发卡 Promise 一旦
reject 就立即 settle(invalidated)。但飞书 API 抛错 ≠ 卡片没投出——500/超时
常是 partial success(卡片已送达、仅响应报错),此时 ask 被误作废,而群里
那张卡仍可点、却喂不回答案。生产 daemon 日志有反复的飞书 500 佐证此路径。

改动(对准共性:减少非 answered 的 settle):
- ask-card 发卡改为有界重试(3 次、退避),并传稳定幂等 uuid(ask-<askId>):
  飞书对 1h 内重复请求返回原 message_id,重试不会重复发卡,partial success
  能拿回真实 message_id 而非作废整个 ask。仅对瞬时错误(5xx/429/网络无状态)
  重试;确定性 4xx(如 400 invalid_message_id)立即抛出,让死群尽快作废。
- ask 默认超时 1h → 24h(对齐 hook 安装侧 timeout:86400s 进程上限):避免
  '人回复慢→超时→passthrough→picker 卡死';保留有限进程级兜底(永不超时会
  让一次 CLI turn 无限阻塞,是更糟的失败)。BOTMUX_ASK_TIMEOUT_MS 覆盖不变。

影响面:仅 ask 卡投递 + 超时默认;不动会话/CLI/后端。仅 claude 家族走 hook
directive 受益;coco 仍走 native picker 按键驱动(daemon 独立分支)不受影响。

验证:
- pnpm build 通过
- 隔离跑 test/ask-card.test.ts + test/cmd-hook.test.ts 全绿(新增 4 例发卡
  韧性:瞬时 500 重试恢复、网络错重试、确定 4xx 不重试立即抛、超上限重抛;
  + 幂等 uuid 断言 + 超时默认 24h/override 断言)
- pnpm test 全量:唯一 failed 为已知墙钟计时 flake(hook-runner 类),隔离
  复跑绿,与本改动无关(本改动只碰 ask-card/cli 超时,零跨模块耦合)

下一步待 live 验证(申晗):飞书触发 AskUserQuestion,验证发卡瞬时失败/慢回复
不再退化成卡死 picker。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Codex delta 复审:阻塞合并(现场根因与本 PR 不匹配)

钉定 260d20ea8 的真实 PR delta(相对其 parent 1db7fff74)已逐行复审。两项改动作为防御性 hardening 有价值,但不能作为这次 test2 复现的修复合入:现场证据已经锁定为 daemon restart 丢失内存态,而不是 1h timeout,也没有证据指向首次 dispatch reject。

Blocking:test2 实际跨过了一次 daemon restart,本 PR 不覆盖

现场时间线可以精确对上:

  • ask 卡创建/发出:2026-08-06T13:52:26.907Z / daemon log 13:52:27.019Z;卡面截止时间是 2026/8/7 06:52:26(宿主 PDT),即这次请求实际已经是 24h timeout,不是旧的 1h。
  • Claude daemon 在 2026-08-06T14:14:20.997Z 明确打印 Daemon shutting down...,新进程于 14:14:27.786Z 启动、换了 boot instance;发生在发卡后约 22 分钟
  • 用户到 15:38:45.119Z 才点击,距发卡约 106 分钟,但距卡面 deadline 还差 22 小时以上。

代码链路也完全吻合两个症状:

  1. ask-broker.tspending 是进程内 Map,没有落盘/恢复;restart 后新 daemon 收到旧卡回调,submitAsk() 查不到 askId,直接返回 stale,所以卡提示“此 ask 已失效”。这不需要等 SETTLED_RETENTION_MS=60s GC。
  2. 原 hook 的长连接随旧 daemon 退出而断开,runHook()postAsk catch 返回 passthrough;Claude 随即渲染 native picker,而新 daemon 已没有通道把旧卡答案送回,正好解释终端卡住。

因此:即便把当前 PR 正确部署,只要发卡与作答之间发生 daemon restart,原复现仍会 100% 出现。一次“不经过 restart”的 live 测试只能验证正常路径/两项邻接 hardening,不能验证这次根因。

合并前请二选一并让申晗确认边界:

  • 在本 PR 补 restart-resume 闭环(需要稳定请求 identity + hook 重连/恢复与 broker 持久化或等价方案),并增加 node-pty 发卡 → restart daemon → 点卡 → directive 生效 的真链路验证;或
  • 明确把 PR 重命名/改描述为“发卡瞬时错误重试 + 延长默认等待”的独立 hardening,不要宣称修复本次复现,并把 restart 丢 ask 作为原问题保持阻塞/单独修复后再决定是否先合这部分。

非阻塞但建议一并收紧:无 status = 网络错误 并不成立

isTransientDispatchError() 把所有无 status 的错误都重试,但 sendMessage() / replyMessage() 会把 res.code !== 0 和“成功响应缺 message_id”都重新抛成普通 Error,这些同样没有 status;LarkTransportDisabledError 也没有 status。于是代码/测试声称的“确定业务错误不重试”只覆盖 Axios 4xx,并未覆盖真实 client wrapper 的永久错误。当前最多多打 2 次、增加约 1.2s,影响有限;更准确的做法是保留 typed/provider error code,或仅把 Axios request && !response / 明确网络 code(ECONNRESETETIMEDOUTECONNABORTEDEAI_AGAIN 等)当无响应瞬时错,并补一例 res.code != 0 不重试。

其余三点结论

  • UUID:ask-<randomUUID> 为 40 字符,小于飞书 50 字符上限;每 ask 随机、同 ask 重试稳定,误命中别的消息的概率可忽略。飞书 create/reply 文档都承诺同 UUID 1h 内至多成功投递一次,防重复成立;“重复请求一定返回原 message_id”在公开文档里没有同等明确的文字,建议 live 验证时保留日志确认返回值。发送消息文档 / 回复消息文档
  • 24h:没有找到任何依赖旧 1h 做清理的消费者;代价是 pending Map、HTTP hook 和 CLI turn 最长保留 24h。它与 hook host 的 timeout: 86400 完全同值,最终边界仍会由 host 先杀 hook,因此只是减少“较慢回答”窗口,不是绝对消除 picker fallback,更不解决 restart。
  • base:origin/master 已前进到 b70f160d8,当前 HEAD 仍基于 1db7fff74(1 behind / 1 ahead)。最新 master 未触碰本 PR 的 4 个文件,预计 rebase 无冲突,但合并前仍需 rebase 并重新钉 HEAD。

验证:

  • pnpm build
  • pnpm exec vitest run test/ask-card.test.ts test/cmd-hook.test.ts ✅(2 files / 49 tests)
  • git diff --check 1db7fff74..260d20ea8
  • GitHub:build + JS/TS Analyze 已绿;actions/python Analyze 尚 pending(复审时点)

当前 gh 身份与 PR 作者同为 deepcoldy,无法正式点 Request changes,故用普通 review 明确记录上述合并阻塞。未改代码、未重启 daemon。

@deepcoldy

Copy link
Copy Markdown
Owner Author

复审补充:真实 UUID 探针通过;其余阻塞结论不变

在当前飞书环境对 interactive 消息做了 create 与 reply 两条真实探针:每条路径连续两次提交完全相同的内容与同一 uuid,第二次响应均返回第一次的同一个 message_id,且没有产生重复卡片;测试卡已撤回。因此,本 PR 依赖的“partial success 后用同 UUID 重试可取回原 message_id”语义,在当前租户/API 上实际成立。公开文档只明确承诺“同 UUID 在 1 小时内至多成功投递一次”,没有同等明确地写出返回原 ID,建议保留这条实测证据。

仍有两项需修正或明确:

  1. isTransientDispatchError() 的“无 status = 网络错误”分类过宽。sendMessage() / replyMessage() 会把 res.code !== 0、响应缺 message_id 转成无 status 的普通 ErrorMessageWithdrawnErrorLarkTransportDisabledError 也无 status。因此这些永久/业务错误同样会重试 3 次,与注释和 PR 描述的“仅网络/429/5xx”不一致。影响目前有界(多两次调用、约 1.2s),但应保留 provider 业务码并分类,或至少修正文案并补普通业务错误测试。
  2. 24h 默认值不是仅 Claude family 受益:同一 runHook() 默认也覆盖 OpenCode 与 CoCo(Codex adapter 不解析 ask);PR 影响面说明应同步修正。24h 与 host hook 上限取相同值,只是把 broker 过早超时窗口推后,不能消除 host 先终止、daemon restart、连接断开等 passthrough 路径。

此前记录的主阻塞仍成立:本 PR 是有价值的“发卡瞬时错误重试 + 延长等待”hardening,但不覆盖已定位的 daemon restart 丢失 pending ask/断开 hook 通道,不能作为该现场复现的完整修复宣称合入。建议先明确缩窄标题/问题边界,或由 restart-resume 修复闭环后再决定合入顺序。

本轮验证:

  • pnpm build
  • pnpm exec vitest run test/ask-card.test.ts test/cmd-hook.test.ts ✅(2 files / 49 tests)
  • git diff --check 1db7fff74..260d20ea8
  • 当前 PR head 仍为 260d20ea8;相对最新 origin/master 为 1 个 PR commit / 落后 4 个 master commits,合并前需 rebase 并重新验证。

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.

1 participant