Telegram 回挂目标的归属单位应为逻辑 turn,而非 userId 单槽位
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 401
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 现象
Telegram 出站的「回挂目标」(把答案以 Telegram 回复形式挂回提问那条消息,`reply_parameters`)
按 **userId 单槽位**记账,而它真正的所有权单位是**一轮逻辑对话(turn)**。两者错配,导致两种
用户可见的错挂:
**A. 并行流式 handle 互相抢槽位**
同一个 userId 上可能同时存在两个流式 handle —— 用户 turn 的卡(`turnRunner.ts:2218`)与
scheduler 自动任务转播的卡(`turnRunner.ts:1828`,`ensureTranspondHandle`)。后开的那个
`startStreamingText` 会无条件领取新目标覆盖前者(`telegram/index.ts:1322`
`claimTurnReplyTarget` → `turnReplyTargets.set`),而每次出站是在**发送时**从共享槽位取值
(`telegram/index.ts:1503` `sendRenderedChunk` → `1420` `leaseReplyTarget`)。于是 A 的后续
分段会挂到 B 的消息上。群 `replyQuoteGroup='all'` 档(要求整轮每条都挂回同一条)症状最明显;
私聊 `'first'` 档表现为「首条挂了、后续错挂」。
**B. 归属只覆盖流式段,不覆盖整个逻辑 turn**
现有保护 `activeStreamRounds`(`telegram/index.ts:267`,`beginStreamRound`/`endStreamRound`/
`trackStreamRound` 见 `1363`/`1367`/`1377`)只在**流式 handle 存活期间**锁住槽位。但一轮
turn 可以在中途收口流式卡:等待工具确认/交互时,`turnRunner.ts:2576` 会在发交互卡前
`await finalizeActiveStream(...)`(`turnRunner.ts:2623`,这是有意设计——让结论落在交互卡下方)。
此刻 A 的 turn 并没有结束,但归属已释放 → B 的排队提示(`notifyQueuedPosition` →
`sendMarkdownText` → `telegram/index.ts:1344` `claimTurnReplyTargetIfIdle`)可以接管 A 的目标;
A 交互后续流时新建 handle 再领取,队列里已无 B 的目标,A 后续输出不再挂回。
同类未覆盖的 turn 生命周期节点还有:续流、用户取消(`!stop`)、超时、以及真正终态
(`handleTurnDoneAsync` `turnRunner.ts:2297` / `handleTurnErrorAsync` `2365`)。
## 影响面(修好之前用户会遇到什么)
- **群里 `replyQuoteGroup='all'` 档**:一轮回答中途改挂到别人的新消息上;多人群里表现为「答案
引用了不相干的提问」,需要人工判断这条到底在回谁。
- **工具型 turn(有确认卡/交互卡)**:交互之后的续答不再挂回提问,答案变成孤立消息。
- **scheduler 自动任务与用户对话同时进行时**:两边的卡互相抢引用,可能把自动任务的输出挂到
用户刚发的消息上。
- 不涉及数据正确性、不丢消息内容、不涉及权限或凭证——**纯粹是「答案挂在哪条消息上」的呈现
错误**,且只在并发/交互场景出现,单轮普通问答不受影响。
## 为什么不在 #1435 里修
依据 git-workflow「不属于本 PR 的问题:外推 issue 的边界」逐条对齐:
1. **存量设计缺陷,#1435 只是路过**:`turnReplyTargets` 的 userId 单槽位语义早于 #1425/#1435;
在 base `643d232a` 上 `claimTurnReplyTarget` 同样是无条件 `set`,`claimTurnReplyTargetIfIdle`
同样在槽位空时被独立出站接管。#1435 没有引入、也没有让它更易触发(#1435 反而把流式段内的
保护做了出来)。
2. **本 PR 声称的体验不依赖它**:#1435 交付的是「首条出站失败重试不丢引用」,与并行 handle /
交互等待归属无关,不构成功能不完整。
3. **修它需要独立设计,且跨出本 PR 评审面**:正确修法是把 turn 生命周期从 turnRunner 传进
`@cindy/im` 传输层,牵动共享接口与 5 个渠道实现:
- `packages/lizi-im/src/channelIM.ts:98`(接口声明)、`packages/lizi-im/src/types.ts:291`
(`StreamingTextHandle`)
- `packages/lizi-im/src/telegram/index.ts:671`
- `packages/lizi-im/src/discord/index.ts:342`
- `packages/lizi-im/src/feishu/index.ts:127`
- `packages/lizi-im/src/dingtalk/index.ts:276`
- `apps/desktop/src/main/im/wechat/WechatIM.ts:586`
turnRunner 侧要挂满 5 个生命周期节点(流式收口、交互等待、续流、取消/超时、终态),**漏一个
就是目标泄漏或永久锁死槽位**——这条状态机已在 #1435 上连续被三轮 review 触碰,正是「局部
补丁不收敛 = 设计缺口」的信号。
## 建议方案(草案,实施时可另议)
把回挂目标从「userId → 单槽位」改为「turn 级 lease」:
- `ChannelIM` 增加显式的 turn 归属边界(如 `beginOutboundTurn(userId): TurnToken` /
`endOutboundTurn(token)`,或让 `startStreamingText` 接受调用方传入的 turn token),由
turnRunner 在**逻辑 turn** 的开始/终态调用,而不是跟随流式 handle 的生死;
- 同一 turn 内的所有出站(流式分段、交互卡、排队提示、图片旁路)共享该 turn 的 lease;
- 提交仍按身份校验(#1435 已实现的 `commitReplyTarget` 语义保留);
- 非本 turn 的独立出站(如另一条消息的排队提示)从队列领取**自己的**目标,不触碰活动 turn 的
lease;
- 其余渠道可先用「无归属 = 旧行为」的默认实现渐进接入,避免一次性改动 5 个渠道。
## 验收标准
- 群 `replyQuoteGroup='all'`:一轮 turn 的**每条**出站都挂回该 turn 的触发消息,期间有新消息
入队、有排队提示发出、有交互卡收口重开流式,均不改挂;
- 私聊 `'first'`:整轮只有首条挂回,且首条出站失败重试时仍挂回(#1435 的契约不被削弱);
- 交互等待 → 用户点选 → 续流:续流输出仍挂回原触发消息;
- 用户取消 / turn 超时 / turn 报错终态:lease 必须释放,下一条入站消息能领到自己的目标
(不出现「回复持续落后一条」);
- scheduler 转播卡与用户 turn 并行:两张卡各自挂回各自的触发来源,互不夺取;
- 回归测试覆盖上述五类,且每条都做负向验证(去掉修复即变红)。
## 相关
- 由 #1435 的 review 发现(Greptile 与 chatgpt-codex-connector 各一条 P1),按边界规则外推。
- #1435 已修掉同一区域内属于它的三层:出站成功后才消耗、提交按身份校验、流式段内归属保护。
Contributor guide
Research direction
Start with turnRunner.ts at the cited streaming, interaction, cancellation, timeout, and terminal-state paths, then trace ChannelIM and StreamingTextHandle in channelIM.ts, types.ts, and the listed channel implementations. Map the turn lease through each outbound path and add regression coverage for the five acceptance scenarios, including negative checks; done means concurrent turns retain separate reply targets and leases always release.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100