Skip to content

feat(channels): GitHub polling adapter with notification-as-wakeup architecture#7632

Merged
wenshao merged 37 commits into
QwenLM:mainfrom
OrbitZore:main
Jul 25, 2026
Merged

feat(channels): GitHub polling adapter with notification-as-wakeup architecture#7632
wenshao merged 37 commits into
QwenLM:mainfrom
OrbitZore:main

Conversation

@OrbitZore

@OrbitZore OrbitZore commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a GitHub channel adapter that polls GitHub notifications and responds to @mentions on issues/PRs by posting comments. Redesigned successor of the earlier shared-abstraction attempt, with a fundamentally different architecture.

The design follows the same "signal vs. content" separation as multica's task dispatch model: in multica, an assignment or @mention enqueues a task (the signal), and the agent's daemon polls to pick it up, then reads the full issue context (description + all comments) separately to do the actual work. The trigger tells the agent where to look, not what to process. Our adapter applies the same principle to GitHub's external notification API: a GitHub notification is only a wake-up signal ("something happened on this thread"); the adapter then enumerates actual comments via listComments within a cursor-based time window to determine which comments are new. Dedup relies on a global cursor window (windowSince, maxUpdatedAt] — not on GitHub's async markNotificationsAsRead — and bot self-comments are filtered to prevent loops.

Notification/comment timestamp decoupling: notification updated_at and comment updated_at are decoupled — a notification can arrive 16s after the comment that triggered it, and can be bumped by unrelated activity (label, push). PUT /notifications is async (202) with a last_read_at cutoff; the bot's reply bumps updated_at past the cutoff before the server processes the mark, so the mark never takes effect. The cursor-based comment window sidesteps this entirely: the window lower bound is the cursor value before the current poll advances it, so comments already eligible in a previous poll are excluded regardless of mark status. Known limitation: if notification delivery is delayed across a poll boundary and another thread advances the cursor past its comments, those comments are missed (user re-mentions to retry).

Polling infrastructure:

  • PollingChannelBase<Cursor> — generic poll loop base class. Encapsulates cursor persistence (JSON, atomic write via rename, sha256-hashed file names to prevent collision), cursor shape validation via validateCursor() virtual hook (subclasses reject wrong-shape cursors and unparseable dates; invalid → fallback to createInitialCursor()), exponential backoff (2s–30s), start/stop lifecycle with re-entrancy guard, and configurable pollInterval (read from channel config, validated as positive finite number, defaults to 60s). Exposes abortableSleep(ms) as a protected method so subclasses can write interruptible waits. Resets consecutiveErrors on startPollLoop() so stop/restart cycles start with fresh 2s backoff instead of inheriting stale elevated values. Subclasses implement only pollOnce() and createInitialCursor().

GitHub adapter:

  • Mark-before-process: markNotificationsAsRead(maxUpdatedAt) is called before the per-notification processing loop. This prevents duplicate processing when the poll loop is interrupted mid-batch (crash, restart). The mark is best-effort cleanup; dedup does not depend on it.
  • Cursor-based comment window: the cursor value before advancement (windowSince) serves as the exclusive lower bound; maxUpdatedAt (max notification updated_at in the batch) serves as the upper bound. Comments outside (windowSince, maxUpdatedAt] are excluded. This prevents duplicates even when PUT /notifications' async mark fails (bot reply bumps updated_at past the cutoff).
  • Identity: senderId uses immutable numeric user.id (not mutable login). allowedUsers logins are resolved to numeric IDs at connect() via users.getByUsername, and the SenderGate is updated with the resolved IDs via replaceAllowedUsers(). senderName retains login for agent-facing display. Bot self-comment filtering compares user.id against botUserId.
  • Notification URL matching for both /issues/N and /pulls/N subject URLs, with threadId set to issue:N or pr:N accordingly. Notifications without a subject.url (Discussion, SecurityAlert) are skipped.
  • Mention detection via lookbehind/lookahead regex (matches cc:@bot, "@bot", start-of-text; rejects partial usernames, emails, embedded words). stripBotMention preserves markdown indentation (no trim).
  • First-contact: when no mention was dispatched (!dispatchedMention) and the notification has no last_read_at (brand-new thread), fetches the issue/PR body and processes it. Dedup uses a dispatchedBodies array persisted in the cursor (bounded to 500 entries, oldest evicted) — unlike a created_at > cursor guard, this also feeds bodies whose notification arrived late after the cursor had advanced past created_at.
  • connect() resolves bot identity and throws on failure (no fail-open).
  • GitHub Enterprise support: baseUrl config drives both API and web URLs in metadata.
  • Proxy support: reads this.proxy from ChannelBaseOptions and passes HttpsProxyAgent to Octokit, matching the Telegram adapter pattern.
  • Error handling: per-notification API errors use continue (best-effort) — one failed notification does not block the rest of the batch. On handleInbound failure, posts one error comment per thread per poll cycle then breaks the comment loop (prevents N identical error comments; user re-mentions to retry). First-contact handleInbound failure also records dispatchedBodies to prevent duplicate error comments when mark-read async hasn't taken effect. All Octokit calls (including sendThreadMessage, postErrorComment, and tryFirstContactBody's issues.get) go through the githubApi retry wrapper with rate-limit awareness (retry-after, x-ratelimit-reset headers) and abortable cooldowns (abortableSleep instead of raw setTimeout, so disconnect() can interrupt rate-limit waits).
  • Initial cursor set to now on first start — existing unread notifications are skipped.

ChannelBase foundation changes:

  • sendThreadMessage(chatId, threadId, text) — thread-targeted delivery. All internal sendMessage calls (shell output, error messages, pairing codes) now route through it. IM adapters unaffected (default fallthrough to sendMessage). Throws on invalid threadId format in the GitHub adapter.
  • Envelope.metadata — contextual metadata (issue type, title, URL) kept separate from text so slash-command parsing operates on the comment body alone. Appended to the prompt after command parsing, sanitized via sanitizePromptText. Cleared on collect-mode synthetic envelopes (both drain paths).
  • chat_thread session scope — routing key channel:chatId:threadId prevents cross-repo session collision. Falls back to channel:chatId when no threadId. Added to SessionScope type and isSharedSessionTarget.
  • defaultSessionScope on ChannelPlugin — applied in parseChannelConfig when config omits sessionScope, so router and adapter agree on chat_thread without explicit user config.
  • Pairing flow passes threadId through onPairingRequired, so pairing codes are delivered on threaded channels instead of throwing.
  • sendResponseMessage resolves threadId from router.getTarget and passes it to sendThreadMessage.

Registration, build & docs:

  • Channel registry, build script, and clean script include the new github package.
  • Design doc (docs/design/channels/channel-code-hosting.md), user guide (docs/users/features/channels/github.md), and developer docs updated.
  • Known limitations documented: requires classic PAT (fine-grained PATs lack notifications API), PR review comments not enumerated (issue comments only), first start skips existing unread.

Why it's needed

The earlier shared-abstraction PR was closed after 6 review rounds: GitHub/Gitea/GitLab have fundamentally different dedup mechanisms, making a shared abstraction unworkable. This PR implements GitHub separately with a cursor-based comment window, resolving all prior blocking findings (thread-level updated_at duplicates, latest_comment_url losing comments, isMentioned false positives, bot self-loop, stripBotMention corrupting markdown). E2E testing additionally uncovered that PUT /notifications is async (202) with a cutoff that the bot's own reply defeats — the cursor window was designed to make dedup independent of mark success.

Reviewer Test Plan

How to verify

  1. Configure a GitHub channel in ~/.qwen/settings.json with a classic PAT bot token
  2. npm run dev -- channel start <name>
  3. Comment @bot hello on an issue → bot replies within ~60s
  4. Comment without @mention → no reply (dispatched as isMentioned: false, gated by GroupGate)
  5. Create issue with @bot help in body → bot replies (first-contact)
  6. Push commit with @bot in message → no reply (non-issue/PR notification skipped)
  7. Verify pollInterval in config is respected (e.g. set to 30000 for 30s polls)

Evidence (Before & After)

E2E full pass on OrbitZore/channel-e2e-0725-full (real GitHub API, bot @orbitzorex, npm run build && npm run bundlenode dist/cli.js channel start, head d85ed6dd6, pollInterval 15s, model qwen3.6-flash). 14/14 passed. The fixture repo is a small utility library with deterministic outputs, so every reply is content-verified (exact expected token), not just presence-verified.

TC Description Result
A1 Issue comment @mention → 1 reply with correct token
A2 No duplicate after 4 idle poll cycles (thread re-fetched; mark-read is async)
A3 Sequential @mention same thread → 2 total, earlier reply not duplicated
A4 Stop → restart → no duplicate; cursor reloaded from disk
B1 Two rapid @mentions (4s apart) → 2 replies
B2 Concurrent threads → both reply, each on its own thread, no bleed
B3 @mention posted while agent is busy on a slow task → not lost, gets its own reply
B4 Issue body @mention (first contact) → reply
B5 3-mention burst → exactly 3 replies
C1/C2 Deterministic token correct; reply lands on the mentioning thread (issue:N / pr:N)
D1 Non-mention comment → no reply (GroupGate); bumped thread not duplicated
D2 Bot self-reply never re-triggers itself (no loop)
E1 PR comment @mention → reply on the PR

Channel logs across both runs (including the restart) contained zero error / warning / backoff / rate-limit events. No repeat-reply or forget-reply bug reproduced. The highest-risk forget path — a new mention arriving while the poll loop is blocked on a slow agent turn (B3, the ⚠️ in the earlier v8 round) — was exercised explicitly and held.

Earlier rounds (superseded by the full pass above): v8 on channel-e2e-0724d and round 2 on channel-e2e-07250549 (a834527); v3–v7 on channel-e2e-0724c confirmed bot-self no-loop, PR body mention, case-insensitive mention, and commit-message no-reply.

Unit tests: 912 ChannelBase, 29 GitHub adapter, 9 PollingChannelBase, 17 mention — all pass (958 total).

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment

npm run dev (tsx), Node v26, real GitHub API.

Risk & Scope

  • Main risk: handleInbound is awaited in poll loop — slow agent blocks subsequent polls.
  • Out of scope: Gitea/GitLab adapters (separate PRs).
  • Breaking changes: none. All existing ChannelBase tests pass unchanged.

Linked Issues

Ref #7266 (closed — earlier shared-abstraction attempt).

中文说明

这个 PR 做了什么

新增 GitHub channel 适配器,轮询 GitHub 通知 API 监听 @mention 并以评论回复。是早期共享抽象方案的重新设计版本,采用了根本不同的架构。

设计遵循与 multica 任务分发模型相同的"信号与内容分离"原则:GitHub 通知仅是唤醒信号("这个线程有动静了"),适配器随后通过 listComments 在基于 cursor 的时间窗口内枚举实际评论。去重依赖全局 cursor 窗口 (windowSince, maxUpdatedAt]——而非 GitHub 异步的 markNotificationsAsRead——bot 自评论被过滤以防止循环。

通知/评论时间戳解耦: PUT /notifications 是异步的(202),使用 last_read_at 作为截止线;bot 的回复在 server 处理 mark 之前就把 updated_at bump 超过截止线,导致 mark 永远不生效。基于 cursor 的评论窗口完全绕开了这个问题。

轮询基础设施:

  • PollingChannelBase<Cursor> — 泛型轮询基类。封装 cursor 持久化(JSON、原子写入、sha256 哈希文件名防碰撞)、cursor 校验(validateCursor() 虚方法,子类拒绝错误形状和不可解析日期)、指数退避(2s–30s)、带重入保护的 start/stop 生命周期、可配置 pollInterval(校验正有限数,默认 60s)。暴露 abortableSleep(ms) 为 protected 方法供子类使用。

GitHub 适配器:

  • Mark-before-process:markNotificationsAsRead(maxUpdatedAt) 在处理循环之前调用,防止中断后重复处理。去重不依赖 mark。
  • 基于 cursor 的评论窗口:windowSince(推进前 cursor)为排他下界,maxUpdatedAt 为上界。
  • 身份标识:senderId 用不可变 user.idconnect() 解析 allowedUsers login → 数字 ID,并通过 replaceAllowedUsers() 更新 SenderGate。
  • 通知 URL 匹配 /issues/N/pulls/N。无 subject.url 的通知(Discussion、SecurityAlert)被跳过。
  • Mention 检测:lookbehind/lookahead 正则。stripBotMention 不做 trim。
  • 首次接触:!dispatchedMention && !lastReadAt 时获取 issue/PR body。去重用 cursor 中的 dispatchedBodies 数组(上限 500,淘汰最旧)。
  • connect() 解析 bot 身份,失败抛异常(不 fail-open)。
  • GHE 支持:baseUrl 驱动 API 和 metadata web URL。
  • 代理支持:HttpsProxyAgent 传给 Octokit。
  • 错误处理:per-notification continuehandleInbound 失败发一条错误评论后 break(防 N 条相同评论);所有 Octokit 调用走 githubApi 重试封装。
  • 首次启动 cursor 设为当前时间。

ChannelBase 基础变更:

  • sendThreadMessage — 线程定向投递。
  • Envelope.metadata — 与 text 分离,命令解析后追加。collect 模式两个 drain 路径均清除。
  • chat_thread session scope — 路由键 channel:chatId:threadId
  • defaultSessionScope on ChannelPlugin
  • Pairing 传递 threadId
  • sendResponseMessage 解析 threadId

注册、构建与文档: registry、build、clean 脚本包含 github 包。设计文档、用户指南、开发者文档已更新。

为什么需要

早期共享抽象 PR 经 6 轮 review 后关闭。本 PR 单独实现 GitHub,使用 cursor 评论窗口,解决所有先前阻塞性问题。

Reviewer 测试计划

见英文部分。单元测试:912 ChannelBase、29 GitHub 适配器、9 PollingChannelBase、17 mention — 全部通过(共 958 个)。

风险与范围

  • 主要风险:慢 agent 阻塞后续轮询。
  • 不在范围:Gitea/GitLab 适配器。
  • 破坏性变更:无。

关联 Issue

引用 #7266(已关闭)。

OrbitZore added 12 commits July 24, 2026 01:36
…p architecture

Introduce a GitHub channel adapter that monitors notifications and
responds to @mentions on issues/PRs by posting comments. Uses
last_read_at as a per-thread watermark for comment enumeration,
replacing the unreliable latest_comment_url approach.

Foundation changes to ChannelBase:
- sendThreadMessage for thread-targeted delivery (IM adapters unchanged)
- Envelope.metadata appended to prompt after command parsing
- chat_thread session scope (channel:chatId:threadId) prevents
  cross-repo session collision
- polling-helpers: testBotMention/stripBotMention (separate detection
  from stripping, no whitespace collapsing), cursor persistence,
  abortableSleep

GitHub adapter design:
- Notifications as wake-up signals only (unread filtering)
- listComments enumeration with last_read_at watermark
- Bot self-comment filtering, case-insensitive mention regex
- In-memory recentlyProcessed set for mark-read failure dedup
- First-contact: new issue body @bot triggers processing
- Error comment + cursor advance on handleInbound failure
- pollInterval minimum 60s, exponential backoff 2s-30s
Replace the loose polling-helpers module with a PollingChannelBase<Cursor>
abstract class that encapsulates the poll loop, cursor persistence (JSON,
atomic write), exponential backoff, and start/stop lifecycle. Subclasses
implement only pollOnce() and createInitialCursor().

- Delete polling-helpers.ts (cursor fns + abortableSleep moved into base)
- Move mention utilities (testBotMention/stripBotMention) to github pkg
- GithubAdapter now extends PollingChannelBase<{ lastProcessedAt }>
GitHub PR notifications use /repos/{owner}/{repo}/pulls/{N} in
subject.url, not /issues/{N}. The regex only matched /issues/,
causing PR notifications to be skipped and marked read.

Also sets threadId to 'pr:N' for PRs (was always 'issue:N').
Verify that PR notifications with @mention in the body (not a comment)
correctly trigger the first-contact path: extractFromSubjectUrl matches
/pulls/N, listComments returns empty, tryFirstContactBody fetches the
PR body and dispatches to handleInbound with threadId 'pr:N'.
…elBase

Move pollInterval config reading from GithubAdapter to the base class.
The user's configured pollInterval in settings.json is now respected
directly without a minimum enforcement. Defaults to 60000ms when not
configured.
Agent sees issue/PR context (type, title, URL) before the user's
request, improving comprehension. Metadata is still appended after
slash-command parsing so commands are not affected.
…Message

Replace all internal sendMessage calls with sendThreadMessage, passing
envelope.threadId (or target.threadId / undefined) so polling adapters
can deliver to the correct thread. IM adapters are unaffected — the
default sendThreadMessage falls through to sendMessage.
- Cap recentlyProcessed Set at 10k entries to prevent unbounded growth
- Validate cursor JSON shape (non-null object) in loadCursorFromDisk
- sendThreadMessage falls through to sendMessage when threadId is
  undefined instead of silently dropping
- Remove duplicate pollInterval from GithubConfig (now in ChannelConfig)
- Fix chat_thread routing key trailing colon when threadId is undefined
Replace the fragile createdAt-vs-cursor check in tryFirstContactBody
with the recentlyProcessed set. The cursor advances globally based on
notification updated_at — when a different notification with a later
updated_at is processed first, the cursor can advance past the issue's
created_at, causing the first-contact check to incorrectly skip the
issue body (forget reply bug, found in E2E TC-2b).
@OrbitZore

OrbitZore commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

E2E Test Report

Test Plan

Repo: OrbitZore/channel-e2e-0724 (fresh, public)
Channel: pollInterval=30s, sessionScope=chat_thread

TC Description Steps Expected
TC-1 Issue comment mention Comment @bot hello, reply "TC1-ACK" Single reply
TC-2 Issue body mention (first contact) Create issue with @bot reply "TC2-ACK" in body Single reply
TC-3 PR comment mention Open PR, comment @bot review, reply "TC3-ACK" Single reply on PR
TC-4 Sequential mention Second @bot comment on same issue Reply to each, no duplicates
TC-5 Different thread New issue with @bot reply "TC5-ACK" Independent session
TC-6 Self-reply check Wait 2 extra poll cycles after bot reply No additional replies
TC-7 Rapid duplicates Two @bot comments within 7s One reply each

Results

TC Result Notes
TC-1 ✅ PASS Bot replied correctly
TC-2 ✅ PASS First-contact body mention works
TC-3 ✅ PASS PR thread, threadId=pr:N
TC-4 ✅ PASS Sequential mentions each get reply
TC-5 ✅ PASS Session isolation confirmed
TC-6 ✅ PASS No self-loop after 2 extra cycles
TC-7 ✅ PASS Two comments → two replies, no duplicates
TC-2b ✅ PASS First-contact retest after bug fix

Score: 8/8 ✅

Bug Found & Fixed

tryFirstContactBody cursor race (forget reply)

tryFirstContactBody() used createdAt <= cursor to filter old issues. But the cursor advances globally based on notification updated_at. When a different notification with a later updated_at is processed first, the cursor advances past the issue's created_at, causing first-contact to incorrectly skip the issue body.

Fix: Replaced createdAt vs cursor check with recentlyProcessed dedup set (same mechanism used for comment dedup).

Reply Count Audit

Thread Bot replies Expected Match
Issue 1 2 2 (TC-1 + TC-4)
Issue 2 1 1 (TC-2)
Issue 4 1 1 (TC-2b)
Issue 5 3 3 (TC-5 + TC-7A + TC-7B)
PR 3 2 2 (TC-3 + concurrent)

Observation (Not a Bug)

The agent session has access to gh CLI authenticated as the repo owner. When asked to "reply with X", the agent sometimes posts via gh issue comment (as the owner) AND the channel delivers the response (as the bot) — creating two comments. This is agent behavior, not a channel adapter bug. Mitigation: exclude gh from channel session tools or add system prompt guidance.

中文翻译

E2E 测试报告

在全新公开仓库上运行 8 个测试场景,全部通过。

发现并修复一个 bug:tryFirstContactBody 使用 createdAt <= cursor 判断是否跳过旧 issue,但 cursor 会被其他通知推进导致误跳过(遗漏回复)。修复为使用 recentlyProcessed 去重集合。

回复计数审计:所有 thread 的 bot 回复数量与预期完全一致,无重复、无遗漏、无自循环。

观察(非 bug):agent session 可使用 gh CLI(以 repo owner 身份),有时会同时通过 gh 和 channel 发两条评论。这是 agent 行为,非适配器问题。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is a well-structured redesign of the GitHub channel adapter after #7266 was closed.

Template looks good ✓

Problem: This is a feature addition, not a bug fix. The predecessor PR #7266 was closed after 6 review rounds because the shared GitHub/Gitea/GitLab abstraction was unworkable. This PR takes the right lesson from that: implement GitHub separately with its native last_read_at watermark. The E2E results (8/8 pass on real GitHub API) demonstrate the feature works. The problem being solved — interacting with Qwen Code via GitHub issues/PRs — is a real user need.

Direction: Aligned. The CHANGELOG shows active channel development (memory, telemetry, DingTalk webhook, workspace scoping, etc.), and adding a code-hosting channel is a natural extension. Claude Code has /install-github-app but no polling-based GitHub channel — no direct CHANGELOG reference, but the area is clearly relevant.

Size: This is a cross-package change touching packages/channels/base (ChannelBase.ts: 204+75, PollingChannelBase.ts: 112 new, SessionRouter.ts: 4, types.ts: 10+1), packages/channels/github (new package: ~350 production lines), and packages/cli (1 line registration). Production logic: ~759 lines; test: ~756 lines; docs/config: ~630 lines. Flagging for maintainer awareness given the 500+ production line threshold on cross-package changes.

Approach: The architecture is sound — notification-as-wakeup with last_read_at watermark is the correct design for GitHub's notification API. A few observations:

  • The sendThreadMessage refactoring in ChannelBase.ts is the highest-risk part: it touches ~40 call sites, replacing sendMessage(chatId, text) with sendThreadMessage(chatId, threadId, text). The default fallthrough to sendMessage means existing IM adapters are behaviorally unchanged, but the sheer number of replacements warrants careful review.
  • PollingChannelBase<Cursor> is clean and minimal — poll loop, cursor persistence, backoff. Good separation.
  • Mention detection via explicit regex (not inferred from strip-before/after) avoids the false-positive class that plagued feat(channels): add GitHub/GitLab/Gitea polling adapters with cursor fix and docs #7266.
  • The recentlyProcessed set clearing entirely at 10K entries is pragmatic but worth noting — a burst of activity could theoretically cause one duplicate after a clear.

Moving on to code review. 🔍

中文说明

感谢贡献!这是 #7266 关闭后 GitHub channel 适配器的重新设计。

模板完整 ✓

问题: 这是功能新增,不是 bug 修复。前序 PR #7266 因三平台共享抽象不可行而在 6 轮 review 后关闭。本 PR 正确吸取了教训:单独实现 GitHub,使用原生 last_read_at 水位线。E2E 8/8 通过(真实 GitHub API),功能可用。通过 GitHub issue/PR 与 Qwen Code 交互是真实用户需求。

方向: 对齐。CHANGELOG 显示 channel 领域活跃开发中,新增代码托管 channel 是自然延伸。Claude Code 有 /install-github-app 但无轮询式 GitHub channel——无直接 CHANGELOG 引用,但该领域明显相关。

规模: 跨包变更,涉及 packages/channels/base(ChannelBase.ts: 204+75, PollingChannelBase.ts: 112 新增, SessionRouter.ts: 4, types.ts: 10+1)、packages/channels/github(新包: ~350 生产行)和 packages/cli(1 行注册)。生产逻辑 ~759 行;测试 ~756 行;文档/配置 ~630 行。因跨包变更超过 500 生产行阈值,提请维护者关注。

方案: 架构合理——notification-as-wakeup + last_read_at 水位线是 GitHub 通知 API 的正确设计。几点观察:

  • ChannelBase.ts 中的 sendThreadMessage 重构是最高风险部分:触及约 40 个调用点。默认回退到 sendMessage 意味着现有 IM 适配器行为不变,但大量替换需要仔细审查。
  • PollingChannelBase<Cursor> 简洁——轮询循环、游标持久化、退避。分离良好。
  • 提及检测使用显式正则(非 strip 前后比较推断),避免了 feat(channels): add GitHub/GitLab/Gitea polling adapters with cursor fix and docs #7266 的误报问题。
  • recentlyProcessed 集合在 10K 条目时整体清空是务实做法,但值得注意——突发活动可能在清空后导致一次重复。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: For a GitHub polling adapter, I'd create a new packages/channels/github package with a GithubChannel extending ChannelBase, use octokit for API access, poll GET /notifications for unread threads, enumerate comments via listComments(since=last_read_at), detect mentions via regex, and post replies via issues.createComment. I'd add poll loop infrastructure (interval, backoff, cursor persistence) and a chat_thread session scope for cross-repo isolation.

Comparison: The PR's approach matches this closely. The PollingChannelBase<Cursor> abstraction is a clean way to share poll loop infrastructure. The sendThreadMessage refactoring in ChannelBase is the most invasive part but necessary for thread-targeted delivery. The mention detection via explicit regex (not strip-before/after comparison) is the right call.

Findings:

One test failure: packages/cli/src/commands/channel/channel-registry.test.ts expects the channel type list to be ['telegram', 'weixin', 'dingtalk', 'wecom', 'feishu', 'qq'] but the PR adds github without updating the assertion. This needs a one-line fix.

No critical blockers in the production code. The sendThreadMessage refactoring is mechanical — every call site passes envelope.threadId or target.threadId, and the default fallthrough to sendMessage means existing IM adapters are behaviorally unchanged. The sendResponseMessage change correctly looks up threadId from activePrompts or router.getTarget.

The PollingChannelBase is well-implemented: atomic cursor persistence (tmp + rename), exponential backoff (2s → 30s), abortable sleep for clean shutdown. The GithubAdapter correctly uses last_read_at as the per-thread watermark, with the global cursor as a performance optimization only.

Files changed (24 of 24 shown)
File What changed
docs/design/channels/channel-code-hosting.md New design doc for the notification-as-wakeup architecture
docs/developers/daemon/15-channel-adapters.md Updated to document sendThreadMessage, PollingChannelBase, chat_thread scope
docs/users/features/channels/_meta.ts Added GitHub to channel docs nav
docs/users/features/channels/github.md New user guide for GitHub channel setup
docs/users/features/channels/overview.md Added mention of code-hosting platform support
package-lock.json Added octokit dependency tree
package.json Added packages/channels/github to workspaces
packages/channels/base/src/ChannelBase.ts Added sendThreadMessage with default fallthrough; routed all sendMessage calls through it; added metadata prepending to prompt
packages/channels/base/src/PollingChannelBase.test.ts 9 tests for poll loop, cursor persistence, backoff
packages/channels/base/src/PollingChannelBase.ts New generic poll loop base with cursor persistence and backoff
packages/channels/base/src/SessionRouter.ts Added chat_thread scope resolution
packages/channels/base/src/index.ts Exported PollingChannelBase
packages/channels/base/src/types.ts Added chat_thread to SessionScope, pollInterval to ChannelConfig, metadata to Envelope
packages/channels/github/package.json New package with octokit dependency
packages/channels/github/src/GithubAdapter.test.ts 21 tests covering poll, mention, dedup, first-contact, error handling
packages/channels/github/src/GithubAdapter.ts GitHub polling adapter with notification-as-wakeup architecture
packages/channels/github/src/index.ts Plugin registration
packages/channels/github/src/mention.test.ts 14 tests for mention detection and stripping
packages/channels/github/src/mention.ts Regex-based mention detection with lookbehind/lookahead
packages/channels/github/tsconfig.json TypeScript config
packages/channels/github/vitest.config.ts Vitest config
packages/cli/package.json Added channel-github dependency
packages/cli/src/commands/channel/channel-registry.ts Registered github channel plugin
scripts/build.js Added github package to build

Test Results

Unit tests (all pass):

packages/channels/github:
 ✓ src/mention.test.ts (14 tests) 4ms
 ✓ src/GithubAdapter.test.ts (21 tests) 28ms
 Test Files  2 passed (2)
      Tests  35 passed (35)

packages/channels/base (PollingChannelBase):
 ✓ src/PollingChannelBase.test.ts (9 tests) 114ms
 Test Files  1 passed (1)
      Tests  9 passed (9)

packages/channels/base (full suite):
 Test Files  19 passed (19)
      Tests  897 passed (897)

TypeScript build: Clean, no errors.

CLI integration: GitHub channel type registered correctly:

Supported channel types: ['telegram', 'weixin', 'dingtalk', 'wecom', 'feishu', 'qq', 'github']
Plugin: { channelType: 'github', displayName: 'GitHub', requiredConfigFields: ['token'] }

Test failure:

packages/cli/src/commands/channel/channel-registry.test.ts
 FAIL  channel registry > only marks the manually configurable built-in types as manageable
 Expected: ['telegram', 'weixin', 'dingtalk', 'wecom', 'feishu', 'qq']
 Received: ['telegram', 'weixin', 'dingtalk', 'wecom', 'feishu', 'qq', 'github']

The test hardcodes the expected channel type list and needs github added.

Real-scenario testing: tmux is not available in this CI environment. Full E2E testing of the GitHub polling flow requires a real GitHub token and a running daemon, which is beyond what can be verified here. The PR author reports 8/8 E2E tests passing on a real GitHub API with a bot account.

中文说明

代码审查

独立方案: 对于 GitHub 轮询适配器,我会创建 packages/channels/github 包,使用 octokit 访问 API,轮询 GET /notifications 获取未读线程,通过 listComments(since=last_read_at) 枚举评论,用正则检测提及,通过 issues.createComment 发布回复。添加轮询循环基础设施和 chat_thread 会话作用域。

对比: PR 方案与此高度一致。PollingChannelBase<Cursor> 抽象简洁。ChannelBase 中的 sendThreadMessage 重构是最具侵入性的部分,但对线程投递是必要的。提及检测使用显式正则(非 strip 前后比较)是正确选择。

发现:

一个测试失败:channel-registry.test.ts 期望的 channel 类型列表未包含 github,需要一行修复。

生产代码无关键阻塞。sendThreadMessage 重构是机械性的——默认回退到 sendMessage 意味着现有 IM 适配器行为不变。

测试结果

单元测试全部通过(35 + 9 + 897 = 941 个)。TypeScript 编译干净。CLI 集成正确注册了 GitHub channel 类型。

一个测试失败:channel-registry.test.ts 需要在期望列表中添加 github

真实场景测试:此 CI 环境无 tmux。完整 E2E 测试需要真实 GitHub token 和运行中的 daemon。PR 作者报告 8/8 E2E 测试通过。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review, but the cross-package scope (759 production lines) needs a maintainer's sign-off, and channel-registry.test.ts needs github added to the expected type list.

This is a well-designed feature PR. The notification-as-wakeup architecture is the right call for GitHub's notification API — treating notifications as thread-level wake-up signals and using last_read_at as the per-thread watermark is exactly how I'd approach it. The predecessor PR #7266 tried to abstract across GitHub/Gitea/GitLab and collapsed under the weight of their different dedup mechanisms; this PR correctly implements GitHub natively.

The PollingChannelBase<Cursor> is clean and reusable — atomic cursor persistence, exponential backoff, abortable sleep. The GithubAdapter at 317 lines is appropriately sized for what it does. Mention detection via explicit regex (not strip-before/after comparison) avoids the false-positive class that was a blocker in #7266.

The sendThreadMessage refactoring in ChannelBase.ts is the highest-risk part — ~40 call sites changed — but it's mechanical and the default fallthrough to sendMessage means existing IM adapters are behaviorally unchanged. All 897 existing channel base tests pass, which is strong evidence the refactoring is safe.

What needs fixing: packages/cli/src/commands/channel/channel-registry.test.ts hardcodes the expected channel type list and fails because github isn't in it. One-line fix.

Why deferring: The PR touches shared channel infrastructure (ChannelBase.ts, SessionRouter.ts, types.ts) across multiple packages with 759 production logic lines. Per our review policy, cross-package changes at this scale need a maintainer's eye before merge — not because anything looks wrong, but because the blast radius of a ChannelBase regression is every channel adapter.

⏸️ Deferring to @wenshao — the code review is clean and the architecture is sound, but the cross-package scope (ChannelBase.ts sendThreadMessage refactoring + new PollingChannelBase + new github package) warrants a maintainer's sign-off. One test failure in channel-registry.test.ts needs a one-line fix (add 'github' to the expected type list).

中文说明

置信度:3/5 — 审查干净,但跨包范围(759 生产行)需要维护者确认,且 channel-registry.test.ts 需要在期望类型列表中添加 github

这是一个设计良好的功能 PR。notification-as-wakeup 架构是 GitHub 通知 API 的正确选择——将通知作为线程级唤醒信号,使用 last_read_at 作为每线程水位线。前序 PR #7266 试图跨 GitHub/Gitea/GitLab 抽象,因三平台去重机制不同而失败;本 PR 正确地原生实现 GitHub。

PollingChannelBase<Cursor> 简洁可复用——原子游标持久化、指数退避、可中止睡眠。GithubAdapter 317 行,大小适当。提及检测使用显式正则,避免了 #7266 的误报问题。

ChannelBase.ts 中的 sendThreadMessage 重构是最高风险部分——约 40 个调用点——但是机械性的,默认回退到 sendMessage 意味着现有 IM 适配器行为不变。全部 897 个现有 channel base 测试通过。

需要修复: channel-registry.test.ts 硬编码了期望的 channel 类型列表,因缺少 github 而失败。一行修复。

为何转交: PR 触及共享 channel 基础设施(ChannelBase.ts、SessionRouter.ts、types.ts),跨多个包,759 生产逻辑行。按审查政策,此规模的跨包变更需要维护者确认——不是因为有问题,而是因为 ChannelBase 回归的影响范围是每个 channel 适配器。

⏸️ 转交 @wenshao — 代码审查干净,架构合理,但跨包范围需要维护者确认。channel-registry.test.ts 有一个测试失败需要一行修复。

Qwen Code · qwen3.8-max-preview

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

@wenshao

wenshao commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Review — GitHub polling channel adapter

Reviewed the full diff (24 files, +2110/−85) against origin/main. The notification-as-wakeup architecture is the right call and a clear improvement over #7266last_read_at as the per-thread watermark, explicit-regex mention detection, and PollingChannelBase (atomic tmp+rename cursor write, abortable sleep, 2s→30s backoff) are all well built. The sendThreadMessage refactor is mechanical and the default fallthrough genuinely leaves IM adapters untouched.

Below are findings the triage bot didn't surface. 2 blockers, 4 worth fixing before merge.


🔴 Blockers

1. sessionScope: 'chat_thread' never reaches the SessionRouter — the headline design property is not in effect by default

GithubAdapter.ts:33 forces the scope via an object spread:

super(name, { ...config, sessionScope: 'chat_thread' }, bridge, options);

That spread builds a new object. The caller's config is unchanged — and every real CLI path derives the router's scope from that object, before createChannel runs:

  • start.ts:212-216 (single-channel): new SessionRouter(bridge, config.cwd, config.sessionScope, …)
  • start.ts:385-387 (multi-channel): router.setChannelScope(name, config.sessionScope) — the loop runs before the createChannel loop
  • daemon-worker.ts:475-477 (daemon): same, before createChannel

All three inject options.router, so ChannelBase.ts:592-594 (this.router = options?.router || new SessionRouter(…, config.sessionScope)) never falls through to the constructor's value. parseChannelConfig defaults sessionScope to 'user' (config-utils.ts:465-466).

Net effect with a config that doesn't spell out sessionScope: routing key is github:<login>:<owner/repo> (SessionRouter.ts:118-120), so every issue and PR in a repo shares one session per commenter. Issue #1's context bleeds into issue #42, and because dispatchMode defaults to 'steer' (ChannelBase.ts:4453), a comment on #42 cancels an in-flight turn on #1. Replies still land on the correct thread (threadId comes from the envelope), so the corruption is invisible from the outside.

This is also a split-brain: this.config.sessionScope inside the channel is 'chat_thread' while the router uses 'user'.

Why testing missed it: the unit tests construct GithubChannel with no injected router (so the constructor path wins), and the E2E settings.json set sessionScope: chat_thread explicitly — as docs/users/features/channels/github.md:173 instructs.

Suggested fix — add defaultSessionScope?: SessionScope to ChannelPlugin (types.ts:309) and apply it in parseChannelConfig where 'user' is defaulted today, so the CLI, the router, and the adapter all agree. Dropping the constructor override afterwards would remove a knob that currently looks load-bearing but isn't.

2. channel-registry.test.ts will fail CI (already flagged by the bot — confirming it's real)

packages/cli/src/commands/channel/channel-registry.test.ts:7-14 asserts the exact ordered type list ['telegram','weixin','dingtalk','wecom','feishu','qq']. Adding github to the registry breaks it. One-line fix; the manageable assertion on line 17 needs no change.


🟠 Should fix before merge

3. markThreadAsRead in a bare finally permanently drops mentions on transient API errorsGithubAdapter.ts:196-198

try {
  const comments = await this.octokit.paginate(this.octokit.rest.issues.listComments, {});
  
} finally {
  await this.markThreadAsRead(notification.id);
}

There is no catch. If listComments (or issues.get inside tryFirstContactBody) throws — secondary rate limit, 5xx, 404 on a repo the token can't read — the thread is still marked read, then the error propagates and aborts pollOnce. Since GET /notifications is unread-only, that thread never comes back: the @mention is silently lost, with no error comment and no retry. Only mark read on the success path, or record failures and retry before marking.

Same shape, milder: an error on notification N aborts the whole batch, so notifications N+1… are skipped for that cycle (they stay unread, so they do come back).

4. The adapter marks every notification read, including ones it has nothing to do withGithubAdapter.ts:100-102, 196-198

Non-issue/PR subjects (releases, discussions, security alerts, CI failures) hit markThreadAsRead and then continue. Point this at a personal account and it will silently clear that account's entire GitHub notification inbox — an irreversible side effect on state the user cares about, and one nothing in the docs warns about. The dedicated-bot-account suggestion in github.md:147 reads as optional; it isn't. Please add an explicit warning, and consider skipping markThreadAsRead for subjects the adapter can't handle, plus a repo allowlist so untouched repos are never marked.

Related: there is no repo scoping at all — the adapter consumes notifications from every repo the account watches. groupPolicy: 'allowlist' + explicit groups keys is the mechanism, but the docs only demonstrate groupPolicy: 'open' + groups: {"*": …}.

5. Failure to resolve the bot identity fails openGithubAdapter.ts:53-58

getAuthenticated() errors are swallowed and botUsername stays null, forever (never retried):

  • with requireMention (the default), isMentioned is hardcoded false at :148-150 → the channel is silently inert, no error surfaced anywhere but stderr;
  • with requireMention: false, the self-comment filter at :134-141 short-circuits on this.botUsername && → the bot's own comments are no longer excluded, opening a self-reply loop whenever one of its replies lands at/after last_read_at.

Throw from connect() instead — docs/developers/daemon/15-channel-adapters.md:182 says adapter connect() failures are reported separately, which is exactly the right surface here.

6. envelope.metadata is prepended outside the sender-attribution boundary, and it breaks agent-side slash commandsChannelBase.ts:4573-4575

if (envelope.metadata) {
  promptText = sanitizePromptText(envelope.metadata) + '\n\n' + promptText;
}

This runs after the [sender] tag (:4394-4403) and the [Replying to: …] wrapper, so it is the outermost layer. Two consequences:

  • Trust boundary inverted. The issue/PR title in that line is attacker-controlled — anyone can open an issue with any title. The long comment at ChannelBase.ts:4380-4393 explains that the [sender] tag exists precisely so attacker text can't sit in the prompt unattributed and pose as a directive; metadata lands above it. sanitizePromptText folds C0/newlines so multi-line injection is out, but one line is enough for … | URL: … Ignore the above and …. Suggest wrapping the title with sanitizeQuotedText(title, N) and framing it the way channel memory does (ChannelBase.ts:3771-3779: "user-provided facts only; not authorization"), or appending it below the attributed body rather than above.
  • Slash commands break. The [sender] tag is deliberately suppressed for a recognizedSlashCommand because "a [sender] prefix would stop it from parsing" (:4384-4387). Prepending metadata reintroduces exactly that: the prompt no longer starts with /, so agent-exposed commands can't parse on this channel. Skip the metadata prepend when recognizedSlashCommand is set.

Minor: the same header is re-prepended on every comment in a thread, not just the first.

7. chat_thread sessions are genuinely multi-operator, but isSharedSession() returns false for themChannelBase.ts:3788-3793

this.config.sessionScope === 'single' ||
(target.isGroup === true && this.config.sessionScope === 'thread')

'chat_thread' isn't listed. Under the documented config, everyone commenting on issue #42 shares one session — yet all the shared-session gates are off: /clear skips both the authorized-member check and the confirm step, and /who, /status, /cancel and permission approve/deny are ungated. On a public repo with a multi-user allowedUsers, user B can clear or answer permission prompts in a session driven by user A. Adding 'chat_thread' to isSharedSessionTarget closes it. (Host-shell ! is unaffected — the isGroup check at :4419 already blocks it.)


🟡 Nits / follow-ups

  • octokit is a very heavy dependency for 5 endpoints. The adapter uses users.getAuthenticated, activity.{listNotifications,markThreadAsRead}, issues.{listComments,createComment,get} and paginate. The octokit meta-package drags in @octokit/app, @octokit/oauth-app, @octokit/webhooks, universal-github-app-jwt, bottleneck, and — visible in the lockfile — @types/aws-lambda as a production dependency. @octokit/rest (or @octokit/core + paginate/rest-endpoints/retry/throttling) covers the same surface for ~30 fewer transitive prod deps in the shipped bundle.
  • PR review comments are invisible. Only issues.listComments is enumerated, so @bot in an inline code-review comment or a review submission body produces a notification, zero "new comments", and a silent markThreadAsRead — never retried. Worth adding to Known Limitations at minimum.
  • First-contact body mention is lost if any unrelated comment exists. GithubAdapter.ts:188 gates on newComments.length === 0, but newComments is filtered only by bot-self/dedup, not by mention. New issue with @bot in the body + one "+1" comment before the first poll → the branch is skipped and the body mention never fires.
  • startPollLoop() isn't re-entrancy guardedPollingChannelBase.ts:62-66 reassigns abortController while an existing loop still holds the old signal, yielding two concurrent loops. if (this.running) return; covers it.
  • onPairingRequired passes threadId: undefinedGithubChannel.sendMessage throws → the pairing code is never delivered (swallowed as "pairing notification failed" at ChannelBase.ts:4139-4148). senderPolicy: 'pairing' is effectively unsupported on this channel; document it or thread the id through.
  • recentlyProcessed.clear() at 10k (GithubAdapter.ts:184) drops all dedup state at once. A FIFO trim of the oldest ~10% avoids the cliff.
  • scripts/clean-package-build-artifacts.js:15-29 wasn't updatedpackages/channels/github is missing, so its stale dist/ survives npm run clean (and therefore npm run preflight). scripts/build.js was updated, so this looks like an oversight. Root vitest.config.ts projects also omits it — lower priority, since npm run test:ci --workspaces picks it up via the package's own test:ci, and wecom/feishu are already absent there.
  • packages/channels/github/src/index.ts doesn't re-export GithubChannel. Every other adapter does (export { QQChannel } from './QQChannel.js';).
  • Tests write to a hardcoded /tmp/test-polling-base with no cleanup (PollingChannelBase.test.ts:20, 33). The loop/stop/backoff cases persist cursors that leak into subsequent runs, and the path ignores the platform tmpdir. mkdtempSync(join(tmpdir(), …)) + cleanup would isolate it.
  • requireMention is two knobs that look like one. The channel-level flag (GithubAdapter.ts:38-40) is separate from GroupGate's per-group requireMention, which defaults to true — so requireMention: false alone changes nothing. github.md:206 documents only the channel-level one.
  • Poll-loop serialization (acknowledged in the PR body) has a second-order effect worth documenting: a slow turn also delays markThreadAsRead for every later notification in the same batch, so the effective cadence is pollInterval + Σ(turn durations).

What's good

Mention detection separating testBotMention from stripBotMention with an explicit boolean is the right lesson from #7266, and the lookahead excluding / correctly avoids matching @org/team. The cursor write is properly atomic. abortableSleep cleans up its listener. sendResponseMessage resolving threadId from activePrompts ?? router.getTarget is a nice touch that makes proactive/loop delivery work on threaded channels. Test coverage of the poll/dedup/first-contact paths is genuinely good — findings 1 and 3 are gaps in what the mocks can observe, not in diligence.

中文版

审查 — GitHub 轮询 channel 适配器

已对照 origin/main 审查完整 diff(24 文件,+2110/−85)。notification-as-wakeup 架构方向正确,明显优于 #7266last_read_at 作为每线程水位线、显式正则的 mention 检测、PollingChannelBase(tmp+rename 原子写游标、可中止 sleep、2s→30s 退避)都实现得不错。sendThreadMessage 重构是机械性的,默认回退确实让 IM 适配器行为不变。

以下是 triage bot 未发现的问题:2 个阻塞项,另有 4 项建议合并前修复。

🔴 阻塞项

1. sessionScope: 'chat_thread' 从未传达到 SessionRouter —— PR 的核心设计属性默认并未生效

GithubAdapter.ts:33 通过对象展开强制作用域:super(name, { ...config, sessionScope: 'chat_thread' }, …)。展开产生的是新对象,调用方的 config 未被修改 —— 而所有真实 CLI 路径都在 createChannel 之前从那个原对象取作用域:start.ts:212-216(单 channel)、start.ts:385-387(多 channel)、daemon-worker.ts:475-477(daemon)。三者都注入 options.router,因此 ChannelBase.ts:592-594 的构造函数兜底永远走不到。parseChannelConfig 默认 sessionScope'user'config-utils.ts:465-466)。

结果:若配置中未显式写 sessionScope,路由键为 github:<login>:<owner/repo>SessionRouter.ts:118-120),即同一仓库下所有 issue 与 PR 对同一评论者共用一个会话。issue #1 的上下文会串到 issue #42;且 dispatchMode 默认 'steer'ChannelBase.ts:4453),在 #42 评论会取消 #1 正在进行的回合。回复仍然投递到正确线程(threadId 取自 envelope),所以污染从外部完全不可见。

同时这是"脑裂":channel 内部 this.config.sessionScope'chat_thread',而 router 用的是 'user'

测试为何未发现:单测构造 GithubChannel 时未注入 router(走构造函数分支);E2E 的 settings.jsondocs/users/features/channels/github.md:173 的指引显式设置了 sessionScope: chat_thread

建议修法:在 ChannelPlugintypes.ts:309)增加 defaultSessionScope?: SessionScope,在 parseChannelConfig 现在默认 'user' 的位置应用它,使 CLI、router、适配器三者一致;随后可移除构造函数里的覆盖(它现在看起来关键、实则无效)。

2. channel-registry.test.ts 会导致 CI 失败(bot 已指出,此处确认属实)

packages/cli/src/commands/channel/channel-registry.test.ts:7-14 断言了精确有序的类型列表 ['telegram','weixin','dingtalk','wecom','feishu','qq']。注册 github 后该断言失败。一行修复即可;第 17 行的 manageable 断言无需改动。

🟠 建议合并前修复

3. finally 中的 markThreadAsRead 会在瞬时 API 错误时永久丢失 mention —— GithubAdapter.ts:196-198

try { listComments… } finally { markThreadAsRead } 没有 catch。若 listComments(或 tryFirstContactBody 中的 issues.get)抛错(二级限流、5xx、token 无权限的 404),线程仍会被标记已读,随后错误上抛中断 pollOnce。由于 GET /notifications 只返回未读,该线程再也不会出现:mention 静默丢失,既无错误评论也不重试。应仅在成功路径标记已读,或记录失败并重试后再标记。

同形但较轻:第 N 条通知出错会中断整批,N+1 及之后本轮跳过(它们仍未读,下轮会回来)。

4. 适配器会把每一条通知标记已读,包括与它无关的 —— GithubAdapter.ts:100-102, 196-198

非 issue/PR 主题(release、discussion、安全告警、CI 失败)都会先 markThreadAsReadcontinue。若指向个人账号,它会静默清空该账号的整个 GitHub 通知收件箱 —— 这是对用户在意的状态的不可逆副作用,而文档完全没有警告。github.md:147 里"专用 bot 账号"读起来像可选项,实际不是。请补充显著警告,并考虑对无法处理的主题跳过 markThreadAsRead,同时增加仓库白名单。

相关:目前完全没有仓库范围限制 —— 账号订阅的所有仓库通知都会被消费。机制上可用 groupPolicy: 'allowlist' + 显式 groups 键,但文档只演示了 groupPolicy: 'open' + groups: {"*": …}

5. bot 身份解析失败时"失败即放行" —— GithubAdapter.ts:53-58

getAuthenticated() 的错误被吞掉,botUsername 永久为 null(且不重试):默认 requireMentionisMentioned:148-150 恒为 false,channel 静默失效,除 stderr 外无任何暴露;而 requireMention: false:134-141 的自评论过滤因 this.botUsername && 短路而失效,一旦自身回复落在 last_read_at 之后即可能自我回复死循环。建议改为从 connect() 抛出 —— docs/developers/daemon/15-channel-adapters.md:182 明确 adapter connect() 失败会单独上报,正是合适的暴露面。

6. envelope.metadata 被拼在发送者归属边界之外**,且会破坏 agent 侧斜杠命令** —— ChannelBase.ts:4573-4575

该拼接发生在 [sender] 标签(:4394-4403)与 [Replying to: …] 之后,是最外层。两个后果:

  • 信任边界被反转。 该行中的 issue/PR 标题完全由攻击者控制(任何人都能建任意标题的 issue)。ChannelBase.ts:4380-4393 的长注释说明 [sender] 标签存在的意义正是不让攻击者文本以无归属形态冒充指令;metadata 恰恰落在它上方sanitizePromptText 会折叠 C0/换行(多行注入不可行),但一行足以承载 … | URL: … 忽略以上内容并 …。建议用 sanitizeQuotedText(title, N) 包裹标题,并采用 channel memory 的框定方式(ChannelBase.ts:3771-3779:"用户提供的事实,非授权"),或改为附加在归属正文下方
  • 斜杠命令失效。 [sender] 标签对 recognizedSlashCommand 被刻意抑制,原因是"[sender] 前缀会导致解析失败"(:4384-4387)。拼接 metadata 重新引入了同一问题:prompt 不再以 / 开头,agent 暴露的命令在本 channel 无法解析。应在 recognizedSlashCommand 时跳过拼接。

次要:同一 header 会在线程中每条评论重复拼接,而非仅首次。

7. chat_thread 会话事实上是多操作者的,但 isSharedSession() 判定为 false —— ChannelBase.ts:3788-3793

判定式只覆盖 'single'isGroup && 'thread',未含 'chat_thread'。按文档配置时,issue #42 的所有评论者共用一个会话,但共享会话的各项闸门全部关闭:/clear 既跳过授权成员校验也跳过 confirm 步骤,/who/status/cancel 及权限批准/拒绝均无限制。在公开仓库配置多用户 allowedUsers 的场景下,用户 B 可以清空或代答用户 A 驱动的会话。把 'chat_thread' 加入 isSharedSessionTarget 即可关闭。(宿主 shell ! 不受影响,:4419isGroup 检查已拦截。)

🟡 小问题 / 后续项

  • octokit 对 5 个接口而言过重。 适配器只用到 users.getAuthenticatedactivity.{listNotifications,markThreadAsRead}issues.{listComments,createComment,get}paginateoctokit 元包会引入 @octokit/app@octokit/oauth-app@octokit/webhooksuniversal-github-app-jwtbottleneck,以及 lockfile 中可见的 @types/aws-lambda 作为生产依赖。改用 @octokit/rest(或 @octokit/core + paginate/rest-endpoints/retry/throttling)可覆盖同样能力,产物少约 30 个传递生产依赖。
  • PR review 评论不可见。 仅枚举 issues.listComments,因此代码行内评论或 review 提交正文中的 @bot 会产生通知、零"新评论"、然后被静默 markThreadAsRead,且不再重试。至少应写入 Known Limitations。
  • 首次接触的正文 mention 会因任意无关评论而丢失。 GithubAdapter.ts:188newComments.length === 0 为门槛,而 newComments 只按 bot 自身/去重过滤,不按 mention 过滤。新 issue 正文含 @bot + 首轮轮询前出现一条 "+1" 评论 → 该分支被跳过,正文 mention 永不触发。
  • startPollLoop() 无重入保护 —— PollingChannelBase.ts:62-66 在旧循环仍持有旧 signal 时重建 abortController,会产生两个并发循环。加 if (this.running) return; 即可。
  • onPairingRequired 传入 threadId: undefinedGithubChannel.sendMessage 抛错 → 配对码永不送达(在 ChannelBase.ts:4139-4148 被吞为 "pairing notification failed")。senderPolicy: 'pairing' 在本 channel 实际不可用,请文档说明或把线程 id 传下去。
  • recentlyProcessed.clear() 在 1 万条时GithubAdapter.ts:184)一次性丢弃全部去重状态。改为 FIFO 淘汰最旧约 10% 可避免断崖。
  • scripts/clean-package-build-artifacts.js:15-29 未更新 —— 缺少 packages/channels/github,其陈旧 dist/ 会在 npm run clean(进而 npm run preflight)后残留。scripts/build.js 已更新,因此这像是遗漏。根 vitest.config.tsprojects 同样缺失 —— 优先级较低,因为 npm run test:ci --workspaces 会通过包自身的 test:ci 覆盖,且 wecom/feishu 本就缺失。
  • packages/channels/github/src/index.ts 未再导出 GithubChannel 其他适配器都有(export { QQChannel } from './QQChannel.js';)。
  • 测试写入硬编码的 /tmp/test-polling-base 且无清理PollingChannelBase.test.ts:20, 33)。loop/stop/backoff 用例持久化的游标会泄漏到后续运行,且该路径忽略平台 tmpdir。建议 mkdtempSync(join(tmpdir(), …)) + 清理。
  • requireMention 是看起来像一个的两个开关。 channel 级标志(GithubAdapter.ts:38-40)与 GroupGate 的分组级 requireMention(默认 true)相互独立 —— 仅设 requireMention: false 不产生任何效果。github.md:206 只记录了 channel 级的那个。
  • 轮询串行化(PR 正文已承认)还有一个值得写进文档的二阶效应:慢回合同时会推迟同批次中后续每一条通知的 markThreadAsRead,实际节奏为 pollInterval + Σ(回合耗时)

