Skip to content

fix(send): 避免推荐当前话题机器人的旧会话 - #620

Open
xiongz-c wants to merge 2 commits into
masterfrom
codex/avoid-stale-subbot-topic-hint
Open

fix(send): 避免推荐当前话题机器人的旧会话#620
xiongz-c wants to merge 2 commits into
masterfrom
codex/avoid-stale-subbot-topic-hint

Conversation

@xiongz-c

@xiongz-c xiongz-c commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

背景 / 动机

现场涉及两个机器人和三个话题,以下均用代号表示:

  • 机器人 A:任务编排者
  • 机器人 B:评审机器人
  • 话题 N:本次新任务的话题
  • 话题 O:历史任务留下的评审子话题,其中机器人 B 的 session 仍为 active
  • 话题 P:话题 O 所属的历史主话题

实际链路如下:

  1. 机器人 A 在新话题 N 中首次 @ 机器人 B,botmux 正常在 N 中为 B 创建了本次评审 session。
  2. 等待评审期间,A 在 N 中执行 botmux send --mention B 追问进度;这条消息本身仍正确发送到 N。
  3. botmux send 查询 dispatch 登记后发现 B 在历史话题 O 中也有 active session,于是额外提示“如需发到那个话题,请使用 --into O”。
  4. 由于提示没有说明 O 属于另一项历史任务,也没有识别 B 已经在当前话题 N 中活跃,调用方把 O 误认为本次评审应续用的会话,并再次把同一任务发送到 O。
  5. B 因而在 N 和 O 中各执行了一次评审;O 中的评审完成后又按其原有 dispatch 映射回报到历史主话题 P,最终表现为“重复派活,并且一份结论出现在旧话题”。

问题开始于第 3 步:旧逻辑只排除了“当前正在回复的机器人”,没有判断被 @ 的机器人是否已经可由本次消息的实际发送落点触达,因此把一个真实存在、但与当前任务无关的历史话题作为候选目标提示出来。

改动

  • 复用 resolveSendTarget 的实际发送落点判断可达性,而不是假设发送方与目标机器人使用相同 scope:同群 chat-scope 机器人可从话题内触达,thread-scope 机器人则要求实际发送 root 与其 session root 相同。
  • 目标机器人已可由当前发送落点触达时,不再输出旧 dispatch 子话题提示;仅存在于其他活跃子话题时仍保留原有 --into 提示。
  • 同名机器人无法通过 display name 唯一关联 App 时 fail closed,不静默压掉提示。
  • 身份文件为合法 JSON 但结构异常时降级为空映射,不影响 botmux send 主流程。
  • 实际消息投递、dispatch 登记和 report 路由保持不变。

测试覆盖

  • 覆盖同一机器人同时存在当前话题和旧话题活跃会话的现场场景。
  • 覆盖 thread 发送方触达同群 chat-scope 机器人,以及 chat-scope 发送方按 per-turn root 触达 thread 机器人。
  • 覆盖 plain chat 发送不能触达 thread session。
  • 覆盖同名机器人 fail closed 和非数组身份文件安全降级。

验证

  • 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 修复的是首次错误引导,避免任务被再次发送到历史话题,从源头阻断后续沿旧映射回报的链路。

@xiongz-c
xiongz-c requested a review from deepcoldy as a code owner July 27, 2026 10:09

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

首次 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 的"子话题提示"只排除了当前正在回复的那个 botquoteTargetSenderOpenId)。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 仍会正确拿到提示。✔

为什么风险低

  1. 提示是 advisory,不是 block——命中只 console.error 一句 ℹ️(cli.ts:6761),不拦截发送、不改路由。
  2. 没引入新的命名空间假设——reachableOpenIds 复用的 crossRef[botName] 查表,正是既有正文 @Name 注入路径(cli.ts:7067)已经在用的同一套 sender-app-scoped open_id;mentionOpenId 与 registry bots[] 也都是发送方视角 open_id,三者同域。
  3. allSessions 只在 dispatchReg 非空时才 loadSessions()——正是提示可能触发的前提,无多余开销。
  4. dispatchActiveSeeds 的重构(内联 status/scope/rootMessageId 判定)与原逻辑逐条等价。
  5. 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.ts60/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-bwrapplugin-mcp-sandboxschedulerv3-distillation-runnermulti-bot-session stale-mock 等),master 上逐条同样复现
    • 结论:零 PR 归因回归

