Skip to content

fix(fork): /fork --create 新群在 new-topic 默认下消息稳定命中分身会话 - #1403

Open
DeepColds wants to merge 1 commit into
masterfrom
fix/fork-create-new-topic-routing
Open

DeepColds wants to merge 1 commit into
masterfrom
fix/fork-create-new-topic-routing

Conversation

@DeepColds

Copy link
Copy Markdown
Collaborator

Fixes #1400

问题

/fork --create <新群名> 把子会话固定注册为 scope=chat、anchor=新群 chatId,但目标 Bot 的普通群默认回复模式为 new-topic 时,新群顶层入站消息被 regularGroupRouting 路由为 scope=thread、anchor=当前消息 messageId —— 每条消息都是新锚点,永远命不中分身会话,于是误建空白会话并弹仓库选择卡。用户在新群执行 /reply-mode chat-topic 后恢复。

此外建群瞬间 bot 被拉入新群,bot.added 自动开工在 new-topic 默认下会另发 seed、按 messageId 注册一个空白 thread 会话,与分身争抢同一目标。

方案

三级优先级:用户对该群的显式 per-chat /reply-mode 设置 > fork 专属群保护 > Bot 全局默认

  1. 新增 src/services/fork-destination-store.ts(进程内登记表,不持久化)
    • createGroupWithBotsonChatCreated 回调在 createChat 一返回 chatId 时同步 markForkDestinationChat,覆盖「建群 → 钉模式落盘」之间数百毫秒的竞态窗口(期间用户已进群可能发消息、bot.added 也可能投递)。
  2. /fork --create 建群后 per-chat 钉 chat-topic(持久真相)
    • 以新增的 setChatReplyMode(appId, chatId, 'chat-topic', { force: true, source: 'fork-pin' }) 写入 chatReplyModes:顶层消息保持平铺命中分身,群内原生话题仍各自独立,与用户验证的临时规避 /reply-mode chat-topic 语义一致。
    • 只写 per-chat,绝不改 Bot 全局默认;best-effort,失败时 marker 与 restore 兜底仍生效。
  3. 入站路由(event-dispatcher regularGroupRouting
    • fork 专属群且没有显式 per-chat 条目时,顶层消息强制 scope=chat, anchor=chatId, source=regular-group-chat,与分身注册形态一致。
  4. handleBotAdded 让位
    • fork 专属群的入群自动开工直接跳过(forcePrompt 的显式领取流程不受影响)。
  5. 显式设置可随时退出保护
    • setChatReplyMode 新增 force / source/reply-mode 命令与 dashboard 两条显式入口均以 force: true 调用(即使所选模式与 Bot 全局默认同值也保留 per-chat 条目),并摘除 fork marker。用户显式切回 new-topic 后路由按 new-topic 走,群级设置保持可编辑。
  6. restore 兜底(session-manager)
    • restoreActiveSessions 从持久化的 forkedFrom + scope=chat + group 子会话行重建 marker,覆盖钉模式曾落盘失败或落盘前重启;同群 /fork <task> 子话题是 thread-scope,不会被误标。路由层的「无显式 per-chat 条目」门控保证用户之后的显式模式在重启后依然优先。

影响面评估

  • 跨 IM:改动仅在 Lark 层(event-dispatcher / reply-mode / daemon 入群开工),不涉及其它 IM 适配。
  • 跨 CLI:路由/会话注册逻辑与 CLI 无关,全部 20+ CLI 适配器零改动;forkSession 的 CLI 原生 fork 机制(claude 系/grok --fork-session、codex fork)完全未动,已在 fork-session.test.ts 既有用例上回归。
  • 跨后端:不涉及 Pty/Tmux backend 代码;fork 能力闸口(codex-app / Hybrid RPC 拒绝)不变。
  • 会话类型
    • /fork <task> 同群子话题(thread-scope)不经新路径,行为不变;
    • relay/transfer、trigger 会话、adopt、VC 会议会话不经 /fork --create
    • 唯一相关的 setChatReplyMode('chat') VC pin 调用语义保持(其目标群非 fork 专属群)。
  • 普通群四模式:chat/chat-topic/shared 下 marker 不改变原有路由结果(原本顶层即 chat-scope);new-topic 为本次修复目标,均有测试钉住。
  • 重启路径:restore 只改进程内状态,不在恢复路径写配置。

测试验证

先红后绿(新测在修复前稳定失败,修复后通过):

  • test/fork-destination-store.test.ts(新增):marker bot 维度幂等、restore 重建口径(只认 group+chat-scope+forkedFrom)、fork-pin 与用户显式切换的优先级(含与全局默认同值的 new-topic 强制落 per-chat 条目并摘 marker)。
  • test/event-dispatcher.test.ts:new-topic(per-bot 默认与 per-chat 覆盖两种形态)下 fork 群顶层 @ 与非@(mention-mode=never)均路由到 chat-scope 分身;显式 per-chat new-topic 优先于 marker;chat/chat-topic/shared 不回归;marker 的 bot 维度隔离。
  • test/command-handler.test.tsonChatCreated 即时标记竞态窗口、pin 入参为 {force:true, source:'fork-pin'}、pin 失败时 fork 与 lineage 仍完成。
  • test/group-join-shared-routing.test.ts:new-topic 默认下 fork 群 bot.added 不开工;无 marker 新群仍按原逻辑发 seed 开工。
  • test/reply-mode-command.test.ts:显式 /reply-mode 四条模式均带 force:true

执行命令与结果:

node_modules/.bin/tsc -p tsconfig.json --noEmit          # 通过
node_modules/.bin/tsc -p tsconfig.scripts.json --noEmit  # 通过
tsc -p tsconfig.test-mocks.json                          # 通过
node_modules/.bin/vitest run <相关 15 个测试文件>          # 全部通过(1000+ 用例)
git diff --check                                          # 通过

全量单测中另有 12 个文件(dist/binary 校验、bun-only sqlite 导入、dashboard UI 等)在本机失败,已用 git stash 在干净 master 上复现,确认为本环境缺 bun / dist 过期的存量问题,与本改动无关;完整 bun run build 以 CI 为准。

🤖 Generated with Claude Code

/fork --create 把子会话固定注册为 chat-scope(anchor=新群 chatId),但目标 Bot
普通群默认回复模式为 new-topic 时,新群顶层入站消息被路由为 thread-scope
(anchor=messageId),永远命不中分身,误建空白会话并弹仓库选择卡;bot.added
自动开工也会在 new-topic 下另发 seed 争抢锚点。

修复(三级优先级:用户显式 per-chat 设置 > fork 专属群保护 > Bot 全局默认):

- 新增 services/fork-destination-store 进程内登记表:createChat 一返回 chatId
  即经 onChatCreated 标记,覆盖「建群→钉模式落盘」竞态窗口;不持久化
- /fork --create 建群后以 force + source:'fork-pin' 把该群 per-chat 回复模式
  钉为 chat-topic(与用户验证的临时规避一致),不改 Bot 全局默认,失败时
  best-effort,marker 与 restore 兜底仍生效
- event-dispatcher regularGroupRouting:fork 专属群且无显式 per-chat 条目时
  顶层消息强制平铺到 chatId;四种模式(chat/chat-topic/shared/new-topic)
  与非@(mention-mode never)入站均稳定命中分身
- daemon handleBotAdded:fork 专属群的自动开工让位给分身(forcePrompt 除外)
- setChatReplyMode 新增 force/source:用户或 dashboard 显式 /reply-mode
  (含切回与全局默认同值的 new-topic)force 落 per-chat 条目并摘除 marker,
  群级设置保持可编辑
- restoreActiveSessions 从持久化的 group+chat-scope+forkedFrom 子会话行重建
  marker,作为钉模式未落盘时的跨重启兜底;同群子话题 fork(thread-scope)
  不会被误标

测试:新增 fork-destination-store 单测;event-dispatcher 增加四模式/非@/
显式覆盖优先级路由回归;command-handler 覆盖 marker 时机、pin 入参、pin
失败仍完成;group-join 覆盖自动开工让位与无 marker 不回归。全部先红后绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner

你好 @DeepColds,已为这个 PR 建立内部自动评审群:https://applink.feishu.cn/client/chat/open?openChatId=oc_e8b1ddc68908fef543d8edd83ebe1cd3

首轮评审正在进行,结论出来后我们会同步在这里。目前暂未能把你拉进评审群(自动拉群名单里你的账号还没有可用的飞书信息)。如果希望直接进群参与讨论,请把 GitHub 账号与飞书信息补录到这份名单文档:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe ,补好后后续复审会自动把你拉进群。

(这是自动流程发出的消息,请以维护者最终审阅为准。)

@deepcoldy

Copy link
Copy Markdown
Owner

你好 @DeepColds,自动评审的初步意见来了。

结论:技术上成立,未发现阻断问题,可直接合入。 下面 4 条都是可选的 follow-up 建议,不影响合码。

验证情况

评审期间主干前进到 fed664e4a#1391),已在本地 rebase 到最新主干复验(无冲突,rebase 前后 patch-id 逐字相同):

  • tsc -p tsconfig.json --noEmit → exit 0
  • 9 个受影响测试文件:928/928 通过
  • 反变异 9 发,8 发转红(路由闸、入群让位闸、onChatCreated 接线、source:'fork-pin'unmarkforce 与 redundant 规则、restore 过滤条件均被现有用例覆盖)

两层设计(建群时钉持久 per-chat chat-topic + 进程内 marker 兜建群竞态 + restore 从子会话行兜底)是清晰的,复用既有 onChatCreated 钩子而不新造口子、路由仍只读 resolveRegularGroupMode 这一个真相源,这两点都很好。

可选 follow-up

1. fork 失败路径可以补一行 unmarkForkDestinationChat

forkSession 失败分支会 best-effort disband 刚建的群,但没有摘除 marker。三重合取下(pin 写入失败 × fork 失败 × disband 失败)会留下一个「有 marker、无分身会话」的真群:顶层消息被强制平铺、入群不自动开工,但背后没有会话接。

很窄,且 marker 是纯内存态、重启自愈、restore 也不会重建它(forkSession 的失败出口要么在建子行之前,要么紧跟 closeSession),所以判为非阻断。补一行更稳妥。

2. src/types.tsforkedFrom 注释已过期

注释仍写着 Does not affect routing or lifecycle,但 restore 路径现在正是依据这个字段推导路由保护。

3. handleBotAdded 侧的显式覆盖闸建议补一条对称用例

daemon.ts!getExplicitChatReplyMode(...) 这半个条件删掉后,现有测试仍全绿(路由侧的同款条件是有覆盖的)。这个闸本身不冗余——用户显式 /reply-mode new-topic 后重启,marker 会被 restore 重建而内存态的 unmark 已丢失,届时正是靠它让用户配置优先。建议补:bot 配 chatReplyModes: {chatId: 'new-topic'} + marker 已标记 → handleBotAdded 应照常发 seed。

4. 「仅 marker」降级态下原生话题会折叠(两处 omt_ 判断)

pin 未落盘、只有 marker 的降级态里,群内原生话题会折叠回群会话,而不是像 pin 成功时那样各自独立。原因是 omt_ 隔离判断读的是 resolveRegularGroupMode(...) === 'chat-topic',此时解析结果仍是 new-topic

注意 event-dispatcher.ts 里这个判断有两处decideRoutingWithSourcemaybeFoldMentionedRegularGroupThreadToChat),只改其中一处治不全。两个 helper 在该文件顶部都已经 import 了。

一点影响面提示

{ force: true } 同时加在了 dashboard (dashboard-ipc-server.ts) 和 /reply-mode (reply-mode-command.ts) 两个入口,所以所有群(不只 fork 群)从此在「切回与默认同值的模式」时也会落一条 per-chat 条目。这看起来是让「显式选择」与「继承默认」可区分所必需的刻意取舍,只是想确认这个扩大到全量群的影响在预期内。另外目前没有代码在群解散/退群时清理 chatReplyModes 条目,条目会随 fork 次数累积(既有情况,本 PR 会让它更快增长)。


以上是自动评审流程的初步意见,最终以维护者审阅为准

@deepcoldy

Copy link
Copy Markdown
Owner

补充一条对上一条评论里 第 4 点 的更正 —— 建议本身不变,但我把症状描述写宽了,这里说准确一些。

上一条我写的是「降级态下群内原生话题会折叠回群会话」。实测下来,这个说法只对 chat / shared 默认成立;而本 issue 真正涉及的 new-topic 默认下,降级态的表现是种子和回复分裂,不是整体折叠:

状态(per-bot 默认 new-topic 原生话题种子 话题内回复
pin 落盘(chat-topic {thread, 种子 messageId} 独立会话 {thread, rootId} 同一独立会话
仅 marker(pin 未落盘) {chat, chatId} 落进群会话 {thread, rootId} 另起独立会话

原因是两处判断各管一段:decideRoutingWithSource 里的 omt_ 分支只管种子(不满足 chat-topic 就落到 regularGroupRouting,被 marker 平掉);而回复走的是 maybeFoldMentionedRegularGroupThreadToChat,那里在 omt_ 判断之后还有一道 if (mode === 'new-topic' || mode === 'chat-topic') return undefined;new-topic 默认在这里就返回了,所以回复不折叠。

于是降级态的实际后果是:话题的第一条消息进了群会话,之后的回复另起一个话题会话 —— 恰好就是 decideRoutingWithSource 那段注释里描述的、chat-topic 本来要避免的形状。

结论不变:仍然是非阻断的可选 follow-up,而且上一条说的「两处 omt_ 判断都要归一、只改一处治不全」依然成立 —— 只是两处各自负责的输入不同(一处种子、一处回复),chat / shared 默认下两处才都会折叠。

同样地,以上仍是自动评审流程的初步意见,最终以维护者审阅为准

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.

fix(fork): new-topic 模式下新群消息未命中分身会话

2 participants