Skip to content

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

Merged
wenshao merged 30 commits into
QwenLM:mainfrom
wenshao:feat/session-pr-state-refresh
Aug 27, 2026
Merged

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

Conversation

@wenshao

@wenshao wenshao commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

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

Why it's needed

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

Reviewer Test Plan

How to verify

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

Evidence (Before & After)

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

Tested on

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

Environment

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

Risk & Scope

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

Linked Issues

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

中文说明

这个 PR 做了什么

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

为什么需要

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

审查者测试计划

如何验证

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

证据(前后对比)

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

测试平台

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

环境

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

风险与范围

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

关联

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

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

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

Copy link
Copy Markdown
Collaborator

Thanks for the re-run — this pass reviews the one new commit that landed since the last approval.

Template looks good ✓

Problem: observed, not theoretical — real operator data (6,454 persisted sessions across 25 workspaces with zero bindings before backfill; badges keeping their open accent long after a PR merged), follow-up to #9543 which is on main.

Direction: aligned — completes the "which session produced PR N, and is it still open?" story for serve-mode operators.

Size: 35 files, +6,529/−129 — ≈1,653 production lines (291 of them in packages/core/src/**), ≈4,975 test lines, 30 docs. Past the 1,000-line advisory mark; still one cohesive feature hanging off a single state field. Author is a maintainer, so the two-tier core gate is exempt per AGENTS.md — size noted for transparency only.

Approach: unchanged and still sound — reuse-first (extends fetchGitHubPullRequests with a slim field set, reuses the #9543 sidecar/upsert machinery, keeps the sweep off the session-list polling path). The new commit continues that pattern: it serializes sidecar commits through the existing SessionArchiveCoordinator shared lane and the existing workspace generation guard rather than inventing new locking.

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

Since the last triage run: one commit — f580c3b ("run PR-state sidecar commits under the generation guard and archive lane"), addressing round-18's concurrency findings. A retired workspace generation can no longer run gh with stale env or commit/notify after replacement, and a backfill/sweep sidecar write can no longer interleave with an archive/delete rename of the same session. Reviewed in detail in Stage 2; the earlier approval was dismissed by the push, as expected.

Moving on to code review. 🔍

中文说明

感谢重新触发——本次审查的是上次批准之后新落的一个提交。

模板完整 ✓

问题:已观测到的真实问题,不是理论性的——真实运营数据(25 个 workspace 的 6454 个存量会话回填前零绑定;PR 合入后 badge 长期保持 open 高亮),是已合入 main 的 #9543 的后续。

方向:对齐——补齐 serve 模式操作者"哪个会话产出了 PR N,它是否还开着"的闭环。

规模:35 个文件、+6,529/−129——约 1,653 行生产代码(其中 291 行在 packages/core/src/**)、约 4,975 行测试、30 行文档。超过 1000 行的大 PR 提示线;仍然是挂在同一个 state 字段上的内聚功能。作者是维护者,按 AGENTS.md 豁免两层核心门禁——规模仅作透明记录。

方案:未变且仍然合理——优先复用(以 slim 字段集扩展 fetchGitHubPullRequests、复用 #9543 的 sidecar/upsert 机制、定时任务避开会话列表轮询热路径)。新提交延续了这一模式:通过既有SessionArchiveCoordinator 共享通道和既有的 workspace generation guard 串行化 sidecar 写入,而不是发明新的锁。

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

上次 triage 之后:一个提交——f580c3b("PR-state sidecar 写入纳入 generation guard 与 archive 通道"),处理第 18 轮审查的并发发现。被替换的 workspace 代际不会再用过期 env 跑 gh、也不会在替换后继续提交/通知;回填/定时刷新的 sidecar 写入不会再与同一会话的 archive/delete 改名交错。详见 Stage 2;先前的批准已按预期被这次 push 撤销(dismiss)。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review — delta pass

The previous pass reviewed 8e8d3fb end to end; this pass covers the single commit pushed since, f580c3b ("run PR-state sidecar commits under the generation guard and archive lane") — 6 files, ≈177 production lines and ≈427 test lines, all inside packages/cli/src/serve/**.

Independent sketch first: the two round-18 holes are (1) a runtime snapshotted from the registry before a long await can be retired mid-run (trust/env replacement), so gh could run with stale env and sidecars/notifications could land for a generation that no longer owns the workspace; and (2) archive/delete rename a session's transcript and sidecar outside the PR mutation queue, so a queued sidecar write could race the move. What I would have done: assert the existing WorkspaceGenerationGuard around the gh fetch, each write, and the bridge notification; and run each commit through the existing SessionArchiveCoordinator shared lane that archive/delete already take exclusively. No new synchronization.

The PR does exactly that. Verified against the code at this head:

  • backfillWorkspaceSessionPrs and refreshWorkspaceSessionPrStates both assert generationGuard.assertOpen() before the gh fetch, inside the queued write (replaceSessionPrs planner / updateSessionPrStates assertCanCommit, which the core service runs inside the mutation queue right before the irreversible write), and before invalidateWorkspaceSessionListCache + markSessionCatalogChanged — so a retired generation neither queries with stale env, nor writes, nor notifies its obsolete bridge.
  • Sidecar commits now run under archiveCoordinator.runSharedMany([sessionId], commit). The coordinator's semantics make this a real mutex: runExclusiveMany (what archive/unarchive/delete use) refuses with SessionArchivingError while a shared holder exists, and runSharedMany refuses while an exclusive transition is in flight. In backfill the shared lane spans the rewrite and the live-entry sync, so an archive move cannot split the list the sync publishes.
  • Error taxonomy is right: generation-closed and daemon-draining escape as whole-run failures (backfill rethrows both; the sweep rethrows DaemonDrainingError to stop the tick), while an archive-lane conflict is counted as one unwritable session and re-planned on the next run/tick. The route reports a generation-retired workspace as a per-workspace error and skips the cache invalidation + notification.
  • Wiring checks out: createServeApp constructs the one coordinator, parks it on app.locals.sessionArchiveCoordinator, and passes it to the backfill route; the refresh timer resolves it per tick via getArchiveCoordinator because the app is built after the timer starts — the same pattern the metrics sampler uses for acpHandle.
  • Eleven new witness tests pin all of it at unit and route level: no-gh-after-retirement, mid-run/mid-sweep closure commits nothing, lane-held sessions defer to the next run and converge, the lane is held across rewrite+sync (an archive attempt in the gap is refused and succeeds after release), a sealed coordinator rejects the run, and the timer resolves the coordinator at tick time.

Two non-blocking observations, neither worth changing:

  1. The sweep swallows WorkspaceGenerationClosedError per target (backfill rethrows it), so a generation retired mid-sweep iterates the remaining targets before the final pre-notify assert throws. No write can land after closure — every commit asserts — and the "commits nothing and notifies nobody" test pins exactly this shape; it just costs a few no-op iterations.
  2. Under daemon drain, the backfill route keeps iterating remaining workspaces (each fails fast with a per-workspace DaemonDrainingError entry). Bounded by the workspace count and consistent with the per-workspace error reporting.

No Critical findings in the delta. The dismissed approval on 8e8d3fb is re-issued against this head in Stage 3.

Testing evidence

Evidence carried: the PR's own CI on the reviewed head, fetched via the API (unattended run — no PR code executed here), plus the sandboxed /verify record.

The push triggered two duplicate CI batches one second apart; the concurrency group cancelled the first, and the second batch is fully green — 6/6 pull_request workflows completed, none failed:

Check Conclusion
Qwen Code CI ✅ success — Test (ubuntu-latest, Node 22.x) green (full unit suite incl. all collocated suites of this PR); Desktop Shell ubuntu+windows and web-shell E2E Smoke green; macos/windows unit lanes and the CLI-integration lane skipped per standing repo config
Serve A/B ✅ success
Web-shell Visuals ✅ success
Qwen Live Host CI ✅ success (Live Host (macos-latest))
Security Checks ✅ success (Dependency CVE audit, TruffleHog)
SDK Java ✅ success (Java 11/17/21 matrix, Real daemon E2E / Java 11)

The cancelled entries in the check list all belong to the superseded duplicate batch; the only still-running check is the bot's own review-pr orchestration job, which is not PR CI. Skipped lanes are the same standing config the previous pass saw — not caused by this PR.

Sandboxed verification: the /verify run against the previous head 8e8d3fb passed 58/58 scripted assertions (A/B against base, mutation matrix killed all three central guards, security battery held) — merge-ready. A fresh /verify run for this head was triggered with the current /triage comment and is still in flight; its report will post itself when it lands. It would re-substantiate at f580c3b what the unit tests pin but CI alone cannot: that the new archive-lane serialization and generation-guard aborts hold on a live daemon (live backfill deferring an archiving session, a trust replacement mid-run leaving no stale-env gh call and no notification).

Not verified: Windows/Linux runtime behavior (author reports them unexercised locally; no OS-specific code paths beyond existing gh spawning). Badge dimming/tooltip labels rest on collocated unit tests plus the green Web-shell Visuals lane — no visual diff of the dimmed state was produced in this run. The author's real-daemon numbers (575 bindings, 272/342 coverage) remain the author's claim.

中文说明

代码审查——增量轮

上一轮完整审查了 8e8d3fb;本轮只覆盖其后新落的一个提交 f580c3b("PR-state sidecar 写入纳入 generation guard 与 archive 通道")——6 个文件、约 177 行生产代码、约 427 行测试,全部在 packages/cli/src/serve/** 内。

先给独立方案: 第 18 轮的两个漏洞是(1)运行时在长 await 之前已从注册表快照,中途被替换(信任/环境更换)后,仍可能用旧环境跑 gh、为不再拥有该 workspace 的代际写入 sidecar 或发通知;(2)archive/delete 在 PR mutation queue 之外对会话的 transcript 与 sidecar 改名,排队中的 sidecar 写入可能与改名交错。我会怎么做:在 gh 拉取、每次写入、bridge 通知外围断言既有的 WorkspaceGenerationGuard;每次提交走 archive/delete 已经独占持有的既有 SessionArchiveCoordinator 共享通道。不引入新的同步机制。

PR 正是这么做的。 已在此 head 的代码中核实:回填与定时刷新都在 gh 拉取前、排队写入内部(planner / assertCanCommit,核心服务在不可逆写入前于 mutation queue 内执行)、以及缓存失效与 markSessionCatalogChanged 之前断言 assertOpen()——退役代际既不查询、也不写入、更不通知其过期 bridge。sidecar 提交改走 runSharedMany([sessionId], commit):协调器语义构成真正的互斥——独占方在共享持有者存在时拒绝,共享方在独占转换进行时拒绝;回填的共享通道横跨重写与 live-entry 同步,archive 无法拆分同步发布的列表。错误分类正确:代际关闭与 daemon drain 作为整轮失败上抛,archive 通道冲突只计一次不可写、下轮重新规划;路由把代际退役的 workspace 报为逐 workspace 错误并跳过通知。接线核实无误:createServeApp 创建唯一协调器、挂在 app.locals、传给回填路由;定时器因 app 晚于定时器构建而每 tick 解析协调器——与 metrics sampler 读取 acpHandle 的模式一致。11 个新见证测试在单测与路由层钉住上述全部行为。

两条非阻断观察,均不值得改:定时刷新逐目标吞掉 WorkspaceGenerationClosedError(回填是上抛),代际中途退役会空转剩余目标后才在通知前的断言抛出——关闭后任何写入都不可能发生,且已有测试钉住该形态;daemon drain 时回填路由继续遍历剩余 workspace(各自快速失败为逐条错误),受 workspace 数量限制且与逐条报错一致。

增量中未发现 Critical。8e8d3fb 上被 dismiss 的批准将在 Stage 3 重新钉在本 head 上。

测试证据

本轮携带的证据:通过 API 获取的该 head 自身 CI(无人值守运行——此处不执行 PR 代码),以及沙箱 /verify 记录。

本次 push 间隔一秒触发了两批重复 CI;并发组取消了先到的那批,后到的一批全绿——6/6 个 pull_request workflow 完成、零失败:ubuntu 完整单测套件(含本 PR 全部同置套件)、Desktop Shell 双平台、web-shell E2E Smoke、Serve A/B、Web-shell Visuals、Live Host macOS、安全扫描(CVE + TruffleHog)、SDK Java 全矩阵。被取消的 check 全部属于被取代的重复批次;唯一仍在运行的 review-pr 是机器人编排任务,不是 PR CI。跳过的 lanes(macos/windows 单测、CLI 集成)与上一轮相同,是仓库既有配置,与本 PR 无关。

沙箱验证:针对上一 head 8e8d3fb/verify 以 58/58 脚本断言通过(对 base 的 A/B、变异矩阵杀死三个核心守卫、安全电池全部保持)——可合入。针对 head 的新一轮 /verify 已随本次 /triage 触发、仍在运行,完成后会自行发布报告。它将在此 head 上重新证实单测钉住但 CI 本身无法证实的一点:新的 archive 通道串行化与代际守卫在真实 daemon 上成立。

未验证:Windows/Linux 运行时行为(作者未本地验证,除既有 gh 派生外无 OS 特有路径);badge 变暗/tooltip 标注依赖同置单测与绿色 Web-shell Visuals lane,本轮未产出变暗状态的视觉对比;作者真实数据(575 条绑定、272/342 覆盖)仍是作者声明。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the delta commit closes the last known holes the same way I would have (reusing the existing guard and lane, not inventing new synchronization), everything is green on this head, and what keeps this at 4 rather than 5 is a short list of tracked non-blocking deferrals plus a verification run still in flight.

Stepping back: the previous pass approved 8e8d3fb at 4/5; the push that dismissed that approval is a single, focused fix for round-18's two concurrency findings, and it is exactly the minimal shape that fix should take — generation-guard assertions around the gh fetch, every write, and every notification; each commit under the archive lane archive/delete already take exclusively; error paths that distinguish "whole run must stop" from "defer this one session". I verified the lane semantics and the per-tick coordinator wiring in the code, not just the diff, and the eleven new tests pin the behaviors, including the awkward ones (lane held across rewrite+sync, mid-sweep closure, sealed coordinator). CI is fully green on this head — the cancelled entries are a duplicate batch the concurrency group superseded, and the skipped lanes are standing repo config. The sandboxed /verify already passed 58/58 on the previous head; the fresh run for this head is in flight and will post its own report, so a maintainer who wants to wait for it has that option — branch protection still requires the human approvals regardless.

The residual items are unchanged from the prior pass and remain Suggestions, not blockers: the merged badge is conveyed by color alone, the never-passed injection parameter, and full-transcript reads in the on-demand scan. Plus two new non-blocking observations from this pass (the sweep's per-target swallow of generation-closed, and drain-time per-workspace iteration in backfill) — both defensible as written, both documented above.

Approving, pinned to the reviewed commit.

中文说明

信心:4/5 —— 增量提交用我会采取的同样方式(复用既有的 guard 与通道,而不是发明新的同步机制)关闭了最后两个已知漏洞;此 head 全绿;之所以是 4 而不是 5,是因为仍有一份已跟踪的非阻断延期清单,且一轮验证仍在运行中。

整体回顾:上一轮以 4/5 批准了 8e8d3fb;这次使批准失效的 push 是针对第 18 轮两个并发发现的单一聚焦修复,且正是该修复应有的最小形态——在 gh 拉取、每次写入、每次通知外围做代际守卫断言;每次提交置于 archive/delete 已独占持有的通道之下;错误路径区分"整轮必须停止"与"推迟这一个会话"。我不只看 diff,还在代码里核实了通道语义与每 tick 解析协调器的接线;11 个新测试钉住了这些行为,包括棘手的场景(通道横跨重写+同步、刷新中途关闭、协调器已封存)。此 head 的 CI 全绿——被取消的条目是被并发组取代的重复批次,跳过的 lanes 是仓库既有配置。沙箱 /verify 已在上一 head 以 58/58 通过;针对本 head 的新一轮仍在运行、会自行发布报告,想等它的维护者可以等——分支保护本来就还要求人工批准。

遗留项与上一轮相同,仍是 Suggestion 而非阻塞:merged badge 仅以颜色传达、从未被传入的注入参数、按需扫描对整个 transcript 的读取。加上本轮两条非阻断观察(定时刷新逐目标吞掉代际关闭、drain 时回填逐 workspace 迭代)——两者按现状都站得住脚,已在上文说明。

批准,钉在已审查的提交上。

Qwen Code · qwen3.8-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown
Contributor

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

中文

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

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

Screenshots · before / after

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

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

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

Qwen Code · serve A/B

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

wenshao commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

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

Copy link
Copy Markdown
Collaborator

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

中文说明

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

中文说明

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

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

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

Comment thread packages/cli/src/serve/server/session-pr-refresh.ts Outdated
Comment thread packages/cli/src/serve/routes/session-pr-backfill.ts Outdated
Comment thread packages/cli/src/serve/server/session-list.ts Outdated
Comment thread packages/cli/src/serve/routes/session-pr-backfill.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/serve/server/session-pr-refresh.ts
Comment thread packages/cli/src/serve/server/session-pr-refresh.ts
Comment thread packages/cli/src/serve/server/session-pr-refresh.ts Outdated
Comment thread packages/cli/src/serve/server/session-pr-refresh.ts
@qwen-code-dev-bot

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

Copy link
Copy Markdown
Collaborator

AutoFix round 5 finishedview run. See this round's report below.

中文说明

AutoFix 第 5 轮已完成 —— 查看运行。本轮报告见下方。

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

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

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

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

Copy link
Copy Markdown
Collaborator

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

Review round summary — PR #9729

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

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

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

Suggestions addressed this round

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

Review bodies

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

Deferred to the next round

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

Verification

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

审查轮次总结 — PR #9729

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

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

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

本轮处理的 Suggestion

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

审查主体评论

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

延迟到下一轮

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

验证

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

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

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

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

中文说明

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

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

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

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

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

Copy link
Copy Markdown
Collaborator

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

Review feedback round summary — PR #9729

Round outcome: 8 findings addressed in code (all 4 Critical findings plus 4 Suggestions), 14 Suggestions deferred to the next round by the per-round batch bound. No conflict resolution was requested (--conflict false); no merge was performed. The review-level note [rv:5001313937] ("Partially reviewed — gaps disclosed") carries no new actionable finding beyond the inline items triaged below.

Addressed findings

id sev disposition
[rc:3837316195] R1-5 Critical Fixed: first-write-wins branch map + witness test
[rc:3837316199] R2-2 Critical Fixed: fast path merges the PR sidecar + witness test
[rc:3837316201] R2-1 Critical Fixed: win32/root guard on the permission-based test
[rc:3837316202] R2-3 Critical Fixed: project-membership filter restored on the sweep + witness test
[rc:3837316235] R1-2 Suggestion Fixed: 400 message names the state constraint + witness test
[rc:3837316226] R2-6 Suggestion Fixed: over-cap slice reserves the convention slot + witness test
[rc:3837316239] R2-9 Suggestion Fixed: sibling test seeding closed pins the skip-absent invariant
[rc:3837316206] R2-10 Suggestion Fixed: seam assertion pins { state: 'all', limit: 500, slim: true }

R1-5 — branch mapping bound the oldest PR on reused head branches

Confirmed the premise in code: the slim fetch omits updatedAt, so parseGhPrList's sort is a stable no-op and the list keeps gh's newest-first order; the unconditional branchToNumber.set then let the oldest PR win a reused branch. The map is now first-write-wins (!branchToNumber.has(...)), so the newest PR owns the branch. New test binds the newest PR when several share one head branch seeds [pr 250 open, pr 10 merged] on one head branch and asserts only 250 binds. Mutation probe: the test fails against the pre-round code.

R2-2 — live-only fast path rendered bind-time PR state

listLiveWorkspaceSessionsForResponse returned the raw bridge rows and never read the sidecar, violating the invariant this PR establishes with liveOnlySummary. The fast path is now async, runs inside the resolved runtime base dir (it receives runtimeBaseDir and signal from the route), and routes every row carrying prs through the same liveOnlySummary merge the persisted paths use; rows without bindings keep the zero-I/O shape. New test reads the PR sidecar for live-only sessions on the live-only fast path seeds a merged sidecar against an open live entry and asserts the row renders merged. Mutation probe: the test fails against the pre-round code. The 121 multi-workspace route tests still pass against the changed gate.

R2-1 — permission-based test failed on Windows and root runs

The chmod-based test keeps sweeping archived sessions when a sidecar write fails now uses the repo convention it.skipIf(process.platform === 'win32' || process.getuid?.() === 0) (same shape as conversation-runtime-ownership.test.ts, scratch-tree.test.ts, and others). No behavior change; the test still runs and passes on non-root POSIX lanes.

R2-3 — sweep could rewrite sidecars of colliding projects

Confirmed: sanitizeCwd maps every non-alphanumeric to -, so …/my-app and …/my.app share one chats dir, and the sidecar-driven enumeration dropped the sessionBelongsToCurrentProject filter that the old transcript-driven path applied. Added SessionService.sessionPrSidecarBelongsToCurrentProject(sessionId, archiveState) in core: when the transcript exists, its head must pass the same membership rule listSessions applies; a missing transcript stays inconclusive so pre-flush sidecars keep refreshing (the exact behavior the sidecar-driven rewrite was introduced for). The sweep now skips foreign sidecars before reading them. New test does not rewrite sidecars owned by a colliding project builds two runtimes on colliding cwds sharing one base dir and asserts { scanned: 0, updated: 0 }, no gh call, and the foreign sidecar untouched. Mutation probe: the test fails against the pre-round code.

R1-2 — invalid-state 400 never mentioned state

The parseSessionPrBody rejection message now ends with "and an optional state of open, merged, or closed". New test 400 message names the state constraint for an invalid pr state posts state: 'draft' with valid number/url and asserts the 400 code/field and that the message contains `state`. Mutation probe: the test fails against the pre-round code. The bridge validator twin (R1-1) stays out of scope as it is outside this round's diff anchors.

R2-6 — over-cap slice evicted the convention number first

When a session resolves more than SESSION_PR_LIST_LIMIT candidates, the tail slice dropped the front of the array — exactly where the authoritative pr-<N> convention number sits. The slice now reserves the convention slot and evicts the oldest branch-mapped numbers instead ([convention, ...numbers.slice(1).slice(-(limit-1))]; the construction already dedupes, so no double bind). New test keeps the convention number bound when candidates exceed the cap seeds a pr-50 slug plus 12 branch-mapped PRs and asserts bound: 10, overLimit: 3 with 50 present. Mutation probe: the test fails against the pre-round code. The existing cap/idempotency test (no convention number) exercises the unchanged else branch and stays green.

R2-9 — skip-absent invariant had no mutation-sensitive witness

Added the sibling case keeps a closed binding closed when its number is missing from the gh page: seeding closed (instead of open) means a regression defaulting gh-absent numbers to 'open' would rewrite the entry and turn the test red, which the original open-seeded case cannot detect. No source change.

R2-10 — load-bearing fetch options were unasserted

The first backfill test now asserts fetchGitHubPullRequests is called with { state: 'all', limit: 500, slim: true }, so a regression to state: 'open' (silently unbinding merged-PR worktrees) turns the suite red.

Deferred findings (next round)

Deferred solely by the ~8-findings-per-round bound after the four Critical fixes and their witnesses consumed the batch — none declined; each remains valid and planned:

  • [rc:3837316209] R1-7 — assert the already-bound sidecar is left byte-identical
  • [rc:3837316210] R2-11 — pagination (1001-session) coverage for the scan loop
  • [rc:3837316212] R2-7 — strengthen the writeErrors test (mocked rejection, two candidates, pre-existing sidecar)
  • [rc:3837316215] R1-37 — route-level per-workspace error-isolation test
  • [rc:3837316217] R1-14 — route-level test with a real seeded trusted workspace
  • [rc:3837316219] R1-3 — strip userinfo/port on ssh://→https rewrite
  • [rc:3837316223] R1-16 — avoid full transcript reads before candidacy
  • [rc:3837316224] R2-5 — draft→open normalization tests in both files
  • [rc:3837316231] R1-19 — cache the remote-URL lookup miss
  • [rc:3837316233] R2-4 — test the unresolved-convention branch
  • [rc:3837316237] R2-12 — EISDIR test for the liveOnlySummary read guard
  • [rc:3837316238] R1-35 — corrupt-sidecar skip coverage for the sweep
  • [rc:3837316241] R1-24 — make updated count actual rewrites
  • [rc:3837316242] R1-11 — fake-timer tests for startSessionPrRefreshTimer

Growth note: the Critical fixes required witness tests, so the test-line budget for this counting window is expected to be over at the next measurement; the additions are the witnesses themselves, each mutation-probed below.

Verification

Commands actually run this round (all from the repository root unless noted):

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0, whole repo)
  • npx prettier --write on all 8 changed files, then prettier --check on the last edited file — clean (the commit's pre-commit hook also re-ran lint-staged and passed)
  • cd packages/cli && npx vitest run src/serve/routes/session-pr-backfill.test.ts src/serve/server/session-pr-refresh.test.ts src/serve/server.test.ts — 3 files, 1122 passed (23 backfill incl. 2 new, 16 refresh incl. 2 new + 1 guarded, 1083 server incl. 2 new)
  • cd packages/cli && npx vitest run src/serve/multi-workspace-sessions.test.ts121 passed (route-level regression net for the changed listing gate)
  • cd packages/core && npx vitest run src/services/sessionService.test.ts src/services/session-pr-service.test.ts210 passed (touched core service)
  • Mutation probes (pre-round sources restored, new tests kept, then fixes restored):
    • binds the newest PR when several share one head branchfailed pre-round (R1-5 witness)
    • keeps the convention number bound when candidates exceed the capfailed pre-round (R2-6 witness)
    • does not rewrite sidecars owned by a colliding projectfailed pre-round (R2-3 witness)
    • reads the PR sidecar for live-only sessions on the live-only fast pathfailed pre-round (R2-2 witness)
    • 400 message names the state constraint for an invalid pr statefailed pre-round (R1-2 witness)
    • all other pre-existing tests stayed green on pre-round code during the probes; every suite re-ran green after restoring the fixes

Commit: b2ba72d035 fix(serve): correct PR binding map, sweep isolation, and fast-path state (8 files, +393/−79).

中文说明

审查反馈轮次总结 — PR #9729

本轮结果:8 条发现已在代码中处理(全部 4 条 Critical 加 4 条 Suggestion),14 条 Suggestion 按每轮批次上限顺延到下一轮。未要求冲突解决(--conflict false),未执行任何合并。审查级备注 [rv:5001313937]("部分审查完成——缺口已披露")除下面逐条分诊的内联条目外,不包含新的可执行发现。

已处理的发现

id 级别 处置
[rc:3837316195] R1-5 Critical 已修复:分支映射改为 first-write-wins + 见证测试
[rc:3837316199] R2-2 Critical 已修复:快速路径合并 PR sidecar + 见证测试
[rc:3837316201] R2-1 Critical 已修复:基于权限的测试加上 win32/root 守卫
[rc:3837316202] R2-3 Critical 已修复:扫描恢复项目归属过滤 + 见证测试
[rc:3837316235] R1-2 Suggestion 已修复:400 消息指明 state 约束 + 见证测试
[rc:3837316226] R2-6 Suggestion 已修复:超限切片为约定编号保留槽位 + 见证测试
[rc:3837316239] R2-9 Suggestion 已修复:种子为 closed 的兄弟用例钉住"缺页即跳过"不变量
[rc:3837316206] R2-10 Suggestion 已修复:接缝断言钉住 { state: 'all', limit: 500, slim: true }

R1-5 — 复用头分支时分支映射绑定到最旧 PR

已在代码中确认前提:slim 抓取不含 updatedAtparseGhPrList 的排序是稳定的空操作,列表保持 gh 的最新在前顺序;无条件的 branchToNumber.set 使最旧 PR 赢得复用分支。映射现为 first-write-wins(!branchToNumber.has(...)),最新 PR 拥有该分支。新测试 binds the newest PR when several share one head branch 在同一条头分支上种子 [pr 250 open, pr 10 merged],断言只绑定 250。变异探针:该测试对修复前代码失败。

R2-2 — live-only 快速路径渲染绑定时刻的 PR 状态

listLiveWorkspaceSessionsForResponse 直接返回 bridge 原始行、从不读 sidecar,违反本 PR 用 liveOnlySummary 建立的不变量。快速路径现为 async,在解析后的 runtime 基目录内运行(路由传入 runtimeBaseDirsignal),并把每个携带 prs 的行送入与持久化路径相同的 liveOnlySummary 合并;无绑定的行保持零 I/O 形态。新测试 reads the PR sidecar for live-only sessions on the live-only fast path 种子 merged sidecar 与 open 的 live 条目,断言该行渲染为 merged。变异探针:该测试对修复前代码失败。121 个多工作区路由测试在改动后的门禁上仍全部通过。

R2-1 — 基于权限的测试在 Windows 与 root 下失败

基于 chmod 的测试 keeps sweeping archived sessions when a sidecar write fails 现采用仓库惯例 it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(与 conversation-runtime-ownership.test.tsscratch-tree.test.ts 等同形)。无行为变化;该测试在非 root 的 POSIX 通道上仍正常运行并通过。

R2-3 — 扫描可能改写碰撞项目的 sidecar

已确认:sanitizeCwd 把所有非字母数字映射为 -…/my-app…/my.app 共享同一 chats 目录,而 sidecar 驱动的枚举丢掉了旧的 transcript 驱动路径所施加的 sessionBelongsToCurrentProject 过滤。在 core 中新增 SessionService.sessionPrSidecarBelongsToCurrentProject(sessionId, archiveState):transcript 存在时,其首记录必须通过与 listSessions 相同的归属规则;transcript 缺失则保持不可判定,使 flush 前的 sidecar 仍可刷新(这正是引入 sidecar 驱动枚举要保护的行为)。扫描在读取之前跳过外部项目的 sidecar。新测试 does not rewrite sidecars owned by a colliding project 构造两个 cwd 碰撞、共享同一基目录的 runtime,断言 { scanned: 0, updated: 0 }、未调用 gh、外部 sidecar 原样保留。变异探针:该测试对修复前代码失败。

R1-2 — 非法 state 的 400 从不提及 state

parseSessionPrBody 的拒绝消息现以 "and an optional state of open, merged, or closed" 结尾。新测试 400 message names the state constraint for an invalid pr state 以合法 number/url 提交 state: 'draft',断言 400 的 code/field 且消息包含 `state`。变异探针:该测试对修复前代码失败。bridge 校验器的孪生问题(R1-1)因不在本轮 diff 锚点范围内,维持不处理。

R2-6 — 超限切片最先丢弃约定编号

当会话解析出超过 SESSION_PR_LIST_LIMIT 个候选时,尾部切片从数组前端丢弃——而权威的 pr-<N> 约定编号恰在最前端。切片现为约定编号保留槽位,改为驱逐最旧的分支映射编号([convention, ...numbers.slice(1).slice(-(limit-1))];构造过程已去重,不会重复绑定)。新测试 keeps the convention number bound when candidates exceed the cap 种子 pr-50 slug 加 12 个分支映射 PR,断言 bound: 10, overLimit: 3 且 50 在列。变异探针:该测试对修复前代码失败。既有上限/幂等测试(无约定编号)覆盖不变的 else 分支,保持绿色。

R2-9 — "缺页即跳过"不变量缺少对变异敏感的见证

新增兄弟用例 keeps a closed binding closed when its number is missing from the gh page:种子 closed(而非 open)后,把 gh 缺页编号默认为 'open' 的回归将改写该条目并使测试变红——原先 open 种子的用例无法发现该变异。无源码变更。

R2-10 — 关键抓取参数无断言

首个回填测试现断言 fetchGitHubPullRequests{ state: 'all', limit: 500, slim: true } 被调用,回归为 state: 'open'(静默破坏已合入 PR 的 worktree 绑定)将令测试变红。

顺延的发现(下一轮)

仅因每轮约 8 条的批次上限顺延——四条 Critical 修复及其见证占用了本批额度——没有一条被拒绝,均仍有效且已排期:

  • [rc:3837316209] R1-7 — 断言已绑定会话的 sidecar 逐字节未被改动
  • [rc:3837316210] R2-11 — 扫描循环的分页(1001 会话)覆盖
  • [rc:3837316212] R2-7 — 加强 writeErrors 测试(mock 拒绝、两个候选、已有 sidecar)
  • [rc:3837316215] R1-37 — 路由级每-workspace 错误隔离测试
  • [rc:3837316217] R1-14 — 以真实种子的受信任 workspace 做路由级测试
  • [rc:3837316219] R1-3 — ssh://→https 重写时剥离 userinfo/端口
  • [rc:3837316223] R1-16 — 候选判定前避免整读 transcript
  • [rc:3837316224] R2-5 — 两个文件各加 draft→open 归一化测试
  • [rc:3837316231] R1-19 — 缓存远端 URL 查询的失败结果
  • [rc:3837316233] R2-4 — 为未解析约定编号分支补测试
  • [rc:3837316237] R2-12 — liveOnlySummary 读取守卫的 EISDIR 测试
  • [rc:3837316238] R1-35 — 扫描对损坏 sidecar 跳过分支的覆盖
  • [rc:3837316241] R1-24 — 让 updated 统计实际改写数
  • [rc:3837316242] R1-11 — startSessionPrRefreshTimer 的 fake-timer 测试

增长说明:Critical 修复必须有见证测试,因此本计数窗口的测试行预算在下次度量时预计超限;新增行即见证本身,均经下方变异探针验证。

验证

本轮实际执行的命令(除注明外均在仓库根目录):

  • npm run build — 通过(exit 0)
  • npm run typecheck — 通过(exit 0)
  • npm run lint — 通过(exit 0,全仓库)
  • 对全部 8 个改动文件执行 npx prettier --write,最后编辑的文件再过 prettier --check — 干净(提交的 pre-commit 钩子亦重跑 lint-staged 并通过)
  • cd packages/cli && npx vitest run src/serve/routes/session-pr-backfill.test.ts src/serve/server/session-pr-refresh.test.ts src/serve/server.test.ts — 3 个文件,1122 通过(23 回填含 2 新增,16 刷新含 2 新增 + 1 守卫,1083 server 含 2 新增)
  • cd packages/cli && npx vitest run src/serve/multi-workspace-sessions.test.ts121 通过(针对改动列表门禁的路由级回归网)
  • cd packages/core && npx vitest run src/services/sessionService.test.ts src/services/session-pr-service.test.ts210 通过(被触碰的 core 服务)
  • 变异探针(还原修复前源码、保留新测试,随后恢复修复):
    • binds the newest PR when several share one head branch修复前失败(R1-5 见证)
    • keeps the convention number bound when candidates exceed the cap修复前失败(R2-6 见证)
    • does not rewrite sidecars owned by a colliding project修复前失败(R2-3 见证)
    • reads the PR sidecar for live-only sessions on the live-only fast path修复前失败(R2-2 见证)
    • 400 message names the state constraint for an invalid pr state修复前失败(R1-2 见证)
    • 探针期间所有既有测试在修复前代码上保持绿色;恢复修复后全部套件重跑为绿

提交:b2ba72d035 fix(serve): correct PR binding map, sweep isolation, and fast-path state(8 个文件,+393/−79)。

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

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


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

@wenshao
wenshao requested a review from yiliang114 August 27, 2026 03:53
@wenshao

wenshao commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao
wenshao enabled auto-merge August 27, 2026 04:44
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — the PR could not be built - workflow run

The PR could not be built because npm ci failed twice in a row before any verification started. This is treated as a PR failure verdict rather than an infrastructure failure.

中文 — 判定:❌ 不通过 · PR 构建失败

由于 npm ci 在验证开始前失败(连续两次),无法构建该 PR。判定为 PR 问题而非基础设施故障;安装日志见下方折叠块。

Install/build log

$ npm ci --prefer-offline --no-audit --progress=false --cache "$RUNNER_TEMP/npm-cache"
npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm warn deprecated prebuild-install@7.1.3: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm warn deprecated @humanwhocodes/config-array@0.13.0: Use @eslint/config-array instead
npm warn deprecated @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated eslint@8.57.1: This version is no longer supported. Please see https://eslint.org/version-support for other options.
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported

> @qwen-code/qwen-code@0.22.2 postinstall
> patch-package

patch-package 8.0.1
Applying patches...
ink@7.0.3 ✔

> @qwen-code/qwen-code@0.22.2 prepare
> node scripts/prepare.js


> @qwen-code/qwen-code@0.22.2 build
> cross-env NODE_OPTIONS="--max-old-space-size=3072" node scripts/build.js


> @qwen-code/qwen-code@0.22.2 generate
> node scripts/generate-git-commit-info.js


> @qwen-code/qwen-code-core@0.22.2 build
> node ../../scripts/build_package.js

src/core/client.telemetrySwap.test.ts(103,5): error TS1117: An object literal cannot have multiple properties with the same name.
node:internal/errors:983
  const err = new Error(message);
              ^

Error: Command failed: tsc --build
    at genericNodeError (node:internal/errors:983:15)
    at wrappedFn (node:internal/errors:537:14)
    at checkExecSyncError (node:child_process:916:11)
    at execSync (node:child_process:988:15)
    at file:///__w/qwen-code/qwen-code/scripts/build_package.js:38:1
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  status: 1,
  signal: null,
  output: [ null, null, null ],
  pid: 2418,
  stdout: null,
  stderr: null
}

Node.js v22.23.2
npm error Lifecycle script `build` failed with error:
npm error code 1
npm error path /__w/qwen-code/qwen-code/packages/core
npm error workspace @qwen-code/qwen-code-core@0.22.2
npm error location /__w/qwen-code/qwen-code/packages/core
npm error command failed
npm error command sh -c node ../../scripts/build_package.js
node:internal/errors:983
  const err = new Error(message);
              ^

Error: Command failed: npm run build --workspace=packages/core
    at genericNodeError (node:internal/errors:983:15)
    at wrappedFn (node:internal/errors:537:14)
    at checkExecSyncError (node:child_process:916:11)
    at execSync (node:child_process:988:15)
    at file:///__w/qwen-code/qwen-code/scripts/build.js:90:3
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  status: 1,
  signal: null,
  output: [ null, null, null ],
  pid: 2398,
  stdout: null,
  stderr: null
}

Node.js v22.23.2
prepare: npm run build exited with status 1
npm error code 1
npm error path /__w/qwen-code/qwen-code
npm error command failed
npm error command sh -c node scripts/prepare.js
npm error A complete log of this run can be found in: /__w/_temp/npm-cache/_logs/2026-08-27T04_44_49_807Z-debug-0.log

npm ci failed with exit code 1; retrying once.
$ npm ci --prefer-offline --no-audit --progress=false --cache "$RUNNER_TEMP/npm-cache"
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated eslint@8.57.1: This version is no longer supported. Please see https://eslint.org/version-support for other options.

> @qwen-code/qwen-code@0.22.2 postinstall
> patch-package

patch-package 8.0.1
Applying patches...
ink@7.0.3 ✔

> @qwen-code/qwen-code@0.22.2 prepare
> node scripts/prepare.js


> @qwen-code/qwen-code@0.22.2 build
> cross-env NODE_OPTIONS="--max-old-space-size=3072" node scripts/build.js


> @qwen-code/qwen-code@0.22.2 generate
> node scripts/generate-git-commit-info.js


> @qwen-code/qwen-code-core@0.22.2 build
> node ../../scripts/build_package.js

src/core/client.telemetrySwap.test.ts(103,5): error TS1117: An object literal cannot have multiple properties with the same name.
node:internal/errors:983
  const err = new Error(message);
              ^

Error: Command failed: tsc --build
    at genericNodeError (node:internal/errors:983:15)
    at wrappedFn (node:internal/errors:537:14)
    at checkExecSyncError (node:child_process:916:11)
    at execSync (node:child_process:988:15)
    at file:///__w/qwen-code/qwen-code/scripts/build_package.js:38:1
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  status: 1,
  signal: null,
  output: [ null, null, null ],
  pid: 2661,
  stdout: null,
  stderr: null
}

Node.js v22.23.2
npm error Lifecycle script `build` failed with error:
npm error code 1
npm error path /__w/qwen-code/qwen-code/packages/core
npm error workspace @qwen-code/qwen-code-core@0.22.2
npm error location /__w/qwen-code/qwen-code/packages/core
npm error command failed
npm error command sh -c node ../../scripts/build_package.js
node:internal/errors:983
  const err = new Error(message);
              ^

Error: Command failed: npm run build --workspace=packages/core
    at genericNodeError (node:internal/errors:983:15)
    at wrappedFn (node:internal/errors:537:14)
    at checkExecSyncError (node:child_process:916:11)
    at execSync (node:child_process:988:15)
    at file:///__w/qwen-code/qwen-code/scripts/build.js:90:3
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  status: 1,
  signal: null,
  output: [ null, null, null ],
  pid: 2641,
  stdout: null,
  stderr: null
}

Node.js v22.23.2
prepare: npm run build exited with status 1
npm error code 1
npm error path /__w/qwen-code/qwen-code
npm error command failed
npm error command sh -c node scripts/prepare.js
npm error A complete log of this run can be found in: /__w/_temp/npm-cache/_logs/2026-08-27T04_46_18_437Z-debug-0.log

npm ci failed with exit code 1 after 2 attempts.

Qwen Code · sandboxed verification

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

Re-review at head f580c3b (new commit since my previous approval):

Both P1 blockers raised at 8e8d3fb are closed — verified against the current sources:

  1. Generation guard: the sweep captures the runtime's generation guard and calls assertGenerationOpen() before the gh fetch, before each sidecar commit, and before bridge/catalog notifications (session-pr-refresh.ts:156,192,225), so a trust/env replacement retiring the runtime aborts the in-flight sweep instead of letting it write sidecars or notify the obsolete bridge.
  2. Archive-lane serialization: backfill and refresh commits now run under the app-wide SessionArchiveCoordinator via runSharedMany([sessionId], commit) (session-pr-backfill.ts:526, session-pr-refresh.ts:208; wired in server.ts:1096,2177). The lane spans the rewrite and the live-entry sync, and archive/delete take the exclusive lane across their renames, so the check-then-rename window is gone.

The earlier Critical batch (timer clamp, tie-safe enumeration, canonical-URL identity, cap double-billing, pr-0 gate, membership guard) remains intact at this head. CI is re-running on the new head; approving on the verified fixes.

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

@wenshao
wenshao added this pull request to the merge queue Aug 27, 2026
Merged via the queue into QwenLM:main with commit 032b907 Aug 27, 2026
192 of 222 checks passed
yiliang114 pushed a commit that referenced this pull request Aug 27, 2026
#10220 and #9729 independently gave this suite's config mock a
`getToolRegistry`, and merging them produced a clean Git merge and a
broken object literal: the same key twice, which is TS1117 ("An object
literal cannot have multiple properties with the same name").

PR CI does not catch it — it runs only `typecheck:integration`, not the
workspace `npm run typecheck` — so it fails for the next person to run
preflight rather than in a check anyone is watching.

Keep #10220's copy: its comment names the call that needs the registry
and the error an empty one avoids.
wenshao added a commit to AaronZ345/qwen-code that referenced this pull request Aug 27, 2026
…ck (QwenLM#10252)

QwenLM#10220 already added a getToolRegistry stub to the telemetry-swap client
test's config mock; the QwenLM#9729 branch carried an identical copy, and the
merge kept both. Duplicate property names in an object literal are a
TS1117 error that breaks tsc --build (and thus npm run build) on main
while vitest, which only transpiles, stayed green. Drop the duplicate;
the mock behavior is unchanged.
qqqys added a commit to qqqys/qwen-code that referenced this pull request Aug 27, 2026
The `Merge branch 'main'` in 1f670a1 brought main's TS1117 in with it:
`client.telemetrySwap.test.ts` declares `getToolRegistry` twice in the
same object literal, which fails `tsc` and kills `packages/core`'s build
before a single test runs — the Test job dies in "Install dependencies".

Not this branch's doing. Two main commits added the property
independently and neither saw the other:

  032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729)
  8241905 test(core): give the telemetry-swap client mock a
             getToolRegistry (QwenLM#10220)

`upstream/main` at 053f17b still carries both — checking that exact
file out here and running `tsc --noEmit -p packages/core` reproduces
`client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so main is
red on its own and every branch that merges it inherits this.

Keeps QwenLM#10220's copy — it was added for this purpose and carries the
explanation — and drops QwenLM#9729's incidental one. main needs the same
removal; this only unblocks the branch.

Verified: `tsc --noEmit -p packages/core` clean,
`client.telemetrySwap.test.ts` 10 passed.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1
qqqys added a commit to qqqys/qwen-code that referenced this pull request Aug 27, 2026
Merging upstream/main (053f17b) clears the 9 `client.telemetrySwap`
failures this branch had from predating QwenLM#10220, but main carries its own
TS1117 on the same file: two commits added `getToolRegistry` to the same
object literal independently and neither saw the other.

  032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729)
  8241905 test(core): give the telemetry-swap client mock a
             getToolRegistry (QwenLM#10220)

Checking that file out from upstream/main here and running
`tsc --noEmit -p packages/core` reproduces
`client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so taking
the merge unmodified would have traded 9 test failures for a build that
never reaches the tests at all.

Removed as part of the merge rather than left for a follow-up: keeps
QwenLM#10220's copy, which was added for this purpose and carries the
explanation, and drops QwenLM#9729's incidental one. main still needs the same
removal — this only keeps it out of the branch.

Verified after the merge: `tsc --noEmit` clean for both packages/core
and packages/cli; client.telemetrySwap 10 passed, Session.test.ts and
acpAgent.test.ts 1260 passed together.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1
wenshao added a commit to wenshao/qwen-code that referenced this pull request Aug 27, 2026
Integrates the merged QwenLM#9729 backfill/refresh evolution with QwenLM#9739's
gh-create binding and /review backfill source:

- backfill sources: user-typed /review commands + worktree pr-<N>
  convention; transcript gh-create traces and bare gitBranch removed
  (forgery vector / measured noise)
- cap planner (single locked RMW, canonical-URL identity, foreign-slot
  accounting) with repo-gated page consumption and async remote fallback
- upsert keeps write-time shape validation and provenance source; re-bind
  moves to latest with fresh createdAt (planner seam detection relies on it)
- summary merge stays eviction-aware; bridge re-bind canonical-URL based
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.3.

euntaek-hong pushed a commit to wrongbutworks/qwen-code that referenced this pull request Aug 28, 2026
…QwenLM#10119)

* feat(review): emit the Step 3A fan-out as a generated workflow script

`qwen review emit-workflow` builds the roster the same way
`agent-prompt --roster` does — same plan, same `buildLaunch`, same briefs,
same prompts, same recorded delivery evidence — and writes those prompts into
a runnable workflow script instead of printing thirteen blocks for the
orchestrator to copy. The script lives under the generated-scripts root the
Workflow loader trusts since QwenLM#9987 (`$QWEN_CODE_PROJECT_DIR/workflows/
generated/review/<session>/`), so it is never a slash command and needs no
cleanup sweep.

The generated file is a fixed body plus three literals — the roster, the
worktree pin, the subagent type. No logic is generated, only data, and the
tests execute the generator's real output. A territory fan-out (Step 3B) and
an unsized plan are refused before anything is written, because a workflow
returns every agent through one tool result and a roster that grows with
the diff is silently truncated there.

Nothing routes through the command yet: the skill still builds its roster
with `agent-prompt --roster`. Routing is its own change.

Part of QwenLM#8769.

Claude-Session: https://claude.ai/code/session_017cUwuTey4APA8wAyAM6ScS

* test(review): mirror the sandbox runtime in fan-out script tests (QwenLM#10119)

Address review feedback on the emit-workflow PR:

- Run the generated fan-out script in a vm context that mirrors the
  workflow sandbox's execution shape: the meta block is stripped instead
  of executed, the body is wrapped in the runtime's strict-mode async
  IIFE, only the sandbox globals are bound, and the agent stub applies
  the runtime's option gates.
- Extend the determinism guard to the sandbox's full Date surface
  (Date.parse, Date.UTC, bare Date calls).
- Exercise the failed-write half of the temp-and-rename cleanup.
- Cover the handler-level --rules happy path end to end.

* fix(review): harden the generated fan-out path and fail closed (QwenLM#10119)

Address the four review blockers on the emit-workflow PR:

- Share the loader's canonical-containment policy on the write side:
  refuse a symlinked directory from the generated root down to the
  session dir, and prove the canonical session dir stays under the
  canonical root, before any brief, prompt record, or script is written.
- Keep colliding sanitized session ids apart by appending a digest of
  the RAW session id to the readable prefix, so two concurrent sessions
  can never select the same script target for the same plan.
- Canonicalize an existing plan path with realpath before hashing it,
  so one plan keeps one script name under divergent spellings of the
  same file (macOS /var vs /private/var, or a link).
- Fail the fan-out whenever any required agent delivered nothing,
  instead of returning a shortened delivered list; a missing role is a
  failed step, not a shorter finding set.

* test(review): canonicalize fixtures and pin the review-dir symlink guard (QwenLM#10119)

* test(review): pin the emit-workflow cannot-read-the-plan guard (QwenLM#10119)

* test(review): pin emit-workflow dispatch guidance and clean-tree silence (QwenLM#10119)

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

* fix(core): drop the duplicated telemetry-swap mock property

The `Merge branch 'main'` in 1f670a1 brought main's TS1117 in with it:
`client.telemetrySwap.test.ts` declares `getToolRegistry` twice in the
same object literal, which fails `tsc` and kills `packages/core`'s build
before a single test runs — the Test job dies in "Install dependencies".

Not this branch's doing. Two main commits added the property
independently and neither saw the other:

  032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729)
  8241905 test(core): give the telemetry-swap client mock a
             getToolRegistry (QwenLM#10220)

`upstream/main` at 053f17b still carries both — checking that exact
file out here and running `tsc --noEmit -p packages/core` reproduces
`client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so main is
red on its own and every branch that merges it inherits this.

Keeps QwenLM#10220's copy — it was added for this purpose and carries the
explanation — and drops QwenLM#9729's incidental one. main needs the same
removal; this only unblocks the branch.

Verified: `tsc --noEmit -p packages/core` clean,
`client.telemetrySwap.test.ts` 10 passed.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1

* fix(review): hermetic probe fixtures and honest fan-out failure messages (QwenLM#10119)

The deterministic gate's `--changed` run collects test-efficacy.test.ts
through this PR's lib/paths.ts change, and its skip-worktree guard test
died on a persistent runner: the fixture's raw git calls inherited an
ambient discovery redirect (GIT_INDEX_FILE reproduces the exact failure)
while the guard reads a sanitized env, so the bit landed in another
index and the refusal never fired. Run every fixture git call with the
same sanitized env the guard uses.

Address the maintainer verification of this PR:

- A fan-out where EVERY agent failed prescribed re-running
  emit-workflow, which regenerates the identical script with the
  identical baked-in pin — a loop. Name the dispatch instead.
- The territory refusal claimed results are "silently truncated away";
  the scheduler persists large results and hands the model a pointer.
  Restate the real bound: the run's wall-clock caps and the fail-closed
  guard a per-chunk roster makes near-certain.
- A refused plan no longer leaves the empty session directory (blocker
  check moved ahead of the mkdir).
- The roster-key mismatch guard gains its missing test.

Also drop the duplicate getToolRegistry the merge of main brought into
client.telemetrySwap.test.ts (TS1117 broke `npm run build`; the two
entries were byte-identical).

* test(review): isolate test-efficacy fixtures from host git config (QwenLM#10119)

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
DragonnZhang pushed a commit to DragonnZhang/qwen-code that referenced this pull request Aug 29, 2026
…eport and wait out head-propagation lag (QwenLM#10117)

* fix(autofix): surface thread-resolution guard refusals in the round report and wait out head-propagation lag

The autofix thread-resolution pass could go completely dark on a PR:
every skip condition logged only a run-log ::warning::, so on PR QwenLM#9729
0 of 90 review threads were resolved across ~16 rounds with nothing on
the PR saying which guard refused (QwenLM#10106).

Observability: each refusing guard now records a stable name (salvage
merge, missing verified_head, verified_head mismatch, live-head drift,
thread state unproven, mutation post-check ambiguous), and both round
report arms carry one host-authored line naming the guard and counting
the selected / resolved / left-behind threads. Healthy rounds report
the positive count, so a dead mechanism is visible on the PR it fails
on. The line is fixed host strings plus counters only.

Root cause on QwenLM#9729: the PR read model is eventually consistent — a
headRefOid read seconds after the round's own push still returns the
previous head, so every pushed round declared live-head drift on one
stale read and silently skipped. The initial live-head equality check
now retries up to five times (bounded, digit-clamped test knob for the
delay) before declaring drift; the per-mutation guards stay
single-shot, because once the head was observed equal a later mismatch
means it actually moved.

Fixes QwenLM#10106

* fix(autofix): exact resolution counts; split drift from unreadable

- Deduplicate resolved ids and subtract threads another actor resolved
  before the per-thread guard, so "left for a later round" reports the
  actual residual instead of overstating it
- Name all-failed live-head reads live-head unreadable instead of
  live-head drift, so an expired PAT or API outage reads differently
  from a contributor push race on the PR-visible note
- Share one id-grammar normalization between the selected-count and the
  resolve loop instead of two independent spellings
- Pin the retry sleep and the zero-valid-id errexit guards in tests;
  every new guard verified by a mutation probe

* fix(autofix): thread-accurate resolution counts; gate head-proof reads (QwenLM#10117)

* fix(autofix): portable CR strip; thread-accurate counts on skip and break paths (QwenLM#10117)

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

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
ZijianZhang989 pushed a commit to ZijianZhang989/qwen-code that referenced this pull request Aug 29, 2026
…verify shard (QwenLM#10121)

* perf(review): dedup candidates against the carried ledger before the verify shard

On a re-review round the Step 3 finders re-derive findings earlier rounds
already reported, and every one of them rode a verify shard before the
posting layer dropped it as a duplicate — the most expensive point in the
pipeline, repeated every round while the original threads stay open
(measured on PR QwenLM#9729: rounds 12 and 13 confirmed 7 and 8 already-reported
Suggestions each, all verified first).

A new deterministic subcommand now runs between the finder union and the
shard write: it matches the pooled candidates against the carried ledger —
the recovered posted work list, plus the previous round's findings-artifact
deferral entries when that artifact exists locally — by file, anchor
proximity and claim similarity, and drops a match before any verifier is
spent on it. Matching is deliberately conservative (a kept-in-error
candidate just rides to verification where the posting-layer drop remains
the backstop; a Critical candidate never drops against a non-Critical
entry), and a dropped candidate's claim survives through the Step 6 ruling
of the entry it matched. compose-review reads the report the command wrote,
bound to the plan diff's hash, and discloses the set-aside count and ids in
the posted body mechanically.

Closes QwenLM#10105

* fix(review): harden the carried-ledger dedup against five review findings

The artifact admission loop read only the D<round>-<n> id shape: a
low-confidence terminal-only deferral (nothing downstream ever rules on
it) or one the fix run already closed (fixed / no_change_needed) could
absorb a matching candidate and vanish the claim — the unrecoverable
direction the module header names. The loop now reads confidence and
outcome and skips both excluded states.

The irreversible drop ran before and independently of the diff hash, so
an unhashable diff (fetch-pr's partition-failure fallback writes
diffPathAbsolute: null) set candidates aside with no disclosure
ledgerDedupFacts could ever bind to the round. The hash is now computed
first and an unhashable diff keeps every candidate; the unused --out
option that opened a second severance from the read side is dropped.
A repeat invocation over the same candidates file also double-counted
its drops in the posted disclosure; the accumulation merge now
identity-dedupes on (file, line, title, matchedId).

In compose-review, the set-aside disclosure shared trim rank 1 with the
deferral list, so every trim surface keyed on the rank named "the
deferred-findings list" and set bodyTrim.deferralList over rounds that
held no deferral list at all. The block gets its own rank (1.5) with
its own RANK_NAMES entry, shed after the deferral list and before the
copy-less disclosures. The dedup-only APPROVE also tripped the
lowSignal gate — "none of the N review agents reported a finding" over
the very rounds disclosing re-derived candidates — so the carve-out now
requires a zero set-aside count too.

In SKILL.md the pair-transition parenthetical merged the pair's findings
into the cumulative list before running the dedup, stranding every
dropped candidate under its unverified tag for the tag backstop to
relaunch. Both pair bullets (3A and 3B) now run the dedup before
anything merges or shards, merge only the kept list, and pin the
ordering with test assertions.

* fix(review): harden the carried-ledger dedup against seven review findings

* fix(review): exclude stand-in locations from deferral dedup and harden the report merge

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

* fix(review): fence deferral dedup to the side file's round and re-validate merged leftovers

* fix(review): normalize carried paths before the stand-in exclusion in ledger dedup

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
pull Bot pushed a commit to TKaxv-7S/qwen-code that referenced this pull request Aug 29, 2026
…aker for the takeover loop (QwenLM#10122)

* feat(autofix): consume the review convergence signals as a loop circuit breaker

The review side has published a machine-readable convergence diagnosis
since QwenLM#9623 (recommendation codes matched from measurement), but nothing
on the autofix/takeover side consumed it: on a non-converging PR the
review names the pathology round after round while the loop's own brakes
(round counter, growth budgets, failure caps) bound only the loop's own
telemetry — the brakes slow each side, neither can stop the pair (QwenLM#10107,
measured on QwenLM#9729: first diagnosis in round 3, ~13 more rounds after it).

Review CLI: the posted ledger marker now carries the round's matched
recommendation codes as `rec` — written off the same derivation the
result and the rendered paragraph use, shape-bounded on the streak rung,
write-only (parseLedger deliberately does not read it back).

Autofix workflow: a convergence-signal circuit breaker. The scan counts
the trailing streak of review-bot rounds whose codes intersect
CONVERGENCE_SIGNAL_CODES, bounded by max(window key, newest trusted-human
activity); at CONVERGENCE_BREAK_ROUNDS (default 3, repo-var tunable) the
PR parks — one bilingual notice, no dispatch, no label — and resumes when
a maintainer responds (fresh N-round runway), on /retry, or on
re-engagement. Prepare mirrors the reading live and discards via STALE,
like the growth-audit conflict park. 'land-and-defer' is deliberately not
a signal code: it is an exit, not a divergence.

Tests: serializer/compose coverage for the marker field, behavioral
replays of both workflow gates, a lockstep pin between them, and a
vocabulary pin of the workflow's code list inside RECOMMENDATION_CODES.

* fix(autofix): harden the convergence breaker per review round 1 (QwenLM#10122)

Guard the streak/codes jq programs against non-object ledger marker
payloads (a crash class verified on jq >= 1.7), reword the park notice
to claim the union of codes it actually reports, and execute the trip
branch in tests: threshold, park continue, once-per-boundary dedup,
boundary arms (reviews/max/bot-login), and the mirror's rearm-key and
malformed-knob fallbacks all gain mutation-probed witnesses. Also give
the two slow string-processing tests explicit timeouts — the deferred-
findings one timed out at the default 5s and failed the Test check.

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

* fix(autofix): harden the convergence breaker per review round 2 (QwenLM#10122)

* fix(autofix): restore the af-150 closing fence the round-1 insertion dropped (QwenLM#10122)

* fix(autofix): harden the convergence breaker per review rounds 3-5 (QwenLM#10122)

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

* fix(autofix): fail the convergence report guard closed on fetch failure (QwenLM#10122)

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

* fix(autofix): re-bump the workflow size baseline after the report-guard hardening (QwenLM#10122)

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

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
wenshao pushed a commit to netbrah/qwen-code-upstream-pr that referenced this pull request Aug 29, 2026
…ed concurrent spawn (QwenLM#10223)

* fix(core): compensating write removes ghost members from persisted team roster

When a concurrent teammate spawn fails after another spawn has already
persisted the shared members array, the failed member remains in
config.json as a ghost entry. Add a compensating writeTeamFile() on
the rollback path so persisted membership matches committed membership.

Fixes QwenLM#10208

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

* fix(core): serialize team-roster writes to close the ghost-member race window

Route both the success-path write and the failed-spawn compensating
write through a per-TeamManager promise-chain queue (persistTeamFile).
Each queued task serializes the live roster when it runs, so commits
land in call order and a stale snapshot can no longer out-commit a
later compensating write and re-persist a removed member (QwenLM#10208).
Also log via debug.warn when the best-effort compensating write itself
fails, so a resurfaced ghost member is diagnosable.

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

* test(core): witness roster write serialization and compensating-write failure

- Add an out-of-order completion case: hold the first (stale) roster
  write until the failed spawn's compensating write is queued, then
  assert the persisted roster excludes the rolled-back member. Goes
  red without the persistTeamFile queue.
- Add a case where the compensating write throws, asserting
  spawnTeammate still rejects with the original spawn error and the
  member is rolled back from in-memory state.
- Fix the stale comment describing the pre-fix behaviour.

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

* fix(core): drop the duplicated getToolRegistry mock key

QwenLM#10220 and QwenLM#9729 independently gave this suite's config mock a
`getToolRegistry`, and merging them produced a clean Git merge and a
broken object literal: the same key twice, which is TS1117 ("An object
literal cannot have multiple properties with the same name").

PR CI does not catch it — it runs only `typecheck:integration`, not the
workspace `npm run typecheck` — so it fails for the next person to run
preflight rather than in a check anyone is watching.

Keep QwenLM#10220's copy: its comment names the call that needs the registry
and the error an empty one avoids.

* test(core): reuse TeamCoordinationHarness in ghost-member tests

Replace the hand-rolled mkdtemp / teams-dir / TeamFile fixture /
FakeBackend().init() / new TeamManager(...) setup in all four
ghost-member regression tests with TeamCoordinationHarness.create(),
matching the sibling TeamManager.plan-approval.test.ts. Assertions are
unchanged; the deferred-spawn interception now targets h.backend, and
teardown uses harness.cleanup(). This keeps the fixture shape in sync
with the centrally maintained harness as TeamFile evolves.

The triplicated Storage vi.mock factory extraction suggested in review
is intentionally deferred: it spans 11+ test files and is out of scope
for this PR.

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

* refactor(core): keep persistTeamFile and its JSDoc attached

Move persistTeamFile (with its own JSDoc) above the spawnTeammate
doc block so the "Spawn a new teammate..." JSDoc is no longer
orphaned between the two and each comment stays attached to the
method it describes.

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

* fix(core): surface compensating-write failure to the leader

Keep the debug.warn but also deliver a concise <team_error> notice
through leaderMessageCallback (guarded by try/catch), mirroring the
fireAndForget pattern, since debug logging alone is invisible in
production. Also extract a single gateSpawns helper for the three
pasted spawn-gating wrappers in the ghost-member suite, and extend
the compensating-write-failure test to witness both the leader
notification and that a rejected write does not poison the queue
(a follow-up spawn persists normally afterwards).

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

* fix(core): gate the failed-spawn compensating write on prior roster writes

The compensating write serializes the live roster, which can still
contain sibling members whose own spawnTeammate is pending. When the
failed spawn is the first write to land, that persisted the in-flight
sibling — a ghost member if the process exits before the sibling
resolves, the QwenLM#10208 symptom in an interleaving that wrote nothing
before this change.

Track roster writes that have started (their snapshot point) in
`teamFileWritesStarted`, capture the counter at member push, and skip
the compensating write when no write could have persisted the failed
member. Writes that started inside the member's window still trigger
it, preserving the repair semantics.

Witness test: concurrent alpha/beta spawns with deferred gates, reject
alpha before any write lands, assert no roster write runs and the
persisted roster stays empty; removing the gate turns the test red.
The compensating-write-failure test switches to concurrent spawns so
its compensating write stays necessary under the gate.

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

* fix(core): snapshot team roster at the counted write start

`persistTeamFile` counted a write as started before `writeTeamFile`
serialized the roster: the real writer awaits `fs.mkdir` before
stringifying, so a member pushed during that fs hop was persisted by a
write the compensating-write gate counted as pre-push — the failed
member's compensating write was wrongly skipped and the ghost member
from QwenLM#10208 resurfaced on the next team load. Snapshot the roster
synchronously at the counted point and hand the writer that snapshot.

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

* test(core): pin that a throwing leader notice cannot mask the spawn error

The inner try/catch around the compensating-write failure notification
had no coverage: every existing setLeaderMessageCallback site installs
a non-throwing capture. If the callback throws while the compensating
write fails, the guard keeps spawnTeammate rejecting with the original
spawn error instead of the callback error.

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

* fix(core): reuse getErrorMessage for the compensating-write notice

The inner catch around the leader notification hand-rolled the cbErr
stringification; for a thrown non-Error with a message field — a shape
getErrorMessage explicitly handles — String(cbErr) logs [object
Object], dropping the diagnostic trail for a failed compensating write.

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

---------

Co-authored-by: root <root@vscode-sqlx011163220057.na131>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
pull Bot pushed a commit to Little-Star888/qwen-code that referenced this pull request Aug 31, 2026
* feat(serve): expose workflow tasks and controls

* fix(serve): align workflow capability gating

* fix(cli): reject live workflow history deletion

* fix(serve): start controlled workflows in background

* fix(serve): address workflow control review blockers

* fix(serve): make workflow history deletion race-safe

* fix(serve): enforce workspace trust on the daemon Workflow surfaces (QwenLM#9546)

* fix(serve): close cross-session workflow deletion races and untrusted task reads (QwenLM#9546)

- Session history merge now treats a persisted snapshot as authoritative
  over a stale callback cache, and retires the cache once the runner
  confirms the snapshot write (new registry snapshot-persisted hook), so
  a sibling's deletion is not resurrected on refresh.
- History deletion consults every sibling session's run registry (live
  entries and settling handles) before deleting from the shared store,
  and a successful deletion purges sibling terminal entries so retries
  cannot re-persist a deleted run.
- Workflow holds mirror the registry's hasRunningEntries: paused runs no
  longer pin the session indefinitely.
- The includeWorkflows opt-in is gated on workspace trust at the daemon
  boundary in both the ACP-HTTP dispatch and the REST tasks route,
  matching the fail-closed shape of the other workflow surfaces.

* fix(cli): close the four cross-session workflow-history consistency holes

Addresses R5-9, R7-4, R7-5 and R7-10 — all four Criticals open on this PR.
They are one family: a run's history can be deleted, retried, or listed
from any session, and each gate had a different idea of which runs exist.

R5-9 — the mutation claim is task-global, not session-scoped. Keyed
`sessionId\0taskId`, it serialized nothing that mattered: every session
shares one snapshot store. A sibling's retry passed canStart (`failed`,
no handle), took its own per-session claim, then awaited journal
load/compile before `register()`; a delete-history landing in that
structural window found the run terminal and handle-less in every
registry, removed the journal directory and snapshot, and answered
`{changed: true}` — after which the retry re-registered and its
settlement re-persisted the history the user was told was deleted. The
claim is now keyed by taskId alone and taken by delete-history, retry,
rerun and run-saved. `run-saved` keys off a saved workflow's NAME, so it
claims in its own `saved\0` namespace rather than colliding with runIds.

R7-4 — deletion tests membership against the uncapped merged set.
`buildSessionTasksStatus` serializes every registry entry
unconditionally while `refreshWorkflowHistory` truncates to
MAX_RETAINED_SNAPSHOTS by startTime, so a long run that settled after
~30 newer ones started stayed listed via the registry but fell out of
the window — terminal, handle-free, live in no sibling, and permanently
undeletable. `refreshWorkflowHistory` now records the merged id set
before the cap, and deletion gates on that, the registry, or the
unpersisted cache. `deleteWorkflowSnapshot` already tolerates an absent
target, so the wider gate cannot delete what is not there.

R7-5 — snapshot retirement is a latch. The registry's dispatch-drain
callbacks emit status changes on TERMINAL entries with no status gate,
and in-flight dispatches keep draining across the snapshot write, so a
terminal emission routinely landed after `notifySnapshotPersisted` had
retired the cache entry — re-inserting the run as "never persisted".
A sibling's deletion was then undone by the next refresh, which reads
"absent on disk, present in cache" as a pending write and republishes.
Persistence is now remembered per runId and `#rememberWorkflowHistory`
returns early for members; the latch releases when the runId goes active
again, so a genuine re-run is still cached.

R7-10 — the liveness gate sees runs whose session is gone. It iterated
`this.sessions` only, but close/kill/shutdown use force semantics and a
background run owns a detached controller, so after
`removeStoredSessionEntry` a still-settling run was invisible to the
gate and unreachable by the delete handler's sibling `removeTerminal`
loop: a sibling delete-history removed the LIVE run's journal and
snapshot, and the orphan's settlement write recreated it. Two halves —
`Session.dispose()` now aborts its workflow registry the way it already
aborts the agent registry (before the callbacks are torn down), and the
registry of a removed session is retained here until its runs drain, so
the gate still answers across the settlement window an abort cannot
compress to zero. Retention is bookkeeping: a Config that cannot answer
is logged, never turned into a shutdown failure.

Regressions, each mutation-checked against its own repair:
- acpAgent: a sibling's parked retry makes delete-history answer
  `{changed: false}` without reaching the store, and the deletion goes
  through once the claim releases
- acpAgent: a live run's registry stays visible to the gate across its
  session's close, and is dropped once the handle is released
- Session: a status emission after `snapshotPersisted` no longer
  resurrects a sibling-deleted run (reverting the latch reproduces the
  reviewer's probe verbatim), and a re-registered runId is remembered
  again
- Session: a run with the oldest startTime behind 30 newer snapshots is
  deletable
- Session: dispose aborts the workflow registry before clearing its
  callbacks

Verification: Session.test.ts 733 passed, acpAgent.test.ts 514 passed,
`tsc --noEmit -p packages/cli` clean, eslint and prettier clean. The 14
`packages/cli/src/serve` failures (fast-path import boundary,
capabilities-docs contract, workspace fs/agents/memory, conversation
runtime ownership) reproduce identically on the unmodified head — base
skew, untouched by this change.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1

* fix(core): drop the duplicated telemetry-swap mock property

Merging upstream/main (053f17b) clears the 9 `client.telemetrySwap`
failures this branch had from predating QwenLM#10220, but main carries its own
TS1117 on the same file: two commits added `getToolRegistry` to the same
object literal independently and neither saw the other.

  032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729)
  8241905 test(core): give the telemetry-swap client mock a
             getToolRegistry (QwenLM#10220)

Checking that file out from upstream/main here and running
`tsc --noEmit -p packages/core` reproduces
`client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so taking
the merge unmodified would have traded 9 test failures for a build that
never reaches the tests at all.

Removed as part of the merge rather than left for a follow-up: keeps
QwenLM#10220's copy, which was added for this purpose and carries the
explanation, and drops QwenLM#9729's incidental one. main still needs the same
removal — this only keeps it out of the branch.

Verified after the merge: `tsc --noEmit` clean for both packages/core
and packages/cli; client.telemetrySwap 10 passed, Session.test.ts and
acpAgent.test.ts 1260 passed together.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1

* fix(workflows): guard pending run lifecycle

* fix(workflows): let a starting run be cancelled, and stop two slow leaks

Four of the behavioural items the review deferred across rounds 8-9; the
test-coverage-only entries stay deferred.

- Cancel during the start window (R9 acpAgent.ts:10884 + workflow-runner.ts:181).
  Between `reserveStart` and `register` the runner loads the script and
  replays the journal — seconds, for a resume of a large one — and the
  registry has no entry yet. `sessionTaskCancel` answered `not_found` for a
  run the client could see starting, and `registry.cancel` could not reach
  the reserved controller either. New `cancelStarting` aborts it (the
  reservation stays the runner's to release, as after `abortAll`), and the
  cancel handler routes there when the liveness gate would say "starting".
  Doing that exposed the second half: the runner threw a bare `Error` for
  an abort during start, and the tool's catch only recognised the CALLER's
  signal — a registry-side abort surfaced as an unexplained failure. It is
  now a typed `WorkflowStartCancelledError`, mapped to the same
  "cancelled before it could start" result.
- `detachedWorkflowRegistries` (R8 acpAgent.ts:3386) was pruned only inside
  the delete-history liveness check. A daemon that closes sessions mid-run
  and never deletes history retained every registry for its lifetime. Prune
  on session close as well.
- Refresh/delete race (R9 Session.ts:3428). `refreshWorkflowHistory` reads
  the directory and merges without a claim; a delete landing between the
  read and the merge was overwritten by the stale listing and the run
  reappeared until the next refresh. Deletions are now sequenced, and a
  refresh drops any run deleted after its read began — keyed by runId and
  compared against the refresh's own mark, so a later retry that reuses
  the id is not suppressed.
- The "Register a new run" JSDoc sat on `reserveStart` (R9 registry:594).

Mutation-verified, all four at once against the full suites: disabling the
starting-window branch, the prune-on-close, the deletion filter, and the
typed-error mapping reddens exactly the four new tests and nothing else.

Claude-Session: https://claude.ai/code/session_01VXsC4f71S6U6YkW82NRw7m

* fix(core): close the round-5 review findings on workflow task controls

- Key the starting-window cancel on a live reservation rather than on the
  absence of an entry: a retry reuses its runId, so its terminal entry
  shadowed the reservation and cancel answered `not_running` about a run
  that was actively starting.
- Answer `changed: false` from retry when `execute()` reports a start that
  never registered (no `workflowRunId`), mirroring rerun and run-saved.
- Classify a registry-side abort of the reserved controller as a cancel in
  foreground starts too, not only background ones; the tool maps
  `WorkflowStartCancelledError` in either mode.
- Report reserved-but-unregistered runs as workflow active-work holds
  (`WorkflowRunRegistry.listStartingRunIds`), so a daemon conditional close
  cannot dispose the session under a start it just accepted.
- Propagate a successful history deletion into every sibling session's
  deletion marker and cached history, symmetric to the `removeTerminal`
  sweep, so a sibling refresh that had already read the directory cannot
  republish the deleted run.

Claude-Session: https://claude.ai/code/session_018dYE4LwSMeMPFchXk5UBdM

* fix(serve): keep workflow retry and the workflowsEnabled flag consistent across sessions

Two cross-session gaps in the workflow control surface:

- A retry consulted only the requesting session's registry. Every
  session shares one journal/snapshot store and the task-global claim is
  released as soon as the background start returns, so a sibling whose
  registry still showed the run `failed` started a second runner under
  the same runId. Retry now refuses while the runId is live in any
  session (or in its own starting window), checked synchronously beside
  canStart so the answer cannot go stale before the claim is taken.

- Workspace reload updated `tools.workflowsEnabled` for `/capabilities`
  but never told existing sessions; `Config.workflowsEnabled` was set
  once at construction. The reload's `tools` branch now propagates the
  flag and pushes an available-commands update when it flips.

Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT

* fix(serve): refuse to report a workflow history deletion whose registry entry survived

`deleteWorkflowHistory` ignored `removeTerminal()`'s answer. The registry
refuses to remove a live or handle-held entry — its own last word on
whether the run is still active in this session — so a `false` for an
entry that exists meant the run re-registered under the deletion and
would re-persist the history the client was just told was gone. The
entry is now retired before the store is touched, and a refusal fails
the deletion; a persisted-only run has no entry and is unaffected.

Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT

* docs(serve): fix a stale method reference in the workflow history comment

The persisted latch is released inline at the top of #rememberWorkflowHistory;
no #forgetPersistedWorkflowRun exists.

Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT

* fix: report workflow lifecycle changes

* fix(core): repair the workflow test doubles and converge every eviction

The previous commit added a `listStartingRunIds()` call to
`describeBlockingBackgroundWork` without teaching the registry test
doubles about it, so seven tests across `clearCommand`,
`useBranchCommand` and `useResumeCommand` threw
`listStartingRunIds is not a function`. Add the reader to those stubs.

Also move the eviction's status emission from `releaseHandle` into
`evictTerminal` itself. Emitting only from `releaseHandle` left the
same convergence gap at the four sweeping call sites that already
emit: complete / fail / cancel / abortAll emit BEFORE they sweep, so a
consumer that re-reads on the callback observes the pre-eviction list
and keeps rendering a row that was just dropped. Emitting once after
the sweep closes all five paths, and a future eviction site inherits
the guarantee. New test pins the ordering; another pins that a release
which evicts nothing stays silent.

A starting reservation now reports `starting` rather than borrowing
`running`, and its line no longer repeats the run id the bracket
already carries.

Claude-Session: https://claude.ai/code/session_01VENc5rZYmMJDdqvjwBEyZd

* fix(cli): drop the duplicated mock keys and stop misdirecting a starting run

R3-2: the previous commit's stub plumbing re-inserted a `list:` line into
four `clearCommand.test.ts` registry mocks that already declared one.
Last-wins made it behaviour-neutral, and neither eslint (`no-dupe-keys`
is off here) nor tsc (the literal is not contextually typed, unlike the
`useResumeCommand` mocks where TS1117 did fire) caught it. Remove the
duplicates.

R3-1: when the only blocker is a reserved-but-unregistered run, the
blocked message ended with "Use /workflows to inspect them, then retry."
— but `/workflows` renders `registry.list()`, which a reservation has
not entered, so it named a surface that cannot show what is blocking.
Track inspectability separately and fall back to a plain retry hint when
nothing is listable; a registered run in the same set still points at
`/workflows`.

Claude-Session: https://claude.ai/code/session_01VENc5rZYmMJDdqvjwBEyZd

* fix(cli): preserve starting workflow guidance

* test(cli): pin background work hint guards

* fix(serve): redact untrusted standalone workflow replay

* test(web-shell): fix daemon SDK mock path

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants