Skip to content

feat(card): 支持按 Bot 置顶实时会话卡片 - #1067

Open
TWT233 wants to merge 33 commits into
deepcoldy:masterfrom
TWT233:feat/pin-streaming-card
Open

feat(card): 支持按 Bot 置顶实时会话卡片#1067
TWT233 wants to merge 33 commits into
deepcoldy:masterfrom
TWT233:feat/pin-streaming-card

Conversation

@TWT233

@TWT233 TWT233 commented Aug 28, 2026

Copy link
Copy Markdown

背景

活跃会话的关闭入口位于公开实时状态卡片中。消息持续产生后,这张卡片容易沉入历史,清理已完成会话时需要反复查找。

本 MR 为每个 Bot 增加一个默认关闭的 pinStreamingCard 开关:开启后,仅将会话当前真实的 streamCardId 置顶;换卡、转移和成功关闭时按生命周期 best-effort 清理旧 Pin。

改动内容

  • 增加 per-bot pinStreamingCard 配置,只有字面量 true 开启,缺省保持关闭。
  • 在 Dashboard「Bot Defaults → Cards」与 /botconfig set pinStreamingCard on|off 暴露即时生效入口。
  • 增加飞书 pinMessage / unpinMessage 窄封装,显式处理 SDK 非零错误码和 transport-disabled 边界。
  • 将 Pin 生命周期接入实时卡片发布、替换、daemon 恢复、卡片恢复、转移和关闭路径。
  • Pin/Unpin 全程 fail-open、异步执行,不阻塞发卡、回复、恢复、转移、关闭或配置响应。
  • 使用发布围栏、per-message mutation queue 与 per-bot 配置串行队列处理 stale continuation、close/resume 同 ID、快速切换等竞态。
  • 保持默认关闭兼容性:从未开启的 Bot 不产生 Pin API 流量,也不会移除人工 Pin;仅真实 streamCardId 参与,明确排除 repo 选择卡、私有 /card、最终回复、CoT 和关闭卡。
  • 补充中英文配置/卡片文档与完整生命周期测试。

影响面

  • 公共层:bot-registry、Dashboard 配置、worker-pool、Lark client 与卡片恢复 handler。
  • 会话类型:话题会话与普通群 chat-scope 会话均按现有 streamCardId 生命周期工作;apiOnly、HTTP virtual 与无 Lark transport 会话不调用 Pin API。
  • CLI / backend:不改变 CLI 适配器、PTY/Tmux/Riff/Mojo 的执行语义;Pin/Unpin 不进入主操作成功边界。
  • 卡片范围:不通过按钮或 JSON 内容推断卡片类型,仅处理已有的 streamCardId / 已知 frozen stream-card ID。

一致性与限制

  • 显式 on → off 会按活跃会话当前/冻结的已知实时卡 ID 清理,即使 daemon 重启后进程内 Pin 来源记录已丢失。
  • 重复写入相同开关值不会触发热重算,避免默认 off 的 Bot 误清人工 Pin。
  • 本 MR 不增加持久 Pin journal,也不扫描整群 Pin;若设置已经是 off 且进程内来源记录已丢失,后续 close/transfer 无法安全区分功能 Pin 与人工 Pin,因此可能保留陈旧 Pin。这是 QoL/fail-open 范围内的已知取舍。

验证

  • 功能/生命周期矩阵(最终 integration HEAD):19 files passed969 passed / 1 skipped
  • mise exec bun@1.4.0 -- bun run build:通过(TypeScript、scripts typecheck、Dashboard bundle、dist audit)。
  • git diff --check origin/master...HEAD:通过。
  • 全量 mise exec bun@1.4.0 -- bun run test1111 files passed / 9 failed / 1 skipped18859 tests passed / 14 failed / 17 skipped。14 个失败均落在未修改的既有环境相关测试:/home/data00/home 路径别名、Bubblewrap/MCP 沙箱权限、宿主进程名以及 DSH 沙箱;与本功能前序基线的失败集合一致。
  • 未执行真实飞书验收:当前唯一可用 Bot 同时承载多个活跃会话,部署 feature 会重启整个 fleet,缺少可安全隔离的测试 Bot。
  • Dashboard UI 已由组件测试覆盖;当前宿主没有可用 Chromium/Playwright browser binary,因此未附截图。

显式讨论:未来是否默认开启?

是否应在观察 API 流量与失败率后,将 pinStreamingCard 改为默认开启?本 MR 为保持兼容性,刻意采用按 Bot 显式开启、默认关闭。

TWT233 and others added 30 commits August 28, 2026 16:29
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
TWT233 and others added 3 commits August 28, 2026 22:00
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
@TWT233
TWT233 requested a review from deepcoldy as a code owner August 28, 2026 14:22
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,生命周期覆盖得很完整 👍 我们做了一轮自动评审(双 agent 交叉复核),没有发现阻断性正确性问题。下面是几条非阻断的建议,供你参考。

先说验证结论,避免你重复劳动:我们注意到 PR 里提到「缺少可隔离的测试 Bot、未做真实飞书验收」,所以我们在临时自建的话题群里对飞书 Pin API 做了端到端探针(跑完即解散,不涉及任何现有会话),结果对这个 PR 是好消息:

  • 话题(thread)群里 Pin / Unpin 全部 code=0——功能在 botmux 主形态下确实可用
  • 飞书没有每群 Pin 数量上限(连续 12 次均成功),所以不会出现「撞上限后功能静默失效」
  • 删除一条已 Pin 的消息,飞书会自动移除对应的 Pin——这一点很关键:它意味着 recallFrozenCards 先删旧卡、后 Pin 新卡的顺序不会残留永久陈旧 Pin,会自愈
  • Unpin 一条从未被 Pin 过的活消息返回 code=0(干净 no-op),所以 close 路径批量 Unpin 不会产生 WARN 噪音;Pin 一条已撤回的消息返回 230011,fail-open 下无害
  • 30 个并发 Pin 全部成功、未触发限流

另外我们也确认了 im:message.pins:write_only 这个 scope 在现网已授权,功能不会一上线就整体降级。

建议(均非阻断)

1)drainBotStreamingCardReconcileQueuefinally 块里有 return

JS 语义下这会静默吞掉 try 块的异常(我们实测确认,而且连正常的 return 值也会被 finally 的 return 覆盖,连日志都不留)。目前 try 内部是 Promise.allSettled + 逐会话 catch,所以当前不可达;但这是给后续改动埋的一个静默黑洞。建议把 finally 里的 return 改成 if/else 分支,或在吞掉之前补一条 logger.debug

2)trackPinStreamingCardTask 建的派生 promise 没有 .catch()

task.finally() 会产生一个新的 promise,它继承 rejection 但没有 handler。我们逐一核过 5 个调用点,当前确实都无法真的 reject(队列任务体内全量 try/catch,pin chain 的 IIFE 自带 .catch),所以这是模式风险而非现行 bug。加固很便宜:给 tracked 补一个 .catch(() => {}),或用 task.then(ok, fail) 双参形式。

3)两处小 nit

  • reconcileStreamingCardPins 的 disabled 分支里 frozenIds 计算出来没有使用(死表达式)
  • pinMessage 失败时打的是 warn。在 scope 没配好的环境里,这会导致每轮发卡都 warn 一次;考虑降到 debug 或做去重。

4).superpowers/sdd/ 下的两份内部报告(共 194 行)建议不入库

docs/superpowers/ 虽然写在 .gitignore 里,但 master 上已经有同类文件(2026-08-24-bot-description-auto-load),算是既有惯例的延续,这部分我们不主张改;只是 .superpowers/sdd/ 那两份偏过程性的 TDD 报告在 master 上没有先例,可以考虑去掉。

5)需要维护者拍板的一点:Pin 是群级的,而实现语义是 per-session

这是我们唯一想请你和维护者一起确认的设计点。实测:同一个群里两个话题各 Pin 一张卡,群级 Pin 列表会同时存在两条。我们查了现网数据,绝大多数群(约 97%)确实只有单个活跃会话,不受影响;但长尾里存在同一个群有 158 个并发会话、且每个都持有真实 streamCardId 的情况——这类群开启后会出现大量互相顶替的 Pin,与文档里「只置顶当前实时状态卡片」的描述会有观感落差。另外 reconcileBotStreamingCardPins 是 bot 级扇出且没有并发上限,最忙的 bot 一次开关切换会触发约 423 次 Pin 调用(实测不会被限流,但量级值得知道)。