做得好的地方

testBotMentionstripBotMention 拆开、以显式布尔返回,是从 #7266 吸取的正确教训;lookahead 排除 / 也正确避免了匹配 @org/team。游标写入是真正原子的。abortableSleep 清理了监听器。sendResponseMessageactivePrompts ?? router.getTarget 解析 threadId 是很好的一手,让 proactive/loop 投递在线程化 channel 上可用。轮询/去重/首次接触路径的测试覆盖确实扎实 —— 问题 1 与 3 是 mock 能观察到的范围的盲区,而非用心程度的问题。

threadId,
messageId: String(comment.id),
text,
isGroup: 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.

[Critical] isSharedSessionTarget in ChannelBase.ts:3907 doesn't recognize 'chat_thread' — it only checks 'single' and 'thread'. Since the GitHub adapter sets isGroup: true on every envelope (this line) and forces sessionScope: 'chat_thread', shared-session authorization gates are silently bypassed: /clear confirmation, /who//status auth checks, and permission response gating all evaluate isSharedSessionTarget as false, treating every GitHub conversation as a single-user session. With multiple users in the allowlist on the same issue, one user can /clear without confirmation to wipe another's context.

Suggested change
isGroup: true,
isGroup: true, // NOTE: isSharedSessionTarget at ChannelBase.ts:3907 must be updated to include 'chat_thread'

— qwen3.7-max via Qwen Code /review

bridge: ChannelAgentBridge,
options?: ChannelBaseOptions,
) {
super(name, { ...config, sessionScope: 'chat_thread' }, bridge, options);

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 constructor overrides sessionScope to 'chat_thread' on a new config object ({ ...config, sessionScope: 'chat_thread' }), but start.ts:386 and daemon-worker.ts:476 call router.setChannelScope(name, config.sessionScope) using the original user config — before the adapter is created. If a user omits sessionScope from their config (relying on the constructor to handle it), the router uses 'user' scope. Routing key becomes channel:senderId:chatId, so all issues/PRs from the same user on the same repo share a session — conversations bleed across threads.

Fix: have the adapter call this.router?.setChannelScope(this.name, 'chat_thread') in connect(), or document sessionScope: 'chat_thread' as a required field.

— qwen3.7-max via Qwen Code /review

{ name: 'wecom', promise: import('@qwen-code/channel-wecom') },
{ name: 'feishu', promise: import('@qwen-code/channel-feishu') },
{ name: 'qqbot', promise: import('@qwen-code/channel-qqbot') },
{ name: 'github', promise: import('@qwen-code/channel-github') },

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] channel-registry.test.ts asserts an exact list of 6 channel types (['telegram', 'weixin', 'dingtalk', 'wecom', 'feishu', 'qq']) but this PR adds github to the registry, making it 7. The test suite fails — CI confirms Test (ubuntu-latest, Node 22.x) is red.

Suggested change
{ name: 'github', promise: import('@qwen-code/channel-github') },
{ name: 'github', promise: import('@qwen-code/channel-github') },
// Also update channel-registry.test.ts to include 'github' in the expected list

— qwen3.7-max via Qwen Code /review

Comment on lines +99 to +101
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;

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] loadCursorFromDisk doesn't exclude arrays from its parsed-JSON validation. typeof [] === 'object' and [] !== null, so a corrupted cursor file containing a JSON array passes the guard. The subclass reads undefined from array properties, producing Invalid Date for API parameters.

Suggested change
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;

— qwen3.7-max via Qwen Code /review

Comment on lines +111 to +114
case 'chat_thread':
return threadId
? `${channelName}:${chatId}:${threadId}`
: `${channelName}:${chatId}`;

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 new chat_thread session scope has no test in SessionRouter.test.ts, which thoroughly tests user, thread, and single scopes. A future refactor could silently break the routing key format (channelName:chatId:threadId), causing cross-repo session collision.

— qwen3.7-max via Qwen Code /review

Comment on lines 1894 to 1903
protected async sendResponseMessage(
chatId: string,
text: string,
_sessionId: string,
sessionId: string,
): Promise<void> {
await this.sendMessage(chatId, text);
const active = this.activePrompts.get(sessionId);
const target = this.router.getTarget(sessionId);
const threadId = active?.threadId ?? target?.threadId;
await this.sendThreadMessage(chatId, threadId, text);
}

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] sendResponseMessage now resolves threadId from activePrompts and router.getTarget, but no test exercises this chain. If both sources lack threadId for a GitHub adapter, sendThreadMessage(chatId, undefined, text) falls through to sendMessage which throws — response delivery fails silently.

— qwen3.7-max via Qwen Code /review

Comment on lines +180 to +185
this.recentlyProcessed.add(String(comment.id));
if (
this.recentlyProcessed.size > GithubChannel.MAX_RECENTLY_PROCESSED
) {
this.recentlyProcessed.clear();
}

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] When recentlyProcessed exceeds 10,000 entries, .clear() drops all dedup state at once — not just the oldest entries. On a busy repository with any thread whose markThreadAsRead failed, this creates a burst of duplicate bot responses. Consider partial eviction (keep the newest half) instead of a full clear.

— qwen3.7-max via Qwen Code /review

@doudouOUC doudouOUC 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.

Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

[Critical] sessionScope: 'chat_thread' constructor override is inert — GithubAdapter.ts:33 spreads a new object but all real CLI paths (start.ts:212-216, start.ts:385-387, daemon-worker.ts:475-477) derive the router's scope from the caller's config before createChannel runs. Without explicit sessionScope in user config, the router uses 'user' while the channel thinks 'chat_thread'. Every issue and PR in a repo shares one session per commenter; cross-issue context bleeds and dispatchMode 'steer' cancels in-flight turns on other issues.

[Critical] channel-registry.test.ts will fail CI — asserts exact ordered list ['telegram','weixin','dingtalk','wecom','feishu','qq'] but github was added to the registry. One-line fix needed.

[Critical] isSharedSession() returns false for 'chat_thread' — ChannelBase.ts:3907-3912 only checks 'single' and (isGroup && 'thread'). The new 'chat_thread' scope creates genuinely multi-operator sessions (all commenters on an issue share one session), but shared-session gates (/clear confirm, /who authorization, /cancel, permission approve/deny) are off. Any authorized commenter can clear or cancel another user's session.

[Critical] Bot identity failure fails open — GithubAdapter.ts:53-58 swallows getAuthenticated() errors, botUsername stays null forever. With requireMention (default), isMentioned is hardcoded false → channel is silently inert. With requireMention: false, self-comment filter short-circuits → self-reply loop.

— qwen3.7-max via Qwen Code /review

Comment on lines +196 to +198
} finally {
await this.markThreadAsRead(notification.id);
}

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] markThreadAsRead in a bare finally permanently drops mentions on transient API errors — Failure scenario: listComments (or issues.get inside tryFirstContactBody) throws on secondary rate limit, 5xx, or 404. The thread is still marked read, the error propagates and aborts pollOnce. Since GET /notifications is unread-only, that thread never comes back — the @mention is silently lost with no error comment and no retry.

Suggested change
} finally {
await this.markThreadAsRead(notification.id);
}
} catch (error) {
await this.postErrorComment(chatId, threadId, error);
}
await this.markThreadAsRead(notification.id);
}

— qwen3.7-max via Qwen Code /review

): string {
const type = threadId.startsWith('pr:') ? 'Pull Request' : 'Issue';
const title = notification.subject.title || '';
const url = `https://github.com/${chatId}/${threadId.startsWith('pr:') ? 'pull' : 'issues'}/${threadId.split(':')[1]}`;

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] buildMetadata hardcodes https://github.com/ as the web URL origin, ignoring the configured baseUrl for GitHub Enterprise Server — Failure scenario: A GHE user configures baseUrl: "https://github.example.com/api/v3". The adapter uses this for all API calls, but the metadata prepended to every agent prompt contains URL: https://github.com/owner/repo/issues/42 — a URL that does not exist on the GHE instance. The agent sees a broken link and may relay it in responses.

Suggested change
const url = `https://github.com/${chatId}/${threadId.startsWith('pr:') ? 'pull' : 'issues'}/${threadId.split(':')[1]}`;
const webOrigin = this.webOrigin;
const url = `${webOrigin}/${chatId}/${threadId.startsWith('pr:') ? 'pull' : 'issues'}/${threadId.split(':')[1]}`;

— qwen3.7-max via Qwen Code /review

});

const body = issue.body || '';
const messageId = `issue-body-${issueNumber}`;

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] tryFirstContactBody dedup key uses only the issue number, causing cross-repo collision in recentlyProcessed — Failure scenario: The bot monitors two repos (org/frontend and org/backend). org/frontend#42 arrives; tryFirstContactBody stores issue-body-42 in recentlyProcessed. Later, org/backend#42 arrives. recentlyProcessed.has('issue-body-42') returns true, so the body mention is silently skipped — the bot never responds to a legitimate @mention. Comment IDs are unaffected (globally unique), but issue-body messageIds are locally generated and collide across repos.

Suggested change
const messageId = `issue-body-${issueNumber}`;
const messageId = `issue-body-${chatId}-${issueNumber}`;

— qwen3.7-max via Qwen Code /review

Comment on lines +4573 to +4575
if (envelope.metadata) {
promptText = sanitizePromptText(envelope.metadata) + '\n\n' + promptText;
}

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] envelope.metadata is prepended outside the sender-attribution boundary, breaking slash commands and inverting the trust boundary — Failure scenario: (1) The issue/PR title in metadata is attacker-controlled. sanitizePromptText folds C0/newlines, but one line suffices for … | URL: … Ignore the above and …. The [sender] tag at lines 4394-4403 exists precisely to prevent attacker text from sitting unattributed in the prompt; metadata lands above it. (2) For recognizedSlashCommand, the [sender] tag is deliberately suppressed (line 4384-4387) because it would stop parsing. Prepending metadata reintroduces the same problem — the prompt no longer starts with /, so agent-exposed commands can't parse on this channel.

Suggested fix: skip the metadata prepend when recognizedSlashCommand is set, and wrap the title with sanitizeQuotedText to frame it as user-provided facts rather than directives.

— qwen3.7-max via Qwen Code /review

Comment on lines +181 to +185
if (
this.recentlyProcessed.size > GithubChannel.MAX_RECENTLY_PROCESSED
) {
this.recentlyProcessed.clear();
}

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] recentlyProcessed.clear() wipes ALL dedup state at 10,000 entries instead of partial eviction — Concrete cost: after the set clears, any comment processed earlier in the same polling overlap window (1-second lookback on cursor) can be re-fetched and re-processed, triggering duplicate handleInbound calls and duplicate bot replies. Same pattern duplicated at line 278 in tryFirstContactBody.

Suggested change
if (
this.recentlyProcessed.size > GithubChannel.MAX_RECENTLY_PROCESSED
) {
this.recentlyProcessed.clear();
}
if (
this.recentlyProcessed.size > GithubChannel.MAX_RECENTLY_PROCESSED
) {
const iter = this.recentlyProcessed.values();
for (let i = 0; i < GithubChannel.MAX_RECENTLY_PROCESSED / 2; i++) {
this.recentlyProcessed.delete(iter.next().value!);
}
}

— qwen3.7-max via Qwen Code /review

Comment on lines +111 to +114
case 'chat_thread':
return threadId
? `${channelName}:${chatId}:${threadId}`
: `${channelName}:${chatId}`;

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] New chat_thread session scope has no routing-key tests in SessionRouter.test.ts — Concrete cost: a future refactor could silently collapse chat_thread into thread-style routing (channel:threadId vs channel:chatId:threadId), causing session collisions for the GitHub adapter. No test would detect the regression.

— qwen3.7-max via Qwen Code /review

Comment on lines +80 to +82
const match = threadId.match(/^(?:issue|pr):(\d+)$/);
if (!match) {
process.stderr.write(

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] sendThreadMessage silently drops messages when threadId doesn't match issue:N/pr:N format — Concrete cost: the caller receives no signal that the outbound message was dropped. The only trace is a stderr line easily missed in daemon logs. Throwing (like sendMessage does) would let the base class error-handling path surface the failure.

Suggested change
const match = threadId.match(/^(?:issue|pr):(\d+)$/);
if (!match) {
process.stderr.write(
if (!match) {
throw new Error(
`[Channel:${this.name}] invalid threadId format: ${threadId}`,
);
}

— qwen3.7-max via Qwen Code /review

Comment on lines +77 to +79
if (!threadId) {
return super.sendThreadMessage(chatId, threadId, text);
}

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] senderPolicy: 'pairing' is effectively dead on this channel — Concrete cost: onPairingRequired passes threadId: undefinedsendThreadMessage delegates to base → sendMessage throws → the pairing code is never delivered (swallowed as stderr). The user never receives the pairing code and gets no feedback. Override onPairingRequired in GithubChannel to deliver the code to the originating thread.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao 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.

Not reviewed: build-and-test — dependency installation failed with EPERM connecting to npm proxy 127.0.0.1:10809, so no builds or tests ran.

Not reviewed: test-efficacy — all three probes were inconclusive because sandbox policy denied temporary worktree creation.

Not reviewed: reverse-audit — stopped at the five-round hard cap before two consecutive dry rounds.

— Codex GPT-5 via Qwen Code /review

bridge: ChannelAgentBridge,
options?: ChannelBaseOptions,
) {
super(name, { ...config, sessionScope: 'chat_thread' }, bridge, options);

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] Forcing chat_thread here is too late for the shared SessionRouter, which the CLI/daemon already built from the original config. With the default user scope, one author's comments on different issues share context, and daemon routes are restored under the wrong key. Normalize and validate GitHub's fixed scope before constructing or restoring the shared router.

— Codex GPT-5 via Qwen Code /review

switch (scope) {
case 'thread':
return `${channelName}:${threadId || chatId}`;
case 'chat_thread':

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] chat_thread deliberately shares a session across senders, but ChannelBase.isSharedSessionTarget does not classify it as shared. Under senderPolicy: open, an unallowlisted commenter can run /clear without the shared-session authorization or confirmation and delete everyone’s issue session. Add chat_thread to every shared-session safety check and cover the command/permission paths.

— Codex GPT-5 via Qwen Code /review

Comment on lines +5087 to 5090
await this.sendThreadMessage(
chatId,
undefined,
`Your pairing code is: ${code}\n\nAsk the bot operator to approve you with:\n qwen channel pairing approve ${this.name} ${code}`,

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] Pairing drops the inbound threadId. On GitHub, an unknown sender's pairing reply falls through to sendMessage, which throws because delivery requires an issue/PR thread, so the user never receives the code. Carry the originating {chatId, threadId} (or envelope) through onPairingRequired and reply to that thread.

— Codex GPT-5 via Qwen Code /review

}

private get requireMention(): boolean {
return (this.config as GithubConfig).requireMention !== 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.

[Critical] requireMention: false only disables this adapter-level filter; every GitHub envelope is still a group message and GroupGate independently defaults wildcard groups to requireMention: true. Ordinary comments are therefore still dropped and marked read. Use the shared group mention setting as the single source of truth, or merge this option into it before constructing the gate.

— Codex GPT-5 via Qwen Code /review

}
}

if (envelope.metadata) {

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] In collect mode, buffered text already includes metadata, but the synthetic envelope retains the last message's metadata; re-entry prepends it again. This duplicates the last issue/PR context and wastes prompt space. Clear metadata on the synthetic envelope alongside attachments and references.

— Codex GPT-5 via Qwen Code /review

}

protected createInitialCursor(): GithubCursor {
return { lastProcessedAt: new Date().toISOString() };

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] A fresh channel initializes its global cursor to now and immediately queries with since = now - 1s, permanently excluding older unread notifications unless the thread changes again. On the first poll, omit since (the endpoint already defaults to unread) or start from an old baseline, then persist the returned high-water mark.

— Codex GPT-5 via Qwen Code /review


const { chatId, threadId, issueNumber } = extracted;
const lastReadAt = notification.last_read_at;
const windowSince = lastReadAt || since;

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] last_read_at is passed unchanged to the exclusive issue-comment since filter. With second-resolution timestamps, a new mention in the same second can compare equal, be omitted, and then be marked read. Subtract a one-second overlap from the per-thread watermark and rely on comment-ID deduplication.

— Codex GPT-5 via Qwen Code /review

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { PollingChannelBase } from './PollingChannelBase.js';
import type { ChannelAgentBridge, ChannelConfig } from './types.js';

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] This test imports a non-exported ChannelAgentBridge and accesses protected cursor at five call sites. Vitest transpiles it, but strict semantic TypeScript checking reports TS2459 plus five TS2445 errors. Import the bridge from ChannelAgentBridge.js and expose typed test-only cursor accessors on the subclass.

— Codex GPT-5 via Qwen Code /review


