Skip to content

fix(qqbot): restore per-group session isolation under thread scope - #8241

Open
Eric-GoodBoy-Tech wants to merge 17 commits into
QwenLM:mainfrom
Eric-GoodBoy-Tech:fix/qqbot-session-scope-thread
Open

fix(qqbot): restore per-group session isolation under thread scope#8241
Eric-GoodBoy-Tech wants to merge 17 commits into
QwenLM:mainfrom
Eric-GoodBoy-Tech:fix/qqbot-session-scope-thread

Conversation

@Eric-GoodBoy-Tech

@Eric-GoodBoy-Tech Eric-GoodBoy-Tech commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Restores per-group session isolation for the QQ Bot channel. Removes the forced sessionScope: 'single' override that the constructor applied whenever groupAllPolicy was keyword or all (introduced during PR #6457 review); the channel now respects the configured scope and warns when a non-shared scope is combined with full-message group mode. Reply streaming is anchored per session so concurrent users in the same group no longer corrupt each other's msg_id threading. Orphaned persisted routing entries from the old single-scope layout are purged on startup, and the QQ channel now declares thread as its default session scope so zero-config deployments get per-group shared context by default.

Why it's needed

With groupAllPolicy: "all", every group, every user, and every DM was collapsed into one shared __single__ session — context leaked across groups and into DMs, and the bot answered one group's questions using another group's history. The forced scope also produced follow-on bugs (shared-session destruction, broken router.getTarget) that were patched around instead of fixed. The replyMsgId concurrency issue that motivated the override was never actually resolved by it, only hidden.

Reviewer Test Plan

How to verify

  • Run the unit suite: npx vitest run packages/channels/qqbot — 7 files, 345 tests pass.
  • Config-level behavior: with groupAllPolicy: "all" and no explicit sessionScope, the channel must not emit the scope-forcing WARNING and must route group messages per group (:) and DMs per user (:).
  • Concurrent streaming: two users messaging in the same group while the bot is mid-stream must keep their replies anchored to their own msg_id (no cross-threading) — covered by the new stream tests.
  • Cancel behavior: interrupting a turn (/cancel or steer) must flush the buffered partial reply before tearing down stream state — partial output is not dropped (regression test pins this).

Evidence (Before & After)

N/A — internal routing/session behavior, no user-visible UI change. Before: all groups/DMs shared one __single__ session. After: per-group shared sessions with cross-group isolation.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

Unit tests only: npx vitest run packages/channels/qqbot — 345 passed; tsc --noEmit -p packages/channels/qqbot/tsconfig.json — clean; eslint on changed source — clean; prettier — clean.

Risk & Scope

  • Breaking change — effective session scope for any QQ deployment that omitted sessionScope: the plugin's defaultSessionScope is now thread instead of the global user default (config-utils.ts fills rawConfig.sessionScope || plugin.defaultSessionScope || 'user'). This applies to every QQ config that omits sessionScope — not just groupAllPolicy: keyword/all users — including plain groupPolicy: "allowlist" + requireMention deployments. Such users go from user (per-sender contexts) to thread (per-group shared context).
    • A group thread session is a shared session: /clear, /cancel, permission-request answers, and the ! host-shell gate apply to it for the whole group. With senderPolicy: "open" and no allowedUsers, any group member can clear/cancel the shared session and approve tool permissions for it. If members should not share history or control each other's turns, set sessionScope: "user" or restrict membership (senderPolicy / allowedUsers) — see the updated Session Isolation doc section.
    • Purge is limited to __single__ orphans. On startup the channel removes persisted <channel>:__single__ routing entries left over from the forced single-scope era (PR feat(qqbot): group message handling and cron-msg-experimental #6457); their history is not migrated (it cannot be split per group). Three-part user-scope keys are not purged — they age out naturally, so a zero-config deployment upgrading from user to thread scope keeps its sessions instead of losing them.
  • Cancel behavior change: onPromptEnd now flushes the buffered partial reply before tearing down stream state (previously the idle timer flushed it asynchronously; the round-1 anchor-release change had dropped it). Cancelled turns keep their partial output. This applies to /cancel, steer, and /clear alike — a cleared turn also flushes any buffered partial reply (documented, intended behavior).
  • Not validated / out of scope: live end-to-end verification requires real QQ credentials and was not run in CI; blockStreaming: "on" mode never captures a per-session anchor (documented limitation).
  • Breaking changes / migration notes: no config format change.

Linked Issues

Fixes #8238

中文说明

修复 QQ Bot channel 的会话隔离:删除 groupAllPolicy=keyword/all 时构造器强制 sessionScope='single' 的逻辑(PR #6457 review 期间引入),改为尊重配置并对非共享 scope 组合发出 WARNING。流式回复按 session 锚定 msg_id,同群并发用户不再互相串线;启动时清理旧的 single 孤儿持久化条目;QQ channel 声明默认 sessionScope 为 thread,零配置部署默认获得每群共享上下文。

背景:强制 single 把所有群、所有用户、私聊折叠进一个 single 会话,导致跨群/私聊上下文互相泄漏;且当年为掩盖 replyMsgId 并发问题引入的强制并未真正解决该问题。

破坏性变更:任何省略 sessionScope 的 QQ 部署(不只是 groupAllPolicy=keyword/all)默认 scope 从 user 变为 thread——群聊线程是共享会话,/clear、/cancel、权限审批、! 门控对全群生效;senderPolicy=open 且无 allowedUsers 时任何群成员可清共享会话/批准工具权限,需要时请设置 sessionScope=user 或限制 allowedUsers。旧 single 持久化会话启动时清理(历史无法按群拆分迁移)。取消 turn 时会先 flush 缓冲的部分回复再清理状态(部分输出不丢失)。

验证:单元测试 345 通过,tsc clean,eslint/prettier clean,均在 macOS 本地完成;Windows/Linux 未测。真实 QQ 凭据的全链路验证不在 CI 覆盖范围。blockStreaming=on 模式不捕获 per-session 锚点(文档化限制)。

Remove the forced sessionScope='single' override for groupAllPolicy
keyword/all (introduced in a3b70a5 during PR QwenLM#6457 review). It
collapsed all groups, users, and DMs into one shared session, leaking
context across groups and DMs.

- Replace the override with a WARNING for non-thread scopes
- Track reply msg_id per session so concurrent streams in one group
  do not cross-thread (sessionReplyMsgId anchor)
- Purge orphaned :__single__ persisted routing entries on startup
- Declare defaultSessionScope: 'thread' for the QQ channel
- Document session isolation semantics in qqbot.md

Fixes QwenLM#8238
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 31, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Gate re-run — Aug 24, triggered by @qwen-code /triage, the second re-run since the maintainer's end-to-end verification. Nothing has moved on the branch: the head is still f5c33173d2, the merge conflict with main persists, and the round-5 items remain unaddressed in code. What changed is what the gate was waiting for — the maintainer's explicit landing decision (approval on this SHA + auto-merge; see Stage 3). Template and direction hold exactly as below.

  • Template: complete ✓ — all required sections, including the bilingual summary.
  • Problem: observed, not theoretical. Linked issue fix(qqbot): groupAllPolicy all/keyword forcibly overrides sessionScope to 'single', leaking context across groups and DMs #8238 documents the forced sessionScope: 'single' override with a concrete config, and the maintainer's end-to-end run independently reproduced the defect on main — and sharpened it: in channel start / daemon mode the forced scope never reaches the router (setChannelScope runs on the parsed config before the constructor rewrites its copy), so the real default-mode defect is per-sender fragmentation plus a split brain between QQChannel's internal single gates and user-scoped routing.
  • Direction: aligned — this removes a regression introduced during PR feat(qqbot): group message handling and cron-msg-experimental #6457's review cycle and restores the documented per-group isolation intent. No direct reference in the reference CHANGELOG, but the area (channel session handling) is a shipped feature getting a bugfix.
  • Size: cross-package (packages/channels/qqbot + packages/cli), so Stage 0's core definition applies. Production logic: 739 lines (QQChannel.ts 730, index.ts 7, channel-settings-store.ts 2) · tests: 3,160 · docs: 63. Not a refactor type, so no hard block — but 500+ production lines crosses the maintainer-awareness threshold. Awareness is plainly satisfied (four review rounds plus a full E2E harness), though per policy it also caps the bot at deferral rather than auto-approval; see Stage 3.
  • Approach: matches what I'd propose independently — drop the constructor override, warn on non-shared scopes in full-message mode, declare defaultSessionScope: 'thread' in the plugin (the maintainer's mutation test confirms that line is load-bearing), anchor streaming replies per session, and purge __single__ orphans on startup. The bulk of the complexity sits in the deferred-flush-chain bookkeeping (turn counter, orphan side buffer, park/settle ordering) — which is exactly where the round-5 review's open Criticals live.
  • Risk: no changed file matches the revert-correlated high-risk paths; no elevated risk signals.

Moving on to code review. 🔍

中文说明

门禁复查(由维护者完成端到端验证后通过 @qwen-code /triage 触发)。模板与方向判断不变;未决项都在代码审查中——见 Stage 2 / Stage 3 评论。

  • 模板:完整 ✓,含双语说明。
  • 问题:已观测到的 bug,非理论问题。关联 issue fix(qqbot): groupAllPolicy all/keyword forcibly overrides sessionScope to 'single', leaking context across groups and DMs #8238 给出了触发配置;维护者的端到端验证在 main 上独立复现了缺陷,并进一步澄清:channel start / daemon 模式下强制 scope 根本没到达 router(setChannelScope 用的是构造器改写前的配置),默认模式下真正的缺陷是按发送人碎片化 + QQChannel 内部 single 判断与 user 作用域路由的"精神分裂"。
  • 方向:对齐——移除 PR feat(qqbot): group message handling and cron-msg-experimental #6457 评审周期引入的回归,恢复文档承诺的每群隔离语义。
  • 规模:跨包改动(channels + cli),适用 Stage 0 核心定义。生产逻辑 739 行、测试 3160 行、文档 63 行。非 refactor 类型,不硬拦;但超过 500 生产行的维护者关注线——关注显然已满足(四轮评审 + 完整 E2E),但按政策机器人仍以 deferral 收尾而非自动批准。
  • 方案:与我的独立提案一致——删掉构造器强制、非共享 scope 给 WARNING、插件声明 defaultSessionScope: 'thread'(变异测试证明该行是承重墙)、按 session 锚定流式回复、启动时清理 __single__ 孤儿。复杂度集中在延迟刷新链的簿记(turn 计数器、孤儿侧缓冲、park/settle 顺序)——也正是 round-5 未决 Critical 所在。
  • 风险:未命中与 revert 相关的高风险路径。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at f5c33173d21cb39c2e21fd9bce03e9a7ba3c32dc · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Code review re-run — Aug 24, still against f5c33173d2; nothing has been pushed since Aug 7, so the findings below stand verbatim against the byte-identical head (the deferred-chain mechanics were re-spot-checked at the head SHA rather than trusted from the ledger). This run reads the diff and the head code statically; no PR code is executed. What changed is the verdict, not the findings: the maintainer's approval plus auto-merge turn these items from blockers into fix-forward work, as Stage 3 explains.

Findings

The scope change, the warning, the per-session reply anchor, the __single__ orphan purge, and the plugin-level default all do what the description says, and the test investment is real (~3,160 new/changed test lines across 4 files). The blocker is not direction — it is the round-5 review: 9 Criticals landed on exactly this commit and remain unaddressed (no author response since Aug 7). I re-verified the load-bearing ones against the code instead of trusting the ledger:

  • R5-3 — silent text loss, independently confirmed. onResponseComplete's stale-entry branch deletes streamOrphanBuffer and sends only fullText. But both bridges clear their collected chunks on every responseBoundary (clearChunks in AcpBridge and DaemonChannelBridge), so fullText carries only post-last-boundary text — while every chunk of the current turn sat stashed in the orphan buffer behind a stale parked entry. Pre-boundary text is dropped, not delivered. Needs edge-case timing (the previous turn's deferred chain must survive into the current turn), but it is a real reply-loss path the suite does not pin.
  • R5-6 — stale pendingStreamDelete flag, independently confirmed. The normal completion path deletes streamState / flushedSessions but never clears pendingStreamDelete. A flag parked by an in-flight deferral survives onPromptEnd's early return into the next turn, whose first successful flush consumes it mid-turn and runs terminal settle — releasing the new turn's anchor and deleting its turn counter. Round-5's probe observed the reply delivered twice; the suggested one-line delete next to the other deletes looks right.
  • R5-2 — mid-turn anchor release, mechanism traces cleanly. The new onResponseBoundary defer branch sets pendingStreamDelete mid turn; an in-flight send settling with an empty buffer then takes the terminal-settle else-branch and releases the anchor while the turn is still running. Later response windows create entries without an anchor and fall back to the chat-level replyMsgId — the exact re-parenting race this PR exists to remove (or an ACTIVE_MSG_DISABLED drop where active messages are off).
  • R5-1 / R5-4 / R5-5 / R5-7 — persisted msgSeqMap orphan family. Release-before-delete ordering plus the live-flush retention guard can keep a counter that no later path ever re-releases (supersede-with-in-flight-settle, session death mid-flush, boundary teardown of a parked entry, concurrent-overwrite retention). One persisted counter per occurrence — a slow leak rather than corruption, but it contradicts releaseSessionReplyAnchor's own documented guarantee. The shared fix proposed across the findings (identity-guarded re-release in flushAndTrack's .finally() after the flush marker clears) would close the family.
  • R5-8 / R5-9 — two tests pin the orphan outcome as intended behavior, so the suite turns red against the fix — the author needs to flip those assertions together with the production change, not after it.

None of these interleavings were exercised by the maintainer's harness (its own "not covered" list names cancel-flush and the deferred modes), which is how the E2E pass and the round-5 findings can both be true at once.

Also open — not code-review findings, but they gate merge:

  • Merge conflict with main (feat(channels): support group pairing #8440's pairing enum vs this PR's chat_thread, plus the re-padded options table). /resolve produced the union resolution as a workflow artifact but cannot push to the org fork — the author needs to merge main into the branch.
  • The two pre-merge asks from the maintainer verification are still open: the PR body's "leaked across groups and into DMs" wording (that is the explicit-single case; default-mode main fragments rather than leaks) and the userthread default flip in the release notes.
Files changed (9)
File What changed
packages/channels/qqbot/src/QQChannel.ts Core of the PR: scope-forcing removed (warning instead), per-session reply anchor + turn counter + orphan side buffer for streaming, single release path with msg_seq cascade, single orphan purge on startup
packages/channels/qqbot/src/index.ts Declares defaultSessionScope thread for the QQ plugin; mutation-tested as load-bearing by the maintainer
packages/cli/src/serve/channel-settings-store.ts Accepts chat_thread in the sessionScope validation set (source of the conflict with #8440)
packages/channels/qqbot/src/stream.test.ts +1833 lines: anchor lifecycle, supersede, settle/park races; two assertions flagged in R5-8/R5-9 as pinning the orphan
packages/channels/qqbot/src/send.test.ts +914 lines: msgIdOverride precedence, TTL fallback, seq retention under live flush
packages/channels/qqbot/src/events.test.ts +313 lines: READY wiring, purge-on-startup, group removal cleanup
packages/cli/src/serve/channel-settings-store.test.ts +29 lines: chat_thread accept/reject
docs/users/features/channels/overview.md sessionScope row + Session Management section updated for plugin defaults and chat_thread
docs/users/features/channels/qqbot.md New Session Isolation section documenting the shared-session gates (fixed in round 4)

Testing evidence — the PR's own CI (this run never executes PR code)

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Real daemon E2E / Java 11 ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
web-shell E2E Smoke (ubuntu-22.04, Node 22.x) ✅ success
Classify PR, label, precheck-pr, authorize, review-pr, delay-automatic-review ✅ success
SDK matrix — Java 11/17/21 × ubuntu/macos/windows ✅ success
Test (macos-latest / windows-latest), Integration Tests (CLI, No Sandbox), Post Coverage Comment ⚪ skipped (fork PR)

All 24 check-runs on the head completed — nothing failed, nothing pending. Green CI proves the suite passes; it cannot settle the findings above either way, since R5-8/R5-9 show part of the suite pinning the wrong outcome.

Behavioural evidence for the primary claims comes from the maintainer's real-environment harness (comment of Aug 12): session isolation, concurrent msg_id anchoring, orphan purge, and the default-flip mutation test were all reproduced side-by-side against main, with a real protocol harness. That is cited as the maintainer's verification — attributed, not re-run here. The author's self-reported unit numbers (345 tests) are consistent with the green CI suite but are the author's claim, not this run's evidence. Not verified by anyone end-to-end: the deferred-chain interleavings above, blockStreaming: "on" anchoring, and daemon mode.

Sandboxed verification would settle what remains: @qwen-code /verify — the R5-2/3/6 interleavings are unproven in either direction by CI or the harness, and this PR's suite passes with some of the wrong outcomes pinned. The lane is currently blocked by the merge conflict (refs/pull/8241/merge unavailable — the /verify job skipped for exactly this reason); it becomes runnable once the branch merges main. The author lacks write access, so this would be a sponsored run: a maintainer's /verify approves the head it runs against, carries a pre-execution risk screen and a full workspace wipe — and its report should be read with the same skepticism as the fork's own CI logs.

中文说明

针对 f5c33173d2 的代码审查复查——与 8 月 7 日 round-5 /review 审查的提交完全相同,此后无新提交。本次运行只做静态阅读,不执行任何 PR 代码。

结论:scope 修改、WARNING、按 session 的回复锚点、__single__ 孤儿清理、插件级默认值都与描述一致,测试投入真实(4 个文件约 3160 行新增/修改测试)。阻塞项不是方向,而是 round-5 评审:9 个 Critical 落在当前提交上且未获回应(作者 8 月 7 日后无回复)。我没有轻信清单,而是对照代码重新验证了关键几项:

  • R5-3——静默丢文本,独立确认。 stale 分支删除 streamOrphanBuffer 且只发 fullText;但两个 bridge 都在每个 responseBoundary 清空已收集分片(clearChunks),所以 fullText 只含最后一个 boundary 之后的文本——当前轮的全部分片此前都暂存在孤儿缓冲里。boundary 之前的文本被丢弃。需要边界时序(上一轮延迟链存活进当前轮),但确实是套件未固定的真实丢回复路径。
  • R5-6——陈旧 pendingStreamDelete 标志,独立确认。 正常完成路径删除 streamState/flushedSessions 但从不清 pendingStreamDelete;陈旧标志经 onPromptEnd 提前返回存活到下一轮,被下一轮首次成功刷新在回合进行中消费并执行终态清理。建议的一行删除看起来正确。
  • R5-2——回合中释放锚点,机制可完整追踪。 新的 onResponseBoundary 延迟分支在回合进行中设置 pendingStreamDelete;空缓冲 settle 走 else 分支在回合进行中释放锚点,后续窗口回退到 chat 级 replyMsgId——正是本 PR 要消除的串线竞态。
  • R5-1/4/5/7——持久化 msgSeqMap 孤儿族。 先释放后删除的顺序 + 在途保留守卫可能留下永不再释放的计数器。慢泄漏而非损坏,但违背 releaseSessionReplyAnchor 自己的文档承诺;各 finding 提出的共同修复(.finally() 清标志后带身份守卫地补释放)可闭合整族。
  • R5-8/9——两个测试把孤儿结果当作预期固定,修复落地时套件会变红,需随生产修改一并翻转。

维护者的测试装置没有覆盖这些交错时序(其"未覆盖"清单自己列出了取消 flush 与延迟模式),这正是 E2E 通过和 round-5 发现可以同时成立的原因。

另外两个非代码审查发现、但卡合并的事项:与 main 的冲突#8440pairing 枚举 vs 本 PR 的 chat_thread/resolve 已产出并集解决方案的工件,但无法推送到组织 fork,需作者自行合并 main);维护者验证中提出的两个合并前请求(PR 正文措辞修正、默认值翻转写入 release notes)仍未完成。

测试证据:头部提交 24 个 check 全部完成——无失败、无进行中(表格见上)。绿 CI 只证明套件通过;鉴于 R5-8/R5-9 表明部分套件固定了错误结果,它无法裁决上述发现。主要行为证据来自维护者 8 月 12 日的真实环境对照验证(会话隔离、并发锚点、孤儿清理、变异测试)——此处为引用归属,不是本次运行复跑。作者自报的 345 单测与绿色 CI 一致,但属作者声明。无人端到端验证过的:上述延迟链交错、blockStreaming: "on"、daemon 模式。沙盒验证通道当前被合并冲突阻塞,分支合并 main 后可用 @qwen-code /verify(赞助运行)裁决 R5-2/3/6。

Qwen Code · qwen3.8-max

Reviewed at f5c33173d21cb39c2e21fd9bce03e9a7ba3c32dc · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — my read of the code hasn't moved an inch since Aug 12: the direction is right, the core fix is real and maintainer-verified, and the round-5 interleaving defects are real too. What changed is not the code reading but who owns the decision — the maintainer has decided, and this run executes that decision instead of deferring to it again.

The Aug 12 deferral set a condition: if the maintainer decided the interleaving defects are acceptable to land now and fix forward, say so explicitly, and the gate would follow that call on re-run. The call is on record, and it is about as explicit as it gets: @wenshao approved this exact SHA on Aug 12 — five days after the round-5 review posted, and after publishing his own end-to-end verification against a real protocol harness — enabled auto-merge (squash) seconds later, and re-triggered /triage today with that approval still standing. That is the landing decision the gate was waiting for; the gate keeps its promise.

So this run approves, pinned to f5c33173d2 — and says plainly what the approval does and does not say:

  • It says: the maintainer's informed landing decision now has the second required approval behind it, and the PR's review decision reads approved instead of changes-requested. The round-5 review stays in the PR's history as the record of what's left; this approval supersedes it as the bot's latest verdict.
  • It does not say: the deferred-flush bookkeeping got a clean bill of health. R5-3 (silent text loss through the stale-entry branch) and R5-6 (stale pendingStreamDelete consumed mid-next-turn) still hold against this code — I re-spot-checked both against the head SHA rather than trusting the ledger — and R5-2's mid-turn anchor release still traces cleanly. Neither the suite nor the maintainer's harness exercises these interleavings (the harness's own "not covered" list names them). They are now fix-forward work: R5-2/3/6 first (user-visible), then the msgSeqMap orphan family (R5-1/4/5/7), with the two R5-8/9 test flips landing together with the production fix. Ideally promptly after merge — this author's record across four review rounds of addressing items quickly and thoroughly is part of why fix-forward is defensible here.
  • Policy note: 739 production lines across two packages from a fork crosses Stage 0's maintainer-awareness threshold. The point of that threshold is exactly that a human makes the landing call on a PR like this — and here the human made it, on the record, at this exact SHA. The bot's approval follows the call; it does not replace it.

Two hard blockers remain before anything actually merges, and neither is something an approval can change:

  1. Merge conflict with main (feat(channels): support group pairing #8440's pairing enum vs this PR's chat_thread in channel-settings-store.ts, plus the re-padded options table). Auto-merge cannot fire on a conflicting branch, and the org fork blocks maintainer pushes — @Eric-GoodBoy-Tech needs to merge main into the branch. The union resolution /resolve produced is attached as an artifact on the Aug 12 run. Heads-up: that push will dismiss both approvals (main runs with stale-review dismissal), so the post-conflict head needs a fresh review pass — auto-merge itself survives, and the re-review should be quick if the merge is exactly the known union.
  2. The maintainer's two pre-merge asks from the Aug 12 verification are still open: the PR body's "leaked across groups and into DMs" wording (that is the explicit-single case; default-mode main fragments rather than leaks) and the userthread default flip landing in the release notes.

@Eric-GoodBoy-Tech — the ball is on your side: merge main into the branch (plus, while you're there, the PR-body wording), and the gate will re-review the conflict-resolution commit quickly. @wenshao — nothing further needed from you unless the post-conflict re-review surfaces something new.

中文说明

置信度:3/5 —— 我对代码的判断自 8 月 12 日以来没有变化:方向正确,核心修复真实有效且经维护者实环境验证,round-5 指出的交错缺陷也真实存在。变化的不是代码判断,而是决定权的归属——维护者已经做出决定,本次运行执行这个决定,而不是再次上交。

8 月 12 日的 defer 提出过条件:若维护者决定交错缺陷可以先合并、后续修复(fix forward),请明确表态,门禁将在下次运行时遵循。表态已在记录中,而且再明确不过:@wenshao 于 8 月 12 日批准了这个 SHA(在 round-5 发布五天后、在其本人用真实协议测试装置完成端到端验证之后),数秒后开启了 auto-merge(squash),今天又在批准仍然有效的状态下重新触发了 /triage。这就是门禁等待的落地决定,门禁信守承诺。

因此本次运行批准,锚定在 f5c33173d2——并明确说明这个批准的含义与不含义:

  • 它意味着:维护者基于充分信息的落地决定现在有了第二个必需批准的支持,本 PR 的评审状态从"请求修改"变为"已批准"。round-5 评审保留在历史记录中作为未完成事项的清单;本批准作为机器人的最新结论取代它。
  • 它不意味着:延迟刷新簿记获得了健康证明。R5-3(stale 分支静默丢文本)、R5-6(陈旧 pendingStreamDelete 被下一轮中途消费)对照当前代码依然成立——我没有轻信清单,而是对该 SHA 重新抽查了这两处——R5-2 的回合中锚点释放依然可完整追踪。单测套件和维护者的测试装置都没有覆盖这些交错(其"未覆盖"清单自己列出了它们)。它们现在是 fix-forward 工作:先做 R5-2/3/6(用户可见),再做 msgSeqMap 孤儿族(R5-1/4/5/7),R5-8/9 两个测试断言随生产修复一并翻转。理想情况下合并后尽快跟进——本 PR 作者在四轮评审中逐条快速消化意见的记录,正是 fix-forward 可行的理由之一。
  • 政策说明:fork 来源、跨两个包、739 行生产代码超过 Stage 0 的维护者关注线。这条线的意义恰恰在于此类 PR 的落地决定由人做出——而这里维护者已经在这个 SHA 上、以记录在案的方式做出了决定。机器人的批准是跟随该决定,不是替代它。

在任何东西真正合并之前仍有两个硬阻塞,都不是批准能改变的:

  1. main 的合并冲突feat(channels): support group pairing #8440pairing 枚举 vs 本 PR 的 chat_thread,外加重新对齐的选项表)。冲突状态下 auto-merge 无法触发,组织 fork 又阻止维护者推送——需要 @Eric-GoodBoy-Techmain 合并进分支。/resolve 产出的并集解决方案已作为工件附在 8 月 12 日的运行上。注意:那次推送会同时驳回两个批准main 启用了陈旧评审驳回),冲突解决后的新头部需要重新过一遍审查——auto-merge 本身保留,且如果合并正是已知的并集方案,复审会很快。
  2. 维护者 8 月 12 日验证中的两个合并前请求仍未完成:PR 正文"跨群、跨私聊泄漏"的措辞(那是显式 single 的情况;默认模式的 main 是碎片化而非泄漏),以及 userthread 默认值翻转写入 release notes。

@Eric-GoodBoy-Tech —— 球在你这边:把 main 合并进分支(顺手修正 PR 正文措辞),门禁会快速复审冲突解决提交。@wenshao —— 除非复审发现新问题,否则无需再做任何事。

Qwen Code · qwen3.8-max

Reviewed at f5c33173d21cb39c2e21fd9bce03e9a7ba3c32dc · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review — PR #8241

3 Critical · 2 Suggestion


Critical

1. Stale per-session reply anchor survives cancelled prompt

  • File: packages/channels/qqbot/src/QQChannel.ts:1025-1027
  • Confidence: high

Issue: sessionReplyMsgId is never cleaned up on prompt cancellation. Under thread scope (now default), a cancelled mid-stream prompt leaves a stale anchor reused by the next prompt.


2. msgSeqMap entry orphaned forever

  • File: packages/channels/qqbot/src/QQChannel.ts:1743-1748
  • Confidence: high

Issue: The streamStillAnchored guard correctly skips msgSeqMap.delete during streaming, but no cleanup path reaps the entry after the anchor is released. Unbounded growth in memory and persisted state.


3. purgeSingleScopeOrphans deletes live single-scope routing entries

  • File: packages/channels/qqbot/src/QQChannel.ts:1695-1699
  • Confidence: high

Issue: Purge runs unconditionally and deletes :__single__ keys, but sessionScope:'single' is still valid. Explicit single-scope users lose their routing on every restart.


Suggestion

4. Unnecessary as-unknown-as cast

  • File: packages/channels/qqbot/src/QQChannel.ts:1688-1691
  • Confidence: high

Issue: getAll() and removeSessionId() are public non-optional on SessionRouter; the cast weakens type safety.


5. Test bypasses setReplyMsgId

  • File: packages/channels/qqbot/src/stream.test.ts:414-416
  • Confidence: high

Issue: Test writes directly to replyMsgId map, never exercises the streamStillAnchored guard.


Automated review by Qwen Code

Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/stream.test.ts
@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Code Review — restore per-group session isolation under thread scope

Reviewed at 0635538 in a clean worktree. The direction is right: the forced single override was a workaround that flattened every group and DM into one context, and killing it at the root — plus a per-session reply anchor and a defaultSessionScope — is the correct shape of the fix. npx vitest run packages/channels/qqbot288 passed, tsc --noEmit -p packages/channels/qqbot/tsconfig.json clean, eslint clean. Everything below was verified by running code against this commit, not inferred from the diff.

One under-advertised upside worth putting in the PR body: moving off single also re-enables features that ChannelBase disables under that scope — channel loops (ChannelBase.ts:1444), webhook tasks (ChannelBase.ts:1753) and channel-memory injection (ChannelBase.ts:3659). groupAllPolicy users silently lost all three to the forced override.


1. purgeSingleScopeOrphans() deletes the LIVE routing entry under an explicit sessionScope: "single" — Critical

QQChannel.ts:1686-1714, called unconditionally at QQChannel.ts:2083 right after restoreSessions().

:__single__ is not only a legacy key — it is the current routing key whenever sessionScope is single (SessionRouter.ts:116), which is still a supported, documented scope. The purge matches on the key suffix alone, with no scope guard, so a deployment that explicitly sets single loses its restored session on every cold start, and because removeSessionId() calls persist(), it is wiped from the sessions file too — i.e. the exact cross-restart context continuation this code path exists to provide. The log line then reports it as an "orphan", so the loss is silent.

This lands on precisely the users the old override created: the previous warning told them Forcing sessionScope to 'single', so many will have written single into their config to silence it.

Confirmed with a real SessionRouter (not a stub) against this commit:

const router = new SessionRouter(bridge, '/tmp', 'single', persistPath);
const sid = await router.resolve('test-bot', 'alice', 'group-openid-1', undefined, '/tmp', true);
// router.getAll() -> [{ key: 'test-bot:__single__', ... }]  and persistPath contains it

new QQChannel('test-bot', { ...cfg, sessionScope: 'single' }, bridge, { router })
  ['purgeSingleScopeOrphans']();

expect(router.getAll()).toEqual([]);              // ✅ passes — live mapping destroyed
expect(router.getTarget(sid)).toBeUndefined();    // ✅ passes
expect(read(persistPath)).not.toContain('__single__'); // ✅ passes — gone from disk
[QQ:test-bot] Purged 1 orphaned ':__single__' session mapping(s) from the single-scope era

Fix: if (this.config.sessionScope === 'single') return; at the top of the method (and a test for it — the three added purge tests all use thread).


2. The anchor's lifecycle is tied to a path that is not guaranteed to run — Critical

QQChannel.ts:1022-1032 (capture) and 1303-1335 (release).

The anchor is captured opportunistically on the first chunk and released in onResponseComplete. Neither end of that is guaranteed, and both directions break:

a) Not released on cancel. ChannelBase.ts:5510 gates delivery on if (!promptState.cancelled && response), so a cancelled turn never reaches onResponseComplete, and cancellation does not go through onSessionDied. The anchor survives into the next turn, which then threads its reply under the cancelled message's msg_id.

b) Not captured when the first window has no usable entry. sessionReplyMsgId.get() returns undefined both for "never captured" and "captured nothing", and the entry is only stored when the capture succeeds (if (anchor !== undefined)), so the "capture only on the first window" invariant in the comment does not hold. If the triggering message is already past the 5-minute TTL when the first chunk lands (slow turn), a later window re-captures — and by then the entry may belong to another user:

replyMap.set('test-chat', { msgId: 'msg-A', timestamp: Date.now() - 400_000 }); // A, past TTL
onResponseChunk(ch, 'test-chat', 'window-1 ', 'sess-A');   // no anchor stored
// ... flush ... B messages mid-stream:
replyMap.set('test-chat', { msgId: 'msg-B', timestamp: Date.now() });
onResponseChunk(ch, 'test-chat', 'window-2 ', 'sess-A');
// anchors.get('sess-A') === 'msg-B'  and the tail is sent with msg_id: 'msg-B'  ✅ passes

That is the cross-threading this PR sets out to prevent, still reachable.

Both collapse into one fix, and the hooks already exist as no-ops at QQChannel.ts:1000-1010: onPromptStart(chatId, sessionId, messageId) / onPromptEnd(...) are called with envelope.messageId (ChannelBase.ts:5427 / 5628), the latter from a finally, so cancel and error paths are covered. For QQ, envelope.messageId is event.id — the very value passed to setReplyMsgId (QQChannel.ts:2510/2608/2753). Setting the anchor at prompt start and clearing it at prompt end makes it exact and deterministic instead of racing the chat-level map, and removes the "did we capture yet" ambiguity entirely. (For loop/webhook turns messageId is undefined — deleting the anchor there is the correct behavior, since proactive sends must be active messages.)


3. msgSeqMap entries leak once an anchored msgId is overwritten — Medium

QQChannel.ts:1735-1752. Keeping the seq counter alive while a stream is anchored is right, but nothing reclaims it afterwards: the periodic sweep (startReplyMsgIdCleanup, 1759-1774) only walks replyMsgId, and the old msgId is no longer in that map. Verified — after the anchored response completes and the sweep runs past the TTL, replyMsgId is empty while msgSeqMap still holds msg-A, forever, and it is serialized to disk (serializeQQState, 1355). One leaked entry per overlap in a busy group.

Suggestion: when the anchor is released, drop msgSeqMap[anchor] if it is no longer the chat's current replyMsgId — which the prompt-end hook in (2) gives you a natural place for.


4. The anchor bypasses the TTL check entirely — Medium

QQChannel.ts:636-641: msgIdOverride both wins over the lookup and forces entry to undefined, so neither the 5-minute check nor the expiry cleanup below it can run. For a turn that streams longer than the passive-reply window, every later chunk goes out with an expired msg_id:

// anchor captured at t0, then 6 minutes of streaming:
vi.advanceTimersByTime(360_000);
onResponseChunk(ch, 'test-chat', 'tail ', 'sess-A');
// body.msg_id === 'msg-A'  ✅ passes — expired, sent anyway

Pre-PR this fell back to an active send with the reply context expired log. It still degrades gracefully (the passive attempt fails and STEP 2 retries actively), but it costs an extra API round-trip per chunk and the stale replyMsgId/msgSeqMap entries are never cleaned. Storing { msgId, timestamp } in sessionReplyMsgId and dropping the anchor once stale keeps the old semantics.


5. The docs example contradicts the new default — Suggestion

docs/users/features/channels/qqbot.md:63 still ships "sessionScope": "user" in the sample config, while the new section at :111 says the channel defaults to thread and that user "is not suitable for group full-message scenarios". Anyone copy-pasting the example opts out of the new default and — with groupAllPolicy set — trips the new WARNING. Update the sample to "thread" or drop the key from it.

While there: the migration note is narrower in the PR body than in reality. sessionScope is unset in most configs, so the default flip applies to every QQ deployment, not just groupAllPolicy ones — group members who each had a private session now share one group-wide context and can read and steer each other's conversation. That is worth stating explicitly in the docs, not only in the PR description.


6. Nits

  • Warning text is wrong for single (QQChannel.ts:257-263): it fires for sessionScope: 'single' and tells the operator "group messages may fragment per user" — under single nothing fragments, everything collapses. Since single is what the old code told users to set, these are the people most likely to see it; a scope-specific message would help.
  • Formatting: QQChannel.ts and send.test.ts are not Prettier-clean on this branch (main is). Three spots: captureReplyMsgId's ternary (1725-1726), and in send.test.ts the callPurge body and the removeSessionId arrow. npm run format fixes it. CI runs prettier --write, so it won't go red — it just leaves a formatting delta in the tree.
  • Cast in purgeSingleScopeOrphans (1688-1691): getAll()/removeSessionId() are public and non-optional on SessionRouter; the as unknown as { … ?: … } shape exists only to tolerate the fake routers in tests. Typing the parameter against the real class (or giving the tests a real SessionRouter) keeps the type safety.
  • Test coverage: the new stream tests run against a fully mocked ChannelBase, so the super.onResponseComplete fallback branch at 1332 is only ever exercised against a stub, and no test covers the cancel path, the deferred-complete (pendingStreamDelete) anchor release, or two consecutive turns in one session re-anchoring correctly.

Blocking: (1) and (2). (3)–(6) are follow-ups that can ride along in the same round.

中文摘要

方向正确:删除强制 single、改为 per-session 锚定 msg_id、声明 defaultSessionScope: 'thread',是对症的根因修复。本地在 0635538 上跑通:288 测试全过,tsc / eslint 干净。以下结论均由实际运行验证,非从 diff 推断。

阻塞项

  1. purgeSingleScopeOrphans() 会删掉正在使用的路由QQChannel.ts:1686-1714,调用点 2083)。:__single__ 不只是历史遗留 key —— 当 sessionScope: 'single' 时它就是当前 key(SessionRouter.ts:116),而该 scope 仍然合法。purge 只按 key 后缀匹配、没有 scope 判断,所以显式配置 single 的部署每次冷启动都会丢会话,且 removeSessionId()persist(),磁盘上的会话文件同样被清掉,日志还把它报告成 "orphan"。用真实 SessionRouter 已复现。修法:方法开头 if (this.config.sessionScope === 'single') return;。注意受影响的正是旧代码提示 Forcing sessionScope to 'single' 而照做的那批用户。
  2. 锚点生命周期挂在不保证执行的路径上1022-1032 捕获、1303-1335 释放)。取消的一轮不会走到 onResponseCompleteChannelBase.ts:5510!promptState.cancelled 门),锚点残留到下一轮,回复被挂到已取消消息的 msg_id 上;反方向同样有洞:get() 无法区分"没捕获过"和"捕获到空",若首个 window 时触发消息已过 5 分钟 TTL,后续 window 会重新捕获到别人的 msg_id —— 正是本 PR 想消灭的串线,已复现。两者可一并解决:onPromptStart / onPromptEnd 已经在 1000-1010 以空实现存在,且带 envelope.messageId(即 QQ 的 event.id,与 setReplyMsgId 同源),onPromptEndfinally 中调用,取消/异常都覆盖。

建议项msgSeqMap 在锚定覆盖后永久泄漏(周期清理只遍历 replyMsgId,已验证);msgIdOverride 完全绕过 TTL,超过 5 分钟的长回复会一直发过期 msg_id(已验证,虽有 active 回退但多一次 API 往返且脏数据不清);qqbot.md:63 示例仍写 "sessionScope": "user",与新增的 :111 小节自相矛盾;默认值变更的影响范围比 PR 描述更广 —— 未显式配置 sessionScope 的所有 QQ 部署都会从「每人独立」变成「同群共享」,涉及群成员间上下文可见性,建议写进文档;single 场景下的 WARNING 文案不准确;两个文件未通过 Prettier(main 是干净的);router 的 as unknown as 转型可以去掉;新增 stream 测试跑在被完全 mock 的 ChannelBase 上,取消路径与 super.onResponseComplete 回退分支没有覆盖。

顺带一提值得写进 PR 描述的收益:脱离 single 后,ChannelBase 中被该 scope 禁用的 channel loops(1444)、webhook tasks(1753)、channel memory 注入(3659)都会重新可用。

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

感谢这个 PR!在 review 之前,想邀请你顺便把 QQ 频道的 Web Shell 管理支持也补上——目前 Web Shell 的频道管理 UI 只支持 DingTalk / WeCom / Feishu,QQ 还只能通过 settings.json 手动配置。

需要两处改动:

  1. packages/channels/qqbot/src/index.ts:给 plugin 添加 management 描述符,参考 DingTalk 的写法。注意 QQ 的 appID / appSecret 在配置层面是 optional 的(有 QR 扫码登录回退),所以 required: false

  2. packages/web-shell/client/components/channels/channel-platform.ts:把 'qq' 加入 SUPPORTED_CHANNEL_TYPES,并更新对应的测试。

这样用户就能在 Web Shell 里直接创建和管理 QQ 频道实例了。可以作为这个 PR 的一部分,也可以单独开一个 PR,你来定。

…hans, guard single purge

- Release sessionReplyMsgId and streamState in onPromptEnd so a cancelled
  turn cannot hand its reply anchor to the next prompt (deferred completions
  are left for the in-flight flush's .then() to finish, preserving the
  residual-buffer re-flush)
- Wrap anchor release in releaseSessionReplyAnchor() to also delete
  msgSeqMap entries no longer referenced by any session or the chat's
  current replyMsgId
- Skip purgeSingleScopeOrphans when sessionScope is 'single' (the route
  is live state, not an orphan)
- Drop the defensive as-unknown-as cast on router cleanup APIs and update
  router mocks in tests
- Exercise the setReplyMsgId guard in stream tests instead of writing the
  reply map directly
@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Review

Removing the forced sessionScope: 'single' is the right call, and the per-session reply anchor is a genuine improvement over the chat-level replyMsgId lookup — the two new stream tests demonstrate the cross-threading fix. Verified locally on the PR head: npx vitest run packages/channels/qqbot → 7 files / 291 passed (PR body says 288), tsc --noEmit -p packages/channels/qqbot/tsconfig.json → clean.

Two defects reproduce with tests against this branch, plus a scope gap and a lint failure.


1. msg_seq resets to 1 for the final segment (blocking)

QQChannel.ts:1348-1356releaseSessionReplyAnchor(sessionId) runs before the final sendMessage(chatId, remaining, capturedMsgId). The release deletes msgSeqMap[anchor] whenever no other session and no chat-level entry still reference it — which is exactly what happens after a concurrent message overwrote the chat entry, i.e. the scenario this PR exists to fix. sendMessage then computes nextSeq = (msgSeqMap.get(msgId) ?? 0) + 1 = 1 (QQChannel.ts:674) and re-sends a msg_seq already consumed under that msg_id; QQ dedupes on (msg_id, msg_seq), so the tail of the reply is dropped by the platform.

Repro (fails on this branch):

setReplyMsgId(ch, 'test-chat', 'msg-A');
onResponseChunk(ch, 'test-chat', 'part1 ', 'sess-A');
vi.advanceTimersByTime(2000); await drain();
expect(seqMap.get('msg-A')).toBe(1);          // ok

setReplyMsgId(ch, 'test-chat', 'msg-B');       // user B arrives mid-stream
onResponseChunk(ch, 'test-chat', 'tail', 'sess-A');
await onResponseComplete(ch, 'test-chat', 'ignored', 'sess-A');

const final = mockSendQQMessage.mock.calls[1][3];
expect(final['msg_id']).toBe('msg-A');         // ok
expect(final['msg_seq']).toBe(2);              // ✗ received 1

flushAndTrack has the same shape at QQChannel.ts:1160: the anchor is released and then idleFlush re-sends the residual buffer with state.msgId.

Suggested fix: release the anchor after the send settles, or have releaseSessionReplyAnchor keep msgSeqMap[anchor] while a send using that msgId is still pending (e.g. a pending-send refcount alongside sessionReplyMsgId).

2. onPromptEnd can drop the deferred tail (blocking — regression vs. main)

QQChannel.ts:1020-1036 guards only on pendingStreamDelete. But the deferred flush's .then() deletes pendingStreamDelete before re-flushing (:1154-1167), and when flushingSessions is still set the re-flush only re-arms an idle timer. If that .then() runs before the turn's finally reaches onPromptEnd — the send resolving during await settleCancelRequested is enough — onPromptEnd then clears the timer and deletes streamState, and the buffered tail is never sent.

Repro (passes on origin/main, fails on this branch):

mockSendQQMessage.mockReturnValueOnce(pendingPromise);
onResponseChunk(ch, 'test-chat', 'part1 ', 'sess-A');
vi.advanceTimersByTime(2000); await drain();       // flush in flight
onResponseChunk(ch, 'test-chat', 'tail', 'sess-A');
await onResponseComplete(ch, 'test-chat', 'ignored', 'sess-A'); // deferred
resolveSend(ok); await drain();                    // .then() clears pendingStreamDelete
onPromptEnd(ch, 'test-chat', 'sess-A');
vi.advanceTimersByTime(5000); await drain();
expect(sentTexts).toContain('tail');               // ✗ only ['part1 ']

Suggested fix: also bail when flushingSessions.has(sessionId), or when the state still has a residual buffer/armed timer — release only the anchor in that case, not the stream state.

3. blockStreaming deployments keep the old bug

The anchor is captured in onResponseChunk, which returns immediately when blockStreaming is on — and blockStreaming: "on" is what the sample config in docs/users/features/channels/qqbot.md:66 recommends. Those turns reach onResponseComplete with capturedMsgId === undefined, fall through to super, and re-read the chat-level replyMsgId — so a concurrent message still re-parents the reply.

onPromptStart(chatId, sessionId, messageId) (QQChannel.ts:1000) already receives envelope.messageId, which is the same event.id fed to setReplyMsgId (:2555, :2653, :2798). Capturing the anchor there instead of on first chunk covers blockStreaming, is deterministic rather than "whatever the chat-level entry holds when the first chunk lands", and closes the pre-stream capture race the PR lists as a known limitation.

4. defaultSessionScope: 'thread' is broader than the PR description

parseChannelConfig falls back to 'user' (packages/cli/src/commands/channel/config-utils.ts:465-468), so this flips the effective default for every QQ deployment that never set sessionScope — not only groupAllPolicy: keyword/all ones. Consequences worth stating explicitly in the PR body and docs:

  • Group members now share one context, one /clear, one /cancel and one approval state. That is the intent, but it is a privacy-relevant change for plain requireMention groups that previously had per-user isolation.
  • Existing <channel>:<sender>:<chat> mappings become unreachable and are not purged (only :__single__ is), so those users silently start from an empty context while the dead entries stay in the sessions file. Either purge them too, or document why the asymmetry is intended.

5. Docs are self-inconsistent

  • docs/users/features/channels/qqbot.md:63 still ships "sessionScope": "user" in the sample config, directly above the new "Session Isolation" section stating the default is thread — and with groupAllPolicy: keyword/all that sample now trips the new WARNING. Drop the key or set it to "thread".
  • docs/users/features/channels/overview.md:64 still documents user (default) with no per-channel exception note.

6. Lint will fail

npx prettier --check fails on all three changed source files (QQChannel.ts, send.test.ts, stream.test.ts); the same check is clean on origin/main. Offenders: captureReplyMsgId's ternary (:1770), callPurge, the removeSessionId arrow in send.test.ts, and the setReplyMsgId / promptEnd helpers in stream.test.ts. npx prettier --write on those three files fixes it.


Nits

  • onPromptEnd (:1020): const sessionId = _sessionId; — the parameter is used now, so rename it to sessionId and drop the alias.
  • purgeSingleScopeOrphans catch (:1727): the template ends with \n and a literal newline, emitting a blank line. Copied from fixRestoredSessions — worth not propagating.
  • releaseSessionReplyAnchor (:1750) allocates [...values()] and full-scans replyMsgId on every release (now once per prompt end). A reverse msgId → refcount map would make it O(1); low priority at current map sizes.
  • purgeSingleScopeOrphans calls removeSessionId per entry, and each call re-persist()s the router. Fine at realistic counts, but a batched removal would avoid N writes.
  • The purge drops router mappings only; the corresponding agent-side sessions are not destroyed, so the old __single__ session data lingers in the daemon.

Coverage

Good targeted tests for the new anchor behavior. Missing: the two cases above (final-segment msg_seq continuity after a concurrent overwrite; deferred tail delivery when the flush resolves before onPromptEnd), and any test for the blockStreaming: on path.

…ct TTL

- Set the per-session reply anchor in onPromptStart from envelope.messageId
  (the same event.id setReplyMsgId stores) instead of opportunistically
  capturing from the chat-level entry on the first chunk, which could
  re-capture another user's msgId for slow turns past the 5-minute TTL
- Store {msgId, timestamp} in sessionReplyMsgId and drop stale anchors at
  use time, so long streams no longer send chunks with an expired msg_id
- Update the qqbot.md sample config to thread, consistent with the new
  default and the Session Isolation section
@Eric-GoodBoy-Tech

Copy link
Copy Markdown
Contributor Author

Thanks for the round-2 review — three of the five items overlap with round 1 and were already addressed in f9cc1bcb1 (the review was run against 0635538); the three genuinely new ones are fixed in 7f1912b1f.

Already fixed in f9cc1bcb1 (round-1 threads all resolved):

  • Stale anchor surviving a cancelled prompt → released in onPromptEnd, with a deferred-flush exemption so in-flight buffers aren't dropped.
  • Orphaned msgSeqMap entries → releaseSessionReplyAnchor() drops the seq entry once no session anchors it and it's no longer the chat's current entry.
  • purgeSingleScopeOrphans() deleting live single-scope routes → early return when sessionScope === 'single'.

Fixed in 7f1912b1f:

  1. Anchor capture blind spot — anchor now set deterministically in onPromptStart from envelope.messageId (the same event.id setReplyMsgId stores), cleared in onPromptEnd; a slow turn past the 5-minute TTL can no longer re-capture another user's msgId. New test: anchors a slow turn to its triggering msgId even after the chat entry expires or moves on.
  2. TTL bypass on msgIdOverridesessionReplyMsgId now stores {msgId, timestamp}, TTL-checked at use time (window creation + final-segment send), stale anchors dropped → long streams fall back to active sends. New test: drops the anchor past its TTL so chunks go out as active sends.
  3. Docs contradictionqqbot.md sample config now uses "sessionScope": "thread", consistent with the Session Isolation section.

Verification: npx vitest run packages/channels/qqbot — 7 files, 293 tests passed (2 new); tsc --noEmit clean.

Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/send.test.ts Outdated
Comment thread packages/channels/qqbot/src/stream.test.ts
…inuity

- Move releaseSessionReplyAnchor after the final-segment send in
  onResponseComplete and after the residual re-flush settles in the
  deferred path, so msg_seq is not reset to 1 mid-reply (QQ dedupes on
  msg_id+msg_seq and silently drops the tail)
- Use releaseSessionReplyAnchor for the TTL-expiry path in
  onResponseChunk instead of a raw delete (prevents msgSeqMap orphans)
- Exclude explicit 'single' scope from the groupAllPolicy WARNING (it is
  global sharing, not per-user fragmentation)
- Persist after dropping an orphaned msgSeqMap counter, matching the
  other mutation paths
- Add regression tests: single-scope purge no-op, expired-anchor
  final segment, and keep existing anchor lifecycle tests green
@Eric-GoodBoy-Tech

Copy link
Copy Markdown
Contributor Author

All 7 items addressed in f2daf02e9.

Critical 1 & 2 (msg_seq reset) — the anchor is now released after the final send settles, not before:

  • onResponseComplete: sendMessage(remaining, capturedMsgId) → await → releaseSessionReplyAnchor (onPromptEnd is the idempotent fallback if the send throws)
  • Deferred path: the in-flight flush's .then() re-arms the pendingStreamDelete marker and re-flushes the residual buffer while the anchor/msgSeqMap are still alive (seq continues 1→2), releasing only once the re-flush chain settles; the .catch() retry branch re-arms the same way
  • Result: msg_seq is never reset mid-reply, so QQ's (msg_id, msg_seq) dedupe can no longer drop the reply tail

Suggestion 3 — the TTL-expiry path in onResponseChunk now calls releaseSessionReplyAnchor instead of a raw delete.

Suggestion 4 — the groupAllPolicy WARNING now excludes explicit 'single' scope (global sharing, not per-user fragmentation); wording updated.

Suggestion 5releaseSessionReplyAnchor persists via saveQQState() when it actually drops a msgSeqMap counter.

Suggestion 6 & 7 — regression tests added: single-scope purge is a no-op; expired-anchor final segment goes out as an active send.

Verification: npx vitest run packages/channels/qqbot — 7 files, 295 tests passed (2 new); tsc --noEmit clean.

Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
…ored-seq helper

- purgeSingleScopeOrphans: exact-match `${this.name}:__single__` instead of
  suffix-matching the whole shared router, so a thread-scope QQ channel no
  longer deletes sibling channels' live single-scope routes in daemon mode
- stderr template literal: drop the stray source newline that emitted \n\n
- extract isMsgIdAnchoredBySession() as the single source of truth for the
  'still anchored' invariant shared by releaseSessionReplyAnchor and
  setReplyMsgId
- onSessionDied test: anchor the session via onPromptStart and assert the
  release drops the sessionReplyMsgId entry
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/send.test.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
…tream state

- mirror isMsgIdAnchoredBySession guard at the two remaining msgSeqMap
  delete sites (startReplyMsgIdCleanup tick and sendMessage's expired-entry
  branch) so a live session anchored past the 5-minute TTL keeps its msg_seq
  counter and the tail send cannot reset to seq 1 (QQ dedup drops it)
- give streamState entries a per-session turn generation bumped on
  onPromptStart; onResponseChunk drops a stale entry from a previous turn
  (deferred send parked in pendingStreamDelete) instead of appending the new
  turn's chunks to the old turn's buffer / delivering under its msgId, and a
  stale release can no longer delete the new turn's anchor
- purge test: seed a sibling-channel ':__single__' key so the exact-match
  guard is actually exercised (a suffix-match mutant stays green otherwise)
- qualify the anchor comments: they cover only the idle-flush path, not
  blockStreaming mode where onResponseChunk returns immediately
- log a stderr line when the per-session anchor is dropped for TTL expiry,
  so a fallback to active send (and ACTIVE_MSG_DISABLED) is attributable to
  the TTL root cause instead of a phantom admin toggle
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/stream.test.ts
… drop, pin seq ordering

- stale-state branch in onResponseChunk now also clears flushingSessions and
  pendingStreamDelete: they belong to the old turn's in-flight chain, whose
  identity guards fail against the fresh entry and would never delete them,
  stranding the session so the new turn's onResponseComplete/onPromptEnd both
  early-return and the buffer is never flushed (silent reply loss, probe:
  sendCalls 1 -> 2)
- new test: drops stale state from a previous turn and clears its flush flags
- new test: keeps msg_seq of an expired replyMsgId that is still
  session-anchored (startReplyMsgIdCleanup guard)
- new test: final segment continues the msg_seq counter of its session anchor
  (pins release-after-send ordering: a future refactor moving release above
  the final send resets seq to 1 and QQ dedup drops the reply tail)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Plan (not a blocker): 288 tests pass — this review observed 306 passed; 288 passed — this review observed 306 passed.

中文说明

Test Plan(非阻断):288 tests pass — this review observed 306 passed; 288 passed — this review observed 306 passed

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment thread packages/channels/qqbot/src/QQChannel.ts Outdated
Comment thread packages/channels/qqbot/src/QQChannel.ts
Comment thread packages/channels/qqbot/src/stream.test.ts
Comment thread packages/channels/qqbot/src/QQChannel.ts
… flag hygiene, real test coverage

- releaseSessionReplyAnchor gains an expectedMsgId identity check: all six
  deferred-chain release points (flushAndTrack .then() else, .catch()
  permanent-failure, pending retry exhaustion, both non-pending retry
  exhaustion paths, onResponseComplete) now pass state.msgId captured at send
  start, so a chain settling after the successor prompt overwrote the anchor
  releases only its own turn's anchor and msgSeqMap entry — never the
  successor's (probe: successor anchor msg-B destroyed before, preserved
  after)
