Skip to content

fix(card): 隔离跨话题流式卡片清理 - #905

Open
mameikagou wants to merge 3 commits into
deepcoldy:masterfrom
mameikagou:agent/topic-card-recall-isolation
Open

fix(card): 隔离跨话题流式卡片清理#905
mameikagou wants to merge 3 commits into
deepcoldy:masterfrom
mameikagou:agent/topic-card-recall-isolation

Conversation

@mameikagou

@mameikagou mameikagou commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

问题

BotMux 3.14.0 的 chat-scope 会话可以把不同飞书话题的 turn 路由到同一个 session/worker。每轮回复的目标已经按 turn 冻结,因此新卡片会发到正确话题;但流式卡片清理仍按整个 session 执行。

实际表现是:

  1. 话题 A 发消息,出现流式卡片 A。
  2. 同一个 chat-scope session 在话题 B 接受新 turn,卡片 B 正确发到话题 B。
  3. 卡片 B 发布成功后,recallFrozenCards() 撤回卡片 A。
  4. 再切回话题 A,会反过来撤回话题 B 的卡片。

脱敏后的现场日志结构如下:

Reply in chat-scope session ...
Replied ... to message <topic-A-or-B-root> [msgType=interactive, replyInThread=true]
[session] Recalled 1 previous streaming card(s)
[session] Posted starting card for turn ...

上游仓库限制外部账号创建 Issue,因此这里直接在 PR 中保留完整问题说明和修复。

根因

  • FrozenCard 只保存 messageId/content/title/...,没有保存卡片实际发往哪个飞书回复目标。
  • live card 使用单个 streamCardId/streamCardNonce,但没有与其绑定的稳定回复目标。
  • currentReplyTarget 会被后来接受的 turn 覆盖,不能用它反推上一张 live card 的归属。
  • 新卡片发布成功后,recallFrozenCards() 遍历并撤回 session 内全部旧卡片,所以会跨话题删除。

共享 session/worker 是 chat/shared 模式的既有语义;本 PR 只修复卡片被跨话题撤回,不改变会话上下文共享方式。

修复

  • 为 live card 和 frozen card 增加稳定的 replyTargetKey,区分:
    • plain:oc_*:群顶层消息;
    • thread:om_*:话题内回复;
    • quote:om_*:普通引用回复。
  • 每次卡片 POST 发出前,一次性捕获 effective turn 和 reply target key;请求完成后直接提交捕获值,不再从可能已被覆盖的会话状态重新计算。
  • parking/freezing 时把 live card 的原目标一同保存。
  • recallFrozenCards() 只撤回与新 live card 目标相同的旧卡片;其他话题的卡片继续保留,等该话题自己的下一张卡片成功发布后再清理。
  • 将 live card 的目标随 session 持久化,daemon 重启后仍能正确归属。
  • 对升级前没有目标信息的历史卡片采取 fail-safe:一旦新 live card 已有精确目标,不冒险撤回归属未知的旧卡片。

影响面

  • 改动位于公共 worker-pool 流式卡片路径,对所有 CLI/后端共用;没有修改 CLI 输入、worker 调度或会话路由。
  • thread-scope 和普通群顶层会话的目标 key 始终相同,仍保持“新卡片成功后清理上一张”的原行为。
  • chat/shared 跨话题场景改为按话题清理。
  • new-topic 模式本来就是独立 session,不改变其行为。
  • POST 失败仍会恢复上一张卡片的 ID、nonce 和目标,不会提前撤回可见卡片。

验证

pnpm build
✓ TypeScript、Dashboard bundle、dist audit 通过

pnpm test -- \
  test/recall-frozen-cards.test.ts \
  test/frozen-card-store.test.ts \
  test/worker-ready-display-mode.test.ts \
  test/session-adopt.test.ts \
  test/usage-refresh-timer-wiring.test.ts \
  test/session-lifecycle-start.test.ts
✓ 6 files / 235 tests passed

新增回归覆盖:

  • session 内同时存在 A/B 两个话题的 frozen card,只撤回当前目标的卡片;
  • A → B 时保留 A;
  • A → B → A 时只撤回旧 A,保留 B;
  • replyTargetKey 可随 frozen-card store 完整持久化。

全量 pnpm test 在本机运行超过 180 秒后被超时终止;其中 codex-app-runner.integration.test.ts 有 42 个环境相关失败。上述受影响路径的定向测试和完整 build 均已通过。

未切换 live daemon 到本 worktree,避免影响当前机器上其他正在运行的 BotMux 会话。

后续审查补充:会话迁移边界

进一步复核发现,/relay 和 mid-session /repo 会复用同一个 DaemonSession,但原实现只改写 Session/飞书锚点,旧话题的 currentReplyTargetturnReplyContextsreplyTargets、alias 和 live-card target key 仍可能留在运行态。replacement worker 的 ready 没有 turnId 时,这些旧状态可把新卡片或回复重新路由/标记到来源话题。

本次补充修复:

  • 在跨飞书目标迁移时,把仍需保留的 buffered-turn sender/participant 信息保留下来,但将所有可见回复目标统一重定位到新 session 的 canonical surface。
  • 清除旧话题 alias、current/quote target 和 live-card target key,确保来源消息 ID 不再具有路由权限。
  • 同一规则覆盖 /relay、卡片 repo switch、文本 /repo switch 三条生命周期路径。
  • 新增 helper、transfer、卡片切仓和文本切仓回归测试。

最新验证:

当前分支:11 files / 681 tests passed
pnpm build passed

模拟合并 upstream/master (f92664b1):
10 files / 673 tests passed
pnpm build passed

@mameikagou
mameikagou marked this pull request as ready for review August 17, 2026 10:48
@mameikagou
mameikagou requested a review from deepcoldy as a code owner August 17, 2026 10:48
@deepcoldy

Copy link
Copy Markdown
Owner

以下是自动化静态追踪与单测复核的初步意见,最终以维护者审阅为准。

建议修复后再合入:fresh-card POST 与 streamCardReplyTargetKey 的绑定仍有一个可达竞态。

ready / screen_update 的 fresh POST 分支通过 scopedReply(...) 在调用时先把 fallbackTurnId(ds, msg.turnId) 传给 sessionReply,但 POST 成功返回后又用 streamingCardReplyTargetKey(ds, msg.turnId) 重新读取一次 fallback。forkTransferReplacement(ds, '', true) 启动的 replacement worker 没有 init turn,因此 ready.turnId 可以是 undefined。如果发往话题 A 的 POST 尚在等待时,话题 B 的新 turn 更新了 currentReplyTarget,则实际 POST 已按 A 路由,返回后记录的 key 却会变成 B。随后 recallFrozenCards 会按错误的 B 归属清理,且这张实际位于 A 的卡在 park 时也会被继续标成 B,重新打开本 PR 想关闭的跨目的地误删窗口。

我在 worker-ready-display-mode 现有夹具上用延迟 sessionReply 做了探针:调用的第 5 个参数稳定为 turn A;等待期间把 current target 切到 B 后 resolve,当前实现会得到 streamCardReplyTargetKey === thread:...B,可稳定复现该分叉。

建议在开始 POST 前一次性捕获“本次 POST 的 effective turn / reply target key”,成功后提交捕获值,不要在 await 后从可变会话状态重算;至少覆盖 readyscreen_update 两个分支(当前约 7719、8132 行),并补一个 msg.turnId 缺失、POST 期间 A→B 的回归测试。其余显式 turnId 路径也可以统一采用同一套 capture-at-dispatch 规则,避免未来出现同类时序问题。

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