已请 @codex 复审。未经申晗确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

首审更正(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-263botEntries.larkAppId → botName → crossRef[botName]。但 bot-routing.ts:1-9 明确支持 bots-info.json 同名多 bot,并专门提供了消歧 helper pickBotEntryByName(按 oncallChats 命中优先)——本 PR 没用它,这个 join 不是一一映射。

纯函数实测:当前话题活跃的是 cli_same_1,与 cli_same_2 同名 SamecrossRef.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 但非数组时,activeConversationBotOpenIdsTypeError: 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、未合码。)

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@xiongz-c

Copy link
Copy Markdown
Collaborator Author

@deepcoldy 已按复审记录完成修正并推送 86ec7390,请复审:

  • mixed-scope:不再按发送方 scope 筛 peer;提前计算 resolveSendTarget 的实际 outbound anchor。同群 chat-scope peer 始终可达,thread-scope peer 仅在实际发送 root 相同时可达,并补齐两种混合 scope 与 plain-chat 反例。
  • 同名 bot:display name 对应多个 App 时 fail closed,不把无法确认归属的 cross-ref open_id 加入 reachableOpenIds,避免错误静音旧话题提示。
  • 异常身份文件:加载时校验 bots-info.json 必须为数组、cross-ref 必须为普通对象;纯函数侧也对非数组输入安全降级。

本次验证:

  • pnpm vitest run test/dispatch.test.ts test/bot-routing.test.ts:97/97 通过
  • pnpm build:通过
  • git diff --check:通过
  • 全量 pnpm test 未出现本改动相关新增失败

PR 描述也已同步更新实现边界和新增覆盖。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

复审更新(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 同名 SamecrossRef.Same=ou_same_2 → 返回空集(不再错标 bot2 可达)✔;唯一名仍正常解析 ✔。(作者选择 fail-closed 而非引入 pickBotEntryByName——对这个"要不要出提示"的 advisory 判定足够且更保守,我认同。)

P3 ✅ FIXED · 非数组身份文件不再抛

双重加固:cli.ts 侧 Array.isArray(parsedBotEntries) + crossReftypeof==='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.ts 64/64(+4);cli-send-dispatch + cli-send-hook-context + send-policy 78/78;无残留旧参引用(currentRootMessageId/chatScope 已全清)。
  • 上一轮已建立全量基线(24 个 known-flaky 文件在干净 master 同样复现);本次改动局限于 dispatch.ts 纯函数 + cmdSend 局部上提,均被上述用例覆盖,无新增回归面。

结论

我这侧无阻塞,3 个点已按预期修复、且负例证明未过度修正。请 @codex 复核 delta(尤其 P1 的 outbound anchor 语义与 P2 fail-closed 取舍)。未经申晗确认不合码。

(全程隔离 worktree + PR 编译产物;未改源码、未重启 daemon、未合码。)

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

三审(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:6749mode==='plain' ? undefined : sendTarget.rootMessageId,把 quotethread 合并。但真实投递 cli.ts:6854 只有 mode==='thread'reply_in_thread=truequotereplyMessage(..., 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 动态切换后不成立,我逐环核实:

  1. setChatReplyMode(chat-reply-mode-store.ts:90-123)只改配置(rmwBotEntry + 内存 bot.config),从不关闭旧 session、也不改 session.scope → chat→new-topic 后那条 active chat-scope session 原样残留、scope 仍是 'chat';
  2. 路由侧 new-topic/chat-topic 明确不 fold 回 chat session(event-dispatcher.ts:1717;顶层 new-topic 另开 thread,见 regularGroupRouting 1761-1764);
  3. 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:266entry.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、非数组不抛——本轮复核仍成立。)

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

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.

2 participants