我们注意到设计文档里其实已经明确写了这个取舍("Pins are chat-wide and the invariant is per session"),所以这不是疏漏。建议:

  • 最小改动:在 docs-site/docs/{zh,en}/cards.md 现有那段里补一句说明——Pin 是群级的,同一个群的多个话题会互相顶替。目前 zh/en 两份都没有提到这一点。
  • 可选的 follow-up(不必在本 PR 做):条件收敛,即只在「该群当前只有 1 个活跃会话」时才 Pin,多会话群自动退化为不 Pin。复用 activeSessionsRegistry 一次同步扫描即可,不引入新状态。按现网分布,这只会关掉那 19 个长尾群的 Pin。

顺带说明我们已排除的一项怀疑,免得你多花时间:postTurnStartingCard 里把 (!activeSessionsRegistry || …) 改成 activeSessionsRegistry?.get(…) === ds(permissive → fail-closed)看起来有风险,但我们确认生产环境下 setActiveSessionsRegistry 只有一个调用点,且在 IPC 绑定、会话恢复、Lark 事件分发器启动之前就已执行,结构上不可达,因此不构成回归。

以上是自动评审的初步意见,最终以维护者审阅为准;第 5 点涉及产品语义,需要维护者决定方向。感谢你的工作 🙏

@deepcoldy

Copy link
Copy Markdown
Owner

补一条上面漏掉的建议,是我们评审里唯一「现在正确、但很容易被后续改动破坏」的点,值得直接固化到代码里。

pinStreamingCardIfEnabled 里的 stale Pin 补偿,必须绕过 per-message 队列

src/core/worker-pool.ts:2236 那句补偿用的是 unpinMessage(appId, messageId)——直接调客户端,没有走 queueStreamingCardMessageMutation。这是对的,而且是必须的:这行代码本身就运行在同一个 messageId 的队列任务体内部

问题在于紧接着 14 行之后(:2250)就定义了 unpinStreamingCardIds(),它内部会把请求排进同一个队列 key。所以「统一一下风格、都走 helper」是一个非常自然的顺手改动——而它会直接死锁:队列任务在等自己派生的队列项,那个队列项又排在自己后面,永远不 settle。

我们把这两种形状都跑了一遍确认(模型化了队列实现和真实调用链):

current    (unpinMessage 直调)                -> ok
refactored (改用 unpinStreamingCardIds)       -> *** DEADLOCK: never settles ***

因为 Pin 全链路是 fail-open + fire-and-forget,这种死锁不会报错、不会告警,表现只是「这条消息的 Pin/Unpin 从此再也不动了」,排查成本很高。

建议:在 :2236 上方加一行注释,说明为什么这里必须直调而不能复用 unpinStreamingCardIds。比如:

// 必须直调 unpinMessage:本函数体已运行在该 messageId 的队列任务内,
// 改用 unpinStreamingCardIds() 会把补偿排进同一个 key,形成自等待死锁。

如果你觉得注释不够牢,另一个选择是给 queueStreamingCardMessageMutation 加一个重入检测(同 key 重入直接抛或降级为直调),但对本 PR 来说一行注释的性价比更高。

另外一条可选的防御(不强求)

reconcileBotStreamingCardPins 是 bot 级扇出,Promise.allSettled 没有并发上限。我们实测 30 并发 Pin 全部成功也没触发限流,所以不阻断;但现网最忙的 bot 一次开关切换会产生约 423 次 Pin 调用。如果你愿意加个保险,在 allSettled 之前按 10~20 一批分片是个 5 行左右的改动。

同上,这些都是自动评审的初步意见,最终以维护者审阅为准

@deepcoldy

Copy link
Copy Markdown
Owner

维护者看过这个 PR 后,提了一个我们前两轮评审没覆盖到的场景:群里有多个机器人、多张流水卡时会怎样。我们为此补做了一轮真机验证(临时自建话题群、跑完即解散,不涉及任何现有会话),验出两条新信息,其中第二条我们认为是本 PR 目前最值得考虑的一处改进,所以单独开一条评论说明。

再次强调:代码正确性我们两轮评审都是 0 阻断,下面这些不是「实现错了」,而是「设计边界值得再想一步」。

