Skip to content

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

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

fix(send): 避免推荐当前话题机器人的旧会话#620
xiongz-c wants to merge 4 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 误认为本次评审应续用的会话,并再次把同一任务发送到 O。
  5. B 因而在 N 和 O 中各执行了一次评审;O 中的结果又按原有 dispatch 映射回报到历史主话题 P,最终表现为“重复派活,并且一份结论出现在旧话题”。

问题开始于第 3 步:旧逻辑没有判断被 @ 的机器人是否已能由本次消息的真实发送落点触达,因而把无关历史话题作为候选目标提示出来。

改动

  • 按真实投递形态判断 thread 可达性:只有 thread 会进入 root;plain/quote 不会。
  • chat-scope 可达性同时检查目标 bot 的 effective reply mode 与当前群拓扑:
    • chat/shared 可复用普通 chat slot;
    • new-topic 不复用;
    • chat-topic 仅在顶层/quote 投递时复用,真实 thread 投递保持隔离;
    • 普通群已转为话题群、拓扑未知或查询失败时 fail closed。
  • 排除 deferred schedule、VC receiver 等使用独立 routing key 的 chat-scope session,不把它们误当普通群共享 slot。
  • deferred schedule 已 materialize 时,advisory 复用与实际 dispatch 相同的 binding root;显式 --top-level 仍保留原有逃生语义。
  • 同名 bot 无法唯一反查、身份文件结构异常或目标 bot/mode 无法解析时均 fail closed。
  • 实际消息投递、dispatch 登记和 report 路由保持不变。

测试覆盖

  • 当前话题与历史话题并存、thread ↔ chat-scope 混合可达性。
  • plain/quote 不误认 thread session,以及 reply-mode 动态切换。
  • chat-topic 顶层与真实话题内的不同路由。
  • deferred/VC 隔离 session、普通群转话题群后的残留 chat session。
  • materialized deferred follow-up 使用绑定 root,显式 top-level 不复用。
  • 未知 bot/mode、同名 bot 和畸形身份数据的 fail-closed 行为。

验证

  • pnpm vitest run test/dispatch.test.ts test/deferred-topic-send.test.ts test/bot-routing.test.ts test/cli-send-dispatch.test.ts test/send-policy.test.ts test/chat-mode-strict.test.ts test/session-resume.test.ts test/session-reply-thread-anchor.test.ts test/deferred-schedule-settlement.test.ts:240/240 通过
  • pnpm build:通过
  • git diff --check:通过

影响范围

改动位于所有 CLI 共用的 botmux send advisory 判断,并核对普通会话、deferred schedule、VC receiver、thread/chat scope、群拓扑变化及 reply-mode 动态切换。不会改变消息实际投递位置、dispatch 登记或 report 路由;修复的是发送前提示与真实路由不一致的问题。

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

@xiongz-c
xiongz-c force-pushed the codex/avoid-stale-subbot-topic-hint branch from 86ec739 to 99245a9 Compare July 29, 2026 09:15
@xiongz-c

Copy link
Copy Markdown
Collaborator Author

@deepcoldy 已按三审意见修正并推送 99245a98,同时 rebase 到当前 origin/master3c0d64de),请复审。

逐项对应:

  1. quote 不再误当作 thread

    • 新增 threadRootForReachability,只有实际 sendTarget.mode === 'thread' 才向 thread-session 可达性判断传 root。
    • quoteplain 都不会被视为进入对应 thread,补了 quote 负例。
  2. 残留 chat-scope session 结合当前 effective reply mode

    • 计算可达 chat session 时,用 session 的 larkAppId + targetChatId 查询目标 bot 当前 resolveRegularGroupMode
    • chat / shared 仍可折回 chat session;new-topic 不可折回。
    • 这里额外按真实路由语义区分了 chat-topic:顶层/quote 会继续使用 chat session,真实 thread 投递则保持话题隔离。这样既覆盖评论中的 mode-switch 反例,也不会在 chat-topic 顶层发送时反向产生错误 advisory。
    • bot/mode 无法解析时 fail closed,保留提示。
  3. 畸形 bots-info.json 数组元素

    • CLI 加载侧过滤 null、非对象及字段类型错误的元素。
    • 纯 helper 内也保留防御性过滤;cross-ref 值仅接受非空字符串。

验证:

  • pnpm vitest run test/dispatch.test.ts test/bot-routing.test.ts test/cli-send-dispatch.test.ts test/send-policy.test.ts:178/178 通过
  • pnpm build:通过
  • git diff --check:通过
  • 全量测试未见本改动相关新增失败;仍观察到既有 host-execution 环境型失败

PR 描述已同步补充 quote、动态 reply-mode、chat-topic 分支和畸形数组元素的现场边界。

@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 99245a98 已修 codex 二审的 2 P1 + 1 P3,我这侧 delta 全绿 ✅

针对上一轮 codex 提出、我实测认同的 3 点,作者在最新提交(head 99245a98,rebase 到 master 3c0d64de,MERGEABLE,无冲突)逐条修复。我在隔离 worktree(编译产物 + 16 项纯函数探针 + 4 场景 e2e guard 集成 + 回源码核语义)核验,三点均已修复,且负例证明未过度修正

P1 ✅ FIXED · quote 不再被当 thread root

新增 threadRootForReachability(sendTarget):仅 mode==='thread' 返回 root,quote/plain 返回 undefined。cli.ts:6851 改用它喂 outboundRootMessageId

探针:quote → undefinedthread → rootplain → undefined;e2e:quote-mode @b(B 恰在 quote 目标 root 有 thread session)→ B 可达 → 提示照常触发(om_O)。与真实投递 cli.ts:6854(仅 thread 才 reply_in_thread)一致。

P1 ✅ FIXED · 活跃 chat session 结合当前 effective mode 判定

新增 foldableChatSessionAppIds:对每个 chat-scope peer 用 resolveRegularGroupMode(larkAppId, targetChatId)当前 effective mode,仅 chat/shared(及 chat-topic 且 outbound 非 thread)才认为"会 fold 回旧 chat session";helper 的 scope==='chat' 分支改为 gate 在 foldableChatAppIds 上。

我核了三处语义正确性:

  • fail-closed 是 load-bearingresolveRegularGroupMode 自身 try/catch 吞掉未注册 bot 错误并回落 default,所以 cli 的 resolveMode 闭包显式先调 getBot(larkAppId) 强制抛错,再由 foldableChatSessionAppIds 的 try/catch 转成 fail-closed。这一步不是冗余——去掉它未知 bot 会拿到 default mode 而非 fail closed。
  • chat-topic 的 outbound 门控对齐路由maybeFoldMentionedRegularGroupThreadToChat(event-dispatcher.ts:1714)在 chat-topic + 原生 omt_ topic 下不 fold,但顶层/quote 仍 fold;作者用 outboundMode !== 'thread' 近似这一点,方向正确。
  • new-topic 永不 fold(event-dispatcher.ts:1717/1761)→ 不在可 fold 集合 ✓。

探针:mode=chat/shared→foldable;new-topic→不 foldable;chat-topic+plain→foldable、chat-topic+thread→不 foldable;resolveMode 抛错/返回 undefined→fail closed。e2e:B 有残留 chat session 但已切 new-topic + plain send @b → B 可达 → 提示照常触发;B 仍 chat mode → 可达 → 提示抑制(无 spurious re-dispatch)。

P3 ✅ FIXED · 元素级异常输入不再崩

cli.ts + helper 双侧都对数组做元素级过滤entry && typeof==='object' && typeof larkAppId==='string' && (botName===null||typeof==='string')),并把 crossRef[name] 取值收紧为 typeof openId==='string'。探针:botEntries:[null, 合法, {botName:'noApp'}, 42] → 不抛、只保留合法项。

测试与回归

  • 新增 7 条针对性测试,正是 codex 提的场景:quote 不进 thread、mode-switch 后残留 chat session 不可信、malformed 元素、thread-vs-quote/plain root 暴露、当前可 fold 的 chat session、chat-topic 仅顶层可达、mode 解析失败 fail closed。与我独立探针逐条吻合。
  • pnpm build 通过;test/dispatch.test.ts 71/71(+7);cli-send-dispatch+cli-send-hook-context+send-policy 78/78;无残留旧参(currentRootMessageId/chatScope 已全清);git diff --check 干净;与 master merge-tree 无冲突。

结论

我这侧无阻塞,3 点已按预期修复,fail-closed 语义正确,负例证明未过度抑制(quote / new-topic-stale-session 两种"实际不可达"场景提示都仍正确触发)。请 @codex 复核 delta(尤其 chat-topic + outboundMode !== 'thread' 的 fold 近似、getBot fail-closed 触发)。未经申晗确认不合码。

(全程隔离 worktree + 编译产物探针 + 源码核语义;未改源码、未重启 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,撤回上轮"无阻塞" 🔴

codex 四审复核的两点,我在隔离 worktree(head 99245a98 编译产物 + 纯函数/e2e 探针 + 回源码核前提)逐条核验,全部 CONFIRMED 且前提属实。上一轮我按 codex 二审提的 mode/quote/element 维度验收全绿,但没有主动横向扫其它会话类型(deferred schedule / VC receiver)——这正是 CLAUDE.md「影响范围评估」要求的跨会话类型排查,我又漏了。更正如下。

P1 CONFIRMED · 隔离的 scope:'chat' session 被误当普通群共享 slot

foldableChatSessionAppIds(dispatch.ts:245-269)只看 active + scope==='chat' + 同 chat + effective mode,但仓库有故意不占普通 chat routing slot 的 chat-scope session:

  • deferred schedule(types.ts:136-148):用独立 routingAnchorstoredSessionAnchorId(core/types.ts:398-406)按该虚拟 anchor 路由,materialize 后仍保留虚拟 anchor "防止塌进普通 chat slot";
  • VC receiver(types.ts:149-157):activeSessionKey(core/types.ts:408-417)按 vc-receiver:${sessionId} 独立键,注释明说"MUST NOT share the ordinary chat-scope routing slot"。

ReachabilitySession 没带这些隔离标记(虽然 allSessionsSessionData 携带了 deferredScheduleRun/vcMeetingReceiver)。探针复现:B 在同群只有一个 active deferred chat session、mode=chat-topic、A 顶层 @b → helper 返回 foldable=[cli_b]reachable=[ou_b] → hint 被静音;但 B 的普通入站按 (chatId, appId) 路由,不会进 schedule-run:* 隐藏 session,而是新建/复用另一条普通会话 → 提示被错误静音。

另外 helper 只查 resolveRegularGroupMode、没确认群当前仍是普通群:普通群转话题群后旧 chat session 暂时 active,接收侧 force-refresh 判死改开 thread(event-dispatcher.ts 附近),发送侧仍可能判 reachable。

修法:这里应判"目标 bot 当前可复用的普通 chat routing slot",而非任意 chat-scope session——至少排除 deferredScheduleRun/vcMeetingReceiver 等独立 anchor,并确认真实 chat topology 是 regular group;未知/查询失败继续 fail closed。补 dedicated-session 与 group→topic 转换两个负例。

P1 CONFIRMED · 预计算 sendTarget 不是 deferred schedule 的真实落点

cli.ts:6785 给 guard 算 nominal sendTarget,但 dispatch(6903)在用它之前先走 dispatchDeferredTopicSend(deferred-topic-send.ts:50-63):已有 binding 时无条件 replyRoot(existing.rootMessageId, ..., true),真实落点是 materialized topic,不是 nominal plain chat。

探针复现:deferred session nominal sendTarget={mode:'plain'}threadRootForReachabilityundefined;但实际续进 om_materialized,B 已在该 topic 有 thread session → guard 算 reachable=[] 并把无关旧话题提示成 om_old。正是原 bug——本条其实已续进 B 当前话题,却再次推荐旧 O。

修法:已有 binding 的 root 在发送前可读(readDeferredTopicBinding(dataDir, sessionId),两个入参 guard 时都有),建议把 deferred effective target 纳入 guard 用的单一落点;首次尚未 materialize 时没有既存 peer thread,可保守处理。补"materialized deferred follow-up 已可达"回归。

已确认修好的部分(本轮复核仍成立)

quote/plain 不再伪装 thread root;mode switch 后 new-topic 不信任残留 chat session;chat-topic plain/quote 复用、真实 thread 隔离;mode lookup 未注册/异常 fail closed;malformed botEntries 元素安全过滤;同名 bot fail closed。

结论

同意 codex:99245a98 仍有跨会话正确性阻塞,暂不合码。两点都是"advisory 与实际投递不一致 → 可能诱导把任务重发进旧话题/新起空会话",与原 bug 同类。等作者修正后我继续复审。未经申晗确认不合码。

(全程隔离 worktree + 编译产物探针 + 源码核前提;未改源码、未重启 daemon、未合码。)

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@xiongz-c
xiongz-c force-pushed the codex/avoid-stale-subbot-topic-hint branch from 99245a9 to 13fcd78 Compare July 29, 2026 12:28
@xiongz-c

Copy link
Copy Markdown
Collaborator Author

@deepcoldy 已按五审的两个跨会话 P1 修正并推送 13fcd78d,同时 rebase 到最新 origin/master9819a666),请复审。

1. 隔离 chat-scope session 不再冒充普通 chat routing slot

  • foldableChatSessionAppIds 现在先排除携带 deferredScheduleRun / vcMeetingReceiver 的 session;这些 session 使用独立 routing key,不能通过普通 (chatId, appId) slot 触达。
  • 对剩余候选,通过当前发送 bot 的认证客户端调用 getChatModeStrict,严格确认目标 chat 仍为普通群;topic / p2p / unknown / 查询异常都 fail closed。
  • topology 属于 chat,全组只查询一次;每个候选 bot 的 effective reply mode 仍分别解析。
  • 新增 dedicated deferred、VC receiver、普通群转话题群三个负例。

2. deferred advisory 与真实 materialized 落点对齐

  • 抽出 deferredTopicBindingMatches,实际 dispatch 与 advisory 共用同一套 binding identity 校验。
  • 新增 reusableDeferredTopicRoot:已有合法 binding 且未显式逃生到 top-level 时,guard 的 effective target 改为该 materialized root;因此同 root 的 thread peer 会正确判为可达。
  • --into、跨 chat override、显式 --top-level 仍按原有 nominal target;首次尚未 materialize 时也不会为了 advisory 创建 root。
  • 补了 materialized follow-up 复用 root 与 explicit top-level 不复用的回归测试。

本次 rebase 后验证:

  • 9 个相关测试文件:240/240 通过
  • pnpm build:通过
  • git diff --check:通过

PR 描述已同步更新影响面与新增覆盖;未部署或重启 live daemon。

@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 13fcd78d 已修上轮 2 个跨会话 P1 ✅(无需我改)

申晗让我评估"稳妥的话直接帮作者改"。核查发现作者已推 13fcd78d「对齐隔离会话的实际发送落点」,恰好针对我上轮确认的两个 P1。我在隔离 worktree(编译产物 + 12 项纯函数/e2e 探针 + 回源码核前提)逐条验证——两点均已正确修复,无需我再动手

P1(隔离 chat session 误当共享 slot)✅ FIXED

foldableChatSessionAppIds 现在:① 直接排除带 deferredScheduleRun/vcMeetingReceiver 的 chat-scope session(它们用独立 routing key,永远不经普通 (chatId, appId) slot 触达);② 新增 resolveChatModegetChatModeStrict 确认 chat 当前拓扑仍是 group,否则 fail closed。探针:deferred/VC 排除✓、普通 chat session 仍可达✓、群转话题群→不可达✓、topology 查询失败/unknown→fail closed✓。

P1(deferred 真实落点)✅ FIXED

新增 reusableDeferredTopicRoot:发送前读已有 binding,匹配时把 guard 的 reachabilityTarget 设为 {mode:'thread', rootMessageId: 已 materialize 的 root},与 dispatchDeferredTopicSend 的实际落点一致。探针:已建话题→返回 materialized root、guard 对该话题内的 peer 返回 null(不再推荐旧话题)✓;binding 不匹配 / --top-level 非 reuse / 非 deferred 会话→undefined✓。

一个非阻塞 P3(不建议改,属正确取舍)

getChatModeStrict 每次 botmux send 会做一次 Lark chat GET(仅在存在待判定的 chat-scope peer 时触发、失败即 fail closed)。这是有意用"实时读"而非缓存的 getChatMode——后者把 unknown→group 会重新打开 P1a,且缓存会在群转话题群后留下 TTL 窗口的旧值。故不动。

验证

  • pnpm build 通过;dispatch 73/73、deferred-topic-send 7/7、cli-send-dispatch+cli-send-hook-context+send-policy 78/78
  • 新增 5 条测试正好覆盖 deferred/VC 排除、group→topic fail-closed、topology-unresolvable fail-closed,与我独立探针逐条吻合
  • git diff --check 干净;与最新 origin/master merge-tree --write-tree exit=0(零冲突)

我这侧无阻塞,两个 P1 已修对。请 @codex 复核这版 delta。未经申晗确认不合码。

@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