async connect(): Promise<void> {
const cfg = this.config as GithubConfig;
this.octokit = new Octokit({

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 daemon worker resolves a proxy URL and passes it in ChannelBaseOptions, but this Octokit instance ignores this.proxy; unlike standalone startup, the worker installs no global dispatcher. In proxy-only networks the channel appears connected but identity/polls fail forever. Configure a proxy-aware request.fetch/dispatcher from this.proxy, or install the shared dispatcher in daemon workers.

— Codex GPT-5 via Qwen Code /review


beforeEach(() => {
vi.clearAllMocks();
channel = new TestableGithubChannel(

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] These tests construct test-github without isolating QWEN_HOME, so the poller loads and saves the developer's real ~/.qwen/channels/test-github-poll-cursor.json. Tests can corrupt a real channel cursor and inherit stale state across runs. Redirect QWEN_HOME to a per-suite temporary directory (or mock getGlobalQwenDir) and clean it afterward.

— Codex GPT-5 via Qwen Code /review

@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.

Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

[Critical] isSharedSessionTarget at ChannelBase.ts:3989 does not recognize 'chat_thread' scope — GitHub channel sessions (isGroup: true, sessionScope: 'chat_thread') bypass shared-session authorization gates (/clear confirmation, /who/status auth, permission gating). Cannot anchor: the method is pre-existing code not in any diff hunk.

— qwen3.7-max via Qwen Code /review

Comment on lines +82 to +85
process.stderr.write(
`[Channel:${this.name}] invalid threadId format: ${threadId}\n`,
);
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.

[Suggestion] sendThreadMessage silently returns when threadId doesn't match issue:N or pr:N format, giving the caller no failure signal. The caller receives a resolved promise as if the comment was posted.

Concrete cost: If a future code change produces a different threadId format, the bot's reply is dropped silently — no retry, no error propagation. This is inconsistent with the !threadId path above (line 78), which delegates to super.sendThreadMessage()sendMessage() → throws an explicit error.

Suggested change
process.stderr.write(
`[Channel:${this.name}] invalid threadId format: ${threadId}\n`,
);
return;
throw new Error(
`[Channel:${this.name}] invalid threadId format: ${threadId}`,
);

— qwen3.7-max via Qwen Code /review

Comment on lines +163 to +173
it('continues when bot identity fails', async () => {
mockOctokit.rest.users.getAuthenticated.mockRejectedValue(
new Error('bad token'),
);
mockOctokit.paginate.mockResolvedValue([]);
await channel.connect();
await (
channel as unknown as { pollOnce: () => Promise<void> }
).pollOnce();
channel.disconnect();
});

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] This test has zero expect() assertions — it only verifies no crash. When botUsername is null (identity failure) and requireMention is true (default), isMentioned is always false and every comment is silently skipped. The bot becomes non-functional until restart, but the test never detects this.

Concrete cost: A transient GitHub API failure (rate limit, network timeout) during connect() sets botUsername = null. Since connect() is called once with no retry, every subsequent poll silently drops all comments. With requireMention: false, the self-comment filter is also disabled, opening a self-reply loop.

Suggested fix: Add assertions verifying comment processing behavior after identity failure, or add retry logic for getAuthenticated() in connect().

— qwen3.7-max via Qwen Code /review

Comment on lines +166 to +168
isGroup: true,
isMentioned,
isReplyToBot: 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] isReplyToBot is hardcoded to false, so users who reply directly to a bot comment without re-@mentioning won't trigger a response. GitHub's comment API exposes in_reply_to_id on comment objects, making reply detection feasible.

Concrete cost: A user replies to the bot's comment on an issue (a natural GitHub interaction pattern). The adapter's requireMention filter skips the comment because it has no @bot mention. Even with GroupGate, isReplyToBot being always false means the reply-to-bot bypass never fires. The user gets no feedback.

Suggested fix: Check whether comment.in_reply_to_id references a comment authored by this.botUsername and set isReplyToBot accordingly.

— qwen3.7-max via Qwen Code /review

Layer 1: global cursor filters notifications by updated_at (sorted
ascending, old first). Layer 2: server-side last_read_at filters
comments by created_at (sorted ascending).

- Delete recentlyProcessed Set (no longer needed)
- Sort notifications by updated_at ascending before processing
- Sort comments by created_at ascending before processing
- Pass latest comment created_at to markThreadAsRead as last_read_at
Blockers:
- sessionScope: add defaultSessionScope to ChannelPlugin, apply in
  parseChannelConfig so router and adapter agree on 'chat_thread'
- channel-registry.test.ts: add 'github' to expected type list

Should-fix:
- Replace per-thread markThreadAsRead (PATCH) with bulk
  markNotificationsAsRead (PUT /notifications + last_read_at).
  API errors stop the batch without marking failed notifications
  read; handleInbound errors still advance (error comment posted).
- connect() throws on bot identity failure instead of failing open
- metadata appended after promptText (inside sender attribution)
- isSharedSessionTarget includes 'chat_thread' scope

Nits:
- startPollLoop re-entrancy guard
- clean-package-build-artifacts.js includes github
- index.ts re-exports GithubChannel
…t_read_at

Prevents re-fetching the same notifications in the next poll cycle.
The bulk PUT /notifications marks all fetched notifications as read
up to the max updated_at, regardless of per-notification success.
- QwenLM#12: loadCursorFromDisk rejects arrays
- QwenLM#13: pollInterval validates positive finite number
- QwenLM#19: first-contact gate uses dispatchedMention flag (not newComments.length)
- QwenLM#25: stripBotMention no longer trims (preserves indentation)
- QwenLM#27: remove adapter-level requireMention, unify on GroupGate
- QwenLM#31: add chat_thread SessionRouter routing key tests
- QwenLM#33: clear metadata on collect-mode synthetic envelope
- QwenLM#35: fix PollingChannelBase.test import path
- QwenLM#36: add @octokit/rest to 15-channel-adapters.md dependencies
- First start skips existing unread notifications (cursor = now)
- Requires classic PAT (fine-grained PATs lack notifications API)
- PR review comments not enumerated (issue comments only)

@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.

Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

中文说明

未审查:反向审计——有审计 agent 运行并打开了自己的 brief,但没有 agent 是用 CLI 构建的 prompt 启动的——启动 prompt 是手写的,agent 实际被要求做的并不是本 skill 所认证的内容。

— qwen3.7-max via Qwen Code /review

Comment on lines +99 to +101
this.config.allowedUsers = resolved;
this.gate.replaceAllowedUsers(resolved);
this.startPollLoop();

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] connect() destructively overwrites config.allowedUsers with resolved numeric IDs, making the method non-idempotent — Failure scenario: the daemon's reconnect paths (start.ts:293–294, start.ts:489–490) call disconnect() then connect() on the same instance. On the second connect(), this.config.allowedUsers is now ['10001', '67890'] (numeric IDs from the first call). getByUsername({username: '10001'}) returns 404, the channel fails to reconnect.

Suggested change
this.config.allowedUsers = resolved;
this.gate.replaceAllowedUsers(resolved);
this.startPollLoop();
// Do NOT mutate this.config.allowedUsers — preserve original logins for reconnect
this.gate.replaceAllowedUsers(resolved);
this.startPollLoop();
中文说明

connect()config.allowedUsers 覆盖为已解析的数字 ID,导致方法不可重入——daemon 的重连路径在同一个实例上调用 disconnect()connect()。第二次 connect() 时,this.config.allowedUsers 已是数字 ID,getByUsername 返回 404,channel 重连失败。

— qwen3.7-max via Qwen Code /review


Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, QQ, DingTalk, WeCom, or Feishu, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI.

Code-hosting platforms (starting with [GitHub](./github)) are also supported via polling adapters — the agent monitors notifications and responds to @mentions on issues and pull requests.

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 new paragraph introduces GitHub as a built-in channel type, but three existing enumerations in this file are not updated — Concrete cost: the type option row, the Quick Start guide list, and the slash-commands compatibility note all omit github. A user consulting the options table would not know "github" is a built-in type.

Suggested fix: add github to all three enumerations.

中文说明

新段落引入了 GitHub 作为内置 channel 类型,但文件中三处现有枚举未更新。查阅选项表的用户不知道 "github" 是内置类型。

— qwen3.7-max via Qwen Code /review

Comment on lines +389 to +395
if (headers['retry-after']) {
cooldown = Number(headers['retry-after']) * 1000;
} else if (
(e.status === 403 || e.status === 429) &&
Number(headers['x-ratelimit-remaining']) === 0 &&
Number(headers['x-ratelimit-reset']) > 0
) {

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] githubApi retry three-way backoff has no unit test — Concrete cost: the retry wrapper (~40 lines) handles retry-after header, x-ratelimit-reset cooldown, and exponential backoff — none exercised by any test. An incorrect cooldown calculation could cause spin-retry or extended blocking of the poll loop.

Suggested fix: add tests mocking Octokit to reject with rate-limit headers and verify cooldown computation and retry.

中文说明

githubApi 重试三路退避没有单元测试。错误的冷却计算可能导致旋转重试或长时间阻塞轮询循环。

— qwen3.7-max via Qwen Code /review

Comment on lines +66 to +68
this.webOrigin = baseUrl
.replace(/\/api\/v3\/?$/, '')
.replace(/^https:\/\/api\.github\.com/, 'https://github.com');

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] webOrigin derivation from baseUrl for GHE is not tested — Concrete cost: the regex logic strips /api/v3 and converts api.github.comgithub.com, but an atypical baseUrl could produce a malformed metadata URL used in every envelope.

Suggested fix: add a test with baseUrl: 'https://github.example.com/api/v3' and verify the metadata URL.

中文说明

GHE 的 baseUrlwebOrigin 的推导没有测试——非典型的 baseUrl 可能产生错误的 metadata URL。

— qwen3.7-max via Qwen Code /review

…r NaN guard, retry/sendThreadMessage tests, docs fixes
@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Local real-build verification — head 8a93314

I built this PR from source and exercised it end-to-end locally, as a merge reference. This is a verification round, not another code review — it follows up my round-8 review (d85ed6d) and covers the one commit added since (8a93314, "address review round 4").

Verdict: I'm satisfied this is mergeable. Every blocker from rounds 1–7 is fixed, and I confirmed the fixes behaviourally rather than by reading the diff. The three findings I raised in round 8 all still reproduce, but none of them corrupts state or loses an answer on the healthy path, so I don't consider them merge blockers — details and one-line fixes below.

How this was verified

Because a real GitHub bot account can't be scripted deterministically (and the notifications API is unread-only and asynchronous), I stood up a local mock of the GitHub REST endpoints and pointed the adapter at it through the PR's own baseUrl config. What that means concretely:

Layer Real or substituted
Adapter under test Real — the compiled packages/channels/github/dist/GithubAdapter.js from this PR's build
HTTP client Real @octokit/rest, real sockets, real pagination, real retry/backoff
Poll loop, cursor file Real PollingChannelBase, real atomic-rename cursor file on disk
Gating, routing, reply path Real ChannelBase.handleInbound → SenderGate / GroupGate / mention gate → SessionRoutersendThreadMessage
CLI entrypoint (Evidence 1) Real node packages/cli/dist/index.js channel start, real settings.json, real plugin registry, real agent subprocess (AcpBridge spawns --acp)
GitHub itself Substituted — a mock that deliberately reproduces the two semantics this design hinges on (see below)
Model provider Substituted — a local OpenAI-compatible endpoint returning a deterministic token

The mock is not a stub that says yes. It models the behaviour that makes this problem hard:

  1. GET /notifications is unread-only — a thread disappears once marked read, so a lost mention is genuinely unrecoverable.
  2. PUT /notifications is asynchronous (202) and account-wide — every notification older than the cutoff is marked read, and the mark can be applied after the bot's own reply has bumped the thread past the cutoff. This is exactly the race the PR description says the cursor window exists to survive, so I tested that claim directly (group D) instead of taking it on trust.

Build and gates

Gate Result
npm ci (runs prepare → full build) + npm run bundle ✅ clean, from scratch in an isolated worktree
npm run lint:ci (--max-warnings 0) ✅ 0 warnings
npm run typecheck (all workspaces) ✅ clean
prettier --check on the 32 changed files ✅ clean
Unit tests 1,968 passed, 0 failed

Test breakdown: channel-base 915 · channel-github 60 · cli/commands/channel 270 · and every other channel adapter — dingtalk 135, qqbot 274, wecom 134, feishu 110, weixin 53, telegram 17 (723). That last group matters: it's the evidence for the PR's "IM adapters unaffected" claim, since sendThreadMessage and Envelope.metadata landed in shared ChannelBase.

Evidence 1 — the shipped CLI, end to end (8/8)

node packages/cli/dist/index.js channel start gh-live reading a real settings.json, talking to the mock over real HTTP, with the real agent process answering.

full-stack run

The one I most wanted to see is L5. My round-1 blocker was a split brain: the adapter believed it was chat_thread while the SessionRouter routed by the config's scope. So this run's settings.json deliberately omits sessionScope, and the persisted session store comes back keyed gh-live:acme/widgets:issue:4242channel:chatId:threadId. The ChannelPlugin.defaultSessionScope fix genuinely reaches the router in the real product path, not just in a unit test.

Also confirmed live: token: "$GH_E2E_TOKEN" resolves from the environment and arrives as the Authorization header (L2); allowedUsers logins resolve to numeric IDs at connect() (L3); an unlisted sender's mention is dropped (L7); and the whole healthy run logged zero backoff, error or rate-limit events (L8).

Evidence 2 — adapter behaviour matrix (25/25)

behaviour matrix

Highlights, all content-verified against an expected token rather than just "a reply appeared":

  • A2 / A4 — dedup holds. One reply, then four further idle polls produce no second reply. This is a real test here because the mock bumps the thread's updated_at when the bot comments, so the thread genuinely is re-fetched. Stop → construct a fresh instance → cursor reloads from disk → still no duplicate.
  • A12 — the blocker that was open for rounds 3–6 is dead. A subject.url: null notification (the CheckSuite/Discussion shape) is skipped, and a notification sorted after it in the same batch is still processed.
  • A9 — first-contact fires once, with dispatchedBodies: ["acme/widgets|issue:51"] in the cursor.
  • A10b — chat_thread isolation is real: two repos in one batch get two distinct sessions, and neither answer bleeds into the other's thread.
  • A1b — prompt layering is correct. The metadata line lands below the [sender] attribution tag ([maintainer-alice] … \n\nType: Issue | Title: …), so it doesn't sit above the trust boundary. This was my round-8 concern and it holds.

Group D — the central design claim, under the real async race

D1 is the one worth the reviewer's attention. With the mark applied late — GitHub's actual 202 behaviour — the poll's own PUT genuinely misses the thread it just handled (unread=true, last_read_at=null immediately after the reply). The bot still replies exactly once. The cursor window, not the mark, is what prevents the duplicate. That's the PR's core architectural claim and it's now demonstrated rather than asserted.

D2 also holds: a label/push bump re-surfaces a thread and produces no extra reply.

Evidence 3 — the three findings from round 8, reproduced

findings

None of these blocks merge, and each is roughly a one-line fix.

PUT /notifications marks the user's whole account read — including on empty polls. (Medium)
An empty first poll still issues PUT /notifications {last_read_at: <cursor>, read: true}. I planted one unrelated notification ("your build finished", another repo, nothing to do with the bot) and the very first poll flipped it to read — a notification the API had never even shown the adapter. On a personal account, which the user guide lists first as "A GitHub account (or a dedicated bot account)", starting the channel quietly clears the user's GitHub inbox and keeps doing it every cycle. The design doc does now honestly say "global mark", so this is no longer impl≠doc — it's that the mark isn't needed for dedup (D1 proves that) and the cost is invisible to the user. Suggestion: per-thread PATCH /notifications/threads/:id, or a loud warning in the guide.

② A transient listComments error loses the mention permanently, and silently. (Medium)
Three injected 500s exhaust githubApi's retries; the per-thread catch at GithubAdapter.ts:262 does a bare continue. But mark-read and the cursor advance already happened at :168:172, so the thread is marked read (invisible to the unread-only GET) and the cursor has moved past it. Five further healthy polls never recover it — the user's @mention is simply gone, with no comment on the thread. The asymmetry is what makes this worth fixing: B2b shows the handleInbound failure path on the very same batch does post "⚠️ Failed to process this request." One path tells the user to retry; the other says nothing. Suggestion: call the existing postErrorComment in that catch too.

③ A failing PUT /notifications makes the channel inert. (Low–Medium)
markNotificationsAsRead at :168 sits outside every try, so a persistent failure aborts pollOnce before a single thread is looked at: 0 listComments calls, 0 replies, just backoff. The design doc's failure table says "markNotificationsAsRead fails → Cursor window still prevents duplicates → no impact on correctness". The dedup half is true; the conclusion isn't — nothing gets processed at all. Fail-safe for data, but a silently dead bot. Suggestion: wrap that one call in try/catch (it's explicitly best-effort cleanup), and fix the doc row.

Two smaller ones from group D, both visible in Evidence 2:

  • D3 Editing an already-answered @mention re-dispatches it — listComments filters on updated_at, so a typo fix pulls the comment back into the window and the agent runs again (1 → 2 replies). Defensible, but it should be a documented behaviour rather than a surprise.
  • D4 githubApi retries non-retryable 4xx: a deleted issue's 404 is retried 3× with 1s + 2s backoff, stalling the poll loop a measured 3.5s per affected thread. Only 403/429 are rate-limit-gated. Suggestion: don't retry 4xx other than 403/429.

Evidence 4 — the harness actually discriminates (4/4 mutations caught)

mutation matrix

A green suite proves nothing if it would stay green after the fix is removed, so I deleted each load-bearing guard from the compiled adapter and required its scenario to flip:

Guard removed Scenario PR build → mutated
null subject.url skip A12 1 reply → 0 (batch poisoned again)
cursor-window lower bound updated_at <= windowSince A2 1 → 2 (duplicate reply)
bot self-comment filter A6 0 agent calls → 1 (self-loop starts)
first-contact dispatchedBodies A9 1 → 5 replies

All four caught; the dist was restored byte-identically afterwards. The dispatchedBodies result is the interesting one — under a synchronous mark it survives (the !lastReadAt gate already covers it), and it only turns load-bearing once the mock reproduces GitHub's async mark. That's precisely the race the author documented, and it confirms the guard earns its place.

Prior findings → status at 8a93314

Round Finding Status
R1 sessionScope split-brain (adapter vs router) ✅ Fixed — proven live, Evidence 1 L5
R1 chat_thread missing from isSharedSessionTarget ✅ Fixed
R1 connect() fails open on identity failure ✅ Fixed — throws
R1 octokit meta-package in prod deps ✅ Fixed — @octokit/rest
R1 mutable login as senderId ✅ Fixed — numeric id + replaceAllowedUsers, A11b/L3
R2 listComments failure breaks the whole batch ✅ Fixed — now per-thread continue (silent-loss remnant = finding ②)
R3–R6 null subject.url crashes the poll loop ✅ Fixed — A12, and mutation M1 proves the guard is load-bearing
R5 late first-contact notifications dropped ✅ Fixed — dispatchedBodies, A9 + M4
R8 validateCursor truthiness vs !== undefined ✅ Fixed
R8 unresolvable allowedUsers login poisons the numeric allowlist ✅ Fixed in 8a93314 — now throws at connect()
R8 account-wide PUT /notifications ⚠️ Open — finding ①
R8 silent loss on listComments failure ⚠️ Open — finding ②
R8 mark-read failure aborts the poll ⚠️ Open — finding ③

One note on the newest commit: making an unresolvable allowedUsers login throw is the right call over silently keeping the login (which could match an all-digit username). Worth being aware that it also means a transient blip on GET /users/:login now fails channel startup — fail-closed, which I think is correct here, just a behaviour change.

Environment

macOS 15 (Darwin 24.6.0, arm64), Node v22.23.1, isolated worktree at 8a9331435fa855624f70c17e19930d39c7cee918, isolated QWEN_HOME per scenario. The verification harness (mock GitHub, mock model, driver, mutation runner) is ~1,500 lines of throwaway script and lives outside the repo — happy to attach it if it'd be useful to fold into integration-tests/.

Nice work on this one — the cursor-window design holds up under the exact race it was built for, which is not something I could have concluded from reading the diff.

中文版本

本地真实构建验证 —— head 8a93314

我从源码完整构建了这个 PR 并在本地做了端到端验证,作为合并参考。这是一轮验证,不是又一次 code review —— 它接续我的第 8 轮 reviewd85ed6d),并覆盖此后新增的那一个 commit(8a93314,"address review round 4")。

结论:我认为可以合并。 第 1–7 轮的所有阻塞问题都已修复,而且我是通过实际行为确认的,不是靠读 diff。我在第 8 轮提出的三个问题目前仍然可复现,但它们都不会在正常路径上损坏状态或丢失回答,所以我不认为是合并阻塞项 —— 详情和一行修复见下。

验证方式

真实的 GitHub bot 账号无法做确定性脚本化(通知 API 是 unread-only 且异步的),所以我搭了一个本地 GitHub REST 端点 mock,通过 PR 自己的 baseUrl 配置把适配器指向它。具体来说:

真实 / 替换
被测适配器 真实 —— 本 PR 构建产物 packages/channels/github/dist/GithubAdapter.js
HTTP 客户端 真实 @octokit/rest,真实 socket、分页、重试退避
轮询循环、cursor 文件 真实 PollingChannelBase,磁盘上真实的原子重命名 cursor 文件
网关、路由、回复路径 真实 ChannelBase.handleInbound → SenderGate / GroupGate / mention gate → SessionRoutersendThreadMessage
CLI 入口(证据 1) 真实 node packages/cli/dist/index.js channel start,真实 settings.json、插件注册表、agent 子进程(AcpBridge spawn --acp
GitHub 本身 替换 —— mock 刻意复现了本设计所依赖的两个语义(见下)
模型 provider 替换 —— 本地 OpenAI 兼容端点,返回确定性 token

这个 mock 不是一个"总是说 yes"的 stub,它复现了让这个问题变难的行为:

  1. GET /notifications 是 unread-only —— 线程一旦被标记已读就消失,所以丢失的 mention 是真的无法恢复。
  2. PUT /notifications 是异步的(202)且账号级 —— 早于 cutoff 的所有通知都会被标记已读,而且这个标记可能在 bot 自己的回复把线程 updated_at 顶过 cutoff 之后才被应用。这正是 PR 描述中说 cursor 窗口要抵御的竞态,所以我直接测了这个论断(D 组),而不是采信它。

构建与门禁

门禁 结果
npm ci(触发 prepare → 完整构建)+ npm run bundle ✅ 隔离 worktree 中从零构建通过
npm run lint:ci--max-warnings 0 ✅ 0 warning
npm run typecheck(全 workspace) ✅ 通过
prettier --check 32 个改动文件 ✅ 通过
单元测试 1968 通过,0 失败

分布:channel-base 915 · channel-github 60 · cli/commands/channel 270 · 以及其他所有 channel 适配器 —— dingtalk 135、qqbot 274、wecom 134、feishu 110、weixin 53、telegram 17(723)。最后这一组很关键:sendThreadMessageEnvelope.metadata 落在共享的 ChannelBase 里,所以这是 PR "IM 适配器不受影响"论断的证据。

证据 1 —— 出货 CLI 端到端(8/8)

node packages/cli/dist/index.js channel start gh-live 读取真实 settings.json,通过真实 HTTP 与 mock 通信,真实 agent 进程作答。

我最想看到的是 L5。我第 1 轮的阻塞问题是"分裂大脑":适配器认为自己是 chat_thread,而 SessionRouter 按配置里的 scope 路由。所以这次运行的 settings.json 刻意不写 sessionScope,而持久化的 session store 回来的 key 是 gh-live:acme/widgets:issue:4242 —— 即 channel:chatId:threadIdChannelPlugin.defaultSessionScope 这个修复在真实产品路径上确实到达了 router,不只是在单测里成立。

同时在线确认:token: "$GH_E2E_TOKEN" 从环境变量解析并作为 Authorization 头送出(L2);allowedUsers 的 login 在 connect() 时解析为数字 ID(L3);未在允许列表中的发送者的 mention 被丢弃(L7);整个健康运行期间零退避 / 错误 / 限流事件(L8)。

证据 2 —— 适配器行为矩阵(25/25)

要点(全部按预期 token 做内容校验,不只是"有回复"):

  • A2 / A4 —— 去重成立。 一条回复,之后 4 个空闲轮询周期没有第二条。这在这里是真测试,因为 mock 会在 bot 评论时顶起线程 updated_at,所以线程确实被重新抓取了。停止 → 新建实例 → 从磁盘重载 cursor → 仍然不重复。
  • A12 —— 挂了 3–6 轮的阻塞问题已经死了。 subject.url: null 的通知(CheckSuite/Discussion 形态)被跳过,且同批次中排在它之后的通知仍被正常处理。
  • A9 —— 首次接触只触发一次,cursor 中为 dispatchedBodies: ["acme/widgets|issue:51"]
  • A10b —— chat_thread 隔离是真的:一批中的两个 repo 得到两个不同 session,两边回答互不串线。
  • A1b —— prompt 分层正确。 metadata 行落在 [sender] 归属标签下方,没有位于信任边界之上。这是我第 8 轮的关注点,成立。

D 组 —— 核心设计论断,在真实异步竞态下

D1 最值得 reviewer 注意。 让标记延迟应用(GitHub 实际的 202 行为)后,本次轮询自己的 PUT 确实漏掉了它刚处理的线程(回复后立刻观察:unread=true, last_read_at=null)。bot 仍然只回复一次。是 cursor 窗口而不是 mark 在防止重复。 这是 PR 的核心架构论断,现在是被证明的而不是被声称的。

D2 同样成立:label/push 顶起线程不产生额外回复。

证据 3 —— 第 8 轮三个问题的复现

都不阻塞合并,每个大致都是一行修复。

PUT /notifications 把用户整个账号标记已读 —— 空轮询也会。(Medium)
空的首次轮询依然发出 PUT /notifications {last_read_at: <cursor>, read: true}。我放了一条无关通知("你的构建完成了",另一个 repo,与 bot 毫无关系),第一次轮询就把它翻成已读 —— 而这条通知 API 根本没给适配器看过。在个人账号上(用户指南把它列在第一位:"A GitHub account (or a dedicated bot account)"),启动 channel 就会安静地清空用户的 GitHub 收件箱,而且每个周期都在做。设计文档现在确实诚实地写了"global mark",所以这已不是实现与文档不一致 —— 问题是这个 mark 对去重并非必需(D1 已证明),而代价对用户不可见。建议:改用按线程的 PATCH /notifications/threads/:id,或在指南里明确警告。

listComments 的瞬时错误会永久且静默地丢掉 mention。(Medium)
注入 3 次 500 耗尽 githubApi 的重试;GithubAdapter.ts:262 的 per-thread catch 只做了裸 continue。但标记已读和 cursor 推进已经在 :168:172 发生了,所以线程被标记已读(对 unread-only 的 GET 不可见)且 cursor 已越过它。之后 5 个健康轮询都无法恢复 —— 用户的 @mention 就这么没了,线程上也没有任何评论。真正值得修的是这个不对称:B2b 显示同一批次里 handleInbound 失败路径发出 "⚠️ Failed to process this request."。一条路径告诉用户重试,另一条什么都不说。建议:在该 catch 里也调用已有的 postErrorComment

PUT /notifications 失败会让 channel 变成僵尸。(Low–Medium)
:168markNotificationsAsRead 位于所有 try 之外,所以持续失败会在任何线程被查看之前中止 pollOnce0 次 listComments、0 条回复,只有退避。设计文档的失效表写着 "markNotificationsAsRead fails → Cursor window still prevents duplicates → no impact on correctness"。去重那半是对的,结论不对 —— 什么都没被处理。对数据是 fail-safe,但是个静默死掉的 bot。建议:把这一个调用包进 try/catch(它本身就被明确定义为 best-effort 清理),并修正文档那一行。

D 组另外两个小项(在证据 2 中可见):

  • D3 编辑一条已被回答过的 @mention 会重新派发 —— listCommentsupdated_at 过滤,所以改个错别字就把该评论拉回窗口,agent 再跑一次(1 → 2 条回复)。可以接受,但应该写成文档化行为而不是意外。
  • D4 githubApi 会重试不可重试的 4xx:已删除 issue 的 404 被重试 3 次,退避 1s + 2s,实测让轮询循环停滞 3.5s。目前只有 403/429 走限流判定。建议:除 403/429 外不重试 4xx。

证据 4 —— 这套 harness 确实有鉴别力(4/4 变异被抓)

如果移除修复后测试仍然全绿,那么全绿毫无意义。所以我把每个承重的 guard 从编译产物里删掉,要求对应场景翻转:

移除的 guard 场景 PR 构建 → 变异后
null subject.url 跳过 A12 1 条回复 → 0(批次再次被毒化)
cursor 窗口下界 updated_at <= windowSince A2 1 → 2(重复回复)
bot 自评论过滤 A6 0 次 agent 调用 → 1(自循环开始)
首次接触 dispatchedBodies A9 1 → 5 条回复

四个全部被抓;之后 dist 按字节还原。dispatchedBodies 这条最有意思 —— 在同步 mark 下它是能存活的(!lastReadAt 那道门已经覆盖了),只有当 mock 复现 GitHub 的异步 mark 之后它才变成承重的。而这正是作者文档里写的那个竞态,说明这个 guard 是名副其实的。

历史问题 → 在 8a93314 的状态

轮次 问题 状态
R1 sessionScope 分裂大脑(适配器 vs router) ✅ 已修 —— 在线证明,证据 1 L5
R1 isSharedSessionTargetchat_thread ✅ 已修
R1 connect() 身份解析失败时 fail-open ✅ 已修 —— 抛异常
R1 生产依赖里的 octokit 元包 ✅ 已修 —— @octokit/rest
R1 用可变 login 作 senderId ✅ 已修 —— 数字 id + replaceAllowedUsersA11b/L3
R2 listComments 失败 break 掉整批 ✅ 已修 —— 改为 per-thread continue(静默丢失残留 = 问题 ②)
R3–R6 null subject.url 使轮询循环崩溃 ✅ 已修 —— A12,变异 M1 证明该 guard 承重
R5 迟到的首次接触通知被丢弃 ✅ 已修 —— dispatchedBodiesA9 + M4
R8 validateCursor 真值判断 vs !== undefined ✅ 已修
R8 无法解析的 allowedUsers login 污染数字白名单 ✅ 在 8a93314 修复 —— 现在在 connect() 抛异常
R8 账号级 PUT /notifications ⚠️ 未修 —— 问题 ①
R8 listComments 失败时静默丢失 ⚠️ 未修 —— 问题 ②
R8 mark-read 失败中止轮询 ⚠️ 未修 —— 问题 ③

关于最新那个 commit 的一点说明:让无法解析的 allowedUsers login 抛异常,比静默保留 login(可能匹配到全数字用户名)更正确。需要注意它同时意味着 GET /users/:login 的瞬时抖动现在会导致 channel 启动失败 —— 这是 fail-closed,我认为在这里是对的,只是行为变了。

环境

macOS 15(Darwin 24.6.0, arm64),Node v22.23.1,隔离 worktree 位于 8a9331435fa855624f70c17e19930d39c7cee918,每个场景独立 QWEN_HOME。验证 harness(mock GitHub、mock model、driver、变异 runner)约 1500 行一次性脚本,放在仓库之外 —— 如果想收进 integration-tests/,我可以贴出来。

这个 PR 做得不错 —— cursor 窗口设计在它专门针对的那个竞态下确实站得住,而这是我单靠读 diff 得不出的结论。

@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Thank you to the maintainers and reviewers for the careful pass — all four inline comments in this review are now addressed.

What changed

  • Critical — connect() was not idempotent (GithubAdapter.ts). Removed the this.config.allowedUsers = resolved mutation. The resolved numeric IDs now go only to the sender gate via replaceAllowedUsers(), while config.allowedUsers keeps the original logins. Previously, the daemon's bridge-crash reconnect path (disconnect() then connect() on the same instance) re-resolved the already-numeric IDs, and GET /users/{username} returns 404 for an ID — so reconnect failed whenever an allowlist was configured. Added a reconnect-idempotency regression test (mocks getByUsername to 404 on numeric IDs, then calls connect() twice).
  • Docs (channels/overview.md). Added github to the three enumerations that omitted it: the type option row, the Quick Start guide list, and the slash-commands compatibility note.
  • Tests (GithubAdapter.ts). Added unit tests for the githubApi three-way retry backoff (retry-after header, x-ratelimit-reset cooldown, exponential fallback, retry exhaustion) and for the GHE baseUrlwebOrigin derivation.

Verification

  • GitHub adapter 72/72, channel-base 915/915, CLI channel commands 276/276 — all pass.
  • Full npm run build and npm run typecheck clean.
  • Re-ran a live E2E pass against the real GitHub API after these changes (and again after merging upstream): mention → exactly one correct reply, no duplicates, cursor persisted across restart, clean logs.

I've also merged the latest upstream (v0.21.0) into this branch and aligned the new package's version; the merge was clean and the branch is mergeable.

This is merge-ready from my side. I'm glad to run another full E2E round if that would help. Thanks again to everyone who reviewed!

中文翻译

感谢各位维护者和 reviewer 的仔细审查 —— 本次 review 的四条 inline comment 已全部处理。

改动内容

  • Critical —— connect() 不幂等(GithubAdapter.ts)。 删除了 this.config.allowedUsers = resolved 这处 mutation。解析出的数字 ID 现在只通过 replaceAllowedUsers() 交给 sender gate,config.allowedUsers 保留原始 login。此前 daemon 的 bridge 崩溃重连路径(在同一实例上 disconnect()connect())会重新解析已是数字的 ID,而 GET /users/{username} 对 ID 返回 404 —— 因此只要配置了 allowlist,重连就会失败。补了一个重连幂等回归测试(mock getByUsername 对数字 ID 返回 404,然后两次调用 connect())。
  • 文档(channels/overview.md)。 给三处遗漏 github 的枚举补上:type 选项行、Quick Start 指南列表、slash 命令兼容说明。
  • 测试(GithubAdapter.ts)。githubApi 三路重试退避(retry-after 头、x-ratelimit-reset 冷却、指数退避、重试耗尽)以及 GHE baseUrlwebOrigin 推导补了单元测试。

验证

  • GitHub adapter 72/72、channel-base 915/915、CLI channel 命令 276/276 —— 全部通过。
  • 完整 npm run buildnpm run typecheck 干净。
  • 在这些改动之后(以及合并 upstream 之后)又对着真实 GitHub API 跑了一轮 E2E:mention → 恰好一条正确回复、无重复、cursor 跨重启持久化、日志干净。

另外已把最新 upstream(v0.21.0)合入本分支并对齐了新包的版本;合并干净,分支可合并。

我这边已 merge-ready。如果有帮助,我很乐意再跑一轮完整 E2E。再次感谢各位的 review!

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Review — GitHub channel adapter (re-review @ 510a2501)

Verdict: merge-ready. This is a re-review of the delta since the last round (head 8a93314). Two adapter commits landed plus a clean merge of upstream/main and a version bump to 0.21.0; the branch is MERGEABLE. Both new commits are correct, well-commented improvements that close prior nits with no regressions. The remaining items below are all non-blocking and pre-existing.

Delta since last review — both good ✅

  • 3796aa28 — comment window keyed on created_at instead of updated_at (GithubAdapter.ts:208,211). This is the right fix for the earlier "editing an already-answered comment re-dispatches it" nit: listComments?since= filters by updated_at, so an edited old comment still comes back over the wire, but the local created_at <= windowSince guard now drops it. New/legit comments (created_at inside (windowSince, maxUpdatedAt]) are unaffected; anything created past maxUpdatedAt simply defers to the next cycle. Boundary tests were migrated to created_at. Correct.
  • 3796aa28retry-after NaN guard (:392-396). retry-after can be an HTTP-date (RFC 7231), where Number(header)NaNNaN * 1000abortableSleep(NaN)setTimeout(fn, 0) → immediate hammering. Number.isFinite + exponential fallback fixes it. Correct.
  • 8744f6ab — reconnect idempotency (:99-103). Keeping this.config.allowedUsers as logins (feeding only resolved IDs to the gate) is the right call: the daemon reconnect path is disconnect() + connect() on the same instance, and re-resolving already-numeric IDs would 404 on GET /users/{username}. Verified the gate is the only consumer of the resolved IDs. Well-tested with a dedicated idempotency test.

Remaining non-blocking findings (all pre-existing)

  1. [Medium] Account-wide PUT /notifications marks the entire inbox read every poll. markNotificationsAsRead (:420-429) hits the account-wide endpoint, and it runs on every cycle including empty polls (maxUpdatedAt falls back to the cursor). A user running this on a personal account silently loses read-state on all their unrelated GitHub notifications. The docs hint "or a dedicated bot account" but never explain why. Prefer per-thread PATCH /notifications/threads/:id (markThreadAsRead), or add a loud doc warning about the whole-inbox effect.

  2. [Low] Doc says mark-read is "best-effort", but the call actually wedges the poll on persistent failure. The user doc (§ "How it works", step 2) calls it "best-effort cleanup". But await this.markNotificationsAsRead(...) at :171 is unwrapped, and githubApi retries 3× then throws — aborting the whole pollOnce() before any thread is processed. A persistently-failing PUT /notifications ⇒ a silently inert channel. It is fail-safe for data (nothing is lost/duplicated), so wrapping it in a try/catch that logs and continues would both match the documented semantics and keep the loop alive.

  3. [Low] listComments failure drops the mention silently, with no error comment. The per-thread catch (:265-270) does continue with only an stderr write. Because mark-read + cursor advance already happened at :171-175, that thread is marked read and the cursor is past it, so the unread-only GET /notifications never resurfaces it — the mention is permanently lost with zero user-visible signal. This is asymmetric with the handleInbound-failure path (:251), which posts "⚠️ Failed to process…". Consider posting the same error comment here so the loss is recoverable by re-mention.

  4. [nit] The new retry-after NaN fallback is untested — the only retry-after test covers the seconds path (:1028). A one-liner feeding a non-numeric header would lock in the fallback.

Overall: clean, well-tested delta. #1 is the one worth addressing before or shortly after merge; the rest are minor hardening.

中文

复审 — GitHub 频道适配器(510a2501

结论:可合并。 本轮为增量复审(自 8a93314 起)。新增两个适配器提交,并干净地合并了 upstream/main、版本对齐到 0.21.0;分支状态 MERGEABLE。两个新提交都是正确且注释清晰的改进,修掉了此前的小问题且无回归。下列各项均为非阻塞且为既有问题。

本轮增量(均 ✅):

  • 3796aa28 — 评论窗口改用 created_at 而非 updated_at:208,211)。这正是修复"编辑旧评论会被重新派发"的正确做法:listComments?since=updated_at 过滤,被编辑的旧评论仍会返回,但本地 created_at <= windowSince 会把它丢弃;新评论不受影响。已迁移边界测试。
  • 3796aa28retry-afterNaN 防护:392-396)。retry-after 可为 HTTP-date,Number()NaNabortableSleep(NaN) → 立即重试打爆接口。Number.isFinite + 指数退避已修复。
  • 8744f6ab — 重连幂等:99-103)。将 this.config.allowedUsers 保留为登录名(仅把解析后的 ID 交给 gate),避免守护进程 disconnect()+connect() 重连时对已是数字的 ID 再次 GET /users/{username}404。有专门测试覆盖。

遗留非阻塞项(均为既有问题):

  1. [Medium] 账户级 PUT /notifications 每轮把整个收件箱标记已读:420-429),空轮也会执行。个人账号用户会静默丢失所有无关通知的未读状态。建议改用按线程的 PATCH /notifications/threads/:id,或在文档中明确警告"整箱已读"的副作用。
  2. [Low] 文档称标记已读为"best-effort",但持续失败会卡死轮询。 :171 的调用未包 try/catchgithubApi 重试 3 次后抛出,在处理任何线程前就中断 pollOnce();持续失败 ⇒ 频道静默失效。它对数据是安全的(不丢不重),建议 try/catch 记录并继续。
  3. [Low] listComments 失败会静默丢失该 @mention 且无错误评论。 由于标记已读 + 游标推进已在 :171-175 完成,该线程不会再出现在仅返回未读的 GET /notifications 中,mention 永久丢失且无提示;与 handleInbound 失败路径(:251 发"⚠️ Failed to process")不对称。建议此处也发错误评论以便重新触发。
  4. [nit] 新增的 retry-after NaN 回退无测试(仅 :1028 覆盖秒数路径)。

综合:增量干净、测试充分。第 1 项最值得在合并前后处理,其余为次要加固。

@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.

[Critical] packages/cli/src/serve/channel-settings-store.ts:146 — sessionScope validator Set is ['user', 'thread', 'single'] but the new 'chat_thread' SessionScope value (added in types.ts:6, used by the GitHub plugin) is missing. Users explicitly setting sessionScope: 'chat_thread' via the daemon channel management API get a 400 error.