- stale-state drop in onResponseChunk also clears flushedSessions (round-7
  missed it): a cross-turn stale wasFlushed=true otherwise strangles the new
  turn's zero-chunk reply via the fullText dedup path, and logs dropped
  superseded-turn chars so tail truncation is observable
- handleGroupDelRobot msgSeqMap.delete now guarded by isMsgIdAnchoredBySession
  (the last unguarded site)
- tests: fix three vacuous flushAndTrack error-path tests (single
  await Promise.resolve() never drained the rejection chain — catch ran after
  assertions; RETRY_EXHAUSTED/ACTIVE_MSG_DISABLED asserted 'keeps streamState'
  against source that deletes it, now flipped + mutation-verified), rebuild
  the overwritten-replyMsgId test to actually reach the setReplyMsgId guard,
  seed the expired-entry guard tests, add lifecycle / stale-timer / two-session
  same-msgId / retry-exhaustion flag / re-flush-to-completion / size-cap
  in-flight / tool-call anchor / boundary anchor-survival tests and
  handleGroupDelRobot cleanup coverage (314 tests)
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

Address wenshao review QwenLM#4:
- channel-settings-store: add 'chat_thread' to the accepted sessionScope
  set (github/gitlab already default to it; the docs and the constructor
  warning advertise it, but the settings API rejected it with
  channel_settings_invalid_config) + accept/reject tests
