fix(send): 避免推荐当前话题机器人的旧会话 - #620
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
首次 review(Claude)— 结论:无阻塞 ✅
在隔离 worktree(git worktree add --detach <sha>,独立 node_modules)对 PR head 564d1494(干净 rebase 于 master b30e8949)做了代码走查 + 构建 + 全量回归对拍。
改动逻辑(白话)
现场 bug:编排者 A 在新话题 N 里 @ 评审 bot B(B 在 N 已有活跃 session),随后 botmux send --mention B 追问进度。旧逻辑里 botmux send 的"子话题提示"只排除了当前正在回复的那个 bot(quoteTargetSenderOpenId)。A 追问 B 时 B 并不是 A 的回复对象,于是提示照常触发,把 B 名下一条无关历史话题 O 的 dispatch 记录当成候选目标,甩出一句"要发到那边用 --into O"。A 被误导,把同一个评审任务又发进 O → B 在 N 和 O 各跑一遍,O 那份还按旧映射回报到历史主话题 P,表现为"重复派活 + 结论落在旧话题"。
本 PR 的修法:新增纯函数 activeConversationBotOpenIds,按当前会话锚点(thread-scope 看 rootMessageId、chat-scope 看整群)算出"此刻在这个对话里就能直接触达的 bot"的 open_id 集合 reachableOpenIds,喂给 offTopicSubBotTopic。判定改为:被 @ 的 bot 只要 ① 是我正在回复的对象,或 ② 已在当前锚点有活跃 session,就不再推荐它的旧 dispatch 话题。只在"该 bot 仅存在于别的历史话题"时才保留原来的 --into 提示。实际投递路由、dispatch 登记、report 回报路径都没动,改的只是那句提示要不要出。
场景复盘对着新代码走一遍:B 在 N 的 session 命中(chatId===targetChatId && scope!=='chat' && rootMessageId===N)→ B 的 open_id 进 reachableOpenIds → 对 B 的提示被抑制;B 在 O 的 session(rootMessageId=O≠N)不命中;一个只存在于旧话题的 bot C 仍会正确拿到提示。✔
为什么风险低
- 提示是 advisory,不是 block——命中只
console.error一句ℹ️(cli.ts:6761),不拦截发送、不改路由。 - 没引入新的命名空间假设——
reachableOpenIds复用的crossRef[botName]查表,正是既有正文@Name注入路径(cli.ts:7067)已经在用的同一套 sender-app-scoped open_id;mentionOpenId与 registrybots[]也都是发送方视角 open_id,三者同域。 allSessions只在dispatchReg非空时才loadSessions()——正是提示可能触发的前提,无多余开销。dispatchActiveSeeds的重构(内联status/scope/rootMessageId判定)与原逻辑逐条等价。offTopicSubBotTopic新增的reachableOpenIds?是可选参 +?.has()可选链:5 个既有测试不传该参仍走原行为,向后兼容。
一个 P3(非阻塞,可留给维护性讨论)
仓库里现在有两处"谁在这个对话里可达"的计算,语义略有出入:新的 activeConversationBotOpenIds 锚定 targetChatId 且两种 scope 都带 chatId 守卫;既有的 convoBotAppIds(cli.ts:7037,本 PR 未动)锚定 s.chatId,chat-scope 分支只查 chatId 不查 scope。二者服务不同用途(前者管旧话题提示、后者管 @cliId 泛型别名收敛),各自都对,但两个近似却不完全一致的 helper 并存,未来容易让人混淆。可考虑注释交代差异或后续统一——不影响本 PR 正确性。
验证
pnpm build:通过(隔离 worktree)pnpm vitest run test/dispatch.test.ts:60/60 通过- 全量回归对拍(head vs 干净 master
b30e8949,各自隔离跑全套):- master:24 文件 / 22 用例 失败
- head:25 文件 / 23 用例 失败
- 差集 = 唯一多出的
test/herdr-backend.e2e.ts(并行跑时waitFor timeout: PHASE1_MARKER)单独串行重跑 9/9 通过 → 判定为并行端口/PTY 争用,非本 PR 回归 - 其余 24 个公共失败均为本机既有 env/flaky 基线(
e2e-browser/feishu-*、fs-policy-bwrap、plugin-mcp-sandbox、scheduler、v3-distillation-runner、multi-bot-sessionstale-mock 等),master 上逐条同样复现 - 结论:零 PR 归因回归
已请 @codex 复审。未经申晗确认不合码。
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
首审更正(Claude)— 撤回"无阻塞",认同 @codex 的 2 个 P1 阻塞点 🔴
codex 复审提出的两点,我在隔离 worktree(PR head 564d1494)里用编译产物的纯函数逐一实测,并回到源码核实了前提,两点均 CONFIRMED。我的首审在这两条上是错的——我抽象论证了 open_id 同域,却没有真正跑 mixed-scope 与同名 bot 两条路径。更正如下。
P1 CONFIRMED · 混合 scope 下仍会误导(原 bug 未修)
activeConversationBotOpenIds(dispatch.ts:251-255)用发送方 session 的 chatScope 决定只看哪一类 peer session。但 regularGroupReplyMode 是 per-bot 配置(chat-reply-mode-store.ts:48-51,默认 chat),同群里 A、B 可以不同 scope。关键前提我在源码确认:maybeFoldMentionedRegularGroupThreadToChat(event-dispatcher.ts:1683-1723)会把"在话题里 @ 一个 chat/shared 模式 bot"折回它既有的 chat-scope session——即 B 此刻真实可达,但 helper 因只看与发送方同类的 peer 而返回空,旧话题提示照旧触发。
纯函数实测(PR build):
- A=thread、B=chat-scope 同群 →
reachable=[](B 漏判) - A=chat-scope、B=thread-scope 在当前话题 →
reachable=[](B 漏判)
且 chat 是默认模式,此组合是常见配置而非边角。建议按实际 outbound target anchor(cli.ts 已有 turnReplyTarget?.rootMessageId / sendTarget,但没传进 helper)判断,并分别识别"同 root 的 thread peer"和"同 chat 可折叠的 chat peer";补两条 mixed-scope 回归。
P2 CONFIRMED · 同名 bot 反查错人 → 错误压掉提示
dispatch.ts:260-263 走 botEntries.larkAppId → botName → crossRef[botName]。但 bot-routing.ts:1-9 明确支持 bots-info.json 同名多 bot,并专门提供了消歧 helper pickBotEntryByName(按 oncallChats 命中优先)——本 PR 没用它,这个 join 不是一一映射。
纯函数实测:当前话题活跃的是 cli_same_1,与 cli_same_2 同名 Same,crossRef.Same=ou_same_2 → helper 错误返回 ou_same_2,把 bot2 当成可达 → offTopicSubBotTopic(bot2) 被压成 null。后果:@bot2 仍会新起无上下文 session,却连提示都没了(比 master 更糟——master 会给 bot2 提示)。建议改用 pickBotEntryByName,或重名时 fail closed(不加入 reachable),补重名测试。
P3 CONFIRMED(非阻塞)· 非数组身份文件会抛
实测:bots-info.json 是合法 JSON 但非数组时,activeConversationBotOpenIds 抛 TypeError: input.botEntries is not iterable。加载在 try 内、helper 调用在 try 外(cli.ts:6704-6736),会让整条 botmux send 崩。旧代码对 botEntries 的后续使用都在 try 内、吞掉坏状态。建议结构校验(Array.isArray)或把计算纳入保护区。
结论
同意 codex:暂不合码。P1/P2 是正确性阻塞(在受支持配置下复现原 bug、或错误静音提示),P3 是健壮性。等作者修正后我与 codex 再复审。未经申晗确认不合码。
(验证均在隔离 worktree + PR 编译产物纯函数上完成;未改源码、未重启 daemon、未合码。)
|
To use Codex here, create a Codex account and connect to github. |
|
@deepcoldy 已按复审记录完成修正并推送
本次验证:
PR 描述也已同步更新实现边界和新增覆盖。 |
deepcoldy
left a comment
There was a problem hiding this comment.
复审更新(Claude)— commit 86ec7390 已修复 P1/P2/P3,delta 全绿 ✅
针对上一轮 codex 提出、我实测认同的 3 个阻塞点,作者在 86ec7390(在 564d1494 之上,仍基于 master b30e8949,MERGEABLE)逐条修复。我在隔离 worktree(PR 编译产物 + 纯函数探针 + 全 build/测试)逐条核验,三点均已修复且无过度修正。
P1 ✅ FIXED · 按实际 outbound anchor 判定可达
改法正是我上一轮建议的方向——把"发送方自己的 scope"换成"这条 send 实际落到哪里":
- cli.ts 把
resolveSendTarget上提到 guard 之前(并删掉原来靠后的重复调用,合成单点),传outboundRootMessageId = sendTarget.mode==='plain' ? undefined : sendTarget.rootMessageId。 - helper 判定改为:
session.scope==='chat'(chat-scope peer 在同群任意消息都可达,因为话题内 @ 会 fold 回其 chat session)或 thread peer 的rootMessageId === outboundRootMessageId(只有 send 真落到那个 thread 才算可达)。
纯函数探针(对编译产物实测):
- A=thread @ B=chat-scope → B 可达(原 bug 修复)✔
- A=chat-scope 回到话题 N @ B=thread-in-N → B 可达 ✔
- 负例:A chat-scope 顶层 plain send、B 只在旧话题 O → B 不可达、提示保留 ✔(未过度压制)
- 全 guard 集成:P1a 场景
offTopicSubBotTopic返回 null(提示正确抑制);genuine off-topic bot C 仍返回om_O(提示照常触发);跨 chat 的 chat-scope peer 不可达(无泄漏)✔
我核了 cli 接缝:resolveSendTarget 上提位置的所有入参(sendInto/sendTopLevel/isChatScope/targetChatId/turnReplyTarget/currentTurnId)在两处之间均未被重新赋值,且都在上提点之前声明,值与原先逐字一致——hint 与真实 Lark 投递现在读同一个 sendTarget,不可能再对 scope 各执一词。
P2 ✅ FIXED · 同名 bot fail closed
helper 现在对每个候选 botName 统计同名条目数,!== 1 直接 continue(不加入 reachable),注释也点明"crossRef 按显示名 key,多 app 同名时无法证明代表哪个 app → fail closed,保留旧话题提示而不是给错 bot 静音"。
探针:cli_same_1 活跃、与 cli_same_2 同名 Same、crossRef.Same=ou_same_2 → 返回空集(不再错标 bot2 可达)✔;唯一名仍正常解析 ✔。(作者选择 fail-closed 而非引入 pickBotEntryByName——对这个"要不要出提示"的 advisory 判定足够且更保守,我认同。)
P3 ✅ FIXED · 非数组身份文件不再抛
双重加固:cli.ts 侧 Array.isArray(parsedBotEntries) + crossRef 的 typeof==='object' && !Array.isArray;helper 内部也再兜一层 Array.isArray(input.botEntries) 与 crossRef 类型校验。探针:botEntries={}、crossRef=[1,2,3] → 返回空集、不抛 ✔。
测试与回归
- 新增 4 条针对性测试,正是 codex 提的场景:thread→chat-scope 可达、chat-scope→同 root thread 可达、plain send 不可达 thread peer(防过度抑制)、同名 fail-closed、非数组不抛。质量到位,与我的独立探针逐条吻合。
pnpm build通过;test/dispatch.test.ts64/64(+4);cli-send-dispatch+cli-send-hook-context+send-policy78/78;无残留旧参引用(currentRootMessageId/chatScope已全清)。- 上一轮已建立全量基线(24 个 known-flaky 文件在干净 master 同样复现);本次改动局限于
dispatch.ts纯函数 +cmdSend局部上提,均被上述用例覆盖,无新增回归面。
结论
我这侧无阻塞,3 个点已按预期修复、且负例证明未过度修正。请 @codex 复核 delta(尤其 P1 的 outbound anchor 语义与 P2 fail-closed 取舍)。未经申晗确认不合码。
(全程隔离 worktree + PR 编译产物;未改源码、未重启 daemon、未合码。)
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
三审(Claude)— 认同 @codex 的 2 个 P1 + 1 P3,撤回上轮"无阻塞" 🔴
codex 二次复审的三点,我在隔离 worktree(head 86ec7390 编译产物 + 纯函数探针 + 回源码核前提)逐条核验,全部 CONFIRMED,且前提属实。我上一轮 delta 只跑了 thread/plain/chat-fold happy path 与同名,漏了 quote 模式与 mode-switch 残留 session 两条路径。更正如下。
P1 CONFIRMED · quote 被误当 thread root
cli.ts:6749 传 mode==='plain' ? undefined : sendTarget.rootMessageId,把 quote 和 thread 合并。但真实投递 cli.ts:6854 只有 mode==='thread' 才 reply_in_thread=true;quote 走 replyMessage(..., false),不进话题。event-dispatcher 注释(1739-1744)也明确"有 root_id、无 thread_id 的 quote bubble 当顶层处理"。
探针(编译产物):sendTarget={mode:'quote', rootMessageId:'om_quote_target'}、B 仅在该 root 有 thread session → helper 返回 B 可达 → offTopicSubBotTopic 返回 null。但 @b 实际会按顶层/目标 bot 重新路由、并不会续到 B 的旧 thread,提示却被静音 → 仍可能新起无上下文 session(正是本 PR 要防的)。
修法:thread-peer 判定应仅在 sendTarget.mode==='thread' 时把 root 传下去(quote/plain 都不算落在该 thread);补 quote 负例。
P1 CONFIRMED · 活跃 chat-scope session ≠ 当前仍会 fold 回它
dispatch.ts:254 无条件 session.scope==='chat' → 可达。但这个前提在 /reply-mode 动态切换后不成立,我逐环核实:
setChatReplyMode(chat-reply-mode-store.ts:90-123)只改配置(rmwBotEntry + 内存 bot.config),从不关闭旧 session、也不改 session.scope → chat→new-topic 后那条 active chat-scope session 原样残留、scope 仍是 'chat';- 路由侧
new-topic/chat-topic明确不 fold 回 chat session(event-dispatcher.ts:1717;顶层 new-topic 另开 thread,见 regularGroupRouting 1761-1764); - helper 仍因那条残留 session 把 B 标可达并压掉提示。
探针:B 有 active chat-scope session、plain 顶层 send → helper 返回 B 可达(但若 B 现在是 new-topic,@b 会另开 thread,不复用旧 chat session)。
修法:helper 判"chat-scope 可达"时需结合目标 bot 当前 effective reply mode——调用点有 B 的 larkAppId(来自其 session)+ targetChatId,可用 resolveRegularGroupMode(larkAppId, targetChatId),仅在 chat/shared 才认 fold;new-topic/chat-topic 时不认。判断不了就 fail closed(保留 advisory)。这是纯函数拿不到的上下文,需在 cli 侧补喂或在 helper 增参。
P3 CONFIRMED(非阻塞)· 数组元素仍会崩
上轮只修了外层非数组。合法 JSON [null] 在 dispatch.ts:266 的 entry.botName 直接抛(Array.isArray 只护了外层,没护元素)。探针:botEntries:[null] → TypeError: Cannot read properties of null。建议过滤非对象元素,sameNameEntries 的 candidate 也用安全访问(candidate?.botName)。
结论
同意 codex:86ec7390 仍有正确性阻塞,暂不合码。P1×2 都是"错误压掉 advisory → 用户可能把任务重新发进 stale/新起的空会话",与原 bug 同类;P3 是健壮性。等作者修正后我与 codex 继续复审。未经申晗确认不合码。
(全程隔离 worktree + 编译产物探针 + 源码核前提;未改源码、未重启 daemon、未合码。上轮已修的部分——常规 thread↔chat peer、plain 不误认 thread peer、同名 fail-closed、非数组不抛——本轮复核仍成立。)
|
To use Codex here, create a Codex account and connect to github. |
背景 / 动机
现场涉及两个机器人和三个话题,以下均用代号表示:
实际链路如下:
botmux send --mention B追问进度;这条消息本身仍正确发送到 N。botmux send查询 dispatch 登记后发现 B 在历史话题 O 中也有 active session,于是额外提示“如需发到那个话题,请使用--into O”。问题开始于第 3 步:旧逻辑只排除了“当前正在回复的机器人”,没有判断被 @ 的机器人是否已经可由本次消息的实际发送落点触达,因此把一个真实存在、但与当前任务无关的历史话题作为候选目标提示出来。
改动
resolveSendTarget的实际发送落点判断可达性,而不是假设发送方与目标机器人使用相同 scope:同群 chat-scope 机器人可从话题内触达,thread-scope 机器人则要求实际发送 root 与其 session root 相同。--into提示。botmux send主流程。测试覆盖
验证
pnpm vitest run test/dispatch.test.ts test/bot-routing.test.ts:97/97 通过pnpm build:通过git diff --check:通过pnpm test:仍只出现已在未修改origin/master复现的既有环境/基线失败,本次新增及相关测试均通过影响范围
改动位于所有 CLI 共用的
botmux send提示判断,覆盖 thread-scope、chat-scope 及其混合组合。不会改变消息实际投递位置、dispatch 登记或 report 路由;本 PR 修复的是首次错误引导,避免任务被再次发送到历史话题,从源头阻断后续沿旧映射回报的链路。