一、Pin 是群级的,多 bot / 多话题会各占一条

实测:

  • 多 bot — A、B 各 Pin 自己的卡,群级 Pin 列表同时存在 2 条,不互相覆盖,是叠加关系。3 个 bot 在群里干活就是群顶 3 张卡。
  • 同一 bot 多话题 — 同群两个话题各 Pin 一张,同样并存。

我们扫了现网全部 session store 来估量级:

  • 好消息是绝大多数群不受影响——913 个持卡群里 894 个只有单个活跃会话(约 97%)
  • 但长尾比较极端:存在同一个群 158 个并发会话、且每个都持有真实 streamCardId 的情况(还有 108 / 60 / 52 / 31 / 27 的群)。这类群开启后,群顶会出现大量互相顶替的 Pin,与文档「只置顶当前实时状态卡片」的描述会有明显落差。

设计文档里已经写明了这个取舍("Pins are chat-wide and the invariant is per session"),所以这是已知的。我们的建议仍是前一条评论里那两点:至少在 cards.md 补一句(说明 Pin 是群级、同群多话题/多 bot 会各钉一张,目前 zh/en 两份都没提到);可选的 follow-up 是条件收敛(仅当该群只有 1 个活跃会话时才 Pin),按现网分布这只会影响那约 19 个长尾群。

另外补一个量级供参考:reconcileBotStreamingCardPins 是按 bot 扇出且 Promise.allSettled 没有并发上限,现网最忙的 bot 一次开关切换会产生约 423 次 Pin 调用。我们实测 30 并发不会被限流,所以不阻断,但知道这个数量级有助于判断是否要加分片。

二、机器人之间可以互相操作 Pin(飞书不做 per-app 隔离)

实测 bot B 能 Pin bot A 发的消息,也能直接 Unpin 掉 A 创建的 Pin(均返回 code=0,Pin 列表 2→1)。

本 PR 因为只处理自己 session 的 streamCardId当前不会踩到这个问题。提出来是因为「我钉的 Pin 只有我能动」是个很自然但不成立的假设——如果后续要做按群扫描或跨 bot 清理(包括上面提到的条件收敛),这一点必须先知道。

三、⭐ 飞书的 Pin 列表自带来源信息,可以直接解掉「provenance 丢失」这条限制

这条是我们觉得最有价值的:

PR 描述里有这样一段已知限制 ——

本 MR 不增加持久 Pin journal,也不扫描整群 Pin;若设置已经是 off 且进程内来源记录已丢失,后续 close/transfer 无法安全区分功能 Pin 与人工 Pin,因此可能保留陈旧 Pin。

我们理解这个取舍的出发点(宁可残留,也绝不误删运维手工钉的 Pin,这个优先级完全正确)。但实测发现:飞书的 GET /im/v1/pins 响应里每条都带 operator_idoperator_id_type

{
  "message_id": "om_xxx",
  "operator_id": "cli_a9f6...",
  "operator_id_type": "app_id"
}

官方文档对这个字段的说明是:open_id 表示操作人为用户,app_id 表示操作人为应用。我们实测 bot 创建的 Pin 确实是 app_id + 对应 appId。

也就是说,「功能 Pin」和「人工 Pin」的区分并不需要依赖进程内存——读一次 Pin 列表,按 operator_id_type === 'app_id' && operator_id === 本 bot 的 appId 就能判定。而目前 client.ts 只封装了 pin.create / pin.delete,没有读列表的调用,所以这个信息一直没被用上。

建议(可以放 follow-up PR,不必阻塞本 PR):补一个读 Pin 列表的窄封装,在 daemon 重启后的自愈路径 / 显式 on→off 转换里用它来判定来源。这样那条「重启后不敢清理、可能残留陈旧 Pin」的限制就能真正解掉,而不是作为已知取舍留下来。而且它天然兼容你现在「绝不动人工 Pin」的安全立场——只是把判据从「进程记得吗」换成「飞书说这是谁钉的」,后者在重启后依然有效。

以上仍是自动评审的初步意见;第一点的方向选择和第三点是否单开 PR,都请以维护者的决定为准。感谢你在生命周期和竞态上做的细致工作 🙏

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