- qqbot.md: split the Session Isolation sentence — /clear and /cancel
  reach follows sessionScope; permission-request answers are gated by
  chat+thread (and shared-target), independent of scope
- QQChannel.ts: drop reviewer name from a comment (release-before-delete)
@Eric-GoodBoy-Tech

Copy link
Copy Markdown
Contributor Author

Thanks for review #4. Items 1, 3, 6 fixed in f5c33173d; item 2 decision below; items 4/5 tracked as follow-ups.

  1. chat_thread accepted by the settings API now — added to the validator set in channel-settings-store.ts, with accept/reject tests.
  2. Default flip: keeping it. The PR body already states it applies to every QQ config that omits sessionScope; the merge's release note will call it out explicitly. The "can't distinguish explicit vs default" cost is accepted — it's the same plugin-level-default mechanism GitHub/GitLab already use, and the constructor warning still fires for groupAllPolicy + non-shared-scope.
  3. Doc corrected — split the sentence. One nuance flagged: canEnvelopeAnswerPendingPermission also requires isSharedSessionTarget(target) || pending.target.senderId === envelope.senderId, so under user scope only the requester can answer their own request, while a shared thread session lets any member answer. The doc now states that.
  4. blockStreaming: "on" — acknowledged, tracked as follow-up (will also review the doc example config).
  5. SessionStream record refactor — agreed it's the right shape, tracked as follow-up with the test suite as the safety net.
  6. Fixed the stale 317→345 in the PR body, dropped the reviewer name from the source comment, and left the msgIdOverride cleanup asymmetry noted.

