fix(ask): 发卡瞬时失败不再作废 ask + 拉长默认超时,修原生 picker 卡死 - #771
Conversation
飞书 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
left a comment
There was a problem hiding this comment.
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 log13: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 小时以上。
代码链路也完全吻合两个症状:
ask-broker.ts的pending是进程内Map,没有落盘/恢复;restart 后新 daemon 收到旧卡回调,submitAsk()查不到 askId,直接返回stale,所以卡提示“此 ask 已失效”。这不需要等SETTLED_RETENTION_MS=60sGC。- 原 hook 的长连接随旧 daemon 退出而断开,
runHook()的postAskcatch 返回 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(ECONNRESET、ETIMEDOUT、ECONNABORTED、EAI_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。
复审补充:真实 UUID 探针通过;其余阻塞结论不变在当前飞书环境对 仍有两项需修正或明确:
此前记录的主阻塞仍成立:本 PR 是有价值的“发卡瞬时错误重试 + 延长等待”hardening,但不覆盖已定位的 daemon restart 丢失 pending ask/断开 hook 通道,不能作为该现场复现的完整修复宣称合入。建议先明确缩窄标题/问题边界,或由 restart-resume 修复闭环后再决定合入顺序。 本轮验证:
|
问题
飞书 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.tscard-dispatch.catch)。但飞书 API 抛错 ≠ 卡片没投出——500/超时常是 partial success(卡片已送达、仅响应报错),此时 ask 被误作废,而群里那张卡仍可点、却喂不回答案。生产 daemon 日志有反复的飞书 500 佐证此路径。改动(对准共性:减少非 answered 的 settle)
src/im/lark/ask-card.ts——发卡有界重试 + 幂等 uuid:createLarkAskCardDispatcher.send改为最多 3 次退避重试,传稳定幂等 uuidask-<askId>(飞书对 1h TTL 内重复请求返回原 message_id → 重试不会重复发卡,partial success 能拿回真实 message_id 而非作废整个 ask)。仅对瞬时错误(无 HTTP status 的网络错 / 429 / 5xx)重试;确定性 4xx(如 400 invalid_message_id、403)立即抛出,让死群尽快作废。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.ts的coco_drive_picker)不受影响。发卡 dispatcher 唯一 caller 是daemon.ts的setAskCardDispatcher(createLarkAskCardDispatcher())+ask-broker.ts的.send(snapshot(ask)),改动收敛。幂等 uuidask-<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 范围。