— qwen3.7-max via Qwen Code /review

Comment thread package-lock.json Outdated
Comment on lines +27581 to +27582
"name": "@qwen-code/channel-github",
"version": "0.20.1",

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] Lock file version mismatch — the workspace entry records "version": "0.20.1" but packages/channels/github/package.json declares "0.21.0". All other channel packages match at 0.21.0. — Failure scenario: npm ci validates lock-file consistency against workspace package.json entries. This mismatch may cause npm ci to reject the lock file as stale, blocking the CI pipeline.

Suggested change
"name": "@qwen-code/channel-github",
"version": "0.20.1",
"name": "@qwen-code/channel-github",
"version": "0.21.0",
中文说明

[Critical] Lock file 版本不匹配 — workspace 条目记录 "version": "0.20.1",但 packages/channels/github/package.json 声明 "0.21.0"。所有其他 channel 包均为 0.21.0。失败场景:npm ci 会验证 lock file 与 workspace package.json 的一致性,此不匹配可能导致 npm ci 拒绝 lock file 并阻断 CI 流水线。

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jul 25, 2026
@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Local real-build verification — round 2, head 510a250

Follow-up to my local verification at 8a93314. I rebuilt this branch from source at the current head (npm ci + npm run build, clean tree) and re-ran everything, plus new probes for the three commits added since.

Verdict: one new blocker. The three new commits each fix a real bug and I confirmed all three behaviourally, with mutation A/Bs proving each is load-bearing. But 8744f6ab's reconnect-idempotency fix has a side effect that disables the shared-session gate for allow-listed users — and it reproduces end-to-end through the real qwen channel start.

Everything below was produced by pointing the adapter's own baseUrl at a local node:http server, so a real @octokit/rest client talks over real sockets — no vi.mock, no module interception. The mock models the two GitHub semantics that make these guards matter: GET /notifications is unread-only, and PUT /notifications is account-wide and applied asynchronously.


🔴 New blocker — an allow-listed user is locked out of every shared-session command

8744f6ab removed this.config.allowedUsers = resolved from connect() (GithubAdapter.ts:99-103), so config.allowedUsers now keeps the configured logins. Three facts combine:

  1. plugin.defaultSessionScope = 'chat_thread' (index.ts:11), and isSharedSessionTarget() returns true for chat_thread (ChannelBase.ts:3992) → every GitHub thread is a shared session.
  2. The GitHub envelope's senderId is the numeric user id — String(comment.user?.id ?? 'unknown') (GithubAdapter.ts:231).
  3. isAuthorizedForSharedSessionTarget() tests authorized.includes(target.senderId) against this.config.allowedUsers (ChannelBase.ts:4016; :4025 is the same check for tool-call permission answers).

["alice"].includes("1001") is always false, so with any non-empty allowedUsers the gate denies everyone — including the people on the allowlist. It only short-circuits when the allowlist is empty (authorized.length === 0), so the default config is unaffected; the bug bites exactly the security-conscious configuration the docs recommend.

Blast radius, from the rejection sites in ChannelBase.ts: /cancel (:2012), /approve /approve-always /deny (:2257), /clear /reset /new (:2493), /who (:2530), /status (:2645), /loop (:2696, :3104) — plus the steer path (:4738), where an allow-listed member's message silently falls back to queuing instead of steering, with only a stderr line to explain it. /approve is the sharpest one: a tool permission request raised in a GitHub thread can no longer be answered by anybody, so the channel cannot run any tool that needs approval.

Full-stack, through the real built CLI. Two cells, identical except for the allowlist. sessionScope is deliberately omitted from settings.json so the plugin's defaultSessionScope decides it, exactly as a user would configure it:

full-stack E2E

Unit-level A/B, head vs. the same dist with the one removed line put back — 4/4 commands flip, and the control with an empty allowlist is unaffected:

gate matrix

Suggested fix — cache the resolution instead of dropping the assignment
private resolvedAllowedUsers: string[] | null = null;

// ...in connect(), replacing the resolve loop's tail:
if (!this.resolvedAllowedUsers) {
  const resolved: string[] = [];
  for (const login of this.config.allowedUsers ?? []) {
    // ...unchanged GET /users/{login} resolution...
  }
  this.resolvedAllowedUsers = resolved;
}
this.config.allowedUsers = this.resolvedAllowedUsers;
this.gate.replaceAllowedUsers(this.resolvedAllowedUsers);

This keeps 8744f6ab's reconnect fix — the second connect() reuses the cache, so there is no GET /users/1001 and no 404 — while restoring what ChannelBase reads. I checked that nothing writes a live channel's config back to settings.json, so the mutation stays process-local.

The more structural alternative is to stop having ChannelBase read config.allowedUsers for this gate and give it an adapter-supplied resolved list instead, but that touches every channel and is a bigger change than this PR needs.

Worth adding a regression test with a non-empty allowedUsers that sends a shared-session command. The new reconnect-idempotency test passes under either shape, because it never sends a message.


✅ The three new commits — all three confirmed, all three load-bearing

new commits

Commit Change Result
3796aa28 comment window keys on created_at, not updated_at (:208, :211) Confirmed. alice is answered once, then edits that comment while bob posts a fresh mention: head posts 1 new reply (bob only); mutated back to updated_at posts 2 — it re-answers alice's edited comment. This closes the edit-re-dispatch finding from my last report.
3796aa28 retry-after NaN guard (:392-396) Confirmed. With the header sent as an RFC 7231 HTTP-date, head backs off 1s + 2s (3010 ms measured); without the guard cooldown is NaN, setTimeout fires immediately and the two retries cost 10 ms — i.e. no backoff at all.
8744f6ab connect() idempotent across a daemon reconnect Confirmed. Head: second connect() succeeds, lookups ["alice","alice"]. With the assignment restored: second connect() throws could not resolve allowedUser "1001", lookups ["alice","1001"]. The bug it fixes is real — which is why I'm suggesting a fix that keeps it.

Standing findings — all three still reproduce

standing findings

Finding Evidence at 510a250
S1 markNotificationsAsRead is the account-wide PUT /notifications (:420-429), fired every poll including empty ones On the very first poll of a fresh channel: 1 PUT with {"last_read_at":"…","read":true}, and an unrelated CheckSuite notification in another repo goes from unread to read. On a personal account — which the docs list as a first-class option — starting the channel clears the user's whole inbox. A per-thread PATCH /notifications/threads/:id is what the design doc describes.
S2 a listComments failure loses the mention silently Two threads in one batch: #42's listComments 500s → 0 comments posted, cursor advanced past it, marked read (and GET /notifications is unread-only, so it never comes back). #43's agent throws → 1 comment, "⚠️ Failed to process this request…". Same batch, same severity of failure, opposite user-visible outcome. One line in the per-thread catch would close the asymmetry.
S3 markNotificationsAsRead at :171 sits outside every try A persistently failing PUT makes pollOnce() throw before the loop: 0 listComments calls, 0 comments. Fail-safe for data, but a persistent mark failure is a silently inert channel — and it contradicts the design doc's scenario row saying a markNotificationsAsRead failure has "no impact on correctness".

Unchanged nit: githubApi (:373-417) has no non-retryable-status check — every status, including 404 and 422, is retried 3× with backoff.


What I ran

Build npm ci + npm run build in an isolated worktree at 510a250, clean. All 19 workspace links verified resolving in-tree, and the PR's code confirmed present in the built dist before driving it.
Tests channel-github 72/72 · channel-base 915/915 · CLI channel commands 276/2761263 passing, 0 failures
Static npm run typecheck clean · eslint --max-warnings 0 on the new package clean · prettier --check clean on the new sources and both touched docs
Probes 1 full-stack E2E through the real built CLI (2 cells) + 3 adapter probes over real sockets, 7 mutation A/Bs total; every mutated dist restored and asserted byte-identical

I also confirmed the docs fix: github now appears in the type option row, the Quick Start list, and the intro (docs/users/features/channels/overview.md:5,20,55).

Recommendation: the blocker is a few lines and everything else in this round came back clean — the three new commits are all genuine fixes and I could not break them. Fix the allowlist regression and add a regression test with a non-empty allowedUsers, and I'm happy to see this merge. S1 is the one I'd still like resolved before this reaches users on personal accounts; S2 and S3 are fine as follow-ups.

中文版本

本地真实构建验证 —— 第 2 轮,head 510a250

这是我在 8a93314 上那次本地验证的后续。我在当前 head 从源码重新构建了本分支(npm ci + npm run build,干净),重跑了全部验证,并为此后新增的三个 commit 补了新的探针。

结论:新增一个阻塞项。 三个新 commit 各自修复了一个真实 bug,我都做了行为级确认,并用变异 A/B 证明每一处修复都是有效负载。但 8744f6ab 的重连幂等修复带来一个副作用:它让 allowlist 中的用户被共享会话门禁挡住 —— 并且通过真实的 qwen channel start 端到端复现了。

下文所有结论都是把 adapter 自己的 baseUrl 指向本地 node:http 服务得到的,因此是真实的 @octokit/rest 客户端走真实 socket —— 没有 vi.mock,没有模块拦截。该 mock 忠实还原了让这些防护真正生效的两条 GitHub 语义:GET /notifications 只返回未读,PUT /notifications 是账号级且异步生效的。

🔴 新阻塞项 —— allowlist 中的用户被所有共享会话命令拒之门外

8744f6abconnect() 中删除了 this.config.allowedUsers = resolvedGithubAdapter.ts:99-103),于是 config.allowedUsers 现在保留的是配置里的 login。三个事实叠加:

  1. plugin.defaultSessionScope = 'chat_thread'index.ts:11),而 isSharedSessionTarget()chat_thread 返回 trueChannelBase.ts:3992)→ 每个 GitHub 线程都是共享会话。
  2. GitHub envelope 的 senderId数字 user id —— String(comment.user?.id ?? 'unknown')GithubAdapter.ts:231)。
  3. isAuthorizedForSharedSessionTarget()authorized.includes(target.senderId) 去比对 this.config.allowedUsersChannelBase.ts:4016:4025 是权限应答的同一检查)。

["alice"].includes("1001") 恒为 false,因此只要 allowedUsers 非空,门禁就会拒绝所有人 —— 包括 allowlist 里的人。只有 allowlist 为空时才会短路(authorized.length === 0),所以默认配置不受影响;换句话说,这个 bug 恰好只咬文档推荐的那种更注重安全的配置。

影响面(依据 ChannelBase.ts 中的各个拒绝点):/cancel:2012)、/approve /approve-always /deny:2257)、/clear /reset /new:2493)、/who:2530)、/status:2645)、/loop:2696:3104),以及 steer 路径(:4738)—— 在 steer 路径上,allowlist 成员的消息会静默退化为排队而非 steer,只在 stderr 留下一行说明。其中 /approve 最要命:在 GitHub 线程里发起的工具权限请求将无人可以应答,也就意味着该 channel 无法运行任何需要审批的工具。

通过真实构建的 CLI 做的全栈验证。 两个单元,除 allowlist 外完全相同。settings.json 中刻意省略 sessionScope,让插件的 defaultSessionScope 来决定,这正是用户的真实配置方式(见上方第 1 张截图)。

单元级 A/B:head 对比同一份 dist 但把被删掉的那行加回去 —— 4/4 命令全部翻转,而 allowlist 为空的对照组不受影响(见上方第 2 张截图)。

建议的修法 —— 缓存解析结果,而不是删掉赋值(详见英文版折叠块中的代码)。这样既保留了 8744f6ab 的重连修复(第二次 connect() 直接复用缓存,不会发出 GET /users/1001,也就没有 404),又恢复了 ChannelBase 所读取的内容。我确认过没有任何地方会把运行中 channel 的 config 回写到 settings.json,所以这个 mutation 只停留在进程内。

更结构化的替代方案是让 ChannelBase 不再为这个门禁读取 config.allowedUsers,改由 adapter 提供已解析的列表 —— 但那会牵动所有 channel,超出本 PR 的必要范围。

建议补一个 allowedUsers 非空并发送共享会话命令的回归测试。新增的重连幂等测试在两种写法下都会通过,因为它从不发送消息。

✅ 三个新 commit —— 全部确认,且都是有效负载

Commit 改动 结论
3796aa28 评论窗口以 created_at 而非 updated_at 为准(:208:211 确认。 alice 被回复一次后编辑该评论,同时 bob 发出新的 mention:head 只新增 1 条回复(仅回 bob);变异回 updated_at 后新增 2 条 —— 它把 alice 编辑过的评论又回了一遍。这修掉了我上一轮报告里的「编辑已回复的 mention 会被重新分发」问题。
3796aa28 retry-after NaN 防护(:392-396 确认。 当该头以 RFC 7231 HTTP-date 形式返回时,head 退避 1s + 2s(实测 3010 ms);去掉防护后 cooldownNaNsetTimeout 立即触发,两次重试仅耗时 10 ms —— 等于完全没有退避。
8744f6ab connect() 在 daemon 重连时保持幂等 确认。 head:第二次 connect() 成功,查询为 ["alice","alice"]。把赋值加回去后:第二次 connect() 抛出 could not resolve allowedUser "1001",查询为 ["alice","1001"]。它修的 bug 是真实存在的 —— 所以我建议的修法是要把它保留下来。

遗留问题 —— 三条仍然复现

问题 510a250 上的证据
S1 markNotificationsAsRead 是账号级的 PUT /notifications:420-429),每轮轮询都会触发,包括空轮询 全新 channel 的第一次轮询:1 次 PUT,body 为 {"last_read_at":"…","read":true},另一个仓库里一条无关的 CheckSuite 通知由未读变为已读。在个人账号上(文档把它列为一等选项),仅仅启动 channel 就会清空用户的整个收件箱。设计文档描述的是按线程的 PATCH /notifications/threads/:id
S2 listComments 失败会静默丢失该 mention 同一批次两个线程:#42listComments 返回 500 → 0 条评论,cursor 越过它,且已被标记为已读(而 GET /notifications 只返回未读,所以它再也不会回来)。#43 的 agent 抛错 → 1 条评论「⚠️ Failed to process this request…」。同一批次、同等严重的失败,用户可见的结果却相反。在按线程的 catch 里加一行即可消除这种不对称。
S3 :171 处的 markNotificationsAsRead 位于所有 try 之外 持续失败的 PUT 会让 pollOnce() 在进入循环前就抛出:0listComments0 条评论。对数据而言是 fail-safe,但持续的标记失败等于 channel 静默失效 —— 且与设计文档中「markNotificationsAsRead 失败对正确性无影响」那一行相矛盾。

遗留小问题:githubApi:373-417)没有对不可重试状态码做判断 —— 包括 404、422 在内的所有状态码都会带退避重试 3 次。

我实际跑了什么

构建 510a250 的独立 worktree 中 npm ci + npm run build,干净。已验证 19 个 workspace 链接全部指向本 worktree 内部,并在驱动前确认 PR 代码确实出现在构建产物 dist 中。
测试 channel-github 72/72 · channel-base 915/915 · CLI channel 命令 276/276 —— 共 1263 条通过,0 失败
静态检查 npm run typecheck 干净 · 新包 eslint --max-warnings 0 干净 · 新源码与两个被改动文档 prettier --check 干净
探针 1 个走真实构建 CLI 的全栈 E2E(2 个单元)+ 3 个走真实 socket 的 adapter 探针,共 7 组变异 A/B;每次变异后的 dist 都已还原并断言与原文件逐字节一致

我也确认了文档修复:github 现已出现在 type 选项行、Quick Start 列表和开头介绍中(docs/users/features/channels/overview.md:5,20,55)。

建议: 阻塞项只需改几行,本轮其余部分全部干净 —— 三个新 commit 都是真实修复,我没能把它们攻破。修掉 allowlist 回归并补一个 allowedUsers 非空的回归测试,我这边就可以放行合并。S1 是我仍希望在功能触达个人账号用户之前解决的;S2 和 S3 可以作为后续跟进。


for (const notification of notifications) {
if (!notification.subject.url) continue;
const extracted = this.extractFromSubjectUrl(notification.subject.url);

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.

[P1] Multiple notifications for the same issue within a single poll cycle are not deduplicated. If the user has e.g. a mention + assign notification for the same issue #42, the for loop processes both: each triggers a separate listComments API call, enumerates the same comments, and calls handleInbound on the same envelopes.

Impact:

  • Redundant API calls (rate-limit pressure)
  • If handleInbound fails, up to N error comments posted on the same issue (one per notification), undermining the P0 fix for error-comment dedup (which only covers duplicate comments within a single notification processing via break)

Suggested fix — deduplicate notifications by thread before the loop:

const seenThreads = new Set<string>();
const uniqueNotifications = notifications.filter((n) => {
  if (!n.subject.url) return true;
  const key = n.subject.url;
  if (seenThreads.has(key)) return false;
  seenThreads.add(key);
  return true;
});

owner: chatId.split('/')[0],
repo: chatId.split('/')[1],
issue_number: issueNumber,
body: text,

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.

[P2] pollOnce() never checks this.abortController.signal.aborted during its processing loop. After disconnect() is called, the runLoop while condition will eventually stop the loop — but only after the current pollOnce() completes all notification processing, including potentially slow listComments / issues.get / createComment API calls.

For a poll processing many notifications (e.g. after a long offline period), this can delay shutdown by several seconds. Consider adding an early-exit check at the top of the notification loop:

for (const notification of notifications) {
  if (this.abortController.signal.aborted) break;
  // ... existing logic
}

@yiliang114 yiliang114 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.

Review Summary

Previous P0 (error comment dedup): ✅ Fixed. The break after postErrorComment in the comment processing loop ensures at most one error comment per notification. The cursor always advances (even on failure), preventing re-processing in subsequent polls. The dispatchedBodies dedup prevents duplicate first-contact body dispatch. Test "posts only one error comment when mention dispatch fails on a new thread" validates this.

Remaining Findings

Severity File Issue
P1 GithubAdapter.ts:179 No per-poll notification dedup by thread. Multiple notifications for the same issue (e.g. mention + assign) within a single poll cycle cause redundant listComments calls and potentially multiple error comments, partially undermining the P0 fix.
P2 GithubAdapter.ts:138 No abort check during pollOnce(). After disconnect(), the current poll completes all notification processing before the loop stops.

What Looks Good

  1. Two-layer dedup is well-designed. Layer 1: cursor-based comment window (windowSince / maxUpdatedAt). Layer 2: dispatchedBodies with bounded eviction (500 entries). Covers both normal and edge-case (late notification, failed mark-read) scenarios.

  2. Cursor validationvalidateCursor checks date validity, normalizes bad dispatchedBodies (non-array → []). Safe against corrupt cursor files.

  3. Sender gatereplaceAllowedUsers with immutable numeric IDs. Config retains original logins for reconnect idempotency. Well-tested.

  4. markRead timing — Marks read BEFORE processing (best-effort), so a crash during processing does not leave notifications permanently unread.

  5. Abortable sleep / retryabortableSleep correctly resolves immediately on abort. retry-after NaN guard falls back to exponential backoff. Rate-limit cooldown uses x-ratelimit-reset with 1s buffer.

  6. Reconnect idempotencystartPollLoop is a no-op if already running, creates a fresh AbortController, resets error counter. Config not mutated.

  7. Test coverage is comprehensive — ~1,375 lines of tests covering: connect flows (including reconnect), poll/dedup logic, first contact (6 edge cases), error handling, retry backoff (4 strategies), cursor validation, webOrigin rewriting, sendThreadMessage, SessionRouter chat_thread scope, and PollingChannelBase lifecycle.

Verdict

One P1 (notification dedup) blocks approval. The P2 is nice-to-have. Everything else is solid — the architecture, error handling, and test coverage are thorough.

@wenshao
wenshao dismissed stale reviews from qwen-code-ci-bot and doudouOUC July 25, 2026 09:04

fixed

@yiliang114 yiliang114 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.

Approve

Re-review after 5 rounds of feedback (32 new commits). The previous P0 (error comment dedup) is confirmed fixed — postErrorComment + break + cursor always advances + dispatchedBodies dedup.

Key improvements since last review:

  • Two-layer dedup (cursor window + recentlyProcessed + dispatchedBodies)
  • Cursor-based comment window prevents duplicates regardless of markRead success
  • Sender gate with immutable senderId, allowedUsers throw on resolve failure
  • Abortable sleep/retry with NaN guard
  • Reconnect idempotency
  • ~1,375 lines of test coverage

Supersedes my previous COMMENT review — the P1 (per-poll notification dedup) was incorrect (GitHub API returns one notification per thread), and the P2 (abort during pollOnce) is standard polling-loop behavior with negligible delay.

@yiliang114

Copy link
Copy Markdown
Collaborator

Congrats @OrbitZore! 🎉 This is a substantial feature addition — notification-as-wakeup architecture, two-layer dedup, cursor-based comment window, sender gate hardening — and you've shepherded it through 5 rounds of review feedback with 37 commits. The defense-in-depth dedup design is particularly well thought out. Thanks for the patience and thoroughness!

@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.

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: test-matrix — subagent execution failed. Not reviewed: 1b (removed-behavior audit) — subagent execution failed.

中文说明

已审查。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。 未审查:test-matrix — subagent execution failed。 未审查:1b (removed-behavior audit) — subagent execution failed。

— qwen3.7-max via Qwen Code /review

Comment on lines +171 to +175
await this.markNotificationsAsRead(maxUpdatedAt);

if (maxUpdatedAt > this.cursor.lastProcessedAt) {
this.cursor.lastProcessedAt = maxUpdatedAt;
}

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] markNotificationsAsRead failure blocks the entire poll cycle — Concrete cost: if GitHub's PUT /notifications returns persistent 500s, the 3 retries exhaust and pollOnce throws before the comment-processing loop ever runs. The cursor-based dedup window handles correctness independently, so the mark-read can be non-fatal without risking duplicates.

Suggested change
await this.markNotificationsAsRead(maxUpdatedAt);
if (maxUpdatedAt > this.cursor.lastProcessedAt) {
this.cursor.lastProcessedAt = maxUpdatedAt;
}
try {
await this.markNotificationsAsRead(maxUpdatedAt);
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] markNotificationsAsRead failed (non-fatal): ${err}\n`,
);
}
if (maxUpdatedAt > this.cursor.lastProcessedAt) {
this.cursor.lastProcessedAt = maxUpdatedAt;
}
中文说明

markNotificationsAsRead 失败会阻塞整个轮询周期——具体代价:如果 GitHub 的 PUT /notifications 持续返回 500,3 次重试耗尽后 pollOnce 抛异常,评论处理循环根本不会执行。基于 cursor 的去重窗口独立保证正确性,因此 mark-read 可以是非致命的而不会导致重复。

— qwen3.7-max via Qwen Code /review

Comment on lines +265 to +270
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`,
);
continue;
}

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] Failed notification silently lost — no error comment posted — Concrete cost: when listComments fails for a notification, the outer catch only writes to stderr. The cursor has already advanced (line 175) and markNotificationsAsRead has already marked it read (line 171). On the next poll, the notification is not re-fetched (unread-only, last_read_at past it). The user's @mention is permanently dropped with no error comment on the thread — the user has no signal to re-mention.

Suggested change
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`,
);
continue;
}
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`,
);
await this.postErrorComment(chatId, issueNumber);
continue;
}
中文说明

失败的通知被静默丢失——未发错误评论——具体代价:当 listComments 对某通知失败时,外层 catch 仅写入 stderr。cursor 已推进(第 175 行),markNotificationsAsRead 已标记已读(第 171 行)。下次轮询不会重新获取该通知(仅未读,last_read_at 已超过)。用户的 @mention 被永久丢弃,线程上没有错误评论——用户没有信号去重新 @mention。

— qwen3.7-max via Qwen Code /review

@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough round-2 verification — the blocker is confirmed and well-understood.

On the allowlist regression: after checking all other channel adapters (Telegram, Weixin, WeCom, Feishu, DingTalk), none of them have this mismatch — their senderId and allowedUsers config use the same platform-native identifier format, so config.allowedUsers.includes(senderId) works directly without any resolution step. GitHub is the only adapter where the user-facing identifier (login, e.g. alice) differs from the envelope identity (immutable numeric ID, e.g. 1001), which is why it's the only one that resolves logins → IDs in connect().

This will be addressed in a separate follow-up PR with a regression test covering a non-empty allowedUsers exercising shared-session commands.

On S1: this is a GitHub platform limitation — PATCH /notifications/threads/:id has no last_read_at parameter (verified against the OpenAPI spec), so the account-wide PUT /notifications is the only call that supports a cutoff timestamp. Not actionable on our side.

On S2/S3: noted as follow-ups, agreed they're not merge-blockers.

Really appreciate the patience and rigor from all reviewers across these rounds — the mutation A/B methodology and full-stack E2E through the real built CLI gave high confidence in every finding. Thank you 🙏

中文

感谢第二轮详尽的验证——阻塞项已确认并完全理解。

关于 allowlist 回归: 检查了所有其他 channel 适配器(Telegram、Weixin、WeCom、Feishu、DingTalk),它们都不存在这个不匹配——senderIdallowedUsers 配置使用的是同一种平台原生标识符格式,config.allowedUsers.includes(senderId) 无需任何解析步骤即可直接工作。GitHub 是唯一一个用户可读标识(login,如 alice)与 envelope 身份标识(不可变数字 ID,如 1001)不同的适配器,因此也是唯一需要在 connect() 中做 login → ID 解析的。

将在单独的后续 PR 中修复,并包含 allowedUsers 非空且执行共享会话命令的回归测试。

关于 S1: 这是 GitHub 平台限制——PATCH /notifications/threads/:id 没有 last_read_at 参数(已对照 OpenAPI spec 验证),账号级 PUT /notifications 是唯一支持截止线时间戳的调用。我方无法规避。

关于 S2/S3: 记录为后续跟进,同意它们不是合并阻塞项。

非常感谢各位 reviewer 在多轮 review 中的耐心和严谨——变异 A/B 方法论和通过真实构建 CLI 的全栈 E2E 让每个发现都有很高的可信度。感谢 🙏

@wenshao
wenshao added this pull request to the merge queue Jul 25, 2026
Merged via the queue into QwenLM:main with commit 62e009a Jul 25, 2026
146 checks passed

@wenshao wenshao 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.

[Critical] isAuthorizedForSharedSessionTarget (ChannelBase.ts:4016) compares config.allowedUsers (logins like 'alice') against envelope.senderId (numeric IDs like '10001') — the GithubAdapter resolves logins to numeric IDs for the SenderGate but deliberately leaves config.allowedUsers as original logins. With senderPolicy: 'allowlist' and the default chat_thread scope (always shared), every allowlisted user is rejected from /who, /clear, /status, /loop, and channel memory commands.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +171 to +173
await this.markNotificationsAsRead(maxUpdatedAt);

if (maxUpdatedAt > this.cursor.lastProcessedAt) {

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] markNotificationsAsRead is called unconditionally (even on empty polls, wasting a rate-limit token per idle cycle) and its failure aborts the entire poll cycle — contradicting the design doc's "best-effort" / "not load-bearing" contract. — Failure scenario: (1) Idle repo: ~60 wasted PUT /notifications/hour per channel at the default 60s interval. (2) Persistent PUT failure (e.g. secondary rate limit on mutations): the throw propagates through pollOnce(), the cursor never advances, and zero comments are processed indefinitely — even though the cursor window handles dedup independently.

Suggested change
await this.markNotificationsAsRead(maxUpdatedAt);
if (maxUpdatedAt > this.cursor.lastProcessedAt) {
if (maxUpdatedAt > this.cursor.lastProcessedAt) {
try {
await this.markNotificationsAsRead(maxUpdatedAt);
} catch (err) {
process.stderr.write(
`[Channel:${this.name}] markNotificationsAsRead failed (best-effort): ${err}\n`,
);
}
中文说明

markNotificationsAsRead 无条件调用(空轮询也发 PUT,浪费限流配额),且失败时中断整个轮询周期——与设计文档中"best-effort"/"非去重依赖"的契约矛盾。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +251 to +253
await this.postErrorComment(chatId, issueNumber);
dispatchedMention = true;
break;

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] break on handleInbound failure permanently skips remaining comments in the thread — Failure scenario: thread has C1, C2, C3 in the current window. Cursor is pre-advanced to maxUpdatedAt (line 173). C1 succeeds; C2 throws → break. C3 is never dispatched. pollOnce() returns normally, saveCursor() persists maxUpdatedAt. Next poll's windowSince filter permanently excludes C3. The error comment says "this request" (singular) — C3's author has no indication their comment was also skipped.

中文说明

handleInbound 失败后 break 永久跳过线程中剩余评论。cursor 已预推进,C3 永远不会被分发,且错误评论只提及单数请求。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +266 to +269
process.stderr.write(
`[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`,
);
continue;

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] When listComments fails after all retries, this outer catch logs to stderr and continues — no postErrorComment is called, unlike the handleInbound failure path (line 251). The thread is already marked read and the cursor already advanced, so the comments are permanently lost with no user-visible indication.

Suggested change
process.stderr.write(
`[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`,
);
continue;
await this.postErrorComment(chatId, issueNumber);
process.stderr.write(
`[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`,
);
continue;
中文说明

listComments 重试耗尽后仅写 stderr,不像 handleInbound 失败路径那样调用 postErrorComment。评论永久丢失且用户无感知。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +407 to +409
} else {
cooldown = 1000 * 2 ** (attempt - 1);
}

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] githubApi retries all errors uniformly, including non-transient client errors (401, 404, 422) that will never succeed on retry. — Concrete cost: an expired PAT causes every pollOnce to burn 3 attempts on listNotifications (~3s delay); a deleted issue wastes 3 listComments calls per poll. Skip retries for 4xx (except 429 and rate-limit 403).

中文说明

githubApi 对所有错误统一重试,包括不会因重试而成功的非瞬态客户端错误(401、404、422)。应对 4xx 状态码(429 和限流 403 除外)跳过重试。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +405 to +408
const markOrder =
mockOctokit.rest.activity.markNotificationsAsRead.mock
.invocationCallOrder[0]!;
const commentOrder = mockOctokit.paginate.mock.invocationCallOrder[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.

[Suggestion] The ordering assertion reads invocationCallOrder[0], which is the background poll's markNotificationsAsRead from initWithoutLoop() — not the explicit poll's call. If pollOnce() were refactored to reorder mark-read after comment processing, the test would still pass. Use mockClear() after initWithoutLoop() or index [1].

中文说明

排序断言读取 invocationCallOrder[0],这是后台轮询的调用而非显式轮询的。如果 pollOnce() 被重构为调换 mark-read 顺序,测试仍会通过。

— qwen3.8-max-preview via Qwen Code /review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants