fix(lark): 隔离普通群用户创建的话题 - #631
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
首审结论:🔴 CHANGES_REQUESTED — 1 个 P1 阻断项(shared 模式 split-brain)
由 Claude 首审。改动本身的核心机制是对的,但引入了一个 shared 模式专属、可复现的会话分裂(split-brain)回归,需修复后再合。
白话:这个 PR 在做什么
飞书「普通群」里,用户手动新建一个原生话题(客户端「消息 → 创建话题」)时,飞书给这条 seed 消息带 thread_id=omt_* 但没有 root_id。改动前 decideRoutingWithSource 走不到 real-thread 分支(它要求 root_id && thread_id 同时在),于是这条 seed 落到 regularGroupRouting,按普通群模式处理 —— 在 shared/chat 模式下被拍进「群大堂」chat-scope 会话,这个用户显式新建的话题就被吞了。
本 PR 加了一条判据:decideRoutingWithSource 里,在 topic-chat 检查之后,若 thread_id?.startsWith('omt_') 就判为 real-thread、thread-scope、anchor=messageId —— 让原生话题 seed 起自己独立的会话。同时给 maybeApplySharedTopicSeed 加了 independentTopicSeed 开关,当这是一条「thread-only 的原生 seed」(source==='real-thread' && thread_id && !root_id)时跳过 shared 折叠,防止刚判出来的独立 seed 又被折回大堂。放在 topic-chat 检查之后是对的:话题群 seed 仍保留 source=topic-chat、autoStartOnNewTopic 语义不变(测试已覆盖)。
🔴 P1:shared 模式下,原生话题里的后续 reply 会逃回群大堂(split-brain)
independentTopicSeed 只匹配 seed(thread-only,无 root_id)。但原生话题里的后续回复带 root_id + thread_id,independentTopicSeed=false。此时:
maybeFoldMentionedRegularGroupThreadToChat因ownsThreadSession=true首行返回 undefined(reply 命中 seed 建的 thread 会话)—— 正确;- 但随后仍无条件进入
maybeApplySharedTopicSeed,该 helper 没有ownsThreadSession守卫,independentTopicSeed又不匹配 reply,于是把routing改成{scope:'chat', anchor:chatId}并返回 messageId; - chat anchor 上没有 owner → 最终走
handleNewTopic,这条 reply 落到群大堂,而不是 seed 建的独立 thread 会话 → 一个话题,两个会话。
实证(隔离树跑 decideRouting/完整 handler,PR 与 master 对拍,同一 stateful seed→登记 owner→reply 序列):
master (6dcca31b): SEED → {NT, scope:chat, anchor:chat-reply-mode} ← 折进大堂
REPLY → {TR, scope:chat, anchor:chat-reply-mode} ← 同一会话,一致
PR (345b689): SEED → {NT, scope:thread, anchor:msg-native-seed} ← 独立会话(本 PR 目的✅)
REPLY → {NT, scope:chat, anchor:chat-reply-mode} ← 逃回大堂 ❌ split-brain
对拍证明这是 PR 引入的回归,不是既存问题:master 上 seed 本就折进大堂,不产生这个独立 owner,所以既存的 maybeApplySharedTopicSeed root+thread 漏洞不可达;PR 让 seed 独立后才把它变成可达的连续性破坏。
blast radius:仅 shared 模式。 chat 模式对拍验证 reply 正确续在 thread 会话(maybeApplySharedTopicSeed 对非 shared 模式提前 return,ownsThreadSession 守卫生效);new-topic/chat-topic 不受影响。
修复方向(已在隔离树验证,零回归)
maybeApplySharedTopicSeed 是话题 seed 逻辑,不该处理已在 thread 里的 reply。在 helper 开头加一条守卫即可:
// A message already inside a native thread (root_id + thread_id) is a reply,
// not a topic seed; folding it would divert an owned native-topic thread
// session into the group lobby (split-brain). Only fold genuine seeds.
if (message?.root_id && message?.thread_id) return undefined;隔离树上加此 4 行 + 一条 reply-continuity 回归测试:event-dispatcher 全量 241/241 通过(240 既存 + 新增),FIX-PROBE 确认 reply 正确续在 {scope:thread, anchor:msg-native-seed},现有 shared 用例全绿。建议作者顺带补一条完整 handler 级 seed→reply 连续性回归(现有新测试只覆盖 decideRouting 纯函数层与 seed 单点,未覆盖 reply 在完整 dispatch 下的会话归属)。
验证记录(隔离树,钉 PR SHA 345b689)
pnpm build✅ /pnpm exec tsc --noEmit✅(EXIT 0)pnpm vitest run test/event-dispatcher.test.ts→ 240/240 ✅(含 4 条新测试实跑,非 skip)- lark 路由 blast-radius 9 文件 373/373 ✅(message-parser / reply-mode-command / relay-target-routing / trigger-session-reply-mode / forward-followup-* / card-prefs-auto-start / grant-gates / summary-command)
- 全量 non-e2e 套件因本机并发测试负载被 SIGTERM,未完整采样;codex 独立复审已将全量 4 个失败在 master 复现为基线问题
结论:核心思路正确,合并前需堵上 shared 模式 reply 逃逸这个 P1。未获申晗确认前不合码。
复审收敛 + 补充:模式语义变化的完整实证矩阵(附给申晗的决策点)Codex 复审与首审收敛:同意 实证:4 模式 × (seed / owned-reply) 路由矩阵(PR 345b689 vs master 6dcca31 对拍)原生话题 seed =
三点在首审之上的补充结论
给申晗的决策点(一句话)「普通群里用户显式创建的原生 Lark 话题,是否应无视
无论哪条,当前 SHA 都不合码。另按仓库规范:标题/commit/描述需改中文格式(建议标题 (实证:PR/master 双树钉 SHA 隔离跑 |
补充实证:codex 的「结构性 reply-as-seed 漏洞」我已确认为既存(独立于本 PR)Codex 复审指出:root+thread guard 修掉的是 PR 新增的 native-seed→reply 回归路径,但 实证(PR 345b689 vs master 6dcca31 对拍,handler 级)三条入口的共同形状 = 「shared 模式 + bot 已拥有一个 thread-scope 会话 + 收到一条 root+thread reply」。直接构造这个状态: 归属探针(靠 replyRootId 区分是哪个函数折的: 结论
诚实的范围边界我实证的是汇聚点可达(给定 shared 模式下存在 thread owner,reply 必被折回 lobby)。codex 列的三条具体入口是否都能端到端产生这个 owner(尤其 mode-switch 的 stale 会话驱逐、restore/adopt 的 owner 重建),我没有逐条跑到底 —— 这属于既存 bug 的 follow-up 范围,建议作者/申晗定语义方向后单独立项验证。它不改变本 PR 的合码判定。 双审最终建议(收敛)
|
双审收敛 —— 验收清单(供作者修复后自检,避免漏项)Claude 首审 + codex 复审已完全收敛。当前 SHA 通用(两条路线都必须满足)
若选 global(原生话题始终压过 /reply-mode)
若选 narrow(仅 chat-topic + new-topic 隔离原生话题)
Follow-up(不阻塞本 PR 产品决策)
验证基线(双审已完成)
|
申晗决策 → narrow(遵循 dashboard 配置);PR 非完全多余,但需收窄申晗判断:「应遵循 dashboard 中的 /reply-mode 配置」= narrow 语义。据此核实 PR 是否必要,结论:不是完全多余,但当前 global 形态过宽,需收窄到只修真实缺口。 核实过程(clean master 6dcca31 实证)关键问题:遵循配置的前提下, master 已有 3 条 补跑纯 seed 探针(clean master): → master 上 chat-topic 的纯话题根 seed 被折进群 lobby,违反它自己的文档契约。 这是真实缺口,不是臆想。 因此
narrow 修法(遵循配置,最小充分)
一句话:PR 修的问题真实存在(chat-topic 纯 seed),但应收窄成「只补 chat-topic」而非「全局改写」;收窄后既符合「遵循 dashboard 配置」,又消灭 shared P1。等作者按此收窄 + 补测 + 改中文规范,双审复验。 |
345b689 to
0d00d45
Compare
|
已按 review 的 global 验收路线完成修复并更新 PR:
最新 commit: |
变更说明
普通群的群大厅继续使用原有 chat-scope session;用户显式创建的真实 Lark 原生话题(
thread_id=omt_*)作为独立上下文边界:thread_id、无root_id)以自身message_id创建 thread-scope session。root_id + thread_id回复继续以话题根消息为 anchor,复用同一 session。omt_*)仍保留原有折回群 chat session 的语义。Review 问题修复
maybeApplySharedTopicSeed不再处理已有root_id + thread_id的真实 thread reply。autoStartOnNewTopic、DMp2pMode=chat、shared alias 和 bot-to-bot 权限路径原语义。/reply-mode中英文帮助、Dashboard 帮助及 reply-mode store 契约说明。影响文件
src/im/lark/event-dispatcher.tstest/event-dispatcher.test.tssrc/services/chat-reply-mode-store.tssrc/i18n/zh.tssrc/i18n/en.tssrc/dashboard/web/i18n.ts验证记录
pnpm vitest run test/event-dispatcher.test.ts:248/248pnpm exec tsc --noEmit:通过pnpm build:通过git diff --check:通过