Skip to content

feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search - #9543

Merged
wenshao merged 18 commits into
QwenLM:mainfrom
wenshao:feat/webshell-session-pr-binding
Aug 22, 2026
Merged

feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search#9543
wenshao merged 18 commits into
QwenLM:mainfrom
wenshao:feat/webshell-session-pr-binding

Conversation

@wenshao

@wenshao wenshao commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

When a pull request is created from the Web Shell Git dialog, the PR number and URL are now bound to the session that created it. A session can produce several PRs (stacked or follow-up work), so the binding is a bounded list (10 entries, oldest dropped) ordered by binding time: re-binding the same PR number refreshes it and moves it to latest, and every bound PR stays searchable.

The daemon accepts a binding through the session metadata routes (both REST and the ACP dispatch path), one PR per call, validates it at every layer — route, bridge, SDK, and the persisted sidecar — with the URL restricted to http(s) because it is rendered as a link target, and persists the full list as a per-session sidecar file so the bindings survive daemon restarts and follow the session through archive, unarchive, and deletion. Reads, SSE events, and route responses all carry the complete list; the sidecar (complete history) is merged with the live entry's bindings (this daemon lifetime) when listing.

In the sidebar, a session with bound PRs shows a small badge next to its title with the latest PR number — #9517, or #9517 +2 when more are bound — that opens the PR (routed through the desktop-aware external-link opener), the session details tooltip lists every bound PR (latest first), and the session search box matches any bound PR number (with or without #), branch name, and worktree slug in addition to the title and session id. The same badge appears in the mission-control session overview panel and in the shared session picker row used by the resume / delete / release dialogs, and the resume dialog's search matches the same git context.

Design doc: docs/design/2026-08-20-webshell-session-pr-binding.md.

Why it's needed

Heavy Web Shell users run dozens of concurrent sessions. When a CI failure or review comment references PR #N, there was no way to answer "which session produced this PR" — the dialog discarded the created PR's coordinates after showing a status line, and the sidebar search only matched titles and session ids. After this change, typing the PR number into the sidebar search filters down to the exact session — including earlier PRs of a session that produced several.

Reviewer Test Plan

How to verify

  1. In the Web Shell, open a workspace with a git repository, create a session, make a commit on a branch, and create a PR via the Git dialog (Commit view → Create Pull Request).
  2. Within a couple of seconds the sidebar session row shows a #N badge next to the session title; clicking it opens the PR. Hovering the row lists the PR in the details tooltip.
  3. Create a second PR from the same session: the badge becomes #N +1, the tooltip lists both PRs (latest first).
  4. Type any bound PR number (9517 or #9517) into the sidebar search box — only the bound session remains, for older bindings too. Searching by branch name or worktree slug also matches. The mission-control overview panel (sessions tab) shows the same badge on each session card, and /resume's picker shows it on each row and matches PR numbers in its own search field.
  5. Restart the daemon: the badge and search still work (bindings are persisted as a sidecar file and enriched back into the session list). Archive and then unarchive the session: the badge follows the session.
  6. Protocol-level: PATCH /workspaces/:w/session/:id/metadata with {"pr":{"number":9517,"url":"https://github.com/o/r/pull/9517"}} returns 200 with the full prs list echoed; repeated calls accumulate bindings; malformed payloads (non-integer number, missing URL, javascript: URL, empty body) return 400 invalid_metadata; the SSE stream emits session_metadata_updated carrying the full prs list.

Evidence (Before & After)

Before: the sidebar showed only title, relative time, and a git icon; nothing referenced the created PR anywhere.

After — badge with multi-PR overflow, and search hitting an older binding:

Sidebar PR badge (multi)

Search by older PR number

The session overview (mission control) shows the same badge on each card:

Session overview PR badge

Single-binding view and latest-number search:

Sidebar PR badge

Search by PR number

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local daemon from the built bundle (dist/cli.js serve) with an isolated HOME, plus the mock-daemon Playwright visuals harness for the browser UI.

Risk & Scope

  • Main risk or tradeoff: the session metadata PATCH routes now accept a pr-only body (previously displayName was mandatory on the workspace-scoped route); an empty body is still rejected with the same invalid_metadata code. The SDK browser-bundle size budget was raised to 200KB (commented in the build script): main's persistent-session-attachment feature took 198→199KB and this PR's new type and validators need the remaining headroom.
  • Not validated / out of scope: PRs created by the agent running gh pr create in a shell are not auto-discovered — only the Git dialog flow binds. No server-side search filtering (client-side matching is sufficient at current session counts). Windows/Linux not tested locally (covered by CI).
  • Breaking changes / migration notes: none.

Linked Issues

N/A

中文说明

这个 PR 做了什么

从 Web Shell 的 Git 对话框创建 PR 后,PR 号和 URL 现在会绑定到创建它的会话上。一个会话可能创建多个 PR(stacked PR、连续修复),因此绑定是一个有上限的列表(10 条,超出丢弃最旧),按绑定时间排序:同号重复绑定会刷新并移到最新位,所有绑定过的 PR 都可搜索。

daemon 通过会话元数据路由(REST 与 ACP dispatch 两条路径)接受绑定,每次调用绑定一个 PR,在每一层——路由、bridge、SDK、持久化 sidecar——都做校验;URL 强制为 http(s),因为它会作为链接目标被渲染。完整列表持久化为 per-session 的 sidecar 文件,daemon 重启后不丢失,且在会话归档、取消归档、删除时随之迁移/清理。读取、SSE 事件、路由响应都携带完整列表;列表接口会把 sidecar(完整历史)与 live entry(本 daemon 生命周期内的绑定)按 PR 号合并。

侧栏中,绑定了 PR 的会话在标题旁显示一个小号 badge,展示最新 PR 号——只绑一个时是 #9517,绑了多个时是 #9517 +2——点击打开该 PR(走 desktop 感知的外链 opener);会话详情 tooltip 列出全部绑定 PR(最新在前);侧栏搜索框在标题和 sessionId 之外,现在还匹配任意一个绑定的 PR 号(带不带 # 均可)、分支名和 worktree slug。同样的 badge 也出现在 Mission Control 会话总览面板和 resume / delete / release 三个会话选择对话框的行上,resume 对话框的搜索同样匹配 PR 号。

设计文档:docs/design/2026-08-20-webshell-session-pr-binding.md

为什么需要

重度 Web Shell 用户会同时运行几十个会话。当 CI 失败或 review 评论提到 PR #N 时,无法回答"哪个会话产生了这个 PR"——对话框创建 PR 后只显示一条状态消息就丢弃了 PR 信息,侧栏搜索也只匹配标题和 sessionId。改动后,在侧栏搜索框输入 PR 号即可过滤出对应会话——包括一个会话较早创建的 PR。

Reviewer 测试计划

如何验证

  1. 在 Web Shell 中打开一个 git 仓库工作区,创建会话,在分支上提交,通过 Git 对话框(Commit 视图 → Create Pull Request)创建 PR。
  2. 几秒钟内,侧栏会话行的标题旁出现 #N badge;点击打开该 PR。hover 会话行时详情 tooltip 中列出该 PR。
  3. 同一会话再创建一个 PR:badge 变为 #N +1,tooltip 列出两个 PR(最新在前)。
  4. 在侧栏搜索框输入任意绑定过的 PR 号(9517#9517)——只剩绑定的会话,较早的绑定同样命中。按分支名或 worktree slug 搜索也命中。
  5. 重启 daemon:badge 和搜索仍然有效(绑定以 sidecar 文件持久化并回填到会话列表)。归档再取消归档该会话:badge 跟随会话。
  6. 协议级验证:PATCH /workspaces/:w/session/:id/metadata 携带 {"pr":{"number":9517,"url":"https://github.com/o/r/pull/9517"}} 返回 200 并回显完整 prs 列表;重复调用会累积绑定;非法载荷(非整数 number、缺 URL、javascript: URL、空 body)返回 400 invalid_metadata;SSE 流发出携带完整 prs 列表的 session_metadata_updated 事件。

证据(Before & After)

Before:侧栏只显示标题、相对时间和 git 图标;创建后的 PR 在任何地方都没有记录。

After——多 PR badge(带 +N)与按较早 PR 号搜索(截图见上方英文区)。

测试平台

macOS 已验证;Windows/Linux 未本地验证(由 CI 覆盖)。

风险与范围

  • 主要风险/取舍:会话元数据 PATCH 路由现在接受仅含 pr 的 body(此前 workspace 作用域路由强制要求 displayName);空 body 仍以相同的 invalid_metadata 错误码拒绝。SDK 浏览器 bundle 体积预算上调至 200KB(构建脚本中已注释):main 的 persistent-session-attachment 功能占了 198→199KB,本 PR 的新增类型与校验器需要剩余空间。
  • 未验证/范围外:Agent 在 shell 中自行 gh pr create 的 PR 不做自动发现——只有 Git 对话框流程会绑定。无服务端搜索过滤(当前会话规模下客户端匹配足够)。Windows/Linux 未本地测试(由 CI 覆盖)。
  • 破坏性变更/迁移说明:无。

…earch

When a PR is created from the Web Shell Git dialog, bind its number and
URL to the current session. The daemon accepts the binding through the
session metadata routes (validated at the route, bridge, SDK, and sidecar
layers, with the URL restricted to http(s) since it is rendered as a link
target), keeps it in live memory, and persists it as a per-session sidecar
file so the binding survives daemon restarts and follows the session
through archive/unarchive/delete.

The sidebar renders a #N badge next to the session title (opening the PR
via the desktop-aware external-link opener, shows the PR in the details
tooltip, and the session search now also matches PR number, branch name,
and worktree slug — so with many concurrent sessions, the one that
produced a given PR is one search away.
EOF
)
@wenshao

wenshao commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

E2E test report (protocol-level + browser UI)

Environment: locally built bundle (dist/cli.js serve, port 18941) with an isolated HOME and bearer token; browser UI verified via the mock-daemon Playwright visuals harness.

Protocol level — 8/8 PASS (against the built bundle):

# Check Result
1 Create session
2 PATCH /workspaces/:w/session/:id/metadata with {"pr":{"number":9517,"url":"https://github.com/o/r/pull/9517"}} ✅ 200, binding echoed in response
3 Negative cases: non-integer number / missing url / empty body ✅ all 400 invalid_metadata (also javascript: URL rejected — covered by route unit test)
4 displayName + pr in one request ✅ 200, both applied
5 GET /workspaces/:w/sessions includes pr in the summary
6 SSE session_metadata_updated carries pr
7 Daemon restart → list still includes pr (sidecar enrichment, clientCount: 0 persisted path)
8 Sidecar file <chatsDir>/<sessionId>.pr.json holds {number, url, createdAt}

Browser UI (mock-daemon Playwright harness) — PASS: #9517 badge renders on the session row with the correct href; searching 9517, #9517, and the branch name each filter the sidebar to the matching session (screenshots in the PR body).

Unit/integration: core 169, acp-bridge 11, cli serve 36 (metadata) + 509 (acp-http), web-shell 171 (sidebar) + dialog/tooltip suites — all green; build, typecheck, ESLint, Prettier clean.

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 20, 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 f489d2c. 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 20, 2026

Copy link
Copy Markdown
Collaborator

Re-run on the current head — the PR grew through five review rounds since the first pass, so this gate re-checks the final shape.

Template looks good ✓

Problem: Real, observed UX gap — unchanged from the first pass and still uncontradicted: heavy Web Shell users run dozens of concurrent sessions, the Git dialog discards the created PR's number/URL after a status line, and nothing answers "which session produced PR #N". The design doc traces the broken chain precisely, and claude-code's CHANGELOG has shipped session↔PR linking in the same product category.

Direction: Aligned. Session lifecycle management is core Web Shell territory, and this closes a genuine gap in it. The growth since the first pass (single pr field → bounded prs list, badge in overview panel and picker dialogs, git-context search) is the same feature's surfaces, justified by the stacked-PR case the PR description names — not scope creep into a different problem.

Size: 49 files — ~1,209 production lines / ~2,868 test lines / 86 docs / 11 lockfile (production vs test vs docs breakdown per the Stage 0 counting rules). Core paths touched (packages/core/src/services/** ≈ 267 production lines: new sidecar service + SessionService lifecycle hooks + config migration entry). The author is a maintainer (admin permissions), so per AGENTS.md the two-tier core gate is exempt; size recorded for visibility. Production lines clear the 1000-line advisory — noted informationally; the diff is one coherent feature that has already been reviewed in depth, so splitting now would cost more than it saves.

Approach: Scope still feels right. One validated binding threaded through the layers that already carry session metadata (route → bridge → SDK → SSE → ACP dispatch), persisted with the established worktree-sidecar pattern, badge + search on top; the hydration-before-mutation and persisted-vs-live merge machinery is what makes the list correct across daemon restarts, not extra furniture. The large deletion in multi-workspace-sessions.test.ts is a mechanical fixture-ID rename forced by the new isValidSessionId traversal gate (human-readable ids like secondary-session no longer pass it), not removed coverage. No drive-by changes spotted.

Risk: No elevated risk signals — none of the 49 changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

在当前 head 上重跑——自首轮审查以来 PR 经过五轮评审演进,本次门禁按最终形态重新核对。

模板完整 ✓

问题: 真实的 UX 缺口——与首轮结论一致且未被推翻:重度 Web Shell 用户会同时运行几十个会话,Git 对话框创建 PR 后只显示一条状态消息就丢弃 PR 号/URL,无法回答"哪个会话产生了 PR #N"。设计文档精确梳理了断链位置,claude-code 的 CHANGELOG 也已在同品类产品中落地会话↔PR 关联。

方向: 对齐。会话生命周期管理是 Web Shell 的核心领域,本 PR 补齐其中真实缺口。自首轮以来的增长(单 pr 字段 → 有上限的 prs 列表、总览面板与选择对话框中的 badge、git 上下文搜索)是同一功能的不同表面,由 PR 描述点名的 stacked PR 场景证明合理——不是跑偏到新问题。

规模: 49 个文件——生产代码约 1,209 行 / 测试约 2,868 行 / 文档 86 行 / lockfile 11 行(按 Stage 0 计数规则拆分)。触及核心路径(packages/core/src/services/** 约 267 行生产代码:新增 sidecar 服务 + SessionService 生命周期钩子 + config 迁移条目)。作者是 maintainer(admin 权限),按 AGENTS.md 豁免两层核心门禁;规模仅记录备查。生产行数超过 1000 行建议线——仅作信息提示;这是一个已经深度评审过的内聚功能,现在拆分得不偿失。

方案: 范围仍然合理。一个经过校验的绑定沿着已有的会话元数据链路贯穿(route → bridge → SDK → SSE → ACP dispatch),用既有的 worktree-sidecar 模式持久化,badge 与搜索叠加其上;mutation 前回填历史与 persisted/live 合并机制正是列表在 daemon 重启后仍然正确的原因,而非多余摆设。multi-workspace-sessions.test.ts 的大量删除是新增 isValidSessionId 遍历门禁强制的机械性 fixture ID 重命名(secondary-session 这类可读 ID 不再通过),不是删除覆盖。未发现夹带改动。

风险: 无升级风险信号——49 个改动文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal first: extend the existing session-metadata plumbing (route → bridge → SDK → SSE → ACP dispatch) with a validated PR binding, persist it in a per-session sidecar copying the worktree-sidecar lifecycle, enrich session lists from it, and put a badge + search on top — with a bounded list, because a session can produce stacked PRs. That is exactly what this PR does; I didn't find a simpler path it missed.

This is the sixth pass over the PR (five review rounds preceded it), so beyond re-reading the final diff end to end I focused on verifying the R5 round's fixes actually landed in the 9c3c1715f489d2c6 increment. They did, as claimed:

  • Control-character rejection for pr.url at all four layers — bridge validator, route parser, core sidecar reader, SDK gate — closing log-line forgery on the stderr audit path and link-target abuse in the UI. Each layer's test pins a javascript:/newline case.
  • Hydration reads are best-effort at all three sites (ACP handler, primary route, workspace route): non-ENOENT I/O errors degrade to "no history" instead of blocking a rename or binding.
  • The session-id traversal gate runs before runtime resolution on the primary route (middleware) and inside the ACP handler, so a hostile id gets an identical 400 on single- and multi-workspace daemons and never reaches sidecar path construction — the tests assert both the 400 and that no escaped write happened.
  • Tooltip rows key on ${index}-${pr.number}, safe against duplicate numbers in a hand-edited sidecar; picker fixtures are annotated DaemonSessionSummary[] so the TS2353 shape error can't hide.

Standing properties I re-verified in the final diff: validation before any mutation (a combined displayName+pr request applies nothing when either is invalid); bridge-first, persist-after ordering so rejected requests leave no durable binding; the non-live fallback persists the sidecar before the rename so a failed write can't strand an unannounced rename; responses and SSE echo the authoritative persisted list rather than the bridge's lifetime-only memory; archive/unarchive move the sidecar with merge-on-split-pair instead of wedging; the upsert queue serializes concurrent bindings and a failed write neither leaks an unhandled rejection nor jams the queue (both tested). Test additions (~2,870 lines) cover the negative paths at every layer — five malformed-payload families, traversal ids, malformed sidecars, abort propagation, write-failure ordering.

Non-blocking observations:

  • packages/sdk-typescript/scripts/build.js gains a "Bumped from 199KB to 200KB" comment for this feature, but main already carries MAX_DAEMON_BROWSER_BUNDLE_BYTES = 206 * 1024 (via fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes #9303) and the diff changes no budget constant — the comment went stale in a rebase. Worth rewording to "lands within existing headroom" or dropping, but it's comment-only.
  • The live/persisted merge in mergeLiveSessionSummary is not capped at SESSION_PR_LIST_LIMIT; that only matters if sidecar writes fail while live bindings accumulate, and the UI renders whatever it gets — not worth code.
  • ZH label keeps 合并请求 where GitHub's zh-CN UI says 拉取请求 — carried from earlier rounds, author's call.
sequenceDiagram
    participant P1 as GitDialog web-shell
    participant P2 as Daemon metadata route
    participant P3 as Bridge live memory
    participant P4 as PR sidecar file
    participant P5 as Session list enrichment
    participant P6 as Sidebar and pickers
    P1->>P2: PR created, bind number and url
    P2->>P4: hydrate persisted history (best effort)
    P2->>P3: validate, then upsert on the session entry
    P2->>P4: upsert persists the binding, reply echoes the list
    P3-->>P6: catalog revision bump, refetch shows the badge
    Note over P4,P5: after a daemon restart enrichment merges sidecar history back in
Loading
Files changed (30 of 49 shown)
File What changed
docs/design/2026-08-20-webshell-session-pr-binding.md Design doc - data model, write path, sidecar lifecycle, explicit non-goals
packages/acp-bridge/src/bridge.ts Validates pr (http(s), 2048 cap, control chars) before any mutation, upserts with cap, SSE with displayName echo, seedSessionPrs hook
packages/acp-bridge/src/bridgeTypes.ts SessionPrInfo type, prs on summaries, pr on metadata update, optional seed hook
packages/acp-bridge/src/bridge.test.ts Bind/expose/event, seeded history, lifetime-wins-over-late-seed, both-direction atomicity, cap, no-republish
packages/cli/src/serve/routes/session.ts Both PATCH metadata routes: id gate, tri-state pr parse, hydrate, bridge-before-sidecar, non-live fallback with write-ordering guarantee
packages/cli/src/serve/server/session-list.ts enrichPrSidecars backfill, live-vs-persisted merge with live url winning
packages/cli/src/serve/acp-http/dispatch.ts ACP update_metadata: id gate, best-effort hydration, sidecar upsert, persisted-list echo
packages/cli/src/serve/acp-http/transport.test.ts ACP re-hydration reply, persisted-over-echo, seed/update call log
packages/cli/src/serve/acp-http/dispatch-error.test.ts InvalidSessionMetadataError maps to the REST-equivalent invalid_metadata contract
packages/cli/src/serve/server.test.ts Pr-only 200 with echo, malformed 400s, traversal blocked with no escaped write, archived binding, write-failure ordering
packages/cli/src/serve/multi-workspace-sessions.test.ts UUID fixture ids for the traversal gate, cross-workspace sidecar lands in the owning workspace
packages/core/src/services/session-pr-service.ts New - SessionPr validator, null-safe reader, atomic writer, serialized upsert queue, merge helper
packages/core/src/services/session-pr-service.test.ts Round-trip, nine malformed shapes, abort, ordering, cap, concurrency, failure-safety, merge semantics
packages/core/src/services/sessionService.ts Path helper, sidecar move with split-pair merge, removal cleanup in both states
packages/core/src/services/sessionService.test.ts Removal cleans both states, archive/unarchive moves, split-pair merges
packages/core/src/config/config.ts Chats-dir migration renames the .pr.json sidecar too
packages/core/src/index.ts Exports the session-pr-service module
packages/sdk-typescript/src/daemon/session-pr.ts New - shared isDaemonSessionPrInfo gate (http(s), cap, control chars)
packages/sdk-typescript/src/daemon/DaemonClient.ts updateSessionMetadata accepts pr, response parses prs with a per-entry gate
packages/sdk-typescript/src/daemon/events.ts Metadata-updated event validates the prs array
packages/sdk-typescript/src/daemon/types.ts DaemonSessionPrInfo, prs on summary and metadata result types
packages/web-shell/client/components/SessionPrBadge.tsx New shared badge - latest number plus overflow, desktop-aware opener, listbox-aware tabIndex, Enter-only key swallow
packages/web-shell/client/components/SessionPrBadge.module.css Accent-tinted pill styles
packages/web-shell/client/components/dialogs/GitDialog.tsx Best-effort binding after PR creation, retry-safe session id ref sync
packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx PR link rows latest-first, duplicate-safe keys, opener-gated
packages/web-shell/client/components/sidebar/sessionSearch.ts New shared matcher - exact PR number with or without hash, branch and worktree substring
packages/web-shell/client/components/sidebar/WebShellSidebar.tsx Badge on session rows, search extended to git context
packages/web-shell/client/components/sidebar/WorkspaceSection.tsx Workspace-level filter uses the shared matcher
packages/web-shell/client/components/SessionOverviewPanel.tsx Mission-control cards show the badge
packages/web-shell/client/components/dialogs/SessionRow.tsx Picker rows show the badge without stealing a tab stop

…and 19 more files — Delete/Release/Resume dialog filters and the SDK plumbing (DaemonSessionClient, exports, build.js comment), i18n keys, the package-lock peer markers, and the colocated tests for the files above.

Testing

Unattended CI run — I did not build or execute any PR code. Evidence is the PR's own CI read through the API at the reviewed commit, fetched once (no polling).

All six pull_request-event workflows are green on the reviewed head: Qwen Code CI (unit suite on ubuntu), Serve A/B, Web-shell Visuals, SDK Java, Qwen Live Host CI, Security Checks. That includes the behavior-bearing jobs — Serve A/B, Real daemon E2E, web-shell E2E Smoke, and the web-shell visuals capture — plus Desktop Shell on ubuntu and windows and Live Host on macos. The windows/macos Node unit jobs are skipped by the repo's CI-profile design (the required Test job is ubuntu-only), not by anything in this PR. The only in-flight check is the review-pr bot orchestration job, which is bot infrastructure, not PR CI.

The author's earlier E2E report in this thread (protocol-level checks + mock-daemon Playwright visuals, macOS) remains the author's claim — attributed as-is, not independently re-run here.

Sandboxed verification would settle the last gap: @qwen-code /verify — the author's end-to-end evidence is macOS-only, and the piece no CI job pins is the browser-side badge/search flow against a real daemon across restarts; an A/B harness would confirm the PATCH → SSE → sidecar → enrichment chain holds on the built artifact, not just in unit mocks.

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Live Host (macos-latest) ✅ success
Real daemon E2E / Java 11 ✅ success
Remind on force-push ✅ success
Secret scan (TruffleHog) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
macos-latest / Java 21 ✅ success
precheck-pr / precheck ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

中文说明

代码审查

独立方案先行:扩展既有会话元数据链路(route → bridge → SDK → SSE → ACP dispatch),加入校验过的 PR 绑定,用 worktree-sidecar 同款生命周期持久化,在会话列表回填,再加 badge 与搜索——并且因为有 stacked PR 场景,必须是有上限的列表。这个 PR 正是这么做的;我没有找到它遗漏的更简路径。

这是第六次过这个 PR(此前已有五轮评审),因此除了端到端重读最终 diff,重点核对了 R5 轮修复是否真实落在 9c3c1715f489d2c6 增量里。确认全部如回复所述:

  • pr.url 的控制字符拒绝落在全部四层——bridge 校验器、route 解析、core sidecar 读取、SDK 门禁——堵住 stderr 审计日志伪造与 UI 链接目标滥用,各层测试都钉了 javascript:/换行用例。
  • 三处 hydration 读取均为尽力而为(ACP handler、主路由、workspace 路由):非 ENOENT I/O 错误降级为"无历史",不阻塞改名或绑定。
  • 会话 ID 遍历门禁先于运行时解析(主路由中间件 + ACP handler 内部),恶意 ID 在单/多 workspace daemon 上得到一致的 400,且永远到不了 sidecar 路径构造——测试同时断言 400 与"无逃逸写入"。
  • tooltip 行键改为 ${index}-${pr.number},手工编辑的 sidecar 里出现重复 PR 号也安全;picker fixture 标注了 DaemonSessionSummary[],TS2353 形状错误无处藏身。

最终 diff 中复核确认的既有性质:任何变更前先完成全部校验(displayName+pr 组合请求任一非法则整体不生效);先 bridge 后持久化,被拒请求不留持久绑定;非 live 回退路径先写 sidecar 再改名,写失败不会搁置一个未被响应宣告的改名;响应与 SSE 回显权威持久化列表而非 bridge 的本生命周期记忆;归档/取消归档迁移 sidecar,分裂对合并而非永久楔住;upsert 队列串行化并发绑定,失败既不泄漏未处理 rejection 也不卡队列(均有测试)。新增测试(约 2,870 行)覆盖每一层的负路径——五类非法载荷、遍历 ID、损坏 sidecar、abort 传播、写失败顺序。

非阻塞观察:

  • packages/sdk-typescript/scripts/build.js 新增了一条 "199KB 提到 200KB" 的注释,但 main 已是 MAX_DAEMON_BROWSER_BUNDLE_BYTES = 206 * 1024fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes #9303),且 diff 未改任何预算常量——注释在 rebase 后过时了。建议改写为"落在既有空间内"或删除,但仅是注释。
  • mergeLiveSessionSummary 的合并不设 SESSION_PR_LIST_LIMIT 上限;只在 sidecar 写失败且 live 绑定累积时才有意义,UI 也能正常渲染——不值得改代码。
  • 中文文案保留"合并请求",GitHub 官方中文 UI 为"拉取请求"——沿袭早前轮次,由作者定夺。

测试

无人值守 CI 运行——未构建或执行任何 PR 代码。证据来自在受审 commit 上通过 API 读取的 PR 自身 CI(一次性抓取,不轮询)。

受审 head 上六个 pull_request 事件工作流全部为绿:Qwen Code CI(ubuntu 单测)、Serve A/B、Web-shell Visuals、SDK Java、Qwen Live Host CI、Security Checks。其中包含承载行为验证的任务——Serve A/B、Real daemon E2E、web-shell E2E Smoke、web-shell 视觉截图——以及 ubuntu/windows 的 Desktop Shell 与 macos 的 Live Host。windows/macos 的 Node 单测按仓库 CI profile 设计被跳过(必需的 Test 任务本就是 ubuntu-only),与本 PR 无关。唯一在跑的是 review-pr 机器人编排任务,属机器人基础设施,不是 PR CI。

作者早前在线程中贴出的 E2E 报告(协议级验证 + mock-daemon Playwright 视觉,macOS)仍为作者声明——如实转述,未独立复跑。

沙箱验证可以补齐最后一个缺口:@qwen-code /verify——作者的端到端证据仅限 macOS,而没有任何 CI 任务钉住的是浏览器侧 badge/搜索流程在真实 daemon 上跨重启的表现;A/B 载荷验证可以确认 PATCH → SSE → sidecar → 回填整条链路在构建产物上成立,而不只是单测 mock 里成立。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean review across every stage; the only residuals are cosmetic (a stale build.js comment and a ZH wording choice), named in Stage 2.

Stepping back: this PR answers one question — "which session produced PR #N" — and five review rounds have worn it down to a shape I'd merge. It extends the machinery already built for session metadata instead of bolting on a parallel system; the write path validates before it mutates at every layer; persistence copies the proven worktree-sidecar lifecycle wholesale, including the unglamorous cases (split-pair merges on archive moves, cleanup in both states, chats-dir migration); and the UI stays best-effort so a binding failure can never shadow a successful PR creation. The security-conscious pieces are real, not performative: http(s)-only URLs with length and control-character caps enforced consistently at four trust boundaries, session-id gates before any filesystem path construction, and a response/event surface that echoes the authoritative persisted list instead of lifetime-only memory.

Going back to my independent proposal — the PR matches it, and the places it goes further (the bounded multi-PR list, hydration-before-mutation, live/persisted merge) are each pinned by a test that would fail if the machinery were removed. That's the check that matters: this suite does not pass unchanged without the diff. Every R5-round fix was verified in the actual increment, not taken on the reply's word. All 40+ review threads are resolved, and CI is fully green on the reviewed head including the behavior-bearing jobs (Serve A/B, Real daemon E2E, web-shell E2E Smoke, visuals).

Reservations, none blocking: the stale 199→200KB budget comment after the rebase, the uncapped live/persisted merge edge that only a failed-disk scenario reaches, and the 合并请求 vs 拉取请求 wording. If I had to maintain this in six months, the sidecar precedent and the doc comments at each subtle spot (queue failure semantics, merge policy, ordering guarantees) would let me read it immediately. Verdict: approve — CI already landed green, so no deferred marker; approving pinned to the reviewed commit.

中文说明

置信度:4/5 —— 各阶段审查均干净;仅剩外观层面的小项(一条过时的 build.js 注释与中文措辞),已在 Stage 2 点名。

整体看:这个 PR 只回答一个问题——"哪个会话产生了 PR #N"——五轮评审已把它打磨到可以直接合入的形态。它扩展现有的会话元数据机制而非另起炉灶;写入路径在每一层都先校验再变更;持久化完整复刻经过验证的 worktree-sidecar 生命周期,包括不起眼的边角(归档迁移时的分裂对合并、两种状态的清理、chats 目录迁移);UI 保持尽力而为,绑定失败绝不遮盖 PR 创建成功。安全相关的部分是实打实的,不是摆设:http(s)-only URL 加长度与控制字符上限,在四个信任边界一致执行;文件系统路径构造之前先过会话 ID 门禁;响应/事件面回显权威持久化列表而非仅本生命周期记忆。

对照我的独立方案——PR 与之吻合,且它走得更远的地方(有上限的多 PR 列表、mutation 前回填、live/persisted 合并)各自都有"删掉机制就会失败"的测试钉住。这才是关键检验:这套测试不是没有 diff 也能原样通过的。R5 轮的每一项修复都是在真实增量里核对过的,不是凭回复采信。40+ 个评审线程全部已解决,受审 head 上 CI 全绿,包括承载行为验证的任务(Serve A/B、Real daemon E2E、web-shell E2E Smoke、视觉截图)。

保留意见,均不阻塞:rebase 后过时的 199→200KB 预算注释、只有磁盘失败场景才会触及的 live/persisted 合并未设上限边角、"合并请求"与"拉取请求"的措辞。六个月后维护这段代码,sidecar 先例加上每个微妙处的文档注释(队列失败语义、合并策略、顺序保证)能让我立刻读懂。结论:批准——CI 已全绿落定,无需延迟标记;批准钉在受审 commit 上。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f489d2c, 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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

A session can produce several PRs (stacked or follow-up work), and
keeping only the latest binding would defeat the sidebar's
search-by-PR-number flow for every earlier one. The binding is now a
bounded list (10, oldest dropped) ordered by binding time: re-binding
the same number refreshes it and moves it to latest, the badge shows
the newest number with a +N overflow, the tooltip lists every bound
PR, and search matches any of them. The write API stays single-binding
per call; reads, SSE events, and responses carry the full list, with
the sidecar as the complete history merged over the live entry's
daemon-lifetime bindings.
Conflict: the SDK browser-bundle budget — main bumped 198→199KB for
persistent session attachments while this branch needed headroom for the
session PR binding types; resolved at 200KB with both reasons recorded.

Also fixes a latent type error currently on main: QwenLM#9477 added
deleteSessionAttachments to the deleteDaemonSessions bridge contract, but
the batch-delete test from QwenLM#9341 still passes a closeSession-only mock;
CI did not catch it because the verify check is skipped on main pushes.
@wenshao

wenshao commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Update: two follow-up commits pushed.

7049f44a2a — multiple PR bindings per session. The initial commit kept only the latest PR per session; a session that produces stacked/follow-up PRs would lose the earlier ones from badge and search, defeating the lookup flow. The binding is now a bounded list (10, oldest dropped): same-number re-binding refreshes and moves to latest, the badge shows the latest number with a +N overflow, the tooltip lists every bound PR, and search matches any of them. Write API stays one-binding-per-call; reads/SSE/responses carry the full list; the sidecar holds the complete history and is merged with the live entry's daemon-lifetime bindings on list.

Re-verified against the built bundle: two sequential bindings accumulate (prs: [9600, 9601]), the list endpoint reflects them, the sidecar stores the full array, and a daemon restart restores both bindings (clientCount: 0 persisted path). New unit coverage: sidecar upsert ordering/dedup/cap, bridge accumulation + rebinding order, route-level accumulation, tooltip multi-PR listing, search-by-older-PR. New screenshots added to the PR body (badge #9517 +1, search hitting the older binding).

e6f8263c55 — merge with main. One conflict: the SDK browser-bundle budget (main took 198→199KB for persistent session attachments; this branch needs headroom for the new DaemonSessionPrInfo type + validators) — resolved at 200KB with both reasons recorded in the build script. The merge also fixes a latent type error currently on main: #9477 added deleteSessionAttachments to the deleteDaemonSessions bridge contract, but the batch-delete test from #9341 still passes a closeSession-only mock — tsc --build fails on it locally. It went unnoticed because the verify check is skipped on main pushes. Fix is a one-line mock addition in session-archive.test.ts (that test now passes: 48/48 in the file).

The case-variant delete test fix carried by the previous merge is
superseded by QwenLM#9551, which landed the same mock on main; this merge
takes main's version of session-archive.test.ts.
…alogs

The mission-control overview panel and the shared session picker row
(resume / delete / release dialogs) now show the same PR badge as the
sidebar — latest number with a +N overflow, opening the PR via the
desktop-aware opener — and the resume dialog's search matches bound PR
numbers, branch names, and worktree slugs through the shared
sessionMatchesGitQuery helper.
Conflict in sessionService.ts: main's prompt-ledger lifecycle
(QwenLM#9426) landed in the same spots as the PR-sidecar lifecycle —
both are kept: removeSessionFiles removes both, archive/unarchive
move both.
The overview card badge used the panel's neutral --primary tint while
the sidebar and picker badges use the accent violet; one element should
read the same on every surface.

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review Verdict / 审查结论

🔄 Request changes — one verified 🔴 plus two 🟡. All findings below were cross-checked against the head tree (031f4fc). Fixes are small. Data is intact in the sidecar throughout, so this is a read/display correctness issue, not corruption.

🔄 请求修改 —— 1 条已验证 🔴 + 2 条 🟡。以下每条都已对照 head tree(031f4fc)复核。修复都不大。数据在 sidecar 中始终完好,因此这是读取/展示层的正确性问题,不是数据损坏。


🎉 Praise / 值得肯定

Validation is genuinely thorough: the number/url checks are identical across all four layers (route, bridge, SDK validator, sidecar reader — Number.isInteger + >0, ^https?://), so javascript:/data: are rejected everywhere. Path traversal is closed off (SESSION_FILE_PATTERN gate + live-registry resolution, so ../ can't reach the upsert). The bridge validates a combined displayName+pr request fully before any mutation (no partial apply). The core sidecar lifecycle (archive / unarchive / delete) mirrors the worktree sidecar line-for-line with no gap. The in-memory bridge upsert and the on-disk sidecar upsert agree on dedupe / ordering / cap. Search is exact-equality, so there's no regex-injection surface. This is careful work.

校验做得非常扎实:number/url 在 route、bridge、SDK 校验器、sidecar reader 四层完全一致Number.isInteger+>0^https?://),javascript:/data: 在哪一层都被拒。路径穿越已封堵(SESSION_FILE_PATTERN 门禁 + live 注册表解析,../ 到不了 upsert)。bridge 对组合请求 displayName+pr 在任何变更前先全量校验(不部分生效)。core 的 sidecar 生命周期(归档/取消归档/删除)与 worktree sidecar 逐行平行、无遗漏。bridge 内存 upsert 与磁盘 sidecar upsert 的去重/排序/cap 语义一致。搜索是精确等值比较,无正则注入面。这是下过功夫的实现。


🔴 1. Post-restart bindings hide sidecar history — the live merge overwrites the enriched full prs list

Where / 位置: packages/cli/src/serve/server/session-list.tsmergeLiveSessionSummary (481-501) and enrichPrSidecars (411-443).

Problem / 问题 (two facets of one root bug / 同一根因的两面):

  • The merge branch inside enrichPrSidecars (433-439, documented as "live url wins, live-only bindings sort latest") is dead code. It runs before the live merge (call sites 560, 1231), at which point the map only holds persisted summaries from toSummary (445-470), which never set prs. So summary.prs is always undefined there and the merge never executes.
    enrichPrSidecars 里的合并分支(433-439,注释称"live url 优先、live-only 排最后")是死代码:它在 live 合并之前运行(560、1231),此刻 map 里只有 toSummary(445-470)产出的 persisted summary,而 toSummary 从不设置 prs,所以那里 summary.prs 恒为 undefined,合并永远不执行。

  • The real live+persisted merge happens later in mergeLiveSessionSummary, which does {...existing, ...live}. prs is not handled explicitly, so a non-empty live prs (this daemon lifetime only) wholesale overwrites the enriched full list.
    真正的 live+persisted 合并发生在其后的 mergeLiveSessionSummary,它用 {...existing, ...live} 朴素展开——prs 没有专门处理,live 非空的 prs(仅本 daemon 生命周期)会整体覆盖富集后的完整列表。

Failure scenario / 失败场景 (exactly the stacked-PR-across-restart case this PR targets / 正是本 PR 主打的 stacked PR + 重启场景): bind PR A → daemon restarts → session goes live again (live entry.prs is empty; the bridge never re-hydrates from the sidecar) → bind PR B (sidecar=[A,B], live=[B]). The enriched [A,B] is overwritten by {...existing,...live} down to [B] — the sidebar badge / search / tooltip loses A. All three list paths (default / organized / metadata-filtered) go through this merge, so all are affected. The data is still in the sidecar, but to the user the older PR is simply un-findable — which defeats the PR's own stated promises ("survive daemon restarts" + "search by older PR number").
先绑 PR A → daemon 重启 → 会话重新拉起(live entry.prs 为空,bridge 从不从 sidecar 恢复)→ 再绑 PR B(sidecar=[A,B]、live=[B])。富集出的 [A,B]{...existing,...live} 覆盖成 [B]——侧栏 badge/搜索/tooltip 丢失 A。default/organized/metadata-filtered 三条列表路径都经过这个合并函数,全部受影响。数据仍在 sidecar 里,但对用户就是"较早的 PR 查不到了",直接击穿 PR 描述里"重启后仍在"+"按较早 PR 号反查"两条核心承诺。

Suggested fix / 建议修复 — merge prs explicitly in mergeLiveSessionSummary (same semantics as enrichPrSidecars: dedupe by number, live wins, keep history):
mergeLiveSessionSummary 里对 prs 做显式合并(与 enrichPrSidecars 相同语义:按 number 去重、live 优先、保留历史):

if (existing.prs || live.prs) {
  const livePrs = live.prs ?? [];
  merged.prs = [
    ...(existing.prs ?? []).filter(
      (p) => !livePrs.some((l) => l.number === p.number),
    ),
    ...livePrs,
  ];
}

(Alternatively, re-run the enrichment after the live merge.) / (或把富集挪到 live 合并之后重跑。)


🟡 2. The new pr-only session_metadata_updated event clears displayName in the SDK's public fold

Where / 位置: producer packages/acp-bridge/src/bridge.ts:9708-9714 (publishes {sessionId, prs}, no displayName); consumer packages/sdk-typescript/src/daemon/events.ts:2029-2034 (displayName: event.data.displayName).

Problem / 问题: Before this PR the event always carried displayName, so "absent" on the wire meant "cleared" (pinned by test/unit/daemonEvents.test.ts:1126-1152, which asserts a bare {sessionId} clears the name). This PR adds a displayName-less variant that still passes isSessionMetadataUpdatedData, but the fold wasn't updated — so any SDK consumer folding the event stream via reduceDaemonSessionEvent gets its session title reset to undefined the moment a PR is bound, until the next rename/load/snapshot. Combined {displayName, pr} requests are worse: the first event sets the name, the trailing pr event wipes it.
此前该事件总带 displayName,"缺省"在 wire 上等价"清除"(test/unit/daemonEvents.test.ts:1126-1152 明确把裸 {sessionId} 断言为清除)。本 PR 新增无 displayName 的变体且仍能通过 isSessionMetadataUpdatedData,但 fold 没同步——任何用 reduceDaemonSessionEvent 折叠事件流的 SDK 消费者,在绑定 PR 的瞬间标题被重置为 undefined,直到下次改名/loadSession/快照才恢复。组合请求更糟:第一个事件设置名字,紧随的 pr 事件又把它清掉。

Suggested fix / 建议修复 — echo the current name on the producer (name present → echoed; already cleared → stays absent), so existing folds stay correct with no consumer changes:
在生产端把当前名字回传(有名字原样回传、已清除时仍缺省),这样既有 fold 无需改动即正确:

data: {
  sessionId,
  ...(entry.displayName !== undefined
    ? { displayName: entry.displayName }
    : {}),
  prs: entry.prs,
},

🟡 3. upsertSessionPr read-modify-write races under the shared lock, silently dropping a binding

Where / 位置: packages/core/src/services/session-pr-service.ts:110-122; callers packages/cli/src/serve/routes/session.ts:5253 (withOwnerMutableSessionrunSharedMany) and packages/cli/src/serve/acp-http/dispatch.ts:2920 (withMutableOwnedrunSharedMany).

Problem / 问题: runSharedMany only reference-counts a session — it does not serialize concurrent shared holders (session-archive.ts:128-143). upsertSessionPr is read → filter → write with awaits in between and no mutual exclusion, so two concurrent bindings for the same session interleave: A reads [] → B reads [] → A writes [A] → B writes [B], and A is silently overwritten. While the daemon lives the in-memory list still shows both, but after a restart the sidecar is the only persisted source, so the dropped binding is gone for good. Two review batches flagged this independently; the trigger (consecutive stacked-PR binds, two attached tabs) is exactly what the docstring says is supported.
runSharedMany 对同一 session 只做引用计数、并不串行化并发的 shared 持有者(session-archive.ts:128-143)。upsertSessionPr 是带 await 的"读-过滤-写",中间无互斥,于是同一 session 的两个并发绑定会交错:A 读 [] → B 读 [] → A 写 [A] → B 写 [B],A 被静默覆盖。daemon 存活时内存列表仍显示两条,但重启后 sidecar 是唯一持久源,丢失的绑定永久消失。两个审查批次独立发现了这条;触发场景(stacked PR 连续绑定、两个已 attach 的标签页)正是 docstring 明示支持的。

Suggested fix / 建议修复 — serialize inside the service with a per-path in-process queue (a failed predecessor must not block later binds):
在服务内按文件路径做进程内队列串行化(前序失败不阻塞后续绑定):

const upsertQueue = new Map<string, Promise<unknown>>();
export function upsertSessionPr(filePath, pr): Promise<SessionPr[]> {
  const run = async () => { /* existing read-filter-write body */ };
  const previous = upsertQueue.get(filePath) ?? Promise.resolve();
  const next = previous.catch(() => undefined).then(run);
  upsertQueue.set(filePath, next);
  void next.finally(() => {
    if (upsertQueue.get(filePath) === next) upsertQueue.delete(filePath);
  });
  return next;
}

🟢 Nits / 建议

4. ACP dispatch rewrites the sidecar on any metadata update (even a pure rename). dispatch.ts:2915-2924 reads result?.prs?.[last] and upserts; the bridge returns prs whenever entry.prs is non-empty (bridge.ts:9720-9723) regardless of whether this call bound a PR. So a displayName-only rename re-upserts the latest binding, refreshing createdAt and moving it to the end — with a full list this can evict a genuinely older entry early. Gate on metadata['pr'] !== undefined.
ACP dispatch 在任何 metadata 更新(含纯改名)时都重写 sidecar:dispatch.ts:2915-2924result?.prs?.[last] 做 upsert,而 bridge 只要 entry.prs 非空就返回 prsbridge.ts:9720-9723),与本次是否绑定 PR 无关。于是 displayName-only 改名也会重 upsert 最新绑定、刷新 createdAt 并移到末尾——列表满时可能提前挤出真正更早的条目。建议按 metadata['pr'] !== undefined 门控。

5. No length cap on pr.url (body limit is 10MB), asymmetric with the 256-char displayName cap in the same route. A token-holding client could stuff 10 near-10MB urls into the sidecar (~100MB), which every subsequent session-listing then reads + parses in full. Add url.length > 2048 to parseSessionPrBody's rejection.
pr.url 无长度上限(body 上限 10MB),与同路由 displayName 的 256 上限不对称。持有 token 的客户端可塞 10 个近 10MB 的 url,sidecar 膨胀到 ~100MB,之后每次列表都整文件读+解析。建议 parseSessionPrBodyurl.length > 2048 拒绝。

6. No component-level scheme guard on the rendered href (defense-in-depth). Four spots render href={pr.url} directly; useExternalLinkOpener early-returns without preventDefault for non-http(s). Upstream validation currently guarantees http(s), so this is only reachable if a future path bypasses validation — consider filtering with isExternalOpenUrl before render.
渲染的 href 无组件级 scheme 防护(纵深防御):4 处直接 href={pr.url}useExternalLinkOpener 对非 http(s) 提前 return 且不 preventDefault。当前上游校验保证 http(s),只有未来某条绕过校验的路径才会触发——建议渲染前用 isExternalOpenUrl 过滤。

7. Test coverage gaps / 测试覆盖缺口. (a) The enrichPrSidecars merge logic — exactly where finding 1 lives — has zero assertions on the prs returned by the list endpoint, and "survive restart" only exercises the clientCount: 0 persisted path, never the live merge; that's why finding 1 slipped through. (b) A couple of server.test.ts cases explicitly depend on sidecar residue from earlier cases (the comment says "9517 from the previous test"), so they break if run alone or reordered. (c) bridge.test.ts has no combined-request atomicity case (valid displayName + invalid pr → name must not apply), even though the code comment states that property. (d) GitDialog's bind-failure degradation (.catch → console.warn, must not mask PR-creation success) has no regression test.
(a)enrichPrSidecars 合并逻辑——正是 finding 1 所在——对列表接口返回的 prs 断言,且"重启后仍在"只测了 clientCount: 0 的 persisted 路径、没测 live 合并,这正是 finding 1 漏网的原因。(b)server.test.ts 有用例显式依赖前序用例的 sidecar 残留(注释自述"9517 from the previous test"),单跑或重排即失败。(c)bridge.test.ts 缺组合请求原子性用例(displayName 合法 + pr 非法时名字不应生效),尽管代码注释声明了该性质。(d)GitDialog 绑定失败降级(.catch→console.warn,不遮盖 PR 创建成功)无回归测试。

8. (Optional) dialog search consistency. Only ResumeDialog wires sessionMatchesGitQuery; Delete/Release show the badge but can't search by PR number. One line each if that wasn't a deliberate scope cut.
(可选)对话框搜索一致性:只有 ResumeDialog 接入了 sessionMatchesGitQuery,Delete/Release 能看到 badge 却搜不到 PR 号。若非有意收窄,各一行即可补齐。


Bottom line / 小结

Fix finding 1 before merge (it defeats the feature's core persistence + reverse-lookup promise); findings 2 and 3 are strongly worth fixing in the same pass. All are small, well-scoped changes. Everything else is polish.
合并前请修复 finding 1(它击穿本功能的核心持久化+反查承诺);finding 2、3 建议一并处理。都是很小的、范围明确的改动。其余为打磨项。

Comment thread packages/cli/src/serve/server/session-list.ts
Comment thread packages/sdk-typescript/src/daemon/events.ts
Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/web-shell/client/components/SessionOverviewPanel.module.css Outdated
Comment thread packages/web-shell/client/components/dialogs/SessionRow.tsx Outdated
Comment thread packages/web-shell/client/components/dialogs/GitDialog.tsx
Comment thread packages/web-shell/client/components/dialogs/GitDialog.tsx
Comment thread packages/web-shell/client/components/sidebar/WebShellSidebar.tsx Outdated
Read/display correctness (verified by ytahdn and the R1 review):
- mergeLiveSessionSummary merged {..existing, ..live} wholesale, so a
  live entry's this-daemon-lifetime prs overwrote the sidecar-enriched
  full history after a restart; prs is now merged by PR number (live
  url wins, history kept), and the dead merge branch in
  enrichPrSidecars is gone.
- The pr-only session_metadata_updated event carried no displayName,
  which SDK folds treat as "cleared" — the title blanked on every PR
  bind. The producer now echoes the current name.
- GitDialog synced sessionIdRef from the prop on every render, so the
  fresh session id the dialog resolves for its own side queries was
  clobbered before the binding read it; the sync now runs only when
  the prop changes.

Robustness:
- upsertSessionPr's read-modify-write is serialized per sidecar path,
  closing the concurrent-bind drop race under runSharedMany.
- The REST routes persist the sidecar before mutating the bridge, so a
  failure on either side leaves the binding durable.
- The ACP dispatch only upserts when the call actually binds a PR (a
  displayName-only rename no longer rewrites createdAt/order).
- pr.url is capped at 2048 chars across all four validation layers.

Structure & a11y:
- The three badge copies (sidebar / overview / picker) are now one
  SessionPrBadge component: shared CSS, count-aware aria-label,
  non-http(s) entries filtered defensively, and tabIndex=-1 inside
  listbox options.
- The SDK's duplicated PR validator is a single session-pr module used
  by both DaemonClient and events.
- Delete/Release dialogs' search matches bound PR numbers like Resume.

Tests: list-level live+sidecar prs merge, sidecar-vs-bridge echo
authority, route tests made order-independent, bridge
atomicity/cap/catalog-revision/displayName-echo cases, concurrent
upsert serialization, SDK fold keeps the name, GitDialog bind-failure
degradation, url-cap rejections at every layer.
@wenshao

wenshao commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

@ytahdn Thanks for the thorough review — all findings are addressed in a60b0db175:

🔴 1. Post-restart history loss — Fixed. The dead merge branch in enrichPrSidecars is removed and mergeLiveSessionSummary now merges prs explicitly by PR number (live url wins, sidecar history kept) instead of the wholesale spread. Your exact scenario is now a list-level test: merges sidecar pr history with the live entry bindings on list (persisted transcript + sidecar [A] + live [B] → [A, B]).

🟡 2. Title blanked by the pr event — Fixed. The producer echoes the current displayName on the pr metadata event, so existing folds stay correct with no consumer changes; a reduceDaemonSessionEvents test pins it.

🟡 3. upsert read-modify-write race — Fixed. upsertSessionPr is serialized per sidecar path with an in-process queue (a failed predecessor doesn't block later binds), with a concurrency test asserting no binding is dropped.

🟢 4. ACP re-upsert on rename — Fixed: the dispatch now gates on metadata.pr actually being present in the call.

🟢 5. No url length cap — Fixed: 2048 chars at all four layers (route / bridge / SDK / sidecar), with rejection tests at each.

🟢 6. Component-level scheme guard — Fixed: the three badge copies are now a single SessionPrBadge component that filters entries through isExternalOpenUrl before rendering (and also unifies the CSS recipe, the count-aware aria-label, and a tabIndex override for listbox rows).

🟢 7. Test gaps — (a) list-level merge test added (see 🔴1) and the workspace-route tests are now order-independent (each removes the sidecar it depends on first); (b) same; (c) atomicity test added (valid displayName + invalid pr → name not applied); (d) GitDialog bind-failure degradation test added (PR-creation success survives a rejected binding, warning only).

🟢 8. Delete/Release search consistency — Fixed: both wire sessionMatchesGitQuery like Resume.

One deliberate non-fix from the inline batch: the GitDialog binding targets the connected-session prop without an ownership check because the daemon route already rejects cross-workspace updates (409), so a wrong-workspace binding degrades to a warning instead of landing — and creating a session just to bind stays ruled out.

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

Re-review(增量提交 a60b0db "Address review findings on PR bindings")

上一轮 8 项 findings 全部验证已修复(逐条核实,见文末)。但修复提交把两条路由改为 persist-first(先写 sidecar 再调 bridge),这个重排本身引入了一条新的 Critical 和两条 Important。

🔴 Critical — persist-first 让归档会话在 active 目录遗留孤儿 sidecar,unarchive 时冲突并静默丢失较早的绑定

位置packages/cli/src/serve/routes/session.ts workspace 路由 5378-5390(无条件写 'active' 路径);交互方 packages/core/src/services/sessionService.ts moveOptionalFile 1016-1017(目标已存在即 throw)、unarchive 2043-2052(throw 仅 warn、不移动)。

问题:该路由在 bridge 调用前无条件向 getPrSessionPathForArchiveState(sessionId, 'active') 写 sidecar。对已归档会话(resolveLiveSessionOwner 返回 not_found,能通过 5334-5361 检查;fallback 注释明确这是受支持的场景),bridge 抛 SessionNotFoundError,fallback 再按真实 location('archived')写一份——同一绑定落到两个文件,active 成为孤儿。旧的 bridge-first 顺序不会发生:bridge 先抛,根本走不到 active upsert。这是本次提交重排引入的回归。

失败场景(stacked PR,逐行追证可达):

  1. 会话绑定 PR A(live)→ active sidecar [A]
  2. 归档会话 → sidecar 随 transcript 搬到 archived → archived=[A],active 无文件;
  3. 归档状态下经该路由(SDK DaemonClient.updateSessionMetadata 打的正是这条路)再绑 PR B → persist-first 写 active=[B](孤儿),fallback 写 archived=[A,B],响应正确;
  4. 取消归档 → moveOptionalFile(archived→active) 因 active 已存在抛 "Archive sidecar conflict",仅 warn 不移动;
  5. 结果:会话已 active,列表 enrichment 读 active=[B]——PR A 从徽章/tooltip/搜索中静默消失;完整历史 [A,B] 孤立留在 archived 目录,无清理路径,只有一条误导性警告。location === 'conflict' 分支同样在抛错前留下孤儿。

建议修复:persist 前先解析真实归档态,写正确路径:

if (pr) {
  const service = createWorkspaceRuntimeSessionService(runtime);
  const located = await service.getSessionLocation(sessionId);
  assertRuntimeGenerationOpen?.();
  if (located === 'conflict') throw new SessionConflictError(sessionId);
  persistedPrs = (
    await upsertSessionPr(
      service.getPrSessionPathForArchiveState(sessionId, located ?? 'active'),
      pr,
    )
  ).map(({ number, url }) => ({ number, url }));
}

(live 会话 transcript 在 active,located ?? 'active' 与原行为一致;fallback 的按 number 幂等 upsert 可保留不动。)

🟡 Important — /session/:id/metadata(route 1)persist-first 把"合法 pr + 非法 displayName"变成部分写入

位置routes/session.ts 5224-5241(只判 typeof === 'string',无空串/控制字符校验)+ 5248-5257(先写 sidecar)+ 5258(后调 bridge);对照 workspace 路由 5305-5329 有完整本地校验。

问题:route 1 把 displayName 内容校验委托给 bridge。旧 bridge-first 顺序下这安全(bridge 先抛、不写 sidecar);改成 persist-first 后委托不再安全。失败场景PATCH /session/:id/metadata{displayName: "bad\u0001name", pr: {合法}} → route 校验通过 → 先写 sidecar(pr 落盘)→ bridge 因控制字符抛 InvalidSessionMetadataError → 客户端收到错误响应,但绑定已持久化:请求"失败"而徽章"生效",sidecar 与 live entry 背离。这正是 PR 明确承诺的原子性(bridge 测试 does not apply displayName when the combined pr is invalid 钉住了 bridge 层,route 层顺序却绕过了它)。

建议修复:persist 前补上与 workspace 路由一致的 displayName 本地校验(建议抽成共享 helper 供两条路由复用),或让 route 1 回到 bridge-first——该路由的会话必然 live,没有 workspace 路由的 fallback 诉求。

🟡 Important — upsertSessionPr 队列清理在写失败时产生未处理 rejection

位置packages/core/src/services/session-pr-service.ts 137-139。

问题void next.finally(...) 派生出一个新 promise;当 run 因 I/O 失败(ENOSPC/EACCES 等)reject 时,调用方 await 的是 next(各调用点都有兜底),但派生的 finally promise 同样 reject 且无人接手 → unhandledRejection。serve 守护进程入口未注册兜底处理器(gemini.tsx:239 的处理器只覆盖交互式 CLI 路径;workspace-extensions-controller.ts 里的同类注释说明这是已知雷区)。失败场景:磁盘满/权限错误时绑定 PR → 除正常 5xx 外,Node ≥15 默认直接终止进程——一次 sidecar 写失败不应拖垮整个 daemon。

建议修复:让清理链自身消化 rejection:

const cleanup = (): void => {
  if (upsertQueue.get(filePath) === next) upsertQueue.delete(filePath);
};
void next.then(cleanup, cleanup);
return next;

🟢 Nit

  • route 层字面量 2048routes/session.ts:2115 用字面量且错误文案硬编码 "2048",而 core 已导出 SESSION_PR_URL_MAX_LENGTH(session-pr-service.ts:32)且本文件已从同一模块导入 upsertSessionPr。建议用常量替换,避免日后调整上限时脱节。
  • route 层缺 url>2048 拒绝测试:bridge/sidecar 两层都有 >2048 拒绝用例,route 层校验(2115)没有——与"各层有拒绝测试"的描述不符。
  • 共享 badge 类把 sidebar 的 margin-left: 6px 带进了 overview/pickerSessionPrBadge.module.css:4 是三份旧拷贝的并集,但旧 overview .prBadge/旧 picker .picker-item-pr-badge 无左边距(间距来自父容器 gap 8px/4px)——重构后这两处 badge 间距各多 6px。纯视觉。
  • 统一后的 onKeyDown stopPropagation 波及 picker 键盘导航SessionPrBadge.tsx:57 对所有场景 stopPropagation。picker 的 useListboxKeyboard 挂在 window 冒泡阶段(hooks/useListboxKeyboard.ts:213,注释明确它是传播链最后一站),旧 picker badge(031f4fce 的 SessionRow)没有 onKeyDown 拦截;现在用户点击 badge 打开 PR 后焦点停在 tabIndex={-1} 的 anchor 上,方向键(及 DialogShell 的 Escape)被吞、导航卡住,直到重新点击行本体。建议 picker 场景(tabIndex === -1 时)不拦截 keydown。

💡 Suggestion

  • list 级合并测试目前只覆盖不相交 number([9500]+[9517]);mergeLiveSessionSummary 的"同 number 跨两侧、live url 赢"分支(session-list.ts:502-507)在 list 级没有测试锚点,建议补一条 sidecar [{n, oldUrl}] + live [{n, newUrl}][{n, newUrl}] 的用例。
  • SessionPrBadge 的 scheme 过滤行(SessionPrBadge.tsx:33-34)是本次安全统合的核心,但无直接测试:过滤分支(全坏 → null、混合列表只渲染可打开项)从未被执行于测试,未来误删会静默回归。建议挂组件直测。

上一轮 findings 复核(全部已验证修复)

  • 🔴 重启后历史丢失:mergeLiveSessionSummary 按 number 显式合并(session-list.ts:500-508,live url 赢、live-only 排尾),死分支已从 enrichPrSidecars 移除;三种边界(单侧、双侧同 number 不同 url)走查正确;list 级测试真实断言。✔
  • 🟡 pr 事件清空标题:唯一携带 prs 的发射点(bridge.ts)已回显当前 displayName;reduceDaemonSessionEvents 测试钉住。✔
  • 🟡 upsert 竞态:按 sidecar 路径串行的队列实现正确(前驱失败不阻塞、身份比对清理、与 archive/unarchive/delete 的 exclusive 锁互斥),并发测试真并发。✔(但见上方未处理 rejection)
  • 🟢 ACP 重命名重复 upsert:dispatch gate 在入参 metadata['pr']。✔
  • 🟢 url 2048 上限:四层实现俱在(route 层测试缺,见 Nit)。✔
  • 🟢 badge 组件级化:单一 SessionPrBadge 渲染前经 isExternalOpenUrl 过滤,全部消费方迁移完毕,无残留自渲染;count-aware aria-label、listbox tabIndex override 统合。✔
  • 🟢 测试缺口:原子性、绑定失败降级、workspace-route 顺序独立化均落实。✔
  • 🟢 Delete/Release 搜索:三处 sessionMatchesGitQuery 语义一致。✔

结论 | Verdict

🔄 请求修改 — 上一轮问题全部妥善修复,值得肯定;但 persist-first 重排引入的孤儿 sidecar 会在本 PR 明确承诺的 archive/unarchive 生命周期里静默丢失绑定,建议先处理 🔴 与两条 🟡(均为小改动)。


Re-review of incremental commit a60b0db "Address review findings on PR bindings"

All 8 findings from the previous round are verified fixed (item-by-item at the end). However, the fix commit reordered both metadata routes to persist-first (sidecar write before the bridge call), and that reordering itself introduces one new Critical and two Important issues.

🔴 Critical — persist-first strands an orphan active sidecar for archived sessions; unarchive then conflicts and silently drops earlier bindings

Location: packages/cli/src/serve/routes/session.ts, workspace route 5378-5390 (unconditional write to the 'active' path); interacting with packages/core/src/services/sessionService.ts moveOptionalFile 1016-1017 (throws when the destination exists) and unarchive 2043-2052 (the throw is warn-only, the file is not moved).

Problem: the route writes the sidecar to getPrSessionPathForArchiveState(sessionId, 'active') before calling the bridge. For an archived session (resolveLiveSessionOwner returns not_found and passes the checks at 5334-5361 — the fallback comment states this is a supported scenario), the bridge throws SessionNotFoundError and the fallback re-upserts at the real location ('archived') — the same binding lands in two files and the active one becomes an orphan. The old bridge-first order could not do this: the bridge threw before any active upsert ran. This is a regression introduced by this commit's reordering.

Failure scenario (stacked PRs, traced line by line):

  1. Session binds PR A while live → active sidecar [A];
  2. Session archived → sidecar moves with the transcript → archived=[A], no active file;
  3. Bind PR B through this route while archived (the SDK's DaemonClient.updateSessionMetadata targets exactly this route) → persist-first writes active=[B] (orphan), the fallback writes archived=[A,B], response is correct;
  4. Unarchive → moveOptionalFile(archived→active) throws "Archive sidecar conflict" because active exists; warn-only, nothing moves;
  5. Result: the session is active and listing enrichment reads active=[B]PR A silently disappears from badge/tooltip/search; the full history [A,B] is stranded in the archived directory with no cleanup path, behind a misleading warning. The location === 'conflict' branch leaves the same orphan before throwing.

Suggested fix: resolve the real archive state before persisting and write to the correct path:

if (pr) {
  const service = createWorkspaceRuntimeSessionService(runtime);
  const located = await service.getSessionLocation(sessionId);
  assertRuntimeGenerationOpen?.();
  if (located === 'conflict') throw new SessionConflictError(sessionId);
  persistedPrs = (
    await upsertSessionPr(
      service.getPrSessionPathForArchiveState(sessionId, located ?? 'active'),
      pr,
    )
  ).map(({ number, url }) => ({ number, url }));
}

(Live sessions keep their transcript in the active dir, so located ?? 'active' matches today's behavior; the number-keyed fallback upsert stays idempotent and untouched.)

🟡 Important — persist-first on /session/:id/metadata (route 1) turns "valid pr + invalid displayName" into a partial write

Location: routes/session.ts 5224-5241 (only typeof === 'string', no empty/control-character validation) + 5248-5257 (sidecar written first) + 5258 (bridge called after); contrast the workspace route's full local validation at 5305-5329.

Problem: route 1 delegates displayName content validation to the bridge. Under the old bridge-first order that was safe (bridge threw before any sidecar write); persist-first makes the delegation unsafe. Failure scenario: PATCH /session/:id/metadata with {displayName: "bad\u0001name", pr: {valid}} → route validation passes → sidecar written (pr persisted) → bridge throws InvalidSessionMetadataError on the control character → the client receives an error response, yet the binding is durable: the request "failed" while the badge "took effect", and sidecar/live diverge. This is precisely the atomicity the PR promises (the bridge test does not apply displayName when the combined pr is invalid pins it at bridge level, but the route-level ordering bypasses it).

Suggested fix: validate displayName locally before persisting (ideally a shared helper used by both routes), or revert route 1 to bridge-first — its sessions are necessarily live, so it has no fallback need.

🟡 Important — the upsertSessionPr queue cleanup produces an unhandled rejection on write failure

Location: packages/core/src/services/session-pr-service.ts 137-139.

Problem: void next.finally(...) derives a new promise; when run rejects on I/O failure (ENOSPC/EACCES/...), callers await next (handled at every call site), but the derived finally promise rejects with the same reason and nobody catches it → unhandledRejection. The serve daemon entry registers no fallback handler (gemini.tsx:239 covers only the interactive CLI path; the same-pattern comments in workspace-extensions-controller.ts show this is a known landmine). Failure scenario: binding a PR while the disk is full or permissions are wrong → on top of the expected 5xx, Node ≥15's default terminates the process — one failed sidecar write should not take down the daemon.

Suggested fix: make the cleanup chain absorb the rejection:

const cleanup = (): void => {
  if (upsertQueue.get(filePath) === next) upsertQueue.delete(filePath);
};
void next.then(cleanup, cleanup);
return next;

🟢 Nits

  • Literal 2048 at route layer: routes/session.ts:2115 hardcodes the limit and the error text "2048", while core exports SESSION_PR_URL_MAX_LENGTH (session-pr-service.ts:32) and this file already imports upsertSessionPr from the same module. Use the constant so a future limit change cannot desync.
  • Missing route-layer url>2048 rejection test: bridge and sidecar both have >2048 rejection cases; the route check (2115) has none — at odds with "rejection tests at each layer".
  • Shared badge class carries the sidebar's margin-left: 6px into overview/picker contexts: SessionPrBadge.module.css:4 is the union of the three old copies, but the old overview .prBadge / picker .picker-item-pr-badge had no left margin (spacing came from the parent gap of 8px/4px) — both contexts now render the badge 6px further out. Purely visual.
  • The unified onKeyDown stopPropagation reaches picker keyboard navigation: SessionPrBadge.tsx:57 stops propagation in every context. The picker's useListboxKeyboard listens at window bubble phase (hooks/useListboxKeyboard.ts:213, documented as the last stop of the propagation chain), and the old picker badge (SessionRow at 031f4fc) had no onKeyDown interception; after the user clicks the badge to open a PR, focus stays on the tabIndex={-1} anchor and arrow keys (and DialogShell's Escape) are swallowed — navigation stalls until the row body is clicked again. Consider skipping the interception in picker context (when tabIndex === -1).

💡 Suggestions

  • The list-level merge tests only cover disjoint numbers ([9500]+[9517]); the "same number on both sides, live url wins" branch of mergeLiveSessionSummary (session-list.ts:502-507) has no list-level anchor — add a sidecar [{n, oldUrl}] + live [{n, newUrl}][{n, newUrl}] case.
  • The scheme-filter line in SessionPrBadge (SessionPrBadge.tsx:33-34) is the core of this round's security consolidation but has no direct test: neither filter branch (all-bad → null; mixed list renders only openable entries) is ever exercised, so a future accidental removal would silently regress. A small component-level test would pin it.

Previous-round findings re-check (all verified fixed)

  • 🔴 Post-restart history loss: mergeLiveSessionSummary now merges by PR number (session-list.ts:500-508, live url wins, live-only entries sort latest); the dead branch is gone from enrichPrSidecars; all three boundary shapes (one-sided, same-number-different-url) trace correctly; the list-level test genuinely asserts. ✔
  • 🟡 pr event blanking the title: the only prs-carrying emitter (bridge.ts) now echoes the current displayName; pinned by a reduceDaemonSessionEvents test. ✔
  • 🟡 upsert race: the per-sidecar-path queue is correct (failed predecessor doesn't block, identity-checked cleanup, mutually exclusive with archive/unarchive/delete exclusive locks); the concurrency test is genuinely concurrent. ✔ (but see the unhandled rejection above)
  • 🟢 ACP re-upsert on rename: dispatch gates on the inbound metadata['pr']. ✔
  • 🟢 url 2048 cap: implemented at all four layers (route-layer test gap noted above). ✔
  • 🟢 Component-level scheme guard: single SessionPrBadge filtering through isExternalOpenUrl before render; all consumers migrated, no residual self-rendering; count-aware aria-label and listbox tabIndex override unified. ✔
  • 🟢 Test gaps: atomicity, bind-failure degradation, and workspace-route order-independence all landed. ✔
  • 🟢 Delete/Release search consistency: all three dialogs wire sessionMatchesGitQuery identically. ✔

Verdict

🔄 Request changes — the previous round was addressed thoroughly and that work is appreciated; however the persist-first reordering introduces an orphan sidecar that silently loses bindings in exactly the archive/unarchive lifecycle this PR promises to support. The 🔴 and the two 🟡s are all small fixes.

@wenshao

wenshao commented Aug 20, 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 20, 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)即可释放。

- SessionPrBadge: narrow onKeyDown to Enter only so the badge no longer
  blocks roving-listbox navigation keys in picker dialogs (R2-15).
- SDK updateSessionMetadata: per-entry prs shape gate so a hostile or
  buggy daemon response cannot surface javascript: urls or malformed
  numbers downstream; valid entries survive (R1-17).
- Tests: bridge mirror atomicity (valid pr + invalid displayName),
  GitDialog stale-id retry binding, list-level merge dedupe by number,
  organized + archived listing paths keep PR sidecars, DaemonClient pr
  request/parse + gate, Delete/Release dialog PR-number search (R2-6,
  R2-13, R2-14, R1-15, R1-17, R2-20).
@wenshao

wenshao commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

R2/R4 review round — all 29 threads addressed (29/29 resolved)

The six Criticals (persist-first orphans, unhandled rejection, path traversal, wedge-on-conflict moves, missing relocation) were fixed by the autofix commits 29e3a1f2a7 + 3f66720def (bridge-first ordering with sidecar hydration, isValidSessionId guards, movePrSidecar merge-on-conflict, .pr.json in getCurrentSessionArtifactMoves, queue .catch). This round (5419305ad6) closes the remaining Suggestions:

  • R2-15 SessionPrBadge onKeyDown narrowed to Enter only (roving-listbox keys pass through).
  • R1-17 SDK updateSessionMetadata per-entry prs shape gate (drops javascript:/malformed, keeps valid) + DaemonClient tests.
  • R2-6 list-merge dedupe-by-number test (shared number, live url wins).
  • R2-13 GitDialog stale-id retry binding test (pins the render-body→useEffect ref migration).
  • R2-14 bridge mirror atomicity test (valid pr + invalid displayName).
  • R1-15 / R2-4(archived) organized + archived listing tests keep PR sidecars.
  • R2-20 Delete/Release dialog PR-number search tests.
  • R2-4/R2-5 (ACP ordering) resolved by unification: REST and ACP both use hydrate → bridge → persist; the idempotent number-keyed upsert serves both transports.
  • R2-10 cap constants unified to SESSION_PR_URL_MAX_LENGTH / SESSION_PR_LIST_LIMIT (core), shared by bridge + route; SDK keeps a deliberate browser-bundle copy.

@wenshao

wenshao commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /resolve

bun.lock conflict: main's 07405e6 (QwenLM#9395) regenerated the desktop
lockfile properly (npmmirror URLs, @types/bun 1.4.0, live-host entry) —
take main's version; this branch's earlier regenerated copy was made
with a local bun whose registry config differs from CI.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Check the workflow run for full logs.

@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. Suggestions are inline.

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) were skipped in CI; the changed workspaces unit suites ran on Linux only.

Not explored to full depth (tool budget reached): chunk 17: could not execute sessionSearch.test.ts , SessionRow.test.tsx , SessionDetailsTooltip.test.tsx — no node_modules in the review worktree or parent checkout…; chunk 2: executing the 12 new pr-binding tests in bridge.test.ts (worktree lacks node_modules/built dist; npm ci + build not run); chunk 15: none — all planned checks completed within budget (tests were not executed; per the brief, verification is a later stage's job)..

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

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

  • docs/design/2026-08-20-webshell-session-pr-binding.md:49 — [review] The design doc states the badge stops propagation on ALL…
  • packages/cli/src/serve/acp-http/dispatch.ts:2945 — [review] A repeated identical binding re-upserts the sidecar and…
  • packages/core/src/services/session-pr-service.test.ts:72 — [review] The sidecar URL-length bound is tested only on the reject…
  • packages/core/src/services/session-pr-service.test.ts:170 — [probe] The queue failure-handling test cannot distinguish the…
  • packages/core/src/services/session-pr-service.test.ts:198 — [review] mergeSessionPrLists' stale-incoming dedupe branch is…
  • packages/core/src/services/session-pr-service.ts:63 — [review] isValidSessionPrList is all-or-nothing, so one malformed…
  • packages/core/src/services/session-pr-service.ts:151 — [review] upsertSessionPr persists the caller-supplied pr without…
  • packages/sdk-typescript/src/daemon/DaemonClient.ts:6129 — [review] WorkspaceDaemonClient.updateSessionMetadata returns the…
  • packages/sdk-typescript/src/daemon/events.ts:2662 — [review] isSessionMetadataUpdatedData rejects the whole…
  • packages/sdk-typescript/src/daemon/session-pr.ts:1 — [review] The new SDK module session-pr.ts is the only file in…
  • packages/web-shell/client/components/dialogs/GitDialog.test.tsx:856 — [review] The three new GitDialog binding tests never mock…
中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查: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) were skipped in CI; the changed workspaces unit suites ran on Linux only。

未探索到全部深度(达到工具调用预算):chunk 17:could not execute sessionSearch.test.ts , SessionRow.test.tsx , SessionDetailsTooltip.test.tsx — no node_modules in the review worktree or parent checkout…;chunk 2:executing the 12 new pr-binding tests in bridge.test.ts (worktree lacks node_modules/built dist; npm ci + build not run);chunk 15:none — all planned checks completed within budget (tests were not executed; per the brief, verification is a later stage's job).

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

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

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

Comment thread packages/cli/src/serve/acp-http/dispatch.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/serve/acp-http/dispatch.ts
Comment thread packages/cli/src/serve/acp-http/transport.test.ts
Comment thread packages/cli/src/serve/server.test.ts
Comment thread packages/cli/src/serve/multi-workspace-sessions.test.ts
Comment thread packages/cli/src/serve/server.test.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — no action taken

This round made no code changes and no commits. The branch stays at 9c3c1715f1 (the origin/main merge) with a clean working tree.

Growth audit (required this round): verdict sound

The counting window is over its growth budget (source 175 / test 1391 net lines vs 400/400 budgets; the overrun is on the test axis). The audit assumed the PR is over-engineered and tried to prove it, and traced every changed file/hunk to the feature, an accepted finding, or a failing check:

  • KISS (structure) — pass. No structurally simpler shape achieves the same goal: one core sidecar service mirroring the worktree-sidecar precedent; a storage-agnostic bridge with validate-before-mutate + optional seed hook; one shared hydrate -> bridge -> persist ordering across the primary REST route, workspace REST route, and ACP dispatch (which is itself the fix for the persist-first-orphan Critical); one shared badge component and one shared search helper in Web Shell; cap constants unified as a single core family with one justified SDK copy (browser bundle cannot import core). Each accumulated piece is load-bearing for a named Critical or an accepted R1/R2 finding.
  • Minimal change (footprint) — pass. Every file traces to the design doc's impact table, an accepted finding, or the witness coverage pinning it. No hunk touches CI, .husky/, .qwen/, repo scripts, workspace-root configs, lockfiles, or package.json scripts fields. The test-axis overrun is the per-finding witness coverage four review rounds required; source growth is 44% of budget.

Recorded in growth-audit.json (verdict sound, both axes pass, no drift alternative, no untraceable hunks).

Triage of new feedback (all no-action)

  • 29 inline comments (rc:3828357471rc:3828368850) — verified against the raw payload: all 29 are thread replies (each carries in_reply_to_id), not new findings. Their content is the "Fixed in 29e3a1f2a7 / 3f66720def / 5419305ad6" resolution confirmations for the six Criticals and the R1/R2 Suggestions that already landed. They assert no defect and request no change.
  • 29 reviews (rv:4990953681rv:4990966685)COMMENTED reviews with empty bodies from @wenshao, serving as envelopes for the thread replies above. No findings, no change requests.
  • 1 issue-level comment (ic:5366862884) — the round summary confirming all 29 threads were addressed (29/29 resolved). A status confirmation, not a request.
  • Checks — no failed checks and no still-red checks; all 48 reported check runs are green or skipped. --conflict false, so no base merge was performed.

Deferred non-Critical feedback (untouched by design)

Critical-only mode is active on a growth-only trigger. The Deferred non-Critical feedback section is an audit record, not work: the 13 automated-reviewer **[Suggestion]** inline findings, its review, and its PR comment remain open for human follow-up. Per the brake rules, nothing in that section was modified, resolved, or replied to this round.

中文说明

Autofix 审查轮次 — 未采取任何行动

本轮未做任何代码改动,也没有任何提交。分支保持在 9c3c1715f1(合并 origin/main 的提交),工作区干净。

增长审计(本轮必需):结论 sound

当前计数窗口已超出增长预算(净增长 源码 175 / 测试 1391 行,预算 400/400;超出的是测试轴)。审计时先假定本 PR 过度设计并尝试证实,同时逐一核对每个改动文件/代码块是否可追溯到功能本身、已采纳的审查发现或失败检查:

  • KISS(结构)— 通过。 不存在能达成同样目标的更简单结构:core 中唯一的 sidecar 服务复刻了 worktree sidecar 的既有先例;bridge 保持与存储无关,先校验后变更并提供可选的 seed 钩子;主 REST 路由、workspace REST 路由与 ACP dispatch 三条写入路径统一采用 hydrate -> bridge -> persist 顺序(该顺序本身就是对"先持久化产生孤儿绑定"这一 Critical 的修复);Web Shell 侧共用一个 badge 组件和一个搜索匹配辅助函数;上限常量统一为 core 中的单一常量族,SDK 保留一份有正当理由的副本(浏览器打包无法引入 core)。每一处累积的改动都对应一个具体的 Critical 或已采纳的 R1/R2 发现,属于必要结构。
  • 最小改动(足迹)— 通过。 每个文件都可追溯到设计文档的影响文件表、某个已采纳的发现,或为固定这些发现而补充的见证测试。没有任何代码块触及 CI、.husky/.qwen/、仓库脚本、工作区根配置、lockfile 或 package.json 的 scripts 字段。测试轴超出的原因是四轮审查要求的逐项见证覆盖;源码增长仅为预算的 44%。

已记录于 growth-audit.json(结论 sound,两个轴均为 pass,无 drift 替代方案,无可疑代码块)。

新反馈分类(全部无需处理)

  • 29 条行内评论(rc:3828357471rc:3828368850 — 已对照原始数据核实:29 条全部是线程回复(每条都带 in_reply_to_id),并非新发现。内容是六个 Critical 与 R1/R2 建议已在 29e3a1f2a7 / 3f66720def / 5419305ad6 中修复的确认说明。它们既不指称缺陷,也不提出任何改动要求。
  • 29 个审查(rv:4990953681rv:4990966685@wenshaoCOMMENTED 审查,正文为空,仅作为上述线程回复的载体。无发现、无改动请求。
  • 1 条 issue 级评论(ic:5366862884 — 轮次总结,确认全部 29 个线程已处理完毕(29/29 已解决)。属于状态确认,不是新的请求。
  • 检查 — 没有失败检查,也没有持续飘红的检查;报告中的 48 个检查运行全部通过或被跳过。--conflict false,因此未执行 base 合并。

延迟的非 Critical 反馈(按设计不做处理)

当前处于仅处理 Critical 的模式,且由纯增长信号触发。Deferred non-Critical feedback 部分属于审计记录而非待办:其中 13 条自动审查器的 **[Suggestion]** 行内发现、对应的审查与 PR 评论保持开放,留待人工跟进。按照刹车规则,本轮未修改、未解决、也未回复该部分中的任何条目。

Deferred non-Critical feedback

Critical-only mode is active: the PR's diff grew src 175 / test 1391 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 净增长已达 源码 175 / 测试 1391 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 @qwen-code /retry 可开启新的计数窗口。)

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


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

Best-effort hydration (R5-1/R5-2/R5-3): the sidecar hydration read at
all three metadata-mutation sites (ACP dispatch, primary and workspace
REST routes) now absorbs non-ENOENT I/O errors as "no sidecar" instead
of failing the whole call — a squatted sidecar path no longer turns a
pr-less rename into a 500/-32603. This also makes the R4-2 fallback
ordering test reach the branch it names.

Validation hardening (R5-4): pr.url rejects control characters at all
four layers (bridge via hasControlCharacter, route, SDK guard, sidecar
reader) — the url is interpolated into the stderr audit line, so a
newline-bearing url could forge audit records.

Traversal parity (R5-5, R4-5): the ACP session/update_metadata handler
now gates on isValidSessionId before any sidecar I/O, and the primary
REST route's gate moved ahead of runtime resolution so traversal ids
get 400 invalid_session_id identically on single- and multi-workspace
daemons (previously 404 on multi-entry registries).

Tooltip (R5-6): PR rows key on index+number — a hand-edited sidecar
with duplicate numbers no longer risks cross-row reconciliation.

Tests: FakeBridge callLog pins seed-before-mutate order (R5-9);
cross-workspace pr sidecar lands in the owning workspace's chats dir
(R4-4); multi-workspace traversal test; metadata-filtered listing
keeps prs (R1-15); Resume dialog PR-number search (R2-20); dialog
fixtures annotated DaemonSessionSummary[] (R5-7/R5-8); control-char
rejection cases at bridge and sidecar layers.
@wenshao

wenshao commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

R4/R5 review round — all 37 threads addressed (0 unresolved)

f489d2c6ef closes the R5 findings; the R4 criticals were fixed by the autofix commits (29e3a1f2a7, 3f66720def) and are now replied/resolved individually.

R5 fixes in this round:

  • Best-effort hydration (R5-1/2/3): non-ENOENT sidecar read errors at all three metadata-mutation sites now degrade to "no sidecar" instead of failing pr-less renames with 500/-32603. This also made the R4-2 fallback-ordering test reach the branch it names (R5-10).
  • Control-character rejection (R5-4): pr.url with \n could forge stderr audit lines — now rejected at all four layers (bridge/route/SDK/sidecar) with tests at bridge + sidecar.
  • Traversal parity (R5-5 + R4-5): ACP update_metadata gates on isValidSessionId before any sidecar I/O; the primary REST route's gate moved before runtime resolution so traversal ids get 400 invalid_session_id on every registry shape (was 404 on multi-workspace daemons). Two-entry-registry test added.
  • Tooltip (R5-6): rows key on index+number (hand-edited sidecars with duplicate numbers no longer collide).
  • Tests (R5-7/8/9, R4-4, R1-15, R2-20): dialog fixtures annotated DaemonSessionSummary[]; FakeBridge callLog pins seed-before-mutate order; cross-workspace sidecar lands in the owning workspace; metadata-filtered listing keeps prs; Resume dialog PR-number search.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (1080000ms)) (attempt 1/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (1080000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (1080000ms))(第 1/100 次尝试)—— 将在下次扫描时重试。

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


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

@wenshao

wenshao commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 94 passed · 0 failed · 94 total

Flakiness gate: ⚠️ timeout — only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:94 通过 · 0 失败 · 94 总计

抖动门:⚠️ timeout — only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed

Verification report

PR 9543 Deep Verification — feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search

Verdict: merge-ready — 94/94 scripted harness assertions passed, 3,592 targeted unit tests green, 6/6 mutation kills, no blocking findings.
Verified head: f489d2c6ef1b92b7d08076e5831d9928a1a56593 (merge-ref checkout; base tip f1d05b79fca43157df1131c6045ddee36598514e).

中文摘要

结论:merge-ready。中心主张经 A/B 实测成立:base 构建对 PATCH /session/:id/metadata 携带 pr 的请求静默丢弃(200、无回显、无 sidecar、无 SSE);head 构建接受绑定、持久化 sidecar、响应/列表/SSE 均回显完整列表,且 daemon 重启后经 sidecar 回填与 hydration 仍能回显完整历史(见 A/B 表与 03-ab-head-arm.png / 04-ab-base-arm.png)。

四层校验(route / bridge / sidecar reader / SDK)经 41 项在线矩阵全部验证:javascript:/ftp:/data: scheme、非整数/非正数/字符串 number、超长 url(2049 字符)、控制字符、路径穿越 sessionId、组合请求原子性、并发上限(cap 10)均按契约拒绝或收敛(01-validation-matrix-head.png)。归档/取消归档/删除时 sidecar 跟随迁移与清理、分裂对(split-pair)按 PR 号合并,17/17 通过(02-lifecycle-head.png)。

变异矩阵 6/6 全部击杀(05-mutation-matrix-all-killed.png):串行化队列、live+sidecar 按号合并、bridge 校验、SDK 逐条门、REST 与 ACP 两处 hydration 均为载荷性守卫,无幸存者。针对性测试门:core 743、acp-bridge 771、sdk 458、cli serve 1543、web-shell 77,共 3592 全绿。

Findings:仅两条 nit——① PR 描述"空 body 仍以 invalid_metadata 拒绝"仅适用于 workspace 作用域路由;主路由空 body 为 200 no-op(与 base 语义一致、非回归:base 路由源码无此门槛,且 A/B base 臂实测缺 displayName 的请求即为 200 no-op;字面 {} 单元仅在 head 上运行)。② build.js 预算注释出现两条 "199KB→200KB"(本 PR 与 main 的 #9303 各一条),常量现值 206KB,纯注释层面。

未覆盖:浏览器 UI 实际渲染(badge/tooltip/搜索交互,无浏览器环境;组件单测已覆盖)、ACP session/update_metadata 的在线 E2E(以 339 项 transport 测试 + M6 变异覆盖)、仓库级 lint/typecheck(依赖 PR 自身 CI)、逐 commit 归因(shallow 检出仅 3 个 commit 可达)。

Central claim + A/B

Central claim: the daemon accepts a PR binding through the session metadata routes, validates it, persists it as a per-session sidecar, echoes the full list in responses/listings/SSE, and the binding survives daemon restarts with the persisted history hydrated before mutations.

Identical scenario driven against the head build and a base control build (worktree at HEAD^1, rebuilt core/acp-bridge/cli + web-templates; readlink -f asserted @qwen-code/* resolves into the base tree; base dist confirmed to contain no session-pr-service). Real daemons (packages/cli/dist/index.js serve --no-web), loopback HTTP, isolated HOME/QWEN_RUNTIME_DIR, bearer token. The harness never prompts a model (offline openai auth selection); sessions needing post-restart persistence get a minimal fabricated transcript — the file layout a real session leaves behind.

Cell (observable) base (f1d05b79) head (f489d2c6)
PATCH /session/:id/metadata {pr:{9517}} 200, binding silently discarded (no prs in body) 200, body echoes prs:[{9517}]
sidecar <chats>/<id>.pr.json on disk absent {prs:[{number,url,createdAt}]}
listing row prs absent [{9517}]
rebind + accumulate no-op [9517,9518] → rebind → [9518,9517]
SSE session_metadata_updated 0 events 3 events, last carries full list
live displayName rename 200, echoed 200, echoed; sidecar untouched
after daemon restart row exists, no prs row carries [9518,9517] (sidecar enrichment)
workspace-scoped pr-only PATCH post-restart 400 invalid_metadata (displayName required — old contract) 200, hydrated full history [9518,9517,9519]

Head arm 16/16, base arm 13/13 — every base-control assertion encodes the expected absence (base failing to bind is the control passing). Witness: 03-ab-head-arm.png, 04-ab-base-arm.png.

Findings

No blocking findings. Two nits:

N1 (nit, description clarity). The PR description says "an empty body is still rejected with the same invalid_metadata code". That holds for the workspace-scoped route (verified live: 400 invalid_metadata), but the primary route answers an empty body with 200 no-op. This is not a regression: the base route source has no empty-body gate on the primary route (absent displayNameundefined → bridge no-op → 200), and the base A/B arm confirmed that same no-op semantics live for bodies lacking displayName (its pr-only cells returned 200 with nothing echoed and nothing persisted); a literal {} cell was run on head only. Measured on head: 02-matrix.mjs cell "primary route: empty body is a no-op as in base (200, echoes current list, sidecar unchanged)". A reader of the description might expect both routes to reject; only the workspace-scoped one does.

N2 (nit, cosmetic). packages/sdk-typescript/scripts/build.js now carries two "Bumped from 199KB to 200KB" comments — this PR's (session PR binding) and #9303's from main — and the constant has since advanced to 206 * 1024 via later main work. The budget gate itself is live and passing: built the SDK browser bundle at head, 202.0 KB ≤ 206 KB (scripts/build.js throws above budget; build exited 0). The Risk-section claim "raised to 200KB" was accurate when written.

Validation matrix (secondary claim 1 — four-layer validation), head build, live daemon

41/41 (01-validation-matrix-head.png), including:

  • javascript:, ftp:, data:, scheme-less URLs → 400 invalid_metadata field=pr; uppercase HTTPS:// accepted (case-insensitive regex).
  • number 0 / negative / 1.5 / "9517" → 400; missing url/number, pr: null, pr: "string" → 400.
  • Length boundary: 2048-char url accepted, 2049 rejected.
  • Control characters in url (R5-4 audit-forgery hardening): \n, \r, \x7f, \x00 all rejected.
  • Atomicity both directions: valid rename + invalid pr → 400 and the rename did NOT apply; valid pr + control-char displayName → 400 and NO durable binding written (bridge-first ordering holds).
  • Traversal: ..%2F..%2Fevil, foo%2Fbar, 64-char id, and the workspace-scoped variant → 400 invalid_session_id before any fs write; no stray sidecars anywhere in the scratch tree.
  • R5 best-effort hydration: squatting the sidecar path with a DIRECTORY does not break a pr-less rename (200).
  • Defensive sidecar reads (persisted-only session, post-restart): corrupt JSON ignored; a hand-edited sidecar with one javascript: entry fails the shape check and the WHOLE list is dropped (fail-closed, including the valid entry — consistent with the worktree-sidecar precedent the design doc cites; noted here as an observation, not a defect); empty prs: [] ignored.
  • Concurrency: 12 parallel distinct binds → all 200, sidecar capped at 10 with no duplicates; 12 serial binds 201..212 → exactly [203..212]; 8 parallel rebinds of the same number → exactly one entry. (Under parallel dispatch which 10 survive follows queue-completion order — e.g. one run kept [101,103..111] — inherent to serialized arrival-order eviction; dedupe and cap invariants hold.)

Lifecycle (secondary claim 2 — sidecar follows the session), head build

17/17 (02-lifecycle-head.png): archive moves sidecar active→archived with content intact; archived listing carries the binding (enrichPrSidecars on archiveState='archived'); binding an ARCHIVED session lands in the archived chats dir; unarchive moves it back; split-pair (both halves on disk) merges by number keeping the freshest entry, ordered by binding time; delete removes sidecars in both states (tested for active-then-archived and archived-only sessions).

Note: archiving a live session that never produced a transcript returns notFound and leaves the active sidecar in place — identical pre-existing archive-route behavior (archive operates on persisted sessions), not a PR regression; the sidecar then travels with the session once it persists.

Mutation matrix — vacuity of the new tests (6/6 killed, no survivors)

05-mutation-matrix-all-killed.png; rerunnable via harness/05-mutation-matrix.sh. Each mutant deletes one guard the PR introduces; each killing assertion failed with the behavioral mismatch quoted (not an import/compile break). Positive controls: every kill landed in the suite that collects the mutated file, and all 3,592 unmutated tests were green beforehand.

Mutant (guard removed) Suite Result
M1 upsertSessionPr serialization queue core session-pr-service.test.ts KILLED — "serializes concurrent upserts…" red (bindings would drop)
M2 mergeLiveSessionSummary prs-by-number merge cli server.test.ts KILLED — expected [ 9517 ] to deeply equal [ 9500, 9517 ]
M3 bridge pr validation block acp-bridge bridge.test.ts KILLED — 8 tests red, all "expected function to throw an error, but it didn't"
M4 SDK per-entry response gate sdk DaemonClient.test.ts KILLED — "drops prs entries that fail the shape gate" red (hostile entries leak)
M5 primary REST route sidecar hydration cli server.test.ts KILLED — expected [] to deeply equal [ [ 9000 ] ]
M6 ACP dispatch hydration cli transport.test.ts KILLED — expected [ 'update' ] to deeply equal [ 'seed', 'update' ]

Tree verified clean (git status --porcelain = 0) after all restores.

Targeted gates (exact counts, unmutated)

Workspace Files Tests
packages/core session-pr-service.test.ts, sessionService.test.ts, config.test.ts 743/743
packages/acp-bridge bridge.test.ts 771/771
packages/sdk-typescript DaemonClient.test.ts, daemonEvents.test.ts 458/458
packages/cli server.test.ts, multi-workspace-sessions.test.ts, acp-http/transport.test.ts, acp-http/dispatch-error.test.ts 1543/1543
packages/web-shell SessionPrBadge-adjacent: GitDialog, SessionDetailsTooltip, sessionSearch, SessionOverviewPanel, Resume/Delete/Release dialogs, SessionRow 77/77

The cli serve gate includes the PR's multi-workspace coverage (cross-workspace sidecar landing in the owning workspace's chats dir — R4-4, and multi-workspace traversal parity — R5-5), which the single-workspace live harness could not reach.

Not covered

  • Browser UI rendering (sidebar badge, tooltip links, search filtering as experienced in a browser): no browser in this container. Covered only at unit level (77 web-shell tests incl. SessionPrBadge rendering via SessionRow/overview/tooltip tests and sessionMatchesGitQuery search cases). The Playwright visuals harness mentioned in the description was not run. This reproduces the wire shape (summaries carry prs), not the rendered badge.
  • ACP session/update_metadata live E2E: exercised only through the transport/dispatch unit suites (339 + 7 tests) and mutant M6, not a live JSON-RPC-over-HTTP session. The REST surface was exercised live.
  • Repo-wide lint/typecheck/test: not run (budget + PR's own CI covers them); gates above are the affected-workspace subset.
  • Per-commit attribution: the checkout is depth 2 (git rev-list HEAD^1..HEAD^2 = 1 commit locally vs 18 in the metadata snapshot — shallow boundary), so only the aggregate HEAD^1..HEAD diff was verified.
  • bun.lock / Live Host gate: the branch's regenerated lockfile was superseded by main's version (final diff carries no bun.lock change); not exercised.
  • Web-shell SDK budget under bun: n/a (npm tree used throughout).
  • Git-dialog bind failure degradation and stale-sessionId retry are pinned only by unit tests (GitDialog.test.ts), not by a live Git-server flow.
  • scripts/verify-capture.mjs worked; no capture failures.

Methodology

Environment: CI merge-ref checkout at 369d1f45 (depth 2), node:22-bookworm container, builds pre-done at head. Harnesses (harness/*.mjs, rerunnable) spawn the real built CLI (packages/cli/dist/index.js serve --port N --hostname 127.0.0.1 --no-web --workspace <scratch-git-repo>) as a child process with isolated HOME (settings selecting offline openai auth + fake key) and QWEN_RUNTIME_DIR, bearer token QWEN_SERVER_TOKEN, and drive it over loopback fetch — no stubs of verified code. Base control: git worktree add tmp/base-tree HEAD^1, root + all 12 nested node_modules hardlink-copied (cp -al, 0.9 s; lockfile diff is 11 "peer": true markers only — dependency tree identical), affected workspaces rebuilt in-tree; control validated by readlink -f node_modules/@qwen-code/* → base-tree paths and absence of session-pr-service in base dist. Sessions needing persistence across restarts received one fabricated minimal ChatRecord JSONL (cwd = workspace) next to their sidecar, since the harness never prompts a model. Raw logs: logs/base-build*.log, harness stdout as printed in the evidence captures; per-harness assertion accumulators produced the counts in assertions.json (A/B head 16 + A/B base 13 + matrix 41 + lifecycle 17 + mutation kill-checks 7 = 94, all expected outcomes).

Flakiness gate log

rounds=5 files=18 skipped=0
file packages/acp-bridge/src/bridge.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/bridge.test.ts
file packages/cli/src/serve/acp-http/dispatch-error.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/acp-http/dispatch-error.test.ts
file packages/cli/src/serve/acp-http/transport.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/acp-http/transport.test.ts
file packages/cli/src/serve/multi-workspace-sessions.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/multi-workspace-sessions.test.ts
file packages/cli/src/serve/server.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server.test.ts
file packages/core/src/config/config.test.ts: (cd packages/core) npx --no-install vitest run ./src/config/config.test.ts
file packages/core/src/services/session-pr-service.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/session-pr-service.test.ts
file packages/core/src/services/sessionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/sessionService.test.ts
file packages/sdk-typescript/test/unit/DaemonClient.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/DaemonClient.test.ts
file packages/sdk-typescript/test/unit/daemonEvents.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/daemonEvents.test.ts
file packages/web-shell/client/components/SessionOverviewPanel.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/SessionOverviewPanel.test.tsx
file packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/dialogs/DeleteSessionDialog.test.tsx
file packages/web-shell/client/components/dialogs/GitDialog.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/dialogs/GitDialog.test.tsx
file packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/dialogs/ReleaseSessionDialog.test.tsx
file packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/dialogs/ResumeDialog.test.tsx
file packages/web-shell/client/components/dialogs/SessionRow.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/dialogs/SessionRow.test.tsx
file packages/web-shell/client/components/sidebar/SessionDetailsTooltip.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/sidebar/SessionDetailsTooltip.test.tsx
file packages/web-shell/client/components/sidebar/sessionSearch.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/components/sidebar/sessionSearch.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/acp-bridge/src/bridge.test.ts: PPPPP
  packages/cli/src/serve/acp-http/dispatch-error.test.ts: PPPPP
  packages/cli/src/serve/acp-http/transport.test.ts: PPPPP
  packages/cli/src/serve/multi-workspace-sessions.test.ts: PPPPP
  packages/cli/src/serve/server.test.ts: PPPPP
  packages/core/src/config/config.test.ts: PPPP
  packages/core/src/services/session-pr-service.test.ts: PPPP
  packages/core/src/services/sessionService.test.ts: PPPP
  packages/sdk-typescript/test/unit/DaemonClient.test.ts: PPPP
  packages/sdk-typescript/test/unit/daemonEvents.test.ts: PPPP
  packages/web-shell/client/components/SessionOverviewPanel.test.tsx: PPPP
  packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx: PPPP
  packages/web-shell/client/components/dialogs/GitDialog.test.tsx: PPPP
  packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx: PPPP
  packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx: PPPP
  packages/web-shell/client/components/dialogs/SessionRow.test.tsx: PPPP
  packages/web-shell/client/components/sidebar/SessionDetailsTooltip.test.tsx: PPPP
  packages/web-shell/client/components/sidebar/sessionSearch.test.ts: PPPP

verdict: timeout
summary: only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 1 · packages/core/src/config/config.test.ts: P (exit 0)
round 1 · packages/core/src/services/session-pr-service.test.ts: P (exit 0)
round 1 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/daemonEvents.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/SessionOverviewPanel.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/dialogs/GitDialog.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/dialogs/SessionRow.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/sidebar/SessionDetailsTooltip.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/sidebar/sessionSearch.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 2 · packages/core/src/config/config.test.ts: P (exit 0)
round 2 · packages/core/src/services/session-pr-service.test.ts: P (exit 0)
round 2 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/daemonEvents.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/SessionOverviewPanel.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/dialogs/GitDialog.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/dialogs/SessionRow.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/sidebar/SessionDetailsTooltip.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/sidebar/sessionSearch.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 3 · packages/core/src/config/config.test.ts: P (exit 0)
round 3 · packages/core/src/services/session-pr-service.test.ts: P (exit 0)
round 3 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/daemonEvents.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/SessionOverviewPanel.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/dialogs/GitDialog.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/dialogs/SessionRow.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/sidebar/SessionDetailsTooltip.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/sidebar/sessionSearch.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 4 · packages/core/src/config/config.test.ts: P (exit 0)
round 4 · packages/core/src/services/session-pr-service.test.ts: P (exit 0)
round 4 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/daemonEvents.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/SessionOverviewPanel.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/dialogs/GitDialog.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/dialogs/ResumeDialog.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/dialogs/SessionRow.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/sidebar/Sessi

...truncated -- full content in the run artifacts.

Evidence images

01-validation-matrix-head

02-lifecycle-head

03-ab-head-arm

04-ab-base-arm

05-mutation-matrix-all-killed

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@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: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 3-5 each reported findings; every reported finding was merged into confirmed entries or rejected).

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) were skipped in CI; the changed workspaces' unit suites ran on Linux only.

Not reviewed: test-efficacy — probe kit inconclusive (harnessValidated: null; probe invocation artifact, the new test files run green directly); the mutation/hunk-survival dimension was unmeasured, not clean.

Not explored to full depth (tool budget reached): chunk 5: run packages/cli vitest for src/serve/multi-workspace-sessions.test.ts — not executed; review worktree has no node_modules or built dist prerequisites, …; "agent reverse-audit (round 4)": none — wait, per instructions I must not write a Budget gap line when nothing was cut short; no checks were curtailed, so no such line is emitted..

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

  • package-lock.json:17581 — [review] eleven lightningcss platform packages gain "peer": true with no package.json change — unrelated lockfile churn
  • docs/design/2026-08-20-webshell-session-pr-binding.md:66 — [review] impact table names DaemonSessionSummary.pr/BridgeSessionSummary.pr (singular) but implementation uses prs arrays
  • docs/design/2026-08-20-webshell-session-pr-binding.md:45 — [review] doc claims live entry.prs is this-daemon-lifetime-only, but the code hydrates the full sidecar history (seedSessionPrs)
  • packages/sdk-typescript/scripts/build.js:104 — [review] budget comment claims a nonexistent 199KB→200KB bump; duplicates the base's recorded transition
  • packages/cli/src/serve/server/session-list.ts:437 — [review] enrichPrSidecars serves persisted prs uncapped; a hand-edited sidecar inflates every list fetch
  • packages/web-shell/client/components/SessionPrBadge.tsx:33 — [review] badge filter admits mailto: against its http(s)-only docstring; hostile mailto binding renders as a phishing seed
  • packages/sdk-typescript/src/daemon/types.ts:1276 — [review] DaemonSessionSummary.prs catalog/list ingress is a raw cast without the isDaemonSessionPrInfo gate
  • packages/sdk-typescript/src/daemon/DaemonClient.ts:6129 — [review] WorkspaceDaemonClient.updateSessionMetadata returns prs via unvalidated cast; the sibling primary client filters
  • packages/sdk-typescript/src/daemon/events.ts:2663 — [review] events validator prs arm has no rejection-branch test; mutation to true ships green
  • packages/core/src/services/session-pr-service.ts:41 — [review] hasControlCharacter misses the C1 band (U+0080-U+009F); NEL in pr.url forges audit-line breaks
  • packages/acp-bridge/src/bridge.test.ts:26718 — [review] invalid-pr table never tries number 0; <=0→<0 mutant ships green and a persisted 0 wipes the sidecar history
  • packages/acp-bridge/src/bridge.test.ts:26729 — [review] exactly-2048 url boundary unpinned across four duplicated length gates; off-by-one desyncs the layers
  • packages/core/src/services/session-pr-service.test.ts:80 — [review] createdAt typeof clause unpinned; a numeric createdAt breaks mergeSessionPrLists' localeCompare during moves
  • packages/core/src/services/session-pr-service.test.ts:163 — [review] readSessionPrs ENOENT-vs-rethrow contract unpinned; unconditional-null mutant silently drops history on transient reads
  • packages/core/src/services/session-pr-service.test.ts:174 — [review] no-wedge retry never exercises previous.catch; a failed predecessor skips the queued successor binding
  • packages/core/src/services/sessionService.test.ts:2006 (+2 locations) — [review] split-pair merge tests under-pin movePrSidecar's contract (read order, tie-break, rename absence)
  • packages/cli/src/serve/server.test.ts:26341 — [review] REST 'echoes the sidecar list' tests don't pin seed-before-mutation ordering (ACP path is pinned)
  • packages/web-shell/client/components/SessionPrBadge.tsx:33 — [review] SessionPrBadge URL-gate branch has no direct test; filter-dropping mutant ships green across 686 tests
  • packages/web-shell/client/components/dialogs/GitDialog.test.tsx:856 — [review] two GitDialog binding tests leave generation unmocked; every run logs a TypeError and passes via the fallback
  • packages/cli/src/serve/acp-http/dispatch.ts:2910 — [review] ACP session/update_metadata isValidSessionId gate has no test; removal degrades to opaque -32603
  • …and 11 more (see the run report)
中文说明

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

未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 3-5 each reported findings; every reported finding was merged into confirmed entries or rejected)。

未审查: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) were skipped in CI; the changed workspaces' unit suites ran on Linux only。

未审查:test-efficacy — probe kit inconclusive (harnessValidated: null; probe invocation artifact, the new test files run green directly); the mutation/hunk-survival dimension was unmeasured, not clean。

未探索到全部深度(达到工具调用预算):chunk 5:run packages/cli vitest for src/serve/multi-workspace-sessions.test.ts — not executed; review worktree has no node_modules or built dist prerequisites, …"agent reverse-audit (round 4)"none — wait, per instructions I must not write a Budget gap line when nothing was cut short; no checks were curtailed, so no such line is emitted.

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

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix round: no code changes needed — all blocking findings re-verified fixed at f489d2c6

This round ran under a time-budget warning, so instead of retrying the full accumulated batch it re-triaged the feedback and verified the claimed fixes directly against the head tree. Every Critical / Request-changes finding from review rounds 1–5 was confirmed present in the code at this HEAD, and the focused witness suites were re-run green. The round-6 automated review posted 0 findings on this same HEAD, and the sandboxed deep verification on it returned merge-ready (94/94 scripted assertions, 6/6 mutation kills).

Verification (all commands actually run this round at HEAD f489d2c6)

Code inspection (each fix located and read in the head tree):

  • R1-1 — mergeLiveSessionSummary merges prs explicitly by PR number (live url wins, sidecar history kept); the dead enrichment branch is gone.
  • R1-2 — the PR-bind event echoes the current displayName, so SDK folds no longer blank the title.
  • R2-1 / R2-2 (persist-first family) — both REST routes are bridge-first with in-route displayName validation; the workspace-route fallback resolves getSessionLocation and persists only at the located state (no optimistic active-dir write), writing the PR sidecar before the rename.
  • R2-2/R2-3 — the upsertSessionPr queue cleanup absorbs rejections (next.then(cleanup, cleanup)).
  • R2-18 / R5-5 / R4-5 — isValidSessionId gates on both REST routes and the ACP handler; the primary-route gate is middleware before runtime resolution.
  • R2-19 — movePrSidecar merges split pairs by number instead of wedging.
  • R2-21 — .pr.json is in getCurrentSessionArtifactMoves.
  • R4-1 — the ACP handler seeds the entry from the sidecar before the mutation and replies with the persisted list.
  • R4-2 — fallback ordering: PR sidecar persisted before rename, catalog bump only when both succeed.
  • R4-3 — toRpcError has the InvalidSessionMetadataError arm.
  • R5-1/2/3 — sidecar hydration reads are best-effort at all three mutation sites.
  • R5-4 — pr.url control characters rejected at all four layers.
  • R2-10 — cap constants unified on SESSION_PR_URL_MAX_LENGTH / SESSION_PR_LIST_LIMIT (route and bridge import them; messages interpolate them).
  • R1-5 — the pr branch writes the attributable stderr audit record.
  • R2-15 / R2-16 / R5-6 — badge keydown intercepts Enter only; the tooltip filters through isExternalOpenUrl and keys rows ${index}-${pr.number}.
  • R5-7/8 — dialog fixtures annotated DaemonSessionSummary[].

Focused test runs (all green):

  • npm run build — passed (full-workspace TypeScript compilation).
  • packages/coresession-pr-service.test.ts, sessionService.test.ts, config.test.ts: 801/801 passed.
  • packages/cliserver.test.ts, multi-workspace-sessions.test.ts, acp-http/transport.test.ts, acp-http/dispatch-error.test.ts: 1548/1548 passed.
  • packages/acp-bridgebridge.test.ts: 771/771 passed.
  • packages/sdk-typescriptDaemonClient.test.ts, daemonEvents.test.ts: 458/458 passed.
  • packages/web-shell — SessionOverviewPanel, GitDialog, Delete/Release/Resume dialogs, SessionRow, SessionDetailsTooltip, sessionSearch: 77/77 passed.

Thread dispositions

  • resolved-comments.txt lists the 85 inline findings re-verified this round as resolved in code by commits a60b0db1, 29e3a1f2/3f66720d, 5419305a, and f489d2c6 — no new commit was needed.
  • Left unresolved on purpose (the maintainer's recorded disposition stays visible on-thread): R1-11 (ACP-dispatch sidecar-write e2e test deferred until the ACP-over-WS harness exists) and R1-20 (GitDialog binding targets the connected session by design — cross-workspace updates are rejected 409, no ghost sessions). No reply was added to either thread because the maintainer's reason is already posted there.
  • The reviewer's round 2–6 "deferred under the convergence posture" items remain recorded and were not requested in this round; no action was taken on them.
中文说明

Autofix 本轮:无需代码改动 —— 所有阻断项已在 f489d2c6 复核确认修复

本轮在时间预算告警下运行,因此没有重试累积的全部反馈批次,而是重新分诊反馈、并直接对照 head 代码树核实各项声称的修复。第 1–5 轮审查中的每一条 Critical / Request-changes 发现都已确认存在于当前 HEAD 的代码中,聚焦见证测试套件重跑全绿。第 6 轮自动审查在同一 HEAD 上未发布任何新发现;针对该 HEAD 的沙箱深度验证返回 merge-ready(94/94 脚本断言、6/6 变异全部击杀)。

验证(本轮在 HEAD f489d2c6 上实际执行的全部命令)

代码核查(每项修复均在 head 树中定位并通读):

  • R1-1 —— mergeLiveSessionSummary 按 PR 号显式合并 prs(live url 优先、保留 sidecar 历史);富集函数中的死分支已移除。
  • R1-2 —— PR 绑定事件回显当前 displayName,SDK fold 不再清空标题。
  • R2-1 / R2-2(persist-first 系列)—— 两条 REST 路由均改为 bridge 先行、路由内预校验 displayName;workspace 路由的兜底先解析 getSessionLocation、只写入定位到的归档状态(不再乐观写 active 目录),且 PR sidecar 在 rename 之前持久化。
  • R2-2/R2-3 —— upsertSessionPr 队列清理自行消化 rejection(next.then(cleanup, cleanup))。
  • R2-18 / R5-5 / R4-5 —— 两条 REST 路由与 ACP 处理器均有 isValidSessionId 门禁;主路由的门禁以中间件形式置于 runtime 解析之前。
  • R2-19 —— movePrSidecar 对分裂文件按号码合并,不再永久卡住。
  • R2-21 —— .pr.json 已加入 getCurrentSessionArtifactMoves
  • R4-1 —— ACP 处理器在变更前从 sidecar 水合 entry,并以持久化列表应答。
  • R4-2 —— 兜底顺序:PR sidecar 先于 rename 持久化,仅当两步都成功才推进 catalog 版本。
  • R4-3 —— toRpcError 已含 InvalidSessionMetadataError 分支。
  • R5-1/2/3 —— 三个变更点的 sidecar 水合读取均为尽力而为。
  • R5-4 —— pr.url 控制字符在四个层全部拒绝。
  • R2-10 —— 上限常量统一为 SESSION_PR_URL_MAX_LENGTH / SESSION_PR_LIST_LIMIT(路由与 bridge 均导入、错误文案内插常量)。
  • R1-5 —— pr 分支写入带客户端归因的 stderr 审计记录。
  • R2-15 / R2-16 / R5-6 —— badge 的 keydown 只拦截 Enter;tooltip 经 isExternalOpenUrl 过滤、行 key 为 ${index}-${pr.number}
  • R5-7/8 —— 对话框 fixture 已标注 DaemonSessionSummary[]

聚焦测试运行(全部通过):

  • npm run build —— 通过(全工作区 TypeScript 编译)。
  • packages/core —— session-pr-service.test.tssessionService.test.tsconfig.test.ts:801/801 通过。
  • packages/cli —— server.test.tsmulti-workspace-sessions.test.tsacp-http/transport.test.tsacp-http/dispatch-error.test.ts:1548/1548 通过。
  • packages/acp-bridge —— bridge.test.ts:771/771 通过。
  • packages/sdk-typescript —— DaemonClient.test.tsdaemonEvents.test.ts:458/458 通过。
  • packages/web-shell —— SessionOverviewPanel、GitDialog、Delete/Release/Resume 对话框、SessionRow、SessionDetailsTooltip、sessionSearch:77/77 通过。

线程处置

  • resolved-comments.txt 列出本轮复核确认已在代码中解决(由提交 a60b0db129e3a1f2/3f66720d5419305af489d2c6 修复)的 85 条行内发现 —— 本轮无需新提交。
  • 有意保持未解决(让维护者已记录的处置意见继续留在线程上):R1-11(ACP dispatch sidecar 写入的端到端测试,推迟到 ACP-over-WS 测试骨架可用时)与 R1-20(GitDialog 绑定目标按设计取连接会话 —— 跨工作区更新会被 409 拒绝,不创建幽灵会话)。这两条线程均已有维护者的理由说明,故未追加机器人回复。
  • 审查方第 2–6 轮"收敛姿态下延后"的条目维持记录状态,本轮未要求修改,故未作处理。

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


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

@wenshao
wenshao enabled auto-merge August 22, 2026 01:29

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Verified the load-bearing surfaces against the head:

  • Validation chain is defense-in-depth: route-level UUID gate on :id (strict regex, before runtime resolution so the contract is config-independent), positive-integer/http(s)/2048/control-char checks on the PR body at route + bridge + sidecar-reader + SDK layers, and the renderers re-filter through isExternalOpenUrl before any href. No URL consumer reaches shell, raw HTML, redirects, or server-side fetch.
  • Lifecycle is consistent: sidecar delete mirrors the worktree sidecar cleanup at both delete sites, archive/unarchive moves merge split pairs via mergeSessionPrLists, and listing enrichment merges persisted history with live bindings after restart. Cross-workspace gating is unchanged (REST mutate({strict}) + ACP connection-scoped ownership), and malformed/hand-edited sidecars fail soft everywhere.
  • Bounded-list semantics hold: re-bind refreshes and moves to latest, search matches older bindings, badge derives latest from the openable tail.

Two non-blocking observations worth a follow-up if you agree:

  1. readSessionPrs enforces per-entry shape but not the 10-entry cap — the cap is write-side only. A hand-written sidecar with thousands of shape-valid entries would flow uncapped through enrichPrSidecars into list responses. Truncating to the tail on read would make the read path enforce the same bound.
  2. If the hydrate read hits a transient non-ENOENT I/O error, seedSessionPrs later no-ops once the live entry has any prs, so session_metadata_updated events carry only this daemon lifetime's bindings until restart even after I/O recovers (HTTP replies and the listing API self-heal). Merging instead of skipping in the seed would close that window.

CI is green on head (unit, web-shell E2E, review-pr, secret scan all pass).

@wenshao
wenshao added this pull request to the merge queue Aug 22, 2026
Merged via the queue into QwenLM:main with commit e2de7d2 Aug 22, 2026
1084 of 1120 checks passed

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

Re-review — feat(web-shell): session PR binding (head f489d2c6)

Verdict: 💬 Comment — no new blocking issues. This is a re-review on top of my earlier (since-dismissed) approval at 29e3a1f2a. Everything that landed afterwards — the R5 fix commits — was read line-by-line and verified against the head tree. The PR is in an approvable state from my side.

Scope

Deep pass over the source delta since 29e3a1f2a (~500 lines: four-layer control-character rejection, seedSessionPrs hydration, traversal gate moved before runtime resolution, fallback write reordering, per-entry SDK filter on the primary client, tooltip keying/filtering, Enter-only badge keydown) plus its tests; a regression audit of all round-1..3 fixes at the current head; and an independent adjudication of the items still open from rounds 5/6.

🎉 What stands out

  1. All ten prior fixes are intact at this head. Spot witnesses: explicit by-number prs merge in mergeLiveSessionSummary (session-list.ts:499-511); displayName echo on the pr-only event (bridge.ts:9781-9787); rejection-safe queue cleanup next.then(cleanup, cleanup) (session-pr-service.ts:169-178); both REST routes bridge-first with the conflict branch throwing before any persist (session.ts:5333-5346, 5488-5513); isValidSessionId gates on both routes; ${sessionId}.pr.json in the session-dir migration list (config.ts:5067-5075); split-pair merge in movePrSidecar (sessionService.ts:1078-1090); shared SESSION_PR_* constants; pr-gated ACP upsert (dispatch.ts:2959-2964).
  2. The hydration design is concurrency-safe. seedSessionPrs only fires when the live entry holds no bindings (bridge.ts:9803-9809), so a seeding race can never overwrite a completed bind; the per-path upsert queue serializes the read-modify-write; hydrating from the active path is correct for route 1 and the ACP path (their sessions are necessarily live), and archive/restore moves the sidecar under an exclusive lock, so the file cannot migrate mid-handler.
  3. The fallback write reordering (sidecar before rename) is the right call. The old window — rename persisted while the sidecar write failed, with the error response never announcing the rename — is gone; the remaining window (sidecar persisted, rename failed) heals on idempotent retry because the upsert is number-keyed. The failure-ordering rationale is inlined as a comment (session.ts:5518-5522), which is exactly how this kind of invariant should be pinned.
  4. Making archiveState required on enrichPrSidecars turns a future wrong-chats-dir call site into a compile error instead of silently dropping every binding; both current call sites pass a state matching their own listing (session-list.ts:572, 1243).
  5. The Enter-only keydown fix is correct. Picker-dialog listbox navigation (arrows/Home/End/Space) bubbles again, while Enter still cannot double-trigger dialog confirmation — the listbox hook independently yields Enter/Space when focus is on an anchor (SessionPrBadge.tsx:57-63).

Independently confirmed — already recorded in earlier rounds, not new findings

Both items appear in the round-5/6 deferred lists. I re-derived them independently on the current head and confirm they are real; I agree with the non-blocking treatment and record them here only for completeness.

1. 🟢 All-or-nothing sidecar validation + destructive rewritesession-pr-service.ts:72-76, 108, 160. isValidSessionPrList uses every(), so one bad entry makes readSessionPrs return null, and the next upsertSessionPr rewrites the file from existing = [] — permanently wiping the legitimate history, not merely hiding it at read time. New twist introduced by this round: R5 added control-character rejection to isValidSessionPr, so a sidecar written by a pre-R5 build of this very branch (when all four layers accepted control characters in urls) becomes wholesale invalid after the update. Trigger is confined to dev-machine sidecars and hand edits (the feature is unreleased and the current daemon cannot write an invalid entry). Direction: filter per-entry at read instead of all-or-nothing.

2. 🟢 Raw-cast ingress bypasses the per-entry validation standard this PR itself establishedDaemonClient.ts:2657 (session list page), 2922 (sessionStatus), 6129-6140 (WorkspaceDaemonClient.updateSessionMetadata) all cast as T with no per-entry gate, while the primary client's metadata path filters entry-by-entry with an explicit threat-model comment (5366-5371). The render layer guards url values (isExternalOpenUrl) but not entry shape: a hostile-or-buggy prs: [null] throws a TypeError in SessionPrBadge.tsx:33 / sessionSearch.ts:18 during render, taking down that workspace's sidebar list and the three picker dialogs. The daemon is a locally trusted component, so this is defense-in-depth; the cheapest fix is gating the three consumers with isDaemonSessionPrInfo.

Bottom line

No new issues; all prior fixes held; the R5 delta is sound. 💬 Comment — nothing blocks re-approval from my side.


中文

结论:💬 评论 — 无新阻塞问题。 这是在我上一轮(已被新推送搁置的)批准(29e3a1f2a)之上的复审。此后落地的 R5 修复提交已逐行读完并对照 head tree 验证。就我这一侧,PR 已处于可批准状态。

范围

精读 29e3a1f2a 之后的源码增量(约 500 行:四层控制字符拒绝、seedSessionPrs 回灌、traversal 门禁前移到运行时解析之前、fallback 写入重排、主 SDK 客户端逐条过滤、tooltip keying/过滤、badge onKeyDown 只吞 Enter)及配套测试;对第 1-3 轮全部修复在当前 head 做回归审计;独立复核第 5/6 轮遗留项。

🎉 值得肯定

  1. 此前 10 项修复在当前 head 全部完好。 抽查 witness:mergeLiveSessionSummary 按 number 显式合并 prssession-list.ts:499-511);pr-only 事件回显 displayNamebridge.ts:9781-9787);队列清理 next.then(cleanup, cleanup) 不泄漏 rejection(session-pr-service.ts:169-178);两条 REST 路由 bridge-first 且 conflict 分支在任何持久化之前抛出(session.ts:5333-53465488-5513);两条路由入口 isValidSessionId 门禁;会话目录迁移清单含 ${sessionId}.pr.jsonconfig.ts:5067-5075);movePrSidecar 分裂 pair 合并(sessionService.ts:1078-1090);共用 SESSION_PR_* 常量;ACP upsert 按入参 pr 门控(dispatch.ts:2959-2964)。
  2. 回灌(hydration)设计并发安全。 seedSessionPrs 仅在 live entry 无绑定时生效(bridge.ts:9803-9809),seed 竞态不可能覆盖已完成的绑定;upsert 按路径队列串行读-改-写;route 1 与 ACP 路径的会话必然 live,读 active 路径正确;归档/恢复在 exclusive 锁下连同 sidecar 一起迁移,handler 期间文件不会被搬走。
  3. fallback 写入重排(sidecar 先于 rename)方向正确。 旧窗口——rename 已落盘而 sidecar 写失败、错误响应只字不提改名——已消除;剩余窗口(sidecar 已写、rename 失败)可被幂等重试治愈(upsert 按 number 去重)。失败序推理已内联为注释(session.ts:5518-5522),这类不变量就该这样钉住。
  4. enrichPrSidecarsarchiveState 改为必填,把未来"从错误 chats 目录富集"的调用点错误变成编译错误;现有两处调用传值均与各自列表归档态一致(session-list.ts:5721243)。
  5. onKeyDown 只吞 Enter 的修复正确。 picker 对话框的 listbox 键盘导航(方向键/Home/End/Space)恢复冒泡,Enter 也不会重复触发对话框确认——listbox hook 在焦点位于 anchor 时本就会让出 Enter/Space(SessionPrBadge.tsx:57-63)。

独立确认 —— 早前轮次已记录,非新 finding

以下两条均已出现在第 5/6 轮 deferred 列表中。我在当前 head 独立重新推导出同样结论,确认属实;认同不阻塞处理,此处仅为完整性记录。

1. 🟢 sidecar 校验 all-or-nothing + 破坏性重写 —— session-pr-service.ts:72-76108160isValidSessionPrListevery(),一条坏数据使 readSessionPrs 返回 null,下一次 upsertSessionPrexisting = [] 重写文件——合法历史被永久抹掉,而非仅读时隐藏。本轮新增的转折:R5 给 isValidSessionPr 加了控制字符拒绝,因此本分支 R5 之前构建(当时四层都接受含控制字符的 url)写出的 sidecar 升级后整体变为非法。触发面仅限开发机 sidecar 与手工篡改(功能未发布,当前 daemon 写不出非法条目)。方向:读时逐条过滤替代整表判定。

2. 🟢 raw-cast 入口绕过了本 PR 自己建立的逐条校验标准 —— DaemonClient.ts:2657(会话列表分页)、2922(sessionStatus)、6129-6140WorkspaceDaemonClient.updateSessionMetadata)均为 as T 裸 cast,无逐条门禁;而主客户端 metadata 路径逐条过滤且注释写明威胁模型(5366-5371)。渲染层只兜底 url 值(isExternalOpenUrl),不兜底条目形状:恶意/故障 daemon 返回 prs: [null] 会在 SessionPrBadge.tsx:33 / sessionSearch.ts:18 的 render 阶段抛 TypeError,击垮该工作区的侧栏列表与三个选择对话框。daemon 是本地信任组件,此条属纵深防御;最省的修法是在三个消费点用 isDaemonSessionPrInfo 门禁。

小结

无新问题;既有修复全部保持;R5 增量健全。💬 评论 —— 就我这一侧没有任何阻塞项。

wenshao added a commit that referenced this pull request Aug 22, 2026
Verification run (fixes + #9370's workflow) cut the Windows failures
from 69 to 5 and left the macOS lane's infra error. Follow-ups:

- daemon-git-worktree-guard resolvePhysicalPath: splitting an absolute
  Windows path yields the drive as a segment (C:), which path.join
  glued back onto the root as C:\C:. Walk only the part past the root.
  Exposed by the first round's tokenizer fix, which let intact drive
  paths reach this code for the first time.
- scratch-tree tests: the git-created gitfile refuses in-place
  overwrite on Windows even after clearing the read-only attribute;
  delete and recreate instead.
- bridge.test stderr audit assertion: the line prints the session id
  through JSON.stringify, escaping Windows backslashes; match the
  escaped spelling (test added this morning by #9543, landed after the
  baseline census).
- managed-scratch 'root replaced' test: dev/ino identity is not
  reliably observable on every Windows volume; state the precondition
  and skip where the swap is indistinguishable.
- scripts vitest suite: the unhandled onTaskUpdate worker RPC timeout
  is deterministic on the macOS runners with every test green; stop
  letting unhandled errors fail this suite while test failures stay
  fatal, and drop the stale claim that the pool override removal
  fixed it.
wenshao added a commit that referenced this pull request Aug 22, 2026
Verification run (fixes + #9370's workflow) cut the Windows failures
from 69 to 5 and left the macOS lane's infra error. Follow-ups:

- daemon-git-worktree-guard resolvePhysicalPath: splitting an absolute
  Windows path yields the drive as a segment (C:), which path.join
  glued back onto the root as C:\C:. Walk only the part past the root.
  Exposed by the first round's tokenizer fix, which let intact drive
  paths reach this code for the first time.
- scratch-tree tests: the git-created gitfile refuses in-place
  overwrite on Windows even after clearing the read-only attribute;
  delete and recreate instead.
- bridge.test stderr audit assertion: the line prints the session id
  through JSON.stringify, escaping Windows backslashes; match the
  escaped spelling (test added this morning by #9543, landed after the
  baseline census).
- managed-scratch 'root replaced' test: dev/ino identity is not
  reliably observable on every Windows volume; state the precondition
  and skip where the swap is indistinguishable.
- scripts vitest suite: the unhandled onTaskUpdate worker RPC timeout
  is deterministic on the macOS runners with every test green; stop
  letting unhandled errors fail this suite while test failures stay
  fatal, and drop the stale claim that the pool override removal
  fixed it.
wenshao added a commit that referenced this pull request Aug 22, 2026
Verification run (fixes + #9370's workflow) cut the Windows failures
from 69 to 5 and left the macOS lane's infra error. Follow-ups:

- daemon-git-worktree-guard resolvePhysicalPath: splitting an absolute
  Windows path yields the drive as a segment (C:), which path.join
  glued back onto the root as C:\C:. Walk only the part past the root.
  Exposed by the first round's tokenizer fix, which let intact drive
  paths reach this code for the first time.
- scratch-tree tests: the git-created gitfile refuses in-place
  overwrite on Windows even after clearing the read-only attribute;
  delete and recreate instead.
- bridge.test stderr audit assertion: the line prints the session id
  through JSON.stringify, escaping Windows backslashes; match the
  escaped spelling (test added this morning by #9543, landed after the
  baseline census).
- managed-scratch 'root replaced' test: dev/ino identity is not
  reliably observable on every Windows volume; state the precondition
  and skip where the swap is indistinguishable.
- scripts vitest suite: the unhandled onTaskUpdate worker RPC timeout
  is deterministic on the macOS runners with every test green; stop
  letting unhandled errors fail this suite while test failures stay
  fatal, and drop the stale claim that the pool override removal
  fixed it.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.0.

pull Bot pushed a commit to edisplay/qwen-code that referenced this pull request Aug 25, 2026
* fix: repair the Windows and macOS test lane failures

The platform lanes have been dark since 2026-07-02 (gated on a merge
queue that is not enabled); reviving them in QwenLM#9370 exposed these
pre-existing failures. 72 failing tests across 16 files, all traced to
platform assumptions:

Product fixes (2):
- daemon-git-worktree-guard: on Windows a backslash is a path
  separator, not a POSIX escape. The shell-quote tokenizer consumed
  `\x` pairs, mangling `C:\repo\sub` into a relative word — false
  denials for legitimate commands AND undetected relocations for
  backslash-relative ones. Preserve unquoted backslashes before
  tokenisation on win32.
- acpAgent isOwnerOnlyDirectory: hard-returning false on win32
  disabled Live managed relocation entirely (Node exposes no
  ownership bits there). Rest on the structural checks — symlink
  rejection and dev/ino identity across the realpath round trip —
  the same trade-off serve/live/discovery.ts already makes.

Test-fixture fixes (the product code was already Windows-correct):
- server.test Live catalog roots use the host-native path shape
  (path.resolve equality proof fails for POSIX literals on win32)
- review cleanup suite pins POSIX node:path semantics for its
  literal-keyed mocks; fetch-pr resume budget uses a native tmpdir;
  scratch-tree clears the DOS read-only attribute before overwriting
  a git-created gitfile; worktree-list assertions compare
  slash-normalized (git prints forward slashes on Windows)
- mode-bit (0600) assertions skip on win32 (no POSIX permission
  bits; every read side already skips its mode check there)
- O_NOFOLLOW symlink test and the unescapePath no-op test skip on
  win32; sidecar errno injection uses a portable NUL byte; Footer
  exact-hint text is platform-conditional (win32 indicator is
  8 columns shorter, shifting the flex shrink by one)

scripts vitest suite: drop the fixed 8-16 worker floor that
oversubscribes the 3-core macOS runners — the main thread stalled
past the 60s worker RPC timeout (onTaskUpdate), exiting 1 with every
test green.

* fix: second round of platform lane repairs from CI verification

Verification run (fixes + QwenLM#9370's workflow) cut the Windows failures
from 69 to 5 and left the macOS lane's infra error. Follow-ups:

- daemon-git-worktree-guard resolvePhysicalPath: splitting an absolute
  Windows path yields the drive as a segment (C:), which path.join
  glued back onto the root as C:\C:. Walk only the part past the root.
  Exposed by the first round's tokenizer fix, which let intact drive
  paths reach this code for the first time.
- scratch-tree tests: the git-created gitfile refuses in-place
  overwrite on Windows even after clearing the read-only attribute;
  delete and recreate instead.
- bridge.test stderr audit assertion: the line prints the session id
  through JSON.stringify, escaping Windows backslashes; match the
  escaped spelling (test added this morning by QwenLM#9543, landed after the
  baseline census).
- managed-scratch 'root replaced' test: dev/ino identity is not
  reliably observable on every Windows volume; state the precondition
  and skip where the swap is indistinguishable.
- scripts vitest suite: the unhandled onTaskUpdate worker RPC timeout
  is deterministic on the macOS runners with every test green; stop
  letting unhandled errors fail this suite while test failures stay
  fatal, and drop the stale claim that the pool override removal
  fixed it.

* fix(ci): skip coverage report generation on non-Linux CI

CI consumes coverage only from the ubuntu lane: the artifact upload and
the coverage comment both pin coverage-reports-*-ubuntu-latest. On the
Windows runners the v8 report generation for 800+ files stalls the
vitest main thread past the 60s worker RPC budget at the end of an
all-green cli run, exiting the lane 1 (observed in verification run
32569004418). Skip coverage on non-Linux CI; local runs keep it.

* fix(ci): stop all-green cli/core runs exiting red on RPC timeout

The Windows lane's third verification round repeated the failure with
coverage already disabled: 866 cli test files green, then the worker
onTaskUpdate RPC budget (60s, hardcoded in vitest's bundled birpc)
expired under runner resource pressure and the unhandled error exited
the lane 1. Extend the scripts suite's treatment to the two big package
suites: test failures stay fatal, unhandled errors do not.

* fix: address review on the win32 guard pre-pass and lane configs

R1-1 (Critical): the win32 pre-pass escaped the character after every
unquoted backslash, so whitespace after a trailing separator glued the
next word into the -C value — a second -C/--git-dir/-c parked there
vanished from the analysis while cmd.exe still split the argv at the
whitespace, allowing a destructive mutation outside the boundary. The
tokenizer treats `\<space>` as an escaped space even after an even
number of backslashes, so escaping forward can never express "literal
backslash, then word boundary". Escape the backslash alone instead: a
double-quoted backslash before whitespace and cmd boundary characters
(; | & < > ( )) keeps them their separator role, and a plain escaped
backslash elsewhere. Verified token boundaries for the attack shape,
its tab variant, trailing-separator-before-flag, UNC, chained -C, and
quoted paths; added win32-only guard tests for the boundary shapes.

R1-2/3/4: gate dangerouslyIgnoreUnhandledErrors to non-Linux — the
ubuntu lane and Linux local runs keep the unhandled-error signal.

R1-5: pin the deterministic win32 footer truncation ('queu') instead
of skipping the content assertion there.

R1-6: build the Live conversations fixture root with the file's
documented path.resolve(path.sep, ...) convention instead of a
hardcoded C: literal.

* fix: three Windows lane failures from recent main commits

Verification of the revived lanes surfaced three failures introduced
by commits that landed while the lanes were dark:

- isSameFile compared dev/ino unconditionally; on volumes that report
  ino 0 (or a colliding value) for every file it equated distinct
  files. Treat an unverifiable inode like core's hasVerifiableInode
  convention and fall back to canonical spellings — losing hard-link
  identity there, but never equating distinct files. The hard-link
  test skips where the volume exposes no inode.
- drive's bound-address recipe test rmSync'd its temp dir while the
  backgrounded service still held it (EBUSY on Windows); shorten the
  service's self-exit timer and retry the removal.
- the FileReadCache seeding test collided under one dev:ino key when
  the volume reports the same inode for both MEMORY.md indexes; skip
  where inode identity is not real.

* fix: address round-2 review on identity fail-closed checks and the win32 guard pre-pass

* fix(cli): deny cmd.exe rewrite syntax in the daemon git-worktree guard (QwenLM#9728)

* fix: address round-4 review on the cmd-rewrite denial reason and cmd-lane test gating

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address round-5 review by closing the divergent Windows shell surface structurally

The win32 cmd/PowerShell lanes were analysed through a POSIX text model
plus per-stage patches; each round closed one entrance of the divergence
family and the next found new ones. Fail closed on syntax whose lane
semantics diverge from the model (lone `&`, `( )`, cmd `#`/`;`/single
quotes, /s outer-quote strip, PowerShell `--%`/`''` doubling), normalize
the whole command text once before any stage reads it, drop the bash
shadow model on lanes where the syntax defines nothing, and stop scoping
PowerShell pipeline stages as subshells.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address round-6 review by gating bash-semantics tests off the win32 lane and failing closed on nested Windows shells

R6-1: the whole-text divergent-syntax gate denied ~20 ungated bash-semantics
expectations on the real win32/cmd merge lane (41 failures reproduced under a
lane-spoof harness). Gate those blocks off the win32 non-bash lanes, splitting
mixed blocks so lane-safe pins keep running there, and commit the lane-spoof
harness so the whole guard suite runs as the win32/cmd merge lane on every
lane.

R5-1 (partial): cmd/powershell/pwsh invocations now fail closed on the
undecidable-payload denial on every lane — their payloads are parsed by a
grammar the POSIX text model cannot read (closes the -EncodedCommand and
nested cmd /c entrances probed at this head). The remaining class closure is
escalated to the maintainer as a product/scope decision.

* fix: address round-7 review by gating the Windows-shell fail-closed rule to the win32 platform

R7-1: the round-6 WINDOWS_SHELL_PROGRAMS branch failed closed on every lane,
which denied benign cross-platform PowerShell on POSIX daemons (pwsh
-NoProfile -Command Write-Output hello reproduced denied on the unresolved
reason at this head). Gate the rule to platform win32 — cmd.exe and Windows
PowerShell only exist there, and they stay reachable from win32 Git Bash
sessions, so the gate is platform-wide rather than windowsNative; on POSIX
the same names keep the base stance of ordinary unmodelled programs. Move
the fail-closed pins onto spoofed win32 lanes, add a win32 Git Bash lane pin
so the entrance stays closed there, and add the POSIX benign-pwsh regression
test.

* fix(cli): close the cmd.exe state-persisting entrances in the daemon guard

Probe-verified bypasses (review R5-1): cmd.exe builtins persist state
into every later &&-chained command, and the analysis modelled none of
them — `set GIT_WORK_TREE=<outside>&& git reset --hard` and
`chdir <outside> && git reset --hard` both analysed cwd-local while
the executed command relocated outside the boundary.

cmd's state-persisting builtins are a closed set, so this enumerates
them instead of chasing individual shapes:

- `set VAR=value` / `setx VAR value` carry the semantics of a POSIX
  `export VAR=value` for every later segment; route them through that
  machinery (GIT_* keys become relocations, unresolvable keys fail
  closed). Non-assignment forms (`set /p`, dynamic operands) fail
  closed.
- `chdir` joins the cd family (cmd's synonym), `/D` consumed as the
  drive-switch option.
- `path` and `doskey` rewrite which executable a bare name resolves
  to — unresolvable, failed closed.
- copy/mklink/move/robocopy/xcopy join the relinking programs on
  win32, and the text relocation markers learn `chdir`.

PowerShell-only entrances (function definitions, New-Item function:,
@-splatting) already fail closed through the unmodelled-syntax gate;
the new branches stay gated off Git-Bash sessions, where these words
are ordinary POSIX commands. Pinned by new tests in the win32-lane
harness, which spoofs win32/cmd on every platform.

* Revert "fix(cli): close the cmd.exe state-persisting entrances in the daemon guard"

This reverts commit a8f137a.

* Reapply "fix(cli): close the cmd.exe state-persisting entrances in the daemon guard"

This reverts commit b888a42.

* fix: address round-10 review by closing the Windows-lane guard entrances and the ino-0 case fold

* fix(cli): repair the round-10 build rejection by mapping chdir variants exhaustively

* fix(cli): make the /MIR relink-switch denial assertion lane-independent

The assertion spelled the unresolvable target as POSIX '/MIR', but the win32
lane resolves it through path.win32/realpathNearestExistingAsync into a
backslash spelling ('\\MIR'/'C:\\MIR'), so the new test failed only on the
merge_group-gated test_windows lane. Match either separator spelling.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
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.

6 participants