Skip to content

feat(serve): backfill session PR bindings and refresh their merge state - #9729

Open
wenshao wants to merge 24 commits into
QwenLM:mainfrom
wenshao:feat/session-pr-state-refresh
Open

feat(serve): backfill session PR bindings and refresh their merge state#9729
wenshao wants to merge 24 commits into
QwenLM:mainfrom
wenshao:feat/session-pr-state-refresh

Conversation

@wenshao

@wenshao wenshao commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Extends the session↔PR binding feature (#9543) in two directions. First, an on-demand daemon route backfills PR bindings onto sessions that predate the feature: for every trusted workspace it scans the persisted session catalog (active and archived), resolves each session's PR numbers from the worktree slug/branch convention and — the dominant source in practice — from the intersection of the git branches recorded in each session's transcript with the repository's PR head branches (one batched, slim gh pr list --state all per workspace), then writes the existing per-session PR sidecars. Second, every binding now carries a merge-state snapshot (open / merged / closed): the creation dialog records open, backfill records the state observed at query time, and a low-frequency daemon sweep (default 5 minutes, configurable, off-able via env) advances the snapshots by re-querying only workspaces that still hold non-merged bindings, rewriting state in place without touching binding order or timestamps. The sidebar badge dims merged PRs and the session details tooltip labels merged/closed rows.

Why it's needed

Operators running dozens of concurrent sessions rely on "find the session by PR number", but sessions created before the binding feature had no binding, and even bound PRs went stale: a badge kept its open accent long after the PR merged. Real data showed the originally-planned backfill sources (worktree slug/branch convention) hit almost nothing — PRs are practically never submitted from worktree branches — while the transcript's recorded git branches intersect PR head branches for the large majority of sessions (272 of 342 in the primary workspace), so backfill now uses that intersection. The state snapshot plus the background sweep make the sidebar answer "which of my sessions produced PR N, and is it still open?" without any manual re-run and without putting network calls on the session-list polling path.

Reviewer Test Plan

How to verify

  • Unit coverage: run the collocated suites — the sidecar service (state persisted on bind, preserved on stateless re-bind, in-place state rewrite that keeps order/createdAt, serialization against concurrent upserts), the gh helper (slim field set, --state all/limit passthrough, MERGED/CLOSED mapping), the backfill route (convention binding, remote-URL fallback, transcript-branch binding, multi-PR binding, idempotency), the refresh sweep (open→merged rewrite, no gh call when everything is merged, gh failure swallowed, reopened-closed PR tracked back to open, interval env parsing), the list merge (sidecar state wins over the live bind-time state), and the badge (merged dimmed, open/stateless accent).
  • Live daemon: start qwen serve against a workspace with persisted sessions, POST /sessions/backfill-prs, and confirm the response reports scanned/bound counts and that the session list now returns prs with state; the Web Shell sidebar shows the badge and dims it once the sweep (or a manual wait past the interval) flips a binding to merged.

Evidence (Before & After)

Before: 6454 persisted sessions across 25 registered workspaces carried zero bindings; the list returned no prs. After running the backfill once on a real daemon: 575 bindings written (288 in the primary workspace, 157 in fastjson2, …), and the first page of the session list showed prs on 99 of 100 rows. Before: a merged PR's badge kept the accent style and the tooltip showed no state. After: merged badges render dimmed and tooltip rows read e.g. "Pull Request #9517 · Merged" / "合并请求 #9517 · 已合入".

Tested on

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

Environment

npm run dev -- serve against the operator's real daemon (25 workspaces, ~6.4k sessions) plus the repo's vitest suites; gh 2.91 authenticated.

Risk & Scope

  • Main risk or tradeoff: a session that merely checked out or reviewed another PR's branch also gets bound to that PR (the transcript-branch source); this is accepted as "related session" semantics and the multi-PR list absorbs it. The refresh sweep adds one slim gh pr list --state all --limit 500 per workspace per interval, but only for workspaces holding non-merged bindings; full-field queries at that size hit GitHub GraphQL 504s, hence the slim field set.
  • Not validated / out of scope: automatic discovery of PRs created by agents via shell commands (would require re-scanning transcripts on the timer — deliberately excluded; the dialog creation path self-binds, covering the main flow). Windows/Linux not exercised locally (no OS-specific code paths beyond existing gh spawning).
  • Breaking changes / migration notes: none — state is optional at every validation layer (route, bridge, SDK, sidecar reader), old sidecars without state keep working, and the sweep is disabled with QWEN_SESSION_PR_REFRESH_MINUTES=0.

Linked Issues

Follow-up to #9543 (session↔PR binding).

中文说明

这个 PR 做了什么

在会话↔PR 绑定功能(#9543)基础上扩展两个方向。其一,新增按需 daemon 路由为存量会话回填 PR 绑定:遍历所有 trusted workspace 的持久化会话(active + archived),从 worktree slug/branch 约定、以及(实践中最主要的来源)会话 transcript 记录的 git 分支与仓库 PR head 分支的交集解析 PR 号(每 workspace 一次批量 slim gh pr list --state all),写入既有的每会话 PR sidecar。其二,每个绑定现在携带合入状态快照(open / merged / closed):创建对话框记 open,回填记查询时刻的状态,一个低频 daemon 定时任务(默认 5 分钟,可用环境变量配置间隔或关闭)只对仍含未合入绑定的 workspace 重新查询并原地回写状态,不改变绑定顺序与时间戳。侧栏 badge 对已合入 PR 弱化显示,会话详情 tooltip 为 merged/closed 行标注状态。

为什么需要

同时运行数十个会话的操作者依赖"按 PR 号找会话",但绑定功能之前的存量会话没有绑定;即使绑定了,PR 合入后 badge 也长期保持 open 样式。真实数据显示原计划的回填来源(worktree slug/branch 约定)几乎零命中——PR 基本不从 worktree 分支提交——而 transcript 记录的 git 分支与 PR head 分支的交集能覆盖大多数会话(主 workspace 342 个会话命中 272 个),因此回填改用该交集。状态快照 + 后台刷新让侧栏无需手动重跑即可回答"哪个会话产出了 PR N,它合入了吗",且不在会话列表轮询热路径上放网络调用。

审查者测试计划

如何验证

  • 单测:sidecar 服务(绑定时持久化 state、无 state 重绑保留旧值、原地回写保持顺序/createdAt、与并发 upsert 串行化)、gh 助手(slim 字段、--state all/limit 透传、MERGED/CLOSED 映射)、回填路由(约定绑定、remote URL 兜底、transcript 分支绑定、多 PR 绑定、幂等)、刷新任务(open→merged 回写、全 merged 时零 gh 调用、gh 失败静默、closed 重开回 open、间隔环境变量解析)、列表合并(sidecar state 优先于 live 绑定时 state)、badge(merged 弱化、open/无 state 保持高亮)。
  • 真实 daemon:对含存量会话的 workspace 启动 qwen servePOST /sessions/backfill-prs,确认响应 scanned/bound 计数与会话列表返回带 stateprs;Web Shell 侧栏显示 badge,合入后弱化。

证据(前后对比)

之前:25 个注册 workspace 的 6454 个存量会话零绑定,列表无 prs。真实 daemon 跑一次回填后:写入 575 条绑定(主 workspace 288、fastjson2 157 等),列表首页 100 行中 99 行带 prs。之前:已合入 PR 的 badge 保持高亮、tooltip 无状态。之后:merged badge 弱化显示,tooltip 显示"Pull Request #9517 · Merged"/"合并请求 #9517 · 已合入"。

测试平台

macOS ✅;Windows/Linux ⚠️ 未本地验证(无 OS 特有路径)。

环境

npm run dev -- serve 操作者真实 daemon(25 workspace、约 6.4k 会话)+ 仓库 vitest 套件;gh 2.91 已认证。

风险与范围

  • 主要风险/权衡:仅 checkout/review 过他人 PR 分支的会话也会被绑定(transcript 分支来源);按"相关会话"语义接受,多 PR 列表可容纳。刷新任务每间隔每 workspace 一次 slim gh pr list --state all --limit 500,且仅对含未合入绑定的 workspace 发起;全字段查询在该规模下触发 GitHub GraphQL 504,故用 slim 字段。
  • 未验证/超出范围:agent 在 shell 里 gh pr create 的自动发现(需在定时器里重扫 transcript,刻意不做;对话框创建路径自绑定,覆盖主流)。Windows/Linux 未本地跑。
  • 破坏性变更/迁移:无——state 在所有校验层(route/bridge/SDK/sidecar 读取)均为可选,旧 sidecar 无 state 继续可用;QWEN_SESSION_PR_REFRESH_MINUTES=0 可关闭定时刷新。

关联

#9543(会话↔PR 绑定)的后续。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

Legacy sessions predate the PR-binding feature, so the sidebar had no way to answer 'which session produced PR N'. An on-demand route scans every trusted workspace's persisted sessions, resolves PR numbers from the worktree slug/branch convention and from transcript gitBranch x gh headRefName intersections (the dominant source in practice), and writes the existing .pr.json sidecars. Bound PRs now carry a state snapshot (open/merged/closed) that a 5-minute daemon sweep advances via a slim gh pr list --state all query, and the sidebar badge dims merged PRs while the tooltip names merged/closed ones.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the follow-up to the session↔PR binding work!

Template looks good ✓

Problem: observed, not theoretical. The description carries real operator data — 6,454 persisted sessions across 25 workspaces with zero bindings before backfill, and bound badges keeping their open accent long after the PR merged. It extends #9543, which landed on main this morning.

Direction: aligned. This completes the "which session produced PR N, and is it still open?" story for serve-mode operators. No direct reference in the comparison CHANGELOG, but the area is a continuation of a feature that just merged.

Size: the numbers GitHub shows for this PR (58 files, +5516/−229) are misleading — the branch diverged from main before #9543 was merged, so the displayed diff re-includes #9543's already-merged content. The true delta on top of merged #9543 is ~25 files, +1707/−48 (≈790 production lines, ≈860 test lines, the rest docs). It does touch core paths (packages/core/src/services, packages/core/src/utils) and spans five packages; the author is a maintainer, so the two-tier core gate is exempt per AGENTS.md — noting size for transparency only.

Approach: sound and reuse-first. The backfill extends the existing fetchGitHubPullRequests helper with a slim field set instead of adding a parallel gh utility, reuses the #9543 sidecar/upsert machinery, and keeps the refresh sweep off the session-list polling path. Bundling backfill + merge-state is fair since both hang off the same new state field.

Risk: no matches against the revert-history high-risk paths.

⚠️ One gate-level flag before the code review: the branch currently conflicts with main (mergeStateStatus: DIRTY), and its displayed diff is polluted by the pre-#9543 divergence. I reviewed the true delta (f489d2c8f09d60) anyway — see the review comment — but this needs a rebase onto current main before it can merge.

Moving on to code review. 🔍

中文说明

感谢这个会话↔PR 绑定功能的后续 PR!

模板完整 ✓

问题:已观测到的真实问题,不是理论性的。描述里有真实的运营数据——回填前 25 个 workspace 的 6454 个持久化会话零绑定,且已绑定的 badge 在 PR 合入后长期保持 open 高亮。这是今早刚合入 main 的 #9543 的延续。

方向:对齐。补齐了 serve 模式操作者"哪个会话产出了 PR N,它是否还开着"的闭环。对比 CHANGELOG 无直接条目,但该方向是刚合入功能的自然延续。

规模:GitHub 上显示的规模(58 文件,+5516/−229)有误导性——分支在 #9543 合入之前从 main 分出,因此展示的 diff 重复包含了 #9543 已合入的内容。相对已合入 #9543 的真实增量约 25 个文件、+1707/−48(约 790 行生产代码、约 860 行测试,其余为文档)。确实触及核心路径(packages/core/src/servicespackages/core/src/utils)并横跨 5 个包;作者是维护者,按 AGENTS.md 豁免两层核心门禁——规模仅作透明记录。

方案:合理且优先复用。回填通过 slim 字段集扩展了现有 fetchGitHubPullRequests helper,而不是新写一套 gh 工具;复用了 #9543 的 sidecar/upsert 机制;刷新任务避开了会话列表轮询热路径。回填 + 合入状态打包在一起合理,两者都落在同一个新增 state 字段上。

风险:未命中 revert 历史高风险路径。

⚠️ 进入代码审查前的一个门禁层面提醒:分支当前与 main 冲突mergeStateStatus: DIRTY),且展示的 diff 被 #9543 合入前的分叉污染。我已按真实增量(f489d2c8f09d60)完成审查——见审查评论——但合入前需要先 rebase 到最新 main。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Reviewed the true delta of this PR — f489d2c8f09d60, 25 files, +1707/−48 — rather than the GitHub diff, which is polluted by the pre-#9543 divergence (see the gate comment). Before reading it I sketched my own design: optional state on the sidecar validated at every layer, an on-demand backfill route with one batched slim gh query per workspace, a low-frequency timer that only re-queries workspaces holding non-merged bindings, in-place state rewrites that never reshuffle the list, and sidecar-state-wins merging on the list path. The PR matches this essentially point for point, and it reuses the existing machinery (fetchGitHubPullRequests, the sidecar upsert queue, listSessions) instead of building parallel helpers. Test coverage is thorough and colocated.

One real finding — the merge-state refresh does not propagate to an open sidebar:

  • The sweep rewrites sidecars through the core service, which never bumps the bridge's in-memory catalog revision. The web-shell's 2s tick only applies live flags; a full catalog refetch — the only path that re-reads sidecars and their state — happens on a catalog-version change or an interactive invalidation (workspace-session-live-state.ts reconciles only when versionsEqual fails). The live-state response itself carries no prs.
  • Net effect: after the sweep flips a binding open→merged, the badge in an already-open sidebar keeps its accent until some unrelated catalog change bumps the revision or the page reloads. The persisted data is correct and self-heals, but the design doc's "badge 经现有 2s 轮询自动更新" claim doesn't hold for the sweep path.
  • The fix looks cheap and has precedent: markSessionCatalogChanged() is on the public bridge interface and the cli layer already calls it from timer contexts (e.g. scheduled-task-keepalive.ts). When a sweep iteration actually updates ≥1 binding for a workspace, bump that workspace's bridge revision so the next 2s tick refetches.

Everything else I checked reads clean:

  • state is validated identically at all four layers (route, bridge, ACP dispatch, SDK guard) and stays optional end-to-end — old sidecars without state keep working.
  • updateSessionPrStates shares the sidecar mutation queue with upsertSessionPr (no read-modify-write interleaving), preserves order and createdAt, and skips the write entirely when nothing changed.
  • Only merged is treated as terminal — a reopened closed PR tracks back to open, and a number absent from the 500-entry gh page is skipped rather than reset.
  • The slim field set exists for a stated reason (full-field --state all --limit 500 hits GraphQL 504s), the timer is unref'd, delayed past boot, re-entrancy guarded, and disposed on runtime rebuild like the metrics sampler.
  • Backfill is idempotent, skips untrusted workspaces, counts already-bound sessions without rewriting them, and falls back to the git remote web URL for convention numbers when gh is unavailable.

The state-refresh flow, including where propagation currently stalls:

sequenceDiagram
    participant P1 as Backfill route (on demand)
    participant P2 as Refresh timer (default 5 min)
    participant P3 as gh pr list (slim, state all)
    participant P4 as Session PR sidecars
    participant P5 as Web Shell sidebar
    P1->>P3: one batched query per workspace
    P3-->>P1: number, url, headRefName, state
    P1->>P4: upsert bindings with state
    P2->>P4: read non-merged bindings
    P2->>P3: only when targets exist
    P3-->>P2: current states
    P2->>P4: rewrite state in place
    P5->>P4: full refetch only on catalog version change
    Note over P4,P5: sweep writes do not bump the catalog version
Loading
Files changed — true delta (14 production files of 25 shown; tests omitted)
File What changed
packages/cli/src/serve/routes/session-pr-backfill.ts New POST /sessions/backfill-prs route; convention + transcript-branch resolution, remote-URL fallback, per-workspace aggregation
packages/cli/src/serve/server/session-pr-refresh.ts New low-frequency sweep plus env-configurable timer (0 disables), unref'd with delayed first run
packages/core/src/services/session-pr-service.ts Optional state field, in-place updateSessionPrStates rewrite, shared mutation queue for upserts and refreshes
packages/core/src/utils/github-prs.ts Slim field set, state/limit options, MERGED/CLOSED mapping with draft fallback
packages/cli/src/serve/routes/session.ts Metadata routes accept and validate the optional state
packages/cli/src/serve/server/session-list.ts Sidecar state wins over the live bind-time state on merge
packages/cli/src/serve/server.ts Registers the backfill routes
packages/cli/src/serve/run-qwen-serve.ts Timer lifecycle, disposed with the runtime rebuild
packages/cli/src/serve/acp-http/dispatch.ts ACP metadata path persists and echoes state
packages/acp-bridge/src/bridge.ts State validation and state preservation on re-bind
packages/acp-bridge/src/bridgeTypes.ts SessionPrInfo gains the optional state
packages/sdk-typescript/src/daemon/session-pr.ts SDK guard mirrors the state validation
packages/sdk-typescript/src/daemon/types.ts DaemonSessionPrInfo gains the optional state
packages/web-shell/client/components/… SessionPrBadge dims merged, tooltip labels merged/closed, GitDialog records open on create, i18n keys EN+ZH

Testing

This is an unattended CI run: no PR code was built or executed here. Test evidence below is the PR's own CI, read through the API — and at review time the build/test lanes have not started for this head SHA. Only the bot orchestration checks exist (precheck and labeler completed green; triage and the review job still in flight). No pull_request-event workflow run exists for 8f09d60 at all, so the unit and OS suites have not exercised this code yet — likely downstream of the branch being stale and conflicting.

Check Conclusion
precheck-pr / precheck ✅ success (×2)
label ✅ success
Qwen Triage ⏳ in progress
🧐 Qwen Pull Request Review ⏳ in progress
Unit suites (core/cli/bridge/web-shell/sdk) ⬜ not started — no check exists on this SHA yet
OS lanes (macOS/Windows/Linux) ⬜ not started — no check exists on this SHA yet

The finalize workflow rewrites the table above in place once CI settles.

Not verified: the author's live-daemon numbers (575 bindings written, 272-of-342 transcript-branch hits, 99/100 list rows carrying prs) are the author's claim from their operator daemon, not independently re-run here. Sandboxed verification would settle the behavioural claims once the rebase lands: @qwen-code /verify — that the backfill actually resolves PR numbers from transcript branches against real gh data and that the sweep advances open→merged in place without reshuffling bindings is not observable from the diff, and no CI has exercised this head yet. @qwen-code /tmux is also available (author has write access) for the badge-dimming TUI surface.

中文说明

代码审查

按本 PR 的真实增量审查(f489d2c8f09d60,25 个文件,+1707/−48),而不是被 #9543 合入前分叉污染的 GitHub diff(见门禁评论)。读代码前我先独立设计了方案:sidecar 上加可选 state 并在各校验层统一验证、按需回填路由(每 workspace 一次批量 slim gh 查询)、低频定时器只重查仍含未合入绑定的 workspace、原地回写 state 且不重排列表、列表合并时 sidecar 状态优先。PR 的实现与这套方案基本逐点对齐,且复用了现有机制(fetchGitHubPullRequests、sidecar upsert 队列、listSessions),没有另起平行 helper。测试覆盖充分且就近放置。

一个真实发现——合入状态刷新不会传播到已打开的侧栏

  • 定时任务通过 core 服务回写 sidecar,从不触发 bridge 内存里的 catalog revision。web-shell 的 2s 轮询只应用 live 标志;完整目录重新拉取(唯一会重读 sidecar 及其 state 的路径)只在 catalog version 变化或主动失效时发生。live-state 响应本身不含 prs
  • 结果:定时任务把绑定从 open 翻成 merged 后,已打开侧栏里的 badge 会保持高亮,直到某个无关的目录变更触发 revision 增加或页面重新加载。持久化数据正确且最终会同步,但设计文档"badge 经现有 2s 轮询自动更新"的说法对定时刷新路径不成立。
  • 修复看起来便宜且有先例:markSessionCatalogChanged() 在 bridge 公共接口上,cli 层已有在定时器上下文调用它的先例(如 scheduled-task-keepalive.ts)。当某轮刷新确实更新了某个 workspace 的至少一条绑定时,bump 该 workspace bridge 的 revision,下一个 2s tick 即会重新拉取。

其余检查均干净:

  • state 在四层(路由、bridge、ACP dispatch、SDK 守卫)做相同校验,且端到端可选——无 state 的旧 sidecar 继续可用。
  • updateSessionPrStatesupsertSessionPr 共享同一 sidecar 变更队列(无读写交错),保持顺序与 createdAt,无变化时完全跳过写入。
  • 只有 merged 视为终态——重开的 closed PR 会被追踪回 open;gh 500 条窗口之外的编号被跳过而不是被重置。
  • slim 字段集有明确理由(全字段 --state all --limit 500 触发 GraphQL 504);定时器 unref、启动延迟、防重入,并像 metrics sampler 一样在 runtime 重建时 dispose。
  • 回填幂等、跳过非信任 workspace、已绑定会话只计数不重写、gh 不可用时对约定编号回退到 git remote web URL。

测试

这是无人值守 CI 运行:此处未构建或执行任何 PR 代码。下方测试证据来自 PR 自身 CI 的 API 读取——审查时刻构建/测试通道尚未启动。只有机器人编排类检查(precheck 与打标签已绿;triage 与审查任务进行中)。8f09d60 上不存在任何 pull_request 事件的 workflow 运行,单测与三大操作系统套件都还没跑过这份代码——大概率与分支过期且冲突有关。

未验证:作者的真实 daemon 数字(写入 575 条绑定、342 个会话中 272 个经 transcript 分支命中、列表首页 100 行中 99 行带 prs)是作者在其运营者 daemon 上的自述,未在此独立复跑。rebase 落地后可用沙箱验证坐实行为性声明:@qwen-code /verify——回填是否真的能从 transcript 分支对着真实 gh 数据解析出 PR 号、定时任务是否真的原地把 open 推进到 merged 且不重排绑定,这些从 diff 观察不到,且尚无 CI 跑过此 head。@qwen-code /tmux 也可用(作者有写权限),用于验证 badge 弱化的 TUI 呈现。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the implementation itself is solid and reviews clean apart from one fixable propagation gap; the score reflects merge-readiness, because the branch as it stands cannot merge.

Stepping back: the motivation is real (operator data, not a hypothetical), the design doc was updated alongside the code, and the true delta (~790 production lines over merged #9543) is tight, reuse-first, and carries roughly a 1:1 test ratio. If this had been rebased onto current main, it would have been an easy approve with one Suggestion — the sweep not bumping the catalog revision, so merged badges don't dim in an open sidebar until something else triggers a refetch.

What blocks it is mechanical but hard: the branch diverged before #9543 landed, GitHub reports it CONFLICTING, and the displayed diff re-includes #9543's already-merged content — a reviewer reading the GitHub diff would be reviewing 58 files and 5,700 lines when the actual contribution is 25 files and ~1,750. On top of that, no pull_request-event CI has run on this head SHA, so none of those tests have actually executed against current main. The path forward is a rebase onto main (the true delta applied cleanly, dropping the now-duplicated #9543 history), address or answer the sidebar-propagation finding, and let CI run — then re-trigger triage and I'll re-review the rebased head.

中文说明

信心:2/5 —— 实现本身扎实,除一个可修复的传播缺口外审查干净;这个分数反映的是合入就绪度,因为当前分支无法合入。

整体看:动机真实(运营数据,不是假设),设计文档随代码同步更新,相对已合入 #9543 的真实增量(约 790 行生产代码)紧凑、优先复用,测试比例接近 1:1。如果它已 rebase 到最新 main,本可以轻松通过,只带一条建议——定时刷新没有 bump catalog revision,导致已打开侧栏中的 merged badge 要等到别的变更触发重新拉取才会弱化。

阻塞项是机械性但硬性的:分支在 #9543 合入前分出,GitHub 报告冲突,且展示的 diff 重复包含了 #9543 已合入的内容——照 GitHub diff 审查的人会在 58 个文件、5700 行里打转,而真实贡献只有 25 个文件、约 1750 行。此外此 head SHA 上没有任何 pull_request 事件的 CI 运行,这些测试尚未在最新 main 上真正跑过。前进路径:rebase 到 main(干净地应用真实增量,丢弃已重复的 #9543 历史),处理或回应侧栏传播问题,让 CI 跑起来——然后重新触发 triage,我会复审 rebase 后的 head。

Qwen Code · qwen3.8-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Needs a rebase onto current main before it can merge: the branch diverged before #9543 landed, so it conflicts with main and the displayed diff re-includes #9543's already-merged content (true contribution is ~25 files, not 58). The true delta reviews cleanly — one Suggestion on badge refresh propagation (the sweep should bump the bridge catalog revision), details in my notes above. @wenshao once rebased, re-run @qwen-code /triage and I'll review the new head. 🙏

@wenshao
wenshao force-pushed the feat/session-pr-state-refresh branch from 8f09d60 to 0c0dbd2 Compare August 22, 2026 14:24
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 3375e2a. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ No preview: one or more scenarios failed to render on this head — see the workflow run. This is not "no visual change" — a scenario that times out or throws produces no image. Fix the failing scenario (or a genuine regression it caught) and the preview returns on the next push.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

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

Qwen Code · serve A/B

run-qwen-serve is a pre-listen bundle root whose static closure must stay free of the SessionService chain (glob et al.). Loading session-pr-refresh statically pulled that chain in; a dynamic import() of the core barrel from inside the refresh module was worse — it made the barrel's full namespace live and poisoned the shared chunk for every static barrel importer (ACP agent included). Load the whole refresh module through a dynamic import at timer start instead, guarded by a generation counter against dispose races.
process-env-guard scans serve sources for process.env reads; register QWEN_SESSION_PR_REFRESH_MINUTES as a documented process-scoped switch. GitDialog now sends state 'open' with the binding, so the dialog tests assert it.
@wenshao

wenshao commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 22, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

中文说明

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

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

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

Comment on lines +42 to +43
if (minutes === 0) return undefined;
return minutes * 60_000;

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] R1-17: An interval whose converted milliseconds exceed Node's 32-bit timer max (2,147,483,647 ms — anything above ~24.8 days) passes the finite/non-negative gates on minutes, but setInterval clamps out-of-range delays to 1 ms — converting "longer interval" into a continuous sweep hot loop. QWEN_SESSION_PR_REFRESH_MINUTES=1e308 reaches the same clamp another way: minutes is finite, but minutes * 60_000 overflows, and the gate checks minutes, not the converted value.

Trigger: an operator sets QWEN_SESSION_PR_REFRESH_MINUTES=43200 (a plausible "monthly" refresh). The daemon then runs full sweeps back-to-back — paginated listSessions over active+archived, a sidecar read per bound session, one gh pr list spawn per workspace with non-merged bindings — burning CPU/IO and GitHub rate limits until restart, while the operator believes the sweep is monthly. The running flag serializes ticks but does not space them.

Witness (probe on unmodified code): resolveSessionPrRefreshIntervalMs({'43200'})2592000000; a real setInterval at that value → TimeoutOverflowWarning: 2592000000 does not fit into a 32-bit signed integer. Timeout duration was set to 1. and 93 ticks in 100 ms; '1e308' behaves the same. With the validation below patched in: both inputs → 300000 ms, 0 ticks, no warnings.

Suggested change
if (minutes === 0) return undefined;
return minutes * 60_000;
if (minutes === 0) return undefined;
const ms = minutes * 60_000;
return Number.isFinite(ms) && ms <= 2 ** 31 - 1 ? ms : DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS;
中文说明

[Critical] R1-17:当换算后的毫秒数超过 Node 32 位定时器上限(2,147,483,647 ms,约 24.8 天以上)时,minutes 本身仍能通过有限/非负校验,但 setInterval 会把超限延时钳制为 1 ms——"更长的间隔"反而变成持续的扫描热循环。QWEN_SESSION_PR_REFRESH_MINUTES=1e308 以另一种方式触发同样的钳制:minutes 有限但 minutes * 60_000 溢出,而校验只检查 minutes 本身。

触发场景:运维设置 QWEN_SESSION_PR_REFRESH_MINUTES=43200("每月一次")。daemon 会背靠背地连续执行完整扫描——对 active+archived 分页 listSessions、逐个读取绑定 sidecar、对每个仍含未合入绑定的 workspace 拉起一次 gh pr list——持续消耗 CPU/IO 与 GitHub 速率配额直到重启,而运维以为扫描是每月一次。running 标志只能串行化 tick,无法拉开间隔。

证据(对未修改代码的探针):resolveSessionPrRefreshIntervalMs({'43200'})2592000000;以该值实际调用 setIntervalTimeoutOverflowWarning: 2592000000 does not fit into a 32-bit signed integer. Timeout duration was set to 1.,100 ms 内触发 93 次;'1e308' 行为相同。打入下方校验后:两种输入均 → 300000 ms,0 次触发,无警告。

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

Comment on lines +40 to +41
const slugMatch = SLUG_PR_PATTERN.exec(slug ?? '');
if (slugMatch) return Number(slugMatch[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] R1-25: The slug-convention parser has no positivity gate — parsePrNumberFromWorktree('pr-0') returns Number('0') = 0 (SLUG_PR_PATTERN matches, and there is no n > 0 check, unlike the canonical GitWorktreeService.parsePRReference). A worktree named pr-0 is a legal user slug (validateUserWorktreeSlug accepts it). Backfill then persists { number: 0, url } — but isValidSessionPr requires number > 0, so one zero entry invalidates the whole sidecar: readSessionPrs returns null, every binding for that session vanishes from listings/tooltip, and the next upsertSessionPr (treating null as []) rewrites the file, permanently dropping the prior bindings. pr-00/pr-000 are the same hole.

Trigger: an operator or agent creates a worktree named pr-0, a session runs in it, and backfill binds number 0 → the session's existing bindings disappear and are eventually dropped for good.

Witness (probe against real code): validateUserWorktreeSlug('pr-0') → accepted; backfill with a seeded existing binding #42{bound: 1}, readAfterBackfill: NULL (whole file rejected), next upsert → [43] — binding 42 permanently dropped. With an n > 0 gate patched in: bound: 0, existing binding intact.

Suggested change
const slugMatch = SLUG_PR_PATTERN.exec(slug ?? '');
if (slugMatch) return Number(slugMatch[1]);
const slugMatch = SLUG_PR_PATTERN.exec(slug ?? '');
if (slugMatch) {
const n = Number(slugMatch[1]);
return n > 0 ? n : undefined;
}
中文说明

[Critical] R1-25:slug 约定解析缺少正数校验——parsePrNumberFromWorktree('pr-0') 返回 Number('0') = 0SLUG_PR_PATTERN 可以匹配,且没有 n > 0 检查,而规范的 GitWorktreeService.parsePRReference 有)。名为 pr-0 的 worktree 是合法的用户 slug(validateUserWorktreeSlug 接受它)。回填随后会持久化 { number: 0, url }——但 isValidSessionPr 要求 number > 0,于是一条 0 值条目使整个 sidecar 失效:readSessionPrs 返回 null,该会话的所有绑定从列表/tooltip 中消失,下一次 upsertSessionPr(把 null 当作 [])重写文件,永久丢弃之前的绑定。pr-00/pr-000 是同样的漏洞。

触发场景:运维或 agent 创建名为 pr-0 的 worktree,会话在其中运行,回填绑定了数字 0 → 该会话已有的绑定消失并最终被永久丢弃。

证据(对真实代码的探针):validateUserWorktreeSlug('pr-0') → 接受;在已有绑定 #42 的会话上回填 → {bound: 1}readAfterBackfill: NULL(整个文件被拒绝)、下一次 upsert → [43]——绑定 42 被永久丢弃。打入 n > 0 校验后:bound: 0,已有绑定完好。

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

Comment on lines +518 to +520
...livePrs.map((l) => {
const persisted = persistedByNumber.get(l.number);
return persisted?.state !== undefined && persisted.state !== l.state

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-27: Live-only sessions (no storage record yet) bypass both this sidecar-wins state merge and the refresh sweep, so their PR state is frozen at bind time. The metadata-bind handler writes the sidecar via upsertSessionPr without flushing a session record; the sweep enumerates via storage-backed sessionService.listSessions (SESSION_FILE_PATTERN matches only .jsonl transcripts, never a bare .pr.json); and the live-only insertion branches on all three listing paths insert {...live} without reading the sidecar.

Trigger: a session binds PR #42 before its first flush (e.g. bound before its first turn); PR #42 merges. The listing keeps reporting state: 'open' — badge and tooltip stuck on "open" — until the session's first flush plus one further sweep; for a session that never turns, indefinitely. After a daemon restart the bridge entry is recreated without prs, so an unflushed live session's binding is not shown at all.

Witness (probe splitting on the single variable "transcript present"): live-only arm (sidecar only) → sweep {scanned: 0, updated: 0, state: 'open'}; control arm (transcript present) → {scanned: 1, updated: 1, state: 'merged'}. With the sweep discovering orphaned sidecars directly: both arms {scanned: 1, updated: 1, state: 'merged'}.

Suggested fix: best-effort read the session's PR sidecar in the live-only insertion branch (as enrichPrSidecars does) and let its state win over the live bind-time state; and/or make the sweep discover sidecars of sessions not yet in storage.

中文说明

[Critical] R1-27:live-only 会话(尚无存储记录)同时绕过了这里的 sidecar 优先状态合并与刷新扫描,其 PR 状态被冻结在绑定时刻。元数据绑定处理器通过 upsertSessionPr 写入 sidecar 但不 flush 会话记录;扫描通过存储层的 sessionService.listSessions 枚举(SESSION_FILE_PATTERN 只匹配 .jsonl transcript,永远不会匹配孤立的 .pr.json);且三条列表路径的 live-only 插入分支都直接插入 {...live} 而不读取 sidecar。

触发场景:会话在首次 flush 前绑定了 PR #42(例如首轮对话前绑定),随后 PR #42 合入。列表会继续返回 state: 'open'——badge 与 tooltip 停留在 "open"——直到该会话首次 flush 加上再一次扫描;对于始终未产生对话的会话,永久如此。daemon 重启后 bridge 条目重建时不带 prs,未 flush 的 live 会话的绑定完全不可见。

证据(仅以"有无 transcript"单一变量分裂的探针):live-only 分支(仅 sidecar)→ 扫描 {scanned: 0, updated: 0, state: 'open'};对照分支(有 transcript)→ {scanned: 1, updated: 1, state: 'merged'}。让扫描直接发现孤立 sidecar 后:两个分支均为 {scanned: 1, updated: 1, state: 'merged'}

建议修复:在 live-only 插入分支中尽力读取该会话的 PR sidecar(如同 enrichPrSidecars),让其 state 覆盖 live 绑定时刻的状态;和/或让扫描能够发现尚未进入存储的会话的 sidecar。

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

Comment on lines +239 to +241
const have = new Set(existing?.map((pr) => pr.number));
for (const number of numbers) {
if (have.has(number)) {

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] R1-32: Backfill is permanently non-idempotent for sessions resolving more than SESSION_PR_LIST_LIMIT (10) PRs. upsertSessionPr slices to the latest 10, evicting the oldest numbers; have is rebuilt from the capped sidecar each run, so evicted numbers look unbound and are re-upserted with a fresh createdAt — evicting the next pair in turn and moving re-bound entries to the end, which flips the "latest" entry the badge renders (last = latest) and the tooltip's latest-first order. This contradicts the design doc's own 重复调用幂等 promise.

Trigger: a long-lived session whose worktree branch + transcript gitBranch records map to 12 distinct PRs. Every manual POST /sessions/backfill-prs forever reports new binds, rewrites createdAt, and reshuffles which PR the UI shows as newest.

Witness (probe, three consecutive runs on unmodified code, one session with 12 mapped PRs): run1 {bound: 12} sidecar [3..12] (1, 2 evicted); run2 {bound: 2, alreadyBound: 10} sidecar [5..12, 1, 2] — latest flips 12→2; run3 {bound: 2} sidecar [7..12, 1, 2, 3, 4] — rotates forever. Candidate fix (bind only numbers.slice(-SESSION_PR_LIST_LIMIT)): run1 {bound: 10}, run2/3 {bound: 0, alreadyBound: 10}, sidecar stable, createdAt identical.

Suggested fix: bound per-session binding to the cap instead of writing then evicting — resolve numbers first and bind only the last SESSION_PR_LIST_LIMIT of them (counting the rest separately), so a second run finds every persisted number already in have and reports bound: 0.

中文说明

[Critical] R1-32:对于解析出超过 SESSION_PR_LIST_LIMIT(10)个 PR 的会话,回填永久不幂等。upsertSessionPr 截取最新 10 条、逐出最旧的编号;have 每次运行都从被截断的 sidecar 重建,于是被逐出的编号看起来未绑定,会以新的 createdAt 重新 upsert——继而逐出下一对,并把重绑条目移到末尾,从而翻转 badge 渲染的"最新"条目(last = latest)与 tooltip 的最新优先顺序。这与设计文档自己承诺的"重复调用幂等"相悖。

触发场景:一个长生命周期会话,其 worktree 分支 + transcript gitBranch 记录映射到 12 个不同的 PR。此后每次手动 POST /sessions/backfill-prs 都会永远报告新绑定、重写 createdAt,并翻转 UI 显示的"最新" PR。

证据(探针,未修改代码上连续三次运行,单个会话映射 12 个 PR):run1 {bound: 12} sidecar [3..12](1、2 被逐出);run2 {bound: 2, alreadyBound: 10} sidecar [5..12, 1, 2]——最新从 12 翻转为 2;run3 {bound: 2} sidecar [7..12, 1, 2, 3, 4]——永久轮转。候选修复(只绑定 numbers.slice(-SESSION_PR_LIST_LIMIT)):run1 {bound: 10},run2/3 {bound: 0, alreadyBound: 10},sidecar 稳定,createdAt 不变。

建议修复:把每会话的绑定限制在上限内,而不是先写入再逐出——先解析 numbers,只绑定其中最后 SESSION_PR_LIST_LIMIT 个(其余单独计数),这样第二次运行会发现所有已持久化编号都在 have 中,报告 bound: 0

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

Comment on lines +9681 to +9683
hasControlCharacter((pr as SessionPrInfo).url) ||
((pr as SessionPrInfo).state !== undefined &&
(pr as SessionPrInfo).state !== 'open' &&

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] R1-1: The new state validation reuses the error message that only describes the number/url constraints, so an invalid state is misreported as a malformed number/url (the REST route validator in session.ts does the same). A client sending { pr: { number: 5, url: 'https://github.com/o/r/pull/5', state: 'draft' } } — number and url valid, but 'draft' outside {open, merged, closed} — gets "must be an object with a positive integer number and an http(s) url …", pointing at two fields that are fine. 'draft' is a likely mistake: gh reports draft PRs and core's GitHubPullRequestState includes it. Consider extending the message, e.g. "… and an optional state of 'open' | 'merged' | 'closed'".

中文说明

[Suggestion] R1-1:新增的 state 校验复用了只描述 number/url 约束的错误消息,导致非法 state 被误报为 number/url 格式错误(session.ts 的 REST 路由校验器同样如此)。客户端发送 { pr: { number: 5, url: 'https://github.com/o/r/pull/5', state: 'draft' } }——number 与 url 合法,但 'draft' 不在 {open, merged, closed} 中——会得到"必须是带正整数 number 与 http(s) url 的对象……",把问题指向两个实际合法的字段。'draft' 是很可能的误用:gh 会报告 draft PR,core 的 GitHubPullRequestState 也包含它。建议扩展消息,例如"……以及可选的 state,取值 'open' | 'merged' | 'closed'"。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round. This round was bounded to the four Critical findings plus their coupled hardening; the error-message wording fix (mentioning the optional state union) is a small follow-up and stays on the queue.

中文说明

延迟到下一轮。本轮范围限定在 4 个 Critical 发现及其配套加固;错误消息措辞修复(提及可选的 state 取值范围)是一个小的后续项,保留在队列中。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred to the next round: this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid. The one-line message extension (mentioning the optional state union) will land with the session.ts twin.

中文说明

延后至下一轮处理:本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。一行错误消息扩展(提及可选 state 取值)将与 session.ts 的孪生问题一起落地。

Comment on lines +104 to +105
runtime.env.effectiveEnv,
{ state: 'all', limit: 500, slim: true },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-18: Bindings whose PR number falls outside the single 500-entry gh page are skipped every sweep and never get their state refreshed — and the window cannot be widened meaningfully because runGhPrList clamps limit to ≤ 1000 and fetches a single page. gh pr list returns newest-updated first, so long-settled PRs — precisely the ones an open→merged sweep must transition — fall out of the window first. In any repo with more than 500 PRs (this repository exceeds it ~19×), a session bound to an out-of-window PR keeps its stale snapshot forever: the UI shows "open" for a PR merged years ago — the exact staleness this sweep was added to fix. Consider resolving numbers still missing from numberToState after the page fetch (e.g. gh pr view <number> --json state, keeping the conservative no-reset semantics).

中文说明

[Suggestion] R1-18:PR 编号落在单页 500 条 gh 结果之外的绑定,每次扫描都会被跳过、状态永不刷新——而且窗口无法实质性放宽,因为 runGhPrListlimit 钳制在 ≤ 1000 且只取单页。gh pr list 按最近更新优先返回,因此早已尘埃落定的 PR——恰恰是 open→merged 扫描必须转换的对象——最先跌出窗口。在任何 PR 数超过 500 的仓库(本仓库约为其 19 倍),绑定到窗口外 PR 的会话会永久保留陈旧快照:UI 对多年前已合入的 PR 显示 "open"——正是这个扫描要消除的陈旧。建议在分页拉取后,对 numberToState 中仍缺失的编号逐个解析(如 gh pr view <number> --json state,保持保守的"不重置"语义)。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round. Resolving numbers absent from the 500-entry page (e.g. targeted gh pr view <number> --json state, keeping the no-reset semantics) is real for large repos, but adds per-number gh spawns that need a rate-limit budget; queued as its own change.

中文说明

延迟到下一轮。对落在 500 条分页之外的编号逐个解析(如定向 gh pr view <number> --json state,保持不重置语义)在大型仓库中是真实缺口,但会引入按编号的 gh 调用、需要速率配额预算;作为独立改动排队。

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.

Deferred to the next round: this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid. Resolving out-of-window numbers (per-number gh pr view, keeping no-reset semantics) is a self-contained extension and gets its own round.

中文说明

延后至下一轮处理:本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。窗口外编号解析(逐个 gh pr view、保持不重置语义)是独立扩展,将单独占一轮。

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.

Deferred. Resolving numbers outside the single gh page needs per-number gh pr view calls (bounded by how many stale bindings a workspace carries) — a rate-limit/design tradeoff that should be a deliberate choice, not a drive-by addition in a Critical-fix round. Stays on the backlog with this trail.

中文说明

延后。解析单页 gh 结果之外的编号需要按编号调用 gh pr view(次数受限于一个 workspace 承载多少陈旧绑定)——这是速率配额/设计上的取舍,应当是刻意的决策,而不是 Critical 修复轮次里的顺手添加。在此留痕,保留在待办清单。

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.

Deferred to the follow-up queue (still unresolved at the current head). Verified real: bindings whose PR number falls outside the single 500-entry gh page are skipped every sweep. Resolving them needs per-number gh pr lookups — a rate-limit vs staleness tradeoff that deserves its own change rather than this converging round.

中文说明

延后到跟进队列(当前 head 上仍未解决)。已核实属实:PR 编号落在单页 500 条 gh 窗口之外的绑定每次扫描都被跳过。解决它们需要逐编号的 gh pr 查询——速率限制与陈旧度之间的权衡,值得单独一次改动,而不是放在本收敛轮中。

Comment on lines +115 to +117
for (const target of pendingNumbers) {
const states = new Map<number, SessionPrState>();
for (const number of target.numbers) {

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] R1-6: A failing sidecar write aborts the rest of the workspace sweep — per-session isolation exists for reads (try/catch around readSessionPrs) but not for these updateSessionPrStates writes. Probe: session A (active, chats dir chmod 555) + session B (archived), gh reporting both merged → the write on A throws EACCES, result = undefined, and stateB stays open — B was never advanced. Wrapping the per-target update in try/catch flipped it to result {scanned: 2, updated: 1}, stateB = merged. Since pendingNumbers order is stable (mtime-descending), a persistently failing sidecar first in that order starves every older session's state updates forever — permanent staleness for a one-file fault. The code documents the sibling intent one level up ("A single workspace's failure must not starve the rest").

Suggested fix: wrap the per-target update in try/catch and continue, e.g. try { if (await updateSessionPrStates(target.prPath, states)) updated += states.size; } catch { /* one unwritable sidecar must not starve the rest */ }.

中文说明

[Suggestion] R1-6:一次失败的 sidecar 写入会中止该 workspace 扫描的剩余部分——读取有按会话隔离(readSessionPrs 外的 try/catch),但这些 updateSessionPrStates 写入没有。探针:会话 A(active,chats 目录 chmod 555)+ 会话 B(archived),gh 报告两者均已合入 → 对 A 的写入抛出 EACCES,result = undefinedstateB 保持 open——B 从未被推进。把按目标的更新包进 try/catch 后翻转为 result {scanned: 2, updated: 1}stateB = merged。由于 pendingNumbers 顺序稳定(mtime 降序),一个在该顺序靠前且持续写入失败的 sidecar 会永久饿死所有更旧会话的状态更新——一个文件故障换来永久陈旧。代码在上一层已经写明了同类意图("单个 workspace 的失败不得饿死其余")。

建议修复:把按目标的更新包进 try/catch 并继续,例如 try { if (await updateSessionPrStates(target.prPath, states)) updated += states.size; } catch { /* 一个不可写 sidecar 不得饿死其余 */ }

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

Comment on lines +124 to +126
if (states.size === 0) continue;
if (await updateSessionPrStates(target.prPath, states)) {
updated += states.size;

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] R1-24: updated is documented as "Bindings whose state was rewritten" but adds states.size — every binding present in the gh page — whenever updateSessionPrStates changed anything in that sidecar, so unchanged bindings are counted as rewritten. Probe, exact scenario: a session bound to #42 open and #43 open, gh reporting #42 merged / #43 open → observed updated: 2 though exactly one binding changed (the unchanged #43 hits state === entry.state inside updateSessionPrStates). The timer discards the result today (zero runtime cost), but the new tests assert updated as a measure of rewrites and none exercises the mixed multi-binding case, so the inflated semantics get codified; any future logging/metrics consumer inherits the wrong number. Consider counting actual rewrites (have updateSessionPrStates return the number of entries it changed — it already tracks changed).

中文说明

[Suggestion] R1-24:updated 的文档是"状态被重写的绑定数",但实际累加的是 states.size——gh 分页中出现的全部绑定——只要 updateSessionPrStates 改变了该 sidecar 中的任何内容,未变化的绑定也被计为重写。探针,精确场景:会话绑定 #42 open 与 #43 open,gh 报告 #42 merged / #43 open → 观察到 updated: 2,而实际只有一个绑定变化(未变化的 #43updateSessionPrStates 内部命中 state === entry.state)。定时器目前丢弃结果(运行时零开销),但新测试把 updated 当作重写次数来断言,且没有覆盖多绑定混合场景,膨胀语义因此被固化;未来任何日志/指标消费者都会继承错误的数字。建议统计真实重写数(让 updateSessionPrStates 返回实际改变的条目数——它已经跟踪了 changed)。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round. Making updated count actual rewrites (returning the changed-entry count from updateSessionPrStates) touches the core service signature; queued with its mixed-multi-binding test.

中文说明

延迟到下一轮。让 updated 统计真实重写数(由 updateSessionPrStates 返回实际改变的条目数)涉及 core 服务签名改动;与其多绑定混合场景测试一同排队。

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.

Deferred to the next round: this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid. Counting actual rewrites (returning the changed-entry count from updateSessionPrStates) is queued.

中文说明

延后至下一轮处理:本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。统计真实重写数(让 updateSessionPrStates 返回实际改变的条目数)已排队。

Comment on lines +149 to +150
for (const runtime of deps.workspaceRegistry.listAll()) {
if (!runtime.trusted) 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] R1-11: startSessionPrRefreshTimer has no test at all — the test file imports only refreshWorkspaceSessionPrStates and resolveSessionPrRefreshIntervalMs, so the untrusted-workspace skip here, the running re-entrancy guard, the disabled (undefined) path, and dispose() are uncovered. Deleting if (!runtime.trusted) continue; makes the sweep read and write .pr.json sidecars in untrusted workspaces' session storage and spawn gh from their cwd — the route-level equivalent is tested ("untrusted workspace skipped"), but the timer's is not, so that mutation ships green. Consider a timer test with fake timers: a registry with one trusted and one untrusted runtime, asserting only the trusted workspace's sidecar is touched, an overlapping tick is skipped, and QWEN_SESSION_PR_REFRESH_MINUTES=0 returns undefined.

中文说明

[Suggestion] R1-11:startSessionPrRefreshTimer 完全没有测试——测试文件只导入了 refreshWorkspaceSessionPrStatesresolveSessionPrRefreshIntervalMs,因此这里的受信任跳过、running 重入守卫、关闭(undefined)路径与 dispose() 都无覆盖。删除 if (!runtime.trusted) continue; 会让扫描读写不受信任 workspace 会话存储中的 .pr.json sidecar,并从其 cwd 拉起 gh——路由层的等价场景有测试("untrusted workspace skipped"),定时器这里没有,因此该变异能绿着上线。建议用假定时器补一个定时器测试:注册表含一个受信任与一个不受信任 runtime,断言只有受信任 workspace 的 sidecar 被触及、重叠的 tick 被跳过、QWEN_SESSION_PR_REFRESH_MINUTES=0 返回 undefined

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round. The timer-level tests (untrusted-workspace skip, re-entrancy guard, disabled path, dispose) with fake timers are queued as their own batch.

中文说明

延迟到下一轮。定时器级测试(不受信任 workspace 跳过、重入守卫、关闭路径、dispose)用假定时器作为独立批次排队。

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.

Deferred to the next round: this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid. Fake-timer tests for the trust guard, 0 short-circuit, re-entrancy, and dispose are queued.

中文说明

延后至下一轮处理:本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。信任守卫、0 短路、重入与 dispose 的假定时器测试已排队。

Comment on lines +171 to +173
{pr.state === 'merged' || pr.state === 'closed'
? ` · ${
pr.state === 'merged'

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] R1-12: The new "· Merged"/"· Closed" state suffix (and the two new i18n keys sidebar.sessionPrStateMerged/Closed) is a changed user-visible behavior with no test coverage, although SessionDetailsTooltip.test.tsx already exercises PR-row rendering and its fixture entries carry no state. Swapping the two label branches (merged renders "Closed" and vice versa) or deleting the block ships green — this label and the badge dimming are the only UI assertions of merge state. Consider extending the existing "shows the bound pull request as a link" case (or adding a sibling): give one binding state: 'merged' and one state: 'closed', assert the row text contains · Merged / · Closed, and assert an open/state-less binding renders without the suffix.

中文说明

[Suggestion] R1-12:新增的"· Merged"/"· Closed"状态后缀(以及两个新 i18n 键 sidebar.sessionPrStateMerged/Closed)是用户可见的行为变更,却没有测试覆盖,而 SessionDetailsTooltip.test.tsx 已经在测试 PR 行渲染,其 fixture 条目都不带 state。交换两个标签分支(merged 渲染 "Closed",反之亦然)或删除整块都能绿着上线——该标签与 badge 弱化是合入状态仅有的两处 UI 断言。建议扩展既有的"将绑定的 pull request 显示为链接"用例(或新增一个):给一个绑定 state: 'merged'、另一个 state: 'closed',断言行文本包含 · Merged / · Closed,并断言 open/无 state 的绑定不带后缀。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round. Extending SessionDetailsTooltip.test.tsx with state: 'merged'/'closed' suffix assertions is a small web-shell test batch on its own.

中文说明

延迟到下一轮。在 SessionDetailsTooltip.test.tsx 中补充 state: 'merged'/'closed' 后缀断言,作为独立的 web-shell 小测试批次。

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.

Deferred to the next round: this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid. Tooltip · Merged/· Closed suffix cases in SessionDetailsTooltip.test.tsx are queued.

中文说明

延后至下一轮处理:本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。SessionDetailsTooltip.test.tsx 中 · Merged/· Closed 后缀用例已排队。

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.

Deferred. The tooltip state suffix (· Merged / · Closed) still has no witness in SessionDetailsTooltip.test.tsx; adding the two fixture cases is straightforward but did not fit this round's bounded Critical-first batch. Tracked here so it is not dropped.

中文说明

延后。tooltip 的状态后缀(· Merged / · Closed)在 SessionDetailsTooltip.test.tsx 中仍无见证;补两个 fixture 用例本身简单,但未纳入本轮有上限的 Critical 优先批次。在此记录以免遗漏。

@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

⚠️ AutoFix round 1 ended without publishing a reportview run.

中文说明

⚠️ AutoFix 第 1 轮结束但未发布报告 —— 查看运行

Review round 1 findings on the PR-state feature. The refresh interval
resolver validated minutes but not the converted milliseconds: values
above setInterval's 32-bit max clamp to 1 ms (a "monthly" interval
becomes a continuous sweep hot loop), sub-minute values degenerate the
same way, and a blank env value parsed as 0 and silently disabled the
sweep. Fall back to the default for all three; keep 0 as the disable.

parsePrNumberFromWorktree accepted `pr-0` (a legal user slug) and bound
number 0, which isValidSessionPr rejects — invalidating the whole
sidecar and permanently dropping prior bindings on the next upsert.
Gate both slug and branch conventions on n > 0.

Live-only sessions (bound before their first transcript flush) bypassed
both the sidecar-wins state merge and the sweep, freezing their PR state
at bind time. All three listing paths now build the live-only insertion
through a shared helper that best-effort reads the sidecar with the same
sidecar-wins merge rule, and the sweep enumerates `.pr.json` sidecars
directly (new SessionService.listSessionIdsWithPrSidecar) instead of
walking transcripts — discovering unflushed sessions and removing the
pagination loop entirely.

Backfill bound every resolved number and let upsertSessionPr evict past
the cap, so sessions with more than SESSION_PR_LIST_LIMIT PRs re-bound
the evicted numbers forever, rotating the badge's latest entry each run.
Bind only the cap's tail (excess counted as overLimit), making repeat
runs converge to bound: 0. Both the sweep and backfill write phases also
isolate per-sidecar failures instead of aborting the rest.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Review round summary — PR #9729

This round addresses all four Critical findings from the automated review plus the closely-coupled cheap Suggestions, and adds regression witnesses for two more. 25 remaining Suggestions are deferred to the next round with per-thread replies (comment-replies.json).

Critical findings — all four fixed (each witnessed by a test that fails on the pre-round code)

  • R1-17 / R1-40 / R1-5 — refresh-interval hardening (resolveSessionPrRefreshIntervalMs): the converted milliseconds are now validated, not just the minutes. Intervals above setInterval's 32-bit max (2,147,483,647 ms — e.g. 43200 minutes, 1e308) fall back to the default instead of being clamped to 1 ms (hot loop); sub-minute values (0.0001, 1e-9) fall back to the default instead of ticking every millisecond; empty/blank values are treated as unset (default) instead of Number('') === 0 silently disabling the sweep. The '0' disable is preserved. New tests pin the boundary pair (35791 accepted / 35792 rejected), the blank case, and the sub-minute case.
  • R1-25 — pr-0 positivity gate: parsePrNumberFromWorktree now requires n > 0 for both the slug and branch conventions, so a legal pr-0 user slug can no longer persist number: 0, which isValidSessionPr rejects — invalidating the whole sidecar and permanently dropping prior bindings on the next upsert.
  • R1-27 — live-only sessions: fixed on both halves. (a) All three listing paths (default, organized, metadata-filtered) now build their live-only insertion through a shared liveOnlySummary helper that best-effort reads the session's PR sidecar and applies the same sidecar-wins state merge as mergeLiveSessionSummary (merge rule extracted into mergeSummaryPrs, reused by both sites). (b) The refresh sweep now enumerates .pr.json sidecars directly (new SessionService.listSessionIdsWithPrSidecar) instead of walking transcripts via paginated listSessions — so a sidecar written before the session's first flush is discovered and refreshed. This also removes the pagination loop entirely (see R1-41).
  • R1-32 — backfill idempotency under the sidecar cap: backfill now resolves the candidate numbers first and binds only the last SESSION_PR_LIST_LIMIT of them (excess counted in a new overLimit result field), instead of binding all and letting upsertSessionPr evict the oldest — which made every later run re-bind the evicted numbers with fresh createdAt and rotate the badge's "latest" entry forever. New test: 12 mapped PRs → run1 bound: 10, overLimit: 2, run2 bound: 0, alreadyBound: 10, overLimit: 2, sidecar byte-identical (createdAt stable).

Suggestions addressed this round

  • R1-6 — sweep write isolation: the per-target updateSessionPrStates write is wrapped in try/catch; one unwritable sidecar no longer aborts the rest of the workspace sweep. Witnessed by a chmod-555 test that also covers the archived arm.
  • R1-38 — backfill write isolation: the per-number upsertSessionPr write is wrapped in try/catch with a writeErrors counter; one failing sidecar (EISDIR in the test) no longer rejects the whole workspace backfill.
  • R1-28 — aggregation pinned: new test seeds two sessions with pending bindings and asserts both sidecars update with exactly ONE gh call (toHaveBeenCalledTimes(1)), pinning the documented one-gh-per-workspace-per-sweep invariant.
  • R1-23 — archived arm covered: the write-isolation test seeds an archived session whose sidecar IS rewritten to merged, covering the previously-zero archived enumeration arm.
  • R1-41 — resolved by subtraction: the sweep's listSessions pagination (size: 1000 + nextCursor do-while) is gone — sidecar-file enumeration has no pages, so the untested cursor-advance surface no longer exists.

Review bodies

  • [rv:5000356506] rebase request: this loop commits additively only (no rebase/history rewriting), and the workflow reported no conflict (--conflict false; a local git merge-tree origin/main HEAD merges cleanly). The rebase onto current main is a maintainer action. The referenced "badge refresh propagation (sweep should bump the bridge catalog revision)" note is not included in this round's feedback payload, so it cannot be addressed blindly; it is flagged for a follow-up round once its details are available.
  • [rv:5000953056] partial-review gap: the integration suite (CLI, No Sandbox) was skipped in CI and is not run locally in this loop; the change is covered by the focused Vitest suites below, and CI's integration job re-runs on push.

Deferred to the next round

25 Suggestions (error-message wording R1-1×2, bridge state-equality/coverage R1-31/R1-9/R1-10, backfill test hardening R1-7/R1-36/R1-37/R1-14, URL/remote corners R1-3, transcript parsing R1-2/R1-16, gh degradation visibility R1-33, branch→number staleness R1-4, archive/delete races R1-26/R1-39, negative memo R1-19, binding order R1-21, import .catch R1-22, skip-arm coverage R1-35, sweep credential fixture R1-34, out-of-window number resolution R1-18, updated semantics R1-24, timer tests R1-11, tooltip label test R1-12) — each with a recorded reason in comment-replies.json.

Verification

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0, no errors/warnings)
  • npx prettier --check on all touched files — passed after --write formatting
  • vitest packages/cli src/serve/server/session-pr-refresh.test.ts src/serve/routes/session-pr-backfill.test.ts — 35 passed (2 files)
  • vitest packages/cli src/serve/server.test.ts (full) — 1081 passed
  • vitest packages/core src/services/session-pr-service.test.ts src/services/sessionService.test.ts — 210 passed
  • Witness/mutation probes: with the round's source fixes stashed, all 11 new fix-witness tests fail on the pre-round code (8 in the refresh/backfill files, 3 live-only listing tests in server.test.ts) while all pre-existing tests stay green; restoring the fixes returns everything to green. Removing the new listSessionIdsWithPrSidecar from core (stash + rebuild) fails the orphan-sweep test.
  • Integration tests were not run (not required: the touched behavior is exercised by the Vitest suites above; CI's integration job remains the final gate).
中文说明

审查轮次总结 — PR #9729

本轮处理自动审查的全部 4 个 Critical 发现,以及与之紧密耦合的低成本 Suggestion,并为另外两个发现补充了回归见证测试。其余 25 个 Suggestion 延迟到下一轮,并逐线程附回复(见 comment-replies.json)。

Critical 发现 — 4 个全部修复(每个都有"在改动前代码上必然失败"的测试作见证)

  • R1-17 / R1-40 / R1-5 — 刷新间隔加固resolveSessionPrRefreshIntervalMs):现在校验的是换算后的毫秒值,而不仅是分钟数。超过 setInterval 32 位上限(2,147,483,647 ms,例如 43200 分钟、1e308)的间隔回退为默认值,而不是被钳制为 1 ms(热循环);小于一分钟的值(0.00011e-9)回退为默认值,而不是每毫秒触发一次;空/纯空白值按"未设置"处理(用默认值),而不是 Number('') === 0 静默关闭扫描。'0' 关闭开关保持不变。新增测试固化边界对(35791 接受 / 35792 拒绝)、空值与小于一分钟的情形。
  • R1-25 — pr-0 正数门禁parsePrNumberFromWorktree 现在对 slug 与 branch 两种约定都要求 n > 0,因此合法的用户 slug pr-0 不会再持久化 number: 0——该值会被 isValidSessionPr 拒绝,导致整个 sidecar 失效、下一次 upsert 永久丢弃已有绑定。
  • R1-27 — live-only 会话:两半均已修复。(a) 三条列表路径(默认、organized、metadata 过滤)的 live-only 插入统一改走 liveOnlySummary 辅助函数:尽力读取该会话的 PR sidecar,并套用与 mergeLiveSessionSummary 相同的 sidecar 优先 state 合并规则(合并逻辑抽取为 mergeSummaryPrs,两处复用)。(b) 刷新扫描改为直接枚举 .pr.json sidecar(新增 SessionService.listSessionIdsWithPrSidecar),不再经由分页 listSessions 遍历 transcript——首次 flush 前写入的 sidecar 也能被发现并刷新。这同时整体移除了分页循环(见 R1-41)。
  • R1-32 — sidecar 上限下的回填幂等:回填现在先解析候选编号,只绑定其中最后 SESSION_PR_LIST_LIMIT 个(超出部分计入新增的 overLimit 结果字段),而不是全部绑定后任由 upsertSessionPr 逐出最旧者——那会让后续每次运行都把被逐出的编号以新的 createdAt 重绑,永久轮转 badge 的"最新"条目。新增测试:映射 12 个 PR → run1 bound: 10, overLimit: 2,run2 bound: 0, alreadyBound: 10, overLimit: 2,sidecar 逐字节一致(createdAt 不变)。

本轮处理的 Suggestion

  • R1-6 — 扫描写入隔离:按目标的 updateSessionPrStates 写入包进 try/catch;单个不可写 sidecar 不再中止该 workspace 扫描的剩余部分。由一个 chmod-555 测试见证,该测试同时覆盖了 archived 分支。
  • R1-38 — 回填写入隔离:按编号的 upsertSessionPr 写入包进 try/catch 并新增 writeErrors 计数;单个失败的 sidecar(测试中为 EISDIR)不再让整个 workspace 的回填 reject。
  • R1-28 — 聚合路径固化:新测试种入两个都有未合入绑定的会话,断言两个 sidecar 均被更新且 gh 只被调用一次(toHaveBeenCalledTimes(1)),固化"每 workspace 每扫描一次 gh"的文档化不变量。
  • R1-23 — archived 分支覆盖:写入隔离测试种入一个归档会话,其 sidecar 确实被重写为 merged,覆盖了此前零覆盖的 archived 枚举分支。
  • R1-41 — 以删减方式解决:扫描的 listSessions 分页(size: 1000 + nextCursor do-while)已不存在——sidecar 文件枚举没有分页,未测试的游标推进面随之消失。

审查主体评论

  • [rv:5000356506] rebase 请求:本循环只做增量提交(不 rebase、不改写历史),且工作流报告无冲突(--conflict false;本地 git merge-tree origin/main HEAD 可干净合并)。rebase 到当前 main 属于维护者操作。其引用的"badge 刷新传播(扫描应 bump bridge catalog revision)"说明不在本轮反馈内容中,无法盲目处理;已标记待其细节可用后在后续轮次跟进。
  • [rv:5000953056] 部分审查缺口:集成测试套件(CLI, No Sandbox)在 CI 中被跳过,本循环也不在本地运行;改动由下述聚焦 Vitest 套件覆盖,CI 的集成 job 会在 push 后重新运行。

延迟到下一轮

25 个 Suggestion(错误消息措辞 R1-1×2、bridge state 相等/覆盖 R1-31/R1-9/R1-10、回填测试加固 R1-7/R1-36/R1-37/R1-14、URL/remote 边角 R1-3、transcript 解析 R1-2/R1-16、gh 降级可见性 R1-33、branch→number 陈旧 R1-4、归档/删除竞态 R1-26/R1-39、失败结果记忆 R1-19、绑定顺序 R1-21、import .catch R1-22、跳过分支覆盖 R1-35、扫描凭据 fixture R1-34、窗口外编号解析 R1-18、updated 语义 R1-24、定时器测试 R1-11、tooltip 标签测试 R1-12)——每个都在 comment-replies.json 中记录了理由。

验证

  • npm run build — 通过(exit 0)
  • npm run typecheck — 通过(exit 0)
  • npm run lint — 通过(exit 0,无 error/warning)
  • 对所有改动文件执行 npx prettier --check — 经 --write 格式化后通过
  • vitest packages/cli src/serve/server/session-pr-refresh.test.ts src/serve/routes/session-pr-backfill.test.ts — 35 通过(2 个文件)
  • vitest packages/cli src/serve/server.test.ts(全量)— 1081 通过
  • vitest packages/core src/services/session-pr-service.test.ts src/services/sessionService.test.ts — 210 通过
  • 见证/变异探针:把本轮源码修复 stash 后,全部 11 个新的修复见证测试在改动前代码上失败(refresh/backfill 文件中 8 个,server.test.ts 中 3 个 live-only 列表测试),且所有既有测试保持绿色;恢复修复后全部回绿。从 core 中移除新的 listSessionIdsWithPrSidecar(stash + 重新构建)会使孤立 sidecar 扫描测试失败。
  • 未运行集成测试(非必需:所触及的行为已由上述 Vitest 套件覆盖,CI 的集成 job 仍是最终门禁)。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R2-8 backfill never reserves sidecar capacity for existing bindings — dropped as overlapping the existing R1-32 comment (3836900701) at packages/cli/src/serve/routes/session-pr-backfill.ts:263

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

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

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

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

Comment on lines +225 to +226
numberToState.set(pr.number, pr.state === 'draft' ? 'open' : pr.state);
if (pr.headRefName) branchToNumber.set(pr.headRefName, pr.number);

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] R1-5: branchToNumber is built with last-write-wins over a newest-first PR list, so when several PRs share a head branch the OLDEST PR wins the mapping and the current PR is never bound. The slim field set omits updatedAt, so parseGhPrList's sort is a stable no-op and the list stays in gh's newest-created-first order.

Branch chore/deps produced PR #10 (merged in January) and was reused for PR #250 (open in July); a July session whose transcript records gitBranch: chore/deps is backfill-bound to PR #10 — the stale merged PR — while PR #250 can never be bound through the branch mapping. The session listing permanently shows the wrong PR link. No test covers a duplicated head branch.

Witness: Real-population sweep over this repo's newest-500-PR window: 5 head branches are reused; simulating line 226's mapping, 5 of 5 bind the OLDEST PR. End-to-end probe: session on chore/deeps with gh order [pr(250, open), pr(10, merged)] → bound [{"number":10,"state":"merged"}] (PR #250 never bound); with first-write-wins fix → bound [{"number":250,"state":"open"}].

Suggested fix: First-write-wins on the newest-first list: if (pr.headRefName && !branchToNumber.has(pr.headRefName)) branchToNumber.set(pr.headRefName, pr.number);

中文说明

branchToNumber 在“最新在前”的 PR 列表上用 last-write-wins 构建:多个 PR 共用同一 head 分支时,映射会落到最旧的 PR,当前 PR 永远无法通过分支映射绑定。机制更深一层:slim 字段集(number,url,headRefName,state)不含 updatedAtmapEntry 对每条记录赋 updatedAt: 0parseGhPrList 的排序成为稳定的空操作——列表保持 gh pr list 的创建时间倒序。触发场景:分支 chore/deps 一月产出 PR #10(已合入)、七月复用产出 PR #250(open);七月会话的 transcript 记录了该分支 → 回填绑定到陈旧的 #10,列表永久显示错误 PR。证据:对本仓库最近 500 个 PR 的真实扫描发现 5 个复用分支,按此映射 5/5 全部绑定最旧 PR;端到端探针在修复(first-write-wins)前后翻转。

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

Comment on lines +553 to +555
async function liveOnlySummary(
live: BridgeSessionSummary,
sessionService: SessionService,

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] R2-2: The list route's live-only fast path (listLiveWorkspaceSessionsForResponse) bypasses liveOnlySummary, so live-only rows served through it render the bind-time state — violating the exact invariant this diff adds liveOnlySummary (and three tests) to establish.

A secondary workspace runtime with zero persisted active transcripts (hasActivePersistedSessions counts transcripts, never sidecars). A live session binds PR #5 while open; the bind route persists the sidecar before any flush; PR #5 merges and the sweep rewrites the sidecar to merged. A client polling the first page takes the fast path (usePersisted === false) and gets state: 'open' — raw bridge prs, no sidecar read — while the same request on the persisted path returns 'merged'. For an archived-only secondary workspace the stale state is served indefinitely.

Witness: Route probe via supertest against createServeApp with a secondary workspace: GET /workspaces//sessions → prs[0] = { number: 9517, state: 'open' } while the sidecar says 'merged'; forcing the gate onto the persisted path returns state 'merged' — probe flips.

Suggested fix: Route the fast path through the same sidecar merge — make listLiveWorkspaceSessionsForResponse async and apply liveOnlySummary per row, or drop the fast path when any live row carries prs; alternatively document the fast path as bind-time-only if that staleness is intended.

中文说明

列表路由的 live-only 快速路径(listLiveWorkspaceSessionsForResponse)绕过了 liveOnlySummary,因此经由它的 live-only 行渲染的是绑定时刻的 state——恰好违反本 diff 新增 liveOnlySummary(及三个测试)所要建立的不变量。触发场景:次级(非 primary)workspace 且没有已持久化的活跃 transcript(hasActivePersistedSessions 只统计 transcript,从不统计 sidecar);live 会话在首次 flush 前绑定 PR #5(open),sidecar 已写入;PR 合入后定时扫描把 sidecar 更新为 merged。客户端轮询第一页走快速路径(usePersisted === false)拿到 state: 'open'(原始 bridge prs,不读 sidecar),而同一请求走持久化路径却返回 merged。对“仅归档”的次级 workspace,过期状态会无限期持续。证据:对 createServeApp 的 supertest 探针:prs[0].stateopen 而 sidecar 为 merged;把门禁强制走持久化路径后返回 merged,探针翻转。

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

Comment on lines +370 to +372
await fsp.chmod(chatsDir, 0o555);
try {
const result = await refreshWorkspaceSessionPrStates(runtime);

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] R2-1: The new permission-based test 'keeps sweeping archived sessions when a sidecar write fails' relies on POSIX directory-permission semantics without the win32/root guard that every comparable test in this repo applies, so it fails on the Windows merge-queue lane and any root run.

The test_windows merge-group job runs packages/cli vitest; on Windows fs.chmod(dir, 0o555) only toggles the read-only attribute and file creation is governed by the directory ACL, so the intended EACCES never happens — the active sidecar write succeeds, the sweep returns { scanned: 2, updated: 2 } instead of { scanned: 2, updated: 1 }, and the follow-up state assertion also fails. The identical failure occurs whenever the suite runs as root (root bypasses the 0o555 bit). Repo convention for this shape: if (process.platform === 'win32' || process.getuid?.() === 0) return; (workspace-artifact-directory.test.ts:149, record-artifact.test.ts:385).

Witness: uid=0 container run of the real test (node:22-bookworm): × keeps sweeping archived sessions when a sidecar write fails → AssertionError: expected { scanned: 2, updated: 2 } to deeply equal { scanned: 2, updated: 1 } at session-pr-refresh.test.ts:374; same file passes 14/14 as non-root.

Suggested fix: Guard the test like its siblings: if (process.platform === 'win32' || process.getuid?.() === 0) return; (or it.skipIf(...)).

中文说明

新增的基于权限的测试 keeps sweeping archived sessions when a sidecar write fails 依赖 POSIX 目录权限语义,却没有本仓库同类测试都加的 win32/root 守卫。Windows 上 fs.chmod(dir, 0o555) 只切换只读属性、文件创建由目录 ACL 决定,预期的 EACCES 不会发生——活跃 sidecar 写入成功,扫描返回 { scanned: 2, updated: 2 } 而非 { scanned: 2, updated: 1 },后续状态断言也会失败;root 运行(root 无视 0o555 位)同样失败。而 test_windows 是 merge_group 必跑门禁。按仓库惯例加守卫即可。证据:以 uid=0 容器实际运行该测试 → AssertionError: expected { scanned: 2, updated: 2 } to deeply equal { scanned: 2, updated: 1 };非 root 下该文件 14/14 全绿。

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

Comment on lines +81 to +83
for (const sessionId of sessionService.listSessionIdsWithPrSidecar(
archiveState,
)) {

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] R2-3: This round's switch from transcript-driven listSessions to the new sidecar-driven listSessionIdsWithPrSidecar drops the project-membership filter that listSessions applies via sessionBelongsToCurrentProject — so the sweep rewrites PR sidecars belonging to other projects that share the same chats dir.

Storage.getProjectDir keys the project dir by sanitizeCwd(cwd), which maps every non-alphanumeric to '-', so distinct repos like ~/dev/my-app and ~/dev/my.app collide onto one chats dir — the hazard listSessions documents and filters out, and which the old sweep inherited. resolveSessionRuntimeBaseDir defaults every workspace to the shared ~/.qwen base. Workspace B's sweep then reads workspace A's .pr.json, fetches gh pr list in repo B, and rewrites A's bindings with repo B's states whenever small PR numbers collide. A wrongly-set 'merged' is terminal (merged entries are filtered out of every future sweep) — permanently wrong state from another repo's PR; an 'open' result makes the two workspaces' sweeps rewrite the same sidecar back and forth forever.

Witness: Probe: runtimes A (/work/my-app) and B (/work/my.app), shared base dir — chatsDir(A) === chatsDir(B) (-work-my-app); B sees A's session via listSessionIdsWithPrSidecar but [] via listSessions (old filter dropped it); B's sweep with gh reporting its own PR #42 merged → result { scanned: 1, updated: 1 }, A sidecar → state:'merged'; with the membership filter restored → { scanned: 0, updated: 0 }, A sidecar stays 'open'.

Suggested fix: Restore membership filtering on the new path: when the session's transcript exists, apply the same sessionBelongsToCurrentProject check before queueing the sidecar — or, collision-safe for pre-flush sidecars too, stamp the project hash into the sidecar at bind time and skip mismatches.

中文说明

本轮把扫描枚举从“transcript 驱动的 listSessions”切换为新的“sidecar 驱动的 listSessionIdsWithPrSidecar”,丢掉了 listSessions 经由 sessionBelongsToCurrentProject 施加的项目归属过滤——扫描可能改写其他项目共享同一 chats 目录的 PR sidecar。Storage.getProjectDirsanitizeCwd(cwd) 为键(所有非字母数字字符映射为 -),~/dev/my-app~/dev/my.app 会碰撞到同一目录——这正是 listSessions 文档明言并用哈希过滤防御的场景;resolveSessionRuntimeBaseDir 默认所有 workspace 共享 ~/.qwen 基目录。触发场景:workspace B 的扫描读到 workspace A 的 <uuid>.pr.json,用仓库 B 的 gh pr list 结果改写 A 的绑定;一旦错误置为 merged 即为终态(merged 条目被后续扫描过滤),永久错误;置为 open 则两个 workspace 的扫描每 5 分钟互相翻写同一文件。证据:探针构造 /work/my-app/work/my.app 两个 runtime → chatsDir 相同;B 经新枚举看到 A 的会话(旧 listSessions 过滤为空);B 的扫描使 A 的 sidecar 变为 merged;恢复归属过滤后 { scanned: 0, updated: 0 },A 保持 open

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

});
});

describe('backfillWorkspaceSessionPrs', () => {

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] R2-10: Every backfill test mocks fetchGitHubPullRequests with mockResolvedValue and never inspects the call arguments (no toHaveBeenCalledWith anywhere in the file), so the load-bearing fetch options { state: 'all', limit: 500, slim: true } can regress without any test turning red.

A one-token edit from state: 'all' to state: 'open' leaves all 21 tests green (runGhPrList builds gh pr list --state options.state ?? 'open'); gh then excludes merged/closed PRs, so sessions whose branches map only to a merged PR are never bound — silently breaking backfill's primary use case (binding worktrees whose PR already merged). The same blind spot covers limit: 500 and slim: true.

Witness: Mutant state: 'open' → Tests 21 passed (suite blind); adding the seam assertion → 1 failed; reverting the mutant with the assertion in place → 21 passed.

Suggested fix: In one existing case assert the seam: expect(fetchGitHubPullRequestsMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ state: 'all', limit: 500, slim: true }));

中文说明

所有回填测试都用 mockResolvedValue mock fetchGitHubPullRequests 且从不检查调用参数(文件里没有一处 toHaveBeenCalledWith),因此实现传入的关键抓取参数 { state: 'all', limit: 500, slim: true } 回归时没有任何测试变红。场景:把 state: 'all' 改成 state: 'open'runGhPrList 组装 gh pr list --state options.state ?? 'open'),21 个测试全绿;此后 gh 排除已合入/关闭的 PR,只映射到已合入 PR 的会话永远不会被绑定——静默破坏回填的首要用途(绑定 PR 已合入的 worktree)。证据:变异 → 21 全过;加上参数断言 → 1 失败;还原变异后全绿。

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

Comment on lines +565 to +567
let sidecar: Awaited<ReturnType<typeof readSessionPrs>>;
try {
const sidecarPath = sessionService.getPrSessionPathForArchiveState(

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] R2-12: The best-effort sidecar-read guard inside the new liveOnlySummary — the try/catch that degrades an unreadable/invalid live-only .pr.json to the bridge's bind-time state — has no test on any of the three listing paths this diff rewires onto it.

A future simplification drops the try/catch (or replaces readSessionPrs with a variant that rethrows). readSessionPrs deliberately rethrows non-ENOENT I/O errors (EACCES/EISDIR). With a live-only session whose sidecar path is a directory or an unreadable file, every session-list request on the plain path then fails outright, and on the organized/metadata paths the outer catch flips to liveMergeFailed, dropping live sessions merged after the bad row instead of one row degrading. The four new server.test.ts cases seed only valid sidecars.

Witness: Probe placing a directory at the live-only session's .pr.json path → unmodified code passes (row renders with bind-time prs); mutant removing the try/catch → probe fails with EISDIR while the four existing tests stay green.

Suggested fix: Add a case placing a directory at the live-only session's .pr.json path (EISDIR avoids the chmod win32/root hazard), asserting the row still renders with the bridge's bind-time prs and the plain-path request succeeds.

中文说明

liveOnlySummary 内的尽力读取守卫(把不可读/非法的 live-only .pr.json 降级为 bridge 绑定时刻状态的 try/catch)在本 diff 切换到的三条列表路径上均无测试。场景:未来简化删掉 try/catch:readSessionPrs 对非 ENOENT I/O 错误(EACCES/EISDIR)故意重抛——当某 live-only 会话的 sidecar 路径是目录或不可读文件时,普通路径的整个列表请求直接失败;组织/元数据路径的外层 catch 置 liveMergeFailed,丢弃坏行之后合并的 live 会话。新增的四个测试只种子有效 sidecar。证据:在 sidecar 路径放目录的探针对原码绿、对删除 try/catch 的变异报 EISDIR,且四个现有测试在该变异下仍绿。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round (not declined): this round's batch was bounded by the four Critical fixes and their witnesses. The EISDIR case for the liveOnlySummary read guard (directory at the live-only session's .pr.json path, row degrades to bind-time prs) remains planned as suggested.

中文说明

顺延至下一轮(非拒绝):本轮批次被四条 Critical 修复及其见证测试占满。liveOnlySummary 读取守卫的 EISDIR 用例(在 live-only 会话的 .pr.json 路径放目录,行降级为绑定时刻 prs)仍按原建议排期。

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.

Deferred to the next round: this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid. The liveOnlySummary unreadable-sidecar case (directory at the path, plain path still renders) is queued.

中文说明

延后至下一轮处理:本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。liveOnlySummary 不可读 sidecar 用例(路径放目录、普通路径仍正常渲染)已排队。

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.

Deferred. The liveOnlySummary degrade-guard case (a directory at the live-only session's .pr.json path, asserting the row still renders with bind-time prs) did not fit this round's bounded Critical-first batch. The guard itself is in place; the witness is on the backlog and tracked here.

中文说明

延后。liveOnlySummary 降级守卫的用例(在 live-only 会话的 .pr.json 路径放一个目录,断言该行仍以绑定时刻的 prs 渲染)未纳入本轮有上限的 Critical 优先批次。守卫本身已就位;见证测试列入待办清单并在此记录。

});
});

describe('refreshWorkspaceSessionPrStates', () => {

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] R1-35: The sweep's corrupt/unreadable-sidecar skip guard (try { prs = await readSessionPrs(prPath); } catch { continue; } + if (!prs) continue;) has zero test coverage — no test seeds an invalid or unreadable .pr.json. The enumeration rewrite preserved both skip arms, but the coverage test deferred in round 1 never landed.

A future simplification removes if (!prs) continue; (or the try/catch). A hand-edited or partially written sidecar makes readSessionPrs return null and prs.filter(...) throws a TypeError; a permission-denied sidecar throws EACCES. The exception escapes refreshWorkspaceSessionPrStates, the timer's per-workspace catch swallows it, and every binding in that workspace silently stops refreshing until the file is repaired by hand.

Witness: Mutation A (remove null-skip): proposed corrupt-sidecar test red — TypeError: Cannot read properties of null (reading 'filter'); existing 14/14 green. Mutation B (remove read try/catch): test red — EACCES; existing suite green.

Suggested fix: Seed two sessions with valid open sidecars, corrupt one (await fsp.writeFile(prPathB, '{invalid'); avoid chmod-based read failures unless guarded per the repo convention), mock gh returning both numbers merged, and assert { scanned: 1, updated: 1 }, the healthy sidecar became merged, and the corrupt file was left untouched.

中文说明

扫描对损坏/不可读 sidecar 的跳过守卫(try { … readSessionPrs … } catch { continue; }if (!prs) continue;)零测试覆盖——枚举重写保留了这两个分支,但第 1 轮承诺的覆盖测试未落地。场景:未来简化删掉 if (!prs) continue;(或 try/catch):手工编辑/部分写入的 sidecar 使 readSessionPrs 返回 null,prs.filter(...) 抛 TypeError;权限拒绝的 sidecar 抛 EACCES。异常逃出 refreshWorkspaceSessionPrStates,被定时器每-workspace catch 吞掉——该 workspace 的所有绑定静默停止刷新,直到手工修复文件。证据:两个方向的变异都让建议的损坏-sidecar 测试红、而现有 14 个测试绿。建议:种子两个有效 sidecar、把其中一个写成 '{invalid',断言 { scanned: 1, updated: 1 }、健康者变 merged、损坏文件原样保留。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round (not declined): this round's batch was bounded by the four Critical fixes and their witnesses. The corrupt-sidecar sweep case (two valid sidecars, one corrupted with '{invalid', gh reporting both merged, assert { scanned: 1, updated: 1 } and the corrupt file untouched) remains planned as suggested.

中文说明

顺延至下一轮(非拒绝):本轮批次被四条 Critical 修复及其见证测试占满。损坏 sidecar 的扫描用例(两个有效 sidecar、其一写成 '{invalid'、gh 报告两者 merged、断言 { scanned: 1, updated: 1 } 且损坏文件原样保留)仍按原建议排期。

Comment on lines +388 to +392
await upsertSessionPr(prPath, {
number: 999,
url: 'https://github.com/o/r/pull/999',
state: 'open',
});

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] R2-9: The test 'does not write back open for bindings missing from the gh page' cannot detect the regression it is named for: because the seeded binding is already 'open', a mutation that maps gh-absent numbers to 'open' produces no rewrite, so { scanned: 1, updated: 0 } and the 'open' assertion still pass — the mutation survives.

A future 'simplification' defaults absent numbers to 'open'. A session bound to a closed PR that falls outside gh pr list's 500-entry page is then rewritten to state: 'open' on the next sweep, and every session listing shows a closed PR as open. The implementation's own comment marks the skip-absent invariant load-bearing ('Only a number ABSENT from gh's page is skipped'), yet no test turns red under the mutation.

Witness: Mutant states.set(number, state ?? 'open') → all 14 existing refresh tests pass; the suggested sibling case (seed 'closed') is red against the mutant and green on clean code.

Suggested fix: Add a sibling case seeding the binding with state: 'closed' and the same gh page missing 999, asserting { scanned: 1, updated: 0 } and that the persisted state stays 'closed'.

中文说明

测试 does not write back open for bindings missing from the gh page 无法捕获其命名所指回归:种子绑定已是 'open',若变异把 gh 缺页编号映射为 'open'states.set(number, state ?? 'open')),updateSessionPrStates 见到 state === entry.state 返回 null、不产生改写,{ scanned: 1, updated: 0 }'open' 断言照样通过——变异存活。场景:未来“简化”把缺页编号默认为 'open':绑定到 closed PR 且落在 500 条页外的会话会被改写为 'open',列表把已关闭 PR 显示为 open;实现自己的注释标明该不变量是关键(“Only a number ABSENT from gh's page is skipped”)。证据:变异下 14 个现有测试全过;种子 'closed' 的兄弟用例对变异红、对原码绿。

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

Comment on lines +131 to +134
try {
if (await updateSessionPrStates(target.prPath, states)) {
updated += states.size;
}

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] R1-24: updated counts every pending binding of a sidecar whenever any one of them changed (updated += states.size), contradicting its documented meaning 'Bindings whose state was rewritten'.

A sidecar holds PR 42 (open) and PR 43 (open); gh reports 42 merged and 43 still open. updateSessionPrStates rewrites only entry 42 and returns non-null, so updated += 2 although exactly 1 binding's state changed. The timer discards the result today, but any future logging/monitoring/surfacing consumer trusting the documented semantics overcounts.

Witness: Probe: sidecar seeded 42 open + 43 open, gh returning 42 merged / 43 open → result { scanned: 1, updated: 2 }, persisted [[42,'merged'],[43,'open']] — one change counted as two.

Suggested fix: Count actual rewrites (have updateSessionPrStates return the changed entries/count and add that), or amend the doc to count sidecars covered by a rewrite.

中文说明

updated 在某 sidecar 只要有任一绑定变化时就累加 states.size(该 sidecar 的全部待处理绑定数),与文档“Bindings whose state was rewritten”不符。场景:sidecar 有 PR 42(open)、43(open);gh 报告 42 merged、43 仍 open → 只改写了 1 条,updated 却加 2。当前定时器丢弃结果,但任何未来信任该语义的日志/监控消费方都会高估。证据:探针 → result { scanned: 1, updated: 2 },持久化 [[42,'merged'],[43,'open']]。建议:让 updateSessionPrStates 返回实际改写的条目/数量并累加该值。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round (not declined): this round's batch was bounded by the four Critical fixes and their witnesses. Making updated count actual rewrites (returning the changed count from updateSessionPrStates) or amending the documented semantics remains planned as suggested.

中文说明

顺延至下一轮(非拒绝):本轮批次被四条 Critical 修复及其见证测试占满。让 updated 统计实际改写数(updateSessionPrStates 返回改写计数)或修订文档语义,仍按原建议排期。

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.

Deferred to the next round: Same finding as the round-1 updated-semantics thread; deferred under it — this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid.

中文说明

延后至下一轮处理:与第 1 轮 updated 语义线程是同一发现,归入该线程一并延后——本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。

Comment on lines +148 to +150
export function startSessionPrRefreshTimer(deps: {
workspaceRegistry: WorkspaceRegistry;
env?: Readonly<Record<string, string | undefined>>;

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] R1-11: startSessionPrRefreshTimer has no test anywhere: the untrusted-workspace skip, the QWEN_SESSION_PR_REFRESH_MINUTES=0 short-circuit inside the timer, the re-entrancy guard, and dispose() are all unexercised (resolveSessionPrRefreshIntervalMs itself is unit-tested).

If the timer's trust guard regressed, the daemon would spawn gh and rewrite .pr.json sidecars inside a workspace that was never granted trust — the exact boundary the backfill route's tested guard protects. A regression turning '0' back into 'default' would re-enable a sweep the operator explicitly switched off, with no failing test.

Witness: Repo-wide grep: startSessionPrRefreshTimer appears only in its implementation and the production call site (run-qwen-serve.ts:5168); no test imports it.

Suggested fix: Fake-timer tests: (a) untrusted runtime → no refresh after advancing past FIRST_RUN_DELAY_MS; (b) '0' → returns undefined, schedules nothing; (c) dispose() prevents subsequent ticks.

中文说明

startSessionPrRefreshTimer 完全没有测试:不受信任 workspace 跳过、QWEN_SESSION_PR_REFRESH_MINUTES=0 在定时器内的短路、重入守卫、dispose() 均未覆盖(resolveSessionPrRefreshIntervalMs 本身有单测)。若信任守卫回归,daemon 会在从未授予信任的 workspace 里拉起 gh 并改写 .pr.json——正是回填路由已测守卫所保护的边界;若 0 回归为默认值,会重新启用运维明确关闭的扫描。建议:fake-timer 测试三个分支。

— qwen3.8-max via Qwen Code /review (v0.22.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.

Deferred to the next round (not declined): this round's batch was bounded by the four Critical fixes and their witnesses. The fake-timer cases for startSessionPrRefreshTimer (untrusted skip, 0 disable, dispose) remain planned as suggested.

中文说明

顺延至下一轮(非拒绝):本轮批次被四条 Critical 修复及其见证测试占满。startSessionPrRefreshTimer 的 fake-timer 用例(不受信任跳过、0 关闭、dispose)仍按原建议排期。

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.

Deferred to the next round: Same finding as the round-1 timer-tests thread; deferred under it — this round's batch was capped (~8 findings) and prioritized the new Critical R4-1 (convention-number eviction through cap writes) plus its sibling hardening; this finding stays queued and valid.

中文说明

延后至下一轮处理:与第 1 轮定时器测试线程是同一发现,归入该线程一并延后——本轮批次受限(约 8 条),优先处理了新的 Critical R4-1(上限写入导致的约定编号逐出)及其相关加固;该发现仍然有效,已排队待处理。

- Backfill branch-to-PR mapping is first-write-wins on the newest-first
  gh list, so a reused head branch binds the newest PR, not the oldest.
- The over-cap slice reserves a slot for the convention (pr-<N>) number
  instead of evicting it first.
- The refresh sweep re-checks project membership for sidecar-discovered
  sessions, so sanitized-cwd collisions cannot cross-rewrite sidecars.
- The live-only list fast path merges the PR sidecar like the persisted
  paths, rendering the sweep-refreshed state instead of bind-time state.
- The invalid-pr 400 message now names the state constraint; the
  permission-based sweep test gets the repo's win32/root guard.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix round: no action (growth-audit round, verdict sound)

This round was triggered by the growth brake (window growth: source 67 / test 536 net lines vs budgets 400/400), so the required work was a growth audit, performed before any other analysis. The audit result is recorded in growth-audit.json with verdict sound (KISS: pass, minimal change: pass).

Audit summary. The counting window (baseline b530dbca6b → head bc54509ba0, two commits) consists entirely of Critical-fix rounds from this PR's review loop:

  • KISS (structure) — pass. No structurally simpler approach exists for what the window does. It adds no abstraction: one env: gitEnv() parity fix, one .catch that degrades a dynamic import to a logged warning instead of an unhandled rejection, two cache-invalidation + catalog-bump pairings built from the already-exported invalidateWorkspaceSessionListCache primitive (the only compound helper invalidateSessionListsAndMarkCatalog is a module-local closure / private method and not importable from the new call sites), one result counter, one boolean, and one net-negative hunk (the summary.prs?.length gate removed from the live-only fast path).
  • Minimal change (footprint) — pass. Every hunk in all 8 changed files traces to an accepted Critical finding on this PR — R9-1 (unhandled rejection exits the daemon), R9-2 (repo-shifting env bypass in getRemoteWebUrl), R10-1/R10-2 (sweep and backfill writes never notified the catalog, leaving stale/invisible badges), R10-3 (live-only rows skipped the sidecar read when the bridge entry had no prs), R11-1 (eviction-only writes got no notification), R11-2 (default-branch exclusion failed open without origin/HEAD). All files stay inside the PR's own session-PR-state footprint.

The 536 test lines are per-finding witness tests mandated by the repository's mutation-probe rule; the budget overage is on the witness side, not defensive code growth. No deletion list and no simpler alternative came out of the audit, so there is nothing to subtract or restructure this round.

Feedback triage. The single actionable item, [rv:5010336565], is a COMMENTED review disclosing that its own /review run could not certify coverage ("no plan was given"). It names no defect, requests no change, and is not actionable at the code level — no reproduction, fix, or decline applies. No failed checks, no inline comments, and no issue-level comments were in scope. The Deferred non-Critical feedback section is an audit record under Critical-only mode and was left untouched, as required.

No code changes were made this round; nothing was committed.

中文说明

Autofix 轮次:无操作(增长审计轮,结论 sound

本轮由增长刹车触发(窗口增长:源码 67 / 测试 536 净行,预算 400/400),因此必需工作是增长审计,且审计先于任何其他分析执行。审计结果记录在 growth-audit.json,结论为 sound(KISS:通过;最小改动:通过)。

审计摘要。 计数窗口(基线 b530dbca6b → 头 bc54509ba0,共两个提交)完全由本 PR 审查循环中的 Critical 修复轮构成:

  • KISS(结构)— 通过。 就窗口所做的工作而言,不存在结构上更简单的方案。窗口未引入任何抽象:一处 env: gitEnv() 同级对齐修复、一处将动态导入失败降级为日志警告而非未处理 rejection 的 .catch、两处基于已导出的 invalidateWorkspaceSessionListCache 原语构建的缓存失效 + 目录版本号提升配对(唯一的复合助手 invalidateSessionListsAndMarkCatalog 是模块内闭包/私有方法,无法从新调用点导入)、一个结果计数器、一个布尔量,以及一处净负增长改动(从 live-only 快速路径移除 summary.prs?.length 门控)。
  • 最小改动(足迹)— 通过。 全部 8 个改动文件中的每个 hunk 都可追溯到本 PR 上已被接受的 Critical 发现——R9-1(未处理的 rejection 会导致守护进程退出)、R9-2(getRemoteWebUrl 中绕过仓库转移环境变量的防护)、R10-1/R10-2(sweep 与 backfill 写入从未通知目录,导致徽章过期/不可见)、R10-3(当 bridge 条目没有 prs 时 live-only 行跳过了 sidecar 读取)、R11-1(仅驱逐式写入没有任何通知)、R11-2(缺少 origin/HEAD 时默认分支排除逻辑失败为开放)。所有文件均保持在本 PR 自身的 session-PR-state 足迹内。

536 行测试是仓库变异探针规则所要求的逐发现见证测试;预算超出发生在见证测试一侧,而非防御性代码的膨胀。审计未产生任何删除清单或更简单的替代方案,因此本轮没有可删减或可重构的内容。

反馈分诊。 唯一的可操作条目 [rv:5010336565] 是一条 COMMENTED 审查,披露其自身的 /review 运行无法认证覆盖范围("未提供计划")。它未指出任何缺陷、未提出任何改动请求,在代码层面不可操作——不适用复现、修复或拒绝。本轮没有失败的检查、没有内联评论、没有 issue 级评论。Deferred non-Critical feedback(已延后的非 Critical 反馈)部分是仅 Critical 模式下的审计记录,按要求未做处理。

本轮未做任何代码改动;未提交任何内容。

Deferred non-Critical feedback

Critical-only mode is active: the PR's diff grew src 67 / test 536 net lines beyond this counting window's baseline (budgets: 400/400). The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback continues to flow unaffected during a growth-only engagement (the per-author batch budget applies only after 5 change-producing rounds). (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:本计数窗口内 diff 净增长已达 源码 67 / 测试 536 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Round summary — PR #9729 (same-run verification repair)

This round repairs the deterministic rejection of commit f04f177848, which is preserved unchanged. No review feedback is re-triaged this round; the carried dispositions from that commit stand (77 of 82 inline findings resolved in code, 5 deferred — threads and replies below).

The rejection

The verification gate failed npm run test in packages/cli: all 668 test files and 19,481 tests passed, but the run exited 1 with three unhandled rejections attributed to src/ui/AppContainer.test.tsx while it was running:

TypeError: this.subagentManager.getAvailableModelGrades is not a function
 ❯ AgentTool.updateDescriptionAndSchema ../core/src/tools/agent/agent.ts:1034:33
 ❯ AgentTool.refreshSubagents ../core/src/tools/agent/agent.ts:887:12

Root cause

Main commit d44030a4c0 (feat(core): add model grade selection for subagent spawn) made AgentTool.updateDescriptionAndSchema() call this.subagentManager.getAvailableModelGrades() and updated the SubagentManager mocks in packages/core tests, but missed the partial mockSubagentManager in packages/cli/src/ui/AppContainer.test.tsx. AgentTool's constructor fires refreshSubagents() as a floating promise: the first updateDescriptionAndSchema() call throws inside the try, the catch calls it again, and the second TypeError escapes — an unhandled rejection whenever the lazy AgentTool factory runs while that spy is active (real config.initialize()ToolRegistry.warmAll() under load), which fails the whole vitest run. The timing dependence is why the file passes in isolation and the full --changed suite failed in CI.

Reproduction evidence (this round):

  • Probe constructing AgentTool against the exact partial mock shape from the test's beforeEach → the identical TypeError surfaces as an unhandled rejection.
  • Probe running the real mockConfig.initialize() in the cli jsdom environment with the same spies → registry warms, AgentTool is constructed, and the captured rejection is exactly TypeError: this.subagentManager.getAvailableModelGrades is not a function.
  • The same hole exists at the same line of origin/main's AppContainer.test.tsx (the main commit fixed every other mock but this one); this PR's changed-files set pulls that file into the --changed origin/main run.

Fix (one commit, test-only)

  • Added getAvailableModelGrades: vi.fn().mockReturnValue(new Map()) to the beforeEach mockSubagentManager — the same mock shape d44030a4c0 applied to the core tests.
  • Added one witness test, keeps the SubagentManager mock complete for AgentTool init: it runs the real mockConfig.initialize() (registry warm → AgentTool construction) with a temporary unhandledRejection listener and asserts nothing was captured.
  • Mutation probe: removing the new mock line makes the witness fail (expected [ …(1) ] to deeply equal [] — the captured TypeError); restoring it returns the file to green (157 passed).
  • Existing-behavior check: the probe run also confirms the fix introduces no new defect — updateDescriptionAndSchema with an empty grade map takes the pre-existing delete schema.properties.model branch, identical to production with no grades configured.

Carried dispositions of the 82 inline findings (commit f04f177, preserved)

  • 77 resolved in code at the current head — every one re-verified by the round that authored that commit (all 25 Criticals each with an existing witness test); resolved-comments.txt lists them. This round's change touches no production code, so those verifications stand.
  • 5 deferred to the follow-up queue (threads left open, replies re-posted via comment-replies.json since the rejected round never pushed them): R1-2 (3836900725), R1-16 (3836900728, 3837316223), R1-21 (3836900740), R1-18 (3836900766).
  • Review-body items: the rebase request (rv:5000356506) remains a maintainer history operation — this workflow makes additive commits only and reported no conflict this round (--conflict false), so no merge was performed. The partially-reviewed disclosure (rv:5000953056) carries no actionable finding. Issue-level notes (web-shell preview, serve A/B) are unchanged from the prior summary.

Verification

Commands actually run this round:

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0)
  • npx prettier --check packages/cli/src/ui/AppContainer.test.tsx — clean
  • npx vitest run src/ui/AppContainer.test.tsx (packages/cli) — 157 passed (156 existing + the new witness)
  • npx vitest run src/serve/routes/session-pr-backfill.test.ts src/serve/server/session-pr-refresh.test.ts (packages/cli) — 79 passed (preserved commit's suites still green)
  • Mutation probe: remove the getAvailableModelGrades mock line → the witness test FAILS (expected [ …(1) ] to deeply equal [] — the captured TypeError); restore → green again
  • Reproduction probes (temporary files, deleted before commit): constructing AgentTool against the exact partial mock → the identical TypeError; running the real config.initialize() with the same spies → the identical TypeError captured as an unhandled rejection; completed mock → zero rejections
  • npx vitest run --changed origin/main --passWithNoTests (packages/cli) — the gate command, run under a CI-mirror environment (CI=true, clean writable HOME, no session env vars): 668/668 test files passed, 19482 passed | 36 skipped, exit 0, zero unhandled rejections (the pre-fix baseline was 19481 passed with 3 unhandled rejections and exit 1)

Environment note: test runs inside this interactive agent session initially showed unrelated failures — the session exports QWEN_HOME/SANDBOX/QWEN_CODE_CUSTOM_SANDBOX_IMAGE and similar variables that leak into environment-sensitive tests, and the session user cannot write the default ~/.qwen. Under the CI-mirror environment above (what the deterministic gate uses) everything is green; one AuthDialog TUI-input test runs only outside CI and is skipped by design when CI=true (hence the 36 skipped, matching the gate baseline).

中文说明

本轮总结 — PR #9729(同轮验证修复)

本轮修复提交 f04f177848 被确定性验证门禁拒绝的问题;该提交原样保留。本轮不重新分诊任何评审反馈,该提交所载的处置维持不变(82 条行内发现中 77 条已在代码中解决,5 条延后——见下方线程与回复)。

拒绝原因

验证门禁在 packages/cli 中运行 npm run test 失败:全部 668 个测试文件、19,481 条测试均通过,但运行以 3 条未处理拒绝(unhandled rejection)退出(退出码 1),归属到正在运行的 src/ui/AppContainer.test.tsx

TypeError: this.subagentManager.getAvailableModelGrades is not a function
 ❯ AgentTool.updateDescriptionAndSchema ../core/src/tools/agent/agent.ts:1034:33
 ❯ AgentTool.refreshSubagents ../core/src/tools/agent/agent.ts:887:12

根因

main 上的提交 d44030a4c0feat(core): add model grade selection for subagent spawn)让 AgentTool.updateDescriptionAndSchema() 调用 this.subagentManager.getAvailableModelGrades(),并更新了 packages/core 各测试中的 SubagentManager mock,却遗漏了 packages/cli/src/ui/AppContainer.test.tsx 里的部分方法 mock mockSubagentManagerAgentTool 构造函数以漂浮 promise 方式触发 refreshSubagents()try 内第一次 updateDescriptionAndSchema() 抛错后,catch 中再次调用,第二个 TypeError 逃逸——只要懒加载的 AgentTool 工厂在该 spy 生效期间运行(真实 config.initialize() → 负载下的 ToolRegistry.warmAll()),就会产生未处理拒绝并使整个 vitest 运行失败。正因为时机依赖,该文件单独运行时通过,而 --changed 全量套件在 CI 中失败。

复现证据(本轮):

  • 用测试 beforeEach 中完全相同的 mock 形状构造 AgentTool 的探针 → 出现同样的 TypeError 未处理拒绝。
  • 在 cli jsdom 环境、同样 spy 下运行真实 mockConfig.initialize() 的探针 → registry 预热、AgentTool 被构造,捕获到的拒绝恰为 TypeError: this.subagentManager.getAvailableModelGrades is not a function
  • origin/mainAppContainer.test.tsx 同一行存在同样的漏洞(该 main 提交修复了其他所有 mock,唯独漏掉这个);本 PR 的变更文件集合使该文件进入 --changed origin/main 运行范围。

修复(一次提交,纯测试)

  • beforeEachmockSubagentManager 中补上 getAvailableModelGrades: vi.fn().mockReturnValue(new Map()) —— 与 d44030a4c0 在 core 测试中应用的 mock 形状一致。
  • 新增一条见证测试 keeps the SubagentManager mock complete for AgentTool init:运行真实 mockConfig.initialize()(registry 预热 → AgentTool 构造),临时挂载 unhandledRejection 监听器并断言未捕获到任何拒绝。
  • 变异探针:删除新增的 mock 方法行 → 见证测试失败(expected [ …(1) ] to deeply equal []——捕获到 TypeError);恢复后文件回到绿色(157 通过)。
  • 既有行为核查:探针运行同时确认修复未引入新缺陷——空 grade 映射下 updateDescriptionAndSchema 走既有的 delete schema.properties.model 分支,与未配置 grades 的生产行为一致。

82 条行内发现的承继处置(提交 f04f177,保留)

  • 77 条已在代码中解决(以当前 head 为准)——均由编写该提交的那一轮逐条复核(全部 25 条 Critical 各有既有见证测试);resolved-comments.txt 列出这些线程。本轮未触碰任何生产代码,上述复核继续有效。
  • 5 条延后到跟进队列(线程保持打开,回复经 comment-replies.json 重新发送,因为被拒绝的那一轮从未推送):R1-2(3836900725)、R1-16(3836900728、3837316223)、R1-21(3836900740)、R1-18(3836900766)。
  • 评审体事项:rebase 请求(rv:5000356506)仍是维护者的历史操作——本工作流只做增量提交,且本轮报告无冲突(--conflict false),故未执行任何合并。「部分审查」披露(rv:5000953056)不含可执行发现。议题级备注(web-shell 预览、serve A/B)与上一轮总结一致。

验证

本轮实际运行的命令:

  • npm run build — 通过(退出码 0)
  • npm run typecheck — 通过(退出码 0)
  • npm run lint — 通过(退出码 0)
  • npx prettier --check packages/cli/src/ui/AppContainer.test.tsx — 干净
  • npx vitest run src/ui/AppContainer.test.tsx(packages/cli)— 157 通过(156 条既有 + 新见证测试)
  • npx vitest run src/serve/routes/session-pr-backfill.test.ts src/serve/server/session-pr-refresh.test.ts(packages/cli)— 79 通过(保留提交的套件仍为绿色)
  • 变异探针:删除 getAvailableModelGrades mock 行 → 见证测试失败(expected [ …(1) ] to deeply equal []——捕获到 TypeError);恢复后重新变绿
  • 复现探针(临时文件,提交前已删除):用完全相同的部分方法 mock 构造 AgentTool → 同样的 TypeError;在相同 spy 下运行真实 config.initialize() → 捕获到同样的 TypeError 未处理拒绝;补全 mock → 零拒绝
  • npx vitest run --changed origin/main --passWithNoTests(packages/cli)— 门禁命令,在 CI 镜像环境(CI=true、干净可写的 HOME、无会话环境变量)下运行:668/668 个测试文件通过,19482 通过 | 36 跳过,退出码 0,零未处理拒绝(修复前基线为 19481 通过、3 条未处理拒绝、退出码 1)

环境说明:在本交互式 agent 会话内的测试运行最初出现过无关失败——会话导出了 QWEN_HOME/SANDBOX/QWEN_CODE_CUSTOM_SANDBOX_IMAGE 等变量,会泄漏进环境敏感测试,且会话用户无法写入默认的 ~/.qwen。在上述 CI 镜像环境(即确定性门禁所用的环境)下一切为绿色;另有一条 AuthDialog TUI 输入测试仅在非 CI 环境运行,CI=true 时按设计跳过(因此跳过数为 36,与门禁基线一致)。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

8 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • transcript-branch regex escape truncation (session-pr-backfill.ts:149) — already reported as R1-2 (comment 3836900725)
  • daemon refresh-timer happy-path wiring untested (run-qwen-serve.ts:5194) — already recorded in the round-9 deferral list (review 5003656717)
  • createServeApp mount of POST /sessions/backfill-prs untested (server.ts:2106) — already recorded in the round-11 deferral list (review 5006236253)
  • normalizeRemoteToWebUrl rejects scp-style remotes with non-git users (session-pr-backfill.ts:73) — already recorded in the round-4 deferral list (review 5001878012)
  • sweep 500-PR window staleness (session-pr-refresh.ts:123) — already reported as R1-18 (comment 3836900766)
  • sweep-write sidecar resurrection race (session-pr-service.ts:225) — already recorded in the round-9 deferral list (review 5003656717)
  • getRemoteWebUrl unbounded synchronous execSync (session-pr-backfill.ts:95) — already recorded in the round-7 deferral list (review 5002868793)
  • file-scoped throwing vi.mock of session-pr-refresh (run-qwen-serve.test.ts:8359) — already recorded in the round-11 deferral list (review 5006236253)

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

Not reviewed: build-and-test — Test (macos-latest/windows-latest, Node 22.x) unit lanes were skipped in CI; only the linux lane and the local linux run exercised the suites.

Not explored to full depth (tool budget reached): "agent 1c": none — though I did not run typecheck/tests; the compile-level edges (barrel exports, signatures) were verified by reading declarations instead.; chunk 11: could not execute session-pr-refresh.test.ts — the review worktree lacks built workspace-package dist outputs and npm run build failed silently in this envi…; chunk 4: could not execute packages/cli/src/serve/routes/session-pr-backfill.test.ts — the review worktree has no node_modules , and a full install + build exceeds th….

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.

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

  • packages/cli/src/serve/routes/session-pr-backfill.ts:249 — [probe] degraded run (unknown default branch) reports ghAvailable: true, defeating the field's contract
  • packages/cli/src/serve/server/session-pr-refresh.ts:111 — [probe] stateless sidecar entries (the sweep's reason to exist) have no test witness; an excluding mutation ships green
  • packages/acp-bridge/src/bridge.ts:9906 — [probe] no-republish clause unpinned for stateless re-binds over a stateful entry; spurious event + revision bump mutant ships green
  • packages/cli/src/serve/server/session-list.ts:539 — [probe] mergeSummaryPrs stateless-persisted branch unpinned; dropping it strips state from every live badge, 1087 tests green
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:55 — [probe] refresh-interval negative-value branch unpinned; a <= 0 consolidation silently disables the sweep
  • packages/core/src/services/session-pr-service.ts:195 — [probe] upsertSessionPr explicit-state precedence (pr.state ?? known?.state) unpinned; operand-swap mutant ships green
  • packages/cli/src/serve/routes/session-pr-backfill.ts:244 — [review] backfill head-branch mapping hard-limited to the newest-500 gh page; window misses leave no binding and no signal
  • packages/cli/src/serve/routes/session-pr-backfill.ts:278 — [probe] backfill closed-state passthrough unpinned; a closed-to-open mutant passes 52/52
  • packages/cli/src/serve/routes/session.ts:5482 — [review] rename-only PATCH returns prs state from the bridge entry the sweep never updates; response lags the sidecar
  • packages/cli/src/serve/server/session-pr-refresh.ts:186 — [review] timer tick per-workspace failure-isolation guard unwitnessed; guard-removal starves later workspaces, suite green
  • packages/web-shell/client/i18n.tsx:4388 — [review] new ZH state labels unwitnessed; a swap ships green while zh users see merged/closed labels flipped
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:222 — [probe] order-preservation contract pinned only with ascending seeds; a number-sort mutant ships green

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

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

本轮确认的 8 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

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

未审查:build-and-test — Test (macos-latest/windows-latest, Node 22.x) unit lanes were skipped in CI; only the linux lane and the local linux run exercised the suites。

未探索到全部深度(达到工具调用预算):"agent 1c"none — though I did not run typecheck/tests; the compile-level edges (barrel exports, signatures) were verified by reading declarations instead.;chunk 11:could not execute session-pr-refresh.test.ts — the review worktree lacks built workspace-package dist outputs and npm run build failed silently in this envi…;chunk 4:could not execute packages/cli/src/serve/routes/session-pr-backfill.test.ts — the review worktree has no node_modules , and a full install + build exceeds th…

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

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

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

if (!existing) return 0;
let changed = 0;
const next = existing.map((entry) => {
const state = states.get(entry.number);

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] updateSessionPrStates applies a number-keyed state map — built solely from the workspace repo's gh pr list — to every persisted entry, ignoring each entry's url. A binding whose URL points at a different repository is rewritten with the workspace repo's same-numbered PR's state, and because merged entries are filtered out of every future sweep, the wrong state is permanent.

The metadata routes accept any http(s) pr.url (parseSessionPrBody validates scheme/length/control-chars only), so a client can bind {number: 42, url: 'https://github.com/other-org/other-repo/pull/42'} while the workspace repo also has a PR #42. The next daemon sweep runs gh pr list --state all in the workspace, sees #42 merged, and writes state: 'merged' onto the external-repo binding — the badge then lies about the other repository's PR. Worse, the sweep's eligibility filter (p.state !== 'merged') exempts the wrongly-merged entry from every future refresh, so the corruption never self-heals.

Witness (probe against unmodified code, sweep driven with the binding above and a mocked workspace fetch reporting the workspace's own #42 merged):

persisted: [{"number":42,"url":"https://github.com/other-org/other-repo/pull/42","state":"merged"}]
result:    {"scanned":1,"updated":1}

With a url-matching guard (apply state only when the fetched PR's url equals the binding's url) the probe flips to {"scanned":1,"updated":0} with the binding's state still 'open'.

Key the refresh by repository, not bare number — either group pending numbers by the host/owner/repo parsed from each entry's url and fetch per repo, or skip entries whose URL does not match the workspace remote:

// in refreshWorkspaceSessionPrStates, when building numberToState:
// keep the fetched PR's url alongside the state...
const numberToState = new Map<number, { state: SessionPrState; url: string }>();
// ...and in updateSessionPrStates, apply only on match:
const mapped = states.get(entry.number);
if (mapped && mapped.url === entry.url) { /* rewrite state */ }
中文说明

[Critical] updateSessionPrStates 把“仅从当前 workspace 仓库的 gh pr list 构建、以 PR 编号为键”的状态映射套用到每一条持久化绑定上,完全忽略各条目的 url。若某条绑定的 URL 指向另一个仓库,它会被改写成 workspace 仓库中同号 PR 的状态;又因为 merged 条目会被排除在后续所有刷新之外,错误状态将永久存在。

元数据路由接受任意 http(s) 的 pr.urlparseSessionPrBody 只校验协议/长度/控制字符),因此客户端可以绑定 {number: 42, url: 'https://github.com/other-org/other-repo/pull/42'},而 workspace 仓库恰好也有一个 #42。下一次 daemon 扫描在 workspace 里执行 gh pr list --state all,看到 #42 已合并,就把 state: 'merged' 写到这条指向外部仓库的绑定上——badge 从此对另一个仓库的 PR 显示错误状态。更糟的是,扫描的过滤条件(p.state !== 'merged')会把这条被误标为 merged 的条目排除在此后所有刷新之外,错误无法自愈。

证据(对未修改代码的探针:用上述绑定驱动扫描,并让 workspace 的 mock 返回“本仓库 #42 已合并”):

persisted: [{"number":42,"url":"https://github.com/other-org/other-repo/pull/42","state":"merged"}]
result:    {"scanned":1,"updated":1}

加上“仅当抓取到的 PR url 与绑定 url 一致才应用状态”的守卫后,探针翻转为 {"scanned":1,"updated":0},绑定状态保持 'open'

建议按仓库(而非裸编号)建立刷新映射:把待定编号按各条目 url 解析出的 host/owner/repo 分组、按仓库分别抓取;或跳过 URL 与 workspace 远端不匹配的条目(示例代码见英文部分)。

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind main, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:

Autofix round stopped: gate rejection is load-induced test timeouts, not a code defect

The deterministic verification rejected commit 50289bde9c ("fix(core): scope session PR state refresh to the binding's url (#9729)") because vitest run --changed origin/main --passWithNoTests failed in packages/cli (5 files / 10 tests). Every failing test is a load-induced timeout in test files this PR never touched. There is no code-level fix inside this PR's footprint, so this round stops with an escalation instead of a speculative commit. The rejected commit is preserved as-is; the Critical finding (rc:3849083091) remains fixed by it.

Evidence

  1. Reproduced, and the failures are nondeterministic. Two runs of the exact gate command with identical sanitized environment (CI=true, neutral HOME, no QWEN_HOME/SANDBOX, LANG=en_US.UTF-8):
    • Run A: 5 files failed / 7 tests + 1 hook — src/commands/update.test.ts, src/serve/server-default-bridge-wiring.test.ts, src/serve/workspace-registration-store.test.ts, src/commands/review/script-lint-isolation.test.ts, src/ui/voice/voice-keyterms-race.test.ts
    • Run B: 2 files failed / 2 tests — server-default-bridge-wiring.test.ts, script-lint-isolation.test.ts
    • The gate itself saw 5 files / 10 tests. All failures are Test timed out in 15000ms (one Hook timed out in 20000ms); the failing set rotates between identical runs.
  2. All failing files are outside this PR's diff. `git diff origin/main...HEAD --na
中文说明

🤖 AutoFix 更新了一个过期的 base —— 修复未通过验证,但本 PR 落后于 main,因此已通过 update-branch 合入当前 main,并将在下次扫描时重试。过期的 base(main 已改动的依赖或符号)可能让构建失败而并非修复本身的错;若 base 更新后仍然失败,将移交人工处理。

⚠️ 此改动未被推送 —— 下文引用的任何提交都只存在于 runner 工作区,已被丢弃。以下是 agent 的报告:

Autofix 本轮停止:门禁拒绝是负载导致的测试超时,并非代码缺陷

确定性验证拒绝了提交 50289bde9c(“fix(core): scope session PR state refresh to the binding's url (#9729)”),原因是 packages/cli 中的 vitest run --changed origin/main --passWithNoTests 失败(5 个文件 / 10 个测试)。所有失败的测试都是本 PR 从未修改过的测试文件中、由负载引起的超时。本 PR 的代码改动范围内不存在任何可修复的缺陷,因此本轮以上报(escalation)结束,而不做投机性提交。被拒绝的提交原样保留;Critical 级发现(rc:3849083091)仍由该提交修复。

证据

  1. 已复现,且失败是不确定的。在完全相同的净化环境(CI=true、中性 HOME、无 QWEN_HOME/SANDBOX、LANG=en_US.UTF-8)下,用与门禁完全相同的命令运行两次:
    • 第一次:5 个文件失败 / 7 个测试 + 1 个 hook——src/commands/update.test.ts、src/serve/server-default-bridge-wiring.test.ts、src/serve/workspace-registration-store.test.ts、src/commands/review/script-lint-isolation.test.ts、src/ui/voice/voice-keyterms-race.test.ts
    • 第二次:2 个文件失败 / 2 个测试——server-default-bridge-wiring.test.ts、script-lint-isolation.test.ts
    • 门禁本身看到 5 个文件 / 10 个测试。所有失败都是 “Test timed out in 15000ms”(一个 “Hook timed out in 20000ms”);在完全相同的两次运行之间,失败的集合会轮换变化。
  2. 所有失败文件都在本 PR 的 diff 之外。git diff origin/main...HEAD --name-only 列出 34 个文件,其中不包含上述任何一个;它们之所以进入运行,仅仅是因为 vitest --changed 的依赖图扩展(本 PR 修改了大部分 CLI 都会导入的 core 导出)。
  3. 聚焦运行全部为绿。这五个文件放在一起运行:48 通过 | 1 跳过。
  4. GitHub CI 在本分支上为绿:“Test (ubuntu-latest, Node 22.x)” = SUCCESS(检查快照取自 50289bde9c 之前,即截至 455866e;差异提交就是那个 4 文件的 url 限定修复,其聚焦的 core + cli 套件均为绿色)。
  5. 根因:共享主机资源争用。整个运行期间 64 核上负载均值 50–60;vitest 按每 CPU 一个 worker 启动,而在测试体内动态导入大型模块图的测试(例如 server-default-bridge-wiring.test.ts 中的 await import('./server.js'))在争用下会超过 15 秒预算。packages/cli/vitest.config.ts 已经明确承认过这一失败模式(“ECS hosts run several jobs at once; leave capacity for neighboring jobs”),并在 RUNNER_NAME 以 ecs-qwen- 开头时把 maxWorkers 限制为 25%——但该模式与这台自托管主机不匹配(runner 目录 actions-runner-test-3,容器 qwen-code-2c3f2f07;agent 环境中甚至根本不存在 RUNNER_NAME),因此该限制在这里从未生效。

另外已排除的因素:

  • 我的 shell 环境最初引入了约 30 个额外失败(

Run log: https://github.com/QwenLM/qwen-code/actions/runs/32801566273


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

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

  • packages/cli/src/serve/routes/session.ts:5498 — [review] REST PATCH metadata accept/persist/echo of a valid pr.state is untested
  • packages/cli/src/serve/run-qwen-serve.ts:5191 — [review] Refresh-timer daemon wiring (start/generation guard/teardown) is untested
  • packages/cli/src/serve/routes/session-pr-backfill.ts:269 — [review] Default-branch guard trusts clone-time-only origin/HEAD; staleness re-enables fork-PR misattribution
  • packages/web-shell/client/components/sidebar/SessionDetailsTooltip.test.tsx:162 — [review] Tooltip test never renders state:open; a truthy-refactor would mislabel open bindings as Closed
  • packages/cli/src/serve/routes/session-pr-backfill.test.ts:492 — [review] gh-available-but-number-out-of-window fallback path has no test witness
  • packages/cli/src/serve/routes/session-pr-backfill.test.ts:175 — [probe] No test pins that credentials are stripped from the derived web URL persisted into the sidecar
  • packages/cli/src/serve/routes/session-pr-backfill.ts:73 — [probe] scp-style remotes with a non-git user are silently left unresolved
  • packages/cli/src/serve/routes/session-pr-backfill.ts:157 — [review] Whole-transcript in-memory reads in backfill can spike daemon heap
  • packages/cli/src/serve/server.test.ts:15507 — [probe] mergeSummaryPrs stateless-sidecar branch has no witness; guard-removal ships green
  • packages/cli/src/serve/server/session-pr-refresh.ts:174 — [review] Production process.env fallback for the refresh interval is exercised by no test
  • packages/cli/src/serve/server/session-pr-refresh.ts:190 — [probe] Reentrancy-flag reset path is unwitnessed; a one-shot timer mutation ships green
  • packages/cli/src/serve/routes/session-pr-backfill.test.ts:482 — [probe] Remote-URL memoization hit path is unwitnessed; miss-only-cache mutation ships green
  • packages/cli/src/serve/routes/session-pr-backfill.ts:376 — [probe] Cap trim evicts in plan order; a re-resolvable dialog binding can be evicted
  • packages/cli/src/serve/server/session-pr-refresh.ts:184 — [probe] Per-workspace sweep failure isolation is unwitnessed; removing it crashes the daemon
  • packages/core/src/services/session-pr-service.ts:69 — [probe] New persisted-state validation clause has no rejection-path test row

Convergence: round 14 posted 4 inline comment(s), 3 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/core/src/services/session-pr-service.ts (findings in round 13; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

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

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

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

收敛情况:第 14 轮发布了 4 条行内评论,其中 3 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/core/src/services/session-pr-service.ts(第 13 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

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

if (!existing) return 0;
let changed = 0;
const next = existing.map((entry) => {
const state = states.get(entry.number);

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] R13-1: updateSessionPrStates applies a number-keyed state map — built solely from this workspace's gh pr list — to every persisted entry, ignoring each entry's url. A binding whose URL points at a different repository is rewritten with this workspace's same-numbered PR's state, and because merged entries are filtered out of every future sweep, the wrong state is permanent. A client can bind {number: 42, url: 'https://github.com/other-org/other-repo/pull/42'} (the metadata route accepts any http(s) url) while this workspace also has a PR #42; the next sweep sees #42 merged here and stamps merged onto the external binding, and the state !== 'merged' filter then exempts it from every future refresh — it never self-heals. Re-checked at this commit: the mechanism still fires.

Witness (probe at the reviewed commit):

seeded: [{number:42, url: other-org/other-repo/pull/42, state:"open"}]
sweep:  workspace gh page reports its own #42 merged
result: {scanned:1, updated:1}
after:  [{number:42, url: other-org/other-repo/pull/42, state:"merged"}]

Key the refresh by repository, not bare number — carry each fetched PR's url alongside its state and apply it only when the fetched url equals the binding's url, or group pending numbers by the host/owner/repo parsed from each entry's url and fetch per repo.

中文说明

[Critical] R13-1:updateSessionPrStates 把"仅从当前 workspace 仓库 gh pr list 构建、以编号为键"的状态映射套用到每一条持久化绑定上,忽略各条目的 url。URL 指向其他仓库的绑定会被改写成 workspace 仓库同号 PR 的状态;又因 merged 条目被排除在后续所有刷新之外,错误状态永久存在。客户端可绑定 {number:42, url:'https://github.com/other-org/other-repo/pull/42'}(metadata 路由接受任意 http(s) url),而本仓库恰好也有 #42;下一次扫描见本仓库 #42 已合入,就把 merged 写到这条外部绑定上,且 state !== 'merged' 过滤使其豁免于此后所有刷新——无法自愈。已在当前 commit 复查:机制仍然触发。

修复建议:按仓库而非裸编号建立刷新映射——抓取时把每个 PR 的 url 与状态一并保存,仅当抓取到的 url 与绑定 url 一致时才应用;或按各条目 url 解析出的 host/owner/repo 分组、按仓库分别抓取。

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


// Transcript records carry the branch the session was on; the set is small
// per session and only ever compared against PR head branches.
const GIT_BRANCH_PATTERN = /"gitBranch":"([^"]+)"/g;

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] R14-1: collectTranscriptBranches extracts branches with a hand-rolled regex over the rendered JSONL transcript instead of a structured parse, so a gitBranch key nested anywhere inside a record's structured JSON (tool-call arguments/results, MCP payloads in message.parts/toolCallResult) is indistinguishable from the record's own top-level field. The injected branch is mapped to a PR and persisted as a binding the session never ran on, and bogus entries also consume the 64-branch cap, which can push out the genuine branch. The transcript writers JSON-stringify each record, so quotes inside plain text are escaped and don't match — but nested structured values serialize with unescaped keys, which is exactly what the pattern captures. The PR's tests only feed top-level gitBranch records, so this path ships untested.

Witness (probe at the reviewed commit):

record:   top-level gitBranch "real-branch" + nested functionResponse.response.gitBranch "feature/login-flow"
regex:    matches ["feature/login-flow","real-branch"]
backfill: session bound to the PR whose head branch is feature/login-flow (a PR it never ran on)
fix:      per-line JSON.parse reading only top-level gitBranch -> bound:0

Parse each transcript line and read only the top-level gitBranch field (or delegate to the existing transcript reader) instead of matching the rendered text:

for (const line of raw.split('\n')) {
  if (!line) continue;
  try {
    const record = JSON.parse(line) as { gitBranch?: unknown };
    if (typeof record.gitBranch === 'string') branches.add(record.gitBranch);
  } catch {
    // tolerate corrupt/truncated lines
  }
  if (branches.size >= MAX_DISTINCT_BRANCHES) break;
}
中文说明

[Critical] R14-1:collectTranscriptBranches 用手工正则扫描渲染后的 JSONL 文本,而非结构化解析,因此记录嵌套 JSON 里任意位置的 gitBranch 键(工具调用参数/结果、message.parts/toolCallResult 中的 MCP 负载)与记录自身的顶层字段无法区分。被注入的分支会被映射到某个 PR 并持久化为该会话从未运行过的绑定;伪造条目还会占用 64 分支上限,可能挤出真实分支。transcript 写入端对每条记录做 JSON.stringify,纯文本中的引号会被转义而不匹配——但嵌套结构化值序列化时键不带转义,恰是该正则能捕获的形态。本 PR 的测试只喂顶层 gitBranch 记录,因此该路径未经测试即上线。

修复建议:逐行 JSON.parse 且只读取顶层 gitBranch 字段(或复用现有 transcript 读取器),不要用正则匹配渲染文本(示例代码见英文部分)。

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

Comment on lines +223 to +227
changed += 1;
return { ...entry, state };
});
if (changed === 0) return 0;
await writeSessionPrs(filePath, next);

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] R14-2: updateSessionPrStates (the refresh-sweep write path) can resurrect a PR sidecar that a concurrent session deletion or archive move just removed. movePrSidecar/removePrSidecars run outside mutationQueue, and this queued read→write cycle has no liveness guard — unlike the backfill planner this same PR guards for the identical hazard with existsSync(candidate.transcriptPath). A sweep tick collects session X's numbers, runs gh, and queues the write; the user deletes or archives X between the queued function's readSessionPrs (still sees the file) and writeSessionPrs; the write (mkdir + atomic write) recreates <id>.pr.json at the stale path. For a deleted session the orphan is permanent — sessionPrSidecarBelongsToCurrentProject fails open on a missing transcript, so every future sweep keeps scanning it; for an archive move the next unarchive merge ties on createdAt and the incoming half wins, regressing a freshly written merged snapshot to open.

Witness (probe at the reviewed commit, deletion forced between the queued read and write):

PR code: sidecarExistsAfterDeleteAndWrite:true, readBack state:"merged" (resurrected, transcript gone)
guard:   no resurrection (flip)
mergeSessionPrLists(base=[merged], incoming=[open]) at equal createdAt -> state:"open"

Add an in-queue liveness re-check equivalent to backfill's — e.g. an optional predicate evaluated after the in-queue read (if (validate && !validate()) return 0;) that the sweep populates with a transcript/sidecar existence check — or route movePrSidecar/removePrSidecars through enqueuePrMutation so moves/deletes serialize with state writes.

中文说明

[Critical] R14-2:updateSessionPrStates(刷新扫描的写入路径)可能复活一个刚被并发删除/归档移动移除的 PR sidecar。movePrSidecar/removePrSidecarsmutationQueue 之外运行,而这个入队的"读→写"循环没有存活校验——本 PR 的 backfill 规划器对同一隐患已用 existsSync(candidate.transcriptPath) 防护。扫描周期收集会话 X 的编号、执行 gh、入队写入;用户在入队函数的 readSessionPrs(仍能看到文件)与 writeSessionPrs 之间删除/归档 X;写入(mkdir + 原子写)会在陈旧路径上重建 <id>.pr.json。对已删除会话,该孤儿永久存在——sessionPrSidecarBelongsToCurrentProject 对缺失 transcript 失败放行,后续每次扫描都会继续扫它;对归档移动,下次取消归档合并时 createdAt 相同、传入的一半胜出,会把刚写入的 merged 快照退回 open

修复建议:为 updateSessionPrStates 增加入队后的存活复检(例如在入队读取后评估一个可选谓词 if (validate && !validate()) return 0;,由扫描传入 transcript/sidecar 存在性检查);或把 movePrSidecar/removePrSidecars 也走 enqueuePrMutation,使移动/删除与状态写入串行化。

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

),
...live.map((l) => {
const persisted = persistedByNumber.get(l.number);
return persisted?.state !== undefined && persisted.state !== l.state

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R14-3: Backfill cap eviction rewrites the sidecar without syncing the bridge, and mergeSummaryPrs's union membership resurrects the evicted bindings from stale hydrated live entries — the rendered badge list exceeds the 10-entry cap until the daemon restarts. Backfill A binds 10 branch-mapped PRs to a live session S (sidecar full at SESSION_PR_LIST_LIMIT); any metadata PATCH hydrates the bridge entry with the full sidecar via seedSessionPrs; backfill B plans 12 numbers, all 10 existing droppable, trims the plan and evicts 2 from the sidecar, then invalidates the cache and bumps the catalog. The sidebar refetch runs mergeSummaryPrs(sidecar=[n3..n12], live=[n1..n10]) and keeps n1,n2 as live-only entries → 12 badges rendered including the 2 deliberately evicted. Nothing removes them from the bridge — seedSessionPrs is a no-op once populated, the refresh timer only rewrites state, and backfill never touches bridge entries — until a restart/close-reload/archive-restore recreates the entry.

Witness (probe driving the real listWorkspaceSessionsForResponse at the reviewed commit):

sidecar=[3..12], stale hydrated live=[1..10]
PR code: merged=[11,12,1,2,3,4,5,6,7,8,9,10] length 12 (evicted #1,#2 resurrected, cap 10 exceeded)
pruned:  merged=[11,12,3,4,5,6,7,8,9,10] length 10 (live pruned to persisted membership)

Give the bridge a replacement API (e.g. setSessionPrs(sessionId, prs) that overwrites entry.prs even when non-empty, unlike seedSessionPrs) and call it from the backfill route for live sessions whose sidecar write returned a changed list — mirroring the cache-invalidation + catalog pairing it already performs; alternatively prune the live side of mergeSummaryPrs to persisted membership when a persisted sidecar exists.

中文说明

[Critical] R14-3:backfill 上限驱逐重写 sidecar 时未同步 bridge,而 mergeSummaryPrs 的并集成员关系会从陈旧水合的 live 条目中复活被驱逐的绑定——渲染出的徽章列表会超过 10 条上限,直到 daemon 重启。backfill A 为活跃会话 S 绑定 10 个分支映射 PR(sidecar 达到 SESSION_PR_LIST_LIMIT 上限);任意 metadata PATCH 都会经 seedSessionPrs 把完整 sidecar 水合进 bridge 条目;backfill B 规划 12 个编号,其中 10 个已存在且可丢弃,裁剪计划并从 sidecar 驱逐 2 条,随后失效缓存并提升 catalog 版本。侧栏重新获取时执行 mergeSummaryPrs(sidecar=[n3..n12], live=[n1..n10]),把 n1、n2 作为仅 live 条目保留→渲染出 12 个徽章,包含被刻意驱逐的 2 条。没有任何机制把它们从 bridge 移除——seedSessionPrs 在已有条目时是空操作、刷新定时器只重写 state、backfill 从不触碰 bridge 条目——直到重启/关闭重载/归档恢复重建该条目。

修复建议:为 bridge 提供一个替换式 API(例如 setSessionPrs(sessionId, prs),即使 entry.prs 非空也整体覆写,不同于 seedSessionPrs),并在 backfill 路由中对 sidecar 写入返回了变化列表的活跃会话调用它——与其已执行的"缓存失效 + catalog 提升"配对;或者,当存在持久化 sidecar 时,把 mergeSummaryPrs 的 live 一侧裁剪到持久化成员集合。

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

…tes (QwenLM#9729)

Address the round-13/14 Critical review findings on the session PR
state feature:

- updateSessionPrStates applies a fetched state only when the fetched
  PR's url matches the binding's url, so a workspace PR can no longer
  stamp its state onto a binding pointing at another repository's
  same-numbered PR (a wrong terminal state was permanent — merged
  entries leave the sweep).
- The refresh sweep re-checks sidecar liveness at the write commit
  step via assertCanCommit, so a session deleted or archived mid-sweep
  no longer gets its sidecar resurrected at the stale path.
- collectTranscriptBranches parses each transcript line as JSON and
  reads only the top-level gitBranch, so nested structured values
  (tool-call arguments/results, MCP payloads) can no longer inject a
  branch the session never ran on.
- Backfill syncs the hydrated bridge entry through a new
  overwrite-capable bridge.setSessionPrs after a capped plan evicts
  bindings, so the summary merge can no longer resurrect evicted
  numbers from a stale live entry until daemon restart.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 3/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/100 轮)。改动内容与我反驳保留之处如下:

Round summary — PR #9729 (address-review)

All five inline Critical findings are resolved in code (rc:3849083091 and rc:3850994550 are the same R13-1 point). No finding was declined, deferred, or escalated. No conflicts (--conflict false; no merge performed). Commit: ad49587ee5.

Findings and decisions

rc:3849083091 + rc:3850994550 — R13-1 (Critical): sweep stamps workspace-repo state onto foreign-repo bindings — FIXED

Reproduced first: a binding {number: 42, url: other-org/other-repo/pull/42, state: 'open'} whose workspace also has PR #42 was rewritten to merged by the sweep on unmodified code (new regression test fails on the pre-round code path).

Fix: the refresh is now keyed by repository, not bare number. fetchGitHubPullRequests results carry each PR's url alongside its state into updateSessionPrStates, and a fetched state is applied only when the fetched url equals the binding's url. A mismatched binding is simply left untouched (fail-safe: stale rather than wrong) and stays refreshable. The fix follows the reviewer's suggested shape (carry url with state, apply on match) rather than per-repo fetch fan-out.

Witnesses: session-pr-service.test.ts (applies a fetched state only when its url matches the entry), session-pr-refresh.test.ts (never applies this workspace's state to a binding pointing at another repository). Mutation probe: removing the url check turns both tests red.

rc:3850994557 — R14-1 (Critical): transcript branch extraction via text regex binds injected nested branches — FIXED

Reproduced first: a record whose message.parts carries a structured functionResponse.response.gitBranch was bound to that injected branch's PR by the regex on unmodified code (new regression test fails on the pre-round code path).

Fix: collectTranscriptBranches now parses each JSONL line with JSON.parse and reads only the record's top-level gitBranch field (transcript writers emit one JSON.stringify(record) per line, confirmed at the writer). Corrupt/truncated lines are tolerated. The hand-rolled GIT_BRANCH_PATTERN regex is deleted — the change is net-subtractive in the source file.

Witness: session-pr-backfill.test.ts (ignores gitBranch keys nested inside structured record values, including a corrupt trailing line). Mutation probe: reverting to a text scan over the rendered record turns the test red.

rc:3850994568 — R14-2 (Critical): sweep write resurrects a sidecar a concurrent delete/archive just removed — FIXED

Reproduced first: with the sidecar deletion landing between the queued read and the write, unmodified code recreated <id>.pr.json at the stale path (new regression tests fail on the pre-round code path).

Fix: updateSessionPrStates accepts an assertCanCommit hook that is passed through to writeSessionPrs/atomicWriteJSON — the existing seam documented as "reject the write immediately before its irreversible commit step", which runs at the latest possible moment on every commit path (rename, in-place uid fallback, EXDEV fallback). The sweep supplies a liveness check that throws when the sidecar vanished, so the queued write aborts instead of resurrecting it; the sweep's existing per-target catch absorbs the rejection and keeps sweeping. This mirrors the backfill planner's existsSync(transcriptPath) guard the same PR already uses for the identical hazard.

Witnesses: session-pr-service.test.ts (does not resurrect a sidecar deleted between the queued read and the write commit, with queue-recovery assertion), session-pr-refresh.test.ts (does not resurrect a sidecar whose session is deleted mid-sweep). Mutation probes: dropping the sweep-side guard or the core pass-through turns the respective test red.

rc:3850994574 — R14-3 (Critical): backfill cap eviction desyncs the bridge; mergeSummaryPrs resurrects evicted bindings — FIXED

Reproduced first: with a full 10-entry sidecar, a hydrated live bridge entry, and a 12-number plan evicting 2, the rendered merged list held 12 badges including the evicted numbers on unmodified code (new end-to-end test fails on the pre-round code path).

Fix (the reviewer's first suggested shape): a new overwrite-capable bridge API setSessionPrs(sessionId, prs) — unlike seedSessionPrs it replaces entry.prs even when non-empty, no-op for unknown ids. Backfill calls it right after a changed sidecar write with the persisted list, pairing with the cache invalidation + catalog bump it already performs; for non-live sessions the bridge call is a no-op. Backfill was the only binding writer that skipped the bridge (bind PATCH updates both; the sweep rewrites state only and the merge prefers persisted state), so this closes the divergence at its source.

Witnesses: bridge.test.ts (replaces this-daemon-lifetime bindings on setSessionPrs, pins overwrite-vs-seed semantics and the unknown-id no-op), session-pr-backfill.test.ts (syncs the live bridge entry when a capped plan evicts bindings, end-to-end through the real listWorkspaceSessionsForResponse merge: merged list is [3..12], cap respected, evicted numbers gone). Mutation probes: removing the backfill call or making setSessionPrs defer like seed turns the respective tests red.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on all nine changed files — passed (three reformatted with --write, suites re-run green afterwards)
  • vitest packages/core src/services/session-pr-service.test.ts — 34 passed
  • vitest packages/cli src/serve/server/session-pr-refresh.test.ts — 29 passed
  • vitest packages/cli src/serve/routes/session-pr-backfill.test.ts — 54 passed
  • vitest packages/cli src/serve/run-qwen-serve.test.ts — 267 passed
  • vitest packages/cli src/serve/server.test.ts + src/serve/acp-http/transport.test.ts — 1432 passed, 1 skipped
  • vitest packages/acp-bridge src/bridge.test.ts — 784 passed
  • Mutation probes (remove guard → focused test fails; restore → green), one per finding, all recorded above
  • Integration tests: not run — the touched behavior is exercised by the unit suites above, not only through the bundled CLI/integration harness

Round-13/14 review bodies listed further items as "Deferred under the convergence posture … recorded, not requested in this round"; per the feedback they are out of scope for this round and untouched.

中文说明

轮次总结 — PR #9729(address-review)

5 条行内 Critical 发现全部在代码中解决(rc:3849083091 与 rc:3850994550 是同一条 R13-1)。没有拒绝、延后或升级任何发现。无冲突(--conflict false,未执行合并)。提交:ad49587ee5

发现与处理

rc:3849083091 + rc:3850994550 — R13-1(Critical):扫描把 workspace 仓库的状态写到指向外部仓库的绑定上 — 已修复

先复现:绑定 {number: 42, url: other-org/other-repo/pull/42, state: 'open'} 且 workspace 恰好也有 #42 时,未修改的代码会被扫描改写为 merged(新回归测试在修改前代码路径上失败)。

修复:刷新改为按仓库而非裸编号建立映射。fetchGitHubPullRequests 的结果把每个 PR 的 url 与状态一并传入 updateSessionPrStates,仅当抓取到的 url 与绑定的 url 相等时才应用状态。不匹配的绑定保持原样(安全方向:宁可陈旧,不可写错),且仍可继续参与刷新。修复采用评审建议的第一种形态(url 随状态携带、匹配才应用),而非按仓库分别抓取。

证据:session-pr-service.test.tsapplies a fetched state only when its url matches the entry)、session-pr-refresh.test.tsnever applies this workspace's state to a binding pointing at another repository)。变异探针:移除 url 匹配条件后两个测试均变红。

rc:3850994557 — R14-1(Critical):文本正则提取 transcript 分支,导致嵌套注入的分支被绑定 — 已修复

先复现:记录的 message.parts 里带结构化 functionResponse.response.gitBranch 时,未修改代码的正则会把会话绑定到该注入分支对应的 PR(新回归测试在修改前代码路径上失败)。

修复:collectTranscriptBranches 改为逐行 JSON.parse,只读取记录顶层的 gitBranch 字段(已确认 transcript 写入端每行一条 JSON.stringify(record))。损坏/截断的行被容忍。手工 GIT_BRANCH_PATTERN 正则被删除——源文件净减少。

证据:session-pr-backfill.test.tsignores gitBranch keys nested inside structured record values,含一行损坏数据)。变异探针:退回到对渲染文本的扫描后测试变红。

rc:3850994568 — R14-2(Critical):扫描写入复活刚被并发删除/归档移除的 sidecar — 已修复

先复现:让删除恰好落在入队读取与写入之间,未修改代码会在陈旧路径上重建 <id>.pr.json(新回归测试在修改前代码路径上失败)。

修复:updateSessionPrStates 接受 assertCanCommit 钩子,透传给 writeSessionPrs/atomicWriteJSON——即现有文档中"在不可逆提交步骤前立即拒绝写入"的接缝,它在所有提交路径(rename、uid 不同的原地写回退、EXDEV 回退)的最晚时机执行。扫描传入存活检查:sidecar 已消失即抛错,使入队写入中止而不是复活文件;扫描既有的按目标 catch 会吸收该拒绝并继续处理其余工作区。这与本 PR 中 backfill 规划器对同一隐患使用的 existsSync(transcriptPath) 守卫同构。

证据:session-pr-service.test.tsdoes not resurrect a sidecar deleted between the queued read and the write commit,含队列恢复断言)、session-pr-refresh.test.tsdoes not resurrect a sidecar whose session is deleted mid-sweep)。变异探针:分别移除扫描侧守卫或核心透传后,对应测试变红。

rc:3850994574 — R14-3(Critical):backfill 上限驱逐与 bridge 失联,mergeSummaryPrs 复活被驱逐的绑定 — 已修复

先复现:sidecar 满 10 条、live bridge 条目已水合、规划 12 个编号并驱逐 2 条时,未修改代码渲染出的合并列表有 12 个徽章,含被驱逐的编号(新端到端测试在修改前代码路径上失败)。

修复(采用评审建议的第一种形态):bridge 新增可覆写 API setSessionPrs(sessionId, prs)——与 seedSessionPrs 不同,即使条目非空也整体替换 entry.prs,未知会话 id 为无操作。backfill 在 sidecar 写入变化后立即以持久化列表调用它,与它已执行的"缓存失效 + catalog 版本提升"配对;非 live 会话时该调用为无操作。backfill 是唯一跳过 bridge 的绑定写入方(bind PATCH 两边都更新;扫描只改状态且合并时以持久化状态为准),因此在源头闭合了失联。

证据:bridge.test.tsreplaces this-daemon-lifetime bindings on setSessionPrs,钉住覆写语义与未知 id 无操作)、session-pr-backfill.test.tssyncs the live bridge entry when a capped plan evicts bindings,端到端走真实 listWorkspaceSessionsForResponse 合并:合并列表为 [3..12],上限得到遵守,被驱逐编号消失)。变异探针:移除 backfill 调用,或让 setSessionPrs 像 seed 一样退让,对应测试分别变红。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 对全部 9 个改动文件执行 npx prettier --check — 通过(3 个文件经 --write 重新格式化,之后重跑套件均为绿色)
  • vitest packages/core src/services/session-pr-service.test.ts — 34 通过
  • vitest packages/cli src/serve/server/session-pr-refresh.test.ts — 29 通过
  • vitest packages/cli src/serve/routes/session-pr-backfill.test.ts — 54 通过
  • vitest packages/cli src/serve/run-qwen-serve.test.ts — 267 通过
  • vitest packages/cli src/serve/server.test.ts + src/serve/acp-http/transport.test.ts — 1432 通过,1 跳过
  • vitest packages/acp-bridge src/bridge.test.ts — 784 通过
  • 变异探针(移除守卫 → 焦点测试失败;恢复 → 变绿),每条发现各一次,均记录如上
  • 集成测试:未运行——本次改动的行为由上述单元套件覆盖,并非仅通过打包 CLI/集成测试框架才能验证

第 13/14 轮评审正文中其余条目被列为"收敛姿态下延后……已记录,本轮不要求修改";按反馈说明,它们不在本轮范围内,未做改动。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

Not explored to full depth (tool budget reached): chunk 3: could not execute session-pr-backfill.test.ts — the review worktree (and parent checkout) has no node_modules , and npm ci plus the prerequisite builds exc….

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

  • packages/cli/src/serve/routes/session-pr-backfill.ts:95 — [review] getRemoteWebUrl blocking execSync with no timeout in the request path
  • packages/cli/src/serve/routes/session-pr-backfill.ts:156 — [review] collectTranscriptBranches whole-file readFile + synchronous per-line JSON.parse on the event loop
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:1020 — [review] Timer suite never verifies a second successful sweep
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:897 — [review] Timer fixtures couple primary to trusted, so the trust filter is indistinguishable from a primary filter
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:1023 — [review] Overlap test exercises the running guard only in the held direction; the finally release on throw is untested
  • packages/cli/src/serve/server/session-pr-refresh.ts:126 — [review] Sweep swallows every gh failure with zero logging
  • packages/core/src/services/session-pr-service.ts:227 — [review] Exact-string url equality in the cross-repo guard freezes benign url variants permanently
  • packages/cli/src/serve/routes/session-pr-backfill.test.ts:1650 — [review] Backfill route suite never seeds an untrusted primary workspace
  • packages/cli/src/serve/routes/session-pr-backfill.test.ts:1824 — [review] Per-workspace cache-invalidation/catalog attribution never pinned across two writing workspaces
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:215 — [review] Sweep suite never seeds a stateless binding — backfill's gh-down shape is unpinned
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:942 — [review] The deps.env ?? process.env fallback is never exercised by any timer test
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:936 — [review] Disabled-via-env test asserts API shape only, not that nothing was scheduled
  • packages/core/src/services/session-pr-service.ts:251 — [review] replaceSessionPrs cannot forward assertCanCommit — backfill commit step unguarded against out-of-queue deletion
  • packages/cli/src/serve/routes/session-pr-backfill.test.ts:524 — [review] droppable filter unpinned — unresolved convention number never combined with cap pressure
  • packages/cli/src/serve/server/session-pr-refresh.test.ts:953 — [review] Tick-loop per-workspace exception isolation unpinned — no timer test registers two trusted runtimes
  • packages/cli/src/serve/routes/session-pr-backfill.ts:298 — [review] Fork-PR headRefName collision can shadow same-branch base-repo PRs in the branch mapping

Convergence: round 15 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 4 (3 new). Findings keep coming back to the same files: packages/core/src/services/session-pr-service.ts (findings in rounds 13, 14; 1 more now); packages/cli/src/serve/routes/session-pr-backfill.ts (findings in round 14; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

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

未探索到全部深度(达到工具调用预算):chunk 3:could not execute session-pr-backfill.test.ts — the review worktree (and parent checkout) has no node_modules , and npm ci plus the prerequisite builds exc…

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

收敛情况:第 15 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 4 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/services/session-pr-service.ts(第 13、14 轮已出过发现,本轮又有 1 条);packages/cli/src/serve/routes/session-pr-backfill.ts(第 14 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

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

const run = async (): Promise<SessionPr[]> => {
return enqueuePrMutation(filePath, async () => {
const existing = (await readSessionPrs(filePath)) ?? [];
const known = existing.find((entry) => entry.number === pr.number);

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] R15-1: upsertSessionPr carries the existing entry's state onto a re-bind matched by PR number alone, even when the url changes to a different repository's same-numbered PR — known is looked up by number only, and the new entry spreads pr.state ?? known?.state unconditionally. This is the exact cross-repo contamination the new sweep guard in updateSessionPrStates (url-match below) exists to prevent; the re-bind path has no such guard. Because merged entries leave the sweep's fetch list (state !== 'merged' filter) and updateSessionPrStates corrects state only when the fetched url matches the entry url, a carried 'merged' is never healed — the wrong terminal state is permanent. The bridge live-entry twin of this pattern is posted separately on bridge.ts.

Failure scenario: a sidecar holds {number: 100, url: repoA/.../pull/100, state: 'merged'}; a client re-binds {number: 100, url: repoB/.../pull/100} with no state (REST parseSessionPrBody and ACP session/update_metadata both accept any http(s) url and state is optional). The entry gets repoB's url and a fresh createdAt but inherits repoA's 'merged' — repoB's open PR displays as merged permanently, in the badge and in every list response.

Witness (probe, both arms at the reviewed commit): PR code persisted {"number":100,"url":"https://github.com/repoB/o/pull/100",...,"state":"merged"} after a stateless re-bind from repoA; with the url-gated carry below the persisted entry has no state key (34/34 existing session-pr-service tests stay green under the fix).

Fix shape — gate the carry on the binding target, comparing normalized urls (a bare strict-equality flip breaks this diff's own same-PR ?v=2 variant behavior; the URL parser already lowercases the host, so normalize repo-path case and trailing slash):

const canon = (u: string) => u.toLowerCase().replace(/\/+$/, '');
const carried =
  known && canon(known.url) === canon(pr.url) ? known.state : undefined;
const state = pr.state ?? carried;
// ...spread `state ? { state } : {}` as today
中文说明

upsertSessionPr 在重新绑定(re-bind)时仅按 PR 编号匹配已有条目,即使 url 已经换成了另一个仓库的同编号 PR,也会把旧条目的 state 带过去——known 只按 number 查找,新条目无条件展开 pr.state ?? known?.state。这正是本轮在 updateSessionPrStates(下方刷新扫描路径)新增 url 匹配守卫所要防止的跨仓库状态污染,而 re-bind 路径没有这个守卫。由于 merged 条目会离开扫描的拉取列表(state !== 'merged' 过滤),且 updateSessionPrStates 只在拉取到的 url 与条目 url 一致时才纠正状态,被带过去的 'merged' 永远不会被修复——错误的终态将是永久的。同样的模式在 bridge 的实时条目(live entry)中另有一条行内评论。

触发场景:sidecar 中存有 {number: 100, url: repoA/.../pull/100, state: 'merged'};客户端以不带 state 的 {number: 100, url: repoB/.../pull/100} 重新绑定(REST parseSessionPrBody 与 ACP session/update_metadata 都接受任意 http(s) url,且 state 可选)。条目换成 repoB 的 url、获得新的 createdAt,却继承 repoA 的 'merged'——repoB 处于 open 状态的 PR 将在徽章和所有列表响应中永久显示为已合并。

证据(探针,在受审提交上双臂验证):PR 代码在从 repoA 无 state 重绑后持久化了 {"number":100,"url":"https://github.com/repoB/o/pull/100",...,"state":"merged"};采用下面的 url 匹配携带后,持久化条目不再含 state 键(修复下现有 34/34 个 session-pr-service 测试全部通过)。

修复方向——仅当绑定目标不变时才携带 state,并比较规范化后的 url(直接严格相等会破坏本 diff 自身的同 PR ?v=2 变体行为;URL 解析器已会将 host 小写,因此规范化仓库路径大小写与末尾斜杠即可)。

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

Comment thread packages/acp-bridge/src/bridge.ts Outdated
// Re-binding a number refreshes it and moves it to latest.
// Re-binding a number refreshes it and moves it to latest; an
// omitted state preserves the known one (mirrors the sidecar).
const known = existing.find((p) => p.number === bound.number);

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] R15-1: the bridge live-entry re-bind in updateSessionMetadata carries the previous binding's state over to a different PR matched by number alone — the live-side twin of the sidecar finding posted on session-pr-service.ts. The same-binding early-return requires latest.url === bound.url, but the carry lookup known does not, so a cross-repo re-bind without state inherits the old repository's state; markSessionCatalogChanged() and the session_metadata_updated event then publish it. Even with the sidecar carry fixed, mergeSummaryPrs only overrides live state when the persisted entry has one, so list responses keep serving the wrong live state, and the refresh sweep rewrites only sidecars (never live entries) — with QWEN_SESSION_PR_REFRESH_MINUTES=0 the wrong state is permanent.

Failure scenario: the live entry holds {number: 100, url: repoA/.../pull/100, state: 'merged'} (set by an earlier bind or hydrated via seedSessionPrs); a client re-binds {number: 100, url: repoB/.../pull/100} without state — the early-return fails on the url, known matches repoA's entry by number, and the new live entry is stamped merged and broadcast in the metadata event: repoB's open PR is shown as merged.

Witness (probe, both arms at the reviewed commit): PR code served [ { number: 100, state: "merged", url: "https://github.com/repoB/o/pull/100" } ] after the cross-repo stateless re-bind; gating the carry on the binding target removes it. Note the existing preserves the known state on a stateless re-bind test re-binds a same-repo ?v=2 url, so the fix must compare normalized urls (see the session-pr-service.ts comment) rather than flip to strict equality, which breaks that test.

const canon = (u: string) => u.toLowerCase().replace(/\/+$/, '');
const known = existing.find(
  (p) => p.number === bound.number && canon(p.url) === canon(bound.url),
);
中文说明

updateSessionMetadata 中 bridge 实时条目的重新绑定仅按 number 匹配,就会把上一个绑定的 state 带到另一个仓库的同编号 PR 上——这是 session-pr-service.ts 上 sidecar 发现的实时侧孪生体。同绑定早退(early-return)要求 latest.url === bound.url,但携带查找 known 没有这个要求,因此不带 state 的跨仓库重绑会继承旧仓库的状态;随后 markSessionCatalogChanged()session_metadata_updated 事件会把该状态发布出去。即使修复了 sidecar 侧的携带,mergeSummaryPrs 只在持久化条目有 state 时才覆盖实时 state,所以列表响应仍会提供错误的实时状态;而刷新扫描只改写 sidecar、从不改写实时条目——在 QWEN_SESSION_PR_REFRESH_MINUTES=0 时错误状态将永久存在。

触发场景:实时条目持有 {number: 100, url: repoA/.../pull/100, state: 'merged'}(来自早先绑定或经 seedSessionPrs 注水);客户端以不带 state{number: 100, url: repoB/.../pull/100} 重绑——早退因 url 不同而失败,known 按编号命中 repoA 的条目,新的实时条目被打上 merged 并通过元数据事件广播:repoB 处于 open 状态的 PR 被显示为已合并。

证据(探针,在受审提交上双臂验证):跨仓库无 state 重绑后,PR 代码返回 [ { number: 100, state: "merged", url: "https://github.com/repoB/o/pull/100" } ];将携带限定在绑定目标一致后该状态消失。注意现有 preserves the known state on a stateless re-bind 测试重绑的是同仓库 ?v=2 url,因此修复必须比较规范化后的 url(见 session-pr-service.ts 的评论),而不是直接改为严格相等——那会使该测试失败。

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

// step with the sidecar; a capped plan can evict numbers, and the
// stale entry would resurrect them in the summary merge until a
// daemon restart. No-op when the session is not live.
runtime.bridge.setSessionPrs?.(

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] R15-2: the new setSessionPrs live-entry sync propagates a cap-trim eviction of a concurrently committed client re-bind. The planner's "never dropped" protection (plannedFor = droppable.has(n) && existingNumbers.has(n)) keys on the pre-run snapshot, so it covers only numbers absent from the snapshot: a client upsertSessionPr of a snapshot-held planned number that commits inside the same per-path mutation queue before this rewrite is trimmed out of the plan and evicted from kept, contradicting the comment above the planner. Before this round the stale live entry masked the storage drop in the summary merge; this new setSessionPrs call now overwrites the live entry with the trimmed list, so the binding the daemon just confirmed in the upsert response disappears from storage and every summary at once.

Failure scenario: a session's sidecar holds ~9 of the 10-slot cap and backfill plans numbers for it; while the backfill request runs, a client re-binds PR 5 (a snapshot-held planned number) via ACP/REST. Both writers serialize on the same mutationQueue keyed by sidecar path — queue order alone decides: upsert first → #5 is trimmed, evicted, and setSessionPrs overwrites the live entry with the #5-less list; upsert after the write → #5 survives.

Witness (probe at the reviewed commit, deterministic seam): arm A (client upsert of #5 commits before the rewrite) — persisted [101,102,103,104,105,106,107,108,6,7] and setSessionPrs called with the same list: #5 gone from storage and the live entry, result {bound: 2, alreadyBound: 0, overLimit: 1} (the dropped confirm counts nowhere); arm B (same upsert after the write) — persisted [102,...,108,6,7,5], binding survives. The fix below flips arm A to [101..108,5,7] with all 54 shipped backfill tests green.

Fix shape — exclude entries newer than the run snapshot from the plan (capture a snapshot timestamp before planning and extend the kept filter with || entry.createdAt > snapshotAt), or re-upsert after the rewrite any live binding whose number the rewrite dropped.

中文说明

新增的 setSessionPrs 实时条目同步会把"容量裁剪导致的驱逐"传播出去:当一个并发提交的客户端重绑被裁剪驱逐时,它也会被从实时条目中抹掉。规划器的"永不丢弃"保护(plannedFor = droppable.has(n) && existingNumbers.has(n))以运行前的快照为键,因此只保护快照中不存在的编号:快照中已有、且已被规划的编号,如果在同一个按路径排序的变更队列里先于本次重写提交(客户端 upsertSessionPr),就会被裁剪移出计划、从 kept 中驱逐——这与规划器上方注释的承诺相矛盾。本轮之前,过期的实时条目会在摘要合并中掩盖存储层的丢失;本轮新增的 setSessionPrs 调用会用裁剪后的列表覆盖实时条目,于是守护进程刚刚在 upsert 响应中确认的绑定会同时从存储和所有摘要中消失。

触发场景:某会话的 sidecar 已占用 10 个容量中的约 9 个,backfill 为其规划了若干编号;在 backfill 请求运行期间,客户端通过 ACP/REST 重绑 PR 5(快照中已有的规划编号)。两个写入者串行于同一按 sidecar 路径为键的 mutationQueue——仅由队列顺序决定结果:upsert 在前 → #5 被裁剪、驱逐,setSessionPrs 用不含 #5 的列表覆盖实时条目;upsert 在写入之后 → #5 保留。

证据(在受审提交上的探针,确定性接缝):臂 A(客户端对 #5 的 upsert 先于重写提交)——持久化为 [101,102,103,104,105,106,107,108,6,7]setSessionPrs 收到同样的列表:#5 同时从存储与实时条目中消失,结果 {bound: 2, alreadyBound: 0, overLimit: 1}(被丢弃的确认完全不计数);臂 B(同一 upsert 在写入之后)——持久化为 [102,...,108,6,7,5],绑定保留。下面的修复方向可让臂 A 变为 [101..108,5,7],且现有 54 个 backfill 测试全部通过。

修复方向——把晚于运行快照的条目排除在计划之外(规划前记录快照时间戳,并在 kept 过滤中追加 || entry.createdAt > snapshotAt),或在重写结束后,把被重写丢弃但仍存活的绑定重新 upsert 一次。

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

…ion (QwenLM#9729)

Address the round-15 Critical review findings:

- upsertSessionPr and the bridge's updateSessionMetadata carry an
  existing entry's state onto a re-bind only when the binding target
  is the same PR: urls are compared in canonical form (host/path case,
  trailing slash, query, and fragment never change which PR a url
  names), so a different repository's same-numbered PR no longer
  inherits a state the refresh sweep can never heal.
- The backfill planner treats an entry committed after the run's
  snapshot read as foreign, so a client re-bind of a snapshot-held
  planned number can no longer be trimmed out of the plan and evicted
  by the capped rewrite the daemon just confirmed; slots recompute
  around it and the live-entry sync publishes the surviving binding.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 4/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 4/100 轮)。改动内容与我反驳保留之处如下:

Round summary — PR #9729 (round 16)

Critical-only mode was active (growth engagement). Three Critical inline findings were in scope; all three were reproduced on the pre-round code, fixed at the root cause, and pinned with regression tests. One commit: db8500abcc.

Feedback points and dispositions

[rc:3853542416] R15-1 — upsertSessionPr carries state across a cross-repository re-bind (Critical) → RESOLVED.
Reproduced first: a witness re-binding {number: 100, url: repoB/.../pull/100} over a persisted {number: 100, url: repoA/.../pull/100, state: 'merged'} inherited 'merged' on the pre-round code (test failed exactly as the finding describes). Fix: the state carry now requires the binding target to be the same PR — a new exported canonicalSessionPrUrl in core compares urls ignoring host/path case, trailing slashes, query, and fragment (those never change which PR a url names; GitHub hosts and repo paths are case-insensitive and query variants are cache-busters), while different repository paths stay different. The entry replacement itself stays number-based, so a cross-repo re-bind still moves the number to latest with the new url and a fresh createdAt — it just no longer inherits the other repository's state. Two deviation notes from the suggested fix shape, both evidence-backed: (1) the suggested canon (lowercase + trailing-slash only) does not strip query strings, which would break the PR's own pinned same-repo ?v=2 carry behavior — the committed normalization drops query/fragment instead; (2) the normalization is shared as one core export used by both carry sites rather than duplicated closures.

[rc:3853542426] R15-1 twin — bridge live-entry carry across a cross-repository re-bind (Critical) → RESOLVED.
Reproduced first: after a stateless cross-repo re-bind the pre-round code served {number: 9517, state: 'merged', url: repoB} and published it through the metadata event (test failed exactly as the finding describes). Fix: the known lookup in updateSessionMetadata now matches number AND canonical url, mirroring the sidecar gate, using the same shared canonicalSessionPrUrl. The same-binding early return keeps its strict url equality, so a ?v=2 re-bind still updates the stored url and carries state — the existing preserves the known state on a stateless re-bind test stays green. With the live entry corrected at bind time, mergeSummaryPrs and the metadata event can no longer publish a carried cross-repo state.

[rc:3853542431] R15-2 — backfill setSessionPrs propagates a cap-trim eviction of a concurrent client re-bind (Critical) → RESOLVED.
Reproduced first using the suite's existing sidecarReadHook seam (client upsert of a snapshot-held planned number commits between the snapshot read and the queued rewrite): the pre-round code evicted the re-bound entry ([...108, 6, 7] instead of [...108, 5, 7]) and setSessionPrs published the trimmed list. Fix: the planner now captures snapshotAt before the snapshot read and plannedFor additionally requires entry.createdAt < snapshotAt, so an entry committed while the run is in flight is foreign — kept unconditionally, with cap slots recomputed around it. One deviation from the suggested fix shape, evidence-backed: gating the kept filter with || entry.createdAt > snapshotAt would keep trimmed entries without recomputing slots, letting the rewritten list exceed SESSION_PR_LIST_LIMIT; gating plannedFor preserves the cap invariant (the new test pins toHaveLength(SESSION_PR_LIST_LIMIT)) and matches the planner's existing comment ("the slots are recomputed around it"). The new test also pins the live-entry arm: setSessionPrs receives the surviving binding.

[rv:5019571274] Review body — "Partially reviewed — gaps disclosed" (CHANGES_REQUESTED) → NO ACTION REQUIRED THIS ROUND.
The body discloses review-scope gaps (Integration Tests suite not run in CI or locally; chunk 3 cut off by the review's tool budget) and explicitly defers its 16 listed items as "recorded, not requested in this round". It names no actionable defect beyond the three inline findings above. Under critical-only mode nothing else was in scope.

Growth audit: growth-audit.json written before any edit — verdict sound (KISS pass: every window-growth piece traces to an accepted round-13/14 failure mode, and this round gates existing behavior rather than adding guards; minimal-change pass: all hunks trace to the three accepted Critical findings inside the PR's existing footprint).

Conflict notes: none — --conflict false, no merge performed.

Verification

All commands actually run, in order:

  • npm run build — passed (run three times: to enable cli unit tests, after the core fix for cross-package dist resolution, and on the final tree; exit 0 each time)
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on the six touched files — one formatting issue found in the new backfill test, fixed with npx prettier --write; re-check clean
  • Focused Vitest, pre-fix reproduction (expected failures):
    • packages/core src/services/session-pr-service.test.ts — 1 failed (new cross-repo witness) | 35 passed
    • packages/acp-bridge src/bridge.test.ts -t "cross-repository re-bind" — 1 failed (new witness)
    • packages/cli src/serve/routes/session-pr-backfill.test.ts -t "snapshot-held" — 1 failed (new witness, evicted 5 replaced by 6)
  • Focused Vitest, post-fix:
    • packages/core src/services/session-pr-service.test.ts — 36 passed (36)
    • packages/acp-bridge src/bridge.test.ts (full suite) — 785 passed (785)
    • packages/cli session-pr-backfill.test.ts + session-pr-refresh.test.ts — 84 passed (84)
    • packages/cli transport.test.ts + multi-workspace-sessions.test.ts + server.test.ts — 1575 passed | 1 skipped
  • Mutation probes (each guard's witness; mutation applied → focused test FAILED → guard restored → green):
    • core: number-only known lookup → cross-repo witness failed; restored
    • core: identity canonicalization (strict equality) → variant-spelling carry witness failed; restored
    • bridge: number-only known lookup → bridge cross-repo witness failed; restored
    • backfill: createdAt < snapshotAt gate removed → snapshot-held witness failed; restored
  • Not run: integration tests after npm run bundle — the behaviors changed this round (carry gates, backfill planner predicate) are exercised directly by the unit harnesses above, not only through the bundled CLI or integration harness. No settings source changed, so npm run generate:settings-schema was not required.
中文说明

轮次总结 — PR #9729(第 16 轮)

本轮处于仅处理 Critical 的模式(由增长触发)。范围内共 3 条 Critical 行内发现;三条均在改动前代码上复现,从根因修复,并补充回归测试固定。提交一个:db8500abcc

反馈要点与处理结果

[rc:3853542416] R15-1 — upsertSessionPr 在跨仓库重绑时携带状态(Critical)→ 已解决。
先复现:在改动前代码上,用 {number: 100, url: repoB/.../pull/100} 对已持久化的 {number: 100, url: repoA/.../pull/100, state: 'merged'} 做无 state 重绑,会继承 'merged'(测试按发现描述的方式失败)。修复:状态携带现在要求绑定目标是同一个 PR——core 新增导出的 canonicalSessionPrUrl 在比较 url 时忽略 host/路径大小写、末尾斜杠、query 与 fragment(这些从不改变 url 所指向的 PR:GitHub 的 host 与仓库路径大小写不敏感,query 变体只是缓存破坏参数),而不同的仓库路径仍然不同。条目替换本身仍按编号进行,因此跨仓库重绑依旧把该编号移到最新位置、使用新 url 与新的 createdAt——只是不再继承另一个仓库的状态。相对建议修复形态有两处偏差,均有证据支撑:(1) 建议的 canon(仅小写化 + 去末尾斜杠)不会去掉 query,这会破坏本 PR 自身已固定的同仓库 ?v=2 携带行为——提交的规范化改为丢弃 query/fragment;(2) 规范化作为一个 core 导出在两个携带点共用,而不是各自复制闭包。

[rc:3853542426] R15-1 孪生体 — bridge 实时条目跨仓库重绑携带状态(Critical)→ 已解决。
先复现:跨仓库无 state 重绑后,改动前代码返回 {number: 9517, state: 'merged', url: repoB} 并通过元数据事件发布(测试按发现描述的方式失败)。修复:updateSessionMetadata 中的 known 查找现在同时匹配编号与规范化 url,与 sidecar 侧守卫一致,共用同一个 canonicalSessionPrUrl。同绑定早退保持严格 url 相等,因此 ?v=2 重绑仍会更新存储的 url 并携带状态——现有 preserves the known state on a stateless re-bind 测试保持通过。实时条目在绑定时即被纠正后,mergeSummaryPrs 与元数据事件不可能再发布跨仓库携带来的状态。

[rc:3853542431] R15-2 — backfill 的 setSessionPrs 传播了并发客户端重绑被容量裁剪驱逐的问题(Critical)→ 已解决。
先复现:使用测试套件现有的 sidecarReadHook 接缝(快照中已有、且已被规划的编号,其客户端 upsert 在快照读取与排队重写之间提交):改动前代码驱逐了重绑条目(得到 [...108, 6, 7] 而非 [...108, 5, 7]),且 setSessionPrs 发布了裁剪后的列表。修复:规划器现在在快照读取之前记录 snapshotAt,并且 plannedFor 额外要求 entry.createdAt < snapshotAt,于是运行期间提交的条目视为外部条目——无条件保留,容量槽位围绕它重新计算。相对建议修复形态有一处偏差,有证据支撑:在 kept 过滤器上追加 || entry.createdAt > snapshotAt 会在不重算槽位的情况下保留被裁剪条目,使重写后的列表可能超过 SESSION_PR_LIST_LIMIT;而约束 plannedFor 保持了容量上限不变量(新测试固定了 toHaveLength(SESSION_PR_LIST_LIMIT)),并与规划器现有注释("槽位围绕它重算")一致。新测试同时固定了实时条目侧:setSessionPrs 收到的列表包含存活的绑定。

[rv:5019571274] 评审正文 — "部分评审完成——缺口已披露"(CHANGES_REQUESTED)→ 本轮无需处理。
该正文披露的是评审范围缺口(Integration Tests 套件在 CI 与本地均未运行;chunk 3 因评审工具预算耗尽未完成),并明确将其列出的 16 条延后项标注为"已记录,本轮不要求修改"。除上述三条行内发现外,未提出可执行的缺陷。在仅处理 Critical 的模式下,没有其他范围内事项。

增长审计:growth-audit.json 已在任何编辑之前写入——结论 sound(KISS 通过:窗口内增长的每一部分均可追溯到第 13/14 轮已接受的失败模式,且本轮是约束既有行为而非新增守卫;最小改动通过:所有 hunk 均可追溯到 PR 既有范围内三条已接受的 Critical 发现)。

冲突说明:无——--conflict false,未执行合并。

验证

实际运行的全部命令,按顺序:

  • npm run build — 通过(共运行三次:首次为使 cli 单测可用、第二次在 core 修复后解决跨包 dist 解析、第三次在最终代码树上;每次退出码均为 0)
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 对六个改动文件执行 npx prettier --check — 新增的 backfill 测试有一处格式问题,用 npx prettier --write 修复;复检干净
  • 聚焦 Vitest,修复前复现(预期失败):
    • packages/core src/services/session-pr-service.test.ts — 1 失败(新的跨仓库见证测试)| 35 通过
    • packages/acp-bridge src/bridge.test.ts -t "cross-repository re-bind" — 1 失败(新见证测试)
    • packages/cli src/serve/routes/session-pr-backfill.test.ts -t "snapshot-held" — 1 失败(新见证测试,被驱逐的 56 取代)
  • 聚焦 Vitest,修复后:
    • packages/core src/services/session-pr-service.test.ts — 36 通过(36)
    • packages/acp-bridge src/bridge.test.ts(完整套件)— 785 通过(785)
    • packages/cli session-pr-backfill.test.ts + session-pr-refresh.test.ts — 84 通过(84)
    • packages/cli transport.test.ts + multi-workspace-sessions.test.ts + server.test.ts — 1575 通过 | 1 跳过
  • 变异探针(每个守卫的见证;应用变异 → 聚焦测试失败 → 恢复守卫 → 变绿):
    • core:仅按编号的 known 查找 → 跨仓库见证测试失败;已恢复
    • core:恒等规范化(严格相等)→ 变体拼写携带见证测试失败;已恢复
    • bridge:仅按编号的 known 查找 → bridge 跨仓库见证测试失败;已恢复
    • backfill:移除 createdAt < snapshotAt 守卫 → 快照保留见证测试失败;已恢复
  • 未运行:npm run bundle 之后的集成测试——本轮改动的行为(携带守卫、backfill 规划器谓词)由上述单元测试直接验证,并非只通过打包后的 CLI 或集成测试框架才能触达。未改动任何 settings 源,因此无需运行 npm run generate:settings-schema

Deferred non-Critical feedback

Critical-only mode is active: the PR's diff grew src 88 / test 579 net lines beyond this counting window's baseline (budgets: 400/400). The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback continues to flow unaffected during a growth-only engagement (the per-author batch budget applies only after 5 change-producing rounds). (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:本计数窗口内 diff 净增长已达 源码 88 / 测试 579 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Could not produce a passing fix for this feedback (round 1/100) — the verification gate rejected the attempt. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:

Address-review summary — PR #9729

One commit this round: af1662ab14 fix(serve): stream backfill transcript reads and pin REST pr state passthrough (#9729).

This round's feedback re-listed the findings of all 15 prior review rounds (the review mechanism lost its incremental anchor, so the whole diff was re-read). Verified against the current head, every Critical finding and nearly every Suggestion was already fixed by earlier commits on this branch (455ae72edb8500ab). This round re-verified each one against the code and its tests, implemented the two remaining cheap, in-scope items, and records a disposition for every point below.

Implemented this round

  1. R1-16 — stream transcript reads in backfill (rc:3836900728, rc:3837316223), the longest-open finding (deferred since round 1). collectTranscriptBranches no longer reads each session's entire transcript into memory; it streams line-by-line through readline over a read stream, JSON-parses each record, and keeps the top-level-gitBranch-only + 64-branch-cap semantics. One backfill request walks every session of every trusted workspace, so the whole-file read spiked daemon heap by the largest transcript and stalled the event loop decoding it; per-line parsing bounds memory to one record.
  2. REST happy-path state pass-through witness (round-10/14 deferred-list gap, the last unwitnessed site of R1-10). New test persists and echoes an explicit pr state on the happy path binds with `s

Why it was not pushed:

bite check: changed tests pass on the pre-round tree (claimed defect does not reproduce)

 when the prop is omitted
�[22m�[39mAn update to App inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});
/* assert on the output */

This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act

 �[32m✓�[39m App.test.tsx �[2m(�[22m�[2m541 tests�[22m�[2m)�[22m�[33m 48735�[2mms�[22m�[39m
   �[33m�[2m✓�[22m�[39m App session callbacks�[2m > �[22mrevalidates a partial Skill mutation only within its workspace �[33m 319�[2mms�[22m�[39m

�[2m Test Files �[22m �[1m�[32m103 passed�[39m�[22m�[90m (103)�[39m
�[2m      Tests �[22m �[1m�[32m2903 passed�[39m�[22m�[90m (2903)�[39m
�[2m   Start at �[22m 02:59:12
�[2m   Duration �[22m 58.76s�[2m (transform 66.71s, setup 4.39s, collect 230.92s, tests 100.41s, environment 45.65s, prepare 17.55s)�[22m

JUNIT report written to /home/github-runner/actions-runner-12/_work/qwen-code/qwen-code/packages/web-shell/junit.xml
🦷 bite check: running this round's changed tests on the pre-round tree
Every test this round added or changed ALSO PASSES on the pre-round tree (the branch as pushed, with only your test files overlaid). This round resolves a Critical / Request-changes finding in code, and a defect fix must come with a test that fails before the fix and passes after it — an all-green result here means the claimed defect does not reproduce, no matter who reported it.

If the finding does not reproduce, do not implement it: decline it (for a disproved finding) or escalate it as an open question, attaching this measurement as the evidence.

If the finding was already fixed by an EARLIER commit on this branch (a re-raised item you re-verified), resolve it in a round of its own without bundling new code changes — re-verification is a no-code claim and is never bite-checked.

Changed tests measured:
- packages/cli/src/serve/server.test.ts
se �[33m 1686�[2mms�[22m�[39m
   �[33m�[2m✓�[22m�[39m GET /session/:id/events (SSE)�[2m > �[22maborts the bridge subscription when the client disconnects �[33m 4014�[2mms�[22m�[39m
   �[33m�[2m✓�[22m�[39m T2.9 SSE writer idle timeout (issue #4514)�[2m > �[22mdoes not evict when the writer idle timeout is unset (legacy contract) �[33m 633�[2mms�[22m�[39m
   �[33m�[2m✓�[22m�[39m T2.9 SSE writer idle timeout (issue #4514)�[2m > �[22mdoes NOT evict when active writes keep refreshing lastWriteAt (#4514 T2.9 wenshao review) �[33m 727�[2mms�[22m�[39m
   �[33m�[2m✓�[22m�[39m T2.9 SSE writer idle timeout (issue #4514)�[2m > �[22mdoes NOT evict when a back-pressured write drains within the idle budget �[33m 379�[2mms�[22m�[39m

�[2m Test Files �[22m �[1m�[32m1 passed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[1m�[32m1089 passed�[39m�[22m�[90m (1089)�[39m
�[2m   Start at �[22m 03:00:11
�[2m   Duration �[22m 44.38s�[2m (transform 8.57s, setup 81ms, collect 12.05s, tests 24.52s, environment 310ms, prepare 81ms)�[22m

JUNIT report written to /home/github-runner/actions-runner-12/_work/qwen-code/qwen-code/packages/cli/junit.xml
�[34m % �[39m�[2mCoverage report from �[22m�[33mv8�[39m
中文说明

🤖 未能为该反馈产生可通过验证的修复(第 1/100 轮) —— 验证门拒绝了该尝试。此项现在需要人工处理;循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。

验证门的拒绝原因与日志证据见上方英文部分(gate-rejection 不翻译)。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/32879569566


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants