feat(channels): GitHub polling adapter with notification-as-wakeup architecture#7632
Conversation
…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).
E2E Test ReportTest PlanRepo:
Results
Score: 8/8 ✅ Bug Found & Fixed
Fix: Replaced Reply Count Audit
Observation (Not a Bug)The agent session has access to 中文翻译E2E 测试报告在全新公开仓库上运行 8 个测试场景,全部通过。 发现并修复一个 bug: 回复计数审计:所有 thread 的 bot 回复数量与预期完全一致,无重复、无遗漏、无自循环。 观察(非 bug):agent session 可使用 |
|
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 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 Size: This is a cross-package change touching Approach: The architecture is sound — notification-as-wakeup with
Moving on to code review. 🔍 中文说明感谢贡献!这是 #7266 关闭后 GitHub channel 适配器的重新设计。 模板完整 ✓ 问题: 这是功能新增,不是 bug 修复。前序 PR #7266 因三平台共享抽象不可行而在 6 轮 review 后关闭。本 PR 正确吸取了教训:单独实现 GitHub,使用原生 方向: 对齐。CHANGELOG 显示 channel 领域活跃开发中,新增代码托管 channel 是自然延伸。Claude Code 有 规模: 跨包变更,涉及 方案: 架构合理——notification-as-wakeup +
进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code ReviewIndependent proposal: For a GitHub polling adapter, I'd create a new Comparison: The PR's approach matches this closely. The Findings: One test failure: No critical blockers in the production code. The The Files changed (24 of 24 shown)
Test ResultsUnit tests (all pass): TypeScript build: Clean, no errors. CLI integration: GitHub channel type registered correctly: Test failure: The test hardcodes the expected channel type list and needs 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 轮询适配器,我会创建 对比: PR 方案与此高度一致。 发现: 一个测试失败: 生产代码无关键阻塞。 测试结果单元测试全部通过(35 + 9 + 897 = 941 个)。TypeScript 编译干净。CLI 集成正确注册了 GitHub channel 类型。 一个测试失败: 真实场景测试:此 CI 环境无 tmux。完整 E2E 测试需要真实 GitHub token 和运行中的 daemon。PR 作者报告 8/8 E2E 测试通过。 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 3/5 — clean review, but the cross-package scope (759 production lines) needs a maintainer's sign-off, and 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 The The What needs fixing: 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 中文说明置信度:3/5 — 审查干净,但跨包范围(759 生产行)需要维护者确认,且 这是一个设计良好的功能 PR。notification-as-wakeup 架构是 GitHub 通知 API 的正确选择——将通知作为线程级唤醒信号,使用
ChannelBase.ts 中的 需要修复: 为何转交: PR 触及共享 channel 基础设施(ChannelBase.ts、SessionRouter.ts、types.ts),跨多个包,759 生产逻辑行。按审查政策,此规模的跨包变更需要维护者确认——不是因为有问题,而是因为 ChannelBase 回归的影响范围是每个 channel 适配器。 ⏸️ 转交 @wenshao — 代码审查干净,架构合理,但跨包范围需要维护者确认。 — Qwen Code · qwen3.8-max-preview Reviewed at |
Review — GitHub polling channel adapterReviewed the full diff (24 files, +2110/−85) against Below are findings the triage bot didn't surface. 2 blockers, 4 worth fixing before merge. 🔴 Blockers1.
super(name, { ...config, sessionScope: 'chat_thread' }, bridge, options);That spread builds a new object. The caller's
All three inject Net effect with a config that doesn't spell out This is also a split-brain: Why testing missed it: the unit tests construct Suggested fix — add 2.
🟠 Should fix before merge3. try {
const comments = await this.octokit.paginate(this.octokit.rest.issues.listComments, {…});
…
} finally {
await this.markThreadAsRead(notification.id);
}There is no 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 with — Non-issue/PR subjects (releases, discussions, security alerts, CI failures) hit Related: there is no repo scoping at all — the adapter consumes notifications from every repo the account watches. 5. Failure to resolve the bot identity fails open —
Throw from 6. if (envelope.metadata) {
promptText = sanitizePromptText(envelope.metadata) + '\n\n' + promptText;
}This runs after the
Minor: the same header is re-prepended on every comment in a thread, not just the first. 7. this.config.sessionScope === 'single' ||
(target.isGroup === true && this.config.sessionScope === 'thread')
🟡 Nits / follow-ups
What's goodMention detection separating 中文版审查 — GitHub 轮询 channel 适配器已对照 以下是 triage bot 未发现的问题:2 个阻塞项,另有 4 项建议合并前修复。 🔴 阻塞项1.
结果:若配置中未显式写 同时这是"脑裂":channel 内部 测试为何未发现:单测构造 建议修法:在 2.
🟠 建议合并前修复3.
同形但较轻:第 N 条通知出错会中断整批,N+1 及之后本轮跳过(它们仍未读,下轮会回来)。 4. 适配器会把每一条通知标记已读,包括与它无关的 —— 非 issue/PR 主题(release、discussion、安全告警、CI 失败)都会先 相关:目前完全没有仓库范围限制 —— 账号订阅的所有仓库通知都会被消费。机制上可用 5. bot 身份解析失败时"失败即放行" ——
6. 该拼接发生在
次要:同一 header 会在线程中每条评论重复拼接,而非仅首次。 7. 判定式只覆盖 🟡 小问题 / 后续项
做得好的地方把 |
| threadId, | ||
| messageId: String(comment.id), | ||
| text, | ||
| isGroup: true, |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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') }, |
There was a problem hiding this comment.
[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.
| { 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
| if (!raw) return null; | ||
| const parsed: unknown = JSON.parse(raw); | ||
| if (typeof parsed !== 'object' || parsed === null) return null; |
There was a problem hiding this comment.
[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.
| 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
| case 'chat_thread': | ||
| return threadId | ||
| ? `${channelName}:${chatId}:${threadId}` | ||
| : `${channelName}:${chatId}`; |
There was a problem hiding this comment.
[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
| 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); | ||
| } |
There was a problem hiding this comment.
[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
| this.recentlyProcessed.add(String(comment.id)); | ||
| if ( | ||
| this.recentlyProcessed.size > GithubChannel.MAX_RECENTLY_PROCESSED | ||
| ) { | ||
| this.recentlyProcessed.clear(); | ||
| } |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
| } finally { | ||
| await this.markThreadAsRead(notification.id); | ||
| } |
There was a problem hiding this comment.
[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.
| } 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]}`; |
There was a problem hiding this comment.
[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.
| 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}`; |
There was a problem hiding this comment.
[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.
| const messageId = `issue-body-${issueNumber}`; | |
| const messageId = `issue-body-${chatId}-${issueNumber}`; |
— qwen3.7-max via Qwen Code /review
| if (envelope.metadata) { | ||
| promptText = sanitizePromptText(envelope.metadata) + '\n\n' + promptText; | ||
| } |
There was a problem hiding this comment.
[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
| if ( | ||
| this.recentlyProcessed.size > GithubChannel.MAX_RECENTLY_PROCESSED | ||
| ) { | ||
| this.recentlyProcessed.clear(); | ||
| } |
There was a problem hiding this comment.
[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.
| 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
| case 'chat_thread': | ||
| return threadId | ||
| ? `${channelName}:${chatId}:${threadId}` | ||
| : `${channelName}:${chatId}`; |
There was a problem hiding this comment.
[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
| const match = threadId.match(/^(?:issue|pr):(\d+)$/); | ||
| if (!match) { | ||
| process.stderr.write( |
There was a problem hiding this comment.
[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.
| 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
| if (!threadId) { | ||
| return super.sendThreadMessage(chatId, threadId, text); | ||
| } |
There was a problem hiding this comment.
[Suggestion] senderPolicy: 'pairing' is effectively dead on this channel — Concrete cost: onPairingRequired passes threadId: undefined → sendThreadMessage 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
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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': |
There was a problem hiding this comment.
[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
| 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}`, |
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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() }; |
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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'; |
There was a problem hiding this comment.
[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({ |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
| process.stderr.write( | ||
| `[Channel:${this.name}] invalid threadId format: ${threadId}\n`, | ||
| ); | ||
| return; |
There was a problem hiding this comment.
[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.
| 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
| 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(); | ||
| }); |
There was a problem hiding this comment.
[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
| isGroup: true, | ||
| isMentioned, | ||
| isReplyToBot: false, |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
| this.config.allowedUsers = resolved; | ||
| this.gate.replaceAllowedUsers(resolved); | ||
| this.startPollLoop(); |
There was a problem hiding this comment.
[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.
| 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. |
There was a problem hiding this comment.
[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
| 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 | ||
| ) { |
There was a problem hiding this comment.
[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
| this.webOrigin = baseUrl | ||
| .replace(/\/api\/v3\/?$/, '') | ||
| .replace(/^https:\/\/api\.github\.com/, 'https://github.com'); |
There was a problem hiding this comment.
[Suggestion] webOrigin derivation from baseUrl for GHE is not tested — Concrete cost: the regex logic strips /api/v3 and converts api.github.com → github.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 的 baseUrl 到 webOrigin 的推导没有测试——非典型的 baseUrl 可能产生错误的 metadata URL。
— qwen3.7-max via Qwen Code /review
…r NaN guard, retry/sendThreadMessage tests, docs fixes
Local real-build verification — head
|
| 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 → SessionRouter → sendThreadMessage |
| 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:
GET /notificationsis unread-only — a thread disappears once marked read, so a lost mention is genuinely unrecoverable.PUT /notificationsis 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.
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:4242 — channel: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)
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_atwhen 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: nullnotification (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_threadisolation 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
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 "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
@mentionre-dispatches it —listCommentsfilters onupdated_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
githubApiretries 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)
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 |
|
| R8 | silent loss on listComments failure |
|
| R8 | mark-read failure aborts the poll |
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 轮 review(d85ed6d),并覆盖此后新增的那一个 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 → SessionRouter → sendThreadMessage |
| CLI 入口(证据 1) | 真实 node packages/cli/dist/index.js channel start,真实 settings.json、插件注册表、agent 子进程(AcpBridge spawn --acp) |
| GitHub 本身 | 替换 —— mock 刻意复现了本设计所依赖的两个语义(见下) |
| 模型 provider | 替换 —— 本地 OpenAI 兼容端点,返回确定性 token |
这个 mock 不是一个"总是说 yes"的 stub,它复现了让这个问题变难的行为:
GET /notifications是 unread-only —— 线程一旦被标记已读就消失,所以丢失的 mention 是真的无法恢复。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)。最后这一组很关键:sendThreadMessage 和 Envelope.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:threadId。ChannelPlugin.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 失败路径会发出 "postErrorComment。
③ PUT /notifications 失败会让 channel 变成僵尸。(Low–Medium)
:168 的 markNotificationsAsRead 位于所有 try 之外,所以持续失败会在任何线程被查看之前中止 pollOnce:0 次 listComments、0 条回复,只有退避。设计文档的失效表写着 "markNotificationsAsRead fails → Cursor window still prevents duplicates → no impact on correctness"。去重那半是对的,结论不对 —— 什么都没被处理。对数据是 fail-safe,但是个静默死掉的 bot。建议:把这一个调用包进 try/catch(它本身就被明确定义为 best-effort 清理),并修正文档那一行。
D 组另外两个小项(在证据 2 中可见):
- D3 编辑一条已被回答过的
@mention会重新派发 ——listComments按updated_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 | isSharedSessionTarget 缺 chat_thread |
✅ 已修 |
| R1 | connect() 身份解析失败时 fail-open |
✅ 已修 —— 抛异常 |
| R1 | 生产依赖里的 octokit 元包 |
✅ 已修 —— @octokit/rest |
| R1 | 用可变 login 作 senderId |
✅ 已修 —— 数字 id + replaceAllowedUsers,A11b/L3 |
| R2 | listComments 失败 break 掉整批 |
✅ 已修 —— 改为 per-thread continue(静默丢失残留 = 问题 ②) |
| R3–R6 | null subject.url 使轮询循环崩溃 |
✅ 已修 —— A12,变异 M1 证明该 guard 承重 |
| R5 | 迟到的首次接触通知被丢弃 | ✅ 已修 —— dispatchedBodies,A9 + 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 得不出的结论。
…cy, github type enumerations, retry/webOrigin tests
|
Thank you to the maintainers and reviewers for the careful pass — all four inline comments in this review are now addressed. What changed
Verification
I've also merged the latest upstream ( 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 已全部处理。 改动内容
验证
另外已把最新 upstream( 我这边已 merge-ready。如果有帮助,我很乐意再跑一轮完整 E2E。再次感谢各位的 review! |
Review — GitHub channel adapter (re-review @
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[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
| "name": "@qwen-code/channel-github", | ||
| "version": "0.20.1", |
There was a problem hiding this comment.
[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.
| "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
Co-authored-by: Qwen-Coder <[email protected]>
Local real-build verification — round 2, head
|
| 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
| 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, "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/276 — 1263 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 中的用户被所有共享会话命令拒之门外
8744f6ab 从 connect() 中删除了 this.config.allowedUsers = resolved(GithubAdapter.ts:99-103),于是 config.allowedUsers 现在保留的是配置里的 login。三个事实叠加:
plugin.defaultSessionScope = 'chat_thread'(index.ts:11),而isSharedSessionTarget()对chat_thread返回true(ChannelBase.ts:3992)→ 每个 GitHub 线程都是共享会话。- GitHub envelope 的
senderId是数字 user id ——String(comment.user?.id ?? 'unknown')(GithubAdapter.ts:231)。 isAuthorizedForSharedSessionTarget()用authorized.includes(target.senderId)去比对this.config.allowedUsers(ChannelBase.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);去掉防护后 cooldown 为 NaN,setTimeout 立即触发,两次重试仅耗时 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 |
同一批次两个线程:#42 的 listComments 返回 500 → 0 条评论,cursor 越过它,且已被标记为已读(而 GET /notifications 只返回未读,所以它再也不会回来)。#43 的 agent 抛错 → 1 条评论「catch 里加一行即可消除这种不对称。 |
| S3 | :171 处的 markNotificationsAsRead 位于所有 try 之外 |
持续失败的 PUT 会让 pollOnce() 在进入循环前就抛出:0 次 listComments、0 条评论。对数据而言是 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); |
There was a problem hiding this comment.
[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
handleInboundfails, 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 viabreak)
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, |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
-
Two-layer dedup is well-designed. Layer 1: cursor-based comment window (
windowSince/maxUpdatedAt). Layer 2:dispatchedBodieswith bounded eviction (500 entries). Covers both normal and edge-case (late notification, failed mark-read) scenarios. -
Cursor validation —
validateCursorchecks date validity, normalizes baddispatchedBodies(non-array →[]). Safe against corrupt cursor files. -
Sender gate —
replaceAllowedUserswith immutable numeric IDs. Config retains original logins for reconnect idempotency. Well-tested. -
markRead timing — Marks read BEFORE processing (best-effort), so a crash during processing does not leave notifications permanently unread.
-
Abortable sleep / retry —
abortableSleepcorrectly resolves immediately on abort.retry-afterNaN guard falls back to exponential backoff. Rate-limit cooldown usesx-ratelimit-resetwith 1s buffer. -
Reconnect idempotency —
startPollLoopis a no-op if already running, creates a freshAbortController, resets error counter. Config not mutated. -
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_threadscope, 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.
yiliang114
left a comment
There was a problem hiding this comment.
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.
|
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
left a comment
There was a problem hiding this comment.
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
| await this.markNotificationsAsRead(maxUpdatedAt); | ||
|
|
||
| if (maxUpdatedAt > this.cursor.lastProcessedAt) { | ||
| this.cursor.lastProcessedAt = maxUpdatedAt; | ||
| } |
There was a problem hiding this comment.
[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.
| 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
| } catch (err) { | ||
| process.stderr.write( | ||
| `[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`, | ||
| ); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
[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.
| } 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
|
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 This will be addressed in a separate follow-up PR with a regression test covering a non-empty On S1: this is a GitHub platform limitation — 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),它们都不存在这个不匹配—— 将在单独的后续 PR 中修复,并包含 关于 S1: 这是 GitHub 平台限制—— 关于 S2/S3: 记录为后续跟进,同意它们不是合并阻塞项。 非常感谢各位 reviewer 在多轮 review 中的耐心和严谨——变异 A/B 方法论和通过真实构建 CLI 的全栈 E2E 让每个发现都有很高的可信度。感谢 🙏 |
wenshao
left a comment
There was a problem hiding this comment.
[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
| await this.markNotificationsAsRead(maxUpdatedAt); | ||
|
|
||
| if (maxUpdatedAt > this.cursor.lastProcessedAt) { |
There was a problem hiding this comment.
[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.
| 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
| await this.postErrorComment(chatId, issueNumber); | ||
| dispatchedMention = true; | ||
| break; |
There was a problem hiding this comment.
[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
| process.stderr.write( | ||
| `[Channel:${this.name}] API error processing ${threadId}, skipping: ${err}\n`, | ||
| ); | ||
| continue; |
There was a problem hiding this comment.
[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.
| 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
| } else { | ||
| cooldown = 1000 * 2 ** (attempt - 1); | ||
| } |
There was a problem hiding this comment.
[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
| const markOrder = | ||
| mockOctokit.rest.activity.markNotificationsAsRead.mock | ||
| .invocationCallOrder[0]!; | ||
| const commentOrder = mockOctokit.paginate.mock.invocationCallOrder[2]!; |
There was a problem hiding this comment.
[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








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
listCommentswithin a cursor-based time window to determine which comments are new. Dedup relies on a global cursor window(windowSince, maxUpdatedAt]— not on GitHub's asyncmarkNotificationsAsRead— and bot self-comments are filtered to prevent loops.Notification/comment timestamp decoupling: notification
updated_atand commentupdated_atare decoupled — a notification can arrive 16s after the comment that triggered it, and can be bumped by unrelated activity (label, push).PUT /notificationsis async (202) with alast_read_atcutoff; the bot's reply bumpsupdated_atpast 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 viavalidateCursor()virtual hook (subclasses reject wrong-shape cursors and unparseable dates; invalid → fallback tocreateInitialCursor()), exponential backoff (2s–30s), start/stop lifecycle with re-entrancy guard, and configurablepollInterval(read from channel config, validated as positive finite number, defaults to 60s). ExposesabortableSleep(ms)as a protected method so subclasses can write interruptible waits. ResetsconsecutiveErrorsonstartPollLoop()so stop/restart cycles start with fresh 2s backoff instead of inheriting stale elevated values. Subclasses implement onlypollOnce()andcreateInitialCursor().GitHub adapter:
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.windowSince) serves as the exclusive lower bound;maxUpdatedAt(max notificationupdated_atin the batch) serves as the upper bound. Comments outside(windowSince, maxUpdatedAt]are excluded. This prevents duplicates even whenPUT /notifications' async mark fails (bot reply bumpsupdated_atpast the cutoff).senderIduses immutable numericuser.id(not mutable login).allowedUserslogins are resolved to numeric IDs atconnect()viausers.getByUsername, and theSenderGateis updated with the resolved IDs viareplaceAllowedUsers().senderNameretains login for agent-facing display. Bot self-comment filtering comparesuser.idagainstbotUserId./issues/Nand/pulls/Nsubject URLs, withthreadIdset toissue:Norpr:Naccordingly. Notifications without asubject.url(Discussion, SecurityAlert) are skipped.cc:@bot,"@bot", start-of-text; rejects partial usernames, emails, embedded words).stripBotMentionpreserves markdown indentation (no trim).!dispatchedMention) and the notification has nolast_read_at(brand-new thread), fetches the issue/PR body and processes it. Dedup uses adispatchedBodiesarray persisted in the cursor (bounded to 500 entries, oldest evicted) — unlike acreated_at > cursorguard, this also feeds bodies whose notification arrived late after the cursor had advanced pastcreated_at.connect()resolves bot identity and throws on failure (no fail-open).baseUrlconfig drives both API and web URLs in metadata.this.proxyfromChannelBaseOptionsand passesHttpsProxyAgentto Octokit, matching the Telegram adapter pattern.continue(best-effort) — one failed notification does not block the rest of the batch. OnhandleInboundfailure, posts one error comment per thread per poll cycle thenbreaks the comment loop (prevents N identical error comments; user re-mentions to retry). First-contacthandleInboundfailure also recordsdispatchedBodiesto prevent duplicate error comments when mark-read async hasn't taken effect. All Octokit calls (includingsendThreadMessage,postErrorComment, andtryFirstContactBody'sissues.get) go through thegithubApiretry wrapper with rate-limit awareness (retry-after,x-ratelimit-resetheaders) and abortable cooldowns (abortableSleepinstead of rawsetTimeout, sodisconnect()can interrupt rate-limit waits).nowon first start — existing unread notifications are skipped.ChannelBase foundation changes:
sendThreadMessage(chatId, threadId, text)— thread-targeted delivery. All internalsendMessagecalls (shell output, error messages, pairing codes) now route through it. IM adapters unaffected (default fallthrough tosendMessage). Throws on invalidthreadIdformat in the GitHub adapter.Envelope.metadata— contextual metadata (issue type, title, URL) kept separate fromtextso slash-command parsing operates on the comment body alone. Appended to the prompt after command parsing, sanitized viasanitizePromptText. Cleared on collect-mode synthetic envelopes (both drain paths).chat_threadsession scope — routing keychannel:chatId:threadIdprevents cross-repo session collision. Falls back tochannel:chatIdwhen nothreadId. Added toSessionScopetype andisSharedSessionTarget.defaultSessionScopeonChannelPlugin— applied inparseChannelConfigwhen config omitssessionScope, so router and adapter agree onchat_threadwithout explicit user config.threadIdthroughonPairingRequired, so pairing codes are delivered on threaded channels instead of throwing.sendResponseMessageresolvesthreadIdfromrouter.getTargetand passes it tosendThreadMessage.Registration, build & docs:
githubpackage.docs/design/channels/channel-code-hosting.md), user guide (docs/users/features/channels/github.md), and developer docs updated.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_atduplicates,latest_comment_urllosing comments,isMentionedfalse positives, bot self-loop,stripBotMentioncorrupting markdown). E2E testing additionally uncovered thatPUT /notificationsis 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
~/.qwen/settings.jsonwith a classic PAT bot tokennpm run dev -- channel start <name>@bot helloon an issue → bot replies within ~60sisMentioned: false, gated by GroupGate)@bot helpin body → bot replies (first-contact)@botin message → no reply (non-issue/PR notification skipped)pollIntervalin 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 bundle→node dist/cli.js channel start, headd85ed6dd6,pollInterval15s, modelqwen3.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.issue:N/pr:N)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-0724dand round 2 onchannel-e2e-07250549(a834527); v3–v7 onchannel-e2e-0724cconfirmed 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
Environment
npm run dev(tsx), Node v26, real GitHub API.Risk & Scope
handleInboundis awaited in poll loop — slow agent blocks subsequent polls.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_atbump 超过截止线,导致 mark 永远不生效。基于 cursor 的评论窗口完全绕开了这个问题。轮询基础设施:
PollingChannelBase<Cursor>— 泛型轮询基类。封装 cursor 持久化(JSON、原子写入、sha256 哈希文件名防碰撞)、cursor 校验(validateCursor()虚方法,子类拒绝错误形状和不可解析日期)、指数退避(2s–30s)、带重入保护的 start/stop 生命周期、可配置pollInterval(校验正有限数,默认 60s)。暴露abortableSleep(ms)为 protected 方法供子类使用。GitHub 适配器:
markNotificationsAsRead(maxUpdatedAt)在处理循环之前调用,防止中断后重复处理。去重不依赖 mark。windowSince(推进前 cursor)为排他下界,maxUpdatedAt为上界。senderId用不可变user.id。connect()解析allowedUserslogin → 数字 ID,并通过replaceAllowedUsers()更新 SenderGate。/issues/N和/pulls/N。无subject.url的通知(Discussion、SecurityAlert)被跳过。stripBotMention不做 trim。!dispatchedMention && !lastReadAt时获取 issue/PR body。去重用 cursor 中的dispatchedBodies数组(上限 500,淘汰最旧)。connect()解析 bot 身份,失败抛异常(不 fail-open)。baseUrl驱动 API 和 metadata web URL。HttpsProxyAgent传给 Octokit。continue;handleInbound失败发一条错误评论后break(防 N 条相同评论);所有 Octokit 调用走githubApi重试封装。ChannelBase 基础变更:
sendThreadMessage— 线程定向投递。Envelope.metadata— 与text分离,命令解析后追加。collect 模式两个 drain 路径均清除。chat_threadsession scope — 路由键channel:chatId:threadId。defaultSessionScopeonChannelPlugin。threadId。sendResponseMessage解析threadId。注册、构建与文档: registry、build、clean 脚本包含
github包。设计文档、用户指南、开发者文档已更新。为什么需要
早期共享抽象 PR 经 6 轮 review 后关闭。本 PR 单独实现 GitHub,使用 cursor 评论窗口,解决所有先前阻塞性问题。
Reviewer 测试计划
见英文部分。单元测试:912 ChannelBase、29 GitHub 适配器、9 PollingChannelBase、17 mention — 全部通过(共 958 个)。
风险与范围
关联 Issue
引用 #7266(已关闭)。