Verification: npx vitest run packages/channels/qqbot → 7 files / 345 tests pass; new channel-settings tests pass (accepts chat_thread, rejects invalid sessionScope) — the channel-settings suite's remaining failures are the pre-existing environment issue (stale dist of sibling channel plugins in node_modules), unrelated to this change.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f5c3317, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. Qwen review aborted with an API error before posting comments. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@Eric-GoodBoy-Tech

Copy link
Copy Markdown
Contributor Author

@qwen-code /review

@Eric-GoodBoy-Tech

Copy link
Copy Markdown
Contributor Author

Hi @wenshao — gentle nudge: the round-4 fixes (items 1, 3, 6 in f5c33173d, CI green) have been up for a while. Could you take a look when you have a moment?

Comment on lines +1231 to +1234
if (state.msgId !== undefined) {
this.releaseSessionReplyAnchor(sessionId, state.msgId);
}
this.streamState.delete(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The superseded-turn branch releases the anchor while the old turn's send is still in flight. The release cascade keeps the msgSeqMap counter (live-flush guard), then the entry and flags are deleted — and when the in-flight send succeeds, flushAndTrack's .then() early-returns at current !== state without ever re-running the release. No other deletion path targets the old msgId again (chat entry and anchors moved on), so the counter is orphaned permanently in memory and in the persisted QQ state. — Failure scenario: turn 1's tail flush in flight, turn 2's first chunk takes this branch; probe-verified: msgSeqMap['msg-A']=1 survives with zero references and survives restarts (restoreQQState reloads it). One persisted orphan per supersede-with-in-flight-settle occurrence. This is one site of a family (with the settle-ordering, onSessionDied, and boundary-teardown orphans); the shared fix is a post-settle re-check — e.g. re-run releaseSessionReplyAnchor(sessionId, state.msgId) in flushAndTrack's .finally() after the flushingSessions marker clears (identity-guarded, idempotent).

中文说明

superseded 分支在旧一轮发送仍在途时释放锚点:release 级联因 in-flight 守卫保留 msgSeqMap 计数器,随后条目与标志被删除;而在途发送成功时,flushAndTrack 的 .then() 在 current !== state 处提前返回,永远不会再次执行释放。其他任何删除路径都不会再触及旧 msgId(chat 条目与锚点已指向新消息),计数器在内存与持久化 QQ state 中永久成为孤儿。失败场景:轮次 1 尾部刷新在途,轮次 2 首个分片走此分支;已用探针验证:msgSeqMap['msg-A']=1 在零引用下存活并跨重启存活。这是同一族缺陷的一个点(与 settle 顺序、onSessionDied、boundary teardown 的孤儿同源);共同修复是 settle 后复查——例如在 flushAndTrack 的 .finally() 清掉 flushingSessions 之后重跑 releaseSessionReplyAnchor(sessionId, state.msgId)(带身份守卫、幂等)。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +1403 to +1406
if (state.msgId !== undefined) {
this.releaseSessionReplyAnchor(sessionId, state.msgId);
}
if (s === state) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The terminal-settle cleanup treats pendingStreamDelete as "the turn is over", but this PR's new onResponseBoundary defer branch (~1618) sets it MID-TURN (tool-call/plan/permission events). A boundary-deferred chain that settles with an empty buffer therefore releases the session reply anchor while the turn is still running. — Failure scenario (probe-verified): flush in flight when onResponseBoundary fires → pendingStreamDelete.add; send resolves → this else branch releases the anchor mid-turn; every later response window snaps no anchor → sendMessage falls back to the chat-level replyMsgId → the reply is re-parented onto another user's message (the exact race this PR removes), or with active messages disabled the passive send fails ACTIVE_MSG_DISABLED and the reply is dropped. Suggested fix: record why the session is parked (e.g. pendingStreamDelete as Map<string, 'terminal' | 'boundary'>) and skip the release/flushedSessions/turnCounter cleanup when the park came from onResponseBoundary.

中文说明

终态 settle 清理把 pendingStreamDelete 当作“回合已结束”,但本 PR 新增的 onResponseBoundary 延迟分支(~1618)会在回合进行中设置它(tool-call/plan/permission 事件)。因此 boundary 延迟的链在缓冲为空时 settle 会在回合仍在进行时释放会话回复锚点。失败场景(探针验证):boundary 触发时刷新在途 → pendingStreamDelete.add;发送完成 → 此 else 分支在回合进行中释放锚点;之后每个响应窗口都无法捕获锚点 → sendMessage 回退到 chat 级 replyMsgId → 回复被重新挂到其他用户的消息上(正是本 PR 要消除的串线),或在禁用主动消息的群里被动发送以 ACTIVE_MSG_DISABLED 失败、回复被丢弃。修复建议:记录挂起原因(如 pendingStreamDelete 改为 Map<string, 'terminal' | 'boundary'>),当挂起来自 onResponseBoundary 时跳过释放/清理。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +1650 to +1651
this.streamOrphanBuffer.delete(sessionId);
const anchorEntry = this.sessionReplyMsgId.get(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The stale-entry branch deletes streamOrphanBuffer and sends only fullText — but while a stale entry persists, every chunk of the CURRENT turn is stashed in streamOrphanBuffer, and fullText contains only post-last-boundary text (the bridge clears its chunk collection at each responseBoundary). Chunks orphaned before the last boundary are silently lost. The normal-path delete (~1721) and the onResponseBoundary immediate-path delete (~1632) share the assumption. — Failure scenario (probe-verified): turn 1's tail flush fails transiently and its entry survives retry backoff; turn 2's chunks stash; a tool-call boundary clears the bridge's collected chunks; turn 2 completes while the old entry stands → this branch deletes turn 2's entire stashed text and sends only post-boundary fullText; observed 'new-turn-text-1' never sent. Suggested fix: while the stale entry persists the orphan buffer is an in-order superset of fullText — deliver orphaned || fullText under the current turn's anchor, or defer completion until the stale chain settles.

中文说明

stale 分支删除 streamOrphanBuffer 且只发送 fullText——但只要旧条目仍存,当前轮的所有分片都暂存在 streamOrphanBuffer 中,而 fullText 只包含最后一个 boundary 之后的文本(bridge 在每个 responseBoundary 清空分片集合)。最后一个 boundary 之前被孤立的分片会被静默丢失。normal 路径的删除(~1721)与 onResponseBoundary 立即路径的删除(~1632)同样基于该假设。失败场景(探针验证):轮次 1 尾部刷新瞬时失败、条目在重试回退中存活;轮次 2 分片被暂存;tool-call boundary 清空 bridge 已收集分片;轮次 2 在旧条目仍存时完成 → 本分支删除轮次 2 全部暂存文本、只发送 boundary 后的 fullText;观测到 'new-turn-text-1' 从未发出。修复建议:旧条目存活期间 orphan buffer 是 fullText 的有序超集——按当前轮锚点发送 orphaned || fullText,或将完成推迟到旧链 settle。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +2247 to +2249
if (!this.isMsgIdAnchoredBySession(oldEntry.msgId)) {
this.msgSeqMap.delete(oldEntry.msgId);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Permanent msgSeqMap orphan via the retention path. The overwrite keeps the counter (this anchor guard); onPromptStart's identity-free release (~1093) keeps it (live-flush guard); the settle-time release runs BEFORE .finally() clears flushingSessions and keeps it again — and no post-settle re-check exists anywhere. — Failure scenario (probe-verified on the exact concurrent-overwrite race this PR targets): user A's tail flush in flight at turn end; user B's setReplyMsgId keeps the counter; turn 2's onPromptStart releases the anchor — cascade keeps the counter; the chain settles, the pending-else release keeps it a second time; then entry + marker are gone. msgSeqMap['msg-A'] survives with zero references, serialized on every save and re-loaded across restarts. On origin/main the overwrite-time delete was unconditional, so this class did not exist. Family fix with R5-1: re-run the cascade for the chain's own msgId in .finally() after the marker clears.

中文说明

保留路径导致的永久 msgSeqMap 孤儿:覆盖写入时该锚点守卫保留计数器;onPromptStart 的无身份释放(~1093)因 in-flight 守卫保留;settle 时的释放先于 .finally() 清 flushingSessions 执行、再次保留——且任何地方都没有 settle 后复查。失败场景(就在本 PR 要解决的同群并发覆盖竞态上探针验证):A 的尾部刷新在回合结束时在途;B 的 setReplyMsgId 保留计数器;轮次 2 onPromptStart 释放锚点——级联保留;链 settle 时 pending-else 释放第二次保留;随后条目与标志消失。msgSeqMap['msg-A'] 零引用存活、每次保存被序列化、跨重启恢复。origin/main 上覆盖时删除是无条件的,因此此前不存在此类问题。与 R5-1 同族修复:在 .finally() 清标志后对链自身 msgId 重跑级联。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +1741 to 1743
// would re-resolve msg_seq from 1 (QQ dedupes on msg_id + msg_seq).
this.releaseSessionReplyAnchor(sessionId);
this.streamState.delete(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] onSessionDied's release-before-delete ordering (required by the cbd7faa review round to stop the msg_seq reset under live sends) retains the msgSeqMap counter while a flush is in flight — but the teardown immediately afterwards removes every path that could ever re-release it, and the settling chain's identity guards never re-run the release → permanent persisted counter orphan. — Failure scenario (probe-verified; the flip fix passes the suite): session dies mid-turn while its flush is in flight and the chat entry moved on; the guard keeps the counter; the deletes remove anchor/entry/marker; success settle early-returns at current !== state (no release), transient failure skips on the identity guard; only a permanent failure cleans up. Every remaining msgSeqMap.delete site keys off references that moved on — the counter is serialized and re-loaded across restarts. Suggested fix (closes the shared hole behind R5-1/R5-7/R5-31 too): in flushAndTrack, when the identity guard fails (.then() early-return and the .catch() skip), still call releaseSessionReplyAnchor(sessionId, state.msgId) — idempotent, and expectedMsgId keeps a successor's anchor untouched. Note the extended onSessionDied test (~1824 in stream.test.ts) pins the orphan as intended and must be flipped together with the fix.

中文说明

onSessionDied 的先释放后删除顺序(cbd7faa 评审轮为避免在途发送下 msg_seq 重置而要求的)会在刷新在途时保留 msgSeqMap 计数器——但紧随其后的清理移除了所有可能再次释放它的路径,而 settle 链的身份守卫永远不会重跑释放 → 永久持久化计数器孤儿。失败场景(探针验证;修复翻转后全套测试通过):会话在刷新在途且 chat 条目已切换时死亡;守卫保留计数器;删除移除锚点/条目/标志;成功 settle 在 current !== state 处提前返回(不释放),瞬时失败被身份守卫跳过;只有永久失败才清理。其余 msgSeqMap.delete 点都依赖已移走的引用——计数器被序列化并跨重启恢复。修复建议(同时闭合 R5-1/R5-7/R5-31 的共因洞):在 flushAndTrack 中身份守卫失败时(.then() 提前返回与 .catch() 跳过处)仍调用 releaseSessionReplyAnchor(sessionId, state.msgId)——幂等,且 expectedMsgId 不动后继锚点。注意 stream.test.ts ~1824 的 onSessionDied 测试把孤儿当作预期行为固定了下来,需随修复一并翻转。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +1380 to +1382
// Exhaustion releases the pending flag and the flushed record...
expect(pendingStreamDelete.has('sess-1')).toBe(false);
expect(flushedSessions.has('sess-1')).toBe(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The pending-retry-exhaustion path's deleteTurnCounterIfOwned cleanup (QQChannel.ts:~1502) is pinned by no test: this test drives exactly that path and asserts the pending flag, flushed record, anchor release and seq cascade — but never turnCounter. Mutant re-verified: deleting the call leaves 345/345 green. — Failure scenario: a refactor dropping or relocating the call leaves a stale per-session turnCounter entry after an abandoned deferred turn, and the successor-ownership semantics at this site are unverified. The sibling onToolCall exhaustion test (~931) has the same omission. Suggested fix: after the exhaustion loop assert expect((chp['turnCounter'] as Map<string, number>).has('sess-1')).toBe(false); (and in the onToolCall sibling).

中文说明

pending 重试耗尽路径的 deleteTurnCounterIfOwned 清理(QQChannel.ts:~1502)没有任何测试固定:本测试恰好驱动该路径并断言了 pending 标志、flushed 记录、锚点释放与 seq 级联——唯独从不检查 turnCounter。突变体复核:删除该调用后 345/345 仍全绿。失败场景:丢弃或搬移该调用的重构会在被放弃的延迟轮之后留下陈旧的会话 turnCounter 条目,且该调用点的后继所有权语义未经验证。兄弟的 onToolCall 耗尽测试(~931)同样遗漏。修复建议:耗尽循环后断言 expect((chp['turnCounter'] as Map<string, number>).has('sess-1')).toBe(false);(onToolCall 兄弟测试同样处理)。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +1129 to +1130
// so the next window's fresh streamState entry reuses the same msgId.
expect(sessionAnchors.has('sess-1')).toBe(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The boundary test pins anchor RETENTION but never anchor REUSE — the post-boundary delivery's msg_id/msg_seq are asserted nowhere, although the stated purpose of the retention is "reuses the same msgId". Current code sends 'final' under msg-A (the stateless complete path reads the fresh session anchor); no test anywhere pins a post-boundary send's routing. — Failure scenario: if a future edit drops the sessionReplyMsgId read on the stateless complete path, moves the release ahead of the send, or re-parents onto the chat-level entry, the post-boundary final segment ships as an active send — dropped outright in groups with active messages disabled (ACTIVE_MSG_DISABLED) or re-parented onto a concurrent message — and the test stays green. Suggested fix: after onResponseComplete assert expect(body['msg_id']).toBe('msg-A') and expect(body['msg_seq']).toBe(1) next to the content assertion.

中文说明

boundary 测试固定了锚点的保留,却从未固定锚点的复用——boundary 之后投递的 msg_id/msg_seq 在任何地方都没有断言,而保留机制的既定目的正是“复用同一 msgId”。当前代码在 msg-A 下发送 'final'(无状态完成路径读取新的会话锚点);没有任何测试固定 boundary 后发送的路由。失败场景:若未来改动删掉无状态完成路径对 sessionReplyMsgId 的读取、把释放提前到发送之前、或回退到 chat 级条目,boundary 后的最后分段会作为主动发送发出——在禁用主动消息的群中被直接丢弃(ACTIVE_MSG_DISABLED)或被挂到并发消息上——而测试依然绿灯。修复建议:在 onResponseComplete 之后、content 断言旁补 expect(body['msg_id']).toBe('msg-A')expect(body['msg_seq']).toBe(1)

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +442 to +443
// Sibling channel's live user-scope route: the owned-by-name guard
// (entry.target?.channelName === this.name) must NOT purge it — a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Both new purge tests (here and ~532) document an "owned-by-name guard" (entry.target?.channelName === this.name) that does not exist in purgeSingleScopeOrphans — the method never reads entry.target (its getAll cast doesn't even include it); sibling-key protection comes solely from an exact key match against the channel's own ":single" key (the 3-part purge was rescoped out in f5c3317, leaving these comments stale). — Failure scenario: the assertions are correct, but the comments are the only documentation of the sibling-safety invariant, and the method's doc comment anticipates a future extension to age out 3-part keys — a maintainer writing that extension would trust an ownership check that isn't there; a prefix/suffix-matching implementation would silently reset sibling channels' sessions with no guard to catch it. Suggested fix: rewrite the comments to name the real mechanism — the sibling key survives because the purge only matches keys exactly equal to the channel's own single-scope key; 3-part keys are never candidates.

中文说明

两个新增 purge 测试(此处与 ~532)都描述了一个“按名称所有权守卫”(entry.target?.channelName === this.name),但 purgeSingleScopeOrphans 中并不存在——该方法从不读取 entry.target(其 getAll 转型甚至不含该字段);兄弟键保护完全来自与本渠道自身 ":single" 键的精确匹配(三段键 purge 已在 f5c3317 中收缩移除,这些注释因此过期)。失败场景:断言本身正确,但这些注释是兄弟安全不变量的唯一文档,且方法 doc 注释预期未来会扩展为淘汰三段键——写该扩展的维护者会信任一个不存在的所有权检查;前缀/后缀匹配的实现会静默重置兄弟渠道的会话而无任何守卫能捕获。修复建议:把注释改写为真实机制——兄弟键之所以保留,是因为 purge 只匹配与本渠道自身 single-scope 键完全相等的键;三段键从来不是候选。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +2743 to +2744
expect(flushedSessions.has('session-pc')).toBe(false);
expect(turnCounter.has('session-pc')).toBe(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The permanent-catch teardown tests pin deleteTurnCounterIfOwned only with MATCHING turns (this test seeds turn 1 / counter 1; the parallel stream.test.ts deferred-permanent test seeds 5/5), so the ownership guard at QQChannel.ts:~1456 is unpinned — mutant re-verified: replacing it with an unconditional turnCounter.delete() keeps 345/345 green. — Failure scenario: in production the guard is load-bearing: a deferred turn whose tail flush fails permanently after a successor started (the stash branch keeps the old entry alive while onPromptStart bumps the counter) would have the successor's counter deleted by the dead turn's settle — stale-state detection resets to 0, the successor's live entry is misread as superseded, and its buffered chunks are dropped. Probe-verified: under the mutant the successor's counter is erased and no PR test observes it. Suggested fix: add a variant where the successor bumped the counter (turnCounter 2, state.turn 1) and assert the counter is still 2 after the permanent settle.

中文说明

permanent-catch 清理测试只用匹配的轮次固定 deleteTurnCounterIfOwned(本测试播种 turn 1/计数器 1;平行的 stream.test.ts 延迟永久失败测试播种 5/5),因此 QQChannel.ts:~1456 的所有权守卫未被固定——突变体复核:替换为无条件 turnCounter.delete() 后 345/345 仍全绿。失败场景:生产中该守卫是承重的:后继轮已开始后尾刷新永久失败的延迟轮(stash 分支保活旧条目、onPromptStart 抬升计数器),会被死轮的 settle 删掉后继计数器——陈旧检测归零,后继轮的存活条目被误判为 superseded,其缓冲分片被丢弃。探针验证:突变体下后继计数器被抹除且无 PR 测试能观测到。修复建议:增加后继轮已抬升计数器的变体(turnCounter 2、state.turn 1),断言永久 settle 后计数器仍为 2。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +353 to +355
expect(st.turn).toBe(2);
// The old turn's flags are cleared so turn 2's onResponseComplete /
// onPromptEnd are not short-circuited into a silent reply loss.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The stale-drop test pins flag clearing and fresh-entry fields but never pins that the drop must PRESERVE the successor's turnCounter — mutant probed: adding this.turnCounter.delete(sessionId); to onResponseChunk's stale branch survives 345/345. — Failure scenario: a cleanup refactor deleting the counter the successor's onPromptStart just bumped leaves turn 2 streaming under entries stamped turn:2 with an empty turnCounter: at completion onResponseComplete computes currentTurn=0, sees state.turn (2) !== 0, misclassifies its own live entry as stale and re-sends the entire fullText (duplicate delivery) while the parked residual still flushes. Probe flips with the suggested assertion. Suggested fix: assert expect((chp['turnCounter'] as Map<string, number>).get('sess-1')).toBe(2); after the stale-drop assertions.

中文说明

stale-drop 测试固定了标志清除与新条目字段,却从未固定“删除必须保留后继轮 turnCounter”——突变体探测:向 onResponseChunk 的 stale 分支加入 this.turnCounter.delete(sessionId); 后 345/345 仍全绿。失败场景:某个清理重构若删掉后继轮 onPromptStart 刚抬升的计数器,轮次 2 会以 turn:2 条目继续流式而 turnCounter 为空:完成时 onResponseComplete 算出 currentTurn=0,发现 state.turn (2) !== 0,把自己的存活条目误判为陈旧并重发整个 fullText(重复投递),而挂起的残余仍在刷新。所建议的断言可使探针翻转。修复建议:在 stale-drop 断言后补 expect((chp['turnCounter'] as Map<string, number>).get('sess-1')).toBe(2);

— qwen3.8-max via Qwen Code /review (v0.21.7)

@wenshao

wenshao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code resolve

@wenshao

wenshao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts, but could not push to Eric-GoodBoy-Tech/qwen-code. For a fork PR this needs Allow edits by maintainers enabled, and GitHub blocks maintainer edits on forks owned by an organization. The resolved diff is attached as the qwen-resolve-pr-8241 artifact on the workflow run.

Root cause

main's PR #8440 (feat(channels): support group pairing, commit 3edecac) collided with this PR's commit f5c3317 (fix(cli): accept chat_thread in channel-settings sessionScope validation):

  • packages/cli/src/serve/channel-settings-store.tsfeat(channels): support group pairing #8440 added 'pairing' to the groupPolicy enum in assertSharedField; this PR added 'chat_thread' to the adjacent sessionScope enum line.
  • docs/users/features/channels/overview.mdfeat(channels): support group pairing #8440 added pairing to the groupPolicy row of the Options table and rewrote the groupHistoryLimit description; this PR rewrote the sessionScope row. Both edits re-padded the whole table (prettier alignment), so the entire table conflicted.

Textual or semantic

Semantic, but orthogonal: both sides edited the same adjacent lines while modifying different enums, so the resolution is the union:

groupPolicy: new Set(['disabled', 'allowlist', 'pairing', 'open']),
sessionScope: new Set(['user', 'thread', 'chat_thread', 'single']),

The doc table is the union of row contents, re-padded to the widest cell (main's groupHistoryLimit description). A cell-by-cell comparison confirms the merged table differs from each parent only in the other side's rows.

What is load-bearing

  • Both enum members must coexist: dropping 'pairing' breaks feat(channels): support group pairing #8440's group-pairing config validation; dropping 'chat_thread' breaks this PR's chat_thread upsert test.
  • Type alignment holds after the merge: channel-base GroupPolicy includes 'pairing' and SessionScope includes 'chat_thread', matching the runtime sets.
  • QQChannel inherits all group/sender gating from ChannelBase and never calls GroupGate directly, so feat(channels): support group pairing #8440's pairing gate applies to qqbot without any QQChannel.ts change. A future edit that moves gating into QQChannel must re-derive the pairing step.

What I could not verify

No build, lint, or tests were run. Two notes for CI:

  1. The auto-merge of channel-settings-store.ts combines this PR's enum change with fix(channels): manage DingTalk interactive card config #8517's rewrite of assertDescriptorValue in the same function neighborhood; main-side and PR-side tests in channel-settings-store.test.ts were also auto-merged and must pass together.
  2. The merge newly enables groupPolicy: "pairing" for the QQ channel via the shared gate (now documented in qqbot.md from main). qqbot's own tests only use 'disabled'/'open', so nothing conflicted, but qqbot pairing behavior is covered only by base-level tests.
中文说明

根因:main 上的 PR #8440(群组配对功能,提交 3edecac)与本 PR 的提交 f5c3317 相撞:前者在 channel-settings-store.tsgroupPolicy 枚举中加入 'pairing' 并更新 overview.md 选项表的 groupPolicy/groupHistoryLimit 行;本 PR 在相邻的 sessionScope 枚举中加入 'chat_thread' 并更新同表的 sessionScope 行。

性质:语义冲突但改动正交——双方修改同一相邻行里的不同枚举,解决方案为两者的并集(见上代码块)。文档表取行内容并集并按最宽单元格重新对齐,逐格比对确认合并结果与两侧的差异仅在对方修改的行。

关键点:两个枚举值必须同时保留,缺一会破坏对应侧的校验或测试;合并后 channel-base 的 GroupPolicy/SessionScope 类型与运行时集合一致;QQChannel 通过 ChannelBase 继承分组/发送者门控、不直接调用 GroupGate,因此 #8440 的配对门控无需改动 QQChannel.ts 即对 qqbot 生效。

未能验证:本次未运行构建/测试。注意:同文件中本 PR 的枚举改动与 #8517assertDescriptorValue 的重写被自动合并,两侧新增测试需一起通过;合并后经共享门控 qqbot 新支持 groupPolicy: "pairing",qqbot 自身测试仅使用 disabled/open,配对行为仅由 base 层测试覆盖。

@wenshao

wenshao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real end-to-end run, not just unit tests

I built a real environment for this PR locally and ran main vs the PR branch side by side. Verdict: the fix does what it claims, and the underlying defect on main is actually worse than the PR description says. Details and evidence below.

Harness — how "real" this is
  • SUT: a real qwen channel start qq process from each worktree's own compiled dist/ (confirmed: head dist/QQChannel.js contains purgeSingleScopeOrphans/sessionReplyMsgId, base dist/ does not). Base = 8fd0162 (this PR's base), head = f5c3317.
  • QQ side: a local fake QQ Bot Open Platform speaking the real protocol — POST /app/getAppAccessToken, GET /gateway, a real WSS gateway (HELLO / IDENTIFY / READY / HEARTBEAT / DISPATCH), and POST /v2/groups/:id/messages + /v2/users/:id/messages. Every outbound request body is recorded to a JSONL ledger, which is where the msg_id / msg_seq evidence comes from.
  • No product code is patched. The only hook is a --require preload that re-points the TCP endpoint of TLS connections to *.qq.com at 127.0.0.1. Real HTTPS, real SNI, real hostname verification against a cert carrying the qq.com SANs — so validateGatewayUrl()'s wss:// + *.qq.com hard boundary is exercised, not bypassed.
  • Model: a recording OpenAI-compatible server. The bot answers RECALL=<passphrase found in the history it was handed>, so "what the bot answers" is a direct readout of "which session history it was given".
  • macOS 26.6 / Node v24.18.1.

1. Session isolation — confirmed, and main has a split brain

session isolation, base vs head

Config: groupAllPolicy: "all", senderPolicy/groupPolicy: open, no explicit sessionScope. Traffic: U1 tells the bot a passphrase in group A → U2 asks in group B → U3 asks in a DM → U2 asks in group A → U3 sends /clear in the DM.

main (8fd0162) this PR (f5c3317)
persisted routing keys qq:U1:GA, qq:U2:GB, qq:U3:U3, qq:U2:GA qq:GA, qq:GB
group A, other member asks RECALL=NONE RECALL=PP-ALPHA
group B / DM ask RECALL=NONE RECALL=NONE
DM /clear "This clears the shared session for everyone who shares it. Re-send with confirm…" "Session cleared."

The important finding: on main the forced sessionScope: 'single' never reaches the router. start.ts:361 (and daemon-worker.ts:516) call router.setChannelScope(name, config.sessionScope) with the parsed config — before QQChannel's constructor rewrites its own copy. So main prints WARNING: … Forcing sessionScope to 'single' to ensure shared group context. while routing stays user-scoped. Two consequences, both reproduced above:

  1. groupAllPolicy: "all" never actually delivered shared group context — group traffic was fragmented per sender, which is the exact opposite of what the override existed to guarantee.
  2. QQChannel still believes it is single, so every sessionScope === 'single' gate inside it fires against user-scoped routing — a DM gets treated as a shared session and /clear demands an extra confirm.

So one line of the PR description overstates a symptom: with channel start / daemon mode, main does not leak context across groups and into DMs — that only happens if the user set sessionScope: "single" explicitly (which the docs did recommend for groupAllPolicy). The real main defect is fragmentation + the split brain. The fix is still correct and strictly better; the description just credits it for fixing the wrong half. Worth a one-line edit before merge.

2. Concurrent streaming reply anchor — reproduced exactly

streaming reply anchor, base vs head

Identical config on both sides (sessionScope: "user", no groupAllPolicy, so no scope forcing is involved). U1 (msg-A) starts a slow streamed answer in group A; 3s later U2 sends msg-B in the same group, overwriting the chat-level replyMsgId. The table is the real request bodies received at /v2/groups/GA/messages:

  • main: U1's segments 2 and 3 go out under msg-B — the bot visibly answers the wrong person — and both users' replies share one msg_id's seq run (1→5).
  • this PR: msg-A → seq 1/2/3, msg-B → seq 1/2/3. No cross-threading.

3. Orphan purge, mutation test, and the declared breaking change

purge, mutation and breaking change

  • Upgrade path: seeded a genuine "single-scope era" deployment by running main with sessionScope: "single" (→ real qq:__single__ in sessions.json), then restarted the same QWEN_HOME with sessionScope removed. PR: [QQ:qq] Purged 1 orphaned single-scope session mapping(s), Ready (0 sessions), routing switches cleanly to qq:GA, and the first post-restart message answers normally. main: the dead qq:__single__ entry survives the restart and a user-scope key is added alongside it.
  • Mutation (necessity check): deleting only defaultSessionScope: 'thread' from packages/channels/qqbot/src/index.ts on the PR branch and rebuilding dist regresses zero-config deployments straight back to user scope (qq:U1:GA…, group A RECALL=NONE). That line is load-bearing, not cosmetic. It also confirms the new WARNING text matches real behavior.
  • Breaking change reproduced as declared: a plain @mention deployment with no groupAllPolicy and no sessionScope also moves from qq:U1:GA (per-member private) to qq:GA (whole-group shared). U2 goes from RECALL=NONE on main to RECALL=PP-ALPHA on the PR. Intended, documented in the PR body — but it needs to reach release notes, not just the PR description.

4. Static checks (head)

Check Result
npx vitest run packages/channels/qqbot 7 files, 345 passed
npx vitest run packages/cli/src/serve/channel-settings-store.test.ts 42 passed
tsc --noEmit -p packages/channels/qqbot/tsconfig.json clean
eslint on changed sources clean
prettier --check on changed sources + docs clean

Not covered

  • blockStreaming: "on" (the documented anchor blind spot) — not exercised end-to-end.
  • The cancel-flush behavior change — only covered by the PR's unit regression test, not driven through a live turn here.
  • Daemon mode (qwen serve --channel) — I drove qwen channel start. daemon-worker.ts:516 has the identical setChannelScope pattern, so the same reasoning applies, but I did not run it.
  • Windows / Linux; real QQ credentials.

Recommendation

LGTM for merge. The routing fix, the per-session reply anchor, and the orphan purge all behave as claimed against a real runtime, and the mutation test shows the plugin default is required rather than incidental. Two asks before merging: (a) correct the "context leaked across groups and into DMs" framing in the PR body — that is the sessionScope: "single" case, not what main does by default; (b) make sure the userthread default flip lands in the release notes, since it hits every QQ deployment that omits sessionScope, not only groupAllPolicy users.

中文版

维护者验证 —— 本地真实环境端到端跑通,不只是单测

我在本地搭了真实环境,把 main 和 PR 分支并排跑了一遍。结论:修复符合声明,而且 main 上的实际缺陷比 PR 描述写的还要糟。

验证环境有多"真"

  • 被测对象:两个 worktree 各自编译出的 dist/ 起真实的 qwen channel start qq 进程(已核对:head 的 dist/QQChannel.jspurgeSingleScopeOrphans/sessionReplyMsgId,base 的没有)。base = 8fd0162,head = f5c3317
  • QQ 侧:本地伪造的 QQ 开放平台,说的是真协议 —— POST /app/getAppAccessTokenGET /gateway、真实 WSS 网关(HELLO / IDENTIFY / READY / HEARTBEAT / DISPATCH)、POST /v2/groups/:id/messages/v2/users/:id/messages。每一次外发请求体都记进 JSONL 账本,msg_id / msg_seq 的证据就来自这里。
  • 产品代码零改动。唯一的钩子是一个 --require 预加载,把发往 *.qq.com 的 TLS 连接在传输层重定向到 127.0.0.1。仍然是真 HTTPS、真 SNI、真主机名校验(证书带 qq.com SAN),所以 validateGatewayUrl()wss:// + *.qq.com 硬边界是被真正执行的,不是被绕过。
  • 模型:录制型 OpenAI 兼容服务。机器人回答 RECALL=<它拿到的历史里出现过的口令>,所以"它答出什么"直接等于"它被喂了哪一份会话历史"。
  • macOS 26.6 / Node v24.18.1。

1. 会话隔离 —— 确认修复,且 main 存在"精神分裂"

配置:groupAllPolicy: "all"senderPolicy/groupPolicy: open不写 sessionScope。流量:U1 在群 A 告诉机器人口令 → U2 在群 B 问 → U3 私聊问 → U2 在群 A 问 → U3 私聊发 /clear

main (8fd0162) 本 PR (f5c3317)
持久化路由键 qq:U1:GAqq:U2:GBqq:U3:U3qq:U2:GA qq:GAqq:GB
群 A 内另一位成员来问 RECALL=NONE RECALL=PP-ALPHA
群 B / 私聊来问 RECALL=NONE RECALL=NONE
私聊 /clear "This clears the shared session for everyone who shares it…" "Session cleared."

关键发现main 上那个强制 sessionScope: 'single' 根本没传到 routerstart.ts:361(以及 daemon-worker.ts:516)用的是解析后、构造器之前的 config 去调 router.setChannelScope(name, config.sessionScope),而构造器只改了 QQChannel 自己那份副本。于是 main 一边打印 WARNING: … Forcing sessionScope to 'single',一边照旧按 user 作用域路由。两个后果上面都复现了:

  1. groupAllPolicy: "all" 从来没有真正提供过"群内共享上下文" —— 群消息按发送人被切碎了,恰恰是这个强制想保证的反面。
  2. QQChannel 自己仍认为single,所以它内部所有 sessionScope === 'single' 的判断都在对着 user 作用域的路由生效 —— 私聊被当成共享会话,/clear 要求二次 confirm

因此 PR 描述里有一句话夸大了症状:在 channel start / daemon 模式下,main不会跨群、跨私聊泄漏上下文 —— 那只发生在用户显式写了 sessionScope: "single" 的部署上(而当时文档确实推荐这么配)。main 真正的缺陷是"碎片化 + 精神分裂"。修复本身依然正确且严格更优,只是描述把功劳记在了错误的那一半上,合并前值得改一行。

2. 并发流式回复锚点 —— 精确复现

两侧配置完全相同(sessionScope: "user",无 groupAllPolicy,不牵涉任何强制逻辑)。U1(msg-A)在群 A 发起慢速流式回答;3 秒后 U2 在同一个群msg-B,覆盖了 chat 级 replyMsgId。表格是 /v2/groups/GA/messages 收到的真实请求体:

  • main:U1 回复的第 2、3 段挂到了 msg-B 上 —— 群里看到的就是"机器人回错了人" —— 而且两个人的回复共用了同一条 msg_id 的 seq 序列(1→5)。
  • 本 PRmsg-A → seq 1/2/3,msg-B → seq 1/2/3,互不串线。

3. 孤儿清理、变异测试、以及 PR 自己声明的破坏性变更

  • 升级路径:先用 main + sessionScope: "single" 真跑出一份"single 时代"的部署状态(sessions.json 里是真实的 qq:__single__),再把 sessionScope 删掉、用同一个 QWEN_HOME 重启。PR:[QQ:qq] Purged 1 orphaned single-scope session mapping(s)Ready (0 sessions),路由干净切到 qq:GA,重启后第一条消息正常回复。main:那条死掉的 qq:__single__ 熬过重启活了下来,旁边还新建了一个 user 作用域的键。
  • 变异测试(必要性验证):在 PR 分支上只删掉 packages/channels/qqbot/src/index.ts 里的 defaultSessionScope: 'thread' 并重新编译 dist,零配置部署立刻退回 user 作用域(qq:U1:GA…,群 A RECALL=NONE)。这一行是承重墙,不是装饰。同时也验证了新 WARNING 的措辞与真实行为一致。
  • 破坏性变更如实复现:一个没有 groupAllPolicy、也没写 sessionScope 的普通 @mention 部署,同样会从 qq:U1:GA(每人私有)变成 qq:GA(整群共享)。U2 在 main 下得到 RECALL=NONE,在 PR 下得到 RECALL=PP-ALPHA。这是有意为之、PR 正文也写了 —— 但它需要进 release notes,而不只是躺在 PR 描述里。

4. 静态检查(head)

检查项 结果
npx vitest run packages/channels/qqbot 7 个文件,345 通过
npx vitest run packages/cli/src/serve/channel-settings-store.test.ts 42 通过
tsc --noEmit -p packages/channels/qqbot/tsconfig.json 干净
改动源文件 eslint 干净
改动源文件 + 文档 prettier --check 干净

未覆盖

  • blockStreaming: "on"(文档化的锚点盲区)—— 没有端到端验证。
  • 取消时 flush 部分回复的行为变更 —— 只有 PR 自带的单测覆盖,我没有在真实回合里跑。
  • Daemon 模式(qwen serve --channel)—— 我跑的是 qwen channel startdaemon-worker.ts:516 是完全相同的 setChannelScope 写法,推理同样成立,但我没有实跑。
  • Windows / Linux;真实 QQ 凭据。

结论

同意合并。 路由修复、按 session 的回复锚点、孤儿清理,在真实运行时下都与声明一致,变异测试也证明了插件默认值是必需项而非顺手加的。合并前两个请求:(a) 修正 PR 正文里"跨群、跨私聊泄漏上下文"的说法 —— 那是 sessionScope: "single" 的情况,不是 main 的默认行为;(b) 确保 userthread 的默认值翻转写进 release notes,因为它影响的是每一个省略 sessionScope 的 QQ 部署,而不只是用 groupAllPolicy 的用户。

@wenshao

wenshao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge August 12, 2026 18:53
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the PR has merge conflicts, so refs/pull/8241/merge is unavailable — resolve conflicts and re-run.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the PR has merge conflicts, so refs/pull/8241/merge is unavailable — resolve conflicts and re-run。

Qwen Code · sandboxed verification

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Triage re-run completed — approved this round.

The maintainer's landing decision (approval on f5c33173d21cb39c2e21fd9bce03e9a7ba3c32dc + auto-merge) is the explicit fix-forward call the previous deferral asked for, so the gate followed it and approved, pinned to that commit. The round-5 findings stay on record as fix-forward work. Nothing can actually merge yet: the author must merge main into the branch (the conflict is still there), and that push will dismiss both approvals for a fresh post-conflict re-review. See the Stage 3 comment.

门禁本轮批准了:维护者的落地决定(批准该 SHA + auto-merge)正是上次 defer 请求的明确表态,门禁遵循该决定、锚定该提交批准;round-5 发现作为 fix-forward 工作保留在记录中。但距离真正合并还差一步:作者需把 main 合并进分支(冲突仍在),该推送会驳回两个批准,冲突解决后需复审。详见 Stage 3 评论。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the PR has merge conflicts, so refs/pull/8241/merge is unavailable — resolve conflicts and re-run.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the PR has merge conflicts, so refs/pull/8241/merge is unavailable — resolve conflicts and re-run。

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving per the maintainer's explicit land-and-fix-forward decision on this SHA (approval + auto-merge) — the gate promised to follow that call on re-run. The round-5 items remain on record as fix-forward work; the branch still cannot merge until the author merges main (conflict). Details in the Stage 3 comment. ✅

@wenshao
wenshao disabled auto-merge August 24, 2026 01:58
@wenshao
wenshao enabled auto-merge August 24, 2026 01:58
@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code attempted to resolve merge conflicts but the run did not complete successfully.

Check the workflow run for full logs.

@wenshao
wenshao disabled auto-merge August 24, 2026 02:03
@wenshao wenshao closed this Aug 24, 2026
@wenshao wenshao reopened this Aug 24, 2026
@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this! The PR is approved and CI is green, but it can't be merged right now because it has merge conflicts with main (the branch is based on a commit that has since been passed by ~543 commits).

The conflicts are small — only two files, one hunk each:

  1. packages/cli/src/serve/channel-settings-store.tsmain added 'pairing' to the allowed groupPolicy values (feat(channels): support group pairing #8440); please adopt the four-value set from main.
  2. docs/users/features/channels/overview.md — the channel options table was redesigned on main (feat(web-shell): expose channel sessions in sidebar and settings #8457, feat(web-shell): redesign Channel policy and workspace management #8848); please re-apply this PR's sessionScope wording onto the new table.

One semantic note while you sync: main now treats thread as a legacy name — the canonical value is chat_thread (the GitHub and GitLab plugins declare defaultSessionScope: 'chat_thread'). This PR declares QQ's default as thread, so please reconcile that during the conflict resolution (prefer chat_thread unless the legacy-alias handling requires otherwise).

Please rebase onto the latest main (or merge main into the branch) and resolve the two conflicts; once CI is green again we can merge.

@qqqys

qqqys commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

我在精确 head f5c33173d21cb39c2e21fd9bce03e9a7ba3c32dc 上又做了一次只读复核,并用仓库外的临时 Vitest probe 独立验证了 round-5 中最关键的几个时序问题。结论是:这个 PR 的方向和主修复都正确,但当前 deferred-flush 状态机仍有会丢回复/串回复的问题,建议合并前至少处理下面前三项。

  1. onResponseBoundary 在 send in-flight 时设置 pendingStreamDelete,但 flushAndTrack 把这个 flag 当成“turn 已结束”,在 boundary 后仍处于同一 turn 时就释放 reply anchor 和 turn counter。探针观察到第一段请求带 msg_id=msg-A,同一 turn 的 boundary 后分片变成 msg_id=undefined;有并发消息时会重新串线,active message 被关闭时会直接投递失败。
  2. parked boundary tail 被正常 onResponseComplete 接管后,代码删除了 streamState / flushedSessions,但没有删除 pendingStreamDelete。该陈旧 flag 会跨进下一 turn,被下一轮第一次成功 flush 当成终态消费,在 turn 中途释放新的 anchor;探针观察到 turn 2 首段仍带 msg-B,后续分片已无 msg_id
  3. stale completion 分支删除 streamOrphanBuffer,只发送 fullText。但 AcpBridgeDaemonChannelBridge 都会在每个 responseBoundary 清空已收集 chunks,所以这里的 fullText 只有最后一个 boundary 之后的文本。探针播种的 PRE-BOUNDARY 内容被完全丢弃,只发送了 POST-BOUNDARY
  4. superseded in-flight send settle 后会留下永久 msgSeqMap orphan:anchor、chat entry、stream state 和 flushing marker 都已消失,旧 msg-A counter 仍保留并进入持久化状态。现有 stream.test.ts 甚至明确断言该 counter 在 settle 后继续存在,因此生产修复需要同步翻转测试期望。

验证结果:外置竞态 probe 4/4 通过(断言的是当前错误行为);精确 head 的官方 stream.test.ts 同时 79/79 通过,说明这些是覆盖/断言缺口,不是普通 CI 能排除的问题。复核和 probe 都没有修改 PR 源码。

建议把 park reason 区分成 terminal / boundary,正常 completion 显式清理 pending flag,stale completion 保留或合并 orphan buffer,并在 flushing marker 清除之后按旧 turn 的 expectedMsgId 再做一次幂等 release/cascade。这样可以一起闭合 R5-1/2/3/4/5/6/7,R5-8/9 的断言随生产修复翻转。

主问题是真实的,移除 constructor scope override、QQ 默认 thread、按 session 锚定 msg_id 这些方向我都赞同;阻塞点集中在 boundary / terminal / superseded settle 共用同一组状态标志。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round:

  • packages/channels/qqbot/src/QQChannel.ts:1663 — [probe] Stale-branch no-anchor fallback is untested (mutant survives)
  • packages/channels/qqbot/src/QQChannel.ts:247 — [probe] blockStreaming note misstates the mechanism (captured-but-unused, not never-captured)

Convergence: round 6 posted 9 inline comment(s), 1 of them reported for the first time. Findings keep coming back to the same files: packages/channels/qqbot/src/QQChannel.ts (findings in round 5; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 6 轮发布了 9 条行内评论,其中 1 条是首次提出。发现反复回到同一批文件:packages/channels/qqbot/src/QQChannel.ts(第 5 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +1225 to +1233
// Release before the delete: the release guard scans streamState +
// flushingSessions for a live flush, so it must still see this entry
// (and marker) or it would drop the msg_seq counter under a send still
// in flight — QQ dedupes on msg_id + msg_seq and silently drops the
// tail. The expectedMsgId identity check keeps a successor turn's
// anchor untouched while still cascading this turn's counter away.
if (state.msgId !== undefined) {
this.releaseSessionReplyAnchor(sessionId, state.msgId);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-1: Round-6 re-check at the unchanged head f5c33173 — still stands. The superseded-turn branch releases the anchor while the old turn's send is still in flight. The release cascade keeps the msgSeqMap counter (live-flush guard), then the entry and flags are deleted — and when the in-flight send succeeds, flushAndTrack's .then() early-returns at current !== state without ever re-running the release. No other deletion path targets the old msgId again, so the counter is orphaned permanently in memory and in the persisted QQ state — one persisted orphan per supersede-with-in-flight-settle occurrence. This round's chunk agents re-derived the identical mechanism independently at this commit.

Suggested fix (family with R5-4/R5-5/R5-7): re-run releaseSessionReplyAnchor(sessionId, state.msgId) for the chain's own msgId in flushAndTrack's .finally(), after the flushingSessions marker clears — identity-guarded, idempotent.

中文说明

第 6 轮复查(HEAD f5c33173 未变化)——仍然存在。superseded 分支在旧一轮发送仍在途时释放锚点:release 级联因 in-flight 守卫保留 msgSeqMap 计数器,随后条目与标志被删除;在途发送成功时,flushAndTrack 的 .then()current !== state 处提前返回,永远不会再次执行释放。其他任何删除路径都不会再触及旧 msgId,计数器在内存与持久化 QQ state 中永久成为孤儿——每次"supersede 且在途发送成功"产生一个持久化孤儿。本轮分块 agent 已在本 commit 独立重新推导出相同机制。修复建议(与 R5-4/R5-5/R5-7 同族):在 flushAndTrack 的 .finally() 清掉 flushingSessions 之后,对链自身的 msgId 重跑 releaseSessionReplyAnchor(sessionId, state.msgId)——带身份守卫、幂等。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +1399 to +1405
} else {
// No own anchor (proactive turn / expired TTL) — nothing to
// release, and an unconditional release here would delete a
// successor turn's anchor. See .catch() release points.
if (state.msgId !== undefined) {
this.releaseSessionReplyAnchor(sessionId, state.msgId);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-2: Round-6 re-check at the unchanged head f5c33173 — still stands. The terminal-settle cleanup treats pendingStreamDelete as "the turn is over", but this PR's new onResponseBoundary defer branch (~1618) sets it MID-TURN (tool-call/plan/permission events). A boundary-deferred chain that settles with an empty buffer therefore releases the session reply anchor while the turn is still running — re-verified at this commit by this round's agents. Every later response window then snaps no anchor, so sendMessage falls back to the chat-level replyMsgId and the reply is re-parented onto another user's message (the exact race this PR removes); with active messages disabled the passive send fails ACTIVE_MSG_DISABLED and the reply is dropped. Round 5 probe-verified this at the same commit.

Suggested fix: record why the session is parked (e.g. pendingStreamDelete as Map<string, 'terminal' | 'boundary'>) and skip the release/flushedSessions/turnCounter cleanup when the park came from onResponseBoundary.

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。终态 settle 清理把 pendingStreamDelete 当作"回合已结束",但本 PR 新增的 onResponseBoundary 延迟分支(~1618)会在回合进行中设置它(tool-call/plan/permission 事件)。boundary 延迟的链在缓冲为空时 settle,会在回合仍在进行时释放会话回复锚点——本轮 agent 已在本 commit 重新验证。之后每个响应窗口都无法捕获锚点,sendMessage 回退到 chat 级 replyMsgId,回复被重新挂到其他用户的消息上(正是本 PR 要消除的串线);若群禁用主动消息,被动发送以 ACTIVE_MSG_DISABLED 失败、回复被丢弃。第 5 轮已在同一 commit 用探针验证。修复建议:记录挂起原因(如把 pendingStreamDelete 改为 Map<string, 'terminal' | 'boundary'>),当挂起来自 onResponseBoundary 时跳过释放/清理。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +1647 to +1651
// This turn's own anchor (set by onPromptStart) may still be valid —
// use it instead of falling back to the racy chat-level entry, which
// this PR exists to avoid re-parenting onto.
this.streamOrphanBuffer.delete(sessionId);
const anchorEntry = this.sessionReplyMsgId.get(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-3: Round-6 re-check at the unchanged head f5c33173 — still stands. The stale-entry branch deletes streamOrphanBuffer and sends only fullText — but while a stale entry persists, every chunk of the CURRENT turn is stashed in streamOrphanBuffer, and fullText contains only post-last-boundary text (the bridge clears its chunk collection at each responseBoundary — re-confirmed in AcpBridge this round). Chunks orphaned before the last boundary are silently lost. Round-5 probe at this commit: turn 1's tail flush fails transiently and its entry survives retry backoff; turn 2's chunks stash; a tool-call boundary clears the bridge's collected chunks; turn 2 completes while the old entry stands → this branch deletes turn 2's entire stashed text and sends only post-boundary fullText; observed 'new-turn-text-1' never sent.

Suggested fix: while the stale entry persists the orphan buffer is an in-order superset of fullText — deliver orphaned || fullText under the current turn's anchor, or defer completion until the stale chain settles.

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。stale 分支删除 streamOrphanBuffer 且只发送 fullText——但只要旧条目仍存,当前轮的所有分片都暂存在 streamOrphanBuffer 中,而 fullText 只包含最后一个 boundary 之后的文本(bridge 在每个 responseBoundary 清空分片集合——本轮已在 AcpBridge 中重新确认)。最后一个 boundary 之前被孤立的分片会被静默丢失。第 5 轮在同一 commit 的探针:轮次 1 尾部刷新瞬时失败、条目在重试回退中存活;轮次 2 分片被暂存;tool-call boundary 清空 bridge 已收集分片;轮次 2 在旧条目仍存时完成 → 本分支删除轮次 2 全部暂存文本、只发送 boundary 后的 fullText;观测到 'new-turn-text-1' 从未发出。修复建议:旧条目存活期间 orphan buffer 是 fullText 的有序超集——按当前轮锚点发送 orphaned || fullText,或将完成推迟到旧链 settle。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +1737 to +1742
// Release before the deletes so the release guard still sees this
// session's entry (with its buffered/flushing state) and can keep the
// msg_seq counter while a live flush owns it — a delete-then-release
// order would drop the counter under an in-flight send and its tail
// would re-resolve msg_seq from 1 (QQ dedupes on msg_id + msg_seq).
this.releaseSessionReplyAnchor(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-5: Round-6 re-check at the unchanged head f5c33173 — still stands. onSessionDied's release-before-delete ordering (required by the cbd7faa review round to stop the msg_seq reset under live sends) retains the msgSeqMap counter while a flush is in flight — but the teardown immediately afterwards removes every path that could ever re-release it, and the settling chain's identity guards never re-run the release → permanent persisted counter orphan, serialized on every save and re-loaded across restarts. Round-5 probe verified the orphan and its flip fix (suite green); this round's layer walk re-mapped the residual to this entry at the same commit.

Suggested fix (closes the shared hole behind R5-1/R5-7 too): in flushAndTrack, when the identity guard fails (.then() early-return and the .catch() skip), still call releaseSessionReplyAnchor(sessionId, state.msgId) — idempotent, and expectedMsgId keeps a successor's anchor untouched. Note the extended onSessionDied test plus the R5-8/R5-9 pins must flip together with the fix.

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。onSessionDied 的先释放后删除顺序(cbd7faa 评审轮为避免在途发送下 msg_seq 重置而要求的)会在刷新在途时保留 msgSeqMap 计数器——但紧随其后的清理移除了所有可能再次释放它的路径,而 settle 链的身份守卫永远不会重跑释放 → 永久持久化计数器孤儿,每次保存被序列化、跨重启恢复。第 5 轮探针已验证该孤儿及其翻转修复(套件绿灯);本轮分层走查再次把该残留映射到本条目。修复建议(同时闭合 R5-1/R5-7 的共因洞):在 flushAndTrack 中身份守卫失败时(.then() 提前返回与 .catch() 跳过处)仍调用 releaseSessionReplyAnchor(sessionId, state.msgId)——幂等,且 expectedMsgId 不动后继锚点。注意扩展的 onSessionDied 测试与 R5-8/R5-9 固定点需随修复一并翻转。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines 1688 to 1690
this.streamState.delete(sessionId);
this.flushedSessions.delete(sessionId);
if (remaining) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-6: Round-6 re-check at the unchanged head f5c33173 — still stands. onResponseComplete's normal path takes over a parked deferred flush (clears the re-parked timer, delivers the residual) but never clears pendingStreamDelete; onPromptEnd's flag-based early return then lets the stale flag survive into the next turn, whose first successful flush's .then() consumes it mid-turn (s === state) and runs the terminal-settle cleanup. Round-5 probe (one-line fix flips): turn 2's reply delivered TWICE (msg-B seq 1 and seq 2 — QQ dedup does not suppress it, the chat-entry guard kept the counter); if more chunks follow instead, they compute currentTurn=0 vs state.turn=2 → the superseded branch drops the buffered middle. This round's R6-1 verification re-confirmed the unconditional pending-flag consumption mechanics at this commit.

Suggested change
this.streamState.delete(sessionId);
this.flushedSessions.delete(sessionId);
if (remaining) {
this.streamState.delete(sessionId);
this.flushedSessions.delete(sessionId);
this.pendingStreamDelete.delete(sessionId);
if (remaining) {

Add the missing pendingStreamDelete.delete(sessionId) next to the streamState/flushedSessions deletes.

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。onResponseComplete 的 normal 路径接管被挂起的延迟刷新(清掉重新挂起的定时器、投递残余缓冲)但从不清除 pendingStreamDeleteonPromptEnd 基于该标志提前返回,使陈旧标志存活到下一轮——下一轮首次成功刷新的 .then() 会在回合进行中消费它(s === state)并执行终态清理。第 5 轮探针(一行修复即翻转):轮次 2 的回复被投递两次(msg-B seq 1 与 seq 2——QQ 去重不会抑制,chat 条目守卫保留了计数器);若后续还有分片,则 currentTurn=0 对比 state.turn=2 → superseded 分支丢弃中段缓冲。本轮 R6-1 验证已在本 commit 重新确认"无条件消费 pending 标志"的机制。修复建议:在 streamState/flushedSessions 删除旁补上缺失的 this.pendingStreamDelete.delete(sessionId);(见上方 suggestion)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +1624 to +1632
// on the replaced entry, dropping the segment's text.
this.pendingStreamDelete.add(sessionId);
return;
}
this.streamState.delete(sessionId);
this.flushingSessions.delete(sessionId);
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);
this.streamOrphanBuffer.delete(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-7: Round-6 re-check at the unchanged head f5c33173 — still stands. onResponseBoundary's non-flushing teardown destroys a previous turn's parked deferred-flush entry (buffer + retry/re-flush timer + pendingStreamDelete) without ever releasing that entry's reply anchor — the anchor's msgSeqMap counter, retained by the cascade guard precisely because the parked entry still held it, becomes a permanent persisted orphan. Distinct trigger from the settle-race and session-death orphans: here the parked chain's timer is cleared, so no settle ever runs again and a settle-time re-release fix cannot catch it. Round-5 probe verified at this commit (the fix flips and passes 346/346): chain settles and re-arms the park (residual + 2s timer + flag); a new message arrives in the park window; the new turn's first responseBoundary fires before the park timer → this teardown clears everything; no reference to msg-A remains anywhere and no future release can target it (the TTL net scans only anchor maps).

Suggested fix: capture the stale entry's msgId (guarded on state.turn !== currentTurn) and release after the deletes.

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。onResponseBoundary 的非刷新清理会摧毁上一轮被挂起的延迟刷新条目(缓冲+重试/再刷新定时器+pendingStreamDelete),却从不释放该条目的回复锚点——锚点对应的 msgSeqMap 计数器正因为挂起条目仍持有它而被级联守卫保留,遂成为永久持久化孤儿。与 settle 竞态、会话死亡孤儿的触发点不同:此处挂起链的定时器被清除,settle 永远不会再发生,settle 时补释放的修复无法覆盖。第 5 轮已在本 commit 用探针验证(修复翻转后 346/346 通过):链 settle 后重新挂起(残余+2s 定时器+标志);挂起窗口内新消息到达;新轮首个 responseBoundary 先于挂起定时器触发 → 本清理删除一切;msg-A 在任何地方都不再有引用,也没有未来释放能触及它(TTL 安全网只扫锚点映射)。修复建议:捕获陈旧条目的 msgId(以 state.turn !== currentTurn 为守卫),在删除之后释放。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +1824 to +1826
// The dead turn's entry still held a buffered residual when the release
// ran, so the guard kept the counter (it cannot reset to 1).
expect(seqMap.get('msg-1')).toBe(2);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-8: Round-6 re-check at the unchanged head f5c33173 — still stands. This assertion pins the orphaned-counter outcome that the onSessionDied release-before-delete ordering produces (R5-5): nothing is in flight in this test (synchronous; timers never advanced; mockSendQQMessage never invoked; flushingSessions empty), yet the guard keeps the counter solely because the entry's buffer is truthy — and after onSessionDied returns nothing references msg-1 anywhere, while serializeQQState persists it: a permanent persisted orphan. Two costs: (1) the suite pins the bug as intended semantics with a comment that will mislead the next maintainer; (2) any fix of the R5-5 orphan makes this assertion fail — the fix cannot land green until the test is flipped. File unchanged at this commit (suite 345/345 green; walked this round).

Suggested fix: together with the R5-5 production fix, flip to expect(seqMap.has('msg-1')).toBe(false) and rewrite the comment. (Do not apply this test flip alone — it is red against today's code.)

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。该断言固定了 onSessionDied 先释放后删除顺序(R5-5)所产生的孤儿计数器结果:本测试中没有任何在途发送(同步执行、从未推进定时器、mockSendQQMessage 从未被调用、flushingSessions 为空),守卫仅因条目 buffer 非空而保留计数器——onSessionDied 返回后 msg-1 在任何地方都无引用,而 serializeQQState 仍会持久化它:永久持久化孤儿。两个代价:(1) 测试套件把该 bug 当作预期语义固定下来,注释会误导后续维护者;(2) 任何对 R5-5 孤儿的修复都会让该断言失败——不翻转测试修复无法绿灯合入。文件在本 commit 未变化(套件 345/345 绿灯;本轮已走查)。修复建议:随 R5-5 生产修复一并改为 expect(seqMap.has('msg-1')).toBe(false) 并重写注释。(请勿单独应用该测试翻转——对当前代码它是红的。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +2827 to +2832
// The tail send settles; the chain's settle-time release runs under the
// same in-flight marker, so the counter survives to the end.
resolveTailSend!(mockResponse(true));
await drain();

expect(seqMap.get('msg-A')).toBe(2);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-9: Round-6 re-check at the unchanged head f5c33173 — still stands. This test pins the orphaned-counter outcome of the settle-time retention (R5-4) as intended behavior: after the tail send settles, msgSeqMap['msg-A'] === 2 is asserted although nothing references msg-A anymore (session anchor → msg-B, chat entry → msg-B, no streamState entry, no flushing marker — round-5 probe verified 0 references; every cleanup path keys off references that moved on). When the settle-orphan fix lands (post-settle re-check release), seqMap.has('msg-A') becomes false and this assertion fails — the suite turns red against the fix; the comment ("the counter survives to the end") documents the orphan as spec, inviting the fix author to preserve the leak. releaseSessionReplyAnchor's own JSDoc names the anti-guarantee ("otherwise they accumulate forever in memory and in the persisted QQ state"). File unchanged at this commit (79/79 green this round).

Suggested fix: with the R5-4 fix, flip the post-settle block to expect(seqMap.has('msg-A')).toBe(false) and drop the "survives to the end" narrative; keep the mid-flight assertions (seqMap.get('msg-A') === 2 before resolveTailSend), which correctly pin the dedup-drop protection. (Do not apply this test flip alone — it is red against today's code.)

中文说明

第 6 轮复查(HEAD 未变化)——仍然存在。该测试把 settle 时保留(R5-4)所产生的孤儿计数器当作预期行为固定下来:尾部发送 settle 后断言 msgSeqMap['msg-A'] === 2,但此时 msg-A 已无任何引用(会话锚点 → msg-B、chat 条目 → msg-B、无 streamState 条目、无 flushing 标志——第 5 轮探针验证 0 引用;所有清理路径都依赖已移走的引用)。settle 孤儿修复(settle 后补释放)落地后,seqMap.has('msg-A') 变为 false,该断言失败——套件对修复变红;注释("计数器存活到最后")把孤儿写成规范,诱导修复者保留泄漏。releaseSessionReplyAnchor 的 JSDoc 明确指出反面("否则它们会在内存和持久化 QQ state 中永远累积")。文件在本 commit 未变化(本轮 79/79 绿灯)。修复建议:随 R5-4 修复把 settle 后断言改为 expect(seqMap.has('msg-A')).toBe(false) 并删除"存活到最后"的说法;保留在途断言(resolveTailSendseqMap.get('msg-A') === 2),它们正确地固定了去重丢包保护。(请勿单独应用该测试翻转——对当前代码它是红的。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines 1453 to 1458
if (this.pendingStreamDelete.has(sessionId)) {
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);
this.deleteTurnCounterIfOwned(state, sessionId);
}
return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R6-1: The flushAndTrack .catch() settle blocks consume pendingStreamDelete (and flushedSessions/turn counter) without any identity/turn check, so a superseded turn's failing flush chain steals a successor turn's parked-completion flag — the successor's residual tail is then silently lost. Failure scenario (probe-verified at this commit): turn 1 completes while its flush F1 is in flight → onResponseComplete parks (pendingStreamDelete.add); turn 2 runs and completes while its own flush F2 is in flight → parked again; F1 then settles with a failure — permanent (RETRY_EXHAUSTED/ACTIVE_MSG_DISABLED/FALLBACK_FAILED) or transient (RATE_LIMITED): the pending cleanup runs unconditionally and deletes turn 2's park flag and flushedSessions record. F2's .then() then sees pendingStreamDelete.has === false, skips the residual re-flush, and the entry is left with a non-empty buffer, no timer, no flag — turn 2's tail is discarded by the next turn's stale-drop ("dropping N chars of superseded turn") or stranded until disconnect. Realistic under QQ rate limits / slow token refresh, and it compounds with R5-10 for the concurrent-flush leg.

Witness (scratch-tree probe at f5c33173):

PERMANENT (RETRY_EXHAUSTED): pendingStreamDelete.has('s1') after F1 reject: false  ← successor park flag stolen
  'turn2-tail' never sent; stderr: dropping 10 chars of superseded turn 2 for s1
TRANSIENT (RATE_LIMITED): same theft; 'turn2-tail' never sent
FLIP (identity-guarded cleanup): both probes pass; qqbot suites 267/267 green
Suggested change
if (this.pendingStreamDelete.has(sessionId)) {
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);
this.deleteTurnCounterIfOwned(state, sessionId);
}
return;
if (
current === state &&
this.pendingStreamDelete.has(sessionId)
) {
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);
this.deleteTurnCounterIfOwned(state, sessionId);
}
return;

The suggestion guards the permanent branch; apply the same identity guard to the transient branch's pendingStreamDelete.delete(sessionId) below (return before consuming the flag when current !== state), or scope the park flag to the turn that set it.

中文说明

flushAndTrack.catch() settle 块在消费 pendingStreamDelete(以及 flushedSessions/turn 计数器)时没有任何身份/轮次检查,因此被取代轮次的失败刷新链会偷走后继轮次的挂起完成标志——后继轮次的残余尾部随后被静默丢失。失败场景(已在本 commit 用探针验证):轮次 1 完成时其刷新 F1 仍在途 → onResponseComplete 挂起(pendingStreamDelete.add);轮次 2 运行并在其自身刷新 F2 在途时完成 → 再次挂起;F1 随后以失败 settle——永久(RETRY_EXHAUSTED/ACTIVE_MSG_DISABLED/FALLBACK_FAILED)或瞬时(RATE_LIMITED):pending 清理无条件执行,删除轮次 2 的挂起标志与 flushedSessions 记录;F2 的 .then() 随后看到 pendingStreamDelete.has === false,跳过残余再刷新,条目带着非空缓冲、无定时器、无标志被留下——轮次 2 的尾部被下一轮的 stale-drop 丢弃("dropping N chars of superseded turn")或一直滞留到断连。在 QQ 限流/慢 token 刷新下现实可达,并与 R5-10 叠加构成并发刷新路径。证据(f5c33173 探针树):永久与瞬时两分支均观测到挂起标志被偷、'turn2-tail' 从未发出;身份守卫修复后两个探针均通过、qqbot 套件 267/267 绿灯。修复建议:上方 suggestion 为永久分支补身份守卫;请对下方瞬时分支的 pendingStreamDelete.delete(sessionId) 应用同样守卫(current !== state 时在消费标志前返回),或把挂起标志改为按轮次作用域。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(qqbot): groupAllPolicy all/keyword forcibly overrides sessionScope to 'single', leaking context across groups and DMs

5 participants