Skip to content

fix(web-shell): disable Push while the branch is behind its upstream - #10754

Merged
wenshao merged 8 commits into
QwenLM:mainfrom
wenshao:fix/webshell-push-hint-behind
Sep 3, 2026
Merged

fix(web-shell): disable Push while the branch is behind its upstream#10754
wenshao merged 8 commits into
QwenLM:mainfrom
wenshao:fix/webshell-push-hint-behind

Conversation

@wenshao

@wenshao wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

The branch picker's Push row now tells the truth about the push destination — and only claims what local state can prove:

  • Information follows the push side. The core branch listing gains pushTarget / pushAhead / pushBehind / pushGone from git's own %(push:short) / %(push:track,nobracket) atoms (no push-destination precedence is re-derived), plus pushConfigured (branch.<name>.pushRemote, remote.pushDefault, or a remote.<name>.push refspec). A resolved destination brings its own counts — in a triangular fork the row shows ↑2 while Update Project shows ↓3 · upstream/main. A destination git declines to name (push.default=simple in triangular shapes, Gerrit-style refspecs) renders no count hint rather than presenting pull-side numbers as push-side ones. A missing push ref says Creates origin/<branch> instead of a dimmed "Nothing to push".
  • Warnings, not blocking. When the counts say a push would be refused — behind (↓3) or diverged (↑1 ↓1 · diverged) — the row warns in the warning tone but stays clickable; git's own message is the authority on click. Push is disabled only on a detached HEAD, the one push failure provable from local state (the daemon's --set-upstream path refuses it). Previously (on main) a behind branch showed a dimmed "Nothing to push" with no indication anything was wrong.
  • Stale counts self-heal. A failed Update Project re-reads the listing and status (a pull's fetch phase usually refreshed the refs), and a rejected Push does the same — the strongest evidence the counts were stale.
  • The newerStatus equal-computedAt tie-break is pinned by a test (kills the mutant that survived feat(web-shell): show git state hints beside branch picker actions #10397's suite).

Why it's needed

#10397's sandboxed verification flagged that a behind branch's Push row looked indistinguishable from a clean one. The first iterations of this PR disabled the row on behind > 0; review then measured that disable misfiring across independent config axes — remote.<name>.push refspecs (Gerrit, where behind-the-tracked-branch is the normal state), forcing refspecs, the everyday checkout -b hotfix origin/main name-mismatch clone, triangular push.default=simple, and plain last-fetch staleness. The common thread: whether a remote will accept a push is not decidable from local state, and each carve-out bred the next. The landed shape is the structural cut — faithful information (push-side, honest about unknowns), disabling only what is locally provable.

Reviewer Test Plan

How to verify

cd packages/web-shell && npx vitest run \
  client/components/BranchPickerPopover.test.tsx \
  client/components/sidebar/WorkspaceSection.test.tsx \
  client/components/panels/EnvironmentPanel.test.tsx \
  client/components/ChatEditor.test.tsx
# Test Files  4 passed (4) · Tests  225 passed (225)
cd packages/core && npx vitest run src/utils/git-branches.test.ts
# 103 passed — real-git fixtures under a hermetic env: triangular (simple → configured-but-unknown,
# current → resolved counts), pushGone, nonzero pushBehind, remote.pushDefault, Gerrit refspec probe,
# per-branch case-preserving pushRemote scoping

Manual states and expectations are in .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md (11 states; only detached HEAD disables).

Evidence (Before & After)

Captured from the real stack (bundled qwen serve on real multi-remote fixtures + Playwright on the sidebar chip; per-row disabled/hint/data-tone read back from the DOM into dom-ledger.jsonl). The "main" column reuses #10397's round-2 captures of the same states, which are this PR's merge-base behavior.

Repo state main (merge base) This PR
Ahead 1 (control) (no push-side data on main) after-ahead
Behind 3, clean base-behind after-behind
Behind 3 + dirty tree base-behind-dirty after-behind-dirty
Diverged ↑1 ↓1 base-ahead-behind after-ahead-behind
Conflicted merge on the branch base-merge-conflict after-merge-conflict
Triangular fork, push.default=current (push side resolved) (no push-side data on main) after-triangular
Triangular fork, default push.default=simple (unresolvable) (no push-side data on main) after-triangular-simple
remote.origin.push refs/heads/*:refs/for/* (Gerrit), behind 1 (no push-side data on main) after-gerrit
Upstream deleted + pruned; push ref also gone base-upstream-gone after-upstream-gone
No upstream base-no-upstream after-no-upstream
Detached HEAD base-detached after-detached

Tested on

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

Environment (optional)

macOS; vitest + jsdom and real-git core fixtures, plus the bundled qwen serve + headless Chromium runs above.

Risk & Scope

  • Main risk or tradeoff: a push the counts call doomed is warned about, not blocked — a user can still click and get git's rejection in the status line (loud, no data loss; that message names remedies like --force-with-lease, which the popover deliberately does not offer — a force affordance would be a new destructive-action surface and is left as a follow-up). Hints remain last-fetch snapshots; the failure-refresh paths narrow but cannot eliminate that window, and a deleted upstream ref only refreshes after a prune (stated at the rule site).
  • Not validated / out of scope: additive optional fields on the core branch listing only — the daemon route serializes result.local verbatim. No force-push affordance. Windows/Linux not run locally.
  • Breaking changes / migration notes: none; all new listing fields are optional.

Linked Issues

Follow-up to #10397 (its verification comment carries F1/F2); the disable→warn pivot responds to review rounds R1/R2 on this PR (all 22 threads addressed and resolved).

中文说明

这个 PR 做了什么

分支选择器的推送行现在陈述的是推送目的地的真相——并且只断言本地状态能证明的事:

  • 信息跟随 push 侧。 core 分支列表新增 pushTarget / pushAhead / pushBehind / pushGone(取自 git 自身的 %(push:short) / %(push:track,nobracket) 原子,不自行推导目的地优先级),以及 pushConfiguredbranch.<name>.pushRemoteremote.pushDefaultremote.<name>.push refspec)。目的地可解析时用它自己的计数——三角 fork 里推送行显示 ↑2 而更新行显示 ↓3 · upstream/main;git 拒绝命名目的地时(三角下默认的 push.default=simple、Gerrit 式 refspec)不显示计数提示,而不是把 pull 侧数字冒充 push 侧;push ref 不存在时显示 将创建 origin/<branch>,而不是变灰的"无待推送"。
  • 警告而非拦截。 计数表明推送会被拒时——落后(↓3)或分叉(↑1 ↓1 · 已分叉)——该行以警告色提示但保持可点,点击后由 git 给出权威答复。推送只在游离 HEAD 时禁用,这是唯一能从本地状态证明的推送失败(daemon 的 --set-upstream 路径会拒绝它)。此前(main 上)落后分支只显示变灰的"无待推送",看不出任何异常。
  • 陈旧计数自愈。 更新失败会重读列表与状态(pull 的 fetch 阶段通常已刷新 refs);推送被拒同样重读——这是计数已陈旧的最强证据。
  • newerStatus 相同 computedAt 的平局规则由测试钉住(杀掉 feat(web-shell): show git state hints beside branch picker actions #10397 套件中存活的变异)。

为什么需要

#10397 的沙箱验证指出落后分支的推送行与干净分支看不出区别。本 PR 最初的迭代在 behind > 0 时禁用该行;评审随后实测该禁用在多个独立配置轴上误伤——remote.<name>.push refspec(Gerrit:落后于跟踪分支是常态)、forcing refspec、日常的 checkout -b hotfix origin/main 名称不匹配、三角 push.default=simple、以及单纯的上次 fetch 陈旧。共同点是:远端是否接受推送无法由本地状态判定,每补一个洞就生出下一个。最终落地的是结构性裁剪——忠实的信息(push 侧、对未知诚实),只禁用本地可证的状态。

审阅测试计划

如何验证

cd packages/web-shell && npx vitest run \
  client/components/BranchPickerPopover.test.tsx \
  client/components/sidebar/WorkspaceSection.test.tsx \
  client/components/panels/EnvironmentPanel.test.tsx \
  client/components/ChatEditor.test.tsx
# Test Files  4 passed (4) · Tests  225 passed (225)
cd packages/core && npx vitest run src/utils/git-branches.test.ts
# 103 passed——hermetic 环境下的真实 git 夹具:三角(simple → 已配置但未知,current → 可解析计数)、
# pushGone、非零 pushBehind、remote.pushDefault、Gerrit refspec 探测、按分支且保留大小写的 pushRemote 作用域

手测状态与期望见 .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md(11 个状态;只有游离 HEAD 会禁用)。

证据(前后对比)

取自真实栈(bundle 版 qwen serve + 真实多远端夹具 + Playwright 点侧栏芯片;每行 disabled/提示/data-tone 从 DOM 读回 dom-ledger.jsonl)。"main" 列复用 #10397 第二轮对相同状态的截图,即本 PR 的合并基线行为。

仓库状态 main(合并基线) 本 PR
仅领先 1(对照) (main 无 push 侧数据) after-ahead
落后 3,干净 base-behind after-behind
落后 3 + 脏工作区 base-behind-dirty after-behind-dirty
分叉 ↑1 ↓1 base-ahead-behind after-ahead-behind
分支上的冲突 merge base-merge-conflict after-merge-conflict
三角 fork,push.default=current(push 侧可解析) (main 无 push 侧数据) after-triangular
三角 fork,默认 push.default=simple(不可解析) (main 无 push 侧数据) after-triangular-simple
remote.origin.push refs/heads/*:refs/for/*(Gerrit),落后 1 (main 无 push 侧数据) after-gerrit
上游被删并 prune;push ref 同样缺失 base-upstream-gone after-upstream-gone
无上游 base-no-upstream after-no-upstream
游离 HEAD base-detached after-detached

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS;vitest + jsdom 与真实 git core 夹具,另有上述 bundle 版 qwen serve + 无头 Chromium 实跑。

风险与范围

  • 主要风险或取舍:计数判死的推送只警告不拦截——用户仍可点击并在状态条看到 git 的拒绝(响亮、无数据损失;该消息会提到 --force-with-lease 等解法,弹窗有意不提供强推入口——那是新的破坏性操作面,留作后续)。提示仍是上次 fetch 的快照;失败刷新路径缩小但无法消除该窗口,上游 ref 被删的形态要 prune 后才刷新(已写在规则处)。
  • 未验证 / 不在范围内:core 分支列表仅新增可选字段——daemon 路由原样序列化 result.local。无强推入口。Windows/Linux 未在本地运行。
  • 破坏性变更 / 迁移说明:无;新增列表字段全部可选。

关联 Issue

#10397 的后续(其验证评论载有 F1/F2);禁用→警告的转向回应本 PR 的 R1/R2 两轮评审(22 条线程全部处理并 resolve)。

Follow-up to QwenLM#10397's sandboxed verification (run 33195566824), which
measured two gaps left at merge time:

- F1: with an upstream and `behind > 0` — behind-only or diverged — the push
  row stayed enabled, but the exact `git push` the daemon runs is refused
  unconditionally as a non-fast-forward, contradicting the derivation's own
  "disabled means git refuses it" contract. The row is now disabled in those
  states (the verification's measured M6' rule): `detached || (!operation &&
  hasUpstream && behind > 0)`. Mid-operation the row still only warns — the
  behind count is in flux until the operation concludes — and conflicts alone
  still don't block a push.
- F2: nothing pinned `newerStatus`'s equal-`computedAt` tie-break (mutant
  M5 survived), so the popover's own fetch winning ties was unasserted. A
  test now renders the fetched counters when both stamps are equal.

The QwenLM#10390 competing-push panel test gets an ahead-only listing fixture,
since its scenario needs a state where push is actually possible.

Local mutation A/B: reverting the rule to `detached` fails 3 tests;
`>=` → `>` in the tie-break fails exactly the new test.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every heading filled, Risk & Scope answered, Chinese translation complete.

This is a re-run, and the PR under review is no longer the PR the last pass reviewed. My previous three comments here were written against 9b3ce46d, a 22-line change that disabled the Push row on behind > 0. Review rounds since then reshaped it into a 190-production-line change across three packages that warns instead of disabling, and it now reaches into packages/core. So I have re-derived every stage from the current head rather than restating findings that no longer describe the diff.

  • Problem: observed, and I confirmed it statically against the base code rather than taking the description's word for it. On main, deriveActionHints chains ahead > 0 && behind > 0ahead > 0hasUpstream, so a branch that is behind 3 and ahead 0 falls all the way through to a muted "Nothing to push" — pixel-identical to a clean, in-sync branch. Meanwhile gitPush issues a plain non-force git push whenever an upstream exists, which the remote refuses. So the row really did look safe while the action was doomed. This is a follow-up to feat(web-shell): show git state hints beside branch picker actions #10397 (merged 2026-08-28, CHANGELOG line 29), which shipped the hint row in the first place.
  • Direction: aligned. It extends a feature this repo already shipped and changelogged, by the same author, and it restores the contract feat(web-shell): show git state hints beside branch picker actions #10397 wrote down ("disabling is reserved for what git itself refuses"). The daemon listing gains four optional fields, so it is additive and backward compatible — no route or consumer has to change in lockstep.
  • Size: core paths are touched, so the breakdown. 190 production lines (git-branches.ts 40, BranchPickerPopover.tsx 135, i18n.tsx 6, daemon types.ts 9), 608 test lines, 100 lines of e2e test plan. That is under the 500-line maintainer-awareness threshold and under the 1000-line large-PR advisory. You have admin on this repo, so the two-tier core gate exempts a maintainer-authored PR and I am not escalating on it — but I still walked every downstream consumer, listed in the code review below.
  • Approach: the shape is defensible and I landed on a worse one independently — see Stage 2. Two honest scope questions rather than blockers. First, the stated problem is fixable in about five lines of the hint chain; the push-side data model that makes up most of the 190 is justified by the misfires your own review measured (Gerrit refspecs, triangular push.default=simple, checkout -b name mismatch), but the "stale counts self-heal" post-failure refresh is a separable concern from "the Push row lies" and could have been its own PR. Second, and more worth fixing: the title now states the opposite of what ships. It says "disable Push while the branch is behind its upstream", and the code sets pushDisabled = detached and deliberately warns instead. The description also still advertises a pushConfigured field that round 3 removed — it appears nowhere in the diff. Both are hygiene, not correctness, but the title becomes the merge-commit and CHANGELOG text, so please retitle before merging.
  • Risk: no high-risk path matches. The signal worth naming is the trajectory: the /review lane is at round 6 and records the source diff having grown 4.7x (92 → 434 lines), and it asks a human to judge whether the shape is still right before anyone fixes its remaining suggestions. All 22 review threads are resolved and it reports nothing blocking, so I am not deferring on that — but a human co-reviewer should answer the shape question, and my approval cannot substitute for one since main needs two.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓——各级标题都填了,Risk & Scope 有回答,中文翻译完整。

这是一次 re-run,而且当前受审的 PR 已不是上一轮审的那个 PR。 我此前在这个 thread 里的三条评论写于 9b3ce46d,当时是一个 22 行、在 behind > 0 时禁用推送行的改动。之后的几轮评审把它重塑成了一个跨三个包、190 行生产代码、改为警告而不再禁用的改动,并且开始触及 packages/core。所以本轮我从当前 head 重新推导了每一个阶段,而不是复述那些已经不再描述这份 diff 的结论。

  • 问题:已观测到,而且我是对照基线代码静态确认的,不是照抄描述。在 main 上,deriveActionHints 的判断链是 ahead > 0 && behind > 0ahead > 0hasUpstream,因此"落后 3、领先 0"的分支会一路落到变灰的"无待推送"——与干净的同步分支看起来完全一样。而只要存在上游,gitPush 执行的就是不带强制的普通 git push,远端会拒绝它。所以这一行确实在动作注定失败时表现得毫无异常。本 PR 是 feat(web-shell): show git state hints beside branch picker actions #10397(2026-08-28 合并,CHANGELOG 第 29 行)的后续,那个 PR 正是引入这个提示行的。
  • 方向:对齐。它扩展的是本仓库已经发布并写入 CHANGELOG 的功能,作者相同,并且是在恢复 feat(web-shell): show git state hints beside branch picker actions #10397 自己写下的契约("禁用只保留给 git 自身会拒绝的情形")。daemon 分支列表新增的是四个可选字段,属于向后兼容的增量——路由和消费方都不需要同步改动。
  • 规模:触及了核心路径,因此给出拆分。生产代码 190 行git-branches.ts 40、BranchPickerPopover.tsx 135、i18n.tsx 6、daemon types.ts 9),测试 608 行,e2e 测试计划 100 行。低于 500 行的维护者关注阈值,也低于 1000 行的大 PR 建议阈值。你在本仓库有 admin 权限,两层核心门禁对维护者自己提交的 PR 豁免,因此我不会据此上报——但我仍然走查了每一个下游消费方,列在下面的代码审查里。
  • 方案:整体形状站得住,而我独立想到的方案更差——见 Stage 2。这里提两个诚实的范围问题,都不是阻断项。第一,标题所述的问题其实只要改判断链里大约 5 行就能解决;占这 190 行大头的 push 侧数据模型,是由你们评审实测到的那些误伤(Gerrit refspec、三角 push.default=simplecheckout -b 名称不匹配)证成的,但"陈旧计数自愈"的失败后刷新与"推送行说谎"是两个可分离的关注点,本可以单独成一个 PR。第二,也更值得修:标题现在说的是与落地行为相反的话。 标题写"分支落后于上游时禁用推送",而代码是 pushDisabled = detached,并且刻意改为警告。描述里也仍然在介绍一个 pushConfigured 字段,而它在第 3 轮已被删除——diff 里根本没有。这两点都属于卫生问题而非正确性问题,但标题会变成合并提交和 CHANGELOG 的文本,所以合并前请改掉标题。
  • 风险:未命中高风险路径。值得点名的信号是这条轨迹:/review 通道已到第 6 轮,并记录源码 diff 增长了 4.7 倍(92 → 434 行),它提出在有人去修剩余建议之前,应由人来判断这次改动的整体形态是否仍然正确。22 条评审线程已全部 resolve,且它报告没有任何阻断项,所以我不会据此推迟——但这个"形态是否仍正确"的问题需要人类共同评审者来回答,而且我的批准无法替代:main 需要两个批准。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Code review — no blockers

My independent proposal first (title plus the why-section, before reading the diff): add a behind > 0 branch to the push hint chain ahead of the hasUpstream fall-through, and widen pushDisabled to detached || (hasUpstream === true && behind > 0 && !operation). No core change at all — the listing already carries ahead/behind. That is roughly what round 1 of this PR did.

The PR is better than my proposal, and the difference is concrete. My version silently assumes the tracking upstream is the push destination. That is false in a triangular workflow — behind upstream/main by 3 while a push to origin/main fast-forwards happily — and false under a Gerrit-style remote.<name>.push refspec, where being behind the tracked branch is the normal state. My rule would have disabled pushes git was about to accept. This PR asks git for the destination through %(push:short) instead of re-deriving precedence in TypeScript, and declines to print a count when git declines to name one. It also gave up the disable entirely, which is the half review measured as not locally decidable. I found no simpler path that is also correct.

Verified against the code rather than the description:

  • pushDisabled = detached is exactly the locally-provable set. gitPush adds --force-with-lease only when the caller passes force; both client call sites (BranchPickerPopover.tsx:513, GitDialog.tsx:289) pass { setUpstream: true } and never force; the --set-upstream path throws its own detached-HEAD error, and with an upstream configured it runs a plain git push. So detached HEAD is the only failure the client can prove without asking the remote.
  • The format-string change is append-only. The two new atoms land at indices 7 and 8; existing parsing reads parts[0]parts[5] and the symref filter reads index 6, all untouched.
  • The minimum git floor does not move. %(push) and the %(symref) this code already depends on both landed in git 2.8.0. Both for-each-ref calls also carry .catch(() => ''), so even a git that rejected the atom would degrade to an empty listing rather than throwing.
  • Hint-chain ordering is right. blockerpushGone → push-side unknown → no-upstream → diverged → behind → ahead → nothing-to-push. The new behind branch sits before the ahead branch, which is what converts the old muted fall-through into a warning, and blocker still outranks pushGone, so a detached HEAD is never dressed up as "Creates origin/x". Core only sets pushGone when pushTarget is defined, so the ?? '' in that i18n call cannot render a bare "Creates ".
  • The aheadBehind copy change is contained. I grepped every read site: it has exactly one production consumer, the push row. The pull row builds its own text from behindDirty or an inline count, so rewording "update first" to "diverged" cannot leak into pull-side guidance. The reword is also correct — the number is now push-side divergence, not an instruction to pull.
  • The refresh asymmetry is deliberate and pinned both ways. Push rejection awaits the re-read so busyAction stays set and the row cannot re-enable on pre-push counts; pull failure fires it without awaiting so the 409 resolution panel is not held disabled by a round-trip it never needed. silent = true skips setLoading(true) and swallows its own error, leaving stale-but-usable rows instead of replacing them with a second failure. Tests cover the settled and in-flight versions of both.
  • Every downstream consumer of the core change, named: fetchGitBranches has one caller, workspace-git-branches.ts:115, which serializes result.local verbatim — so the optional fields reach the wire with no route change. parseBranchLines is shared by the heads and remotes listings; remote entries may now carry push fields and nothing reads them. GitBranchInfo is mirrored by DaemonGitBranchInfo in this same PR, re-exported at daemon/index.ts:526, consumed by the popover. deriveActionHints and listingContradictsStatus are exported but have no importer outside their own file. The new i18n key exists in both the EN and ZH maps, and green Lint & Static means the Messages type is satisfied on both sides.

Non-blocking, but worth a maintainer's explicit yes:

  • The silent shapes are a visible reduction in information, not only an addition. Where main showed an ahead count, four configurations now show no push hint at all: triangular push.default=simple, a remote.<name>.push refspec, an upstream whose name the branch does not match, and push.default=nothing. The reasoning holds — a pull-side number wearing a push-side label is worse than silence, and in the name-mismatch shape git refuses the push outright — and the everyday matching-name clone still resolves a destination, which the core test pins. But someone in the common checkout -b hotfix origin/main shape loses a hint they had yesterday.
  • The duplicated --format literal just became load-bearing. The same nine-atom string appears twice and feeds one positional parser. Before this PR a drift between the copies was cosmetic; now it silently mis-assigns the push fields. Already reported as R6-1 and deferred under the convergence posture — I agree it is deferrable, but it is the one deferred item I would pick up.
  • Correcting a false signal from the round-6 review. Its Test Plan section reports "no such file or directory" for all four web-shell test files. I checked each path at head 1725d09a through the contents API: all five test files exist. The PR's commands are relative to packages/web-shell, exactly as its cd says — that is a cwd artifact of the review harness, not a defect here, and its observed pass counts come from unrelated files. Please do not read that section as a finding against the PR.

Not verified in this pass, stated plainly:

  • The live rendering. This is an unattended CI run, so I did not build or execute any PR code — per this gate's rules the live-behaviour signal comes from the sandboxed lanes named below, not from me re-running anything.
  • The 11-state screenshot matrix and the DOM ledger in the description are the author's evidence, captured on macOS; the Tested-on table marks Windows and Linux as not run. I am not presenting them as verified here.
  • Whether a strictly-behind git push is refused with the "fetch first" reason or reports "Everything up-to-date". My Stage 1 claim rests on reading gitPush and git's non-fast-forward rules, not on an executed push. The warn-don't-block design is correct under either answer — a click surfaces git's own message either way — which is a genuine strength of this shape, but the code comment's "a push would be rejected as it stands" is only literally true under the first.
Files changed (7 of 7 shown)
File What changed
.qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md New 11-state manual plan; records the round-2 disable-to-warn pivot and why round 3 re-keyed the silence rule
packages/core/src/utils/git-branches.ts Four optional push-side fields on the branch listing, taken from two new for-each-ref atoms; parsing only, no new git precedence logic
packages/core/src/utils/git-branches.test.ts Real-git fixtures for the triangular shapes, pushGone, a nonzero pushBehind, and the three shapes where git names no destination
packages/sdk-typescript/src/daemon/types.ts Mirrors the four optional fields onto the wire type so the client can read them
packages/web-shell/client/components/BranchPickerPopover.tsx Push hint now reasons about the push destination instead of the upstream; adds a silent post-failure re-read of listing and status
packages/web-shell/client/components/BranchPickerPopover.test.tsx Pins warn-not-disable, the silence boundary, the pushGone copy, and both refresh paths including their in-flight states
packages/web-shell/client/i18n.tsx New creates-branch key in EN and ZH; rewords the diverged hint away from update first

Test evidence

This run carries the PR's own CI results, read through the API — no PR code was built or executed. Fetched once for the reviewed commit, no polling. All 17 distinct checks that ran are green, including Test (ubuntu-latest, Node 22.x), Lint & Static (ubuntu-latest, Node 22.x), Capture web-shell visuals, web-shell E2E Smoke, Integration Tests (no-AK, No Sandbox), Desktop Shell on both ubuntu-22.04 and windows-2022, and Secret scan (TruffleHog). There is no failure and no cancellation, so there is no failing-job log to excerpt.

Three matrix entries are skipped rather than failed: Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), and Integration Tests (CLI, No Sandbox). The PR matrix runs the unit suite on ubuntu only, and the same three were skipped at the previously reviewed head 9b3ce46d. Skipped is not a red conclusion, and a green Desktop Shell (windows-2022) means Windows is not wholly unexercised — but it does mean these component tests never ran on macOS or Windows in CI, which lines up with the author's own Tested-on table. Zero pull_request workflow runs are still pending on this head, so nothing below is waiting on CI.

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Live Host (macos-latest) ✅ success
Remind on force-push ✅ success
Secret scan (TruffleHog) ✅ success
Signal the reviewed fork PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
assign ✅ success
label ✅ success
precheck-pr / precheck ✅ success
route ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

Sandboxed verification would settle what CI cannot: @qwen-code /tmux — that the behind-3 row really renders a warning tone while staying enabled in the bundled qwen serve stack, and that the triangular-simple and Gerrit shapes really render no hint, is DOM behaviour only the author's screenshots currently assert. And @qwen-code /verify — an A/B run against the base build would prove the new behind-branch is what changes the row; this PR's own suite cannot show that, because its fixtures already carry the push fields and so never exercise main's muted fall-through. You have write access, so neither lane needs sponsoring.

中文说明

代码审查——无阻断项。

先说我独立的方案(只看标题和"为什么需要",未读 diff):在 push 提示链里、hasUpstream 兜底之前加一个 behind > 0 分支,并把 pushDisabled 扩成 detached || (hasUpstream === true && behind > 0 && !operation)。完全不动 core——分支列表本来就带 ahead/behind。这大致就是本 PR 第 1 轮的做法。

PR 的方案比我的好,而且差别是具体的。 我的版本默默假设"跟踪的上游就是推送目的地"。这在三角工作流里是错的——落后 upstream/main 3 个提交,而推送到 origin/main 可以顺利快进;在 Gerrit 式 remote.<name>.push refspec 下也是错的,那里落后于跟踪分支是常态。我的规则会禁用掉 git 正要接受的推送。本 PR 改为通过 %(push:short) 向 git 询问目的地,而不是在 TypeScript 里重新推导优先级;git 拒绝给出目的地时就不显示计数。它也彻底放弃了"禁用",而那正是评审实测出无法由本地状态判定的一半。我没有找到既更简单又正确的路径。

以下是对照代码(而非描述)验证过的:

  • pushDisabled = detached 恰好就是本地可证的集合。 gitPush 只在调用方传 force 时才加 --force-with-lease;两个客户端调用点(BranchPickerPopover.tsx:513GitDialog.tsx:289)都只传 { setUpstream: true },从不传 force--set-upstream 分支对游离 HEAD 抛自己的错误,而配置了上游时执行的是普通 git push。所以游离 HEAD 是唯一不必询问远端就能证明的失败。
  • format 字符串改动是纯追加。 两个新原子落在索引 7 和 8;既有解析读 parts[0]parts[5],symref 过滤读索引 6,都未改动。
  • 最低 git 版本要求没有提高。 %(push) 与这段代码本就依赖的 %(symref) 都在 git 2.8.0 引入。两个 for-each-ref 调用也都带 .catch(() => ''),所以即便某个 git 拒绝该原子,也只会退化为空列表而不是抛错。
  • 提示链顺序正确。 blocker → pushGone → push 侧未知 → 无上游 → 分叉 → 落后 → 领先 → 无待推送。新的"落后"分支排在"领先"分支之前,这正是把旧的变灰兜底变成警告的原因;blocker 仍优先于 pushGone,所以游离 HEAD 不会被包装成"将创建 origin/x"。core 只在 pushTarget 有值时才设 pushGone,因此那个 i18n 调用里的 ?? '' 不可能渲染出孤零零的"将创建 "。
  • aheadBehind 文案改动的影响面是封闭的。 我 grep 了所有读取点:它只有一个生产消费方,即 push 行。pull 行用 behindDirty 或内联计数自己拼文案,所以把"请先更新"改成"已分叉"不会渗到 pull 侧的指引里。这个改写本身也是对的——那个数字现在是 push 侧的分叉,不再是"先去 pull"的指令。
  • 刷新的不对称是刻意的,且两个方向都有测试钉住。 推送被拒时 await 重读,使 busyAction 保持置位,行不会用推送前的计数重新启用;pull 失败时不 await 直接发起,使它刚打开的 409 解决面板不会被一次它并不需要的往返卡在禁用态。silent = true 跳过 setLoading(true) 并吞掉自己的错误,保留陈旧但可用的行,而不是用第二个失败替换它们。两种路径的"已落定"与"进行中"版本都有测试。
  • core 改动的每一个下游消费方,逐个点名: fetchGitBranches 只有一个调用者 workspace-git-branches.ts:115,它把 result.local 原样序列化——所以这些可选字段无需改路由就能上线。parseBranchLines 由 heads 和 remotes 两个列表共用;remote 条目现在可能带 push 字段,而没有任何代码读它们。GitBranchInfo 由本 PR 同步镜像到 DaemonGitBranchInfo,在 daemon/index.ts:526 再导出,由弹窗消费。deriveActionHintslistingContradictsStatus 虽是导出的,但在自身文件之外没有导入者。新的 i18n key 在 EN 和 ZH 两份表里都有,而 Lint & Static 全绿说明两侧的 Messages 类型都满足。

不阻断,但值得维护者明确点头:

  • "沉默"的几种形态是可见的信息减少,而不只是新增。main 上会显示领先计数的地方,现在有四种配置完全不显示 push 提示:三角 push.default=simpleremote.<name>.push refspec、上游名与分支名不匹配、以及 push.default=nothing。理由是成立的——把 pull 侧数字套上 push 侧标签比沉默更糟,而名称不匹配那种形态 git 会直接拒绝推送——而且日常的同名克隆仍能解析出目的地,core 测试钉住了这点。但处在常见的 checkout -b hotfix origin/main 形态下的人,会失去昨天还有的一个提示。
  • 重复的 --format 字面量刚刚变成了承重结构。 同一个九原子字符串出现两次,喂给同一个按位置解析的函数。本 PR 之前两份副本漂移只是外观问题;现在会静默地把 push 字段解析错位。它已作为 R6-1 报告过,并在收敛姿态下延后——我同意可以延后,但如果要捡一条,我会捡这条。
  • 纠正第 6 轮评审里的一个假信号。 它的 Test Plan 部分对四个 web-shell 测试文件报了 "no such file or directory"。我通过 contents API 在 head 1725d09a 上逐个查了这些路径:五个测试文件全部存在。 PR 的命令是相对于 packages/web-shell 的,正如它自己写的 cd 所示——那是评审 harness 的 cwd 产物,不是本 PR 的缺陷,它观察到的通过数也来自无关文件。请不要把那一段当作对本 PR 的发现来读。

本轮未验证,直说:

  • 实机渲染。这是无人值守的 CI 运行,我没有构建或执行任何 PR 代码——按本门禁的规则,实机行为信号来自下面点名的沙箱通道,而不是我自己重跑。
  • 描述里的 11 状态截图矩阵与 DOM 账本是作者的证据,摄于 macOS;Tested-on 表把 Windows 和 Linux 标为未运行。我不把它们当作本轮已验证的内容呈现。
  • 严格落后的 git push 究竟是以 "fetch first" 原因被拒,还是报告 "Everything up-to-date"。我 Stage 1 的判断基于阅读 gitPush 与 git 的 non-fast-forward 规则,而非一次真实执行的推送。"警告而不拦截"的设计在两种答案下都成立——点击都会带出 git 自己的消息——这确实是这个形状的一个优点,但代码注释里"按现状推送会被拒"只在第一种答案下才字面为真。

测试证据:本轮携带的是 PR 自己的 CI 结果,通过 API 读取——未构建、未执行任何 PR 代码。对受审提交只抓取一次,不轮询。跑起来的 17 个不同检查全绿,包含 ubuntu 单元套件、Lint & Static、web-shell 视觉捕获、web-shell E2E Smoke、集成测试、ubuntu 与 windows 两侧的 Desktop Shell、以及 TruffleHog 密钥扫描。没有失败也没有取消,因此没有失败日志可摘录。

有三个矩阵项是 skipped 而非失败:macOS 与 Windows 的单元套件、以及 CLI 无沙箱集成测试。PR 矩阵只在 ubuntu 上跑单元套件,上一轮受审的 head 9b3ce46d 上也是同样这三项被跳过。skipped 不是红色结论,而 windows-2022 的 Desktop Shell 全绿说明 Windows 并非完全没跑到——但这确实意味着这些组件测试在 CI 里从未在 macOS 或 Windows 上运行过,与作者自己的 Tested-on 表一致。该 head 上没有仍在进行的 pull_request 工作流运行,所以表格里的内容不等 CI。

沙箱验证可以收掉 CI 收不掉的部分:@qwen-code /tmux——落后 3 的行在 bundle 版 qwen serve 栈里是否真的以警告色渲染且保持可点,以及三角 simple 与 Gerrit 形态是否真的不显示提示,这些 DOM 行为目前只有作者的截图在断言;@qwen-code /verify——对基线构建做 A/B 可以证明是新增的"落后"分支改变了该行,而本 PR 自己的套件证明不了这点,因为它的夹具已经带上了 push 字段,从未走到 main 的变灰兜底。你有写权限,两条通道都不需要赞助。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the reasoning is sound and I could not find a correct simpler shape; the missing fifth is that the live rendering rests on the author's macOS captures and the PR's own title and description no longer describe what it ships.

Stepping back. My independent proposal was the naive one — treat the tracking upstream as the push destination and disable on behind — and this PR is measurably better than it, because it asks git where the push would go instead of re-deriving precedence, and because it accepts that remote acceptance is not locally decidable rather than carving out exception after exception. That is the right structural cut, and the fact that review pushed the author off the disable and onto warn-only is the process working, not thrashing.

The code itself I am confident in. Every downstream consumer is named in Stage 2 and each one is either updated in this PR or unaffected; the change to the core listing is append-only at the parse level, optional on the wire, and does not raise the minimum git version. pushDisabled = detached is not a judgement call, it is the exact set of failures gitPush can prove without talking to a remote, and I verified that against both client call sites. CI is fully green on this head — 17 of 17, nothing pending — and all 22 review threads are resolved with nothing blocking at round 6.

My reservations, none of which I think should hold this up:

  • The title says the opposite of what ships. "disable Push while the branch is behind its upstream" describes the round-1 approach that review correctly rejected. The code warns and disables only on detached HEAD. This matters more than it looks, because the title becomes the merge commit and the CHANGELOG line — the project's own record would say we added a disable when we deliberately removed one. Please retitle before merging.
  • The description still advertises pushConfigured, which round 3 deleted; it appears nowhere in the diff, and this PR's own e2e doc says it was removed. Two stale artifacts of six rounds.
  • The shape question is a human call, not a diff call. The /review lane records the source diff growing 4.7x and asks whether the whole approach is still right before anyone fixes its remaining suggestions. I can settle correctness from the diff; I cannot settle whether a 190-line, three-package reshaping of a shipped hint system is the scope the project wants versus the five-line fix — especially since four configurations now display less than they did on main. That deserves an explicit yes from whoever owns this surface.

Why I am approving rather than deferring: on a re-run, naming and scope-mismatch hygiene are not grounds to defer, and I have no blocking uncertainty left — nothing here that the diff, the tests, and the description cannot answer. And main requires two approving reviews, so my vote cannot merge this on its own; a human co-reviewer will face the shape question regardless. Deferring would just hide my read of the code behind a question I have already answered.

What I would want before the fifth point: a retitle, a description that matches the diff, and one of the sandboxed lanes named in Stage 2 to confirm the DOM behaviour independently of the macOS captures.

Approving now, pinned to the reviewed commit — no CI is outstanding on it.

中文说明

置信度:4/5——推理是扎实的,我也找不到一个既正确又更简单的形状;差的这一分在于实机渲染依赖作者在 macOS 上的截图,而且 PR 自己的标题和描述已经不再描述它实际交付的东西。

退一步看。我独立想到的方案是天真的那个——把跟踪上游当作推送目的地,并在落后时禁用——而本 PR 明显更好:它向 git 询问推送会去哪里,而不是重新推导优先级;它承认"远端是否接受"无法由本地判定,而不是一个接一个地打补丁。这是正确的结构性裁剪。评审把作者从"禁用"推到"只警告",是流程在起作用,不是来回摇摆。

代码本身我是有把握的。Stage 2 里点名了每一个下游消费方,它们要么在本 PR 中被同步更新,要么不受影响;对 core 分支列表的改动在解析层面是纯追加、在线协议上是可选的,也没有提高最低 git 版本要求。pushDisabled = detached 不是一个主观判断,它恰好就是 gitPush 在不与远端通信的前提下能证明的失败集合,我对照两个客户端调用点验证过。该 head 上 CI 全绿——17/17,没有仍在进行的检查——22 条评审线程全部 resolve,第 6 轮没有任何阻断项。

我的保留意见,我认为都不足以拦住它:

  • 标题说的是与落地行为相反的话。 "分支落后于上游时禁用推送"描述的是第 1 轮那个被评审正确否掉的方案。代码是警告,并且只在游离 HEAD 时禁用。这比看起来更要紧,因为标题会变成合并提交和 CHANGELOG 那一行——项目自己的记录会说我们加了一个禁用,而我们其实是刻意移除了它。合并前请改标题。
  • 描述里仍在介绍 pushConfigured,而它在第 3 轮已被删除;diff 里没有它,本 PR 自己的 e2e 文档也写了它被移除。这是六轮评审留下的两处陈旧痕迹。
  • "形态是否仍正确"是人的判断,不是 diff 的判断。 /review 通道记录源码 diff 增长了 4.7 倍,并提出在有人去修剩余建议之前,应先判断整体方案是否仍然正确。正确性我可以从 diff 里定;但"把一个已发布的提示系统重塑成跨三包 190 行"是不是项目想要的范围(相对于那个 5 行的修法),我定不了——尤其考虑到有四种配置现在显示的信息比 main更少。这需要一个真正负责这块 surface 的人明确点头。

为什么我选择批准而不是推迟:在 re-run 里,命名与范围不匹配这类卫生问题不构成推迟的理由,而我也没有剩下任何阻断性的不确定——这里没有什么是 diff、测试和描述回答不了的。而且 main 需要两个批准,所以我这一票无法独自合并它;人类共同评审者无论如何都要面对那个形态问题。推迟只会把我已经回答了的代码判断藏在一个问题背后。

要拿到第五分,我希望看到:改掉标题、让描述与 diff 一致,以及跑一条 Stage 2 里点名的沙箱通道,独立于 macOS 截图确认 DOM 行为。

现在批准,并钉在受审提交上——该提交上没有未完成的 CI。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 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 1725d09. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ One or more scenarios failed to render on this head, so this preview may be missing views — see the workflow run. The composites below are the scenarios that did render.

terminal-turn-error-copy-narrow-light before/after

workflow-page-running-dark before/after

workflow-page-running-light before/after

workflow-page-saved-dark before/after

workflow-page-saved-detail-dark before/after

workflow-page-saved-detail-light before/after

workflow-page-saved-light before/after

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

Qwen Code · web-shell visuals

@qqqys

qqqys commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

E2E verification report (independent reviewer run, head 9b3ce46d938a).

Static review (at head)

  • The new rule pushDisabled = detached || (!s.operation && hasUpstream === true && behind > 0) is sound: behind comes from head?.behind ?? s.behind where deriveStatus supplies numeric defaults, and hasUpstream === true excludes the unknown-listing state — so when the branch fetch hasn't landed (or failed) the row stays enabled, exactly as before (fail-open, no regression on the loading path).
  • Mid-operation (s.operation set) stays warn-only and conflicted-only states don't disable — both consistent with what git push actually refuses (a push never consults the index).
  • F2 claim verified in the source: newerStatus at head does carry the >= tie-break toward the popover's own fetch; this PR pins it with the new exact-tie test.
  • The one modified existing test ("competing push clears the pull panel") had its fixture corrected to ahead-only, which matches the state that scenario describes — assertion unchanged.

Unit tests at head (scratch tree, hermetic env): BranchPickerPopover + WorkspaceSection + EnvironmentPanel + ChatEditor = 217/217 passed, matching the PR's test plan.

Mutation A/B (each verified byte-identical before running, restored from the PR ref afterwards):

  • Rule reverted to pushDisabled = detached → exactly 3 tests fail (behind-only hint unit, diverged unit, DOM disabled test) — the new behavior is pinned.
  • Tie-break >=> (the M5 mutant that survived feat(web-shell): show git state hints beside branch picker actions #10397's suite) → exactly 1 test fails, the new exact-computedAt-tie test — the surviving mutant is now killed.
  • Restored tree: BranchPickerPopover.test.tsx 44/44 green again.

Live browser e2e: N/A on this host — no Playwright browsers installed, and the change is web-shell-client-only; the jsdom DOM-level assertions (real React render, real disabled attribute and data-tone readback) plus the CI web-shell visuals lane's base-vs-head screenshots are the available evidence, and both are green.

No Critical findings. Not approving — no maintainer/ci-bot approval on record yet at this head.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory.

中文说明

已审查。 建议见行内评论。

Test Plan(非阻断):client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory

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

Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md Outdated
Review round 1 follow-ups:

- The branch listing now carries the push side: `pushTarget` /
  `pushAhead` / `pushBehind` / `pushGone` from git's own `%(push:short)` and
  `%(push:track,nobracket)` atoms (no push-destination precedence is
  re-derived), plus `pushConfigured` (`branch.<name>.pushRemote` or
  `remote.pushDefault` present). Notably, under the default
  `push.default=simple` git refuses to resolve `@{push}` in exactly the
  triangular shape where a plain `git push` succeeds, so the resolved target
  cannot be the only signal.
- The push row's disable and counts now use the push destination: a resolved
  target brings its own ahead/behind; configured-but-unresolvable fails open
  (never disables on upstream counts); a missing push ref (`pushGone`) never
  disables; the plain-clone shape falls back to the upstream, which is where
  a plain `git push` goes.
- The rule-site comment states the counts are as of the last fetch, and all
  `handlePull` error paths now re-fetch the listing — a pull's embedded
  fetch has usually updated the refs, so a failed Update no longer strands
  the rows on a pre-pull snapshot.
- The E2E plan's unreachable "click Push while the panel shows" state is
  rewritten: the reachable check asserts Push is disabled while the
  409 panel is up, and the competing-push race keeps its unit coverage on a
  triangular fixture — a state real git can occupy, unlike
  ahead-only-with-a-409.

Tests: real-git triangular and push-gone fixtures in core (simple →
configured-but-unknown, current → resolved counts); derivation cases for the
triangular, diverged-from-push-target, push-gone, and
configured-but-unresolvable shapes (the last mutation-checked: dropping the
fail-open guard fails exactly that test); a pull-failure listing-refetch
test.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

Not reviewed: reverse audit — stopped before round 7 by the review time budget.

Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 98 passed — this review observed 1755, 5648, 1898, 27820, 256, 504, 94 passed.

中文说明

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

未审查:反向审计——评审时间预算不足,未能开始第 7 轮。

Test Plan(非阻断):client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 98 passed — this review observed 1755, 5648, 1898, 27820, 256, 504, 94 passed

— qwen3.8-max-2026-09-02 via Qwen Code /review (v0.22.3)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/core/src/utils/git-branches.ts
Comment thread packages/web-shell/client/components/BranchPickerPopover.test.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.test.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.test.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.test.tsx
@wenshao

wenshao commented Sep 3, 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 Sep 3, 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-dev-bot

qwen-code-dev-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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

中文说明

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

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

Round blocked: the rejected packages/core test check fails on runner contention, not on this PR

Blocker. The deterministic gate rejected commit 522f705339 with "tests failed in
packages/core". I re-ran the gate's exact command at that same commit, in this checkout, and
it failed again — on a different and larger set of tests, in files this PR does not touch,
every one of them a wall-clock or child-process timeout. The failure set is not reproducible
run-to-run at a fixed commit, which is the signature of an oversubscribed runner rather than a
code defect. There is no fix for it inside this PR's scope that does not weaken a check I do
not own, and the required check is still failing, so per the skill's rules ("Do not commit
while a required runnable check is failing") nothing was committed this round. The rejected
commit is preserved untouched; the working tree is clean.

The exact command and its real output

Both arms ran, in packages/core:

vitest run --changed origin/main --passWithNoTests --maxWorkers=25% \
  --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false

Gate, round 3, at 522f705339 (quoted from the rejection in feedback.md):

 Test Files  2 failed | 197 passed | 1 skipped (200)
      Tests  8 failed | 10340 passed | 8 skipped (10356)
     Errors  1 error
   Duration  355.15s (collect 3132.71s, tests 1211.06s)
Error: [vitest-worker]: Timeout calling "onTaskUpdate"

My re-run, identical command, identical co

中文说明

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

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

本轮受阻:被拒的 packages/core 测试检查失败源于 runner 资源争抢,而非本 PR

阻塞原因。 确定性验证门以「tests failed in packages/core」拒绝了提交 522f705339。我在同一份 checkout 中、针对同一个提交重新执行了验证门的原样命令,它再次失败——而且失败的是一组不同且更多的测试,全部位于本 PR 未触及的文件中,且每一个都是墙钟时间或子进程超时。在固定提交上,失败集合在两次运行之间无法复现,这正是 runner 被超额订阅的特征,而不是代码缺陷的特征。在本 PR 的范围内,没有一种修复不需要削弱一个不属于我的检查;而该必需检查仍在失败,因此按照本 skill 的规则(「必需的可运行检查失败时不得提交」),本轮没有提交任何内容。被拒的提交原样保留,工作树干净。

原样命令及其真实输出

两组运行都在 packages/core 中执行:

vitest run --changed origin/main --passWithNoTests --maxWorkers=25% \
  --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false

验证门,第 3 轮,在 522f705339(引自 feedback.md 中的拒绝信息):

 Test Files  2 failed | 197 passed | 1 skipped (200)
      Tests  8 failed | 10340 passed | 8 skipped (10356)
     Errors  1 error
   Duration  355.15s (collect 3132.71s, tests 1211.06s)
Error: [vitest-worker]: Timeout calling "onTaskUpdate"

我的重跑,命令相同、提交相同、checkout 相同:

 Test Files  3 failed | 196 passed | 1 skipped (200)
      Tests  14 failed | 10334 passed | 8 skipped (10356)
     Errors  1 error
   Duration  339.78s (collect 3014.31s, tests 1133.77s)
Error: [vitest-worker]: Timeout calling "onTaskUpdate"
GATE_EXIT=1

按文件对比:

文件 验证门那次 我重跑那次
src/services/session-writer-lease.test.ts 7 次 Timed out waiting for lease helper command —— 编号 1、2、3、4、6、7、9 7 次同样的错误 —— 编号 1、3、4、6、7、8、9(2 没有出现)
src/memory/recall-scan-latency.test.ts expected 273.86265 to be less than 50 expected 113.14839 to be less than 50
src/skills/skill-manager.test.ts 通过 4 个失败(例如 expected [ 'skill1', 'skill2' ] to deeply equal [ 'skill1', 'skill2', 'skill3' ]
合计 2 个文件 / 8 个测试 3 个文件 / 14 个测试

命令相同、提交相同,失败却在两个方向上都不同。表中没有任何一项是由被测代码决定的。

为什么这是资源争抢,而不是本次改动

  1. 本 PR 触不到这些断言。 git diff --name-only origin/main...HEAD 只有六个文件;其中唯一位于 packages/core 的是 src/utils/git-branches.ts(以及它自己的测试)。git diff --name-only origin/main...HEAD -- packages/core/src/memory packages/core/src/services 的结果为

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


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

…oomed

Review round 2 measured the behind-based disable misfiring across
independent config axes — a `remote.<name>.push` refspec (Gerrit), forcing
refspecs, the triangular `push.default=simple` shape, the everyday
`checkout -b hotfix origin/main` name-mismatch clone, and plain last-fetch
staleness. The common thread: whether a remote will accept a push is not
decidable from local state, so every disable built on the counts acquires
another carve-out per config axis. This round makes the structural cut
instead of the next carve-out:

- Push is disabled only on a detached HEAD — the one push failure provable
  locally. Behind or diverged counts render as warning-tone hints on an
  enabled row (`↓3`, `↑1 ↓1 · diverged`), and the click surfaces git's own
  authoritative message. The derivation's contract comment now says exactly
  that.
- The information layer stays push-side and gets honest in the shapes review
  flagged: a resolved push target brings its own counts; a missing push ref
  says "Creates <target>" instead of a dimmed "Nothing to push"; a
  configured-but-unresolvable destination says nothing rather than
  presenting pull-side numbers as push-side ones.
- The `pushConfigured` probe also matches `remote.<name>.push` refspecs, so
  the Gerrit shape reads as configured rather than as a plain clone.
- A rejected push re-reads the listing and the working-tree status (the
  strongest evidence the counts were stale); a failed pull now refreshes the
  status alongside the listing so the hints don't mix snapshots.

Test hygiene from the same review: the core push-side fixtures run under a
hermetic env with `push.default` pinned; new real-git coverage for
`remote.pushDefault`, the refspec probe, per-branch case-preserving
pushRemote scoping, and a nonzero `pushBehind`; the shared popover fixture
is annotated with the wire type (it silently failed to typecheck before);
the triangular race fixture uses distinct upstream/push refs (the same-ref
contradictory counts were impossible for real git); the pull-failure test
asserts the refreshed rows, not just the fetch call; the pushGone test pins
its copy. The E2E plan is rewritten for the warn-only semantics with
`push.default` made explicit where resolution requires it.
wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 3, 2026

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

Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23146, 1769, 5727, 1919, 28043, 298, 504, 94 passed.

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

  • packages/core/src/utils/git-branches.ts:271 — [review] push-side track parse re-implements the upstream-side :track,nobracket grammar (ahead/behind/gone regexes twice in parseBranchLines) — deferred by the code-age rule: anchored on code un…
  • packages/core/src/utils/git-branches.ts:178 — [review] the 9-field for-each-ref format literal is duplicated byte-for-byte in both calls and must stay in sync — deferred by the code-age rule: anchored on code unchanged since round 2

Convergence: round 3 posted 9 inline comment(s), 9 of them reported for the first time; the previous round posted 19 (19 new). Findings keep coming back to the same files: packages/web-shell/client/components/BranchPickerPopover.tsx (findings in rounds 1, 2; 6 more now); packages/core/src/utils/git-branches.ts (findings in round 2; 3 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

Test Plan(非阻断):client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23146, 1769, 5727, 1919, 28043, 298, 504, 94 passed

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

收敛情况:第 3 轮发布了 9 条行内评论,其中 9 条是首次提出;上一轮发布了 19 条(其中 19 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/components/BranchPickerPopover.tsx(第 1、2 轮已出过发现,本轮又有 6 条);packages/core/src/utils/git-branches.ts(第 2 轮已出过发现,本轮又有 3 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/core/src/utils/git-branches.ts Outdated
Review round 3 measured the push row deciding whether it could speak from
"a push override is configured" rather than from the boundary its own
comment states — git declining to name a destination. Real git shows the
key was wrong in both directions:

- A tracking upstream whose name the branch does not match under the
  default `push.default=simple`, and `push.default=nothing`, both leave
  `%(push)` empty and make a bare `git push` exit 128, yet the row asserted
  the upstream counts for that refused push (`↑1`, `↑2`).
- A branch with no upstream in a repo that sets `remote.pushDefault` lost
  the accurate "Sets upstream on push" hint, for a push the daemon performs
  with an explicit refspec that git accepts.

Keying the silence on a live upstream with no push destination covers both,
and leaves `pushConfigured` with no reader anywhere in the product — so the
atom and the `git config --get-regexp` probe that produced it are removed
rather than extended. That also retires the probe's serial round-trip on
the listing's critical path and the two doc blocks that disagreed about
which overrides it detected.

The post-action refresh gains a single owner. It is best-effort, so a
re-read that fails next to the action that triggered it keeps the stale but
usable rows instead of replacing them with its own error; and the pull path
no longer awaits it, which had left the resolution panel's buttons disabled
for a listing round-trip the panel never needed.

Coverage: real-git core fixtures for all three silence shapes, decision
table cases for both boundary directions and for the gone-upstream in-sync
corner, a push counterpart to the pull-failure refresh test that also pins
the status leg, and cases for the best-effort refresh and the responsive
panel. Fixtures that meant to exercise the count path now carry the
push-side atoms core actually emits. Every guard added here was mutation
probed; the E2E plan gains the two states the re-keyed boundary changes.
@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 轮)。改动内容与我反驳保留之处如下:

Round 4 — review feedback addressed

Commit c2a55dc3bd on fix/webshell-push-hint-behind (6 files, +342 / −204; net −31 source lines, +146 test lines, +23 doc lines).

Headline

The round's Critical (R3-1) reproduced against real git exactly as reported, and the fix went one structural step further than the suggested patch. Re-keying the push row's silence to "git named no destination for a live upstream" — the boundary the rule site's own comment already stated — leaves the pushConfigured atom with no reader anywhere in the product (grep -rn pushConfigured over the whole tree returned exactly one non-test read site: the predicate R3-1 proved wrong). So the atom and the git config --get-regexp probe that produced it are removed rather than extended.

That single subtraction is what closed R3-1, R3-2, R3-3 and R3-8 together: there is no probe left to run serially (R3-8), no push.default=nothing gap in it (R3-2), and no pair of doc blocks disagreeing about which overrides it detected (R3-3). This is the shared root cause the review's own convergence note pointed at — four of this round's nine findings were siblings of one another.

Reproduction, before any edit

Real git 2.39.5 fixtures driven through this PR's own fetchGitBranches, then through deriveActionHints at head 9c30948dd4:

shape git push --dry-run fetchGitBranches head entry row before row after
A — push.default=simple, branch master tracking origin/bar, ahead 1 exit 128 (refused) {upstream:"origin/bar", ahead:1}, no pushTarget, no pushConfigured ↑1 info silent
B — no upstream, remote.pushDefault=fork exit 0 (accepted) {pushConfigured:true}, no upstream silent (hint dropped) setsUpstream info
C — push.default=nothing, upstream origin/master, ahead 2 exit 128 (refused) {upstream:"origin/master", ahead:2}, no pushTarget ↑2 info silent

Both directions of R3-1 confirmed, with R3-2 confirmed by shape C. Shapes A and C are pull-side counts asserted for a push git refuses outright; shape B dropped an accurate hint for a push git accepts.

Findings

id rc disposition what happened
R3-1 (Critical) 3921509327 Implemented pushSideUnknown is now head !== undefined && head.pushTarget === undefined && hasUpstream === true. Written with the already-derived hasUpstream rather than a fresh Boolean(head.upstream) && !upstreamGone — when head is present those are the same expression, and this way the upstreamGone term the comment warns about cannot be forgotten. Both witness cases added.
R2-6 (fix-induced) 3921509346 Implemented One owner: refreshAfterAction. Both catch blocks call it; the await / void choice stays at each call site, which is exactly the axis on which the two paths differ (push keeps its spinner up, pull must not wedge its panel). The open effect keeps its own non-silent pair — that is the initial load, not a post-action refresh, and it must still surface a listing error.
R3-2 3921509354 Implemented Folded into R3-1's boundary, as the comment offered. push.default=nothing with an upstream now yields no pushTarget, so the row is silent; the no-upstream shape still reaches setsUpstream and stays pushable via the daemon's explicit --set-upstream refspec. The requested deriveActionHints case is shape A above — push.default=nothing and a name-mismatched upstream produce byte-identical core output, so a second UI case would assert the same thing twice; the provenance is pinned instead by a real-git core arm for each.
R3-3 3921509358 Resolved by deletion Both doc blocks (core interface + SDK mirror) are gone with the field they described. The contradiction can no longer be written down.
R3-4 3921509367 Implemented Push counterpart added, asserting the call count and that the rows re-render from the second listing. It also pins the status leg (4 changes from the refreshed tree), which closes the second half of the comment — the void fetchStatus() mutant that previously survived.
R3-5 3921509372 Implemented fetchBranches(silent) skips only the setError branch; loading still clears in the finally, so the stale-but-usable rows come back. Applied to the two refresh callers alone — the on-open path still renders its error. Consistent with fetchStatus, which already swallowed its failures for the same reason.
R3-6 3921509376 Implemented The pull catch no longer awaits, so setBusyAction(null) runs immediately and the panel's Stash / Discard / Cancel buttons are enabled while the listing round-trip is in flight. The existing pull-failure test stays green.
R3-7 3921509384 Implemented Gone-upstream + resolved in-sync destination arm added, asserting the muted nothingToPush.
R3-8 3921509388 Resolved by deletion Strictly better than the requested parallelization: the subprocess is gone from the listing's critical path entirely, not just moved off it. The fail-fast rev-parse --git-dir probe remains serial and first, as the comment required.

Nine of nine addressed; none declined, deferred, or escalated.

Deleted tests — the content evidence

Two core tests were removed: flags remote.pushDefault and remote.<name>.push refspecs as configured and scopes per-branch pushRemote to that branch, preserving subsection case. Both existed only to pin the three locals of the deleted probe (pushDefaultSet, pushRefspecSet, pushRemoteBranches); with the probe gone there is no behaviour left for them to witness, and the field they asserted no longer exists in either type.

The part of that coverage which is not about the flag survives and is stronger than before. The Gerrit arm's expect(headG?.pushTarget).toBeUndefined() moved into the new reports no push destination when git declines to name one, which now pins all three real-git shapes that make the row silent (name-mismatched upstream under simple, push.default=nothing, remote.<name>.push refspec) and additionally asserts that no push counts are emitted in any of them.

Mutation probe for that test: making core fall back to the upstream when %(push) is empty (const pushTarget = parts[7] || upstream) turns it red with 2 failures. That is precisely the change which would silently re-introduce the pull-counts-as-push-counts mislabel this PR exists to remove, and nothing pinned it before this round.

Threads left open on purpose

rc:3916688954 (R2-10) and rc:3916688960 (R2-11) were adopted in 9c30948dd4 with tests whose replies claim live coverage — "Deleting pushDefaultSet || now fails it", "pinning both the per-branch scoping and case preservation". Those statements are false as of this commit: the code and the tests are both gone. Each thread got a reply saying so, and neither is marked resolved, so the removal is read rather than discovered later in a coverage hunt.

Failed check: web-shell E2E Smoke (ubuntu-latest, Node 22.x)

Not addressed in code, because no evidence ties it to this diff and the check cannot run on this runner. Recording the reasoning rather than guessing at a fix:

  • Unavailable here. Chromium's headless shell fails to launch with error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file. playwright install --with-deps needs root, this runner has no sudo, and there is no system browser. All 49 local smoke attempts died on that same launch error — a harness failure, not a test signal.
  • Nothing in the suite can reach this diff. playwright.config.ts sets testIgnore: '**/visuals/**', and grepping every non-visuals spec for branch-picker|BranchPicker|branchPicker|Update Project|diverged matches only client/e2e/visuals/git-branch-picker.spec.ts — which the smoke job excludes. The two changed i18n strings are branch-picker-only.
  • The browser lane that does render this UI passed at this exact head. Capture web-shell visuals is SUCCESS on 9c30948, driving the same npm run dev webServer the smoke job uses. So the client bundle builds, serves and renders in a real browser with these changes in it.
  • Timing is consistent with the fleet, not the diff. The job consumed 18m22s of a 20-minute budget. 035418958d (fix(ci): retry a contended unit attempt and bound a hung one, landed on main roughly two hours before this run) records that on this pool "the same third of the same suite measures 6.7 or 36 minutes … depending only on which host it lands on, and the same commit run three times failed three disjoint test sets."

Left to the workflow's independent CI as the final gate. If it is red again at c2a55dc3bd, the logs — not this diff — are the next thing to read.

Mutation probes

Every guard this round adds was removed or negated, the focused suite re-run, and the guard restored:

mutant result
M1 — boundary never fires (hasUpstream === truefalse) KILLED by says nothing on push when git names no destination for a live upstream
M2 — drop the head !== undefined term KILLED by falls back to status for ahead/behind when the listing has no head entry (its new push assertion)
M3 — drop the silent-refresh guard (`
M4 — pull catch awaits the refresh again KILLED by leaves the resolution panel usable while the post-failure refresh is in flight
M5 — push catch drops the refresh KILLED by 3 tests
M6 — drop `
M7 — drop void fetchStatus() from refreshAfterAction KILLED by the push-refresh test's 4 changes assertion
M8 — core: pushTarget = parts[7] || upstream KILLED by reports no push destination when git declines to name one (2 failures)

Baseline before probing was 51/51 web-shell and 102/102 core; the source was restored after each mutant and re-run green. M2 and M7 are worth calling out: both mutants survived the suite as it stood at the start of this round, so the assertions that kill them are new.

Verification

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0)
  • npx prettier --check over all six changed files — passed (after --write collapsed one chain in the new panel test; whitespace only)
  • cd packages/web-shell && npx vitest run --config vitest.config.ts (full suite) — 5730 passed, 255 files
  • cd packages/web-shell && npx vitest run --config vitest.config.ts client/components/BranchPickerPopover.test.tsx51 passed (was 48)
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts102 passed (was 103: −2 probe-only tests, +1 covering all three silence shapes)
  • cd packages/sdk-typescript && npx vitest run1769 passed, 38 files
  • The focused web-shell and core runs were repeated on the committed tree after the pre-commit hook ran — 51 and 102 passed.
  • npm run generate:settings-schema — not applicable; no settings source changed.
  • Integration tests after npm run bundle — not run. The touched behaviour is web-shell client-side hint derivation plus one listing atom in fetchGitBranches; both are exercised directly by the unit suites above (the core one against real git), so neither is reachable only through the bundled CLI or the integration harness.
  • web-shell E2E Smokecould not be run on this runner (missing libglib-2.0.so.0, no root for --with-deps, no system browser). See the section above for the attribution evidence.
中文说明

第 4 轮——已处理评审意见

提交 c2a55dc3bd,位于 fix/webshell-push-hint-behind 分支(6 个文件,+342 / −204;源码净减 31 行,测试净增 146 行,文档净增 23 行)。

要点

本轮的 Critical(R3-1)用真实 git 完整复现,与报告一致;修复比建议补丁多走了一步结构性动作。把推送行「是否开口」的判定改为*「git 未能为一个存活的 upstream 给出推送目的地」*——也就是规则处注释自己早已声明的边界——之后,pushConfigured 这个原子在整个产品里再无任何读取方(对全仓执行 grep -rn pushConfigured,非测试读取点只剩一处:正是 R3-1 证伪的那个谓词)。因此该原子连同产生它的 git config --get-regexp 探测被一并删除,而不是被扩宽。

正是这一次删减同时关闭了 R3-1、R3-2、R3-3 与 R3-8:探测不存在了,就没有串行执行的探测(R3-8),没有探测里漏掉 push.default=nothing 的缺口(R3-2),也没有两段互相矛盾的文档注释去争它到底检测哪些覆盖项(R3-3)。这就是评审自己的收敛提示所指的共同根因——本轮九条发现里有四条互为兄弟。

修改前的复现

用真实 git 2.39.5 夹具,先跑本 PR 自己的 fetchGitBranches,再把输出喂给 head 9c30948dd4 上的 deriveActionHints

形态 git push --dry-run fetchGitBranches head 条目 修复前行内显示 修复后
A — push.default=simple,分支 master 跟踪 origin/bar,领先 1 退出码 128(拒绝) {upstream:"origin/bar", ahead:1},无 pushTarget、无 pushConfigured ↑1 info 静默
B — 无 upstream,remote.pushDefault=fork 退出码 0(接受) {pushConfigured:true},无 upstream 静默(提示被丢弃) setsUpstream info
C — push.default=nothing,upstream 为 origin/master,领先 2 退出码 128(拒绝) {upstream:"origin/master", ahead:2},无 pushTarget ↑2 info 静默

R3-1 的两个方向都得到确认,形态 C 同时确认了 R3-2。形态 A 与 C 是把 pull 侧计数当作一个 git 直接拒绝的推送的计数来断言;形态 B 则丢掉了一个 git 会接受的推送的准确提示。

各条发现

编号 rc 处置 说明
R3-1(Critical) 3921509327 已实现 pushSideUnknown 现为 head !== undefined && head.pushTarget === undefined && hasUpstream === true。这里复用已推导出的 hasUpstream,而不是重新写一遍 Boolean(head.upstream) && !upstreamGone——head 存在时两者等价,而这样写就不会漏掉评论特别提醒的 upstreamGone 项。两个见证用例均已补上。
R2-6(由上轮修复引入) 3921509346 已实现 单一归属:refreshAfterAction。两个 catch 块都调用它;await / void 的选择留在各自调用点,而这恰恰是两条路径唯一不同的轴(push 要让转圈保持到刷新落地,pull 不能卡住它的解决面板)。open effect 保留它自己那份非静默的刷新对——那是首次加载,不是操作后刷新,仍然必须把列表错误显示出来。
R3-2 3921509354 已实现 按评论给出的另一条路,并入 R3-1 的边界。带 upstream 的 push.default=nothing 现在不产生 pushTarget,行内保持静默;无 upstream 的形态仍走 setsUpstream,并可经 daemon 的显式 --set-upstream refspec 推送。评论要求的 deriveActionHints 用例就是上表的形态 A——push.default=nothing 与上游名不匹配这两种情况在 core 输出上逐字节相同,再写一个 UI 用例等于把同一件事断言两遍;因此改为在 core 侧各用一个真实 git 分支来钉住它们的来源
R3-3 3921509358 通过删除解决 两段文档注释(core 接口 + SDK 镜像)随字段一起消失,矛盾再也写不出来。
R3-4 3921509367 已实现 补上 push 对称用例,既断言调用次数,也断言各行按第二次列表重渲染。它还钉住了状态这一路(刷新后的树给出 4 changes),从而关闭了该评论的后半段——此前能存活的 void fetchStatus() 变异。
R3-5 3921509372 已实现 fetchBranches(silent) 只跳过 setError 这一支;loading 仍在 finally 中清除,所以陈旧但可用的行会回来。静默模式只作用于两个刷新调用方——open 路径仍会渲染它自己的错误。这与 fetchStatus 一致,后者出于同样原因本来就吞掉自己的失败。
R3-6 3921509376 已实现 pull 的 catch 不再 await,setBusyAction(null) 立即执行,列表往返在途时面板的 Stash / Discard / Cancel 按钮可用。既有的 pull 失败刷新测试保持绿。
R3-7 3921509384 已实现 补上「upstream 已消失 + 推送目的地已解析且同步」分支,断言变灰的 nothingToPush
R3-8 3921509388 通过删除解决 严格优于所要求的并行化:这个子进程彻底从列表关键路径上消失,而不只是被挪出关键路径。快速失败的 rev-parse --git-dir 探测按评论要求保持串行且最先执行。

九条全部处理;无拒绝、无延后、无上升给维护者。

删除的测试——内容层面的依据

删除了两个 core 测试:flags remote.pushDefault and remote.<name>.push refspecs as configuredscopes per-branch pushRemote to that branch, preserving subsection case。两者仅仅用于钉住被删探测的三个局部量(pushDefaultSetpushRefspecSetpushRemoteBranches);探测删除后已无行为可供见证,它们断言的字段在两个类型里也都不存在了。

其中不属于该标志位的覆盖被保留,并且比之前更强。Gerrit 分支的 expect(headG?.pushTarget).toBeUndefined() 移入了新的 reports no push destination when git declines to name one;该测试现在钉住让行内静默的全部三种真实 git 形态(默认 simple 下上游名不匹配、push.default=nothingremote.<name>.push refspec),并额外断言三者都不产生任何推送计数。

针对该测试的变异探针:让 core 在 %(push) 为空时回落到 upstream(const pushTarget = parts[7] || upstream),它以 2 个失败变红。这恰恰是会悄悄重新引入「pull 计数冒充 push 计数」错标的改动,而本 PR 存在的意义就是消除它——在本轮之前没有任何测试钉住这一点。

有意保持未解决的讨论串

rc:3916688954(R2-10)与 rc:3916688960(R2-11)在 9c30948dd4 中被采纳,其回复声称存在有效覆盖——「删除 pushDefaultSet || 现在会让它变红」「同时钉住按分支作用域与大小写保留」。自本次提交起这两句话已不成立:代码与测试都不存在了。两条讨论串各自收到一条说明此事的回复,且都不标记为已解决,以便这次删除是被读到的,而不是日后在排查覆盖率时才被发现。

失败的检查:web-shell E2E Smoke (ubuntu-latest, Node 22.x)

未在代码层面处理,因为没有证据把它与本 diff 关联起来,且该检查无法在本 runner 上运行。这里记录推理过程,而不是猜一个修复:

  • 本机不可用。 Chromium 的 headless shell 启动失败:error while loading shared libraries: libglib-2.0.so.0: cannot open shared object fileplaywright install --with-deps 需要 root,本 runner 没有 sudo,也没有系统浏览器。本地 49 个 smoke 用例全部死于同一个启动错误——这是测试装置失败,不是测试信号。
  • 该套件无法触达本 diff。 playwright.config.ts 设置了 testIgnore: '**/visuals/**';对所有非 visuals 的 spec 搜索 branch-picker|BranchPicker|branchPicker|Update Project|diverged,只匹配到 client/e2e/visuals/git-branch-picker.spec.ts——而 smoke 作业恰好排除它。改动的两个 i18n 字符串也只服务于分支选择器。
  • 真正渲染这块 UI 的浏览器通道在同一 head 上通过了。 Capture web-shell visuals9c30948 上是 SUCCESS,它驱动的是与 smoke 作业相同的 npm run dev webServer。也就是说带着这些改动的客户端产物能够在真实浏览器里构建、服务并渲染。
  • 耗时与机器池状况吻合,与 diff 无关。 该作业在 20 分钟预算里用掉 18 分 22 秒。035418958dfix(ci): retry a contended unit attempt and bound a hung one,在本次运行前约两小时合入 main)记录道:在这个池子上「同一套件的同一部分,仅取决于落到哪台主机,就可能测得 6.7 分钟或 36 分钟;同一个提交跑三次,失败的是三组互不相交的测试」。

交给工作流独立的 CI 作为最终闸门。若它在 c2a55dc3bd 上再次变红,下一步该读的是日志,而不是这个 diff。

变异探针

本轮新增的每一处护栏都被删除或取反,重跑聚焦套件,然后再还原:

变异 结果
M1 — 边界永不触发(hasUpstream === truefalse 被杀,由 says nothing on push when git names no destination for a live upstream
M2 — 去掉 head !== undefined 这一项 被杀,由 falls back to status for ahead/behind when the listing has no head entry(其新增的推送断言)
M3 — 去掉静默刷新护栏(|| silent 被杀,由 keeps the stale rows when the post-failure re-read itself fails
M4 — pull 的 catch 重新 await 刷新 被杀,由 leaves the resolution panel usable while the post-failure refresh is in flight
M5 — push 的 catch 去掉刷新 被杀,由 3 个测试
M6 — 从 Nothing-to-push 分支去掉 || pushKnown 被杀,由 reasons about the push target, not the upstream, when they differ
M7 — 从 refreshAfterAction 去掉 void fetchStatus() 被杀,由 push 刷新测试的 4 changes 断言
M8 — core:pushTarget = parts[7] || upstream 被杀,由 reports no push destination when git declines to name one(2 个失败)

探测前基线为 web-shell 51/51、core 102/102;每个变异之后都还原源码并重跑至绿。M2 与 M7 值得单独指出:这两个变异在本轮开始时的套件下都能存活,杀死它们的断言是本轮新增的。

验证

  • npm run build —— 通过(退出码 0)
  • npm run typecheck —— 通过(退出码 0)
  • npm run lint —— 通过(退出码 0)
  • 对全部六个改动文件执行 npx prettier --check —— 通过(此前用 --write 把新面板测试里的一处链式调用折叠了,纯空白改动)
  • cd packages/web-shell && npx vitest run --config vitest.config.ts(全量套件)—— 5730 通过,255 个文件
  • cd packages/web-shell && npx vitest run --config vitest.config.ts client/components/BranchPickerPopover.test.tsx —— 51 通过(原 48)
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts —— 102 通过(原 103:删掉 2 个仅服务于探测的测试,新增 1 个覆盖全部三种静默形态)
  • cd packages/sdk-typescript && npx vitest run —— 1769 通过,38 个文件
  • pre-commit 钩子跑完后,在已提交的树上重跑了 web-shell 与 core 的聚焦测试——分别 51 与 102 通过。
  • npm run generate:settings-schema —— 不适用;未改动任何 settings 源。
  • npm run bundle 之后的集成测试 —— 未运行。改动的行为是 web-shell 客户端的提示推导,外加 fetchGitBranches 里的一个列表原子;两者都被上面的单元测试直接覆盖(core 那部分是对着真实 git 跑的),因此都不是只能通过打包后的 CLI 或集成装置才能触达的路径。
  • web-shell E2E Smoke —— 无法在本 runner 上运行(缺 libglib-2.0.so.0,无 root 执行 --with-deps,无系统浏览器)。归因证据见上一节。

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

🧵 Resolved all 29 selected review thread(s). · 已关闭全部选中的 29 条评审线程。

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-2026-09-02

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

No action this round — nothing actionable was reported

No code change was made and no commit was created. The branch stays at
c2a55dc3bd on fix/webshell-push-hint-behind, identical to
origin/fix/webshell-push-hint-behind; the working tree is clean.

What the round contained

feedback.md listed no reviews, no inline comments, and no issue-level
comments
newer than the last evaluation (2026-09-03T06:12:33Z), and no
still-red checks. The only entry under Failed checks was:

  • Signal the reviewed fork PR: CANCELLED

There was no retry context, no rejected-round notice, no budget warning, no
Deferred non-Critical feedback section (so the critical-only brake is not
engaged), and no Growth audit required section. Reported growth is
source −8 / test +146 against budgets of 400/400 with 0 prior rounds over
budget — comfortably inside budget, so no growth audit was owed either.

The one "failed check" is not a defect, and not this PR's code

Diagnosed from checks.json and the workflow definition rather than assumed:

  1. It is the autofix loop's own signalling job, not a build or test check.
    Signal the reviewed fork PR is the single job of the
    Qwen Autofix Fork Signal workflow
    (.github/workflows/qwen-autofix-fork-signal.yml:67). That workflow runs no
    repository code at all — it declares permissions: {}, performs no
    checkout, and its only purpose is to complete success so the base-context
    bridge half can start a real scan. It cannot fail on anything in this diff.

  2. CANCELLED here is the workflow's documented, deliberate coalescing
    behaviour.
    That file sets
    concurrency: cancel-in-progress: true with a per-PR group for trusted
    reviews (qwen-autofix-fork-signal-pr-<number>), and its own comment states
    the intent: "Trusted reviews coalesce PER PR (two reviews seconds apart
    signal once)."
    Two review events landed within seconds, so the first signal
    run was cancelled by the second.

  3. The superseding run is already in flight — it self-healed. checks.json
    shows the cancelled run started 08:01:27Z and completed 08:01:32Z (5
    seconds), and a second Signal the reviewed fork PR run started 08:01:33Z
    in QUEUED state. The same pattern covers the other cancelled job,
    Qwen Autofix / route (cancelled 08:01:27Z08:01:32Z, superseded by the
    route run IN_PROGRESS from 08:03:10Z — which is this very round).

  4. Nothing is actually red. Across all 41 check runs there is not a
    single FAILURE conclusion
    . Every other entry is SUCCESS, SKIPPED,
    QUEUED, or IN_PROGRESS. The substantive CI — Qwen Code CI,
    Qwen Live Host CI, Security Checks, Web-shell Visuals, and
    🧐 Qwen Pull Request Review — had not reported yet at snapshot time; those
    runs remain the final verification gate and were still pending.

Even if a change to that workflow were desirable, it would be out of bounds:
.github/ is CI and verification machinery this PR was never about, and the
loop's own workflow files are explicitly off-limits to this skill. The correct
response to a concurrency cancellation of the signalling job is no response.

Independent confirmation that no feedback was missed

Because a cancelled check was the sole signal, I verified the empty feedback
sections against the raw payload rather than trusting them at face value.
Scanning rc.json (55 review comments), rv.json (27 reviews), and
ic.json (11 issue comments) for anything newer than the 06:12:33Z cutoff
found only this loop's own round-4 output, all authored by
qwen-code-dev-bot:

  • two COMMENTED reviews with empty bodies (the containers for the replies);
  • two inline replies on packages/core/src/utils/git-branches.ts, both opening
    "Superseded in c2a55dc3bd, not left standing…";
  • one issue-level round summary, "🤖 Addressed the latest review feedback
    (round 2/100)… # Round 4 — review feedback addressed"
    .

These are echoes of the round that just landed, which the deterministic filter
correctly excluded — they are neither maintainer feedback nor automated-reviewer
findings. The two inline replies deliberately leave their threads open
rather than resolved, which is consistent with them being recorded pushbacks
from the prior round, not new work.

A methodological note worth recording: my first scan of these files returned
"nothing newer" because it read camelCase keys (createdAt, submittedAt).
The payloads use snake_case (created_at, submitted_at), so that pass parsed
zero timestamps and its clean result was meaningless. I re-ran against the
real field names and confirmed each file's newest timestamp
(08:01:24Z / 08:01:26Z / 08:01:28Z) before drawing the conclusion above.
The finding stands on the corrected scan.

Why this is "no action" rather than a failure or an escalation

  • No finding was reported, so there is nothing to reproduce, fix, decline,
    defer, or escalate.
  • The one flagged check is a superseded infrastructure run with no code cause,
    so there is no failing required check to repair — an unavailable or
    cancelled CI job is not a failed runnable local check.
  • Round 4 already landed and was deterministically verified; re-touching the
    diff now would only grow it against a budget that is currently healthy, which
    AGENTS.md's Simplicity First and this skill's diff-growth guidance both argue
    against.

Verification

Commands actually run this round (all read-only; no build, typecheck, lint, or
test run was warranted because no source file was modified):

  • git log --oneline -5 + git status --short — passed; HEAD is c2a55dc3bd,
    in sync with origin/fix/webshell-push-hint-behind, working tree clean, no
    unintended files.
  • git diff origin/main...HEAD --stat / full diff — passed; read to re-confirm
    the PR footprint (7 files: core git-branches + tests, sdk daemon types,
    web-shell BranchPickerPopover + tests, i18n.tsx, one e2e-test doc). No
    --conflict merge was performed since --conflict was false.
  • checks.json analysis (41 runs, grouped by conclusion and timestamp) —
    passed; zero FAILURE conclusions, both CANCELLED jobs traced to
    superseding runs.
  • qwen-autofix-fork-signal.yml read — passed; confirmed
    cancel-in-progress: true per-PR concurrency group and the permissions: {}
    / no-checkout design that makes the job incapable of failing on this diff.
  • rc.json / rv.json / ic.json timestamp scan — passed on the corrected
    snake_case pass; no post-cutoff maintainer or automated-reviewer feedback
    exists.

Files written: no-action.md only. No commit, no address-summary.md, no
resolved-comments.txt, no comment-replies.json, no deferred-findings.json,
no growth-audit.json, no failure.md, no handoff.md.

中文说明

本轮无需处理 —— 没有可执行的反馈

本轮未修改任何代码,也未创建任何提交。分支停留在
fix/webshell-push-hint-behindc2a55dc3bd,与
origin/fix/webshell-push-hint-behind 完全一致;工作区干净。

本轮收到的内容

feedback.md没有任何评审、没有任何行内评论、也没有任何 issue 级评论
晚于上次评估时间(2026-09-03T06:12:33Z),也没有持续标红的检查项。
Failed checks 下唯一的条目是:

  • Signal the reviewed fork PR: CANCELLED

没有重试上下文、没有"上一轮被拒绝"的提示、没有预算警告、没有
Deferred non-Critical feedback 章节(说明"仅处理 Critical"的刹车并未触发),
也没有 Growth audit required 章节。上报的增量为
源码 −8 / 测试 +146,预算为 400/400,此前 0 轮超预算 ——
远在预算之内,因此也不欠一次增长审计。

唯一的"失败检查"既不是缺陷,也不属于本 PR 的代码

以下结论基于 checks.json 与工作流定义诊断得出,而非臆测:

  1. 它是 autofix 循环自身的信号任务,不是构建或测试检查。
    Signal the reviewed fork PRQwen Autofix Fork Signal 工作流唯一的
    job(.github/workflows/qwen-autofix-fork-signal.yml:67)。该工作流完全
    不执行仓库代码 —— 它声明 permissions: {}、不做 checkout,其唯一目的是
    success 完成,好让 base 上下文的 bridge 那一半去启动真正的扫描。
    它不可能因为本 diff 中的任何内容而失败。

  2. 这里的 CANCELLED 是该工作流有文档记录的、刻意设计的合并行为。
    该文件设置了 concurrency: cancel-in-progress: true,并对受信评审使用
    按 PR 分组的并发组(qwen-autofix-fork-signal-pr-<number>);文件自身的
    注释写明了意图:"受信评审按 PR 合并(相隔数秒的两次评审只发一次信号)"
    两个评审事件在数秒内相继到达,于是第一个信号运行被第二个取消。

  3. 取代它的运行已经在进行中 —— 它已自愈。 checks.json 显示被取消的运行
    08:01:27Z 开始、08:01:32Z 结束(5 秒),而第二个
    Signal the reviewed fork PR 运行于 08:01:33Z 开始,状态为 QUEUED
    另一个被取消的 job Qwen Autofix / route 也是同样模式
    08:01:27Z08:01:32Z 被取消,由 08:03:10ZIN_PROGRESS
    route 运行取代 —— 那正是本轮自身)。

  4. 实际上没有任何检查是红的。 在全部 41 个检查运行中,
    没有任何一个结论是 FAILURE。其余条目全部是 SUCCESSSKIPPED
    QUEUEDIN_PROGRESS。真正实质性的 CI —— Qwen Code CI
    Qwen Live Host CISecurity ChecksWeb-shell Visuals 以及
    🧐 Qwen Pull Request Review —— 在快照时刻尚未出结果;这些运行仍是最终的
    验证关卡,当时仍在进行中。

即便修改那个工作流是可取的,也超出了权限边界:.github/ 属于 CI 与验证
机制,而本 PR 从来就不涉及它,且循环自身的工作流文件明确禁止本 skill 改动。
对信号任务的并发取消,正确反应就是不做反应。

独立确认没有遗漏任何反馈

由于被取消的检查是唯一的信号,我没有直接采信那几个空的反馈章节,而是对照
原始数据做了核验。扫描 rc.json(55 条评审评论)、rv.json(27 条评审)与
ic.json(11 条 issue 评论),查找晚于 06:12:33Z 截止时间的内容,结果
只找到本循环自己第 4 轮的产出,作者均为 qwen-code-dev-bot

  • 两条 body 为空的 COMMENTED 评审(即那些回复的容器);
  • 两条位于 packages/core/src/utils/git-branches.ts 上的行内回复,均以
    "Superseded in c2a55dc3bd, not left standing…" 开头;
  • 一条 issue 级轮次总结:"🤖 Addressed the latest review feedback
    (round 2/100)… # Round 4 — review feedback addressed"

这些是刚刚落地那一轮的回声,确定性过滤器正确地将其排除 —— 它们既不是维护者
反馈,也不是自动评审器的发现。那两条行内回复刻意让其讨论串保持未解决
状态而非标记为已解决,这与它们是上一轮记录在案的反驳、而非新工作相一致。

一个值得记录的方法论说明:我第一次扫描这些文件时得出"没有更新内容",是因为
读取了驼峰式字段名(createdAtsubmittedAt)。而这些数据实际使用蛇形命名
created_atsubmitted_at),所以那一次解析到了零个时间戳,其"干净"
结果毫无意义。我按真实字段名重新运行,并确认了每个文件的最新时间戳
08:01:24Z / 08:01:26Z / 08:01:28Z),才得出上述结论。
该结论建立在修正后的扫描之上。

为什么这是"无需处理",而不是失败或上报

  • 没有任何发现被报告,因此没有东西需要复现、修复、拒绝、延后或上报。
  • 唯一被标记的检查是一个已被取代的基础设施运行,没有代码层面的成因,
    因此不存在需要修复的失败必检项 —— 不可用或被取消的 CI job 不等于
    本地可运行检查失败。
  • 第 4 轮已经落地并通过了确定性验证;此刻再去改动 diff 只会让它在当前
    健康的预算下继续增长,而这与 AGENTS.md 的"简洁优先"以及本 skill 的
    diff 增长指引都是相悖的。

验证

本轮实际执行的命令(全部为只读;由于没有修改任何源文件,因此没有必要运行
构建、类型检查、lint 或测试):

  • git log --oneline -5 + git status --short —— 通过;HEAD 为 c2a55dc3bd
    origin/fix/webshell-push-hint-behind 同步,工作区干净,无意外文件。
  • git diff origin/main...HEAD --stat / 完整 diff —— 通过;读取以再次确认
    PR 的改动范围(7 个文件:core 的 git-branches 及其测试、sdk daemon
    types、web-shell 的 BranchPickerPopover 及其测试、i18n.tsx,以及一份
    e2e 测试文档)。由于 --conflictfalse,未执行任何合并。
  • checks.json 分析(41 个运行,按结论与时间戳分组)—— 通过;
    FAILURE 结论为零,两个 CANCELLED job 均追溯到取代它们的运行。
  • 读取 qwen-autofix-fork-signal.yml —— 通过;确认了
    cancel-in-progress: true 的按 PR 并发组,以及 permissions: {} /
    不 checkout 的设计使该 job 不可能因本 diff 失败。
  • rc.json / rv.json / ic.json 时间戳扫描 —— 在修正为蛇形命名后通过;
    不存在截止时间之后的维护者或自动评审器反馈。

写入的文件:仅 no-action.md。没有提交,没有 address-summary.md
resolved-comments.txtcomment-replies.jsondeferred-findings.json
growth-audit.jsonfailure.mdhandoff.md

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 6b": run the changed test suites ( packages/core git-branches.test.ts and packages/web-shell BranchPickerPopover.test.tsx) — the review worktree has no node_modul….

Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23145, 1769, 5730, 1919, 28043, 298, 504, 94 passed.

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

  • packages/web-shell/client/components/BranchPickerPopover.tsx:213 — [probe] the push row's status-counter ahead fallback ( : ahead ) has no test — a : 0 regression survives the whole suite (mutant measured) — deferred by the code-age rule:…

Convergence: round 4 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 9 (9 new). Findings keep coming back to the same files: packages/web-shell/client/components/BranchPickerPopover.tsx (findings in rounds 2, 3; 3 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 6b"run the changed test suites ( packages/core git-branches.test.ts and packages/web-shell BranchPickerPopover.test.tsx) — the review worktree has no node_modul…

Test Plan(非阻断):client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23145, 1769, 5730, 1919, 28043, 298, 504, 94 passed

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

收敛情况:第 4 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 9 条(其中 9 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/components/BranchPickerPopover.tsx(第 2、3 轮已出过发现,本轮又有 3 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

中文说明

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

@wenshao

wenshao commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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

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

Scripted assertions: 443 passed · 0 failed · 443 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

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

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

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

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 10754 deep verification — fix(web-shell): disable Push while the branch is behind its upstream

Verdict: merge-ready — scripted assertions: 443 pass / 0 fail (details in assertions.json; the 8-mutant mutation matrix is reported separately below because its kill expectations were adjudicated from captured logs, not harness-encoded).
Verified head: 496bc770491a694dd685830083d81748da529207 (git rev-parse HEAD^2), base 93e1597b (HEAD^1, matches baseRefOid in the metadata snapshot). Merge commit f3ebe55a.
Environment: CI verify container (node:22-bookworm), npm ci + npm run build pre-run; no GitHub token; local tree + metadata snapshot only.

中文摘要

结论:merge-ready。443 条脚本化断言全部通过,0 失败。

  • A/B 结论(核心):在 13 个真实 git 夹具状态上(含三角 fork 的 simple/current、Gerrit refspec、push.default=nothing、分支名不匹配、pushGone、上游已删、无上游+remote.pushDefault、detached),HEAD 构建解析出的 pushTarget/pushAhead/pushBehind/pushGone 与 git 自身 %(push:short)/%(push:track,nobracket) 原子逐字段一致(51/51),BASE 构建完全不发射 push 字段,pull 侧字段两臂逐字节相同。派生层 14 格决策表在两臂各 14/14 通过:BASE 臂实测出 6 格误导行为(behind 分支显示变灰的"无待推送"、被 git 拒绝的 push 显示 ↑1 等),HEAD 臂全部翻转为忠实提示(↓3 警告但可点、不可解析时沉默、Creates origin/<branch>、仅 detached 禁用)。
  • 变异矩阵:8/8 守卫全部被点名的行为测试杀死(含 F2 平局断言:>=> 恰好使 1 个新测试失败,与 commit 声明一致);还原后对照全绿。
  • Findings:1 条 Suggestion(静默刷新仍会置 loading=true,刷新往返期间行列表短暂被"Loading…"替换;所宣称的"错误不替换可用行"性质未被破坏且已被测试钉住);1 条 Nit(PR 正文测试计数与 Linux 实测略有出入:103→102、225→228,两套均全绿)。
  • 未覆盖:逐 commit 归因(浅克隆仅 3 个可达提交,元数据为 6 个);真实 qwen serve + 浏览器 DOM 的 11 状态手测(本次在夹具+派生层复现了形状,非 daemon 点击链路的成因复现);Windows/Linux 真机 UI;仓库级全量测试。

Central claim and A/B evidence

Central claim. The branch picker's Push row reports on the push destination (git's own %(push) resolution), warns instead of disabling when the last-fetch counts suggest a push would be rejected, and disables only on a detached HEAD — the one push failure provable from local state.

Secondary claims. (1) The core listing's new push-side fields are faithful across triangular/Gerrit/push.default shapes; (2) failed pull / rejected push re-read the listing and status (self-healing counts), and a failed silent re-read never replaces usable rows with its own error.

A/B 1 — core listing (fetchGitBranches), 13 real-git states

Each fixture is a real repository built by build-fixtures.sh; the oracle is git itself (an independent git for-each-ref for the push atoms) plus the behavior a click would actually meet (bare git push --dry-run exit code). Base arm = bundle of git-branches.ts from HEAD^1; head arm = bundle from HEAD (esbuild-standalone, no workspace imports in either — the module's only local import is ./gitDirect.js, bundled in). Witness: 01-core-ab-head-vs-base.png.

state git push target git ahead/behind head parsed base push fields bare git push --dry-run rc
s01 ahead-1 (control) origin/main 1/0 identical absent 0 (pushable)
s02 behind-3 origin/main 0/3 identical absent 1 (non-ff rejection)
s03 diverged ↑1↓2 origin/main 1/2 identical absent 1 (non-ff rejection)
s04 conflicted merge origin/main 2/2 identical absent 1
s05 upstream gone + push gone origin/main gone identical absent 0 (push creates the ref)
s06 triangular push.default=current origin/main 2/0 identical absent 0
s07 triangular default simple (none) identical absent 0 (push succeeds via pushRemote)
s08 pushGone, no upstream origin/feat-x gone identical absent 0 (creates)
s09 name-mismatch under simple (none) identical absent 128 (git refuses)
s09b push.default=nothing (none) identical absent 128 (git refuses)
s10 Gerrit refs/for/* refspec, behind 2 (none) identical absent 0 (push succeeds via refspec)
s10b no upstream + remote.pushDefault (none) identical absent 128 (daemon uses --set-upstream)
s11 detached detached: true detached: true 128

51/51 scripted checks pass: head parse == git oracle on every state, base emits no push fields anywhere, pull side (upstream/upstreamGone/ahead/behind) byte-identical between arms, every dry-run rc matches the encoded ground truth. The behavioral column is what justifies the design: the shapes where the head build stays silent (s07/s09/s09b/s10) are exactly the shapes where a bare push either succeeds for reasons the counts can't see or is refused outright — pull-side numbers would have been wrong in both directions. The shipped production artifact carries the change: packages/core/dist/src/utils/git-branches.js contains the push:short/push:track atoms (grep count 2).

A/B 2 — derivation (deriveActionHints), 14-cell decision table

Drove the exported deriveActionHints with the real EN translator (getTranslator('en')) through the 13 fixture shapes plus the no-listing status fallback — same inputs on both arms, expectations per arm. Witness: 02-derive-ab-decision-table.png. Result: 14/14 on head, 14/14 on base (logs/derive-ab-head.log, logs/derive-ab-base.log); the base arm passing is the proof the misleading cells below really existed at the merge base.

cell base (merge base) head (PR) flip
behind-3 clean dimmed "Nothing to push", enabled ↓3 warning, enabled yes
behind-3 dirty dimmed "Nothing to push", enabled ↓3 warning, enabled yes
diverged ↑1↓2 "↑1 ↓2 · update first" warning "↑1 ↓2 · diverged" warning copy only
triangular current (↓3 upstream, ↑2 fork) dimmed "Nothing to push" (2 pushable commits invisible) ↑2 info — push side wins yes
triangular simple (git names no destination) dimmed "Nothing to push" no hint, enabled yes
name-mismatch simple (git refuses, rc=128) ↑1 info — asserts a push git rejects no hint, enabled yes
pushGone, no upstream "Sets upstream on push" "Creates origin/feat-x" info refined
upstream gone + push gone "Sets upstream on push" "Creates origin/main" info refined
Gerrit refspec behind-2 dimmed "Nothing to push" no hint, enabled yes
no upstream + remote.pushDefault "Sets upstream on push" "Sets upstream on push" control
ahead-1 ↑1 ↑1 control
detached HEAD "Detached HEAD", disabled "Detached HEAD", disabled control
conflicted merge "Merging" warning, enabled "Merging" warning, enabled control
no listing, status ahead-2 ↑2 ↑2 control

6 cells flip from misleading to faithful; the only disabled push anywhere in the table is detached HEAD, on both arms. The aheadBehind copy change ("update first" → "diverged") has exactly one consumer — the push row (grep-verified); the pull row is untouched.

Corrections

None — first verification round for this PR; no prior-round claims to correct.

Findings

S1 — silent post-action refresh still flips the public loading state (Suggestion)

fetchBranches(silent = true) begins with an unconditional setLoading(true) / setError(null) (BranchPickerPopover.tsx ~L385-390), and the render swaps the row list for the "Loading…" indicator while loading is true (L773-778). So during a post-action refresh — including the failed re-read path the comment describes as "stale but usable" — the rows briefly disappear behind the loading indicator, then return (on success with fresh data; on failure with the stale rows and no error). The property the PR claims and pins — a failed silent re-read never replaces the rows with its own error — holds (mutation M7 below proves the test bites, and the test asserts not.toContain('daemon generation closed') with both rows present). The residual is cosmetic: a flicker of the row list during the round-trip. If the intent is for stale rows to stay visible throughout, the silent path needs to skip setLoading(true) (e.g. if (!silent) setLoading(true)); the pull leg already shows the intended pattern — its resolution panel is deliberately kept usable mid-refresh.

N1 — claimed test counts differ slightly from Linux measurements (Nit)

The PR body says git-branches.test.ts has "103 passed" and the four web-shell files "225 passed (225)". Measured on this Linux runner: 102 (confirmed by vitest list: 102 collected, no skips in play) and 228 (Linux runs platform-gated tests the author's macOS box skips or vice versa). Both suites are fully green; the numbers in the description are stale by one and three respectively.

Not covered

  • Per-commit attribution. The checkout is depth 2 (shallow); git rev-list HEAD^1..HEAD^2 reaches 1 commit locally while the metadata snapshot lists 6. The aggregate HEAD^1..HEAD diff is what was verified; the commit messages' individual claims (e.g. round-1's disable-on-behind, later reverted) were not exercised per commit.
  • Live daemon/browser runs. The 11 manual E2E states were reproduced at the fixture + derivation layer — i.e. the shape (listing fields, hints, disabled state) is reproduced, not the cause chain through a running qwen serve, real click, and git process. The daemon route itself was verified statically: workspace-git-branches.ts sends local: result.local verbatim, so the new optional fields pass through unchanged; the SDK diff is type-only (pushTarget appears only in dist/daemon/types.d.ts, zero runtime delta — realpath-asserted, no base-arm confound).
  • Competing-push 409 race and Windows/Linux real-machine UI — unit-fixture coverage only (the PR's own), not re-driven end to end here.
  • Repo-wide test suite, integration tests, bundle output — the PR's own CI covers these; targeted gates were run instead.
  • One environment oddity, unrelated to the PR: the tracked file .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md is missing from the container's disk (pre-existing D in git status at session start, read-only mount); its content was read from git show HEAD:… and used as the state checklist.

Mutation matrix (8/8 killed; unmutated controls green)

Each guard the PR introduces, reverted alone, with the suite that should catch it (witness: 03-mutation-matrix.png, raw: logs/mutation-matrix.log). Kill expectations were adjudicated by reading the captured failure output — every failure names the intended behavioral assertion, none is an import/compile break. Positive control: M4 fails exactly one test, matching the commit message's claim.

# mutation (single-hunk revert) suite result
M1 remove the pushBehind > 0 warning branch BranchPickerPopover.test.tsx killed (3 tests: "warns on push… behind the destination", +2)
M2 remove the pushSideUnknown silence branch same killed ("says nothing on push when git names no destination")
M3 pushDisabled = detached || behind > 0 (revive F1 disable) same killed (4 tests incl. competing-push panel)
M4 newerStatus tie-break >=> same killed — exactly 1 test ("breaks a computedAt tie…")
M5 core: drop !pushGone from the count parse git-branches.test.ts killed ("marks a resolvable push destination… pushGone")
M6 remove await refreshAfterAction() on rejected push BranchPickerPopover.test.tsx killed (3 post-failure-refresh tests)
M7 remove || silent (failed silent refresh surfaces its error) same killed ("keeps the stale rows when the re-read itself fails")
M8 remove void refreshAfterAction() on failed pull same killed (2 tests; spy called 1× instead of 2×)
restored controls (web-shell + core, unmutated) both green

Vacuity: the central new tests are load-bearing — reverting the guard each pins produces the exact behavioral failure it was written for. No survivors; nothing to classify.

Targeted gates

gate result
packages/core git-branches.test.ts 102 passed (102)
packages/web-shell 4 files (BranchPickerPopover, WorkspaceSection, EnvironmentPanel, ChatEditor) 228 passed (228), 4 files
typecheck core / web-shell / sdk-typescript rc=0 / rc=0 / rc=0
eslint on the 4 changed files clean (rc=0); gate proven live — a planted unused-variable probe in the same directory was caught (rc=1), then removed

Methodology

CI verify container, PR checked out as merge commit f3ebe55a (depth 2). HEAD^2 is itself a fresh merge of main into the branch (2026-09-03 10:58Z), so the verified tree is the landing shape. Assertion breakdown for assertions.json: core A/B 51 + derivation A/B 28×2 (14 cells × 2 expects per arm) + core gate 102 + web-shell gate 228 + typecheck 3 + eslint clean/probe 2 + dist-atoms check 1 = 443. Fixtures: 13 real git repositories built by build-fixtures.sh (bare "upstream"/"fork" remotes seeded with c1, fixtures cloned, remotes advanced, fixtures fetched — so behind/diverged/gone states are genuine; each fixture's behavioral truth probed by bare git push --dry-run, logs/oracle-ground-truth.txt). Core A/B: fetchGitBranches bundled standalone with esbuild from each tree's source (module has no workspace imports; base bundle grep-verified free of the push atoms), driven over the fixtures by core-ab.mjs, compared field-by-field against an independent git for-each-ref call. Derivation A/B: push-ab-harness.test.tsx (copied into each tree's packages/web-shell/client/components/, run under the package's own vitest, removed after each run) with ARM=head|base selecting per-arm expectations; the base arm resolved @qwen-code/sdk through the root node_modules symlink into the head tree — confound excluded because the SDK diff is type-only (verified: no runtime pushTarget in dist). Mutations applied with a uniqueness-asserting single-occurrence replace, reverted with git checkout, controls re-run green. Raw logs in logs/, harnesses and fixture builder in this directory.

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/core/src/utils/git-branches.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/git-branches.test.ts
file packages/web-shell/client/components/BranchPickerPopover.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/BranchPickerPopover.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/utils/git-branches.test.ts: PPPPP
  packages/web-shell/client/components/BranchPickerPopover.test.tsx: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 2 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 3 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 4 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 5 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)

Evidence images

01-core-ab-head-vs-base

02-derive-ab-decision-table

03-mutation-matrix

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

Qwen Code · sandboxed verification

qqqys
qqqys previously approved these changes Sep 3, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE — 独立复验 @ 496bc770

Reviewed at head 496bc770491a694dd685830083d81748da529207(c2a55dc3 后仅合入 main;round-4 评审所对内容即当前 PR 内容)。

历史阻塞核对: 最后一条站立 Critical 为 R3-1(push 提示边界两处皆偏):已在 head 逐锚核实双向闭合——(a) pushSideUnknown 现按「存在活 upstream 而 git 未给出 pushTarget」判定(BranchPickerPopover.tsx :210-212,hasUpstream 已含 !upstreamGone,:151-153,与 finding 草拟式等价),name-mismatch 见证 branches({ upstream: 'origin/bar', ahead: 1 }) → push undefined 钉在 test :946-953;(b) pushConfigured 整个键在 c2a55dc3 被移除——推送目的地完全交给 git 的 %(push:short)/%(push:track) 解析(git-branches.ts 的 for-each-ref 扩列,不再本地重推优先级),无 upstream(含 repo 级 override 场景与 gone-upstream 变体)恢复 setsUpstream 提示,见证在 test :835-839/:853-857。更早各轮 Critical 均已在后续轮闭合(round-4 复审对同一内容零 Critical)。reviewDecision 上的 CHANGES_REQUESTED 旗标来自已被其自身后续评审取代的陈旧一轮,不构成独立证据。

当前 Critical-only 扫描: 核心 diff 读毕——format 字符串 9 字段与 parts[7]/[8] 索引一致,NUL 分隔无 subject 污染,pushGone\bgone\b 跟踪标记判定、仅在有 target 时计算 ahead/behind;sdk types 为纯可选字段扩展(向后兼容);UI 三态(known/unknown/none)边界与注释自洽;无新可证明阻塞缺陷。

CI(非门禁): 本 head 17 项成功、7 路径性跳过、1 在途、0 失败。

✅ APPROVE — 唯一站立 Critical 已在精确 head 核实修复且各有红/绿见证,提交批准。

— 衍星 · read-only PR review (posted as qqqys)

@wenshao
wenshao enabled auto-merge September 3, 2026 11:51

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

⚠️ Round 5, and the diff has grown 4.7x since this review first measured it (92 → 430 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

Not explored to full depth (tool budget reached): "agent 3b": none — nothing was cut short..

Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23144, 1769, 5739, 1919, 298 passed.

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

  • packages/core/src/utils/git-branches.ts:233 — [review] D5-1 push-side track parse re-implements the upstream-side :track,nobracket grammar (ahead/behind/gone regexes twice in parseBranchLines) — re-surfaces the round-3 deferred finding; def…
  • packages/core/src/utils/git-branches.test.ts:377 — [review] D5-2 new push-side tests inline second-clone boilerplate the file's makeUpstream()/remoteCommit() helpers already provide — deferred by the code-age rule: anchored on code unchange…
  • packages/core/src/utils/git-branches.ts:146 — [probe] D5-3 git 2.13-2.21 %(push:track,nobracket) reports upstream counts, mislabeling the push row in triangular repos (verified by building git 2.21.0) — deferred by the code-age rule: anchor…
中文说明

已审查。 建议见行内评论。

⚠️ 第 5 轮,且自本审查首次测量以来 diff 已增长 4.7 倍(源码 diff 行数 92 → 430)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

未探索到全部深度(达到工具调用预算):"agent 3b"none — nothing was cut short.

Test Plan(非阻断):client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23144, 1769, 5739, 1919, 298 passed

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

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

Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

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

Round 7 (same-run verification repair) — BLOCKED, nothing committed

The rejected commit 6103cd80b8 is preserved unchanged and the working tree is
clean. No follow-up commit was made, because the supplied deterministic rejection is
not a defect in this PR and its only correct fix lies in CI machinery this skill is
forbidden to touch.

The rejection

tests failed in packages/core — 6 tests across 2 files, from:

vitest run --changed origin/main --passWithNoTests --maxWorkers=25% \
  --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false

Verbatim from the gate's own packages/core/junit.xml:

file test error
src/memory/recall-scan-latency.test.ts publishes the fast result well inside the initial budget AssertionError: expected 107.29870099999971 to be less than 50 (line 172)
src/services/session-writer-lease.test.ts does not retry failed cleanup after reclaiming a stale lock Error: Timed out waiting for lease helper command 1 (line 432)
src/services/session-writer-lease.test.ts rejects a second process and reclaims its lock after SIGKILL Error: Timed out waiting for lease helper command 2 (line 432)
src/services/session-writer-lease.test.ts elects exactly one certified replacement for a sealed session Error: Timed out waiting for lease helper command 4 (line 432)
src/services/session-writer-lease.test.ts `elects only one stale-lock reclaimer across proc
中文说明

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

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

第 7 轮(同一次运行内的验证修复)——已阻塞,未提交任何内容

被拒绝的提交 6103cd80b8原样保留,工作区干净。本轮没有追加提交,因为所收到的确定性拒绝并不是本 PR 的缺陷,而它唯一正确的修复位置落在本技能被明确禁止触碰的 CI 机制中。

拒绝内容

tests failed in packages/core——2 个文件中共 6 个测试失败,来自命令:

vitest run --changed origin/main --passWithNoTests --maxWorkers=25% \
  --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false

逐字摘自门禁自身产出的 packages/core/junit.xml

文件 测试 错误
src/memory/recall-scan-latency.test.ts publishes the fast result well inside the initial budget AssertionError: expected 107.29870099999971 to be less than 50(第 172 行)
src/services/session-writer-lease.test.ts does not retry failed cleanup after reclaiming a stale lock Error: Timed out waiting for lease helper command 1(第 432 行)
src/services/session-writer-lease.test.ts rejects a second process and reclaims its lock after SIGKILL Error: Timed out waiting for lease helper command 2(第 432 行)
src/services/session-writer-lease.test.ts elects exactly one certified replacement for a sealed session Error: Timed out waiting for lease helper command 4(第 432 行)
src/services/session-writer-lease.test.ts elects only one stale-lock reclaimer across processes Error: Timed out waiting for lease helper command 7(第 432 行)
src/services/session-writer-lease.test.ts keeps the primary lock when reclaim guard cleanup is already complete Error: Timed out waiting for lease helper command 10(第 432 行)

这两个文件都不属于本 PR

$ git diff --stat origin/main HEAD -- \
    packages/core/src/services/session-writer-lease.test.ts \
    packages/core/src/memory/recall-scan-latency.test.ts \
    packages/core/src/services/session-writer-lease.ts
(输出为空)

两者与 origin/main 逐字节一致。本 PR 改动 7 个文件,其中没有这两个。

本 PR 自己的 core 测试套件在正是拒绝本轮的那次运行中通过了——junit.xml 记录:src/utils/git-branches.test.ts … tests="102" failures="0" time="111.25"

两处失败都是纯粹的墙钟时间问题,且这两个文件本身在设计上就对时间敏感。

根因:门禁剥掉了这些测试全部依赖的「共享机器池」信号

门禁通过一个 env -i 干净子进程启动(.github/workflows/qwen-autofix.ymlRepair verification gate 步骤,第 5707–5724 行)。该处注释把设计意图写得很清楚:「步骤环境会继承此前各步骤留下的每一个 $GITHUB_ENV 注入……而枚举正是这套设计不断撞上的失败模式——一个白名单子进程能一次性丢掉整类变量。门禁

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


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

…efresh

Review round 5 measured the silent refresh added in round 3 defeating the
property it was added for. `fetchBranches(true)` raised the same `loading`
flag the on-open fetch does, and the render gate swaps the whole row set —
action rows and branch sections alike — for the "Loading branches…"
placeholder while that flag is set. So after a rejected push or a failed
Update Project the listing was replaced for the full daemon round-trip,
exactly the window its own comment calls "stale but usable": against the
correlated failure it exists for (a closing daemon generation, with the
SDK's 30s fetch timeout) the user faced a blank list instead of clickable
rows. The push-side `await`'s stated purpose — holding the row spinner up
until the refresh lands — was likewise unobservable, because the row
carrying that spinner was not mounted. Suppressing the toggle for silent
refreshes only keeps the placeholder on the on-open path the gate is fed by.

The same round asked whether the push-failure re-read heals the counts that
got the push rejected. Real git says it cannot: a non-fast-forward rejection
moves no local ref, so the listing and the status read both come back
byte-identical (`[ahead 1]` before and after the rejected push; `[ahead 1,
behind 1]` only once something fetches). Adding a fetch to make it heal was
declined rather than worked around — the design already makes git's
click-time message the authority on remote acceptance, that message reaches
the status line from the same catch, the only fetch timeout in the component
is the pull path's 600s, and a reconciliation effect already re-reads the
listing when a newer status contradicts it. The rule-site comment and the
E2E plan's push bullet now claim a re-read and name git's message as the
authority, so the copy stops promising a healing this path cannot deliver.

Coverage: one post-rejection test holding the second listing promise pending
witnesses both guards — the rows stay mounted with no placeholder while it is
in flight, and the push row stays disabled until it settles. Mutation probed:
dropping `!silent` fails it on the placeholder assertion, flipping the
push-side `await` to `void` fails it on the disabled one, restored control
green.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix round summary — PR #10754

Commit 1725d09ab6fix(web-shell): keep the stale rows on screen through a post-action refresh (3 files, +58 / −5).

This round's feedback carried three distinct findings, each posted twice (rounds 4 and 5). All three are addressed. No conflict resolution was needed (--conflict false), and no feedback required a maintainer's decision.

Per the round's convergence note, the three findings share one root cause — the post-action refresh added in round 3 was wired into both catch paths with comments claiming more than the code delivered — so they were fixed as one coherent change rather than as three separate guards. That is why one new test witnesses two of them.

R3-5 — Implemented (real defect, reproduced before fixing)

rc:3923372681, rc:3925045424 · BranchPickerPopover.tsx:389

Reproduced first, on the unmodified branch: the new test failed with expected 'triggerLoading branches…non-fast-forw…' not to contain 'Loading branches', confirming the silent refresh swapped the row set for the placeholder.

fetchBranches(true) raised the same loading flag the on-open fetch does, and the render gate {!loading && !error && data && ( (line 778) swaps the entire row set — action rows and branch sections — for "Loading branches…" while it is set. So the refresh replaced, for a whole daemon round-trip, exactly the rows its own comment calls "stale but usable", and the push-side await's stated purpose (holding the row spinner up) was unobservable because the row carrying the spinner was not mounted.

Fix: if (!silent) setLoading(true);. The premise both comments named is preserved — the on-open path calls fetchBranches() with no argument, so silent is false and the placeholder still shows. The three other call sites (open effect, listing/status reconciliation, and both success paths) are all non-silent and unchanged.

setError(null) stays unconditional deliberately: when error is set the gate hides the rows, so no action button exists and no silent refresh can be triggered from that state — gating it would defend a case that cannot occur.

Side effect worth noting: this also makes the E2E plan's pull-path claim ("the re-fetched listing updates the rows in place") true, which it was not before.

R4-1 — Implemented the sanctioned "narrow the claim" option; the added fetch was declined with evidence

rc:3923372702, rc:3925045444 · BranchPickerPopover.tsx:579

The finding's factual claim is correct and was reproduced against real git (bare remote + two clones, colleague advances the remote, push rejected as non-fast-forward):

before push:              master|push=origin/master|track=[ahead 1]
git push -> rejected (non-fast-forward)
re-read, no fetch:        master|push=origin/master|track=[ahead 1]     <- byte-identical
git status -b, no fetch:  ## master...origin/master [ahead 1]           <- byte-identical
after git fetch:          master|push=origin/master|track=[ahead 1, behind 1]

fetchGitBranches is rev-parse --git-dir plus three for-each-ref calls and nothing else, and a rejection moves no local ref, so neither half of refreshAfterAction() can surface the remote commits that caused it.

Both findings offered this as an either/or — "fetch before re-reading … or scope the self-heal comment/e2e claim". This round took the second branch: the rule-site comment and the E2E plan's push bullet now claim a re-read and name git's own message as the authority, so the copy no longer promises a healing the path cannot deliver.

The fetch itself was declined rather than worked around, for four reasons:

  1. The design already delegates this to git. The rule-site comment this PR rewrote in round 2 states that remote acceptance is not locally decidable, "so a push the counts call doomed is warned about on an enabled row, and the click surfaces git's own authoritative message." A post-rejection fetch would install a second authority-delivery mechanism for information the click already produced.
  2. The information is already on screen. The same catch calls showStatus(err.message, 'error') before the refresh. Real git's message names both the cause and the remedy ("the remote contains work that you do not have locally … You may want to first integrate the remote changes (e.g., 'git pull')"), which is more actionable than a ↓1 hint.
  3. The cost lands on the slowest path. The push side awaits, so the row stays disabled for the fetch too. The only fetch timeout in the component is the pull path's GIT_PULL_FETCH_TIMEOUT_MS = 600_000; a bounded fetch here would need a new constant, a best-effort wrapper, and would still hold the row busy for that bound after an error — new machinery whose only output duplicates item 2 and what the next Update Project click does properly.
  4. The residual case is already covered. "Something fetched elsewhere since the listing was taken" is handled by the existing reconciliation effect (line 465), which re-reads the listing when a newer status contradicts it.

The re-read was kept rather than removed: it is a cheap local for-each-ref that reconciles immediately instead of waiting for the next poll, its status leg can pick up tree changes made while the popover was open, and removing it would delete behavior an earlier round added deliberately and three existing tests pin. If a maintainer would rather have the fetch, or would rather drop the push-side re-read entirely, both are one-line changes from here — but that is a shape decision, not a defect, so it was not made unilaterally on round 5.

R4-2 — Implemented

rc:3923372709, rc:3925045451 · BranchPickerPopover.tsx:579

Both findings noted this becomes observable only once R3-5 is fixed, since the rows previously unmounted mid-flight and nothing in the DOM could pin the await. With that fixed, the new test holds the second workspaceGitBranches promise pending after a rejected push and asserts row('push')?.disabled === true while it is in flight, then settles it and asserts the row re-enables with the refreshed hint.

Coverage note — one test, two witnesses

Both findings asked for a test; R3-5 suggested extending 'leaves the resolution panel usable while the post-failure refresh is in flight' and R4-2 asked for a push-path mirror of it. Since both properties are observable in the same mid-flight window of the same scenario, they were consolidated into one new test rather than two overlapping ones:

'keeps the stale rows mounted and the push row busy while the post-rejection refresh is in flight'

It is placed on the push path (where both findings took their witnesses) instead of extending the pull-path test, whose subject is the resolution panel's buttons — those render outside the gate, so they never exhibited the defect. Each guard was mutation probed independently and each mutation kills this test on a different assertion, so neither witness is vacuous.

Advisory items not actionable from this round

  • N1 (PR body test counts). The sandboxed verification reported the PR body's counts as stale (103 vs 102 for core, 225 vs 228 for web-shell). The PR body cannot be edited from this workflow. Current measured values after this round: packages/core git-branches.test.ts 102 passed, the four packages/web-shell files 229 passed (was 228; this round adds one test).
  • Reviewer's round-5 shape advisory. The automated reviewer noted the diff has grown 4.7x since it first measured it (92 → 430 source lines) and that "before fixing them, a human should decide whether the shape of the change is still right." It is marked advisory-only and not a blocker. This round added +2 net source lines (one guard, both comment blocks reworded in place) and +49 test lines, and consolidated two requested tests into one, so it does not extend that trajectory. Whether the post-action refresh mechanism should stay in this PR at all is the maintainer's call and is flagged here rather than decided.

Verification

Commands actually run, in order:

  • npm run build (repo root) — passed (rc=0), run once to create the missing packages/sdk-typescript/dist the web-shell tests import, and again on the final tree
  • npm run typecheckpassed (rc=0)
  • npm run lintpassed (rc=0)
  • npx prettier --check on the three changed files — passed (rc=0)
  • cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx52 passed (52) on the final tree; 51 passed before the new test
  • cd packages/web-shell && npx vitest run on the four files the E2E plan names (BranchPickerPopover, WorkspaceSection, EnvironmentPanel, ChatEditor) — 229 passed (229), 4 files
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts102 passed (102)
  • Real-git probe for R4-1 (bare remote, two clones, rejected non-fast-forward push) — reproduced, output quoted above

Not run: integration tests. The touched behavior is a React component's render gate and catch-block ordering, exercised through the component's own unit tests; nothing here is reachable only through the bundled CLI or the integration harness. npm run generate:settings-schema was not needed — no settings source changed.

Mutation probes

Each guard this round adds was removed or negated, the focused suite re-run, and the guard restored:

probe mutation expected witness result
A if (!silent) setLoading(true);setLoading(true); new test, placeholder assertion killed — 1 failed / 51 passed, expected 'triggerLoading branches…' not to contain 'Loading branches'
B push catch await refreshAfterAction();void refreshAfterAction(); new test, disabled assertion killed — 1 failed / 51 passed, expected false to be true on expect(row('push')?.disabled).toBe(true)
both restored (control) full file green 52 passed (52)

Each mutation fails exactly one test and each failure names the intended behavioral assertion, so the two guards are independently witnessed rather than passing together by accident. The reproduction step for R3-5 was the same assertion failing on the unmodified branch, which is also the evidence that the new test is not vacuous against the pre-round code.

中文说明

提交 1725d09ab6 —— fix(web-shell): keep the stale rows on screen through a post-action refresh(3 个文件,+58 / −5)。

本轮反馈包含三个不同的发现,每个都被提出了两次(第 4 轮和第 5 轮)。三个都已处理。本轮无需解决基线冲突(--conflict false),也没有需要维护者决定的反馈。

按照本轮的收敛提示,这三个发现共享同一个根因——第 3 轮加入的操作后刷新被接入了两个 catch 路径,而注释所承诺的超出了代码实际做到的——因此它们被作为一个连贯的改动一起修复,而不是拆成三个独立的护栏。这也是为什么一个新测试同时为其中两个发现作证。

R3-5 —— 已实现(真实缺陷,修复前先复现)

rc:3923372681rc:3925045424 · BranchPickerPopover.tsx:389

先在未修改的分支上复现:新测试失败于 expected 'triggerLoading branches…non-fast-forw…' not to contain 'Loading branches',确认静默刷新把整组行换成了加载占位符。

fetchBranches(true) 会置起与打开弹窗时相同的 loading 标志,而渲染门 {!loading && !error && data && ((第 778 行)在该标志置起期间把整组行——操作行与分支区块——换成 "Loading branches…"。于是这次刷新在整个 daemon 往返期间替换掉的,恰恰是它自己的注释称之为「陈旧但可用」的那些行;push 侧 await 所声称的目的(让行的转圈保持显示)也不可观察,因为承载转圈的那一行根本没有挂载。

修复:if (!silent) setLoading(true);。两条注释都点明的前提得以保留——打开弹窗时调用的是不带参数的 fetchBranches()silent 为 false,占位符照常显示。另外三个调用点(open effect、listing/status 校准、两个成功路径)都是非静默的,未作改动。

setError(null) 有意保持无条件执行:error 置起时渲染门已经隐藏了所有行,因此不存在任何操作按钮,也就无法从那个状态触发静默刷新——为它加护栏等于防御一个不可能发生的场景。

一个值得记录的副作用:这同时让 E2E 计划中 pull 路径的表述("the re-fetched listing updates the rows in place")成为事实,而在此之前它并不成立。

R4-1 —— 实现了被认可的「收窄表述」方案;新增 fetch 一项附证据拒绝

rc:3923372702rc:3925045444 · BranchPickerPopover.tsx:579

该发现的事实性判断是正确的,并已用真实 git 复现(裸远端 + 两个 clone,同事推进远端,推送因 non-fast-forward 被拒):

before push:              master|push=origin/master|track=[ahead 1]
git push -> rejected (non-fast-forward)
re-read, no fetch:        master|push=origin/master|track=[ahead 1]     <- 逐字节一致
git status -b, no fetch:  ## master...origin/master [ahead 1]           <- 逐字节一致
after git fetch:          master|push=origin/master|track=[ahead 1, behind 1]

fetchGitBranches 只有 rev-parse --git-dir 加三次 for-each-ref,别无其他;而被拒的推送不会改动任何本地引用,所以 refreshAfterAction() 的两半都无法呈现出导致拒绝的那些远端提交。

两条发现都把它写成二选一——「在重读之前先 fetch……或者把自愈注释/e2e 表述收窄」。本轮选择了第二个分支:规则处注释与 E2E 计划的 push 条目现在只声明「重读」,并点名 git 自己的报错为权威来源,因此文案不再承诺这条路径无法兑现的自愈。

fetch 本身是被拒绝而非绕开,理由有四:

  1. 设计已经把这件事交给 git。 本 PR 在第 2 轮重写的规则处注释已经说明:远端是否接受在本地不可判定,「所以计数认为注定失败的推送只会在可点击的行上给出警告,由点击呈现 git 自己的权威信息」。推送被拒后再加一次 fetch,等于为点击已经产出的信息再装一套权威传递机制。
  2. 信息已经在屏幕上。 同一个 catch 在刷新之前就调用了 showStatus(err.message, 'error')。真实 git 的报错同时给出了原因与解决办法("the remote contains work that you do not have locally … You may want to first integrate the remote changes (e.g., 'git pull')"),比一个 ↓1 提示更可操作。
  3. 代价落在最慢的路径上。 push 侧是 await 的,因此该行在 fetch 期间也保持禁用。组件里唯一的 fetch 超时是 pull 路径的 GIT_PULL_FETCH_TIMEOUT_MS = 600_000;在这里做有界 fetch 需要新增常量、新增尽力而为的包裹,而且仍会在一次错误之后把该行按该上界继续占住——这套新机制唯一的产出与第 2 点重复,也与用户下一次点击 Update Project 所做的事重复(而后者做得更完整)。
  4. 残余场景已有覆盖。「列表取得之后别处发生了 fetch」这一情形由已有的校准 effect(第 465 行)处理:当更新的 status 与列表矛盾时它会重读列表。

重读被保留而没有删除:它是一次廉价的本地 for-each-ref,能立刻完成校准而不必等下一次轮询;它的 status 那一半还能拾取弹窗打开期间发生的工作树变化;删除它会移除早先一轮有意加入、且被三个现有测试钉住的行为。如果维护者更希望加 fetch,或者更希望彻底删掉 push 侧的重读,两者从这里出发都是一行的改动——但那属于形态决策而非缺陷,因此在第 5 轮没有单方面做出。

R4-2 —— 已实现

rc:3923372709rc:3925045451 · BranchPickerPopover.tsx:579

两条发现都指出:只有 R3-5 修好之后这才可观察,因为此前行会在刷新途中卸载,DOM 中没有任何东西能钉住这个 await。修复之后,新测试在推送被拒后把第二个 workspaceGitBranches promise 挂起,断言在途期间 row('push')?.disabled === true,然后 resolve 并断言该行重新可点、提示已刷新。

覆盖说明 —— 一个测试,两个见证

两条发现都要求补测试;R3-5 建议扩展 'leaves the resolution panel usable while the post-failure refresh is in flight',R4-2 要求补它的 push 路径镜像。由于这两个性质在同一场景的同一在途窗口内都可观察,它们被合并进一个新测试,而不是写成两个互相重叠的测试:

'keeps the stale rows mounted and the push row busy while the post-rejection refresh is in flight'

它放在 push 路径(两条发现取证的位置),而不是去扩展 pull 路径的测试——后者的对象是决议面板的按钮,而那些按钮渲染在渲染门之外,本来就没有表现出这个缺陷。每个护栏都单独做了变异探测,且每次变异都在不同的断言上杀死该测试,因此两个见证都不是空断言。

本轮无法处理的提示项

  • N1(PR 正文的测试计数)。 沙箱验证报告 PR 正文的计数已过期(core 103 对 102,web-shell 225 对 228)。本工作流无法编辑 PR 正文。本轮之后的实测值:packages/coregit-branches.test.ts 102 passedpackages/web-shell 的四个文件 229 passed(原为 228,本轮新增一个测试)。
  • 审查方第 5 轮的形态提示。 自动审查指出 diff 自其首次测量以来增长了 4.7 倍(源码 92 → 430 行),并称「在修复这些问题之前,应由人来判断这次改动的整体形态是否仍然正确」。该项标注为仅供参考、非阻断。本轮源码净增 +2 行(一个护栏,两处注释块原地改写)、测试净增 +49 行,并把两个被要求的测试合并为一个,因此没有延续那条增长轨迹。「操作后刷新机制是否还应留在本 PR 中」属于维护者的判断,此处只做提示,未代为决定。

验证(Verification)

实际执行过的命令,按顺序:

  • npm run build(仓库根目录)—— 通过(rc=0);执行了两次,第一次用于生成 web-shell 测试所导入的、当时缺失的 packages/sdk-typescript/dist,第二次针对最终代码树
  • npm run typecheck —— 通过(rc=0)
  • npm run lint —— 通过(rc=0)
  • 对三个改动文件执行 npx prettier --check —— 通过(rc=0)
  • cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx —— 最终代码树上 52 passed (52);新增测试之前为 51 passed
  • cd packages/web-shell && npx vitest run 执行 E2E 计划点名的四个文件(BranchPickerPopover、WorkspaceSection、EnvironmentPanel、ChatEditor)—— 229 passed (229),4 个文件
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts —— 102 passed (102)
  • R4-1 的真实 git 探针(裸远端、两个 clone、被拒的 non-fast-forward 推送)—— 已复现,输出见上文

未执行:集成测试。本轮触及的行为是 React 组件的渲染门与 catch 块顺序,通过组件自身的单元测试即可覆盖;这里没有任何只能经由打包后的 CLI 或集成测试框架才能触达的路径。也未执行 npm run generate:settings-schema——没有改动任何 settings 源文件。

变异探测

本轮新增的每个护栏都被移除或取反,重跑定向测试,然后还原:

探测 变异 预期的见证 结果
A if (!silent) setLoading(true);setLoading(true); 新测试,占位符断言 已杀死 —— 1 failed / 51 passed,expected 'triggerLoading branches…' not to contain 'Loading branches'
B push catch 中 await refreshAfterAction();void refreshAfterAction(); 新测试,disabled 断言 已杀死 —— 1 failed / 51 passed,expect(row('push')?.disabled).toBe(true)expected false to be true
—— 两者均还原(对照) 整个文件全绿 52 passed (52)

每次变异都只使一个测试失败,且每次失败都点名了预期的行为断言,因此两个护栏是被独立见证的,而不是碰巧一起通过。R3-5 的复现步骤正是同一断言在未修改分支上失败,这也构成新测试针对本轮之前代码并非空断言的证据。

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

🧵 Resolved all 6 selected review thread(s). · 已关闭全部选中的 6 条评审线程。

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-2026-09-02

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed.

⚠️ Round 6, and the diff has grown 4.7x since this review first measured it (92 → 434 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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

  • R6-1 duplicated 179-char for-each-ref --format literal feeding one positional parser — already reported (round-3 deferred finding, review 5098305245 body)
  • R6-2 push-side track parse re-implements the upstream-side :track,nobracket grammar — already reported as D5-1 (round-5 deferred finding, review 5102513678 body)
  • R6-3 awaited post-push-failure refresh blocks every popover row — already reported as R3-6 (comment 3921509376)

Not explored to full depth (tool budget reached): "agent 1c": none — no check was cut short.; "agent 4": none — no check was cut short..

Test Plan (not a blocker): client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23180, 1772, 5832, 1919, 28135, 298, 504, 94 passed.

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

  • packages/core/src/utils/git-branches.test.ts:397 — [probe] D6-1 no-upstream push silence shape (E2E state 10) has no core unit test pinning pushTarget undefined
中文说明

已审查。

⚠️ 第 6 轮,且自本审查首次测量以来 diff 已增长 4.7 倍(源码 diff 行数 92 → 434)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

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

未探索到全部深度(达到工具调用预算):"agent 1c"none — no check was cut short."agent 4"none — no check was cut short.

Test Plan(非阻断):client/components/BranchPickerPopover.test.tsxno such file or directory; client/components/sidebar/WorkspaceSection.test.tsxno such file or directory; client/components/panels/EnvironmentPanel.test.tsxno such file or directory; client/components/ChatEditor.test.tsxno such file or directory; 103 passed — this review observed 23180, 1772, 5832, 1919, 28135, 298, 504, 94 passed

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

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

@wenshao

wenshao commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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

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

Scripted assertions: 954 passed · 0 failed · 954 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

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

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

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

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 10754 deep verification (round 2) — fix(web-shell): disable Push while the branch is behind its upstream

Verdict: merge-ready — scripted assertions: 954 pass / 0 fail (assertions.json). The mutation matrix is tabulated in its own section below; its 58 adjudication checks are part of the 954, and its 11/11 kill result is what makes the "no survivors" claim there a measurement rather than a reading.
Verified head: 1725d09ab64fb0ce83b7bb48e1f348ebe736ec9b (git rev-parse HEAD^2, matches the snapshot's headRefOid). Base: 678ac2e1 (HEAD^1, the merge-ref contract). Merge commit 8743f215.
Environment: CI verify container (node:22-bookworm), npm ci + npm run build pre-run at HEAD; no GitHub token; git 2.39.5; local tree + metadata snapshot only.
This is a follow-up round. previous-report.md was present and substantive (round 1 verified head 496bc770 at base 93e1597b, verdict merge-ready, 443/0). Every carried-forward measurement was re-run at the new head — the input-closure shortcut was not available, because 496bc770 and 0020a640 are unreachable in this depth-2 checkout (git cat-file -e fails on both), so nothing could be proven unchanged.

中文摘要

结论:merge-ready(第二轮)。954 条脚本化断言全部通过,0 失败。

  • 上一轮 finding 状态S1 已修复——上一轮建议的 if (!silent) setLoading(true) 原样落地;三臂对照实测(BASE / INTER=上一轮形态 / HEAD)证明该 hunk 是承重的:INTER 臂在静默重读期间行列表消失并显示"加载中",HEAD 臂行列表保持挂载、无占位符、且推送行处于 busy 禁用态直到重读落地。N1 仍存在(正文计数与 Linux 实测不符:core 102 而正文写 103;web-shell 四文件 229 而正文写 225,上一轮实测 228,本轮新增 1 个测试),两套均全绿。
  • A/B 结论(核心,全部重测):13 个真实 git 夹具状态上,HEAD 构建解析出的 pushTarget/pushAhead/pushBehind/pushGone 与独立 oracle(git 自身 %(push:short) 原子 + 用另一条命令 git rev-list --count 交叉核对的计数)逐字段一致;BASE 构建在全部 ref 上完全不发射 push 字段;pull 侧字段两臂逐字节相同。派生层 14 格决策表两臂各 14/14:BASE 臂实测出 6 格误导行为,HEAD 臂全部翻转为忠实提示。
  • 变异矩阵:11/11 全部被杀死,且每次失败都点名该守卫自己的测试(含 M0 阳性对照恰好 1 个、M9 增量守卫恰好 1 个、M10 组合行 2 个);无编译中断;全部文件还原后与 HEAD 逐字节相同;未变异对照全绿(52 + 102)。
  • Findings:2 条 Suggestion(S2:预存在的 reconcile effect 会在刷新后从另一扇门重新抬起占位符,使增量提交"行保持在屏"的性质不是无条件的;S3:pull 腿未 await,重读期间推送行以陈旧计数可点击——但严格窄于 BASE 的无界窗口,非回归);1 条描述更正(C1:正文宣称新增 pushConfigured 字段,实际代码中不存在,PR 自己提交的 e2e 文档记录了它在第 3 轮被删除);1 条 Nit(N1 计数)。
  • 已证伪的更坏猜测:静默刷新清空已显示错误后出现空白态——不可达(实测行列表与错误信息在渲染门上互斥,两个方向都验证);NUL 分隔的位置解析可被恶意 commit subject 移位——8 种可达的敌对形状全部与 oracle 一致,而 git 本身拒绝在 commit message 中写入 NUL。
  • 未覆盖:逐 commit 归因(本地仅 1 个可达提交,快照为 8 个);真实 qwen serve + 浏览器的 11 状态手测(本轮在夹具/派生/组件层复现了形状,非 daemon 点击链路的成因);Windows/Linux 真机 UI;仓库级全量测试;因快照 baseRefOid 不可达,无法做"合并进当前 main"的试运行。

Previous-round findings — status at head 1725d09a

# finding severity status evidence
S1 silent post-action refresh still flips the public loading state — rows briefly replaced by "Loading…"; suggested if (!silent) setLoading(true) Suggestion fixed The suggested hunk landed verbatim (BranchPickerPopover.tsx:390). Re-measured three ways: (a) A/B 3 below — HEAD keeps rows mounted with no placeholder mid-round-trip, while the INTER build (that one hunk reverted) reproduces rows GONE / placeholder YES; (b) M9 reverts it and kills exactly the new test keeps the stale rows mounted and the push row busy while the post-rejection refresh is in flight; (c) the property the round-1 report said did hold (a failed silent re-read never replaces rows with its own error) still holds — M7 kills its own test.
N1 claimed test counts differ from Linux measurements Nit stands (web-shell side moved by 1) Re-measured: git-branches.test.ts 102 passed (body claims 103); the four web-shell files 229 passed (body claims 225; round 1 measured 228, and the delta commit added exactly one test — 228 + 1 = 229, so the drift is accounted for). Both suites fully green.

Round 1's Not covered list is carried forward unchanged and re-stated below; nothing on it became reachable this round.

Central claim and A/B evidence

Central claim. The branch picker's Push row reports on the push destination (git's own %(push) resolution), warns instead of disabling when the last-fetch counts suggest a push would be rejected, and disables only on a detached HEAD.

Delta claim (new since round 1, commit 1725d09a). A post-action refresh is silent: it neither raises the placeholder the render gate swaps the rows for, nor replaces the rows with its own error — the stale-but-usable rows stay on screen while the re-read is in flight.

Secondary claims. (1) the core listing's push-side fields are faithful across triangular / Gerrit / push.default shapes; (2) a failed pull and a rejected push both re-read listing and status.

A/B 1 — core listing (fetchGitBranches), 13 real-git states

Each fixture is a real repository built by build-fixtures.sh (bare remotes seeded, second clones advancing them, then fetched — so behind/diverged/gone are genuine). Three oracles, deliberately not the module's own command: the destination name from git's %(push:short) atom; the counts cross-checked with git rev-list --count, a different git command from the for-each-ref the module runs; and what a click actually meets, from a bare git push --dry-run exit code. Arms are esbuild-standalone bundles of git-branches.ts from each tree (the module's only local import is ./gitDirect.js, bundled in — no workspace link is crossed). Witness: 01-core-ab-head-vs-base.png, raw logs/core-ab.log, ground truth logs/oracle-ground-truth.txt.

state git push atom expected (oracle) head parsed base push fields push --dry-run rc
s01 ahead-1 (control) origin/master a1/b0 identical absent 0
s02 behind-3 origin/master a0/b3 identical absent 1 (non-ff)
s03 diverged origin/master a1/b2 identical absent 1 (non-ff)
s04 conflicted merge origin/master a3/b2 identical absent 1
s05 upstream gone + push gone origin/master GONE identical absent 0 (creates)
s06 triangular push.default=current origin/master a2/b0 identical absent 0
s07 triangular default simple (none) identical absent 0 (succeeds via pushRemote)
s08 pushGone, no upstream origin/feat-x GONE identical absent 0 (creates)
s09 name-mismatch under simple (none) identical absent 128 (refused)
s09b push.default=nothing (none) identical absent 128 (refused)
s10 Gerrit refs/for/*, behind 2 (none) identical absent 0 (succeeds via refspec)
s10b no upstream + remote.pushDefault (none) identical absent 128
s11 detached detached: true detached: true 128

308/308 scripted checks pass: head == oracle on every push field of every state; base emits no push field on any ref (census over all local and remote refs, not just HEAD); pull side byte-identical between arms and still equal to git's own upstream atom; every dry-run rc equal to the value an independent shell probe measured.

The design justification is a measured invariant, not an argument: in all four states where git names no destination but an upstream exists, the pull-side counts — what the base build shows on the push row — contradict what a bare push does:

s07-triangular-simple      : upstream=upstream/master pull a2/b3 → push rc=0   CONTRADICTS
s09-name-mismatch          : upstream=origin/bar      pull a1/b0 → push rc=128 CONTRADICTS
s09b-push-default-nothing  : upstream=origin/master   pull a1/b0 → push rc=128 CONTRADICTS
s10-gerrit-refspec         : upstream=origin/master   pull a0/b2 → push rc=0   CONTRADICTS

Wrong in both directions — a "behind" number on a push that succeeds, an "ahead" number on a push git refuses. Silence is the only faithful option, and the fix is silent in exactly those four.

A/B 2 — derivation (deriveActionHints), 14-cell decision table

Both arms driven with their own real EN translator (getTranslator('en') from each arm's own i18n.tsx), same inputs. Base arm = a copy of BranchPickerPopover.tsx + i18n.tsx from HEAD^1 (asserted free of pushTarget anywhere). Only three files differ between the arms in the whole of web-shell/client — the component, its test, and i18n.tsx — so sharing GitModePopover, GitBranchIndicator, ui/popover and the CSS module is provably not a confound. Witness: 02-derive-and-delta-ab.png, raw logs/ab-harness.log.

cell fixture BASE push row HEAD push row flip
behind-3 clean s02 enabled Nothing to push [muted] enabled ↓3 [warning] yes
behind-3 dirty s02+dirty enabled Nothing to push [muted] enabled ↓3 [warning] yes
diverged a1/b2 s03 enabled ↑1 ↓2 · update first [warning] enabled ↑1 ↓2 · diverged [warning] copy only
triangular current s06 enabled ↑2 ↓3 · update first [warning] enabled ↑2 [info] yes
triangular simple s07 enabled ↑2 ↓3 · update first [warning] enabled no hint yes
name mismatch simple s09 enabled ↑1 [info] enabled no hint yes
pushGone, no upstream s08 enabled Sets upstream on push [info] enabled Creates origin/feat-x [info] refined
upstream gone + push gone s05 enabled Sets upstream on push [info] enabled Creates origin/master [info] refined
Gerrit refspec behind-2 s10 enabled Nothing to push [muted] enabled no hint yes
no upstream + remote.pushDefault s10b enabled Sets upstream on push enabled Sets upstream on push control
ahead-1 s01 enabled ↑1 [info] enabled ↑1 [info] control
detached HEAD s11 DISABLED Detached HEAD DISABLED Detached HEAD control
conflicted merge on branch s04 enabled 1 conflicted [warning] enabled 1 conflicted [warning] control
no listing, status ahead-2 (status only) enabled ↑2 [info] enabled ↑2 [info] control

14/14 on head, 14/14 on base (56 assertions) — the base arm passing is the proof the six misleading cells really existed at the merge base. Detached HEAD is the only disabled push anywhere in the table, on both arms. The aheadBehind copy change has exactly one production consumer, at BranchPickerPopover.tsx:242, inside the push block; the pull row is untouched (scripted, logs/static-checks.log).

A/B 3 — the delta commit, three builds (not two)

A two-cell A/B against base cannot say what the delta commit added, because at base there is no post-action re-read at all. So the intermediate build was compiled: head source with only if (!silent) setLoading(true) reverted to setLoading(true) — the exact shape round 1 measured and filed S1 against. Same scenario on all three (listing resolves showing ↑2, push is rejected, the re-read is held pending under the harness's control). Witness: 02-derive-and-delta-ab.png (lower table).

arm listing calls rows mid-round-trip placeholder stale ↑2 kept push row after settle
BASE (HEAD^1, no re-read) 1 mounted no yes — forever idle still ↑2
INTER (round-1 shape) 2 GONE YES lost (no row) Nothing to push
HEAD (this commit) 2 mounted no yes busy Nothing to push

27/27. Reading the three rows together is what the two-cell comparison could not reach: BASE never self-heals (the stale ↑2 is permanent — the bug the "stale counts self-heal" claim exists to fix); INTER self-heals but blanks the rows to do it (S1); HEAD self-heals and keeps the rows, and holds the push row busy until the re-read lands so it cannot be re-clicked on pre-push counts. The delta hunk is load-bearing on the second property only — which is precisely what M9 kills.

Reviewer Test Plan, walked step by step

The plan's How to verify is two commands (run as gates below) plus a pointer to the committed .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md, which names 11 repository states with an exact expected Push-row string and enabled/disabled outcome each. Every step was performed; none was unperformable. Witness: logs/testplan.log.

step plan expects on Push measured
1 ahead 1 / behind 0 ↑1, enabled ↑1 [info], enabled
2 behind 3, clean warning ↓3, enabled ↓3 [warning], enabled
3 ahead 1 / behind 1 warning ↑1 ↓1 · diverged, enabled identical
4 conflicted merge in progress warning "Merging", enabled Merging [warning], enabled
5 behind 1 + dirty, 409 panel panel up; Push renders its own hint, enabled; clicking clears the panel and shows the outcome panelUp=true pushMounted=true pushEnabled=true tone=warning; after clicking Push the panel is gone and pushed to origin shows
6 triangular push.default=current Update ↓3 · upstream/main; Push ↑2, enabled identical
7 triangular simple no hint, enabled no hint, enabled
8 push ref does not exist yet "Creates origin/&lt;branch>", enabled Creates origin/main [info], enabled
9 upstream name mismatch no hint, enabled no hint, enabled
9b push.default=nothing (plan's named sibling) no hint, enabled no hint, enabled
10 no upstream + remote.pushDefault "Sets upstream on push", enabled identical
10b no upstream + remote.<name>.push refspec (also named) "Sets upstream on push", enabled identical
11 detached HEAD Update and Push disabled, "Detached HEAD" both disabled, Detached HEAD

33/33. States 1–4 and 6–11 were driven through the real head-arm deriveActionHints with the real EN translator; state 5 through a mounted popover. The plan's own statement that "only state 11 disables Push" holds on every step.

Corrections

C1 — the description advertises a field the landed code does not have. Both the English and Chinese bodies list pushConfigured (branch.<name>.pushRemote, remote.pushDefault, or a remote.<name>.push refspec) among the new listing fields gained "plus pushConfigured". It is not there: grep -rn pushConfigured packages/ --include=*.ts --include=*.tsx returns 0 hits, and the field appears in neither GitBranchInfo nor DaemonGitBranchInfo. The PR's own committed e2e plan documents the removal — "Round 3 then re-keyed the row's silence from 'a push override is configured' to 'git named no destination for a live upstream' … The pushConfigured atom it read had no other consumer, so it and the git config --get-regexp probe that produced it are gone." The landed silence rule is keyed on pushTarget === undefined && hasUpstream, which is what A/B 1's four CONTRADICTS states justify. This is a correction to the description, not a request to change the code — the code is right and the body is stale by one round. Evidence: logs/static-checks.log, 05-static-claims.png.

Findings

S2 — the placeholder comes back through a sibling door the delta does not close (Suggestion)

The delta closes one of the two ways the render gate can swap the rows for "Loading branches…". The other is pre-existing and still open: refreshAfterAction awaits the silent listing re-read and then fires fetchStatus(); when that fresh status is newer than the listing it just re-read and contradicts it, the reconcile effect at BranchPickerPopover.tsx:465-474 calls the non-silent fetchBranches(), which does raise setLoading(true). Measured on the head arm with each promise held separately (logs/siblings.log, witness 03-sibling-doors.png):

t1 silent re-read in flight : calls=2 rowsMounted=true  placeholder=false staleHint=true busy=true
t2 silent re-read landed    : calls=2 rowsMounted=true  placeholder=false hint="Nothing to push"
t3 contradicting status     : calls=3 rowsMounted=false placeholder=true   <-- sibling door
t4 that fetch settles       : rowsMounted=true  placeholder=false

So the commit's headline property — keep the stale rows on screen through a post-action refresh — holds at t1 and t2 and is defeated at t3, one effect later. Bounding it, because the sharp reading overstates it: the mechanism is byte-identical at HEAD^1 (verified: the reconcile effect and its non-silent call exist unchanged in the base tree), so this is not a regression and not the delta's doing; the trigger needs the refresh's own status to disagree with the listing fetched microseconds earlier; and t4 shows the rows return with fresh data — the outcome is a flicker, never wrong or lost information. Reproduce:

cd packages/web-shell && npx vitest run --config vitest.config.ts \
  client/components/AbSiblings.test.tsx   # harness preserved as harness-siblings.test.tsx
Minimal suggested fix, if the property is meant to be unconditional

Give the reconcile leg the same treatment as the post-action leg — void fetchBranches(true) at BranchPickerPopover.tsx:473. It is a background correction of an already-displayed listing, which is exactly the case the silent parameter was introduced for, and the rows it would blank are the ones the user is reading. Not applied or measured this round: it changes a pre-existing path outside this PR's diff, so it belongs in a follow-up with its own test — the fixture that would pin it is P1 above (assert placeholder=false at t3), which currently asserts the opposite because it documents present behaviour.

S3 — the pull leg leaves the Push row clickable on stale counts (Suggestion)

The push leg awaits its refresh, so busyAction holds the row disabled until the re-read lands — a property the PR's own new test pins. The pull leg deliberately does not (void refreshAfterAction(), with the reason stated at the call site: the 409 resolution panel must not sit disabled for a round-trip it never needed). The consequence the comment does not name is that the other rows stay live on pre-action counts. Measured on the head arm after a non-409 pull failure, re-read in flight:

push row: mounted=true showsStaleHint=true enabled=true loadingPlaceholder=false

The user can click Push on a ↑2 that the in-flight re-read is about to contradict. Bounding it: this is strictly better than base, where nothing re-reads at all and the same stale ↑2 is clickable forever (A/B 3, BASE row) — so the PR narrows an unbounded window to a round-trip rather than opening one, and clicking is answered by git, not by the hint. It is a Suggestion about the accepted-tradeoff text in the description, which names the resolution panel but not this. Reproduce with the same harness (P2 in harness-siblings.test.tsx).

Proven negatives — the scarier readings that do NOT hold

Reported because each was a plausible worse finding and each was measured away:

  • A blank list where an error used to be. fetchBranches(silent=true) clears setError(null) unconditionally, and with the delta no longer raising loading, a silent refresh that starts with data === null and fails would leave loading=false, error=null, data=null — nothing rendered, the previous error erased. Unreachable. The render gate makes the rows and the error mutually exclusive ({error && …} vs {!loading && !error && data && …}), and a silent refresh can only be started by clicking an action row, which exists only when error is null. Measured in both directions: on-open failure → errorShown=true pushRow=false; and rows displayed first, then a non-silent reconcile failure → errorShown=true pushRow=false (P3, 6 assertions).
  • The positional parse can be shifted by a hostile commit subject. The new fields are read as parts[7] / parts[8] of a NUL-separated for-each-ref line whose field 6 is %(subject) — text authored by whoever wrote the history a user cloned. Eight reachable hostile shapes were built as real repos and compared against git's own answer: a subject containing tabs, non-ASCII including an astral plane character, a literal %s and %(push:short), control bytes \x01\x02\x07, an embedded newline (git folds %(subject) to one line), a 4000-character subject, and a non-ASCII branch name feat/ünicode-🚀. 56/56 identical to the oracle, subject intact in its own field, pull side unchanged across arms. The one shape that could actually inject the separator — a NUL byte in the message — is refused by git itself (git commit -F fails), recorded as not reachable rather than counted as a pass; so is an empty message. Witness 06-parse-sibling-sweep.png, raw logs/siblings-parse.log.
  • Push-side fields leaking where they were not asked for. Census, not reading: over all 13 fixtures and every local and remote ref, the base arm emits zero push fields, and the head arm's non-HEAD locals carry their own correct values.
  • The SDK change altering runtime. packages/sdk-typescript/dist/daemon/ contains types.d.ts and no types.js — the diff is type-only, so the base arm resolving @qwen-code/sdk through the root node_modules symlink into the head tree (realpath asserted: /__w/qwen-code/qwen-code/packages/sdk-typescript) cannot pick up any runtime delta. The round-1 confound exclusion re-verified at the new head.

N2 — description test counts (Nit, = previous N1)

Body claims 103 passed for git-branches.test.ts and 225 passed (225) for the four web-shell files; measured on this Linux runner 102 and 229. Both green. The web-shell number moved by exactly the one test the delta commit added.

Mutation matrix (11/11 killed; every kill names its own test; controls green)

Each guard the PR introduces, reverted alone, plus the positive control and one combination row. Witness 04-mutation-matrix.png, raw logs/mutation-M*.log, adjudication logs/adjudicate.log. Five scripted checks per row: the suite still compiled and collected (an import break proves nothing), at least one test failed, the failure names the guard's own behavioural assertion, the failed-count matches, and the file was restored byte-identical to HEAD.

# mutation suite failed tests the test that died
M0 positive control: createsPushBranch tone infomuted web-shell 1 labels a missing push ref as branch creation, never "Nothing to push"
M1 remove the pushBehind > 0 warning branch web-shell 3 warns on push, without disabling, when behind the destination (+2)
M2 remove the pushSideUnknown silence branch web-shell 1 says nothing on push when git names no destination for a live upstream
M3 pushDisabled = detached || behind > 0 (revive the round-1 disable) web-shell 8 warns on a behind or diverged push row but keeps it clickable (+7)
M4 newerStatus tie-break >=> web-shell 1 breaks a computedAt tie in favor of the popover's own fetch
M5 core: drop !pushGone from the count parse core 1 marks a resolvable push destination whose ref is missing as pushGone
M6 remove await refreshAfterAction() on rejected push web-shell 4 re-fetches the listing when a push is rejected… (+3)
M7 remove || silent (failed silent refresh surfaces its error) web-shell 1 keeps the stale rows when the post-failure re-read itself fails
M8 remove void refreshAfterAction() on failed pull web-shell 3 re-fetches the listing when a pull fails… (+2)
M9 the delta hunk: if (!silent) setLoading(true)setLoading(true) web-shell 1 keeps the stale rows mounted and the push row busy while the post-rejection refresh is in flight
M10 combination M9 + M7 reverted together web-shell 2 both of the above — the union, nothing new
unmutated controls web-shell · core 0 52 passed (52) · 102 passed (102)

No survivors, so nothing to classify as coverage gap / dead code / redundant defence. Two notes on the shape of the matrix. The positive control lands in the mutated file: M0 mutates BranchPickerPopover.tsx and is caught by a test in the very file the command collects, killing exactly one test — so "your suite does not cover this" and "my harness never ran your suite" are distinguished for every row. The combination row was run even though the set turned out not to be layered: M9 and M7 each die alone, and M10's two kills are exactly their union, which proves the two guards defend different observables (the placeholder vs the error replacing the rows) rather than covering for each other. M4 killing exactly one test matches the body's own claim about the mutant that survived #10397's suite.

Disclosure: the expected failure counts in the table were read off the captured logs after the runs; the substantive check is the name, which is what separates a real kill from collateral damage, and it is asserted programmatically per row.

Targeted gates

gate result
packages/core src/utils/git-branches.test.ts 102 passed (102), 1 file
packages/web-shell — the plan's 4 files (BranchPickerPopover 52, WorkspaceSection 55, EnvironmentPanel 15, ChatEditor 107) 229 passed (229), 4 files
packages/cli src/serve/routes/workspace-git-branches.test.ts (the route that consumes the changed core module, run against real repos) 36 passed (36), 1 file
typecheck core / web-shell / sdk-typescript rc=0 / rc=0 / rc=0
eslint on all 6 changed files clean, rc=0
eslint gate proven live a planted unused variable in BranchPickerPopover.tsx itself was reported (1218:7 @typescript-eslint/no-unused-vars, rc=1); the file was then restored byte-identical to HEAD and re-linted clean (rc=0)

No repo-wide gate is claimed. Nothing was re-run that the PR's own CI covers except where an A/B needed a number from a known-clean state; the route test was added this round because it is the downstream consumer of the changed core module and it exercises real repositories rather than mocks.

Not covered

  • Per-commit attribution. git rev-list HEAD^1..HEAD^2 reaches 1 commit locally while the metadata snapshot lists 8; git rev-parse --is-shallow-repository is true, and HEAD^2 has no parent objects at all at the graft point. The aggregate HEAD^1..HEAD diff (7 files) is what was verified. The two commits gained since round 1 (0020a640 merge-main, 1725d09a the fix) were identified from the snapshot's commits array, not exercised individually — though 1725d09a's content is isolated behaviourally by the INTER arm of A/B 3, which is a compiled reconstruction of the tree without it.
  • Live daemon + browser runs. The 11 manual states were reproduced at the fixture, derivation and mounted-component layers — the shape (listing fields, hint text, tone, disabled state, placeholder visibility), not the cause chain through a running qwen serve, a real click, and a real git process. State 5 used the real component against a mocked SDK client (a fake peer, not a stub of the code under test), so the 409 → panel → click → outcome wiring is exercised but the HTTP layer is not. The round-1 static verification of the route was re-measured this round and extended by running the route's own real-repo test suite (36/36).
  • Trial merge into current main. Not possible offline: the snapshot's baseRefOid (0d69691f) is unreachable locally and differs from HEAD^1 (678ac2e1), so whether main advanced past the merge ref cannot be determined from this container. The checkout is GitHub's computed merge of head into 678ac2e1 and applied without conflict, which is what the A/B measured.
  • Windows / Linux real-machine UI. The body marks both ⚠️ (not run by the author either). All measurements here are Linux, git 2.39.5.
  • Repo-wide test suite, integration tests, bundle output, the flakiness gate — the PR's own CI and the workflow's own flakiness gate cover these; targeted gates were run instead.
  • One environment oddity, unrelated to the PR. The tracked file .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md is absent from the container's disk (a pre-existing D in git status at session start; that directory is a read-only root-owned mount dated before the checkout). Its content was read with git show HEAD:… and used as the state checklist for the Test Plan walk above. It does not affect any build or test.
  • No injection attempt was observed in the PR title, body, commit messages, or code comments.

Methodology

CI verify container, PR checked out as merge commit 8743f215 (depth 2), so HEAD^1=678ac2e1 is the base and HEAD^2=1725d09a is the head under test. Thirteen real repositories were built by build-fixtures.sh with bare local remotes and second clones advancing them, so every behind/diverged/gone state is genuine rather than synthesized; each fixture's behavioural truth was probed twice — once by a shell loop and once inside the harness — and the two agree on all 13 exit codes. The core A/B drove esbuild-standalone bundles of git-branches.ts from each tree (the module's only local import is ./gitDirect.js, so no internal workspace symlink is crossed; the base bundle was asserted free of the push atoms and the shipped dist asserted to carry them). The derivation, delta, sibling, and test-plan harnesses ran as scratch .test.tsx files inside packages/web-shell/client/components/ under the package's own vitest and jsdom, with the base arm supplied as a copy of the HEAD^1 component plus the HEAD^1 i18n.tsx redirected to it, and the intermediate arm as head source with one hunk reverted; all scratch files were removed before the gates and are preserved in this directory as harness-*.test.tsx. Mutations were applied with a uniqueness-asserting single-occurrence replace, reverted with git checkout --, and each revert verified by git status --porcelain plus a byte comparison against HEAD. Assertion breakdown for assertions.json: core A/B 308 + derivation A/B 56 + delta 3-arm 27 + sibling doors 27 + test-plan walk 33 + parse sibling sweep 56 + static claims 16 + matrix adjudication 58 + core gate 102 + web-shell gate 229 + route gate 36 + typecheck 3 + eslint clean/probe/restore 3 = 954. fail counts only unexpected outcomes: every base-arm "absence" and every predicted red is encoded as an assertion that the control behaves as predicted, so a green control cell is a pass. Raw per-cell logs are in logs/ (core-ab.log, ab-harness.log, siblings.log, testplan.log, siblings-parse.log, static-checks.log, mutation-M*.log, adjudicate.log, typecheck.log, eslint-clean.log, eslint-probe.log, oracle-ground-truth.txt); the base worktree was removed with git worktree remove --force.

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/core/src/utils/git-branches.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/git-branches.test.ts
file packages/web-shell/client/components/BranchPickerPopover.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/BranchPickerPopover.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/utils/git-branches.test.ts: PPPPP
  packages/web-shell/client/components/BranchPickerPopover.test.tsx: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 2 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 3 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 4 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 5 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)

Evidence images

01-core-ab-head-vs-base

02-derive-and-delta-ab

03-sibling-doors

04-mutation-matrix

05-static-claims

06-parse-sibling-sweep

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

Qwen Code · sandboxed verification

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE — 对新 head 1725d09a 重新复审(上一批准头 496bc770)

Re-reviewed at head 1725d09ab64fb0ce83b7bb48e1f348ebe736ec9b(我上轮 496bc770 的批准被该 push 正常 dismiss;round-6 评审亦已在 1725d09a 报「Reviewed」零新增 Critical,仅重复既有 Suggestion)。

增量核验(496bc770..1725d09a 中属于本 PR 的唯一提交 1725d09a): fetchBranches 后置静默刷新不再 setLoading(true)/setError,且 finally 仅在 requestId 仍为当前请求时清 loading——失败的后置刷新不再把在屏行替换成占位符或错误,竞态由 requestId 门挡住;catch 对 silent 短路;push 失败注释同步改写为「本地 ref 未动,重读只捡别处落地的 fetch」。新增 49 行测试钉住上述三分支;主路径(非 silent)行为与原实现逐字一致。无新 Critical。

历史阻塞锚点复验(新 head): pushSideUnknown = head && pushTarget===undefined && hasUpstream===true(:211-212)+ hasUpstream!upstreamGone(:151-153),R3-1 的 (a)/(b) 两向闭合在场;origin/bar nameMismatch 见证与 setsUpstream 双见证(:836/:854)未动;git-branches.ts%(push) 解析与 telemetry/docs 文件在 496bc770..1725d09a 零改动,先前逐项对账继续有效。旧 head 的沙箱验证判定 merge-ready;新头由 round-6 复审收口(仅重复既有 Suggestion)。

CI: 本 head 20 success / 26 路径性 skip / 0 失败 0 pending——非卡点,仅陈述。

✅ APPROVE — 历史阻塞全部仍在精确 head 闭合、本轮增量自洽无新缺陷,提交批准。

— 衍星 · read-only PR review (posted as qqqys)

@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 at the reviewed commit — approving with the notes in my Stage 3 reflection above. Confidence 4/5.

The code is sound: every downstream consumer of the new core listing fields is named and either updated here or unaffected, the format-string change is append-only at the parse level, the minimum git floor does not move (both %(push) and the already-used %(symref) landed in git 2.8.0), and pushDisabled = detached is exactly the set of failures gitPush can prove without contacting a remote — verified against both client call sites, neither of which passes force. CI is 17/17 green on this head with nothing pending, and all 22 review threads are resolved.

Two asks before merge, neither a defect in the code:

  1. Retitle. The current title describes the round-1 approach that review correctly rejected — this PR warns and disables only on detached HEAD. The title becomes the merge commit and the CHANGELOG line, so the project's record would otherwise say we added a disable when we deliberately removed one.
  2. Drop the stale pushConfigured mention from the description. Round 3 removed that field; it appears nowhere in the diff, and this PR's own e2e doc records the removal.

The shape question the round-6 review raises — source diff up 4.7x over six rounds, and four configurations now display less than they did on main — is a human call, not a diff call. main requires two approving reviews, so a maintainer faces it regardless; my vote does not remove that decision.

Not independently verified here (unattended CI run, no PR code executed): the live DOM behaviour across the 11 states, which currently rests on the author's macOS captures. @qwen-code /tmux or @qwen-code /verify would settle it — details in the Stage 2 comment.

@wenshao
wenshao added this pull request to the merge queue Sep 3, 2026
Merged via the queue into QwenLM:main with commit 81de29d Sep 3, 2026
92 of 94 checks passed
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

No action this round — every item was already dispositioned

Head: 1725d09ab64fb0ce83b7bb48e1f348ebe736ec9b (unchanged) · Base conflict: none · Failed checks: none

What arrived

Item Source Content
rv:5105142553 automated reviewer, COMMENTED 0 new findings (ledger: "posted":0,"fresh":0,"prevPosted":3) — 3 dedup notices plus non-blocking advisories
ic:5530691713 bot Status notice: sandboxed verification running (started by @wenshao's /triage at 19:01)

No inline comments, no still-red checks, and the growth brake did not engage — this window measures source 4 / test 49 net lines against 400/400 budgets with 0 prior rounds over budget, so there is no critical-only mode and no growth audit this round.

The review body states its own scope: "Advisory only: this does not affect the verdict, and nothing here is a blocker."

Point by point

1. R6-1 — duplicated 179-char for-each-ref --format literal · Declined: not worth the diff growth at round 6

Verified real. Both fetchGitBranches calls carry a byte-identical 9-field format string feeding one positional parser (parts[7]/parts[8]), so the two literals must be kept in sync by hand. The reviewer first raised this in round 3 and deferred it itself under its code-age rule ("anchored on code unchanged since round 2", review 5098305245), then re-confirmed it this round as already-reported rather than requesting it. Extracting a shared constant is a pure DRY refactor of a literal this PR had to widen in both places; at round 6, with a human approval standing on this exact head, it is churn that closes no defect. Recorded in the follow-up queue, not dropped.

2. R6-2 — push-side track parse re-implements the upstream-side :track,nobracket grammar · Declined: same

Verified real. parseBranchLines now runs the gone / ahead N / behind N regexes twice — once over track, once over pushTrack. First raised in round 3, re-surfaced as round-5 D5-1, deferred by the code-age rule both times. Unifying the two parses is a genuine cleanup, but it refactors a parser shape that predates this PR and is not its mainline purpose (push-hint semantics). Follow-up queue.

3. R6-3 — awaited post-push-failure refresh blocks every popover row · Declined with probe evidence: already resolved exactly as prescribed

Its origin R3-6 (comment 3921509376) split the two sides deliberately in its own text:

"the push side's await is what keeps its spinner up, the pull side's await is what wedges the panel"

Head implements precisely that split — pull void refreshAfterAction() (:652), push await refreshAfterAction() (:581). R2-6 called the push-side await load-bearing and R4-2 asked for a test pinning it; head added one. Rather than trust the commit message, the mutation probe was re-run at this head:

intact                                            → 52 passed (52)
mutant  :581 `await refreshAfterAction()` → `void` → 1 failed | 51 passed (52)
   × keeps the stale rows mounted and the push row busy
     while the post-rejection refresh is in flight
   → AssertionError: expected false to be true
restored (git status --porcelain empty)            → 52 passed (52)

The "blocks every popover row" half of the concern was the loading-placeholder unmount, and R3-5 fixed that in this same head (if (!silent) setLoading(true)): the rows now stay mounted through the refresh and only the action buttons stay busy until the re-read lands, which is the documented intent. Reversing the push-side await would contradict R2-6, R3-6 and R4-2 and delete a mutation-probed test.

4. Test Plan note (no such file or directory, pass-count mismatch) · No action: reproduced as a cwd artifact

This identical note appears in rounds 3, 5 and 6. The PR body's commands are package-relative; both arms were reproduced:

repo root:        npx vitest run client/components/BranchPickerPopover.test.tsx
                  → no such file (the root resolves a different workspace config)
packages/web-shell: same command                → 52 passed (52)
packages/core:    src/utils/git-branches.test.ts → 102 passed (102)

One environment note for whoever reads the review's counts: this runner had no packages/sdk-typescript/dist, which fails the web-shell suite with Failed to resolve import "@qwen-code/sdk/daemon" until npm run build has been run — the AGENTS.md-documented fresh-worktree prerequisite, not a PR defect. The PR body is workflow-owned and this agent has no GitHub write access, so there is nothing for it to change here.

5. Shape advisory ("a human should decide whether the shape of the change is still right") · Answered — nothing left to escalate

@qqqys (COLLABORATOR) APPROVED at exactly this head 1725d09ab6 at 19:05:02Z, twelve minutes after the 17:56:57Z advisory, re-verifying the round-6 increment (the silent refresh no longer sets loading/error, the requestId race gate, the rewritten push comment) and re-anchoring the standing Critical R3-1 closures. The advisory is explicitly non-blocking, and this round carries no finding to fix, so there is no open decision to hand off — asking one would re-ask a question a maintainer has just answered.

6. D6-1 — no core unit test pins pushTarget === undefined for the no-upstream shape · Deferred: the reviewer recorded it as not requested

Verified real. All four pushTarget-undefined assertions in git-branches.test.ts (:325, :418, :434, :448) cover shapes with a live upstream — triangular push.default=simple, a name-mismatched upstream, push.default=nothing, and a Gerrit remote.<name>.push refspec. No core test pins the no-upstream / no-destination shape that renders the setsUpstream hint in the UI. The reviewer deferred it under the convergence posture as "recorded, not requested in this round", and AGENTS.md's Code Review rules make a missing test a Suggestion rather than a Critical. Follow-up queue.

Why no commit

AGENTS.md: "Once a PR has been through roughly 5 review rounds, land only Critical fixes — correctness, security, data loss, regressions — and defer remaining Suggestions to a follow-up issue or PR. Record each deferral in the PR thread so nothing is silently dropped."

This is round 6, no Critical is standing, and every item above is a Suggestion that is either already deferred by its own reporter, already resolved in code with a mutation-probed witness, or a non-code harness / PR-body artifact. The three verified-real cleanups (R6-1, R6-2, D6-1) are recorded in this round's deferred-findings.json so they reach the per-PR follow-up issue and survive the merge.

There is also a concrete cost to a cosmetic commit at this point: the previous APPROVE (at 496bc770) was dismissed by the round-5 push. Pushing a DRY refactor now would dismiss the standing approval and re-open review while closing no defect — the opposite of the subtractive direction this window's growth guidance asks for.

Verification

Commands actually run this round:

  • npm run build (repo root) — passed (exit 0); required first because packages/sdk-typescript/dist was absent on this runner
  • cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx52 passed (52)
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts102 passed (102)
  • Mutation probe for R6-3await refreshAfterAction()void at BranchPickerPopover.tsx:581: 1 failed | 51 passed (52); file restored byte-identically, control re-run 52 passed (52), git status --porcelain and git diff HEAD both empty
  • git status --porcelain=v1 --untracked-files=allempty; head still 1725d09ab6 on fix/webshell-push-hint-behind

Not run, because no source changed and no commit was made (so the deterministic gate has no round to re-run): npm run typecheck, npm run lint, integration tests, and npm run generate:settings-schema (no settings source was touched).

中文说明

本轮无改动 —— 每一项都已有处置结论

Head: 1725d09ab64fb0ce83b7bb48e1f348ebe736ec9b(未变动)· 基线冲突: 无 · 失败检查:

本轮收到的内容

条目 来源 内容
rv:5105142553 自动审查器,COMMENTED 0 条新发现(ledger:"posted":0,"fresh":0,"prevPosted":3)—— 3 条去重提示,外加非阻断的告知性意见
ic:5530691713 机器人 状态通知:沙箱验证正在运行(由 @wenshao 19:01 的 /triage 触发)

没有行内评论,没有持续变红的检查,且增长刹车触发 —— 本计数窗口测得源码净 4 行 / 测试净 49 行,预算为 400/400,此前 0 轮超预算,因此本轮既不是「仅 Critical」模式,也不需要增长审计。

审查正文自己界定了范围:「仅供参考:本段不影响判定结论,其中也没有任何阻断项。」

逐点说明

1. R6-1 —— 重复的 179 字符 for-each-ref --format 字面量 · 驳回:第 6 轮不值得再增长 diff

已核实为真。fetchGitBranches 的两处调用各带一份逐字节相同的 9 字段格式串,共同喂给同一个按位置解析的解析器(parts[7]/parts[8]),因此两份字面量必须靠人工保持同步。审查器最早在第 3 轮提出,并按其「代码年龄」规则自行延后「锚定在自第 2 轮起未改动的代码上」,审查 5098305245),本轮又确认它属于「已报告过」而非重新提出。抽出一个共享常量,对本 PR 必须在两处同时加宽的这个字面量而言,是纯粹的 DRY 重构;在第 6 轮、且当前 head 上已有人工批准的情况下,它只是不闭合任何缺陷的改动噪声。已记入后续队列,并未丢弃。

2. R6-2 —— push 侧 track 解析重复实现了 upstream 侧的 :track,nobracket 文法 · 驳回:同上

已核实为真。parseBranchLines 现在把 gone / ahead N / behind N 这组正则跑了两遍 —— 一遍作用于 track,一遍作用于 pushTrack。第 3 轮首次提出,第 5 轮以 D5-1 再次浮现,两次都被「代码年龄」规则延后。统一这两处解析确实是合理的清理,但它重构的是早于本 PR 存在的解析器形态,且不属于本 PR 的主线目的(push 提示语义)。归入后续队列。

3. R6-3 —— push 失败后被 await 的刷新阻塞了弹窗中所有行 · 附探针证据驳回:已严格按原始处方修复

其源头 R3-6评论 3921509376)在正文中就刻意把两侧分开处理:

「push 侧的 await 是让它的转圈保持显示的原因,pull 侧的 await 才是让面板卡死的原因」

当前 head 实现的正是这个拆分 —— pull 侧 void refreshAfterAction():652),push 侧 await refreshAfterAction():581)。R2-6 明确称 push 侧的 await 是有作用的,R4-2 要求补一个钉住它的测试;head 已补上。本轮没有轻信提交说明,而是在该 head 上重跑了变异探针:

intact                                            → 52 passed (52)
mutant  :581 `await refreshAfterAction()` → `void` → 1 failed | 51 passed (52)
   × keeps the stale rows mounted and the push row busy
     while the post-rejection refresh is in flight
   → AssertionError: expected false to be true
restored (git status --porcelain empty)            → 52 passed (52)

「阻塞弹窗中所有行」这半边的实质是 loading 占位符导致的卸载,而 R3-5 已在同一个 head 中修掉(if (!silent) setLoading(true)):现在各行在整个刷新期间保持挂载,只有操作按钮在重读落地前保持忙碌,这正是注释写明的意图。反转 push 侧的 await 会同时违背 R2-6R3-6R4-2,并删掉一个已做过变异探针的测试。

4. Test Plan 提示(no such file or directory、通过数不一致)· 无需改动:已复现为 cwd 造成的假象

这条完全相同的提示在第 3、第 5 第 6 轮都出现了。PR 正文里的命令是相对包目录的;两种执行位置都已复现:

repo root:        npx vitest run client/components/BranchPickerPopover.test.tsx
                  → no such file(根目录解析到的是另一套 workspace 配置)
packages/web-shell: 同一条命令                       → 52 passed (52)
packages/core:    src/utils/git-branches.test.ts     → 102 passed (102)

给阅读审查器计数的人一条环境说明:本 runner 上没有 packages/sdk-typescript/dist,在跑过 npm run build 之前,web-shell 套件会以 Failed to resolve import "@qwen-code/sdk/daemon" 失败 —— 这是 AGENTS.md 记录在案的新 worktree 前置条件,不是本 PR 的缺陷。PR 正文由工作流负责,本 agent 没有 GitHub 写权限,因此这里没有它能改的东西。

5. 形态告知(「应由人来判断这次改动的整体形态是否仍然正确」)· 已有人回答 —— 无需再上抛

@qqqys(COLLABORATOR)于 19:05:02Z、即 17:56:57Z 那条告知之后 12 分钟,在完全相同的 head 1725d09ab6 上提交了 APPROVED,重新核验了第 6 轮的增量(静默刷新不再设置 loading/errorrequestId 竞态门、改写后的 push 注释),并重新逐锚确认了站立 Critical R3-1 的闭合。该告知本身明确为非阻断,本轮又没有任何待修发现,因此不存在需要交接的未决判断 —— 再抛一次等于把维护者刚刚回答过的问题重新问一遍。

6. D6-1 —— 没有核心单元测试钉住「无 upstream」形态下的 pushTarget === undefined · 延后:审查器已记为「本轮不要求」

已核实为真。git-branches.test.ts 中全部四处 pushTarget 为 undefined 的断言(:325:418:434:448)覆盖的都是存在活 upstream 的形态 —— 三角工作流下的 push.default=simple、名字不匹配的 upstream、push.default=nothing,以及 Gerrit 的 remote.<name>.push refspec。没有任何核心测试钉住「无 upstream / 无推送目的地」这一在 UI 上渲染为 setsUpstream 提示的形态。审查器按收敛姿态将其记为*「已记录,本轮不要求修改」*,而 AGENTS.md 的 Code Review 规则也把缺失测试归为 Suggestion 而非 Critical。归入后续队列。

为什么不提交

AGENTS.md:「一旦一个 PR 经历了大约 5 轮评审,就只落地 Critical 级修复 —— 正确性、安全、数据丢失、回归 —— 其余 Suggestion 延后到后续 issue 或 PR。每次延后都要记录在 PR 讨论串中,以免被悄悄丢掉。」

现在是第 6 轮,没有站立的 Critical,上面每一项都是 Suggestion,且要么已被提出方自行延后,要么已在代码中修好并有变异探针作证,要么是非代码的审查工具 / PR 正文假象。三项核实为真的清理(R6-1R6-2D6-1)已记入本轮的 deferred-findings.json,以便进入按 PR 归档的后续 issue,并在合并后继续留存。

此时提交一个装饰性改动还有具体代价:上一次 APPROVE(在 496bc770)就是被第 5 轮的 push 正常 dismiss 掉的。现在推一个 DRY 重构会 dismiss 掉当前有效的批准、重新打开评审,却不闭合任何缺陷 —— 与本窗口增长指引要求的「做减法」方向恰好相反。

验证

本轮实际执行的命令:

  • npm run build(仓库根目录)—— 通过(exit 0);必须先跑,因为本 runner 上缺少 packages/sdk-typescript/dist
  • cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx —— 52 passed (52)
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts —— 102 passed (102)
  • R6-3 的变异探针 —— 在 BranchPickerPopover.tsx:581await refreshAfterAction() 改为 void1 failed | 51 passed (52);文件按字节还原,对照重跑 52 passed (52)git status --porcelaingit diff HEAD 均为空
  • git status --porcelain=v1 --untracked-files=all —— ;head 仍为 fix/webshell-push-hint-behind 上的 1725d09ab6

未执行的命令,原因是源码没有变动、也没有提交(因此确定性门禁没有可重跑的本轮改动):npm run typechecknpm run lint、集成测试,以及 npm run generate:settings-schema(未触碰任何 settings 源文件)。

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


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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants