fix(ask): AskUserQuestion 卡片扛住 daemon 重启(存盘 restore + hook 重连接回) - #775
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
复审结论: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.ts、test/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→click 和 click→reattach。
…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, 隔离复跑绿)。
…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, 隔离复跑绿)。
f3c48fd to
1972d07
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
Delta 复审结论:仍为 REQUEST_CHANGES(代码阻塞)。同一 GitHub 身份只能显示 COMMENTED,请继续按阻塞 review 处理。
先确认已真正修好的部分:
- P1-1 的目标双序已闭环:
reattach→click与click→reattach(含二次 restart)在当前实现/测试中都能交付,后者不会再把答案直接丢进空 resolver。 - P1-4 的 store 注入与测试隔离已成立;
_resetForTest()只 detach、不删目录。 runHook已改成只按 typedretryable===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 显式剥掉 requestId;PendingAsk 也没有这个字段。生产 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。建议:
- 给 dispatcher 一个明确的稳定
dispatchUuid(≤50;若 API 允许 requestId 128 字符就应 hash/规范化),生产 send/reply 确实传入; - 同 scoped requestId 的 active 重放应 join 同一结果(共享 Promise/多 waiter),settled-but-response-ambiguous 的短窗重放也应返回同一 terminal result;
- 补生产 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 仍回 403(src/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/asks0 文件 ✅(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。
…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, 隔离复跑绿)。
1972d07 to
2e1ce43
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
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 反证。
…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, 隔离复跑绿)。
2e1ce43 to
2e0e3b5
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
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 派生 dispatchUuid;resumable 只控制持久化/跨 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-265;test/ask-resume-startup-guards.test.ts:66-69)。这里的前提“trusted-host 不是 session-scoped hook”不成立:postAsk() 在没有 BOTMUX_SEND_RELAY 时加载 host secret,并通过 HMAC fetchDaemonIpc(src/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-CN、https://open.feishu.cn/document/server-docs/api-call-guide/frequency-control?lang=zh-CN。11232/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/asks0 残留;worktree clean
Claude 已确认先保持 HEAD 不动。请一次性补上面三点再钉 SHA;下一轮我只复跑这三个 red case + 冻结矩阵,不再扩题。
deepcoldy
left a comment
There was a problem hiding this comment.
Delta 复审:CODE APPROVED(afafd6834)
本轮固定复跑 2e0e3b56c..afafd6834,上轮冻结的 3 个代码阻塞均已解除,未发现新的代码级 blocker:
- uuid 与 retry 同门:
snapshot()已对所有 ask 派生 scopeddispatchUuid,resumable仅门控持久化。explicit 与 PTY 的 landed→reset 反例都验证为两次派发复用同一 uuid,且仍不落盘。 - startup gate 不再误放 trusted-host hook:未知 session + restore 未完成现在无条件走 503;已知 session 不受影响。实际运行的 decision seam 与 daemon 调用点一致。
- 瞬时 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/asks:0 个 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-resetfollow-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 纯函数直测。
afafd68 to
b0974b9
Compare
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 无关。
|
🚀 Released in v3.11.0 |
问题
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,在旧代码上跑红)再改实现让其转绿:askKeyFor改长度前缀编码消除分隔符别名与截断碰撞;持久化文件名对完整 key 做 SHA-256(不截断);派发 uuid 从完整 scoped key 派生(非裸 requestId),同 invocation 幂等、跨 session 同 requestId 不互相别名。invalidated,不 fall-through 新建第二张卡;questionsShape 纳入 option label。getSessionPersistentBackendType:tmux/herdr/zellij/zmx=yes,pty=no),不信 client 的 origin 字符串;undefined fail-closed 不落盘(否则 PTY hook 落盘 → 无人认领 → 孤儿)。AskDispatchError{retryable},broker 有界重试(4 次线性退避)复用同 uuid → 服务端已成功的投卡在 socket reset 后重试返回原 messageId(一张逻辑卡,broker 保持 pending);确定 4xx/withdrawn/未 typed 裸抛 → 立即 invalidate(fail closed)。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接线 + clipostAsk分类器。AskDispatchError(飞书投卡)生效,不碰其它 CLI 的出站路径;未动adapters/cli/共用层。backendSurvivesRestart只门控「是否落盘恢复」——PTY 会话的 ask 仍能实时问答,只是不跨重启持久化(与 tmux/herdr/zellij/zmx 的可恢复语义分离)。managed_action_required拒绝;apiOnly / HTTP 虚拟会话仍unsupportedfail-fast。验证
test/ask-resume-contract.test.ts六条契约 + 复审 3 条 red case(explicit/PTY 双卡、瞬时 Lark code、unsandbox-hook 应 503),每条含 red-on-old 反例。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 稳过)。复审后 3 点收敛(HEAD
afafd6834,delta2e0e3b56c..afafd6834)snapshot()对所有 ask 派生dispatchUuidForKey(scoped askKey);resumable只再门控跨重启持久化,不门控进程内派发幂等。补 explicit + PTY 双卡反例。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 作答走另一路由)。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 /
99991400的x-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 全绿。