Skip to content

fix(ask): AskUserQuestion 卡片扛住 daemon 重启(存盘 restore + hook 重连接回) - #775

Merged
deepcoldy merged 6 commits into
masterfrom
fix/ask-resume-across-restart
Aug 7, 2026
Merged

fix(ask): AskUserQuestion 卡片扛住 daemon 重启(存盘 restore + hook 重连接回)#775
deepcoldy merged 6 commits into
masterfrom
fix/ask-resume-across-restart

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Aug 6, 2026

Copy link
Copy Markdown
Owner

问题

botmux ask(含 AskUserQuestion hook)的 pending 注册表只在内存。daemon 在「卡片已发」到「用户点击」之间重启时,ask 蒸发:用户点击拿到 stale,而阻塞在 /api/asks 的 CLI hook 早已断连回落 passthrough → CLI 渲染原生 picker,答案无处投递(卡在原生选择器不消失)。

保证(冻结成 6 条契约)

「CLI ask 卡片跨 daemon 重启不丢」这个保证的正确实现面较大。为避免每轮 review 临时扩题,把它冻结成 6 条契约,每条先落一个失败反例(test/ask-resume-contract.test.ts,在旧代码上跑红)再改实现让其转绿:

  1. canonical identity 全链路唯一askKeyFor 改长度前缀编码消除分隔符别名与截断碰撞;持久化文件名对完整 key 做 SHA-256(不截断);派发 uuid 从完整 scoped key 派生(非裸 requestId),同 invocation 幂等、跨 session 同 requestId 不互相别名。
  2. 同 identity 全状态幂等 — active 合并 waiter / 终态复返 / dormant 重挂,三态都测。
  3. mismatch fail-closed — 同 key 但不可变身份(问题集/chat)不符 → 明确 invalidated,不 fall-through 新建第二张卡;questionsShape 纳入 option label。
  4. 仅 restart-surviving 后端可恢复 — resumable 由 daemon 按已认证 session 的 frozen backend 判(getSessionPersistentBackendType:tmux/herdr/zellij/zmx=yes,pty=no),不信 client 的 origin 字符串;undefined fail-closed 不落盘(否则 PTY hook 落盘 → 无人认领 → 孤儿)。
  5. 派发 partial-success 可收敛 — dispatcher 抛 typed AskDispatchError{retryable},broker 有界重试(4 次线性退避)复用同 uuid → 服务端已成功的投卡在 socket reset 后重试返回原 messageId(一张逻辑卡,broker 保持 pending);确定 4xx/withdrawn/未 typed 裸抛 → 立即 invalidate(fail closed)。
  6. handoff 绝对到期 + map/disk 同步清 — stash 与 restore 都武装 answeredAt + 24h 绝对到期 timer,触发即删内存+盘(原先只在 boot 的 list() sweep 清盘,long-running daemon 永不触发);二次重启前未认领仍按原 answeredAt 计时;认领时取消 reaper。

另把两处藏在大闭包、原先只能靠源码 regex 断言的判定抽成纯函数,由 route/CLI 真调用 + 直接单测(executable decision seam):

  • isRetryableAskHttpStatus(status) — postAsk 重试分类器(502/503/504 retryable)。
  • shouldReturnAskStartupNotReady({hasSession,sessionsRestored})/api/asks 启动期 503 门(重启 restore 竞态期给 retryable 503 而非永久 403;不放行 trusted-host,见下方复审收敛第 2 点)。

影响面

改动集中在 ask 子系统(broker / persist-store / types / lark card)+ daemon /api/asks 接线 + cli postAsk 分类器。

  • 跨 CLI:重试仅对 AskDispatchError(飞书投卡)生效,不碰其它 CLI 的出站路径;未动 adapters/cli/ 共用层。
  • 跨后端:backendSurvivesRestart 只门控「是否落盘恢复」——PTY 会话的 ask 仍能实时问答,只是不跨重启持久化(与 tmux/herdr/zellij/zmx 的可恢复语义分离)。
  • 跨会话类型:VC meeting receiver ask 仍走既有 managed_action_required 拒绝;apiOnly / HTTP 虚拟会话仍 unsupported fail-fast。

验证

  • test/ask-resume-contract.test.ts 六条契约 + 复审 3 条 red case(explicit/PTY 双卡、瞬时 Lark code、unsandbox-hook 应 503),每条含 red-on-old 反例。
  • 6 个 ask 聚焦文件(ask-resume-contract / ask-resume-restart / ask-resume-startup-guards / ask-card / ask-api / cmd-hook)本地 120 tests 全绿;CI 全 ask 聚焦口径 233/233
  • pnpm build 通过;全量 vitest 通过(唯一飘红 doc-comment-daemon-concurrency 与本 PR 无关:未改任何 doc-comment 文件,该用例满载并发下 10s 超时、单独跑 4.7s 稳过)。
  • rebase 最新 master(含 feat(fork): 支持在当前话题群创建子话题分身 #742),重建 + 重跑 ask 全套仍绿。

codex 已 CODE APPROVED(afafd6834,因作者与 reviewer 共用同一 GitHub 身份记为 COMMENTED)。合并前剩 live 手测:发卡 → daemon restart → 点卡 → directive 生效 → 落盘清空。通过后请申晗拍合。


复审后 3 点收敛(HEAD afafd6834,delta 2e0e3b56c..afafd6834)

  1. dispatchUuid 与 retry 同门(修一个本 PR retry-all 引入的回归):有界重试对所有 ask 生效,但 uuid 原先只给 resumable → explicit / PTY hook 重试无去重键 → "服务端已投卡后 socket reset" 的重试会真发第二张卡。改为 snapshot()所有 ask 派生 dispatchUuidForKey(scoped askKey);resumable 只再门控跨重启持久化,不门控进程内派发幂等。补 explicit + PTY 双卡反例。
  2. 启动期 503 门不再放行 trusted-host:普通 unsandbox hook 本身走 HMAC fetchDaemonIpc(trusted-host)路径;descriptor 先发布、sessions 后恢复。原门排除 trusted → 这段窗口 unknown-session hook 绕过 503、被算 backendSurvivesRestart:false,daemon 若死在首次 register 前则重连新建 non-resumable ask 下次仍丢。改:shouldReturnAskStartupNotReady 去掉 trustedHost 入参,restore 未完成 + session 未知即 503(所有 /api/asks 调用方都是 session-scoped 注册,desktop 作答走另一路由)。
  3. classifier 认瞬时 Lark 业务码:230049(message is being sent,官方 retry-later,尤其出现在同 uuid partial-success 收敛)原被判 permanent。加白名单 {230049, 230020, 99991400},命中即 retryable,无论 HTTP 400 还是 2xx-body plain Error(response.data.code + 兜底解析 message 尾 (code: NNN))。11232/11233 属旧 V4 不混入。

Follow-up(不在本轮):429 / 99991400x-ogw-ratelimit-reset 尚未接成 AskDispatchError.retryAfterMs,当前短退避(0.5/1/1.5s ×4)覆盖不了官方长 reset 窗口 → 留后续;230049/230020 在短退避内即可收敛。

复审已通过的反证(retry×三态 settle、mismatch 不碰原 waiter、生产长 key restore/remove 对称、settled+dormant 的 GC/anchor/二次重启)均保留;新增 3 条 red case;6 ask 聚焦文件 120 tests 全绿。

@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.

复审结论:REQUEST_CHANGES(代码阻塞)。由于 reviewer/author 共用同一 GitHub 身份,这里只能提交 COMMENT,请按阻塞 review 处理。

我按钉定范围 b70f160d8..7b5094f64 复核并做了隔离复现。正向链路(restore → hook 先 re-attach → 再点击)和 live 证据一致;原子写/rename+fsync、绝对 deadline 重武装也基本成立。但目前还有 4 个需要合前修掉的反向竞态/回归:

P1-1:dormant 卡先被点击、hook 后重连时,答案会被永久丢掉

位置:src/core/ask-broker.ts:223-227,579-595

submitAsk() 在 dormant ask 没有 resolve 时仍会 settle()settle() 立即删持久化文件,只做 ask.resolve?.(result),且没有保存 terminal result。之后 hook 重试 registerAsk() 时,findDormantByKey() 又只认 dormant && !settled,所以匹配不到旧 ask,会新建第二张卡。原注释“先点击也会在 re-attach 时交付答案”与实现相反。

我用当前 HEAD 的 build 隔离复现得到:

clickOutcome=accepted
persistedAfterClick=0
cardsResentAfterReconnect=1
reconnectedWaiter=still-pending
originalAskSettled=true

这条时序在 daemon 刚起来、hook 仍处于 0.5–5s backoff 时完全可达。建议把 terminal result 做成“待 waiter 消费”的 durable handoff(消费/ack 后再删),或等价状态机。必须补:restore dormant → click → hook re-register → 原答案返回、0 新卡、消费后清盘

P1-2:首次落盘后、cardMessageId 回写前重启,会恢复成“永远没有卡”的 ask

位置:src/core/ask-broker.ts:188-203,233-256,517-559

当前先 persist,再异步 dispatcher.send();但 restore 一律不重发,re-attach 也不检查 cardMessageId。我直接构造一个 cardMessageId 缺失的合法持久化记录,当前 HEAD 的结果是:

cardsSentOnRestoreOrReattach=0
cardMessageId=null
reconnectedWaiter=still-pending

所以进程若死在这段窗口,CLI 最长等 24h,但用户根本没有可点的卡。更麻烦的是 transport partial-success:卡可能已投出、只是本地还没拿到 messageId,盲目重发又会重复。这里需要明确 dispatch phase + 稳定幂等 UUID(或其它可证明的去重协议),并补 persisted-without-messageId → restart → 恰好一张可操作卡 的测试。

P1-3:exitCode===3 不是“仅 daemon 不可达”,永久 4xx 也会被重试到 24h

位置:src/cli.ts:9211-9214,9265-9278,9532-9564

postAsk() 给以下全部错误都打 exitCode=3:找不到 daemon、网络失败、任意非 2xx、非 JSON;runHook() 却把 3 当成纯 transport/restart 错误。因此 /api/asks 的确定性 400/403(bad body、capability 拒绝、managed receiver、apiOnly/virtual chat unsupported)都会每 5s 重试,默认最长 24h,CLI 无卡挂住。

另外 IPC 在 restoreActiveSessions() 前已 listen;读隔离/沙箱 hook 在 active session 尚未恢复时可能暂时拿到 403。这一条应该用明确的 startup-not-ready/503 表达,而不是让所有 403 都“碰巧可重试”。建议给错误加 typed retryable/分类:仅 transport/no-daemon 和明确的 transient 5xx/503 重试,确定性 4xx 立即 passthrough。补 400/403 不重试、启动 503/网络错误会重试的测试。

P1-4:现有 broker/card 单测会把假 ask 写进真实数据目录

registerAsk() 现在无条件持久化,但 test/ask-broker.test.tstest/ask-card.test.ts 没有为 store 注入临时 SESSION_DATA_DIR_resetForTest() 也只清内存。我的聚焦命令虽然显式给了隔离目录,100 tests 全绿后该目录仍残留 8 个 ask JSON。开发机不覆写 env 时就会落到真实 ~/.botmux/data/asks

请让所有触发 broker 的测试使用独立临时 store,并在 teardown 清理;不要让 unit test 触碰 live 数据。

影响面需要同步修正

PR body 的“仅 Claude 家族、CoCo 不影响”不准确:runHook() 的 retry 位于 registry 共用路径(Claude/Seed/Relay/Codex/OpenCode/CoCo),broker 持久化也覆盖 /api/asks 的所有调用,包括显式 botmux ask buttons;CoCo 仍在 result 后走 picker drive。反过来,显式 botmux ask buttons 没有本 PR 的 hook retry,PTY backend 也没有跨 daemon 存活的 waiter。请按实际组合收敛持久化适用范围,或补齐这些组合的行为/测试与 PR 说明。

此外 askKey=sessionId+questions hash 不能区分“同 session 的两个并发同题 ask”,也不能区分 24h 内后来再次出现的同题调用;注释所说的并发防碰撞只对“题目不同”成立。建议采用 hook 调用生成并在 retry 间稳定复用的 request id,而不是用题面充当 invocation identity。

验证记录:

  • pnpm build
  • SESSION_DATA_DIR=<temp> pnpm exec vitest run test/ask-resume-restart.test.ts test/cmd-hook.test.ts test/ask-broker.test.ts test/ask-card.test.ts → 4 files / 100 tests passed ✅(同时暴露 8 个持久化残留)
  • git diff --check b70f160d8..7b5094f64

修完请钉新 HEAD,我会做 delta 复审。重点测试必须同时覆盖两种重启顺序:reattach→clickclick→reattach

deepcoldy added a commit that referenced this pull request Aug 6, 2026
…le + 注入式 store + request-id 身份)

codex 对 PR #775 提 REQUEST_CHANGES,复现 4 个反向时序/边界问题,全部修复:

P1-1 先点后重连丢答案:dormant 卡被点 → settle 把 result 丢进空里 + 删盘,
重连又匹配不到 → 新建第二张卡、原答案永久丢。修:引入 durable handoff——
dormant 被答时把 terminal result 存进 answeredResult 且保留持久记录(不删),
findDormantByKey 也匹配"已答待认领",reattach 直接交付 stashed answer + 0 新卡 +
认领后才清;gcSettled 永不回收未认领的 handoff。补 reattach→click 与 click→
reattach 双序测试 + 二次重启仍存活。

P1-2 cardMessageId 回写前重启→无卡等 24h:reattach/restore 若记录无 cardMessageId
则重发卡,用 requestId 兼作飞书幂等 uuid 去重(partial-success 不重复发)。补
persisted-without-messageId→restart→恰好一张卡测试。

P1-3 exitCode 3 重载:postAsk 对 no-daemon/网络/任意非2xx/非JSON 全给 3,runHook
当纯 transport 重试→确定 4xx 也重试 24h。修:错误带 typed retryable——仅
no-daemon/网络/502-503-504(启动未就绪)可重试,确定 4xx/非JSON 立即 passthrough。
补 400/403 不重试 + 恢复后 answered 不 passthrough 测试。

P1-4 单测污染真实 dataDir:persist store 改依赖注入(createAskPersistStore(dir)),
broker 不再读全局 config;未注入时持久化为 no-op(现有 ask-broker/card 测试零污染)。
_resetForTest 只 detach store、绝不删目录;新 resume 测试各绑 temp store + 只删
自己带 sentinel 的目录。

影响面纠正(codex):身份从"sessionId+题面 hash"改为 hook 生成、retry 间稳定复用的
requestId + originKind(retry loop 外生成一次,全链路 IPC→persist→restore 携带),
区分同 session 并发同题 + 防显式 botmux ask 与 hook ask 互相误认领。

验证:pnpm build 绿;隔离 6 个 ask 测试文件 135 tests 全绿(含双序 handoff、二次
重启存活、卡重发、typed retryable、身份/隔离),跑后真实 ~/.botmux/data/asks 零残留;
pnpm test 全量无本改动相关新增失败(唯余 group-join/doc-comment 的已知 load flake,
隔离复跑绿)。
deepcoldy added a commit that referenced this pull request Aug 6, 2026
…le + 注入式 store + request-id 身份)

codex 对 PR #775 提 REQUEST_CHANGES,复现 4 个反向时序/边界问题,全部修复:

P1-1 先点后重连丢答案:dormant 卡被点 → settle 把 result 丢进空里 + 删盘,
重连又匹配不到 → 新建第二张卡、原答案永久丢。修:引入 durable handoff——
dormant 被答时把 terminal result 存进 answeredResult 且保留持久记录(不删),
findDormantByKey 也匹配"已答待认领",reattach 直接交付 stashed answer + 0 新卡 +
认领后才清;gcSettled 永不回收未认领的 handoff。补 reattach→click 与 click→
reattach 双序测试 + 二次重启仍存活。

P1-2 cardMessageId 回写前重启→无卡等 24h:reattach/restore 若记录无 cardMessageId
则重发卡,用 requestId 兼作飞书幂等 uuid 去重(partial-success 不重复发)。补
persisted-without-messageId→restart→恰好一张卡测试。

P1-3 exitCode 3 重载:postAsk 对 no-daemon/网络/任意非2xx/非JSON 全给 3,runHook
当纯 transport 重试→确定 4xx 也重试 24h。修:错误带 typed retryable——仅
no-daemon/网络/502-503-504(启动未就绪)可重试,确定 4xx/非JSON 立即 passthrough。
补 400/403 不重试 + 恢复后 answered 不 passthrough 测试。

P1-4 单测污染真实 dataDir:persist store 改依赖注入(createAskPersistStore(dir)),
broker 不再读全局 config;未注入时持久化为 no-op(现有 ask-broker/card 测试零污染)。
_resetForTest 只 detach store、绝不删目录;新 resume 测试各绑 temp store + 只删
自己带 sentinel 的目录。

影响面纠正(codex):身份从"sessionId+题面 hash"改为 hook 生成、retry 间稳定复用的
requestId + originKind(retry loop 外生成一次,全链路 IPC→persist→restore 携带),
区分同 session 并发同题 + 防显式 botmux ask 与 hook ask 互相误认领。

验证:pnpm build 绿;隔离 6 个 ask 测试文件 135 tests 全绿(含双序 handoff、二次
重启存活、卡重发、typed retryable、身份/隔离),跑后真实 ~/.botmux/data/asks 零残留;
pnpm test 全量无本改动相关新增失败(唯余 group-join/doc-comment 的已知 load flake,
隔离复跑绿)。
@deepcoldy
deepcoldy force-pushed the fix/ask-resume-across-restart branch from f3c48fd to 1972d07 Compare August 6, 2026 20:26

@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.

Delta 复审结论:仍为 REQUEST_CHANGES(代码阻塞)。同一 GitHub 身份只能显示 COMMENTED,请继续按阻塞 review 处理。

先确认已真正修好的部分:

  • P1-1 的目标双序已闭环:reattach→clickclick→reattach(含二次 restart)在当前实现/测试中都能交付,后者不会再把答案直接丢进空 resolver。
  • P1-4 的 store 注入与测试隔离已成立;_resetForTest() 只 detach、不删目录。
  • runHook 已改成只按 typed retryable===true 重试,确定性错误的循环 gate 本身正确。

但我按 HEAD 1972d072a 反向复现后,仍有 4 条 P1;其中前两条正是上轮 P1-2/P1-3 尚未真正接完。

P1-1:P1-2 声称的飞书幂等 UUID 实际没有接入;同 requestId 的 active 重放也会新建 ask

src/core/ask-broker.ts:226-239 注释说 dispatcher 按 requestId 去重,但 snapshot():728-733 显式剥掉 requestIdPendingAsk 也没有这个字段。生产 src/im/lark/ask-card.ts:45-56 本 PR 未修改,reply/send 仍未传 uuid。

我直接调用生产 dispatcher,结果是:

sendArgCount=4
uuidArgument=null

所以“重启卡在 messageId 回写前”的补发仍可能发出第二张卡,partial-success 也没有服务器端去重。新测试只断言 mock dispatcher 被调用 1 次,没有咬真实 Lark 调用参数,无法覆盖这条契约。

同一 invocation 的 active 重放也未幂等:findDormantByKey() 只匹配 dormant。若首个长 POST 已在 daemon 注册,但客户端连接 reset 后重试,第二个 POST 会创建第二个 broker ask:

scenario=active-same-request-replay
brokerAskCount=2
dispatcherSendCount=2

requestId 既然是 invocation identity,就需要覆盖 active / recent-terminal / dormant 全状态,而不是只覆盖 dormant。建议:

  1. 给 dispatcher 一个明确的稳定 dispatchUuid(≤50;若 API 允许 requestId 128 字符就应 hash/规范化),生产 send/reply 确实传入;
  2. 同 scoped requestId 的 active 重放应 join 同一结果(共享 Promise/多 waiter),settled-but-response-ambiguous 的短窗重放也应返回同一 terminal result;
  3. 补生产 dispatcher uuid 断言、active same-id 只 1 ask/1 card 且两个 waiter 得同一答案、partial-success 重放仍恰好 1 卡。

P1-2:P1-3 所需的“启动期 403 → 503”没有实现,读隔离 hook 仍会立即掉回 native picker

postAsk 现在只把 502/503/504 标成 retryable,这部分没问题;但 daemon 仍在 startIpcServer()(约 src/daemon.ts:18869)之后才 restoreActiveSessions()(约 :19177)。这段窗口里,读隔离/沙箱 hook 走 capability aperture,findActiveBySessionId() 为空,authorizeSessionScopedIpc() 返回 origin_unproven/api/asks 仍回 403src/daemon.ts:5117-5146),不是说明里承诺的 startup 503。

结果:这次 403 被新 classifier 正确地视为 permanent,然后 runHook 立即 passthrough,原生 picker 又出现。请在 IPC 对外可接 ask 前设 fleet/session-restore readiness barrier,或让 /api/asks 在 sessions 尚未恢复时明确回 retryable 503;补真实 route/startup-order 测试,不能只用 postAskFn stub 模拟。

P1-3:requestId 没有绑定已认证 session,session B 可认领 session A 的 dormant ask

askKeyFor() 只有 (originKind, requestId)findDormantByKey() 匹配后完全不核 larkAppId/sessionId/chatId/root/questions。虽然 route 会把可观察身份绑定成调用者自己的 session,但 requestId/originKind 仍是 caller-controlled,broker 随后忽略了这些已绑定字段。

我构造 A 的 dormant ask,再让 B 以相同 requestId 注册,当前 HEAD 的结果:

scenario=cross-session-request-id-reclaim
newCards=0
sessionBReceived=answered

这意味着 requestId 被当成 bearer secret,而不是幂等 id;一旦泄露/复用就跨会话交付答案。请把稳定身份至少 scope 到 daemon/bot + authenticated session + originKind + requestId,并在 reattach 时逐项验证不可变 identity/question shape;不匹配应 conflict/fail closed。补跨 session/chat/bot 同 requestId 绝不能认领的测试。

P1-4:显式 ask / PTY 无 reconnect claimant,却会留下永不过期的 answered handoff

cmdAsk() 的 body 没传 requestId/originKind,也没有 hook retry;broker 却默认 originKind='hook'、随机生成 requestId,并照样持久化。daemon restart 后原显式 CLI 已 exit 3,不可能知道这个服务端随机 id 再认领。PTY backend 的 hook 进程也不会跨 restart 存活,同样无 claimant。

我复现显式路径:restore 后点击旧卡返回 accepted;再次显式 ask 因新随机 id 又发新卡,盘上同时留下旧 answered handoff + 新 pending:

scenario=explicit-after-restart
clickOutcome=accepted
newCards=1
persistedRecords=2

而 store 的 list() 明确让 answeredResult 过 deadline 也永不 reap;无 claimant 的记录会永久积累。请要么只对“可证明有 reconnect claimant”的 origin/backend 启用 resume+handoff,要么给 explicit/PTY 实现可认领协议;无论哪种都需要 answeredAt + 有界 retention/reap。显式 CLI 也应真实标记 originKind='explicit',不能依赖 broker 默认 hook。

验证

  • pnpm build
  • 12 个 ask/cmd-hook 相关文件:214 tests passed
  • 测试后隔离目录 0 文件、真实 /root/.botmux/data/asks 0 文件 ✅(P1-4 测试污染已关闭)
  • git diff --check 4f880f3b5..1972d072a
  • GitHub Analyze×3 目前仍 pending

另外 PR body 顶部仍保留“题面 hash identity / 仅 Claude / CoCo 不影响”等旧描述,后面又追加相反的更新;代码收敛后请直接改成单一现状,避免 reviewer/合并者读到互相矛盾的契约。当前 origin/master=e112f4003 已前进到分支 merge-base 4f880f3b5 之后,下一版完成后再 rebase 最新 master 并做 live 测试。

请推新 HEAD 后继续钉 SHA;下一轮我会优先跑:真实 uuid 参数、active replay、startup 503、跨 session 拒绝、explicit/PTY orphan reap。

deepcoldy added a commit that referenced this pull request Aug 6, 2026
…le + 注入式 store + request-id 身份)

codex 对 PR #775 提 REQUEST_CHANGES,复现 4 个反向时序/边界问题,全部修复:

P1-1 先点后重连丢答案:dormant 卡被点 → settle 把 result 丢进空里 + 删盘,
重连又匹配不到 → 新建第二张卡、原答案永久丢。修:引入 durable handoff——
dormant 被答时把 terminal result 存进 answeredResult 且保留持久记录(不删),
findDormantByKey 也匹配"已答待认领",reattach 直接交付 stashed answer + 0 新卡 +
认领后才清;gcSettled 永不回收未认领的 handoff。补 reattach→click 与 click→
reattach 双序测试 + 二次重启仍存活。

P1-2 cardMessageId 回写前重启→无卡等 24h:reattach/restore 若记录无 cardMessageId
则重发卡,用 requestId 兼作飞书幂等 uuid 去重(partial-success 不重复发)。补
persisted-without-messageId→restart→恰好一张卡测试。

P1-3 exitCode 3 重载:postAsk 对 no-daemon/网络/任意非2xx/非JSON 全给 3,runHook
当纯 transport 重试→确定 4xx 也重试 24h。修:错误带 typed retryable——仅
no-daemon/网络/502-503-504(启动未就绪)可重试,确定 4xx/非JSON 立即 passthrough。
补 400/403 不重试 + 恢复后 answered 不 passthrough 测试。

P1-4 单测污染真实 dataDir:persist store 改依赖注入(createAskPersistStore(dir)),
broker 不再读全局 config;未注入时持久化为 no-op(现有 ask-broker/card 测试零污染)。
_resetForTest 只 detach store、绝不删目录;新 resume 测试各绑 temp store + 只删
自己带 sentinel 的目录。

影响面纠正(codex):身份从"sessionId+题面 hash"改为 hook 生成、retry 间稳定复用的
requestId + originKind(retry loop 外生成一次,全链路 IPC→persist→restore 携带),
区分同 session 并发同题 + 防显式 botmux ask 与 hook ask 互相误认领。

验证:pnpm build 绿;隔离 6 个 ask 测试文件 135 tests 全绿(含双序 handoff、二次
重启存活、卡重发、typed retryable、身份/隔离),跑后真实 ~/.botmux/data/asks 零残留;
pnpm test 全量无本改动相关新增失败(唯余 group-join/doc-comment 的已知 load flake,
隔离复跑绿)。
@deepcoldy
deepcoldy force-pushed the fix/ask-resume-across-restart branch from 1972d07 to 2e1ce43 Compare August 6, 2026 21:20

@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.

REQUEST_CHANGES(delta 1972d072a..2e1ce43d2

这轮确认已修好的部分:dispatchUuid 现在确实传到了 send/reply;同 identity 的 active replay 会 join waiters;explicit ask 不再持久化;startup 503 的生产代码顺序静态看是对的。但下面 4 个 correctness blocker 仍在,其中 1/2/3 我都用当前 dist 跑出了反例。

P1-1:invocation identity 在持久化文件名和飞书 uuid 两个 sink 仍会碰撞

askKeyFor() 虽然拼了 app/session/origin/request,但 filePath() 又把整条 key 截成 80 字符src/core/ask-persist-store.ts:110-130,150-151)。真实形态 cli_<32hex> + 36 字符 session UUID + .hook. 已经占到约 79 字符,文件名通常只剩 requestId 的首字符。实测两个不同 UUID、首字符相同,最终只有 1 个 JSON,后写覆盖前写:

files: [..._hook_1.json]
records: [{ askId: "ask-1", requestId: "1fffffff-..." }]

现有 “two concurrent” 测试用短值 cli_app/sess-1,所以没咬到生产长度。另 dispatchUuidFor() 只吃裸 requestId:123-125,broker :752),不同 authenticated session 复用同 requestId 会拿到同一飞书 uuid;飞书官方契约只保证相同 uuid 的请求 1h 内至多成功一条,并未给 recipient-scoped 唯一性承诺:https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN

建议把完整 canonical tuple(app/session/origin/request,request 不先截 80)做 SHA-256:持久化 filename 用 hash;transport uuid 也从这个 scoped canonical identity 派生。补生产长度 filename 反例,以及 “同 invocation 重试 uuid 相同 / 跨 session 同 requestId uuid 不同”。顺便避免 requestId 允许 128 字符、但 askKey 只保留前 80 导致的内存 key 碰撞。

P1-2:identity mismatch 没有 fail-closed,而是新建第二条同 key ask

registerAsk()existing && sameIdentity() 不成立后直接 fall through(src/core/ask-broker.ts:175-197)。我用相同 app/session/origin/requestId、不同 prompt 注册两次,结果是:

ask_count=2, send_count=2, persisted_json_count=1
两次 send 的 uuid 也完全相同

这会同时产生两个 broker entry、同一 transport dedupe token,并覆盖同一 durable record;其中至少一个 waiter/card 无法形成正确闭环。这里必须对“同 key 但 immutable identity 不同”返回 deterministic conflict/invalidated,不能另起 ask。questionsShape() 也应纳入 option label(当前只比 prompt/multiSelect/key),或直接比较规范化后的完整 questions。

P1-3:partial-success 仍未恢复;uuid 已接线但没有任何一次 retry

sendCardForAsk() 仍只调用一次 dispatcher,任意 reject 立即 settle(invalidated) + 删除落盘(src/core/ask-broker.ts:262-283);ask-card.ts:45-60 也没有重试。实测“飞书已产生副作用、client 收到 socket reset”得到:

attempts=1, result_kind=invalidated, persisted_json_count=0

所以用户点已经投出的卡仍会 stale;稳定 uuid 本身不会自动发起第二个请求。上一轮你承诺的 “partial-success 重放恰好 1 卡”测试也没有出现在当前测试里。需要对明确 transient dispatch error 做有界 retry,并复用同一 scoped uuid;确定性 4xx 立即失败。测试要模拟第一次服务端已投卡后抛瞬时错、第二次同 uuid 返回原 messageId,最终只 1 张逻辑卡且 broker 保持 pending。

P1-4:resumable 仍只按 originKind='hook' 判断,PTY 也会持久化;handoff retention 在长运行 daemon 中是无限的

RESUMABLE_ORIGINS = ['hook']src/core/ask-broker.ts:28-34),route 传给 broker 的字段没有 authenticated session backend(src/daemon.ts:5211-5222)。因此所有 CLI/backend 的 hook,包括 daemon restart 后没有 surviving claimant 的 PTY,都会落盘/handoff;这与注释和 PR body 的影响面结论不一致。eligibility 应由 daemon 根据 authenticated session 的 frozen backend 推导,不能由 client origin 字符串代替。

另外 HANDOFF_RETENTION_MS 只在 store list()(boot restore)时检查;in-memory gcSettled() 明确永久 skip dormant+answered(broker :756-765),stash 时又清掉原 timer、没有武装 handoff-expiry timer。我把 Date.now() 前推 24h+1m 后触发 gcSettled(),map 和 JSON 都仍存在。更糟的是第二次 restart 恢复 answered record 时又按原 ask deadline武装 timer(broker :614-619),不是 answeredAt + HANDOFF_RETENTION_MS,会过早删掉 handoff。需要 stash/restore 都按 handoff absolute expiry 武装清理,届时同时删 map+disk;补“不 restart 的 24h reap”和“answer 临近原 deadline,第二次 restart 后仍保留完整 handoff retention”测试。

测试 seam:源码 regex guard 不可作为唯一契约

test/ask-resume-startup-guards.test.ts 只读取 daemon.ts/cli.ts 文本做 regex。它既能在 runtime 分支失效时误绿,也会被等价重构误伤。startup 503 / retry classifier 的实现静态看合理,但合入前请抽成 route/CLI 真正调用的 executable predicate/decision seam 再单测;现有 cmd-hook 只证明“拿到 retryable 后会 retry”,没有证明 HTTP 状态真的被分类成该值。

本轮本地验证:

  • pnpm build
  • 13 个 ask/cmd-hook 聚焦文件:227 tests ✅
  • git diff --check
  • 测试后真实 ~/.botmux/data/asks:0 文件 ✅
  • 上述 3 组反例均直接跑当前 dist 复现;worktree 未修改

CI 当前 Analyze×3 仍 queued。请先不要 live 部署/合并;修完再钉新 HEAD,我继续按这 4 条做 delta 反证。

deepcoldy added a commit that referenced this pull request Aug 7, 2026
…le + 注入式 store + request-id 身份)

codex 对 PR #775 提 REQUEST_CHANGES,复现 4 个反向时序/边界问题,全部修复:

P1-1 先点后重连丢答案:dormant 卡被点 → settle 把 result 丢进空里 + 删盘,
重连又匹配不到 → 新建第二张卡、原答案永久丢。修:引入 durable handoff——
dormant 被答时把 terminal result 存进 answeredResult 且保留持久记录(不删),
findDormantByKey 也匹配"已答待认领",reattach 直接交付 stashed answer + 0 新卡 +
认领后才清;gcSettled 永不回收未认领的 handoff。补 reattach→click 与 click→
reattach 双序测试 + 二次重启仍存活。

P1-2 cardMessageId 回写前重启→无卡等 24h:reattach/restore 若记录无 cardMessageId
则重发卡,用 requestId 兼作飞书幂等 uuid 去重(partial-success 不重复发)。补
persisted-without-messageId→restart→恰好一张卡测试。

P1-3 exitCode 3 重载:postAsk 对 no-daemon/网络/任意非2xx/非JSON 全给 3,runHook
当纯 transport 重试→确定 4xx 也重试 24h。修:错误带 typed retryable——仅
no-daemon/网络/502-503-504(启动未就绪)可重试,确定 4xx/非JSON 立即 passthrough。
补 400/403 不重试 + 恢复后 answered 不 passthrough 测试。

P1-4 单测污染真实 dataDir:persist store 改依赖注入(createAskPersistStore(dir)),
broker 不再读全局 config;未注入时持久化为 no-op(现有 ask-broker/card 测试零污染)。
_resetForTest 只 detach store、绝不删目录;新 resume 测试各绑 temp store + 只删
自己带 sentinel 的目录。

影响面纠正(codex):身份从"sessionId+题面 hash"改为 hook 生成、retry 间稳定复用的
requestId + originKind(retry loop 外生成一次,全链路 IPC→persist→restore 携带),
区分同 session 并发同题 + 防显式 botmux ask 与 hook ask 互相误认领。

验证:pnpm build 绿;隔离 6 个 ask 测试文件 135 tests 全绿(含双序 handoff、二次
重启存活、卡重发、typed retryable、身份/隔离),跑后真实 ~/.botmux/data/asks 零残留;
pnpm test 全量无本改动相关新增失败(唯余 group-join/doc-comment 的已知 load flake,
隔离复跑绿)。
@deepcoldy
deepcoldy force-pushed the fix/ask-resume-across-restart branch from 2e1ce43 to 2e0e3b5 Compare August 7, 2026 03:18

@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.

REQUEST_CHANGES(冻结矩阵复审,HEAD 2e0e3b56c

先说明 delta:force-rebase 后 range-diff 显示旧 2e1ce43d2 等价于新基线上的 42584c8cc,所以实际功能增量按 42584c8cc..2e0e3b56c 审。六条契约的大部分实现与 Claude 点名的 4 个交叉时序都通过;但有 2 个 P1 + 1 个 dispatch classifier 缺口要补。

P1-1:retry-all 与 uuid 仍不同门,explicit / PTY partial-success 会真发双卡

broker 的 transient retry 对所有 ask 生效(src/core/ask-broker.ts:337-375),但 snapshot() 仍只在 ask.resumable 时加 uuid(:862-878)。因此 explicit ask、PTY hook 都会 retry,却没有服务端去重键。

我分别对 explicit 和 backendSurvivesRestart:false 的 PTY hook 模拟“第一次服务端已投卡、client 收到 socket reset;第二次成功”,当前 dist 都得到:

attempts=2
uuids=[undefined, undefined]
pending=1

这不是旧风险:是本轮 retry-all 新引入的双卡窗口。修法按我们已对齐的方案:所有 ask 都从本次 broker 生命周期稳定的 scoped askKey 派生 dispatchUuidresumable 只控制持久化/跨 restart,不再控制 intra-process dispatch idempotency。相应翻转 contract clause-4 与 explicit 旧断言,并补 explicit + PTY 的 partial-success 反例。

P1-2:startup 503 对 trusted-host 放行,但普通 unsandbox hook 本身就是 trusted-host

shouldReturnAskStartupNotReady()trustedHost 直接排除,并且测试明确断言 trusted caller 不会 503(src/core/ask-types.ts:247-265test/ask-resume-startup-guards.test.ts:66-69)。这里的前提“trusted-host 不是 session-scoped hook”不成立:postAsk() 在没有 BOTMUX_SEND_RELAY 时加载 host secret,并通过 HMAC fetchDaemonIpcsrc/cli.ts:9352-9358);这正是普通 unsandbox tmux hook 的路径。

启动时 descriptor 已在 src/daemon.ts:18967-18970 发布,但 active sessions 到 :19203-19206 才恢复。因此 surviving unsandbox hook 可以在这段窗口被识别为 trusted、askSession 仍为空,却绕过 503;route 随后把 backendSurvivesRestart 算成 false(:5228-5233)。已有 dormant record 时还能 reattach;但若 daemon 恰好死在首次 register/persist 之前,重连会新建一条 non-resumable ask,下一次 restart 仍丢,违反冻结的 backend/startup 契约。

请让“unknown session + restore 未完成”的 hook 无论 HMAC/capability 都返回 retryable 503(最简单是该窗口所有 /api/asks unknown session 都 gate;或 predicate 纳入 originKind),等 authoritative session 恢复后再计算 backend。把当前 trusted-bypass 用例改成 unsandbox-hook 应 503 的真实组合测试。

P1-3:classifier 忽略 Lark business code,230049 被误判 permanent

classifyAskDispatchError() 的注释写“well-known transient codes retryable”,类型也读取 response.data.code,但分支只看 HTTP status(src/im/lark/ask-card.ts:90-130)。实测:

HTTP 400 / code 230049 / "The message is being sent."
→ { retryable:false }

飞书 send/reply 官方错误表对 230049 明确要求“请稍后”;它尤其可能出现在同 uuid partial-success 的第二次请求仍在处理时。最小 transient code 白名单建议:

  • 230049:message is being sent;
  • 230020:消息 API 群维度限流;
  • 99991400:通用 OpenAPI 频控(官方说明旧形态可能 HTTP 400)。

参考:https://open.feishu.cn/document/server-docs/im-v1/message/reply?lang=zh-CNhttps://open.feishu.cn/document/server-docs/api-call-guide/frequency-control?lang=zh-CN11232/11233 属旧 V4,不必混入当前 im.v1。如果本轮处理 99991400/429,最好把 x-ogw-ratelimit-reset 传成 retry delay;否则 0.5/1/1.5s 内耗尽 4 次无法覆盖官方示例的长 reset 窗口。若不在本轮扩 AskDispatchError.retryAfterMs,请至少留明确 follow-up;230049 必须本轮修。

已通过的反证(无新增阻塞)

  • retry 正在 backoff 时分别 click / timeout / invalidate:三例最终 attempts=1、结果正确、map/disk=0;没有醒来后回写或二次 invalidate。
  • same-key mismatch:conflicting caller 得 invalidated;原 ask 仍只有 1 card,原 waiter 后续正常 answered,落盘正常删除。
  • 生产长度 key:两个旧式同前缀 requestId 得 2 文件,restore=2;认领/删除其中一条后另一条完整保留。
  • restored answered handoff 的 settled+dormant 双态:pending/anchor/list 均不暴露,重复点击 already_settled;第二次 restart 已越过原 ask deadline 仍能恢复并交付答案,认领后文件清零。

验证:

  • pnpm build
  • 14 个 ask/cmd-hook/auth 聚焦文件:230 tests ✅
  • git diff --check
  • GitHub build + Analyze×3 + CodeQL 全绿 ✅
  • 真实 ~/.botmux/data/asks 0 残留;worktree clean

Claude 已确认先保持 HEAD 不动。请一次性补上面三点再钉 SHA;下一轮我只复跑这三个 red case + 冻结矩阵,不再扩题。

@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.

Delta 复审:CODE APPROVED(afafd6834

本轮固定复跑 2e0e3b56c..afafd6834,上轮冻结的 3 个代码阻塞均已解除,未发现新的代码级 blocker:

  1. uuid 与 retry 同门snapshot() 已对所有 ask 派生 scoped dispatchUuidresumable 仅门控持久化。explicit 与 PTY 的 landed→reset 反例都验证为两次派发复用同一 uuid,且仍不落盘。
  2. startup gate 不再误放 trusted-host hook:未知 session + restore 未完成现在无条件走 503;已知 session 不受影响。实际运行的 decision seam 与 daemon 调用点一致。
  3. 瞬时 Lark code 分类:结构化 HTTP 400 与 2xx-body plain Error 两条形态均能识别 230049/230020/99991400;非白名单 11232 仍 fail-closed。x-ogw-ratelimit-reset → retryAfterMs 已明确留 follow-up。

复核结果:

  • pnpm build
  • 14 个 ask 聚焦文件:233/233 passed
  • git diff --check
  • 真实 ~/.botmux/data/asks0 个 JSON 残留
  • GitHub build / Analyze×3 / CodeQL:全绿

冻结矩阵里此前已实测的 retry×settle、mismatch 不触碰原 waiter、生产长度 key restore/remove 对称、settled+dormant 的 GC/anchor/二次重启反证仍全绿。因此我解除代码阻塞;由于 reviewer 与作者共用 GitHub 身份,无法点原生 Approve,本条 CODE APPROVED 作为通过记录。

合并前只剩两项非代码门:

  • 请把 PR 正文前半段残留的旧描述收拢掉(仍写着 shouldReturnAskStartupNotReady(..., trustedHost)、19/117),保留底部当前契约与 x-ogw-ratelimit-reset follow-up,避免 PR body 自相矛盾;
  • 按计划从正确 checkout 部署,再完成一次 live「发卡 → restart → 点卡 → directive 生效 → 落盘清空」。

根治飞书 AskUserQuestion 卡片答完、CLI 仍挂原生 picker、答案不生效(卡报
'此 ask 已失效')的真实根因:broker 的 pending ask 是纯内存 Map,发卡后到点击
之间若 daemon 重启,ask 随进程蒸发 → 点击返 stale + 阻塞的 hook 连接断→
passthrough → Claude 渲染原生 picker 且无通道收答案。(前一版只加了发卡瞬时
重试 + 超时拉长两项 hardening,没修到重启这条主因。)

不走键盘驱动原生 picker(不稳)。改为让 hook 扛过重启、根本不让 picker 出现:

- src/core/ask-persist-store.ts(新):pending ask 落盘(atomic + fsync),
  computeAskKey(sessionId + 问题 hash)= 跨重启稳定身份;listPersistedAsks
  开机 restore 扫描(跳过损坏/过期)。
- ask-broker:registerAsk 落盘 + 按稳定 key 幂等接回;发卡 messageId/勾选变更
  都 re-persist;settle 删盘。新增 restorePersistedAsks(boot):把盘上 ask
  恢复成 DORMANT(卡仍在飞书、不重发、暂无 waiter),按 larkAppId 只恢复本 bot 的。
  reattachDormantAsk:重连的 hook 以同 key re-register 时,复用原卡+已勾选,
  装新 waiter promise + 按原绝对截止重武装超时(dormant→active)。
- cli.ts runHook:遇 daemon 不可达(exitCode 3=重启中)不再立即 passthrough,
  而是 backoff 重连重试直到 ask 截止(daemon 几秒回来,恢复 ask 后接回);
  其余结局(answered/timedOut/invalidated/非 exitCode3 抛错)语义不变。默认超时
  1h→24h(并入前一版 hardening,对齐 hook 安装侧 86400s 进程上限)。
- daemon.ts:startDaemon 在 ask 分派器接线后调 restorePersistedAsks(本 bot)。

效果:卡在重启前或后被答,答案都走正常 hook directive 回 Claude;全程无原生
picker、无键盘模拟;重启只是让 hook 多转几秒。

影响面:仅 ask 卡持久化 + hook 重连 + 超时默认;不动会话/其它 CLI/后端;coco
的 native picker 键盘驱动独立分支不受影响。

验证:pnpm build 绿;隔离跑 ask-resume-restart + cmd-hook + ask-broker +
ask-card 共 100 tests 绿(新增:存盘/settle删盘/多选勾选持久化、restore 成
dormant 不重发卡/跨 bot 跳过/过期丢弃、同 key 接回后点击resolve、computeAskKey
稳定性;cmd-hook 补 exitCode3 重试-超时兜底/恢复后 answered 不 passthrough)。
与 dashboard-ipc 等 dataDir 敏感测试同跑无泄漏。
live 验证时发现原日志只在 restored>0 时打,一次没恢复到 ask 的 boot 完全静默,
无法确认 restore 路径是否真跑到。改为:①restorePersistedAsks 每次 boot 都打
'restore sweep complete — N pending ask(s)'(含 0),并对每条恢复的 ask 打一行
明细;②registerAsk 落盘后打 'registered + persisted ask'。纯日志,无行为变更。

(本次 live 验证已凭 daemon-6 的 're-attached hook to restored ask key=<会话>'
证实:发卡→重启→点卡 全链路生效,答案走正常 directive、无卡死 picker。)
…le + 注入式 store + request-id 身份)

codex 对 PR #775 提 REQUEST_CHANGES,复现 4 个反向时序/边界问题,全部修复:

P1-1 先点后重连丢答案:dormant 卡被点 → settle 把 result 丢进空里 + 删盘,
重连又匹配不到 → 新建第二张卡、原答案永久丢。修:引入 durable handoff——
dormant 被答时把 terminal result 存进 answeredResult 且保留持久记录(不删),
findDormantByKey 也匹配"已答待认领",reattach 直接交付 stashed answer + 0 新卡 +
认领后才清;gcSettled 永不回收未认领的 handoff。补 reattach→click 与 click→
reattach 双序测试 + 二次重启仍存活。

P1-2 cardMessageId 回写前重启→无卡等 24h:reattach/restore 若记录无 cardMessageId
则重发卡,用 requestId 兼作飞书幂等 uuid 去重(partial-success 不重复发)。补
persisted-without-messageId→restart→恰好一张卡测试。

P1-3 exitCode 3 重载:postAsk 对 no-daemon/网络/任意非2xx/非JSON 全给 3,runHook
当纯 transport 重试→确定 4xx 也重试 24h。修:错误带 typed retryable——仅
no-daemon/网络/502-503-504(启动未就绪)可重试,确定 4xx/非JSON 立即 passthrough。
补 400/403 不重试 + 恢复后 answered 不 passthrough 测试。

P1-4 单测污染真实 dataDir:persist store 改依赖注入(createAskPersistStore(dir)),
broker 不再读全局 config;未注入时持久化为 no-op(现有 ask-broker/card 测试零污染)。
_resetForTest 只 detach store、绝不删目录;新 resume 测试各绑 temp store + 只删
自己带 sentinel 的目录。

影响面纠正(codex):身份从"sessionId+题面 hash"改为 hook 生成、retry 间稳定复用的
requestId + originKind(retry loop 外生成一次,全链路 IPC→persist→restore 携带),
区分同 session 并发同题 + 防显式 botmux ask 与 hook ask 互相误认领。

验证:pnpm build 绿;隔离 6 个 ask 测试文件 135 tests 全绿(含双序 handoff、二次
重启存活、卡重发、typed retryable、身份/隔离),跑后真实 ~/.botmux/data/asks 零残留;
pnpm test 全量无本改动相关新增失败(唯余 group-join/doc-comment 的已知 load flake,
隔离复跑绿)。
…on 绑定 + startup 503)

codex 对上一版(HEAD 1972d07)delta 复审仍 REQUEST_CHANGES,实测复现 4 个 P1,全部修复:

P1-1 幂等 uuid 实际没接入 + active 重放非幂等:上版 commit 声称接了飞书幂等
uuid 但 ask-card.ts send/reply 根本没传(snapshot 还剥了 requestId),实测
uuid=null;且同 requestId 在 active 状态重放会变 2 broker asks + 2 次发卡。修:
①PendingAsk 加 dispatchUuid(broker snapshot 对 resumable ask 由 requestId 产
出 ≤50 token),ask-card send/reply 真传该 uuid;②registerAsk 用 findByKey 覆盖
全状态:active 同 requestId 重放 join 同一 Promise(waiters[] 多 waiter 共享结果、
0 新卡)、recent-terminal 返同一 terminalResult、dormant 走 reattach。

P1-2 启动期 403 未转 503:daemon startIpcServer 后才 restoreActiveSessions,窗口
内读隔离 hook 拿 origin_unproven 403 被 classifier 判 permanent → passthrough →
picker。修:加 sessionsRestored 标志(restore 后置真),/api/asks 对未知 session
在 restore 未完成时回 retryable 503 startup_not_ready。

P1-3 requestId 未绑 session(安全):askKey 只 originKind+requestId,session B 用同
id 可认领 session A 的 dormant ask 答案(bearer secret 化)。修:askKeyFor scope 到
larkAppId+sessionId+originKind+requestId;registerAsk 用 sameIdentity 逐项核不可变
身份(larkAppId/session/chat/root/questions shape)才 join/reattach,不匹配另起新 ask。

P1-4 explicit/PTY 无 claimant 却留永不过期 handoff:cmdAsk 没传 id/kind → broker
默认 hook + 随机 id + 持久化,但显式 CLI exit 后无人认领。修:只有 RESUMABLE_ORIGINS
(hook) 且带 requestId 才 resumable/持久化;显式 ask 标 originKind='explicit'(不持久化/
无 handoff/无 dispatchUuid);persist store 的 handoff 记录加 answeredAt +
HANDOFF_RETENTION_MS 有界 reap(list() 不再让 answered 永不过期)。

验证:pnpm build 绿;隔离 6+ ask 文件 141 tests 绿(双序 handoff + 二次重启存活 +
active 重放 1ask/1card 两 waiter 同答案 + 跨 session/bot 同 requestId 绝不认领 +
dispatchUuid 真传断言 + explicit 不持久化 + typed retryable 400/503 + requestId 解析
+ 启动 503/classifier 源码 guard),跑后真实 ~/.botmux/data/asks 零残留;pnpm test
全量无本改动相关新增失败(唯余 group-join/doc-comment 已知 load flake,隔离绿)。
PR body 已重写为单一现状(旧题面 hash/仅 Claude 契约已删)。
第 3 轮 delta 复审又发现 4 个正确性硬点。把「CLI ask 卡片跨 daemon 重启不丢」
这个保证冻结成 6 条契约,每条先落一个失败反例(test/ask-resume-contract.test.ts)
再改实现让其转绿,避免每轮临时扩题。

1. canonical identity 全链路唯一 (P1-1)
   - askKeyFor 改长度前缀编码 `<len>:<seg>` 拼接,消除分隔符别名 + 截断碰撞
     (原 sanitize+slice(80):真实 36 字符 app id + uuid session 会把 requestId
     顶出 80 字符,两个不同 requestId 前缀相同 → 同一文件 → 覆盖)
   - 持久化文件名改对完整 key 做 SHA-256(定长 hex,不再截断)
   - 派发 uuid 从「完整 scoped key」派生(非裸 requestId):同 invocation 幂等去重,
     跨 session 同 requestId 得不同 uuid,不能相互别名对方的卡片

2. 同 identity 全状态幂等 (P1-1):active 合并 waiter / 终态复返 terminalResult /
   dormant 重挂,三态都有反例

3. mismatch fail-closed (P1-2):同 key 但不可变身份(问题集/chat)不符 → 明确返回
   invalidated,不再 fall-through 新建第二张卡;questionsShape 纳入 option label
   (改标签=用户看到不同卡片,不得静默复用)

4. 仅 restart-surviving 后端可恢复 (P1-4):resumable 由 daemon 按已认证 session 的
   frozen backend 判定(getSessionPersistentBackendType:tmux/herdr/zellij/zmx=yes,
   pty=no),不再信任 client 的 originKind 字符串;undefined 信号 fail-closed 不落盘
   (否则 PTY hook 落盘 → 无人能 reconnect 认领 → 孤儿记录)

5. 派发 partial-success 可收敛 (P1-3):dispatcher 抛 typed AskDispatchError{retryable},
   broker 有界重试(4 次,线性退避)复用同 uuid → 服务端已成功的投卡在 socket reset 后
   重试返回原 messageId(一张逻辑卡,broker 保持 pending);确定 4xx / withdrawn / 未 typed
   的裸抛 → 立即 invalidate(fail closed)。分类器 classifyAskDispatchError 为纯函数,
   直接单测

6. handoff 绝对到期 + map/disk 同步清 (P1-4):stash 与 restore 都武装 answeredAt +
   HANDOFF_RETENTION_MS 的绝对到期 timer,触发即删内存 + 盘(原先只在 boot 的 list()
   sweep 清盘,long-running daemon 永不触发);二次重启前未认领仍按原 answeredAt 计时,
   不重置时钟;认领时 clearTimeout 取消 reaper

另外把两处藏在大闭包里、原先只能靠源码 regex 断言的判定抽成纯函数,由 route/CLI 真调用 +
直接单测(executable decision seam):
- isRetryableAskHttpStatus(status):postAsk 重试分类器(502/503/504 retryable)
- shouldReturnAskStartupNotReady({hasSession,sessionsRestored,trustedHost}):
  /api/asks 启动期 503 门(重启 restore 竞态期给 retryable 503 而非永久 403)

影响面:改动集中在 ask 子系统(broker/persist-store/types/lark card)+ daemon /api/asks
接线 + cli postAsk 分类器。重试仅对 AskDispatchError(飞书投卡)生效,不碰其它 CLI 出站;
backendSurvivesRestart 只门控「是否落盘恢复」——PTY 会话的 ask 仍能实时问答,只是不跨重启。

验证:
- test/ask-resume-contract.test.ts 六条契约 19 通过(每条含 red-on-old 反例)
- test/ask-resume-restart.test.ts 13 / ask-card 27 / ask-api 29 / cmd-hook 21 /
  startup-guards 8 全通过(共 117 ask 相关)
- pnpm build 通过;全量 vitest 通过
冻结矩阵复审后的 3 点收敛(base 2e0e3b5):

1. dispatchUuid 与 retry 同门(P1-1 回归修复)
   有界重试对所有 ask 生效,但 dispatchUuid 原先只给 resumable → explicit /
   PTY hook 重试时无去重键 → 服务端已投卡后 socket reset 的重试会真发第二张卡。
   改:snapshot() 对所有 ask 都派生 dispatchUuidForKey(scoped askKey)。resumable
   只再门控「跨重启持久化」,不门控「进程内派发幂等」。补 explicit + PTY 的
   partial-success 反例(重试复用同 uuid、仍不落盘),翻转两条旧断言。

2. 启动期 503 门不再放行 trusted-host(P1-2)
   普通 unsandbox hook 本身就走 loadDaemonIpcSecret + HMAC fetchDaemonIpc 的
   trusted-host 路径;而 daemon 先发布 descriptor、后 restore sessions。原门把
   trusted 直接排除 → 这段窗口里 unknown-session 的 hook 绕过 503、被算成
   backendSurvivesRestart=false,若 daemon 恰死在首次 register/persist 前,重连会
   新建一条 non-resumable ask,下次重启仍丢。改:shouldReturnAskStartupNotReady
   去掉 trustedHost 入参——restore 未完成 + session 未知即 503,无论如何认证。
   (/api/asks 的所有调用方都是 session-scoped ask 注册;desktop 作答走另一路由。)
   把 trusted-bypass 用例翻成「unsandbox hook 应 503」的真实组合。

3. dispatch classifier 认瞬时 Lark 业务码(P1-3)
   classifyAskDispatchError 原先只看 HTTP status,230049「message is being sent」
   (官方 retry-later,尤其出现在同 uuid partial-success 收敛时)被误判 permanent。
   改:先查 Lark 业务码白名单 {230049, 230020, 99991400},命中即 retryable,
   无论 HTTP 4xx 还是 2xx-body plain Error(code 从 response.data.code 取,兜底
   解析 message 尾部 (code: NNN))。11232/11233 属旧 V4 不混入。

Follow-up(不在本轮):429 / 99991400 的 x-ogw-ratelimit-reset 尚未接成
AskDispatchError.retryAfterMs,当前短退避(0.5/1/1.5s ×4)覆盖不了官方长 reset
窗口——留作后续;230049/230020 在短退避内即可收敛。

验证:pnpm build✅;6 ask 聚焦文件 120 tests✅(含 3 条新 red case:explicit/PTY
双卡、瞬时码、unsandbox-hook 应 503);启动门 / 分类器 seam 纯函数直测。
@deepcoldy
deepcoldy force-pushed the fix/ask-resume-across-restart branch from afafd68 to b0974b9 Compare August 7, 2026 05:48
@deepcoldy
deepcoldy merged commit 7ec6d4b into master Aug 7, 2026
6 checks passed
deepcoldy added a commit to xiaoxueSunn/botmux that referenced this pull request Aug 7, 2026
master 前进到 7ec6d4b(含 deepcoldy#775/deepcoldy#730/deepcoldy#750/deepcoldy#762),与 deepcoldy#597 二次冲突 6 文件。
本 worktree merge origin/master 解冲突(admin-squash 时拍平)。

6 文件逐点取舍:
- core/reply-target.ts: union 两套并存的 per-turn 记录——deepcoldy#597 turnReplyContexts(frozen dispatch target)+ deepcoldy#750 replyTargets(mention-back 参与者窗口)。dedupeParticipants/buildTurnParticipantsFrom/collectTurnWindowParticipants/frozenReplyContextForTurn 全保留。
- cli.ts: replyTargets 类型取 master 更全版 + 保 deepcoldy#597 codex-app 字段;flash footer 取 master 对象式(deepcoldy#762),删除会话用 result.mode(非 master 误用的 result.via);replyTargetSenderOpenId 回退链 union:VC → deepcoldy#597 frozenTurnDispatch → deepcoldy#750 turnReplyTarget.senderOpenId → legacy。
- daemon.ts: 三处 registration-race 取 deepcoldy#597 结构化 claimNewDaemonSession + routeToCanonicalOwner/handleThreadReplyAdmitted;destructure union routeToCanonicalOwner + senderIsBot。修真实回归:CAS-loser 经 handleThreadReplyAdmitted 重算 post @s 只读 data.message 漏 ctx.forwardSeedData → 双 race 丢种子转发 post @。两处重算补 forward-seed 参数(codex 合并不变量#1)。
- 3 测试文件: import union;initial-passthrough-ownership 取 deepcoldy#597 claim 断言;cli-send-hook-context + daemon-turn-reply-sender-wiring 迁写到 deepcoldy#597 模型(forwardSeed 重算)并保 deepcoldy#750 mention-back 断言并存。

验证: pnpm build✅; 受影响套件全绿(reply-target-fallback 41/daemon-turn-reply-sender-wiring 7/cli-send-hook-context 14/send-policy 46/initial-passthrough-ownership 8/daemon-rename-route 55/command-handler 235/transfer-session 71/dashboard-create-session 41/trigger-session-root-message 41/restore-zombie-close 33/scheduler-silent-execute 24/session-resume 39); deepcoldy#597 核心 steer worker-routing 8+bridge 66+dispatch 6+transfer-gate 2+initial-user-turn 28。codex-app-runner 1 项既有 bounded-history flake 无关。
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

🚀 Released in v3.11.0

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