Skip to content

fix(review): lock the PR review worktree lease against concurrent sessions - #9211

Merged
wenshao merged 11 commits into
QwenLM:mainfrom
wenshao:fix/review-worktree-lease-lock
Aug 17, 2026
Merged

fix(review): lock the PR review worktree lease against concurrent sessions#9211
wenshao merged 11 commits into
QwenLM:mainfrom
wenshao:fix/review-worktree-lease-lock

Conversation

@wenshao

@wenshao wenshao commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

The worktree lease that a PR review records for its session now doubles as a lock. A PR review's worktree lives at a fixed path per PR number, and until now the lease was only consulted by the end-of-session crash sweep — nothing checked it before destructive operations. With this change:

  • The PR setup command refuses to run, with an actionable error naming the holder session and the lease file, when another session already holds the lease for the same PR. The refusal happens before any stale-clean or lease overwrite, so even an interrupted concurrent setup can no longer destroy a running review's state.
  • The post-review cleanup skips the whole target — worktree, probe and base sibling trees, branch ref, side files, bypass audit, and the lease itself — with a note: on stdout when another session holds the lease.
  • When the review worktree is removed mid-review, the repository-context step fails with an actionable "worktree missing — re-run fetch-pr" error instead of a bare ENOENT.

Ownership is keyed by session, not by prompt, so drift restarts and later rounds of a multi-prompt review re-take their own lease and are not locked out. A process with no session id cannot prove ownership and is blocked by any existing lease (fail-closed).

Why it's needed

Concurrent reviews of the same PR share one fixed worktree path, so whichever session ran a stale-clean or cleanup next removed the other session's worktree, branch, diff, and plan mid-review. In the reported incident a round-4 high-effort review had its worktree deleted five minutes after creation by another session finishing the same PR; the failure surfaces were asymmetric and unhelpful — one command degraded all code facts to unknown with a warning, another hard-failed with a bare ENOENT that named neither the cause nor the remedy, and the review had to restart from scratch while remaining exposed to the same race. With the lock, same-PR reviews serialize cleanly: the second run refuses without touching anything, and the workflow relays that to the user instead of destroying state.

Reviewer Test Plan

How to verify

Simulate two sessions with distinct QWEN_CODE_SESSION_ID / QWEN_CODE_PROMPT_ID env vars in two terminals (any PR works; the verification below used #9118):

  1. Session A runs the PR setup command → worktree and lease are created as before.
  2. Session B runs the same setup command for the same PR → exits non-zero with "already being reviewed by another session (session A)". Confirm A's worktree contents, branch, side files, and lease are all untouched (previously this step destroyed them and silently stole the lease).
  3. Session B runs the cleanup for that PR → prints note: skipped cleanup … and removes nothing.
  4. Session A re-runs the setup with a new prompt id → succeeds (no self-lockout, audit window preserved across the drift restart). With the worktree manually removed, the repository-context step now fails with "worktree … is missing — re-run qwen review fetch-pr to recreate it" instead of a bare ENOENT.
  5. Session A's own cleanup removes worktree, branch, side files and lease; a fresh session C can then review the same PR without refusal.

Expected: nothing session B does destroys session A's state, and every refusal/skip names the holder session and the lease file.

Evidence (Before & After)

Before (reproduced on v0.21.11): session B's setup removed session A's worktree and overwrote the lease even when it subsequently failed; session B's cleanup removed A's worktree, branch and side files; A's repository-context step died with Error: ENOENT: no such file or directory, lstat '…/review-pr-9118'.
After (verified against this PR's bundle, same PR and SHA): all of the above refuse or skip while leaving A's state byte-identical, and the missing-worktree error is actionable. The full step-by-step E2E verification report is posted as a separate comment.

Tested on

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

Environment (optional)

npm run build && npm run bundle, then E2E via node dist/cli.js review … against a real PR with simulated session env vars. Unit tests for all changed modules pass locally.

Risk & Scope

  • Main risk or tradeoff: the guard is a read-then-write check, not an atomic acquire — two same-PR setups starting within the same millisecond could still interleave. That window is far narrower than the reported incident (destruction was guaranteed for the entire review duration), and closing it fully would mean an exclusive-create lease acquire, which can follow if ever needed.
  • Not validated / out of scope: per-run random worktree suffixes (the issue's alternative option) — that would touch every consumer deriving the path by convention. CI runs that hard-kill a review and skip its cleanup leave the lease behind; the next setup then refuses and names the lease file to delete, which the docs now state.
  • Breaking changes / migration notes: none. Same-session behavior is unchanged, and cleanup of a finished review is unchanged.

Linked Issues

Fixes #9205

中文说明

这个 PR 做了什么

PR 审查为会话记录的 worktree 租约(lease)现在同时充当锁。PR 审查的 worktree 位于按 PR 编号固定的路径上,此前租约只在会话结束的崩溃清扫中被读取——任何破坏性操作之前都没有检查它。本次改动后:

  • 当另一个会话已持有同一 PR 的租约时,PR 准备命令会拒绝运行,并给出可操作的报错(指明持有者会话与租约文件)。拒绝发生在任何 stale-clean 或租约覆盖之前,因此即使并发准备中途失败,也不会破坏正在进行的审查状态。
  • 当另一个会话持有租约时,审查后清理会跳过整个目标——worktree、probe/base 兄弟树、分支引用、附属文件、旁路审计以及租约本身——并在 stdout 输出一条 note:
  • 当审查 worktree 在审查进行中被移除时,仓库上下文步骤以可操作的"worktree 缺失——请重跑 fetch-pr"报错失败,而不是裸 ENOENT

所有权按会话(而非 prompt)判定,因此 drift 重启和多 prompt 审查的后续轮次可以重新取得自己的租约,不会被自己锁住。没有会话 ID 的进程无法证明所有权,会被任何已存在的租约拦下(fail-closed)。

为什么需要

同 PR 的并发审查共享同一个固定 worktree 路径,因此哪个会话先跑 stale-clean 或 cleanup,就会把另一个会话的 worktree、分支、diff 和计划在审查中途删掉。在报告的场景中,一次 round-4 high-effort 审查的 worktree 在创建 5 分钟后被另一个收尾同一 PR 的会话删除;失败表现不对称且无帮助——一个命令把所有代码事实降级为 unknown 并告警,另一个直接以裸 ENOENT 失败(既没说原因也没说补救办法),审查被迫从头再来,且仍暴露在同样的竞态下。加锁后,同 PR 审查干净地串行化:第二个运行直接拒绝、不动任何东西,工作流把这一点转告用户,而不是破坏状态。

审阅者测试计划

如何验证

用两个终端、以不同的 QWEN_CODE_SESSION_ID / QWEN_CODE_PROMPT_ID 环境变量模拟两个会话(任意 PR 均可;下面的验证使用 #9118):

  1. 会话 A 运行 PR 准备命令 → 照常创建 worktree 和租约。
  2. 会话 B 对同一 PR 运行相同准备命令 → 以 "already being reviewed by another session (session A)" 非零退出。确认 A 的 worktree 内容、分支、附属文件和租约全部未被动过(此前这一步会销毁它们并悄悄抢走租约)。
  3. 会话 B 运行该 PR 的清理 → 输出 note: skipped cleanup …,什么都不删。
  4. 会话 A 以新的 prompt id 重跑准备 → 成功(无自我锁死,drift 重启间审计窗口保留)。手动删除 worktree 后,仓库上下文步骤现在以 "worktree … is missing — re-run qwen review fetch-pr to recreate it" 失败,而不是裸 ENOENT。
  5. 会话 A 自己的清理移除 worktree、分支、附属文件和租约;随后全新会话 C 可以正常审查同一 PR,不被拒绝。

预期:会话 B 的任何操作都不会破坏会话 A 的状态,且每次拒绝/跳过都会指明持有者会话与租约文件。

证据(前后对比)

修复前(在 v0.21.11 上复现):会话 B 的准备会移除会话 A 的 worktree 并覆盖租约(即使它随后失败);会话 B 的清理会移除 A 的 worktree、分支和附属文件;A 的仓库上下文步骤以 Error: ENOENT: no such file or directory, lstat '…/review-pr-9118' 失败。
修复后(在本 PR 构建上验证,同一 PR 与 SHA):上述全部变为拒绝或跳过,A 的状态逐字节保持不变,worktree 缺失报错可操作。完整的逐步 E2E 验证报告以单独评论发布。

测试环境

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

环境(可选)

npm run build && npm run bundle,然后通过 node dist/cli.js review … 对真实 PR 以模拟会话环境变量做 E2E。所有改动模块的单元测试在本地通过。

风险与范围

  • 主要风险或权衡:该守卫是"先读后写"检查,不是原子获取——两个同 PR 准备在同一毫秒内启动仍可能交错。该窗口远比报告的场景窄(原问题中破坏在整个审查期间是必然发生的);要完全关闭需要 exclusive-create 的租约获取,如有需要可后续跟进。
  • 未验证 / 超出范围:按运行随机的 worktree 后缀(issue 提供的另一选项)——那会触及每个按约定推导路径的使用方。CI 硬杀审查且跳过其清理时会留下租约;下一次准备会拒绝并指明要删除的租约文件,文档现已说明。
  • 破坏性变更 / 迁移说明:无。同会话行为不变,已完成审查的清理也不变。

关联 Issue

Fixes #9205

…sions

The /review worktree lives at a fixed path per PR number, and the lease recording its owning session was only consulted by the end-of-session crash sweep. A second session reviewing or finishing the same PR deleted the first session's worktree, branch, and side files mid-run (QwenLM#9205).

Make the lease double as a lock: fetch-pr refuses with an actionable error before touching anything when another session holds it, and cleanup skips the whole target with a note. Ownership is per session, so drift restarts and later rounds of a multi-prompt review are not locked out. A missing worktree now fails repo-context with a re-run-fetch-pr message instead of a bare ENOENT.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 15, 2026
@wenshao

wenshao commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

E2E verification report

Status: VERIFIED_FIXED
Method: e2e-headless
Binary: node dist/cli.js (locally built + bundled fix, all three fix strings confirmed present in dist/chunks/chunk-ZEN5CYNO.js before testing)
Command: real review subcommands against PR #9118 (QwenLM/qwen-code, head 662d98f1fa — same PR/SHA as the incident), run from /Users/wenshao/git/qwen-code-x6 as node dist/cli.js review <subcommand> ...; two sessions simulated via distinct QWEN_CODE_SESSION_ID/QWEN_CODE_PROMPT_ID env vars (session-A-9205v / session-B-9205v). Raw outputs kept in /tmp/issue-9205-verify/.

Baseline

Before testing: no qwen-review/pr-9118 branch, no .qwen/tmp/review-pr-9118 worktree (directory or git registration), no lease file, no qwen-review-pr-9118-* side files.

Checks (each mirrors a reproduction step)

  1. Session A fetch-pr still works as before — exit 0; worktree at .qwen/tmp/review-pr-9118 checked out at 662d98f1fa8840b8b765ec858acc53605e7f4945, branch qwen-review/pr-9118 created, lease records session-A-9205v/prompt-A1, diff + fetch report side files written. A marker file placed inside the worktree to simulate in-flight review state. ✔
  2. Concurrent session B fetch-pr is REFUSED and destroys nothing — exit 1 with Error: PR #9118 is already being reviewed by another session (session session-A-9205v). Same-PR reviews share one worktree path and cannot run concurrently, so this run refuses rather than destroy the other session's state. Wait for that session to finish — its cleanup releases the lease — or, only if that session is gone, delete …/qwen-review-lease-pr-9118.json and re-run. Afterward: session A's marker file still in the worktree, branch SHA unchanged, lease still session-A-9205v/prompt-A1 (not overwritten), fetch report byte-identical to the pre-attempt snapshot (audit window intact), side files intact. In the reproduction this exact step deleted A's worktree, recreated the branch, and stole the lease. ✔
  3. Concurrent session B cleanup pr-9118 skips — exit 0 with stdout note note: skipped cleanup for "pr-9118" — another review session (session session-A-9205v) still holds the worktree lease at …/qwen-review-lease-pr-9118.json. Its own cleanup releases the lease when it finishes; if that session is gone, delete the lease file and re-run to force cleanup. Nothing removed: worktree, marker, branch, lease, and side files all verified intact afterward (in the reproduction this step deleted everything). ✔
  4. repo-context against a removed worktree fails actionably — worktree removed out-of-band (git worktree remove --force, simulating the mid-run disappearance the lock now prevents), plan left on disk. review repo-context --plan … --worktree … --out … → exit 1 with Error: repo-context: worktree /Users/wenshao/git/qwen-code-x6/.qwen/tmp/review-pr-9118 is missing — re-run \qwen review fetch-pr` to recreate it— names both cause and remedy, no bareENOENT … lstat`. ✔
  5. Session A is not locked out of its own lease — re-ran fetch-pr with the same session id and a NEW prompt id (prompt-A2): exit 0, worktree recreated, lease refreshed to session-A-9205v/prompt-A2, and the drift-restart auditSince preservation still works (2026-08-15T08:07:44.875Z carried over). ✔
  6. Session A's own cleanup pr-9118 still works — exit 0: Removed worktree: .qwen/tmp/review-pr-9118, Deleted ref: qwen-review/pr-9118, both side files removed, lease file gone, no leftover git worktree registration. ✔
  7. The lock is not sticky (happy path after release) — a fresh session-C-9205v ran the full cycle immediately after: fetch-pr exit 0 with zero refusal, lease held by session C, its own cleanup removed everything again. ✔

Observed behavior (post-fix)

Concurrent same-PR sessions can no longer destroy each other's state: a second session's fetch-pr refuses up front (before lease write, stale-clean, or fetch) with an error naming the holding session and the lease file; a second session's cleanup skips the whole target with a stdout note; the holder's own session passes both gates (ownership is per session, not per prompt — multi-round reviews still work); and if the worktree is nonetheless missing, repo-context says so and points at fetch-pr instead of dying with a bare ENOENT.

Expected behavior

Matches the issue's ask: the fixed worktree path is now locked via the lease, and the missing-worktree failure is actionable. All six required checks plus the post-release happy path pass.

Cleanup

Session C's final cleanup removed all verification artifacts; the repo was confirmed back to its exact pre-test baseline (no qwen-review/pr-9118 branch, no worktree directory or registration, no lease, no qwen-review-pr-9118-* files; .qwen/tmp/ contains only the unrelated pre-existing pr-6267 artifacts).

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-running the gate on the current head (b4d4f1d7). The only commit since the previous pass (01f67553) is a merge of origin/main — but that merge resolved conflicts in three of this PR's own files (fetch-pr.ts, fetch-pr.test.ts, SKILL.md), so this is a fresh look at the resolution, not a rubber stamp.

Template looks good ✓ — all sections present, bilingual summary included, Reviewer Test Plan carries full steps and the posted E2E report.

Problem: observed, not theoretical. Fixes #9205 — a round-4 review whose worktree was deleted mid-run by a second session reviewing the same PR. The body carries a before/after reproduction and the author posted an E2E verification report (VERIFIED_FIXED) in the thread.

Direction: this is the /review tooling tripping over its own fixed per-PR worktree path — a destructive race in our own workflow, hit in practice. Clearly in scope. CHANGELOG: no direct reference, but the area is our internal review workflow, so the signal is inherently aligned.

Size: ~1458 production lines (fetch-pr.ts 1260, lease service 111, cleanup.ts 58, repo-context.ts 29) vs ~909 test lines vs 7 docs lines — that crosses the 1000-line advisory threshold, but context matters: a de-indented line diff of the wrapped runFetchPr body shows ~74 net new lines inside it; the rest is re-indentation from wrapping the body in the lease-rollback try. Author is a maintainer, so the core-module two-tier gate does not apply.

Approach: the scope is right — every edit serves the lock (fetch-pr gate + rollback, atomic lease acquire, cleanup skip + sweep guard, actionable repo-context error, docs/SKILL relay). The issue's alternative (random per-run worktree suffixes) was considered and rejected with reason: it would touch every consumer that derives the path by convention.

Risk: no elevated risk signals — none of the revert-correlated paths are touched. The one thing this re-run adds on top of the prior pass: the merge from main conflicted exactly in the lock's files (main's #9091 run-session ledger and #9100 incremental-anchor validation landed mid-PR), so the conflict resolution is where I focused Stage 2.

Moving on to code review. 🔍

中文说明

针对当前 head(b4d4f1d7)重新跑准入门检查。自上次通过(01f67553)以来唯一的提交是一次 origin/main 合并——但该合并在本 PR 自己的三个文件(fetch-pr.tsfetch-pr.test.tsSKILL.md)里有冲突解决,因此本次是对冲突解决结果的全新评估,而非走过场。

模板完整 ✓——各节齐全,含双语摘要;Reviewer Test Plan 附有完整步骤与已发布的 E2E 报告。

问题:真实观测到的,不是理论问题。修复 #9205——一次 round-4 审查的 worktree 在审查中途被另一个审查同一 PR 的会话删除。PR 正文含 before/after 复现,作者还在评论区发布了 E2E 验证报告(VERIFIED_FIXED)。

方向:这是 /review 工具在自身按 PR 固定的 worktree 路径上互相踩踏——我们自己工作流中的破坏性竞态,实际发生过,显然在范围内。CHANGELOG:无直接引用,但该领域是我们内部的审查工作流,方向天然对齐。

规模:生产代码约 1458 行(fetch-pr.ts 1260、租约服务 111、cleanup.ts 58、repo-context.ts 29),测试约 909 行,文档 7 行——超过 1000 行建议阈值,但需要上下文:对包裹后的 runFetchPr 函数体做去缩进逐行对比,体内净新增仅约 74 行,其余是把函数体包进租约回滚 try 产生的重缩进。作者是 maintainer,核心模块两级门禁不适用。

方案:范围合理——每处改动都服务于这把锁(fetch-pr 门禁与回滚、原子租约获取、cleanup 跳过与清扫守卫、repo-context 可操作报错、文档/SKILL 转达)。issue 中的备选方案(按运行随机 worktree 后缀)已被有理由地否决:那会触及所有按约定推导路径的使用方。

风险:无升级风险信号——未触及任何与回滚相关的高风险路径。本次相比上次新增的一点:main 合并恰好在锁所在的文件里发生冲突(main 的 #9091 运行会话账本与 #9100 增量锚点校验在 PR 期间合入),因此 Stage 2 的重点放在冲突解决上。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal for this problem (written before reading the diff, same baseline as the prior pass): exclusive-create lease acquire keyed by session, fail-closed without identity, input validation before the gate, a fetch-pr gate ahead of every destructive step with owned-only rollback, cleanup skip with a post-audit re-check, and an actionable missing-worktree error. The PR matches it point for point — and adds the compare-before-delete rollback (clearReviewWorktreeLeaseIfOwned) I would have had to ask for.

What this re-run covers that the prior pass didn't: the only delta since 01f67553 is the merge of origin/main (b4d4f1d7), and it recorded real conflicts in fetch-pr.ts, fetch-pr.test.ts, and SKILL.md — main's #9091 (run-session ledger) and #9100 (incremental-anchor validation) landed in runFetchPr mid-PR. I verified the resolution by stripping indentation from the old and new runFetchPr bodies and diffing them line by line: the old side is current main and carries both merged features intact; the new side adds only the lock — the identity/refusal gate before the try and the guarded rollback in its catch (~74 net lines). No main-side line was lost, and nothing inside the moved body changed behavior beyond the lock. The test-side resolution is the expected fallout: main's new suites (diff-identity, run-session ledger wiring) now register/restore the lease identity env vars, since fetch-pr refuses to run without them.

All Critical findings from the earlier review rounds remain verified at this head:

  • Lease sweep could delete live leases — the cleanup sweep and the exit-sweep scan both skip isReviewLeaseFile, which shares the lease grammar with the writer; the positive control (its own side files still sweep) is tested both ways.
  • Cleanup audit-window TOCTOU — the lease is re-read after the network-bound audit and before any destruction, with a test pinning the second read's position via invocation order.
  • Lock disengaged for malformed pr_number — fetch-pr validates ^\d+$ and > 0 before the gate, so 5/. or 0 can't reach cleanStale lease-less.
  • Non-atomic acquire / unconditional rollback / silent fail-open — acquire is flag: 'wx' exclusive-create (EEXIST on a foreign holder throws, same-session refreshes, torn files self-heal consistently with every reader treating them as no-lease); rollback fires only when the gate read no lease and only removes a lease this run owns; missing QWEN_CODE_SESSION_ID/QWEN_CODE_PROMPT_ID refuses before touching anything.
  • ENOTDIR test portabilityskipIf(win32) with the libuv ENOENT-vs-ENOTDIR record cited.

The deferred round-8 suggestions are real but non-blocking, and none can reintroduce the #9205 destruction; they stay open for maintainer follow-up under the autofix critical-only brake:

  • R8-1 (fetch-pr.ts:605): the gate admits non-canonical number spellings — a ~308+ digit string coerces to Infinity and passes Number(prNumber) <= 0; a long zero-padded string makes the lease filename exceed NAME_MAX (raw ENAMETOOLONG instead of the clean refusal); '05' derives a second lock key (pr-05 vs pr-5). No destruction path (the write fails before cleanStale), but the gate misses its stated purpose on pathological input.
  • R8-2 (cleanup.ts:440): the post-audit skip note drops the lease path and the force-recovery instruction its pre-audit sibling carries, and "its own cleanup releases it" is false when the holder was SIGKILLed.
  • R8-6 (fetch-pr.ts:651): the exit sweep matches sessionId AND promptId (pre-existing), while this PR defines ownership per session — a lease that outlives its prompt is never auto-released, though the refusal message's manual-delete escape keeps it recoverable.

My own pass found nothing blocking. Two carried-over non-blocking notes: the PR body's "Risk & Scope" still calls the guard a read-then-write check — stale text predating the atomic-acquire commit; and the residual window between cleanup's post-audit re-check and its final unconditional lease release remains by design (the unconditional release is what lets a bare-terminal cleanup finish at all, and the window is a fraction of the audit window this PR closes).

Test evidence — the PR's own CI

Unattended CI run (GITHUB_EVENT_NAME=issue_comment): no PR code was built or executed here; the evidence below is the PR's own CI on the reviewed commit, fetched via the API. Everything completed — nothing pending, nothing red. One superseded Qwen Code CI attempt shows cancelled, including a Desktop Shell (${{ matrix.os }}) job whose matrix variable never expanded — a broken run replaced by the green one, not a failure of the PR. The macOS/Windows test jobs and the no-sandbox integration job are skipped on fork PRs (runner limitation, not failures), so cross-platform evidence rests on the author's report (macOS only) plus libuv portability of the two platform-sensitive bits ('wx' open flag; the ENOENT mapping, with the ENOTDIR test skipped on win32).

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
precheck-pr / precheck success
Secret scan (TruffleHog) success
Dependency CVE audit success
Classify PR success
Qwen Code CI (workflow run) success
Security Checks (workflow run) success
Test (macos-latest / windows-latest, Node 22.x) skipped (fork)
Integration Tests (CLI, No Sandbox) skipped

Sandboxed verification would settle this: @qwen-code /verify — the central claim (a concurrent second session is refused and destroys nothing; the holder's state survives byte-identical) was E2E-verified on the original commit only, and the head has since gained seven autofix commits plus two merges of main, one of them conflict-resolved inside the lock's own files. The unit suite pins gate ordering and wiring but not real two-session behaviour on the current head, so that claim currently rests on the author's earlier report plus green CI.

中文说明

代码审查

我在读 diff 之前独立给出的方案(与上次审查同一基线):按会话判定的 exclusive-create 租约获取、无身份时 fail-closed、门禁前的输入校验、fetch-pr 在任何破坏性步骤之前设门禁并做仅属主的回滚、cleanup 跳过并在审计后复查、worktree 缺失时给出可操作报错。PR 与之逐点一致——并且加上了我也会要求的回滚前先比对属主(clearReviewWorktreeLeaseIfOwned)。

本次重审覆盖而上次没有的内容:01f67553 以来唯一的增量是合并 origin/mainb4d4f1d7),且该合并在 fetch-pr.tsfetch-pr.test.tsSKILL.md 中记录了真实冲突——main 的 #9091(运行会话账本)与 #9100(增量锚点校验)在 PR 期间落进了 runFetchPr。我对冲突解决的核验方式:把新旧两版 runFetchPr 函数体去缩进后逐行 diff——旧侧即当前 main,两项合入特性完整保留;新侧仅新增锁本身——try 之前的身份/拒绝门禁与 catch 中受守卫的回滚(净增约 74 行)。main 侧无一丢失,移动后的函数体内除锁之外无任何行为变化。测试侧的解决结果是预期内的:main 的新测试套件(diff 身份、运行会话账本接线)现在需要在 before/after 钩子里注册/恢复租约身份环境变量,因为 fetch-pr 在无身份时拒绝运行。

早前各轮审查的全部 Critical 发现在当前 head 上仍然成立:清扫跳过活跃租约(isReviewLeaseFile 与写入方共享语法,双向均有测试);审计后复查 TOCTOU(调用顺序测试钉住第二次读取位置);畸形 pr_number 在门禁前被校验拦截;原子获取('wx' 独占创建、同会话刷新、损坏文件自愈);回滚仅在门禁读到空租约且仅删本运行拥有的租约;缺身份时在任何动作前拒绝;ENOTDIR 测试 skipIf(win32)

被延后的第 8 轮三条建议真实存在但非阻塞,且都无法重新引入 #9205 式的破坏,按 autofix 仅 Critical 刹车保持开放留待维护者跟进:R8-1——门禁接受非规范数字写法(约 308 位以上纯数字转为 Infinity 可通过 <= 0 判断;长零填充使租约文件名超 NAME_MAX 而抛原始 ENAMETOOLONG;'05' 派生第二把锁键),无破坏路径但门禁未达既定目的;R8-2——审计后跳过通知缺少姊妹通知携带的租约路径与强制恢复指引,且持有者被 SIGKILL 时"它自己的 cleanup 会释放"为假;R8-6——退出清扫按 sessionId 与 promptId 匹配(既有行为),而本 PR 的所有权按会话定义,存活超过其 prompt 的租约不会被自动释放,但拒绝消息中的手动删除逃生通道使其可恢复。

我自己的一遍审查没有发现阻塞项。两条沿用的非阻塞备注:PR 正文 "Risk & Scope" 仍称守卫是"先读后写"——原子获取提交之前的旧文案;cleanup 审计后复查与最后无条件释放租约之间的残余窗口按设计保留(无条件释放正是裸终端 cleanup 能走完的前提,且该窗口远小于本 PR 已关闭的审计窗口)。

测试证据——PR 自身的 CI

无人值守 CI 运行(GITHUB_EVENT_NAME=issue_comment):此处未构建或执行任何 PR 代码;以下为通过 API 获取的、该 PR 在被审提交上的自身 CI。全部完成——无 pending、无失败。其中一次被取代的 Qwen Code CI 尝试显示 cancelled(包含一个矩阵变量未展开的 Desktop Shell (${{ matrix.os }}) 任务)——是被绿色运行替换的损坏运行,不是 PR 的失败。macOS/Windows 测试与 no-sandbox 集成任务在 fork PR 上被跳过(runner 限制,非失败),跨平台证据依赖作者报告(仅 macOS)加两处平台敏感点的 libuv 可移植性('wx' 打开标志;ENOENT 映射,ENOTDIR 测试在 win32 上跳过)。CI 结论见上表(英文部分)。

沙盒验证可以补上这块:@qwen-code /verify——核心论断(并发第二会话被拒绝且不破坏任何东西、持有者状态逐字节幸存)只在最初提交上做过 E2E 验证,此后 head 又经历了七轮 autofix 提交与两次 main 合并(其中一次恰好在锁所在的文件里做了冲突解决)。单测钉住了门禁顺序与接线,但没有覆盖当前 head 上的真实双会话行为,该论断目前建立在作者早前的报告与绿色 CI 之上。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean re-review on the post-merge head; the only delta since the last pass (a main merge conflict-resolved inside the lock's own files) verified line by line; three deferred suggestion-level findings remain open for follow-up but none can reintroduce the destruction this PR closes.

Stepping back: this started as a fix for a genuinely destructive race that happened in practice (#9205), and it went through the grinder — seven review rounds, five of them requesting changes, each round's Critical findings answered by a dedicated commit. My independent proposal for the problem matches the PR's design point for point. What a re-run adds here is the merge question: main's #9091 and #9100 landed in runFetchPr mid-PR and the merge conflicted in exactly the files carrying the lock. That is where a silent regression would hide, so I stripped indentation and diffed the old and new function bodies line by line — the resolution kept both main features intact and added nothing semantic beyond the lock itself. The test suite pins the invariants that make the lock real (ordering, argument wiring, both directions of every guard), and CI on the reviewed head is fully green with nothing pending. The residual cleanup window is disclosed and designed-around; the stale sentence in the PR body's Risk section is a body-only nit; and the three deferred round-8 suggestions are genuine follow-ups that don't block. Approving, pinned to the reviewed commit — this replaces the earlier approval that the push to this head dismissed. If you want belt-and-suspenders on the live two-session behaviour before merging, @qwen-code /verify names the exact claim it would settle (see the Stage 2 comment).

中文说明

退一步看:这个 PR 始于一个真实发生过的破坏性竞态(#9205),并经历了充分打磨——七轮审查、其中五轮要求修改,每一轮的 Critical 发现都以专门提交回应。我对该问题独立给出的方案与 PR 设计逐点一致。本次重审新增的是合并问题:main 的 #9091#9100 在 PR 期间落进 runFetchPr,而合并恰好在承载这把锁的文件里发生冲突——静默回归最可能藏在那里。因此我去缩进逐行 diff 了新旧函数体:冲突解决完整保留了两项 main 特性,除锁本身之外没有新增任何语义。测试套件钉住了让这把锁真实成立的不变量(顺序、参数接线、每个守卫的双向行为),被审 head 上的 CI 全绿且无 pending。cleanup 的残余窗口已披露且有设计应对;PR 正文 Risk 节的过时文案只是正文层面的一粒沙;被延后的三条第 8 轮建议是真实的跟进项但不阻塞。批准,钉在被审提交——取代此前被本次 head 推送所 dismiss 的批准。如果想在合并前对真实双会话行为加一道保险,@qwen-code /verify 指明了它将验证的确切论断(见 Stage 2 评论)。

Qwen Code · qwen3.8-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed. Suggestions are inline.

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

中文说明

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

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

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

Comment on lines 220 to 221
createReviewWorktreeLease({
sessionId: process.env['QWEN_CODE_SESSION_ID'],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] When fetch-pr fails after writing the lease, it rolls back the branch ref but not the lease — every post-lease-write failure path (git fetch failure, gh pr view failure, worktree-add failure) leaves qwen-review-lease-pr-<n>.json on disk. — Failure scenario: session A starts a review, the lease is written, then a transient git fetch failure throws. The ref is rolled back but the lease remains; the skill stops on fetch failure so the cleanup step never runs, and the end-of-prompt sweep only fires on cancel/error with an exact session+prompt match. A later session B's fetch-pr then refuses with "already being reviewed by another session (session A)" though nothing is reviewing, and B's cleanup skips the target wholesale — until someone deletes the lease file by hand. Unlike the documented hard-kill case, this is triggered by ordinary handled failures whose own function already rolls back sibling state.

Fix: call clearReviewWorktreeLease(process.cwd(), leaseTarget) on the post-lease-write failure paths, alongside the existing branch-ref rollbacks:

tryRemove(() => execFileSync('git', ['branch', '-D', ref], ...));
clearReviewWorktreeLease(process.cwd(), leaseTarget);

A same-session retry rewrites the lease freely, so this is safe.

中文说明

[建议] fetch-pr 在写入租约之后失败时只回滚分支引用、不回滚租约——写租约之后的所有失败路径(git fetch 失败、gh pr view 失败、worktree 创建失败)都会把 qwen-review-lease-pr-<n>.json 留在磁盘上。— 失败场景:会话 A 开始审查、租约已写入,随后一次瞬时的 git fetch 失败抛错。分支引用被回滚但租约留下;技能在 fetch 失败时即停止,因此清理步骤永远不会运行,而 prompt 结束清扫只在取消/出错且 session+prompt 完全匹配时触发。之后会话 B 的 fetch-pr 会以 "already being reviewed by another session (session A)" 拒绝——尽管实际没有任何审查在进行,且 B 的 cleanup 会整体跳过该目标——直到有人手动删除租约文件。与已记录的硬杀场景不同,这是由普通的、已处理的失败触发的,而该函数本已回滚了同批创建的其他状态。

修复:在写租约之后的失败路径上调用 clearReviewWorktreeLease(process.cwd(), leaseTarget),与现有的分支引用回滚并列。同会话重试会自由重写租约,因此该修复是安全的。

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

Comment on lines +368 to +371
expect(vi.mocked(createReviewWorktreeLease)).not.toHaveBeenCalled();
expect(producerMocks.git).not.toHaveBeenCalled();
expect(producerMocks.gh).not.toHaveBeenCalled();
expect(producerMocks.writeFileSync).not.toHaveBeenCalled();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] The refusal test's comment promises "the refusal must precede every destructive step", but its negative-space assertions omit cleanStale's releaseWorktree/execFileSync — the most destructive steps in the function. — Failure scenario: probe-verified surviving mutant — moving cleanStale(prNumber) above the lock check keeps the lease-lock tests green (2/2): releaseWorktree is an anonymous no-op vi.fn in the ./lib/git.js mock factory (not in producerMocks) and refExists is mocked false so the branch delete is skipped, so cleanStale leaves no trace in any asserted mock. In production that mutant removes the holder's worktree before refusing — the exact #9205 destruction this PR exists to prevent.

Witness:

MUTANT-B (cleanStale moved above the lock) vs original tests:
  Tests 2 passed | 36 skipped (38)   <- survives
MUTANT-B + the fix below:
  AssertionError: expected "spy" to not be called at all, but actually been called 1 times   <- killed

Fix: hoist releaseWorktree from the ./lib/git.js factory into producerMocks and assert it here (ideally a hoisted execFileSync assertion too):

expect(producerMocks.releaseWorktree).not.toHaveBeenCalled();
中文说明

[建议] 拒绝测试的注释承诺"拒绝必须先于所有破坏性步骤",但其"什么都没发生"断言遗漏了 cleanStalereleaseWorktree/execFileSync——函数中最具破坏性的步骤。— 失败场景:经探针验证的存活变异体——把 cleanStale(prNumber) 移到锁检查之上,lease-lock 测试仍然全绿(2/2):releaseWorktree./lib/git.js mock 工厂里的匿名空 vi.fn(不在 producerMocks 中),且 refExists 被 mock 为 false 使分支删除被跳过,因此 cleanStale 不会在任何被断言的 mock 上留下痕迹。在生产中该变异体会在拒绝之前删除持有者的 worktree——正是本 PR 要阻止的 #9205 破坏。

证据:变异体(cleanStale 上移)对原测试 Tests 2 passed | 36 skipped (38)(存活);加上下方修复后 AssertionError: expected "spy" to not be called at all, but actually been called 1 times(被杀)。

修复:把 releaseWorktree./lib/git.js 工厂提升进 producerMocks,并在此断言它未被调用(最好同时提升并断言 execFileSync)。

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

Comment on lines +359 to +361
it('refuses with an actionable error when another session holds the lease', async () => {
vi.mocked(readReviewWorktreeLease).mockReturnValueOnce(foreignLease);
vi.mocked(reviewLeaseHeldByAnotherSession).mockReturnValueOnce(true);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] Neither lock test verifies which lease target the command reads — mockReturnValueOnce is argument-blind, so the lock's target wiring is untested. The same shape exists in cleanup.test.ts's skip test (readReviewWorktreeLease is never argument-asserted there either). — Failure scenario: probe-verified surviving mutant — readReviewWorktreeLease(process.cwd(), leaseTarget)readReviewWorktreeLease(process.cwd(), 'pr-999') keeps 38/38 fetch-pr tests and 29/29 cleanup tests green; the 'qwen-review-lease-pr-42.json' assertion only pins the target passed to reviewLeasePath for the error message (a separate call). In production the lock consults an unrelated PR's lease file, the same-PR race goes undetected, and the concurrent same-PR worktree destruction recurs.

Witness:

under the 'pr-999' mutant:   Tests 38 passed (38)   <- survives
with the assertion below:    AssertionError: expected "spy" to be called with arguments ... - "pr-42", + "pr-999"   <- killed

Fix: assert the wiring in both suites:

expect(vi.mocked(readReviewWorktreeLease)).toHaveBeenCalledWith(process.cwd(), 'pr-42');
// and in the cleanup skip test:
expect(vi.mocked(readReviewWorktreeLease)).toHaveBeenCalledWith(process.cwd(), 'pr-123');
中文说明

[建议] 两个锁测试都没有验证命令读取的是哪个租约目标——mockReturnValueOnce 对参数不敏感,因此锁的目标接线未被测试。cleanup.test.ts 的跳过测试存在同样的形态(那里的 readReviewWorktreeLease 同样从未被参数断言)。— 失败场景:经探针验证的存活变异体——readReviewWorktreeLease(process.cwd(), leaseTarget)readReviewWorktreeLease(process.cwd(), 'pr-999') 仍使 fetch-pr 38/38、cleanup 29/29 测试全绿;'qwen-review-lease-pr-42.json' 断言只钉住了传给 reviewLeasePath 用于报错文案的目标(另一次调用)。在生产中锁会去查一个无关 PR 的租约文件,同 PR 竞态无法被检测到,并发同 PR 审查互相删除 worktree 的问题会复现。

证据:'pr-999' 变异体下 Tests 38 passed (38)(存活);加上下方断言后 AssertionError: expected "spy" to be called with arguments ... - "pr-42", + "pr-999"(被杀)。

修复:在两个测试套件中断言接线(fetch-pr 用 'pr-42',cleanup 用 'pr-123')。

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

Comment on lines +207 to +208
const holder = readReviewWorktreeLease(process.cwd(), leaseTarget);
if (reviewLeaseHeldByAnotherSession(holder)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] fetch-pr has no test that an existing lease held by the SAME session lets the fetch proceed — cleanup has the symmetric "proceeds when the lease belongs to this session" test, but this suite only exercises "no lease" and "foreign lease refuses". — Failure scenario: probe-verified surviving mutant — if (reviewLeaseHeldByAnotherSession(holder))if (holder) keeps 38/38 tests green (the refusal tests' holder is non-null; every other test's default read returns null), so lock-by-mere-presence ships. In production that locks a session out of its own lease: a later round of a multi-prompt review re-runs fetch-pr while the earlier prompt's lease is still on disk and refuses — contradicting the comment above ("ownership is per session, not per prompt").

Witness: under the if (holder) mutant, a same-session test fails with Error: PR #42 is already being reviewed by another session (session session-self) thrown at this line; against correct code 39/39 pass.

Fix: add a lease-lock test where readReviewWorktreeLease returns a lease whose sessionId equals QWEN_CODE_SESSION_ID and reviewLeaseHeldByAnotherSession returns false, asserting the run proceeds (no throw, createReviewWorktreeLease called).

中文说明

[建议] fetch-pr 没有测试"同一会话已持有租约时 fetch 应继续"——cleanup 有对称的"租约属于本会话时继续"测试,但 fetch-pr 的锁测试只覆盖"无租约"和"他会话租约拒绝"两种情况。— 失败场景:经探针验证的存活变异体——if (reviewLeaseHeldByAnotherSession(holder))if (holder) 仍使 38/38 测试全绿(拒绝测试的 holder 非空;其他测试的默认读取返回 null),因此"只要有租约就锁"的形态可以绿灯上线。在生产中这会把会话锁在自己的租约之外:多 prompt 审查的后续轮次在上一 prompt 的租约仍在磁盘上时重跑 fetch-pr 会被拒绝——与上方注释("所有权按会话而非 prompt 判定")矛盾。

证据:if (holder) 变异体下同会话测试失败,抛出 Error: PR #42 is already being reviewed by another session (session session-self);正确代码下 39/39 通过。

修复:新增锁测试——readReviewWorktreeLease 返回 sessionId 等于 QWEN_CODE_SESSION_ID 的租约且 reviewLeaseHeldByAnotherSession 返回 false,断言运行继续(不抛错、createReviewWorktreeLease 被调用)。

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

Comment on lines +377 to +378
if (isAbsentError(err)) {
throw new Error(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] The rethrow branch of the new try/catch is untested: no test feeds a worktree path that exists but is not a directory, or any other non-absent startup error. — Failure scenario: probe-verified surviving mutant — dropping the isAbsentError(err) condition so every startup error converts to "worktree … is missing — re-run fetch-pr" keeps 34/34 tests green (the only new test feeds ENOENT, which still converts). In production a --worktree pointing at a regular file, or an EACCES, would be diagnosed as "missing" with a remedy that cannot fix it, instead of the precise "worktree is not a directory" message this diff preserves via the rethrow.

Witness:

under the unconditional-conversion mutant:   Tests 34 passed (34)   <- survives
with the test below:                         AssertionError: expected [Function] to throw error including 'worktree is not a directory' but got 'repo-context: worktree ... is missing ...'   <- killed

Fix: add a test that creates a regular file and passes it as worktree, expecting worktree is not a directory.

中文说明

[建议] 新 try/catch 的重抛分支未被测试:没有测试传入"存在但不是目录"的 worktree 路径,也没有测试任何其他非缺失类启动错误。— 失败场景:经探针验证的存活变异体——去掉 isAbsentError(err) 条件、让所有启动错误都转换为 "worktree … is missing — re-run fetch-pr",34/34 测试仍全绿(唯一的新测试喂的是 ENOENT,仍会被转换)。在生产中 --worktree 指向一个普通文件、或遇到 EACCES 时,会被诊断为"缺失"并给出无法修复它的补救建议,而不是本 diff 通过重抛保留的精确报错 "worktree is not a directory"。

证据:无条件转换变异体下 Tests 34 passed (34)(存活);加上下方测试后 AssertionError: expected [Function] to throw error including 'worktree is not a directory' but got 'repo-context: worktree ... is missing ...'(被杀)。

修复:新增测试——创建一个普通文件并作为 worktree 传入,期望报错包含 worktree is not a directory

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

Comment on lines +379 to +380
`repo-context: worktree ${worktreeRoot} is missing — re-run ` +
`\`qwen review fetch-pr\` to recreate it`,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] The new actionable remedy hardcodes qwen review fetch-pr, but repo-context is also invoked for local and file-path reviews, whose worktree is not created by fetch-pr — for local reviews --worktree is the user's own checkout. — Failure scenario: the bundled skill runs repo-context "for local, file-path, and same-repository PR reviews"; if a local review's --worktree directory is absent at runtime (a stale absolute path, or the checkout deleted/moved mid-review), the error tells the agent to re-run qwen review fetch-pr — a PR-only command whose positionals are demandOption and which only creates .qwen/tmp/review-pr-<n>; it cannot recreate a local review's tree under any arguments. A skill-following agent pursues a command that structurally cannot fix its flow and burns turns on argument-validation failures. The new test asserts this exact string against a plan shape with no prNumber, corroborating that the message is flow-agnostic.

Fix: make the remedy flow-aware — at this throw site the plan has not been read yet, so either read it first or phrase the remedy conditionally, e.g. is missing — recreate the review worktree (for PR targets: re-run qwen review fetch-pr).

中文说明

[建议] 新的可操作补救建议硬编码了 qwen review fetch-pr,但 repo-context 同样会被本地审查和文件路径审查调用,而这些流程的 worktree 并不是 fetch-pr 创建的——本地审查的 --worktree 就是用户自己的检出目录。— 失败场景:内置技能会"在本地、文件路径和同仓库 PR 审查中"运行 repo-context;如果本地审查的 --worktree 目录在运行时不存在(过期的绝对路径,或审查中途检出被删除/移动),报错会指示 agent 重跑 qwen review fetch-pr——一个仅限 PR 的命令,其位置参数是 demandOption,且只会创建 .qwen/tmp/review-pr-<n>;在任何参数下它都无法重建本地审查的目录。遵循技能的 agent 会去执行一个结构上不可能修复其流程的命令,在参数校验失败上白白消耗轮次。新测试正是在没有 prNumber 的计划形态上断言了这串文案,印证该消息与流程无关。

修复:让补救建议感知流程——在此抛错处计划尚未被读取,因此要么先读计划,要么把补救措辞改为条件式,例如 "is missing — recreate the review worktree (for PR targets: re-run qwen review fetch-pr)"。

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

@wenshao

wenshao commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 15, 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 Aug 15, 2026

Copy link
Copy Markdown
Collaborator

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

中文说明

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

…issing-worktree remedy

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

Copy link
Copy Markdown
Collaborator

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

Review round summary (PR #9211, round 1)

All six inline findings are Suggestions from the automated reviewer; all six were verified against the code and implemented. One commit: 95db313f38. No base conflicts (--conflict false); origin/main was not merged.

Findings and dispositions

  1. rc:3789341700 — lease left behind on post-lease-write failure paths (fetch-pr.ts) — Implemented. Reproduced first: three new tests asserting clearReviewWorktreeLease(process.cwd(), 'pr-42') fail against the pre-fix code on all three paths (git fetch, gh pr view, worktree add), because the lease was never rolled back. The fix adds clearReviewWorktreeLease(process.cwd(), leaseTarget) alongside the existing rollback in each of the three catch/throw sites. This is safe: only a run that passed the lock (no foreign lease) reaches those paths, so the clear only ever removes a lease this run or its own session wrote, and the lock's same-session rule lets a retry rewrite it freely. The pre-fix failure was amplified by this PR's own lock: before it, a leftover lease only made cleanup skip; with the lock, it refuses every later session's fetch-pr until the file is deleted by hand.
  2. rc:3789341704 — refusal test's negative space misses cleanStale's destructive steps (fetch-pr.test.ts) — Implemented. Hoisted releaseWorktree and execFileSync from anonymous mock-factory entries into producerMocks, and the refusal test now asserts neither was called. Verified by inspection that the described mutant (moving cleanStale above the lock) previously left no trace in any asserted mock (refExists is mocked false, so the branch delete is skipped).
  3. rc:3789341706 — lock tests never assert which lease target is read (fetch-pr.test.ts, cleanup.test.ts) — Implemented. Added expect(readReviewWorktreeLease).toHaveBeenCalledWith(process.cwd(), 'pr-42') to the fetch-pr refusal test and the symmetric (process.cwd(), 'pr-123') assertion to the cleanup skip test, killing the argument-blind 'pr-999' mutant shape.
  4. rc:3789341708 — no test that the holding session passes its own lock (fetch-pr.ts) — Implemented. Added lets the holding session re-fetch its own lease: the read returns a lease with the current session's id, reviewLeaseHeldByAnotherSession returns false, and the run proceeds (no throw, createReviewWorktreeLease called exactly once). This kills the if (holder) lock-by-mere-presence mutant and mirrors cleanup's existing symmetric test.
  5. rc:3789341709 — rethrow branch of the new try/catch untested (repo-context.ts) — Implemented. Added a test that passes a regular file as --worktree and expects the precise rethrown worktree is not a directory error, killing the unconditional-conversion mutant.
  6. rc:3789341710 — missing-worktree remedy hardcodes a PR-only command (repo-context.ts) — Implemented. Premise verified: the bundled skill runs repo-context for local, file-path, and same-repository PR reviews, and fetch-pr can only recreate a PR target's worktree. The remedy is now flow-conditional: is missing — recreate the review worktree (for PR targets: re-run qwen review fetch-pr); the existing test assertion was updated to the new wording. (The issue-level E2E report quoting the old wording predates this round and needed no action.)

No findings were declined, deferred, or escalated.

Notes

Verification

  • Reproduction run (tests only, pre-fix source): npx vitest run src/commands/review/fetch-pr.test.ts src/commands/review/cleanup.test.ts src/commands/review/repo-context.test.ts — 4 failed / 102 passed; the 4 failures are exactly the defect witnesses (3 lease-rollback tests + 1 remedy-wording test), confirming findings 1 and 6 reproduce on current code
  • Focused suites post-fix: npx vitest run src/commands/review/fetch-pr.test.ts src/commands/review/cleanup.test.ts src/commands/review/repo-context.test.ts src/services/review-worktree-lease.test.ts — 121 passed
  • Whole review directory: npx vitest run src/commands/review/ (packages/cli) — 2529 passed, 4 skipped
  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on the 5 changed files — passed
  • Integration tests after npm run bundle — not required: no integration test references fetch-pr/repo-context/cleanup (verified by grep); the touched behavior is fully exercised by the unit suites above
  • npm run generate:settings-schema — not required: no settings source changed
中文说明

审查轮次总结(PR #9211,第 1 轮)

六条行内发现均为自动审查器给出的 Suggestion;六条都已对照代码核实并实现。单个提交:95db313f38。无基线冲突(--conflict false);未合并 origin/main

发现与处理

  1. rc:3789341700 —— 写租约之后的失败路径遗留租约fetch-pr.ts)——已实现。 先复现:三个新测试断言 clearReviewWorktreeLease(process.cwd(), 'pr-42'),在修复前的代码上三条路径(git fetch、gh pr view、worktree 创建)全部失败,因为租约从未被回滚。修复在三个 catch/throw 位置与现有回滚并列调用 clearReviewWorktreeLease(process.cwd(), leaseTarget)。该修复是安全的:只有通过了锁检查(无他人租约)的运行才会到达这些路径,因此清除的只可能是本次运行或其同会话写入的租约,且锁的同会话规则允许重试自由重写租约。修复前的失败会被本 PR 自身的锁放大:此前遗留租约只会让 cleanup 跳过;加锁之后,它会拒绝所有后续会话的 fetch-pr,直到手动删除租约文件。
  2. rc:3789341704 —— 拒绝测试的"什么都没发生"断言遗漏 cleanStale 的破坏性步骤fetch-pr.test.ts)——已实现。releaseWorktreeexecFileSync 从匿名的 mock 工厂条目提升进 producerMocks,拒绝测试现在断言两者均未被调用。经代码检查确认所述变异体(把 cleanStale 移到锁之上)此前不会在任何被断言的 mock 上留下痕迹(refExists 被 mock 为 false,分支删除被跳过)。
  3. rc:3789341706 —— 锁测试从未断言读取的是哪个租约目标fetch-pr.test.tscleanup.test.ts)——已实现。 在 fetch-pr 拒绝测试中新增 expect(readReviewWorktreeLease).toHaveBeenCalledWith(process.cwd(), 'pr-42'),并在 cleanup 跳过测试中新增对称的 (process.cwd(), 'pr-123') 断言,杀死参数不敏感的 'pr-999' 变异体形态。
  4. rc:3789341708 —— 没有测试持有会话能通过自己的锁fetch-pr.ts)——已实现。 新增 lets the holding session re-fetch its own lease:读取返回当前会话 id 的租约、reviewLeaseHeldByAnotherSession 返回 false,运行继续(不抛错、createReviewWorktreeLease 恰好被调用一次)。该测试杀死"只要有租约就锁"(if (holder))变异体,并与 cleanup 已有的对称测试呼应。
  5. rc:3789341709 —— 新 try/catch 的重抛分支未被测试repo-context.ts)——已实现。 新增测试:把一个普通文件作为 --worktree 传入,期望精确的重抛报错 worktree is not a directory,杀死无条件转换变异体。
  6. rc:3789341710 —— worktree 缺失的补救建议硬编码了仅限 PR 的命令repo-context.ts)——已实现。 前提已核实:内置技能会在本地、文件路径和同仓库 PR 审查中运行 repo-context,而 fetch-pr 只能重建 PR 目标的 worktree。补救措辞改为条件式:is missing — recreate the review worktree (for PR targets: re-run qwen review fetch-pr);既有测试断言同步更新为新措辞。(引用旧措辞的 issue 级 E2E 报告早于本轮,无需处理。)

没有发现被拒绝、推迟或升级。

说明

验证

  • 复现运行(仅测试、修复前源码):npx vitest run src/commands/review/fetch-pr.test.ts src/commands/review/cleanup.test.ts src/commands/review/repo-context.test.ts —— 4 失败 / 102 通过;4 个失败恰为缺陷证据(3 个租约回滚测试 + 1 个补救措辞测试),确认发现 1 与 6 在当前代码上可复现
  • 修复后聚焦套件:npx vitest run src/commands/review/fetch-pr.test.ts src/commands/review/cleanup.test.ts src/commands/review/repo-context.test.ts src/services/review-worktree-lease.test.ts —— 121 通过
  • review 目录全量:npx vitest run src/commands/review/(packages/cli)—— 2529 通过,4 跳过
  • npm run build —— 通过
  • npm run typecheck —— 通过
  • npm run lint —— 通过
  • 对 5 个改动文件运行 npx prettier --check —— 通过
  • npm run bundle 后的集成测试 —— 无需:集成测试未引用 fetch-pr/repo-context/cleanup(已用 grep 核实);上述改动的行为已由单元测试套件完整覆盖
  • npm run generate:settings-schema —— 无需:未改动任何 settings 源

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

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

中文说明

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

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

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

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

Comment on lines +400 to +401
const holder = readReviewWorktreeLease(process.cwd(), target);
if (reviewLeaseHeldByAnotherSession(holder)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] A file-review cleanup for a target named lease deletes every live PR lease, defeating the lock this PR adds. The new guard only runs inside the prMatch branch, but the unchanged per-target side-file sweep below it deletes any .qwen/tmp/ entry matching tmpPrefix(target). For a file-review target named lease (or ./lease, lease-pr), safeTarget/tmpPrefix flatten to exactly qwen-review-lease- (= LEASE_PREFIX) or qwen-review-lease-pr-, so the sweep rmSyncs every PR's lease file — including a live lease held by another session. Before this PR that collision was inert; this diff makes the lease the lock, so the sweep now defeats the diff's own protection.

Failure scenario: session A is mid-review of PR 9205 (lease on disk). A file review of a top-level file named lease ends with qwen review cleanup ./lease: prMatch is null so the guard never runs, the sweep deletes qwen-review-lease-pr-9205.json, and session C's fetch-pr for 9205 then reads no lease, passes the lock, and cleanStale removes session A's worktree mid-review — the exact (9205) destruction this PR exists to prevent.

Witness (probe, unmodified PR):

runCleanup('lease') -> stdout "Removed temp file: .qwen/tmp/qwen-review-lease-pr-9205.json"
existsSync(lease)=false; readReviewWorktreeLease->null; heldByAnotherSession(null)->false
with one-line fix -> lease survives; heldByAnotherSession->true (session C blocked); cleanup.test.ts 29/29 pass

Suggested fix — skip lease files in the sweep (lease removal stays the job of clearReviewWorktreeLease, already called for the run's own target): add if (file.startsWith(LEASE_PREFIX)) continue; at the top of the tmpEntries loop, plus a regression test: runCleanup('./lease') with a live pr-123 lease on disk must leave it standing.

中文说明

[Critical] 对名为 lease 的文件审查目标执行清理时,会删除所有仍在生效的 PR 租约,从而瓦解本 PR 新加的锁。新守卫只在 prMatch 分支内运行,但其下方未改动的按目标附属文件清扫会删除任何匹配 tmpPrefix(target).qwen/tmp/ 条目。对于名为 lease(或 ./leaselease-pr)的文件审查目标,safeTarget/tmpPrefix 恰好被压平为 qwen-review-lease-(= LEASE_PREFIX)或 qwen-review-lease-pr-,因此该清扫会 rmSync 掉所有 PR 的租约文件——包括另一个会话正持有的租约。本 PR 之前该碰撞无害;本 diff 使租约成为锁,于是该清扫现在反而破坏了 diff 自身的保护。

失败场景:会话 A 正在审查 PR 9205(租约在盘上)。对名为 lease 的顶层文件的文件审查以 qwen review cleanup ./lease 收尾:prMatch 为 null,守卫不会运行,清扫删除 qwen-review-lease-pr-9205.json,随后会话 C 对 9205 的 fetch-pr 读不到租约、通过锁,cleanStale 在审查中途移除会话 A 的 worktree——正是本 PR 要修复的 (9205) 破坏。

建议修复:在清扫循环顶部加入 if (file.startsWith(LEASE_PREFIX)) continue; 跳过租约文件(租约删除仍由 clearReviewWorktreeLease 负责),并补充回归测试:盘上存在 pr-123 生效租约时 runCleanup('./lease') 必须保留它。

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

Comment on lines +237 to +240
// Roll back the lease too: the lock above refuses any later session that
// finds another session's lease, so one left behind by a handled failure
// would block every later review of this PR until deleted by hand.
clearReviewWorktreeLease(process.cwd(), leaseTarget);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Lease rollback covers only the three wrapped catches; later throwing paths orphan the lease. After the lease write these paths remain unwrapped: git('rev-parse', ref) (~line 247), mkdirSync(REVIEW_TMP_DIR, { recursive: true }), and the final report write writeFileSync(out, stringifyPlanReport(result), 'utf8') (~line 468). The yargs handler has no outer catch, and cleanupReviewWorktreeLeases requires an exact (session, prompt) match of the ending run, so an orphan from a dead fetch-pr is not swept.

Concrete cost: worktree and lease created successfully, then the report write fails with ENOSPC/EACCES on .qwen/tmp; the run exits non-zero while the lease persists; every other session's review of that PR is refused with "already being reviewed" until someone deletes the lease file by hand — the exact stuck state the added rollback comment says it exists to prevent. Any step added after worktree add in the future reintroduces the same hole unless it remembers its own clearReviewWorktreeLease.

Suggested fix — wrap the post-lease body of runFetchPr in a single try/catch that calls clearReviewWorktreeLease(process.cwd(), leaseTarget) on any failure and rethrows (keeping the branch-deletion rollbacks where they are), instead of per-catch additions.

中文说明

[Suggestion] 租约回滚只覆盖了三个被包裹的 catch;其后的抛错路径会让租约成为孤儿。租约写入之后这些路径仍未被包裹:git('rev-parse', ref)(约 247 行)、mkdirSync(REVIEW_TMP_DIR, { recursive: true }),以及最终的报告写入 writeFileSync(out, stringifyPlanReport(result), 'utf8')(约 468 行)。yargs handler 没有外层 catch,而 cleanupReviewWorktreeLeases 要求结束运行精确匹配(session, prompt),因此一个死掉的 fetch-pr 留下的孤儿租约不会被清扫。

具体代价:worktree 与租约成功创建后,报告写入因 .qwen/tmp 的 ENOSPC/EACCES 失败;运行非零退出而租约残留;该 PR 的其他会话审查都会被 "already being reviewed" 拒绝,直到有人手动删除租约文件——正是新增回滚注释声称要避免的卡死状态。

建议修复:把 runFetchPr 中租约写入后的主体包进单个 try/catch,在任何失败时调用 clearReviewWorktreeLease(process.cwd(), leaseTarget) 后重新抛出(分支删除的回滚保留在原处),取代逐个 catch 的补充。

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

Comment on lines +376 to +377
} catch (err) {
if (isAbsentError(err)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] An intermediate-component ENOTDIR is mapped to the "worktree is missing" remedy. isAbsentError treats ENOTDIR as absent, so a realpathSync failure caused by a non-directory path component is converted into "worktree … is missing — re-run qwen review fetch-pr" — a remedy that cannot fix a malformed path argument, while the precise "not a directory" diagnosis is lost. The ENOTDIR inclusion is correct for the helper's sibling call sites (identity-file lookup, absent → null); the fix belongs at this new call site only.

Failure scenario: --worktree /repo/some-file/subdir where some-file is a regular file (stale plan path or typo through a file) raises ENOTDIR; the user is told the worktree is missing and to re-run fetch-pr, which cannot fix the argument.

Witness: node -e "fs.realpathSync('/etc/passwd/foo')" → ENOTDIR (missing path → ENOENT, for contrast).

Suggested fix — at this call site, treat only ENOENT as "missing" and rethrow ENOTDIR (or convert it to the existing "not a directory" error); leave the shared helper unchanged:

const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') { /* actionable missing-worktree message */ }
中文说明

[Suggestion] 中间路径分量触发的 ENOTDIR 被映射为 "worktree 缺失" 的补救提示。isAbsentError 把 ENOTDIR 视为缺失,因此由非目录路径分量导致的 realpathSync 失败会被转换为 "worktree … is missing — re-run qwen review fetch-pr"——该补救无法修复错误的路径参数,同时丢失了精确的 "not a directory" 诊断。ENOTDIR 的纳入对该辅助函数的其他调用点(identity 文件查找,缺失→null)是正确的;修复只应落在本新调用点。

失败场景:--worktree /repo/some-file/subdirsome-file 是普通文件时抛出 ENOTDIR;用户被告知 worktree 缺失并重跑 fetch-pr,但这无法修复参数。

建议修复:在本调用点仅将 ENOENT 视为 "缺失",对 ENOTDIR 重新抛出(保持共享辅助函数不变)。

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

Comment on lines +376 to +377
} catch (err) {
if (isAbsentError(err)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] No test pins the ENOTDIR branch at this new call site. The two new tests cover ENOENT (missing path) and the rethrow half (regular file as final component — realpathSync succeeds there). Nothing exercises a regular file as an intermediate path component, so the ENOTDIR branch is unpinned.

Concrete cost: a future edit narrowing this site to ENOENT-only silently regresses the actionable message for the ENOTDIR case while both new tests stay green. Verified by mutation: replacing isAbsentError(err) with an ENOENT-only check here leaves repo-context.test.ts passing 35/35.

Suggested fix — add a case with a regular file as a path component (e.g. write(join(root, 'blocker'), 'x'), then worktree: join(root, 'blocker', 'wt')) pinning whichever behavior is decided for the ENOTDIR remedy above.

中文说明

[Suggestion] 没有测试钉住本新调用点的 ENOTDIR 分支。两个新测试覆盖了 ENOENT(路径缺失)与重新抛出的那一半(普通文件作为最终分量——此时 realpathSync 成功)。没有任何用例让普通文件成为中间路径分量,因此 ENOTDIR 分支未被钉住。

具体代价:未来把本调用点收窄为仅 ENOENT 的改动会让 ENOTDIR 情形下的可操作提示悄然回归,而两个新测试仍全绿。已通过变异验证:把此处的 isAbsentError(err) 替换为仅 ENOENT 检查,repo-context.test.ts 仍 35/35 通过。

建议修复:补充一个普通文件作为路径分量的用例,钉住上面 ENOTDIR 补救所决定的行为。

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

Comment on lines +421 to +427
await expect(reportFor({})).rejects.toThrow(
'Failed to fetch PR #42 from remote "origin"',
);
expect(vi.mocked(clearReviewWorktreeLease)).toHaveBeenCalledWith(
process.cwd(),
'pr-42',
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] No test pins that the lease write precedes cleanStale / the first git call. The refusal-side ordering IS pinned (no destructive calls on refusal), but there is no mock.invocationCallOrder assertion that createReviewWorktreeLease runs before the first git call — the ordering that keeps the (9205) window at microseconds instead of the whole network-bound fetch duration.

Concrete cost: relocating createReviewWorktreeLease to after the fetch's catch keeps all 42 tests green (verified by mutation) while widening the concurrent interleave window — session B starts while session A is inside git fetch, reads no lease, passes, and runs cleanStale. The repo already uses the idiom (parse-args.test.ts, publish-assets.test.ts, drive.test.ts).

Suggested fix — in a happy-path or rollback test, assert vi.mocked(createReviewWorktreeLease).mock.invocationCallOrder[0] is less than the first git call's invocation order.

中文说明

[Suggestion] 没有测试钉住"租约写入先于 cleanStale/首个 git 调用"。拒绝侧的顺序已被钉住(拒绝时无破坏性调用),但没有 mock.invocationCallOrder 断言保证 createReviewWorktreeLease 先于首个 git 调用——正是这个顺序把 (9205) 窗口压在微秒级,而不是整个网络受限的 fetch 时长。

具体代价:把 createReviewWorktreeLease 移到 fetch 的 catch 之后,42 个测试仍全绿(已变异验证),却放宽了并发交错窗口——会话 B 在会话 A 处于 git fetch 期间启动,读不到租约、通过检查并运行 cleanStale

建议修复:在 happy-path 或回滚测试中断言 createReviewWorktreeLease 的调用序号小于首个 git 调用。

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

Comment on lines +175 to +180
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('skipped cleanup for "pr-123"'),
);
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('session-a'),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The skip-note test does not pin the lease path interpolation. The skip note interpolates reviewLeasePath(process.cwd(), target) so the operator knows which lease file to delete, but this test pins only the target and the holder session id; the mocked reviewLeasePath goes unused and unasserted. The fetch-pr side of this same diff explicitly pins its counterpart ("names the lease file to delete").

Concrete cost: a future edit dropping or mangling the ${reviewLeasePath(...)} interpolation leaves the operator told to delete "the lease file" without knowing which file, while every test stays green. Verified by mutation: dropping the interpolation leaves cleanup.test.ts passing 29/29.

Suggested change
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('skipped cleanup for "pr-123"'),
);
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('session-a'),
);
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('skipped cleanup for "pr-123"'),
);
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('session-a'),
);
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen-review-lease-pr-123.json'),
);
中文说明

[Suggestion] skip-note 测试没有钉住租约路径插值。skip note 通过 reviewLeasePath(process.cwd(), target) 插值告知操作者要删除哪个租约文件,但本测试只钉住了 target 与持有者会话 id;被 mock 的 reviewLeasePath 未被使用也未被断言。同一 diff 的 fetch-pr 一侧已明确钉住了对应项("names the lease file to delete")。

具体代价:未来删除或破坏 ${reviewLeasePath(...)} 插值的改动会让操作者只被告知删除"租约文件"却不知道是哪个文件,而所有测试仍全绿。已变异验证:删除该插值后 cleanup.test.ts 仍 29/29 通过。

建议修复:补充对 qwen-review-lease-pr-123.json 的断言(见上方 suggestion)。

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

Comment thread docs/users/features/code-review.md Outdated
- If anything goes wrong, your environment is unaffected — just delete the worktree
- The worktree is automatically cleaned up after the review completes
- If a review is interrupted (Ctrl+C, crash), the next `/review` of the same PR automatically cleans up the stale worktree before starting fresh
- If a review is interrupted (Ctrl+C, crash), the next `/review` of the same PR automatically cleans up the stale worktree before starting fresh. If the interrupted session still leaves its lease behind — a hard kill that skips this, or a multi-prompt review interrupted during a later prompt — `/review` refuses and names the lease file to delete

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The emptyDiff early stop leaves the lease behind with no cleanup. This residue inventory covers interrupted sessions only, but the skill's own CLEAN emptyDiff: true early stop (SKILL.md step 1: "stop here … recommend close-as-superseded") never runs cleanup — unlike the adjacent same-SHA stop, which explicitly runs qwen review cleanup pr-<n> before stopping. The TUI registers no exit lease sweep on a clean stop, so the lease survives process exit; the guidance this PR adds then forbids later agents from deleting it.

Failure scenario: session 1 hits emptyDiff and stops per SKILL.md; the lease persists; session 2 (e.g. a scheduled CI review) refuses with "already being reviewed by another session" and is told not to delete the lease — the PR is unreviewable until a human removes the file. Pre-PR, session 2 would have stale-cleaned and proceeded.

Suggested fix — mirror the same-SHA stop: add run "${QWEN_CODE_CLI:-qwen}" review cleanup pr-<n> to the emptyDiff stop in SKILL.md step 1 (cleanup passes the guard — same session — and releases the lease), and extend this bullet's inventory accordingly.

中文说明

[Suggestion] emptyDiff 提前停止会遗留租约且没有清理。此处的残留清单只覆盖被中断的会话,但技能自身干净退出的 emptyDiff: true 提前停止(SKILL.md 第 1 步:"stop here … recommend close-as-superseded")从不运行清理——不像相邻的 same-SHA 停止那样在停止前显式运行 qwen review cleanup pr-<n>。TUI 在干净退出时不注册租约清扫,因此租约会存活到进程退出之后;而本 PR 新增的指引又禁止后续 agent 删除它。

失败场景:会话 1 命中 emptyDiff 并按 SKILL.md 停止;租约残留;会话 2(如定时 CI 审查)以 "already being reviewed by another session" 拒绝且被告知不要删除租约——该 PR 在有人手动删除文件前无法再被审查。本 PR 之前,会话 2 会 stale-clean 后继续。

建议修复:参照 same-SHA 停止,在 SKILL.md 第 1 步的 emptyDiff 停止中加入运行 qwen review cleanup pr-<n>(cleanup 能通过守卫——同一会话——并释放租约),并相应扩展本条清单。

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review round addressed — PR #9211

All 7 findings (1 Critical, 6 Suggestions) were validated against the code and resolved in code in commit 6f3a68e4f9. No conflict was flagged, so no merge was performed.

Findings and dispositions

  1. [Critical] A file-review cleanup for a target named lease deletes every live PR lease (cleanup.ts) — Fixed. Reproduced first: a new regression test running runCleanup('lease') with qwen-review-lease-pr-123.json in .qwen/tmp/ failed on the pre-fix code — the sweep rmSynced the lease. safeTarget flattens lease / ./lease to exactly the lease prefix, so the per-target sweep matched every PR lease while the PR-only lease guard never ran. Fix: skip any LEASE_PREFIX entry at the top of the sweep loop; lease removal stays the job of clearReviewWorktreeLease, which runs for the run's own target. Added the regression test plus a positive control proving the sweep still removes non-lease side files.
  2. [Suggestion] Lease rollback covers only the three wrapped catches (fetch-pr.ts) — Fixed. The three scattered per-catch clearReviewWorktreeLease calls were replaced by a single try/catch wrapping the whole post-lease body of runFetchPr, rolling the lease back on ANY throw. This covers the previously unwrapped paths (rev-parse, mkdirSync, the final report write — pinned by a new test that fails the report write with ENOSPC and asserts the lease is cleared) and any future step added there. Branch rollbacks stay where the ref they remove is created; the change leaves one rollback point instead of three.
  3. [Suggestion] An intermediate-component ENOTDIR is mapped to the "worktree is missing" remedy (repo-context.ts) — Fixed. Reproduced: realpathSync through a regular-file path component raises ENOTDIR, which the pre-fix code converted to the missing-worktree message. The new call site now treats only ENOENT as "missing"; ENOTDIR (a malformed --worktree argument that no re-fetch can fix) rethrows with its precise errno diagnosis. The shared isAbsentError helper is unchanged — its ENOTDIR inclusion remains correct for the identity-file lookups.
  4. [Suggestion] No test pins the ENOTDIR branch at this call site (repo-context.ts) — Fixed. Added a test using a regular file as an intermediate path component; it failed on the pre-fix code (got the "is missing" message) and now pins the ENOTDIR rethrow.
  5. [Suggestion] No test pins that the lease write precedes cleanStale / the first git call (fetch-pr.test.ts) — Fixed. Added a mock.invocationCallOrder assertion that createReviewWorktreeLease runs before both the stale-clean's releaseWorktree call and the first git call, keeping the concurrent interleave window pinned to microseconds instead of the network-bound fetch duration.
  6. [Suggestion] The skip-note test does not pin the lease path interpolation (cleanup.test.ts) — Fixed. The skip-note test now also asserts the note names the lease file (qwen-review-lease-pr-123.json), matching the fetch-pr side's existing pin.
  7. [Suggestion] The emptyDiff early stop leaves the lease behind (SKILL.md / code-review.md) — Fixed. The emptyDiff stop in the review skill now runs "${QWEN_CODE_CLI:-qwen}" review cleanup pr-<n> before stopping, mirroring the adjacent same-SHA stop — cleanup passes the guard (same session) and releases the lease, which the TUI's exit sweep would not do on a clean stop. The docs bullet now states that clean stops release the lease, so residue is confined to interrupted sessions.

Integration tests were not run: the touched behavior is exercised by the unit suites that drive the real command entry points (runCleanup, the fetch-pr handler, runRepoContext), not only through the bundled CLI or integration harness. No settings source changed, so npm run generate:settings-schema was not applicable.

Verification

Commands actually run, in order:

  • npx vitest run src/commands/review/cleanup.test.ts src/commands/review/fetch-pr.test.ts src/commands/review/repo-context.test.ts (packages/cli) against the PRE-fix code — 3 failed | 108 passed; the 3 failures are the new regression tests reproducing findings 1, 2, and 3 (RED evidence)
  • same command against the fixed code — 111 passed
  • npx vitest run src/commands/review src/services/review-worktree-lease.test.ts (packages/cli, full review area) — 72 files passed, 2554 passed | 4 skipped
  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --write on all touched files — compliant (one formatting pass applied to fetch-pr.test.ts before the final run)
  • Re-ran the four touched test files after the commit's lint-staged pass — 4 files passed, 126 passed
中文说明

审查轮次已处理 — PR #9211

全部 7 个发现(1 个 Critical、6 个 Suggestion)均已对照代码核实,并在提交 6f3a68e4f9 中以代码解决。未标记冲突,因此未执行任何合并。

发现与处理

  1. [Critical] 对名为 lease 的文件审查目标执行清理会删除所有生效的 PR 租约(cleanup.ts)— 已修复。 先复现:新增回归测试在 .qwen/tmp/ 中存在 qwen-review-lease-pr-123.json 时运行 runCleanup('lease'),在修复前代码上失败——清扫 rmSync 了该租约。safeTargetlease / ./lease 恰好压平为租约前缀,因此按目标清扫会匹配到所有 PR 租约,而仅限 PR 目标的租约守卫根本不会运行。修复:在清扫循环顶部跳过所有 LEASE_PREFIX 条目;租约删除仍由 clearReviewWorktreeLease 负责,它只对本运行的目标执行。补充了回归测试,以及证明清扫仍会删除非租约附属文件的正向对照测试。
  2. [Suggestion] 租约回滚只覆盖三个被包裹的 catch(fetch-pr.ts)— 已修复。 用单个 try/catch 包裹 runFetchPr 租约写入后的整个主体,在任何抛错时回滚租约,取代原先分散在三个 catch 中的 clearReviewWorktreeLease 调用。这覆盖了此前未被包裹的路径(rev-parsemkdirSync、最终报告写入——由新测试钉住:让报告写入以 ENOSPC 失败并断言租约被清除),也覆盖未来在此处新增的任何步骤。分支回滚保留在创建该引用的原处;改动后回滚点从三个收敛为一个。
  3. [Suggestion] 中间路径分量的 ENOTDIR 被映射为 "worktree 缺失" 的补救(repo-context.ts)— 已修复。 已复现:realpathSync 经过普通文件路径分量时抛出 ENOTDIR,修复前代码会将其转换为 "worktree 缺失" 提示。新调用点现在仅把 ENOENT 视为 "缺失";ENOTDIR(错误的 --worktree 参数,重跑 fetch-pr 也无法修复)原样重新抛出,保留精确的 errno 诊断。共享辅助函数 isAbsentError 保持不变——它对 ENOTDIR 的纳入对 identity 文件查找仍然正确。
  4. [Suggestion] 没有测试钉住该调用点的 ENOTDIR 分支(repo-context.ts)— 已修复。 新增以普通文件作为中间路径分量的测试;它在修复前代码上失败(得到 "is missing" 提示),现在钉住 ENOTDIR 的重新抛出行为。
  5. [Suggestion] 没有测试钉住租约写入先于 cleanStale/首个 git 调用(fetch-pr.test.ts)— 已修复。 新增 mock.invocationCallOrder 断言:createReviewWorktreeLease 先于 stale-clean 的 releaseWorktree 调用,也先于首个 git 调用,把并发交错窗口钉在微秒级,而非整个网络受限的 fetch 时长。
  6. [Suggestion] skip-note 测试没有钉住租约路径插值(cleanup.test.ts)— 已修复。 skip-note 测试现在同时断言提示中写明了租约文件名(qwen-review-lease-pr-123.json),与 fetch-pr 一侧已有的钉住保持一致。
  7. [Suggestion] emptyDiff 提前停止会遗留租约(SKILL.md / code-review.md)— 已修复。 审查技能中的 emptyDiff 停止现在会先运行 "${QWEN_CODE_CLI:-qwen}" review cleanup pr-<n> 再停止,与相邻的 same-SHA 停止保持一致——cleanup 能通过守卫(同一会话)并释放租约,而 TUI 的退出清扫在干净退出时不会做这件事。文档条目现在说明干净停止会释放租约,因此残留租约只可能来自被中断的会话。

未运行集成测试:本次触及的行为由直接驱动真实命令入口(runCleanupfetch-pr handler、runRepoContext)的单元测试套件覆盖,并非只能通过捆绑 CLI 或集成测试框架验证。未改动任何 settings 源文件,因此不适用 npm run generate:settings-schema

验证

实际执行的命令(按顺序):

  • npx vitest run src/commands/review/cleanup.test.ts src/commands/review/fetch-pr.test.ts src/commands/review/repo-context.test.ts(packages/cli),针对修复前代码——3 失败 | 108 通过;3 个失败正是复现发现 1、2、3 的新回归测试(RED 证据)
  • 同一命令针对修复后代码——111 通过
  • npx vitest run src/commands/review src/services/review-worktree-lease.test.ts(packages/cli,完整 review 区域)——72 个文件通过,2554 通过 | 4 跳过
  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 对所有改动文件执行 npx prettier --write — 合规(最终运行前对 fetch-pr.test.ts 做了一次格式化)
  • 提交的 lint-staged 钩子运行后重跑四个改动的测试文件——4 个文件通过,126 通过

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

中文说明

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

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

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


// Before the sweep below deletes the fetch report (the audit window's
// carrier), check the PR for writes that bypassed `qwen review submit`.
auditPrWrites(target, prNumber);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-10: TOCTOU between cleanup's lease check and its destructive steps — the lease is read once at the top of runCleanup (lines 401-402), but the destructive work (releaseWorktree ×3, git branch -D, the side-file sweep, and the tail clearReviewWorktreeLease) runs only after this auditPrWrites call, which spawns 2-3 network-bound gh processes (seconds-scale). A lease written during that gap — session B legitimately starting a review of the same PR after reading no lease — is never seen: cleanup releases B's live worktree at the fixed path, deletes B's branch ref, rmSyncs B's freshly written side files (same fixed names), and deletes B's lease — re-opening the exact #9205 destruction this PR exists to prevent, through the very guard added to prevent it. This is distinct from the disclosed fetch-pr acquisition window (seconds vs microseconds, cleanup side, and the check-then-audit-then-destroy sequence is new code in this diff), and the refusal message's own recovery instruction ("delete the lease file and re-run") manufactures the lease-less state this race starts from. — Failure scenario: cleanup runs for an unleased target (e.g. after the operator deleted a stuck lease per the refusal message) while a new review of the same PR starts during the audit window → the new review's worktree, branch, side files and lease are all destroyed mid-run.

Witness (probe — B's acquisition simulated inside the audit):

UNMODIFIED: lease reads: 1; releaseWorktree: [review-pr-123, -probe, -base];
  git branch -D qwen-review/pr-123; clearLease: [pr-123]   ← all of B's state destroyed
WITH FIX (re-read lease after auditPrWrites): lease reads: 2;
  all three destruction lists []                            ← B is skipped, intact

Suggested fix — re-validate the lease after the audit and take the same skip path if one appeared; also skip the tail clearReviewWorktreeLease when the run started with no lease:

Suggested change
auditPrWrites(target, prNumber);
auditPrWrites(target, prNumber);
// The audit is network-bound (seconds) — a lease can appear during it
// (a review that started after the gate above read none). Re-check
// before destroying anything (#9205).
const holderAfterAudit = readReviewWorktreeLease(process.cwd(), target);
if (reviewLeaseHeldByAnotherSession(holderAfterAudit)) {
writeStdoutLine(
`note: skipped cleanup for "${target}" — a review session ` +
`(session ${holderAfterAudit.sessionId}) acquired the lease ` +
`during the audit; its own cleanup releases it.`,
);
return;
}
中文说明

[Critical] R2-10:cleanup 的租约检查与破坏性步骤之间存在 TOCTOU——租约只在 runCleanup 顶部(第 401-402 行)读取一次,但破坏性操作(releaseWorktree ×3、git branch -D、附属文件清扫、尾部 clearReviewWorktreeLease)都在这个 auditPrWrites 调用之后执行,而后者会启动 2-3 次网络受限的 gh 进程(秒级)。在该窗口内写入的租约——会话 B 在读到"无租约"后正常启动同 PR 审查——不会被看到:cleanup 会释放 B 在固定路径上的活跃 worktree、删除 B 的分支引用、rmSync B 刚写入的附属文件(同名固定路径)、删除 B 的租约——正是本 PR 要防止的 #9205 破坏,反而经由新增的守卫重新打开。这与已披露的 fetch-pr 获取窗口不同(秒级 vs 微秒级、cleanup 侧,且"检查→审计→破坏"序列是本 diff 的新代码),拒绝消息自身的恢复指引("删除租约文件并重跑")恰好制造了该竞态的起始无租约状态。 — 失败场景:cleanup 对一个无租约目标运行(例如操作者按拒绝消息指引删除了卡住的租约),期间一个新审查在审计窗口内启动 → 新审查的 worktree、分支、附属文件与租约全部在运行中被销毁。

建议修复:在审计之后重新校验租约,若出现则走同样的跳过路径;若本次运行启动时无租约,也跳过尾部的 clearReviewWorktreeLease。见上方 suggestion。

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

// this prefix — unguarded, the sweep rmSyncs every live PR lease,
// including another session's. Lease removal belongs to
// `clearReviewWorktreeLease` below, which runs for this run's target.
if (file.startsWith(LEASE_PREFIX)) continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-1: The lease-skip guard keys on the bare LEASE_PREFIX, which is broader than the set of lease files that can exist — leases are always qwen-review-lease-pr-<n>.json (createReviewWorktreeLease is gated on validTarget /^pr-\d+$/). A file-review target whose safeTarget flattens to lease or lease-* (a repo-root file named lease, ./lease, lease-notes.md) gets tmpPrefix(target) equal to or subsumed by LEASE_PREFIX, so the sweep skips that target's OWN side files and nothing else ever removes them (clearReviewWorktreeLease no-ops on non-pr-\d+ targets) — permanent residue in .qwen/tmp on every repeat review of that target, plus a false Nothing to clean announcement. — Failure scenario: file review of a file named lease at repo root → its side files (qwen-review-lease-diff.txt, …) match the guard → never swept → permanent residue.

Witness (probe, runCleanup('lease') with own side files + a live pr-999 lease on disk):

PR code:    rmSync paths: [] — stdout "Nothing to clean for target \"lease\"."
fix below:  side files removed, live lease survives
Suggested change
if (file.startsWith(LEASE_PREFIX)) continue;
if (file.startsWith(LEASE_PREFIX) && /^pr-\d+\.json$/.test(file.slice(LEASE_PREFIX.length))) continue;
中文说明

[Suggestion] R2-1:租约跳过守卫只按裸 LEASE_PREFIX 匹配,比实际可能存在的租约文件集合更宽——租约永远是 qwen-review-lease-pr-<n>.jsoncreateReviewWorktreeLeasevalidTarget /^pr-\d+$/ 门禁)。文件审查目标若被 safeTarget 压平为 leaselease-*(仓库根目录下名为 lease./leaselease-notes.md 的文件),其 tmpPrefix(target) 会等于或被 LEASE_PREFIX 包含,于是清扫连该目标自己的附属文件也一并跳过,而没有其他路径会删除它们(clearReviewWorktreeLease 对非 pr-\d+ 目标是空操作)——每次重复审查都在 .qwen/tmp 永久残留,还会虚假地宣告 Nothing to clean。 — 失败场景:对名为 lease 的文件做文件审查 → 其附属文件命中守卫 → 永不清扫 → 永久残留。

建议修复:守卫收窄为真实租约形状(见上方 suggestion),并可为 lease-notes.md 风格目标补一个清扫测试。

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

'pr-42',
);
// Nothing was touched on the way out.
expect(vi.mocked(createReviewWorktreeLease)).not.toHaveBeenCalled();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-2: The refusal test asserts six side effects were NOT made but omits clearReviewWorktreeLease — the one call that would destroy the holder's lock. If a refactor moves the lease-read/refusal check inside the rollback try block (it sits immediately adjacent), the catch would delete the FOREIGN holder's lease while every existing assertion stays green, silently defeating the lock: a third session sees no lease, proceeds, and stale-cleans the holder's live worktree — the exact #9205 destruction. The refusal path is the one place where clearing the lease is catastrophic rather than corrective, and clearReviewWorktreeLease is already imported and mocked in this file (the rollback describe proves it is called on failure paths), so the regression is one mutation away and costs one line to pin. — Failure scenario: adjacent refactor moving the refusal inside the rollback try → refusal path clears the foreign lease → lock silently defeated, all tests green.

Witness (mutant probe):

refusal moved inside the rollback try → lease-lock tests 4/4 GREEN (foreign lease deleted)
+ the assertion below                 → mutant RED; correct code stays 44/44 green
Suggested change
expect(vi.mocked(createReviewWorktreeLease)).not.toHaveBeenCalled();
expect(vi.mocked(createReviewWorktreeLease)).not.toHaveBeenCalled();
expect(vi.mocked(clearReviewWorktreeLease)).not.toHaveBeenCalled();
中文说明

[Suggestion] R2-2:拒绝测试断言了六类副作用"未发生",却遗漏了 clearReviewWorktreeLease——唯一会摧毁持有者锁的调用。若某次重构把租约读取/拒绝检查移入回滚 try 块(两者紧邻),catch 会删除外部持有者的租约,而所有现有断言仍然全绿,锁被悄然瓦解:第三个会话看不到租约、继续执行、stale-clean 掉持有者的活跃 worktree——正是 #9205 的破坏。拒绝路径是唯一一个"清除租约具有破坏性而非纠正性"的地方,且 clearReviewWorktreeLease 在本文件中已导入并已 mock(回滚 describe 证明它在失败路径被调用),因此该回归只差一次突变,钉住它只需一行。 — 失败场景:把拒绝移入回滚 try 的相邻重构 → 拒绝路径清除外部租约 → 锁被悄然瓦解,测试全绿。

建议修复:在 not-called 断言块中加入上方 suggestion 的一行。

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

Comment on lines +491 to +493
} catch (err) {
clearReviewWorktreeLease(process.cwd(), leaseTarget);
throw err;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-5: The rollback clearReviewWorktreeLease is unguarded — it calls rmSync(path, { force: true }), and force suppresses only ENOENT; any other unlink failure (EROFS read-only fs, EACCES on a shared CI runner) throws, so throw err is never reached and the original failure cause is masked; the lease also persists, refusing every later review of the PR until deleted by hand — the exact wedge this rollback exists to prevent. The file already uses the tryRemove idiom for exactly this class of best-effort rollback (the branch -D rollbacks at lines 169-175). — Failure scenario: post-lease-write failure (e.g. git fetch network error) + un-removable lease file → the operator sees EACCES: … unlink …qwen-review-lease-pr-42.json instead of the real cause, and the lease wedges later reviews.

Witness (probe on the real service, chmod 0555 lease dir, non-root):

unguarded:       surfaced error: EACCES: permission denied, unlink …  (real cause lost)
                 lease still on disk: true                             (wedge)
with tryRemove:  surfaced error: REAL CAUSE: git fetch failed
Suggested change
} catch (err) {
clearReviewWorktreeLease(process.cwd(), leaseTarget);
throw err;
} catch (err) {
tryRemove(() => clearReviewWorktreeLease(process.cwd(), leaseTarget));
throw err;
中文说明

[Suggestion] R2-5:回滚处的 clearReviewWorktreeLease 没有防护——它调用 rmSync(path, { force: true }),而 force 只吞 ENOENT;任何其他 unlink 失败(EROFS 只读文件系统、共享 CI runner 上的 EACCES)都会抛错,于是 throw err 永远到不了,原始失败原因被掩盖;租约也会残留,拒绝该 PR 的每一次后续审查,直到手动删除——正是这个回滚本要防止的卡死。本文件已对同类尽力而为的回滚使用 tryRemove 习语(第 169-175 行的 branch -D 回滚)。 — 失败场景:写租约后失败(如 git fetch 网络错误)+ 租约文件无法删除 → 操作者看到 EACCES: … unlink … 而非真实原因,租约卡住后续审查。

建议修复:用 tryRemove 包裹(见上方 suggestion),使原始错误总能传播。

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

);
// The lock must consult THIS PR's lease: mockReturnValueOnce is
// argument-blind, so an unwired target leaves the race undetected.
expect(vi.mocked(readReviewWorktreeLease)).toHaveBeenCalledWith(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-7: This refusal test pins readReviewWorktreeLease argument wiring — the comment above explains that "mockReturnValueOnce is argument-blind, so an unwired target leaves the race undetected" — but leaves the adjacent decision call reviewLeaseHeldByAnotherSession(holder) completely wiring-unasserted: the same hazard, one call over. A refactor that drops or breaks the holder argument makes production return false for every lease (if (!lease) return false in the service), silently disabling the whole lock, while every test in both new describe blocks stays green: tests 1-2 hardcode the decision via argument-blind mockReturnValueOnce(true), test 3 hardcodes false, and the service's own unit tests test the function in isolation and cannot see fetch-pr's wiring. The cleanup-side equivalent IS pinned (identity-checking mock implementation), confirming the asymmetry is real and one call over. — Failure scenario: broken holder threading (e.g. reviewLeaseHeldByAnotherSession(null)) → lock silently disabled → every concurrent same-PR fetch-pr proceeds and stale-cleans the holder (#9205 again), all tests green.

Witness (mutant probe):

reviewLeaseHeldByAnotherSession(null) injected → 44/44 GREEN (lock disabled)
+ the assertion below                          → 1 failed | 43 passed
correct wiring + assertion                     → 44/44 green

Add next to the read-wiring assertion (after line 376):

expect(vi.mocked(reviewLeaseHeldByAnotherSession)).toHaveBeenCalledWith(foreignLease);
中文说明

[Suggestion] R2-7:该拒绝测试钉住了 readReviewWorktreeLease 的参数接线——上方注释解释"mockReturnValueOnce 对参数不敏感,未接线的目标会让竞态逃过检测"——却完全没有断言相邻判定调用 reviewLeaseHeldByAnotherSession(holder) 的接线:同样的隐患,只差一个调用。若重构丢掉或接错 holder 参数,生产代码对任何租约都返回 false(服务中 if (!lease) return false),整个锁被悄然禁用,而两个新 describe 块的全部测试仍然全绿:测试 1-2 用对参数不敏感的 mockReturnValueOnce(true) 硬编码判定,测试 3 硬编码 false,服务自身的单元测试只孤立测试该函数、看不到 fetch-pr 的接线。cleanup 一侧的等价物已钉住(identity-checking 的 mock 实现),证实这种不对称真实存在且只差一个调用。 — 失败场景:holder 接线被破坏(如 reviewLeaseHeldByAnotherSession(null))→ 锁被悄然禁用 → 所有并发同 PR fetch-pr 都会继续执行并 stale-clean 掉持有者(#9205 重演),测试全绿。

建议修复:在读接线断言旁(第 376 行之后)加入上方代码块的一行断言。

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


await reportFor({});

expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledTimes(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-8: No test pins the success-mode invariant that IS the lock — the four rollback tests assert clearReviewWorktreeLease WAS called on throw, but nothing asserts it is NOT called after a successful fetch. Lease persistence on success rests entirely on the clear living inside the catch, not a finally. A natural cleanup refactor of catch (err) { clear; throw err; } into finally { clear; } would delete the lease immediately after a successful fetch — the next session reads no lease, passes the new refusal check, and its cleanStale deletes the live worktree (#9205 again) — while all four rollback tests, the ordering test, and this re-fetch test stay green. Distinct from R2-2 (refusal path / foreign lease): this guards the success path / the run's own lease. — Failure scenario: catch→finally refactor → lease deleted right after success → lock void, every test green.

Witness (mutant probe):

catch → finally refactor → 44/44 GREEN (lease deleted on success, lock voided)
+ the assertion below    → finally variant RED; correct catch stays green
Suggested change
expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledTimes(1);
expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledTimes(1);
expect(vi.mocked(clearReviewWorktreeLease)).not.toHaveBeenCalled();
中文说明

[Suggestion] R2-8:没有测试钉住"锁之所以为锁"的成功态不变量——四个回滚测试断言了抛错时 clearReviewWorktreeLease 被调用,但没有任何测试断言成功 fetch 之后它不被调用。成功后租约的留存完全依赖于 clear 位于 catch 而非 finally。一次顺手的清理重构把 catch (err) { clear; throw err; } 改成 finally { clear; },就会在成功 fetch 后立即删除租约——下一个会话读不到租约、通过新的拒绝检查、其 cleanStale 删除活跃的 worktree(#9205 重演)——而四个回滚测试、顺序测试与本重取测试全部保持绿色。与 R2-2(拒绝路径/外部租约)不同:这里守护的是成功路径/本运行自己的租约。 — 失败场景:catch→finally 重构 → 成功后租约立删 → 锁失效,测试全绿。

建议修复:在本测试中加入上方 suggestion 的第二行断言。

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

Comment on lines +405 to +407
await reportFor({});

expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledTimes(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-11: No test asserts the ARGUMENTS of createReviewWorktreeLease — the suite pins only call count (this line) and invocation order (the next test) on an argument-blind vi.fn(), while the real service silently no-ops on malformed input (its validTarget guard returns without writing — no throw, no warning). The file applies its own stated argument-wiring discipline to readReviewWorktreeLease (asserted (process.cwd(), 'pr-42')) and to clearReviewWorktreeLease in all four rollback tests, but not to the one call whose unwiring silently voids the lock. A refactor passing target: prNumber ('42') instead of leaseTarget ('pr-42') hits the silent no-op: no lease on disk, a concurrent session passes the check, and its cleanStale destroys the holder mid-review (#9205) — all 44 tests green. The same blind spot hides a sessionId/promptId env swap. — Failure scenario: create-call wiring regression → silent no-write → lock void, tests green.

Witness (mutant probe):

target: String(prNumber)   → 44/44 GREEN (silent no-write, lock void)
+ the assertion below      → 1 failed | 43 passed  (- "target": "pr-42", + "target": "42")
correct code + assertion   → 44/44 green

Add in this test:

expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledWith(
  expect.objectContaining({
    target: 'pr-42',
    repositoryRoot: process.cwd(),
    worktreePath: '.qwen/tmp/review-pr-42',
    branch: 'qwen-review/pr-42',
  }),
);
中文说明

[Suggestion] R2-11:没有测试断言 createReviewWorktreeLease 的参数——套件只用对参数不敏感的 vi.fn() 钉住调用次数(本行)与调用顺序(下一个测试),而真实服务对非法输入会静默空操作(其 validTarget 门禁直接 return 不写——不抛错、不告警)。本文件把自己声明的参数接线纪律用在了 readReviewWorktreeLease(断言 (process.cwd(), 'pr-42'))和四个回滚测试中的 clearReviewWorktreeLease 上,唯独没有用在"接线错误会悄然废掉锁"的这个调用上。若重构把 target: prNumber'42')当成 leaseTarget'pr-42')传入,就会命中静默空操作:盘上没有租约,并发会话通过检查,其 cleanStale 在审查中途摧毁持有者(#9205)——全部 44 个测试保持绿色。同样的盲区还会隐藏 sessionId/promptId 环境变量互换。 — 失败场景:create 调用接线回归 → 静默不写 → 锁失效,测试全绿。

建议修复:在本测试中加入上方代码块的参数断言。

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

…ease-lock

# Conflicts:
#	packages/cli/src/commands/review/fetch-pr.ts
…OU (QwenLM#9211)

- Re-read the lease after the network-bound bypass audit and before any destructive step, so a session that acquires the lease during the audit is skipped, not destroyed (R2-10).

- Narrow the cleanup lease-skip guard to the real lease shape so a target named 'lease' still has its own side files swept (R2-1).

- Make the fetch-pr lease rollback best-effort via tryRemove so an un-removable lease file cannot mask the original failure (R2-5).

- Pin the lease-lock wiring and success/rollback invariants in tests (R2-2, R2-7, R2-8, R2-11).
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review-feedback round — PR #9211

Addressed all 7 actionable findings from the automated review (1 Critical, 6 Suggestions). Also resolved the base-conflict by merging origin/main.

Base-conflict resolution

Merged origin/main. One conflict, in packages/cli/src/commands/review/fetch-pr.ts: upstream #9096 edited the report's host line while this PR wraps the fetch flow in a lease-rollback try block (re-indenting the same region). Resolved by keeping this PR's try-wrapper structure and applying upstream's trimmed-host change (host: args.host?.trim() || null with its rationale comment) inside it. Verified the merged file differs from this branch only by that upstream host change.

Findings

  • [Critical] R2-10 — cleanup TOCTOU between the lease gate and the destructive steps (implemented). runCleanup read the lease once at the top, then ran the network-bound bypass audit (seconds-scale, spawns gh) before destroying the worktree, branch, side files and lease. A session that legitimately acquires the lease during that window was never seen and got destroyed — re-opening /review: concurrent same-PR reviews race on the fixed worktree path (worktree deleted mid-run) #9205 through the very guard added to prevent it. Fix: re-read the lease immediately after auditPrWrites and, if another session now holds it, emit a note and return before any destructive step. Added a regression test that fails on the pre-fix code (single read) and passes with the re-check.
  • [Suggestion] R2-1 — lease-skip guard keyed on the bare prefix (implemented). The sweep skipped anything starting with LEASE_PREFIX, which is broader than any real lease (…-pr-<n>.json). A file-review target named lease flattens to exactly that prefix, so its OWN side files were skipped and never removed (permanent residue). Narrowed the guard to the real lease shape and added a test that a lease-named target's side files are swept while a live foreign lease survives.
  • [Suggestion] R2-5 — unguarded lease rollback masking the original error (implemented). The fetch-pr rollback called clearReviewWorktreeLease bare; an un-removable lease file (EACCES/EROFS) would throw there, masking the real failure cause. Wrapped it in the file's existing tryRemove idiom and added a test proving the original cause still propagates when the rollback itself throws (fails pre-fix, passes post-fix).
  • [Suggestion] R2-2 — refusal test omitted the clearReviewWorktreeLease not-called assertion (implemented). Added it; a refactor moving the refusal inside the rollback try would otherwise clear a foreign lease with every existing assertion still green.
  • [Suggestion] R2-7 — refusal test left the reviewLeaseHeldByAnotherSession(holder) wiring unasserted (implemented). Added a toHaveBeenCalledWith(foreignLease) assertion next to the existing read-wiring assertion.
  • [Suggestion] R2-8 — success path did not pin that the lease is NOT cleared (implemented). Added expect(clearReviewWorktreeLease).not.toHaveBeenCalled() to the successful re-fetch test, so a catchfinally refactor cannot silently delete the lease on success.
  • [Suggestion] R2-11 — no assertion on the ARGUMENTS of createReviewWorktreeLease (implemented). Added an objectContaining assertion pinning target, repositoryRoot, worktreePath, and branch, since the service silently no-ops on a malformed target.

No finding was declined or deferred.

Verification

Commands actually run this round and their results:

  • npm run build — passed. (Required after the merge: acp-bridge's dist predated the merge and was missing mcpTimeouts/channelControlTimeouts/daemonEventTypes, which the merged sdk/webui sources import; rebuilding regenerates them.)
  • npm run typecheck — passed, 0 errors. (An initial run failed only on those stale acp-bridge subpath modules; the failure was build-state, not source, and cleared after npm run build.)
  • npm run lint — passed, no problems in touched files.
  • npx prettier --check on the four touched files — passed.
  • npx vitest run src/commands/review/cleanup.test.ts src/commands/review/fetch-pr.test.ts src/services/review-worktree-lease.test.ts — 93 passed (33 + 45 + 15).
  • npx vitest run src/commands/review/ src/services/review-worktree-lease.test.ts src/acp-integration/session/Session.review-lease.test.ts — 2697 passed, 4 skipped, 0 failed.

Reproduction evidence (per the reproduce-first rule): the new R2-10 cleanup test and the new R2-5 rollback test were each confirmed to FAIL against the pre-fix code and PASS after the fix.

中文说明

Autofix 审查反馈轮 — PR #9211

已处理自动审查给出的全部 7 条可操作发现(1 条 Critical、6 条 Suggestion)。同时通过合并 origin/main 解决了与基线的冲突。

基线冲突解决

合并了 origin/main。唯一冲突位于 packages/cli/src/commands/review/fetch-pr.ts:上游 #9096 修改了报告中的 host 一行,而本 PR 把 fetch 流程包进了租约回滚 try 块(对同一区域重新缩进)。解决方式:保留本 PR 的 try 包裹结构,并在其中套用上游的 trimmed-host 改动(host: args.host?.trim() || null 及其理由注释)。已验证合并后的文件相对本分支只多出这一处上游 host 改动。

各项发现

  • [Critical] R2-10 — cleanup 在租约门禁与破坏性步骤之间的 TOCTOU(已实现)。 runCleanup 只在顶部读取一次租约,随后执行依赖网络的 bypass 审计(秒级、会启动 gh),再销毁 worktree、分支、附属文件与租约。在该窗口内正常获取租约的会话不会被看到,从而被销毁——恰恰经由为防止 /review: concurrent same-PR reviews race on the fixed worktree path (worktree deleted mid-run) #9205 而新增的守卫重新打开了 /review: concurrent same-PR reviews race on the fixed worktree path (worktree deleted mid-run) #9205。修复:在 auditPrWrites 之后立即重读租约,若此时已由另一会话持有,则输出提示并在任何破坏性步骤之前返回。新增了一个回归测试:在修复前(单次读取)失败、加入重查后通过。
  • [Suggestion] R2-1 — 租约跳过守卫只按裸前缀匹配(已实现)。 清扫会跳过所有以 LEASE_PREFIX 开头的文件,这比任何真实租约(…-pr-<n>.json)都宽。名为 lease 的文件审查目标恰好被压平为该前缀,导致它自己的附属文件也被跳过且永不被删除(永久残留)。已把守卫收窄为真实租约形状,并新增测试:lease 命名目标的附属文件被清扫、同时一个存活的外部租约得以保留。
  • [Suggestion] R2-5 — 无防护的租约回滚掩盖原始错误(已实现)。 fetch-pr 的回滚直接裸调 clearReviewWorktreeLease;一个无法删除的租约文件(EACCES/EROFS)会在那里抛错,从而掩盖真实失败原因。已用本文件既有的 tryRemove 习语包裹,并新增测试证明当回滚自身抛错时原始原因仍能传播(修复前失败、修复后通过)。
  • [Suggestion] R2-2 — 拒绝测试遗漏了 clearReviewWorktreeLease 未被调用的断言(已实现)。 已补充;否则把拒绝逻辑移入回滚 try 的重构会在所有现有断言仍为绿的情况下清除外部租约。
  • [Suggestion] R2-7 — 拒绝测试未断言 reviewLeaseHeldByAnotherSession(holder) 的接线(已实现)。 在已有的读接线断言旁补充了 toHaveBeenCalledWith(foreignLease) 断言。
  • [Suggestion] R2-8 — 成功路径未钉住"租约不被清除"(已实现)。 在成功的重取测试中补充 expect(clearReviewWorktreeLease).not.toHaveBeenCalled(),使 catchfinally 的重构无法在成功后悄然删除租约。
  • [Suggestion] R2-11 — 没有对 createReviewWorktreeLease 的参数做断言(已实现)。 补充 objectContaining 断言钉住 targetrepositoryRootworktreePathbranch,因为服务对非法 target 会静默空操作。

没有发现被拒绝或推迟。

验证

本轮实际执行的命令及结果:

  • npm run build — 通过。(合并后必须执行:acp-bridge 的 dist 早于合并、缺少 mcpTimeouts/channelControlTimeouts/daemonEventTypes,而合并进来的 sdk/webui 源码会导入它们;重新构建即可重新生成。)
  • npm run typecheck — 通过,0 个错误。(首次运行仅在这些过期的 acp-bridge 子路径模块上失败;该失败属于构建状态问题而非源码问题,npm run build 后即消除。)
  • npm run lint — 通过,改动文件无问题。
  • 对四个改动文件执行 npx prettier --check — 通过。
  • npx vitest run src/commands/review/cleanup.test.ts src/commands/review/fetch-pr.test.ts src/services/review-worktree-lease.test.ts — 93 通过(33 + 45 + 15)。
  • npx vitest run src/commands/review/ src/services/review-worktree-lease.test.ts src/acp-integration/session/Session.review-lease.test.ts — 2697 通过、4 跳过、0 失败。

复现证据(按 reproduce-first 原则):新增的 R2-10 cleanup 测试与 R2-5 回滚测试均已确认在修复前失败、修复后通过。

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

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

中文说明

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

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

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

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

Comment on lines +207 to +208
const leaseTarget = `pr-${prNumber}`;
const holder = readReviewWorktreeLease(process.cwd(), leaseTarget);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The lock silently disengages for any pr_number outside pr-\d+: leaseTarget fails validTarget, so the gate reads null and createReviewWorktreeLease no-ops — yet cleanStale still destroys worktreePath(prNumber), and path.join normalizes inputs like '5/.' onto another review's locked worktree path. The lock's input domain (validated pr-\d+) is narrower than the destroyer's (raw prNumber); the diff's own comment says "Refuse before touching anything". Every sibling command (fetch-diff, meta, issue-context) validates pr_number as a positive integer — fetch-pr is the outlier, and the command this PR adds the lock to. — Failure scenario: session A reviews PR 5 (lease on disk, worktree .qwen/tmp/review-pr-5); session B runs qwen review fetch-pr '5/.' owner/repo --out r.jsonleaseTarget 'pr-5/.' fails validTarget → no refusal, no lease → cleanStale('5/.') force-removes session A's live worktree mid-review (path.join normalizes review-pr-5/. onto review-pr-5) — exactly incident #9205 with the new lock never engaged.

Witness (two-arm probe with the real lease service; only git fetch/gh mocked):

UNMODIFIED: fetch-pr '5/.' against a live holder →
  holder worktree exists = false | session-A lease exists = true   ← destroyed
WITH FIX (numeric validation): throws "pr_number must be a positive integer, got '5/.'" →
  holder worktree exists = true  | session-A lease exists = true   ← refused, intact
Suggested change
const leaseTarget = `pr-${prNumber}`;
const holder = readReviewWorktreeLease(process.cwd(), leaseTarget);
if (!/^\d+$/.test(prNumber)) {
throw new Error(
`fetch-pr: pr_number must be a positive integer, got "${prNumber}"`,
);
}
const leaseTarget = `pr-${prNumber}`;
const holder = readReviewWorktreeLease(process.cwd(), leaseTarget);
中文说明

对任何不在 pr-\d+ 范围内的 pr_number,锁会静默失效:leaseTarget 无法通过 validTarget,门禁因此读到 nullcreateReviewWorktreeLease 空操作——但 cleanStale 仍会销毁 worktreePath(prNumber),而 path.join 会把 '5/.' 这类输入归一化到另一个审查锁定的 worktree 路径上。锁的输入域(已校验的 pr-\d+)窄于破坏者的输入域(原始 prNumber);diff 自身的注释写着“在触碰任何东西之前先拒绝”。所有兄弟命令(fetch-diffmetaissue-context)都校验 pr_number 为正整数——唯独 fetch-pr 例外,而它恰是本 PR 加锁的命令。 — 失败场景:会话 A 正在审查 PR 5(租约在盘,worktree 为 .qwen/tmp/review-pr-5);会话 B 运行 qwen review fetch-pr '5/.' owner/repo --out r.jsonleaseTarget 'pr-5/.' 未通过 validTarget → 不拒绝、不写租约 → cleanStale('5/.') 在审查中途强制移除会话 A 的活跃 worktree(path.joinreview-pr-5/. 归一化为 review-pr-5)——新锁从未生效的情况下重演 #9205 事故。

证据(使用真实租约服务的双分支探针;仅 mock git fetch/gh):未修复分支上,对持有者运行 fetch-pr '5/.' 后 worktree 被删除(exists = false)而租约仍在;加上数字校验后以 “pr_number must be a positive integer” 拒绝,worktree 与租约均完好。

建议修复:在门禁之前校验参数,使锁的输入域与破坏者对齐(镜像兄弟命令的写法),见上方 suggestion。

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

Comment on lines +509 to +512
if (
file.startsWith(LEASE_PREFIX) &&
/^pr-\d+\.json$/.test(file.slice(LEASE_PREFIX.length))
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The sweep guard re-derives the lease target grammar locally, so "what counts as a lease file" now has three definitions that can drift: this regex, the writer's validTarget in review-worktree-lease.ts, and that same service's broader LEASE_PREFIX* + .json scan in cleanupReviewWorktreeLeases. paths.ts's LEASE_PREFIX comment itself says "the sweep pattern and the lease writer must share one definition". — Concrete cost: nothing misbehaves today, but if validTarget ever admits a second lease target shape, this guard silently stops protecting those leases and a file-review cleanup whose target flattens to lease deletes another session's live lease — reproducing the round-1 blocker this PR's own tests build.

// Suggested: export one shared predicate (e.g. in review-worktree-lease.ts
// or lib/paths.ts) and use it here and in cleanupReviewWorktreeLeases:
export function isReviewLeaseFile(fileName: string): boolean {
  return (
    fileName.startsWith(LEASE_PREFIX) &&
    /^pr-\d+\.json$/.test(fileName.slice(LEASE_PREFIX.length))
  );
}
中文说明

清扫守卫在本地重新推导了租约目标的语法,于是“什么算租约文件”现在有三份可能漂移的定义:这个正则、写入方 review-worktree-lease.ts 里的 validTarget,以及同一服务中 cleanupReviewWorktreeLeases 里更宽的 LEASE_PREFIX* + .json 扫描。paths.tsLEASE_PREFIX 的注释本身就写着“清扫模式与租约写入方必须共享同一定义”。 — 具体代价:今天三者一致、无异常;但若 validTarget 将来允许第二种租约目标形状,该守卫会悄悄不再保护那些租约,名为 lease 的文件审查目标执行清理时会删除另一个会话的生效租约——复现本 PR 测试所构造的第一轮阻塞问题。

建议修复:导出一个共享谓词(如 isReviewLeaseFile),在此处与 cleanupReviewWorktreeLeases 中统一使用,见上方代码。

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


runCleanup('pr-123');

expect(mocks.readReviewWorktreeLease).toHaveBeenCalledTimes(2);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This re-check test proves a second lease read exists but not that it happens after the audit — in the mocked environment readFileSync throws ENOENT, so auditPrWrites no-ops via a stderr note and never calls ghApiAll, and the test asserts nothing about that note's order relative to the second read. — Failure scenario: a future edit hoisting the re-check block above auditPrWrites(...) keeps every assertion here green (probe-verified: under exactly that mutation the test stayed green) while in production the seconds-long network-bound audit again runs after the last lease check, and a session that acquires the lease during it is destroyed — the exact #9205 interleave this test was written to catch.

Suggested change
expect(mocks.readReviewWorktreeLease).toHaveBeenCalledTimes(2);
expect(mocks.readReviewWorktreeLease).toHaveBeenCalledTimes(2);
const auditNoteIndex = mocks.writeStderrLine.mock.calls.findIndex((c) =>
String(c[0]).includes('bypass audit skipped'),
);
expect(auditNoteIndex).toBeGreaterThanOrEqual(0);
expect(
mocks.readReviewWorktreeLease.mock.invocationCallOrder[1]!,
).toBeGreaterThan(
mocks.writeStderrLine.mock.invocationCallOrder[auditNoteIndex]!,
);
中文说明

这个重查测试只证明了存在第二次租约读取,没有证明它发生在审计之后——在 mock 环境中 readFileSync 抛 ENOENT,auditPrWrites 经由一条 stderr 提示空操作返回、从不调用 ghApiAll,测试对该提示与第二次读取的顺序没有任何断言。 — 失败场景:未来把重查块提升到 auditPrWrites(...) 之上的改动,能让这里所有断言保持绿色(已用探针验证:该变异下测试仍然通过);而生产中秒级、依赖网络的审计又会跑在最后一次租约检查之后,在审计期间获取租约的会话将被销毁——正是本测试要拦截的 #9205 交错。建议修复:把审计钉在两次读取之间(对 'bypass audit skipped' 提示用 invocationCallOrder 断言),见上方 suggestion。

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

// not be swept. Skip only the real lease shape (…-pr-<n>.json), not the
// bare prefix: a file-review target named "lease" flattens to this same
// prefix, and its OWN side files still need removal — nothing else removes
// them. Lease removal itself belongs to clearReviewWorktreeLease below.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The sweep guard delegates all lease removal to the trailing clearReviewWorktreeLease below, but that call is gated on if (!failedAny) — and failedAny is also set by a per-target side-file rmSync failure — so a cleanup that fully freed the worktree, probe/base siblings and branch still leaves the lock behind when one side file cannot be deleted (probe-verified). The gate is pre-existing, but this PR's new gates are what convert that residue into a cross-session block — pre-PR a leftover lease was inert. — Failure scenario: runCleanup('pr-123') frees worktree+branch, then the sweep hits one un-deletable side file (a read-only entry under …-agent-prompts/ — EACCES; on Windows an indexer/antivirus handle — EBUSY/EPERM; rmSync force: true suppresses only ENOENT) → failedAny = true → the trailing lease clear is skipped. Every later cross-session fetch-pr pr-123 refuses "already being reviewed by another session" (a false premise — nothing is reviewing), every later cleanup skips at the gate, and the prompt-end sweeper only matches its own sessionId+promptId — no automatic recovery, only manual lease deletion.

Suggested fix — decouple lease release from side-file residue: clear the lease once the worktree+branch steps succeeded (e.g. gate the tail clear on destruction-phase failures only, not side-file failedAny). Note the existing test keeps the lease when branch deletion fails pins the gate for destruction failures, so a fix must distinguish the two.

中文说明

清扫守卫把租约的删除全部委托给下方的尾部 clearReviewWorktreeLease,但该调用被 if (!failedAny) 门禁控制——而 failedAny 也会因某个按目标的附属文件 rmSync 失败而置位——于是当某个附属文件删不掉时,即便清理已经完整释放了 worktree、probe/base 兄弟树与分支,锁仍会被留下(已用探针验证)。该门禁是既有代码,但本 PR 新增的门禁把这种残留转化成了跨会话阻塞——PR 之前残留的租约是无害的。 — 失败场景:runCleanup('pr-123') 释放了 worktree 与分支,清扫时遇到一个无法删除的附属文件(…-agent-prompts/ 下的只读条目——EACCES;Windows 上索引/杀毒句柄占用——EBUSY/EPERM;rmSyncforce: true 只压制 ENOENT)→ failedAny = true → 尾部清租约被跳过。此后每次跨会话 fetch-pr pr-123 都以“已有另一会话在审查”拒绝(前提为假——并没有审查在跑),每次清理都在门禁处跳过,prompt 结束清扫器只匹配自己的 sessionId+promptId——没有自动恢复路径,只能手动删除租约。

建议修复——把租约释放与附属文件残留解耦:worktree+branch 步骤成功后即清除租约(例如尾部门禁只看破坏阶段的失败,不看附属文件的 failedAny)。注意现有测试 keeps the lease when branch deletion fails 钉住了破坏失败时的门禁,修复需区分这两类失败。

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

Comment on lines +418 to +419
expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledWith(
expect.objectContaining({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This same-session test's comment claims to pin the lease's ARGUMENTS so the service's silent no-op is caught, but the objectContaining assertion omits sessionId and promptId — two of the three no-op triggers in createReviewWorktreeLease's guard. The suite never sets QWEN_CODE_SESSION_ID/QWEN_CODE_PROMPT_ID, so fetch-pr passes undefined for both in this very test — the precise inputs that make the real service write nothing — and the field-blind assertion stays green. — Failure scenario (probe-verified): with the env vars unset, editing fetch-pr to pass sessionId: undefined left all 45 tests green while the lock is voided. A regression that drops, renames, or mis-forwards sessionId/promptId (including mishandling an empty QWEN_CODE_PROMPT_ID) voids the lock in production while this test — written to catch exactly that — stays green.

// Set both env vars around runCleanup (restore after), then extend the assertion:
process.env['QWEN_CODE_SESSION_ID'] = 'session-self';
process.env['QWEN_CODE_PROMPT_ID'] = 'prompt-now';
// ...
expect(vi.mocked(createReviewWorktreeLease)).toHaveBeenCalledWith(
  expect.objectContaining({
    sessionId: 'session-self',
    promptId: 'prompt-now',
    target: 'pr-42',
    // ...the existing fields
  }),
);
中文说明

这个同会话测试的注释声称钉住了租约的参数以捕获服务端的静默空操作,但 objectContaining 断言遗漏了 sessionIdpromptId——createReviewWorktreeLease 守卫三个空操作触发条件中的两个。该测试文件从未设置 QWEN_CODE_SESSION_ID/QWEN_CODE_PROMPT_ID,因此本测试中 fetch-pr 传给服务的两者都是 undefined——恰是让真实服务什么都不写的输入——而对字段视而不见的断言依然通过。 — 失败场景(已用探针验证):在未设置环境变量时把 fetch-pr 改为传 sessionId: undefined,全部 45 个测试仍绿,而锁已失效。任何丢弃、重命名或错误转发 sessionId/promptId 的回归(包括误处理空的 QWEN_CODE_PROMPT_ID)都会在生产中使锁失效,而这个为此而写的测试依然绿色。建议修复见上方代码:设置环境变量并扩展断言。

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


runCleanup('pr-123');

expect(mocks.readReviewWorktreeLease).toHaveBeenCalledTimes(2);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This re-check test never pins the ARGUMENTS of either lease read; mockReturnValueOnce is argument-blind, so a re-check that reads a malformed target stays green while silently failing open in production. The first skip test in this same file documents this exact hazard in a comment and defends with toHaveBeenCalledWith(process.cwd(), 'pr-123'). — Failure scenario (probe-verified mutation): change the re-check to readReviewWorktreeLease(process.cwd(), prNumber) (bare '123', in scope) — the mock still hands back the lease on the second call and every assertion passes; in production validTarget('123') is false → null → not held → cleanup destroys the state of the session that acquired during the audit and its trailing clear deletes that lease — re-opening the #9205 race the re-check was added to close. Distinct axis from the ordering comment on this same line.

Suggested change
expect(mocks.readReviewWorktreeLease).toHaveBeenCalledTimes(2);
expect(mocks.readReviewWorktreeLease).toHaveBeenNthCalledWith(
1,
process.cwd(),
'pr-123',
);
expect(mocks.readReviewWorktreeLease).toHaveBeenNthCalledWith(
2,
process.cwd(),
'pr-123',
);
expect(mocks.readReviewWorktreeLease).toHaveBeenCalledTimes(2);
中文说明

这个重查测试从未钉住两次租约读取的参数;mockReturnValueOnce 对参数视而不见,因此一个读取了畸形目标的重查在测试里依然绿色,在生产中却会静默失败放行。同文件的第一个跳过测试已在注释里记录了这一隐患并用 toHaveBeenCalledWith(process.cwd(), 'pr-123') 防御。 — 失败场景(已用探针验证的变异):把重查改为 readReviewWorktreeLease(process.cwd(), prNumber)(裸 '123',在作用域内)——mock 第二次调用仍返回租约、所有断言通过;生产中 validTarget('123') 为 false → null → 未持有 → 清理销毁在审计期间获取租约的会话的状态,其尾部清理还会删掉该租约——重新打开了重查本要关闭的 #9205 竞态。与本行的顺序问题评论是不同的轴向。建议修复见上方 suggestion:用 toHaveBeenNthCalledWith 钉住两次读取的参数。

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

Comment on lines +441 to +443
expect(leaseOrder).toBeLessThan(
producerMocks.releaseWorktree.mock.invocationCallOrder[0]!,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This ordering test pins only one of cleanStale's two destructive legs against the lease write — the branch-deletion leg (execFileSync('git', ['branch', '-D', ref]) guarded by refExists) is never ordered against the lease write, and refExists is mocked false suite-wide, so that leg never executes in any test in this file. — Failure scenario (probe-verified): moving the branch leg before createReviewWorktreeLease left all 45 tests green. A future reorder landing the branch deletion before the lease write (e.g. splitting cleanStale's two legs while refactoring the lock) re-opens the #9205 window for the branch — a concurrent other-session review deletes the holder's qwen-review/pr-<n> branch before the lease is visible — with every test green.

// Hoist refExists into producerMocks (like releaseWorktree), set it true in
// this test, and extend the pin:
expect(leaseOrder).toBeLessThan(
  producerMocks.execFileSync.mock.invocationCallOrder[0]!,
);
中文说明

这个顺序测试只把 cleanStale 两条破坏腿中的一条与租约写入钉了顺序——分支删除腿(refExists 守卫下的 execFileSync('git', ['branch', '-D', ref]))从未与租约写入比较顺序,且 refExists 在整个文件里被 mock 为 false,因此该腿在本文件的任何测试中都不会执行。 — 失败场景(已用探针验证):把分支腿移到 createReviewWorktreeLease 之前,全部 45 个测试仍绿。未来若把分支删除排到租约写入之前(例如重构锁时拆分 cleanStale 的两条腿),就会为分支重新打开 #9205 窗口——并发的其他会话审查在租约可见之前删除持有者的 qwen-review/pr-<n> 分支——而所有测试保持绿色。建议修复见上方代码:把 refExists 提升进 producerMocks、在本测试置 true 并补充顺序断言。

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

Comment on lines +477 to +479
expect(producerMocks.execFileSync).toHaveBeenCalledWith(
'git',
['branch', '-D', 'qwen-review/pr-42'],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This rollback test pins that both the branch deletion and the lease clear happen on the metadata-failure path, but nothing pins their ORDER — production's safe sequence (destructive branch rollback first, lease released last) is untested. This is the teardown mirror of the acquisition window this PR's own ordering test calls "the lock's window". — Failure scenario (probe-verified): inserting a lease clear before the branch -D in the gh-failure catch left all 45 tests green. A refactor releasing the lease before the branch rollback lets session B pass the now-empty gate and re-fetch pull/42/head:qwen-review/pr-42 while session A's pending branch -D deletes B's freshly fetched branch. The real-world window is narrow (B must act between two consecutive synchronous statements of A's process), which keeps this at Suggestion — the pin is worth the two lines.

// Add to this test — compare against the FIRST lease clear (the last-clear
// variant does not detect the mutation: the outer catch's second clear still
// fires after branch -D):
expect(
  producerMocks.execFileSync.mock.invocationCallOrder[0]!,
).toBeLessThan(
  vi.mocked(clearReviewWorktreeLease).mock.invocationCallOrder[0]!,
);
中文说明

这个回滚测试钉住了元数据失败路径上分支删除与租约清除都会发生,但没有钉住它们的顺序——生产中的安全序列(先做破坏性的分支回滚、最后释放租约)未被测试。这是本 PR 自己的顺序测试所称“锁的窗口”在拆除侧的镜像。 — 失败场景(已用探针验证):在 gh 失败的 catch 中把租约清除插到 branch -D 之前,全部 45 个测试仍绿。若重构把租约释放排到分支回滚之前,会话 B 就能通过已清空的门禁并重新 fetch pull/42/head:qwen-review/pr-42,而会话 A 悬而未决的 branch -D 会删掉 B 刚取回的分支。现实窗口很窄(B 必须在 A 进程两条相邻同步语句之间完成动作),因此维持 Suggestion——但这两行断言值得加。注意要与第一次租约清除比较顺序(与最后一次比较无法检出该变异:外层 catch 的第二次清除仍在 branch -D 之后发生)。

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

@wenshao

wenshao commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

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

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the PR has merge conflicts, so refs/pull/9211/merge is unavailable — resolve conflicts and re-run.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the PR has merge conflicts, so refs/pull/9211/merge is unavailable — resolve conflicts and re-run。

Qwen Code · sandboxed verification

@wenshao

wenshao commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /resolve

…ease-lock

# Conflicts:
#	packages/cli/src/commands/review/fetch-pr.test.ts
#	packages/cli/src/commands/review/fetch-pr.ts
#	packages/core/src/skills/bundled/review/SKILL.md
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — PR #9211 (conflict-resolution round)

No actionable feedback this round. Reviews, inline comments, issue-level
comments, failed checks, and still-red checks were all empty; Critical-only
mode is active, and every remaining finding sits in the Deferred non-Critical feedback audit section, which this round must not modify,
resolve, or reply to. Per the budget warning (one prior round exhausted its
time budget), this round did only the smallest blocking subset: the
base-branch conflict resolution the workflow requested (--conflict true).

Conflict resolution (--conflict true)

Merged origin/main (f9e6b67aa7, 10 commits ahead of the branch's last
merge point) into the PR branch. Main's new work lands in exactly the code
this PR changes: the run-session ledger (#9091) and the incremental-anchor
validation (#9100) both restructure fetch-pr, whose lease lock is this
PR's subject. Three files conflicted; each was resolved by understanding
both sides, never taking one side blindly:

  1. packages/cli/src/commands/review/fetch-pr.ts — the core conflict. Main
    kept the OLD no-op lease call but rewrote steps 5–6 of the pipeline
    (byte-exact diff capture with diffSha256, incremental anchor ruling,
    containment oracle, partition rescue, run-session ledger append). This PR
    replaced that lease call with the lock: number validation, fail-closed
    identity gate, other-session refusal, and a try/catch that rolls the lease
    back on any failure. The resolution is main's full pipeline running inside
    this PR's lock (steps 0–6 wrapped, rollback catch preserved), plus the
    five-function lease import. An indent-normalized diff against main
    confirms the only content deltas are the lease-lock additions.
  2. packages/cli/src/commands/review/fetch-pr.test.ts — three regions, all
    resolved as unions: the import list (buildRoleBrief from main +
    worktreePath from this PR), the producerMocks hoisted block (main's
    gitOpt/gitRaw/resolveMergeBase/buildDiffPlan mocks plus this PR's
    execFileSync/refExists/releaseWorktree), and the ./lib/git.js
    module mock (main's exit-code-aware gitProbe plus this PR's
    mutable refExists/releaseWorktree bindings). One real semantic merge
    issue surfaced: main's two new suites (diff identity, run-session ledger
    wiring) drive the handler WITHOUT the session env vars this PR's
    fail-closed gate now requires — they would have thrown on the gate. Both
    suites received the same save/set/restore env pattern the existing
    report-assembly suite uses.
  3. packages/core/src/skills/bundled/review/SKILL.md — the fetch-report
    paragraph: kept this PR's additions (run cleanup on the emptyDiff stop
    so the lease is released; the lease-conflict failure relay telling users
    never to delete the lease file) and merged in main's new incremental
    field documentation.

Auto-merged files were inspected, not just trusted: cleanup.ts (main moved
runEpochMs to prompt-record.js; this PR's lease shielding intact),
repo-context.ts (this PR's missing-worktree diagnosis + main's plan
identity guard coexist), and the lease service (untouched by main).

Feedback dispositions

  • Deferred non-Critical items (review 4946613918; rc:3792193046,
    rc:3792193052, rc:3792193055, rc:3792193059, rc:3792193068, rc:3792193071,
    rc:3792193073, rc:3792193075, rc:3792193077, rc:3792193081; issue comment
    5308704341): untouched, per the Critical-only rules — no code changes, no
    thread resolutions, no replies. They remain open for human follow-up.

Verification

Commands actually run this round, on the committed merge state:

  • npm run typecheck — passed (exit 0, re-run after the merge commit)
  • npm run build — passed
  • npm run lint — passed
  • npx prettier --check on the three resolved files — passed (after
    formatting the reconstructed fetch-pr.ts)
  • Focused Vitest, packages/cli: fetch-pr.test.ts, cleanup.test.ts,
    repo-context.test.ts, review-worktree-lease.test.ts,
    fetch-pr.integration.test.ts — 5 files passed, 236 tests passed
  • Focused Vitest, packages/core: SKILL.test.ts — 20 tests passed
  • Integration harness after npm run bundle — not run: nothing in this
    round's change is exercised only through the bundled CLI (the
    integration-tests/ harness has no fetch-pr coverage; the in-package
    integration test file ran under Vitest above).
中文说明

Autofix 审查轮次 — PR #9211(冲突解决轮)

本轮没有可执行的反馈。 Reviews、行内评论、issue 级评论、失败的 check、
持续失败的 check 全部为空;Critical-only 模式已生效,所有剩余 finding 都位于
Deferred non-Critical feedback 审计区,本轮不得修改、不得解决、也不得回复
这些条目。根据预算警告(此前有一轮耗尽了时间预算),本轮只做了最小的阻塞
子集:即工作流要求的基分支冲突解决(--conflict true)。

冲突解决(--conflict true

origin/mainf9e6b67aa7,领先本分支上次合并点 10 个提交)合并进 PR
分支。main 的新工作恰好落在本 PR 修改的代码上:run-session ledger(#9091
与增量锚点校验(#9100)都重构了 fetch-pr,而该命令的租约锁正是本 PR 的
主题。共 3 个文件冲突,逐一在理解双方语义后解决,绝不盲目取某一边:

  1. packages/cli/src/commands/review/fetch-pr.ts — 核心冲突。main 保留了
    旧的 no-op 租约调用,但重写了流水线的第 5–6 步(按字节精确捕获 diff 并
    生成 diffSha256、增量锚点裁决、containment 判定、分区救援、run-session
    ledger 追加)。本 PR 则把那个租约调用替换成了锁:编号校验、身份缺失即
    失败关闭(fail-closed)、拒绝其他会话、以及在任何失败时回滚租约的
    try/catch。解决方案是:main 的完整流水线运行在本 PR 的锁之内(第 0–6 步
    整体包裹,回滚 catch 保留),外加五函数租约导入。对 main 做去除缩进的
    diff 确认:内容差异只有租约锁的新增部分。
  2. packages/cli/src/commands/review/fetch-pr.test.ts — 三处冲突,全部按
    并集解决:导入列表(main 的 buildRoleBrief + 本 PR 的 worktreePath)、
    producerMocks hoisted 块(main 的 gitOpt/gitRaw/resolveMergeBase/
    buildDiffPlan mock,加上本 PR 的 execFileSync/refExists/
    releaseWorktree)、以及 ./lib/git.js 模块 mock(main 的感知退出码的
    gitProbe,加上本 PR 的可变 refExists/releaseWorktree 绑定)。发现
    一个真实的语义合并问题:main 的两个新测试套件(diff identity、
    run-session ledger wiring)调用 handler 时没有设置会话环境变量,而本 PR
    的 fail-closed 闸门现在要求这两个变量——它们会在闸门处抛错。已为这两个
    套件补上与现有 report-assembly 套件相同的保存/设置/恢复环境变量模式。
  3. packages/core/src/skills/bundled/review/SKILL.md — fetch 报告段落:
    保留本 PR 的新增内容(emptyDiff 停止时运行 cleanup 以释放租约;租约
    冲突失败的转述,告知用户绝不要删除租约文件),并合入 main 新增的
    incremental 字段说明。

自动合并的文件也经过人工检查而非盲信:cleanup.ts(main 将 runEpochMs
移到 prompt-record.js;本 PR 的租约保护逻辑完整保留)、repo-context.ts
(本 PR 的缺失 worktree 诊断与 main 的 plan 身份守卫并存)、租约服务
(main 未改动)。

反馈处置

  • 延后的非 Critical 条目(review 4946613918;rc:3792193046、rc:3792193052、
    rc:3792193055、rc:3792193059、rc:3792193068、rc:3792193071、rc:3792193073、
    rc:3792193075、rc:3792193077、rc:3792193081;issue 评论 5308704341):
    按 Critical-only 规则不做任何处理——不改代码、不解决线程、不回复。这些
    条目保持开放,留待人工跟进。

验证

本轮在合并提交后的状态上实际运行的命令:

  • npm run typecheck — 通过(exit 0,合并提交后复跑)
  • npm run build — 通过
  • npm run lint — 通过
  • 对三个解决冲突后的文件运行 npx prettier --check — 通过(先格式化了
    重构的 fetch-pr.ts
  • 定向 Vitest,packages/clifetch-pr.test.tscleanup.test.ts
    repo-context.test.tsreview-worktree-lease.test.ts
    fetch-pr.integration.test.ts — 5 个文件通过,236 个测试通过
  • 定向 Vitest,packages/coreSKILL.test.ts — 20 个测试通过
  • npm run bundle 后的集成测试 harness — 未运行:本轮改动没有任何只能经由
    打包 CLI 验证的行为(integration-tests/ harness 没有 fetch-pr 覆盖;
    包内集成测试文件已在上述 Vitest 中运行)。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。

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


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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — PR #9211 (no action)

No actionable feedback this round — no code change was made.

Triage summary:

  • Actionable feedback: none. No reviews, inline comments, or issue-level comments newer than the last evaluation (2026-08-16T17:28:14Z) request any code change. The only newer comments are a maintainer /resolve command and bot status messages (the previous round summary and a review-pipeline fallback notice) — none is review feedback to address.
  • Critical-only mode is active (5 change-producing rounds complete). The single deferred non-Critical item (a PR comment by @qwen-code-ci-bot) is an audit record of that brake; per workflow rules it was left untouched — no code change, no thread resolution, no reply.
  • Failed checks: all CANCELLED, not failed. Every listed check belongs to the superseded CI runs that GitHub's concurrency settings cancelled when the conflict-resolution commit (b4d4f1d) was pushed at 18:07 UTC. The replacement runs started seconds later and are passing or still in progress; the "still-red checks" list is empty. There is no failure evidence to diagnose and nothing to fix locally.
  • No merge conflict reported for this round (--conflict false). Verified read-only with git merge-tree against the freshly fetched origin/main: clean merge. The previous round already merged origin/main into this branch.
  • Diff growth this window: source 80 / test 204 net lines — within the 400/400 budgets.

Branch head remains b4d4f1d777, already pushed; the workflow's independent CI remains the final verification gate.

中文说明

Autofix 评审轮次 — PR #9211(无操作)

本轮没有可处理的反馈 — 未做任何代码改动。

分类处理摘要:

  • 可处理反馈:无。 自上次评估(2026-08-16T17:28:14Z)之后,没有任何 review、行内评论或 issue 级评论要求进行代码改动。较新的评论仅包括一条维护者的 /resolve 命令和若干机器人状态消息(上一轮的处理摘要与评审流水线回退提示)——均不属于需要处理的评审反馈。
  • 已进入仅处理 Critical 的模式(已完成 5 个产生改动的轮次)。其中唯一一条被延后的非 Critical 条目(@qwen-code-ci-bot 的一条 PR 评论)只是该刹车机制的审计记录;按工作流规则保持原样不动 —— 不改代码、不解析线程、不回复。
  • 失败检查:全部为 CANCELLED(已取消),并非失败。 列出的每一项检查都属于被取代的 CI 运行:当冲突解决提交(b4d4f1d)于 UTC 18:07 推送后,GitHub 的并发控制设置取消了这些旧的运行。替代运行在几秒后即启动,目前要么已通过、要么仍在进行中;“持续失败的检查”列表为空。没有可供诊断的失败证据,本地也没有任何需要修复的内容。
  • 本轮没有报告合并冲突--conflict false)。已用 git merge-tree 对刚拉取的 origin/main 做只读校验:合并干净无冲突。上一轮已将 origin/main 合并进本分支。
  • 本窗口 diff 增长: 源码净增 80 行 / 测试净增 204 行 —— 均在 400/400 的预算之内。

分支头仍为 b4d4f1d777,已推送;工作流的独立 CI 仍是最终验证关卡。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

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

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": none (did not hit budget).; chunk 2: execute fetch-pr.test.ts and cleanup.test.ts to confirm green (no node_modules in the worktree or parent checkout; skipped npm install as disproportionate).

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

未审查:reverse audit — did not converge within the reverse-audit round cap of 5。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"none (did not hit budget).;chunk 2:execute fetch-pr.test.ts and cleanup.test.ts to confirm green (no node_modules in the worktree or parent checkout; skipped npm install as disproportionate)

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

Comment on lines +602 to +605
if (!/^\d+$/.test(prNumber) || Number(prNumber) <= 0) {
throw new Error(
`fetch-pr: pr_number must be a positive integer, got ${JSON.stringify(prNumber)}`,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R8-1: The new pr_number gate admits non-canonical number spellings — three entrances: (a) a digit string of ~308+ digits coerces to Infinity and passes Number(prNumber) <= 0; (b) a long zero-padded string is a small finite integer, but the derived lease filename exceeds NAME_MAX 255, so writeFileSync throws raw ENAMETOOLONG instead of the clean rejection this hunk exists to produce; (c) a short zero-padded spelling ('05') derives a second lock key (pr-05 vs pr-5, since paths.ts interpolates the raw string), so per-PR exclusivity does not hold across spellings on a remote that resolves padded refs. — Failure scenario: fetch-pr "$(printf '9%.0s' {1..400})" → the gate passes (Infinity <= 0 is false) → the lease write throws ENAMETOOLONG (426-char filename > NAME_MAX 255), a raw filesystem error instead of the gate's clean refusal. fetch-pr 05 → acquires a lock distinct from pr-5's. No cross-session destruction (the write fails before cleanStale), but the gate fails its stated purpose. Witness: probe verified Number('9'×400) === Infinity, Infinity <= 0 === false, and createReviewWorktreeLease for pr-<400 digits> threw ENAMETOOLONG.

Suggested change
if (!/^\d+$/.test(prNumber) || Number(prNumber) <= 0) {
throw new Error(
`fetch-pr: pr_number must be a positive integer, got ${JSON.stringify(prNumber)}`,
);
if (!/^[1-9]\d{0,8}$/.test(prNumber)) {
throw new Error(
`fetch-pr: pr_number must be a positive integer, got ${JSON.stringify(prNumber)}`,
);

The suggested regex rejects leading zeros, non-digits, zero, and over-long inputs together, closing all three entrances (the sibling commands' Number.isInteger pattern closes only entrance (a)).

中文说明

新的 pr_number 门禁接受了非规范的数字写法,存在三个入口:(a) 超过约 308 位的纯数字字符串会被转换为 Infinity,从而通过 Number(prNumber) <= 0 检查;(b) 长零填充字符串是一个较小的有限整数,但派生的租约文件名超过 NAME_MAX 255,导致 writeFileSync 抛出原始的 ENAMETOOLONG,而非此 hunk 本应给出的干净拒绝;(c) 短零填充写法('05')会派生出第二个锁键(pr-05pr-5,因为 paths.ts 直接插值原始字符串),因此在能解析填充引用的远程上,跨写法的按 PR 排他性不成立。失败场景:fetch-pr "$(printf '9%.0s' {1..400})" → 门禁通过(Infinity <= 0 为 false)→ 租约写入抛出 ENAMETOOLONG(426 字符文件名 > NAME_MAX 255),是原始文件系统错误,而非门禁的干净拒绝。fetch-pr 05 → 获取了与 pr-5 不同的锁。无跨会话破坏(写入在 cleanStale 之前失败),但门禁未能实现其既定目的。建议的正则表达式一并拒绝前导零、非数字、零和过长输入,关闭全部三个入口(兄弟命令的 Number.isInteger 模式只能关闭入口 (a))。

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

Comment on lines +436 to +440
writeStdoutLine(
`note: skipped cleanup for "${target}" — a review session ` +
`(session ${holderAfterAudit.sessionId}) acquired the lease ` +
`during the audit; its own cleanup releases it.`,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R8-2: The post-audit skip note drops the actionable recovery its sibling pre-audit note carries: no lease-file path and no "if that session is gone, delete the lease file and re-run" instruction — while asserting "its own cleanup releases it," which is false when the holder was hard-killed (the session-end sweeps do not run on SIGKILL). — Failure scenario: session B acquires the lease during A's seconds-long audit window, then is SIGKILLed; A's cleanup hits the re-check and prints this note; the operator is told B's own cleanup will release the lease — but B is dead and never will — and the note withholds both the lease path and the force-recovery instruction the first gate's note provides for the identical terminal state. Recovery currently requires re-running cleanup to hit the first gate and get the complete note. Fix: reuse the first note's tail.

Suggested change
writeStdoutLine(
`note: skipped cleanup for "${target}" — a review session ` +
`(session ${holderAfterAudit.sessionId}) acquired the lease ` +
`during the audit; its own cleanup releases it.`,
);
writeStdoutLine(
`note: skipped cleanup for "${target}" — a review session ` +
`(session ${holderAfterAudit.sessionId}) acquired the lease ` +
`during the audit at ${reviewLeasePath(process.cwd(), target)}. Its ` +
`own cleanup releases it when it finishes; if that session is gone, ` +
`delete the lease file and re-run to force cleanup.`,
);
中文说明

审计后的跳过通知丢弃了其姊妹(审计前)通知所携带的可操作恢复信息:既无租约文件路径,也没有"如果该会话已不存在,删除租约文件并重跑"的指引——同时却声称"它自己的 cleanup 会释放它",而当持有者被强杀时(会话结束清扫不会在 SIGKILL 时运行)这一说法是错误的。失败场景:会话 B 在 A 的秒级审计窗口内获取了租约,随后被 SIGKILL;A 的 cleanup 命中复查并打印此通知;操作者被告知 B 自己的 cleanup 会释放租约——但 B 已死亡,永远不会——而且该通知既未给出租约路径,也未给出第一道门禁通知为同一终态提供的强制恢复指引。目前的恢复途径只有重新运行 cleanup 以命中第一道门禁并获得完整通知。修复:复用第一道通知的尾部。

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

Comment on lines +647 to +651
`path and cannot run concurrently, so this run refuses rather than ` +
`destroy the other session's state. Wait for that session to finish ` +
`— its cleanup releases the lease — or, only if that session is ` +
`gone, delete ${reviewLeasePath(process.cwd(), leaseTarget)} and ` +
`re-run.`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R8-6: The lease's only automatic release path — the cleanupReviewWorktreeLeases exit sweep — matches by sessionId AND promptId (review-worktree-lease.ts:316-322), while this PR's gate and same-session refresh define ownership per session; a lease that outlives the prompt that wrote it can never be auto-released, yet this refusal message promises "its cleanup releases the lease." — Failure scenario: probe-verified on real git repos this round: a sweep with a MISMATCHED promptId leaves both the lease and the worktree surviving, while the control arm with the matching promptId releases both — the promptId alone is the difference. Shape: a round ends without the explicit cleanup step (the class this PR's own SKILL.md emptyDiff patch proves exists; TUI sweeps fire only on cancel/loop/error, never normal completion); the session exits; every later fetch-pr of that PR is refused until a human deletes the file. The manual-delete escape in this message keeps the wedge recoverable (why this is a Suggestion, not a Critical), but the promise "its cleanup releases the lease" is false for exactly this shape. Fix: match the exit sweep by sessionId alone (a session-exit sweep releasing any lease its own session holds is what the per-session ownership model promises), or document the promptId match as intentional fail-closed residue — in review-worktree-lease.ts:316-322.

中文说明

租约唯一的自动释放路径——cleanupReviewWorktreeLeases 退出清扫——按 sessionId 和 promptId 匹配(review-worktree-lease.ts:316-322),而本 PR 的门禁和同会话刷新按会话定义所有权;存活超过写入它的 prompt 的租约永远无法被自动释放,但此拒绝消息承诺"它的 cleanup 会释放租约"。失败场景:本轮在真实 git 仓库上经探针验证——promptId 不匹配的清扫会同时留下租约和 worktree,而 promptId 匹配的控制组则两者都释放——差异仅在 promptId。场景:某一轮在未执行显式 cleanup 步骤的情况下结束(本 PR 自身的 SKILL.md emptyDiff 补丁证明此类情况存在;TUI 清扫仅在取消/循环/错误时触发,正常完成从不触发);会话退出;此后该 PR 的每次 fetch-pr 都被拒绝,直到有人手动删除该文件。此消息中的手动删除逃生通道使该楔子可恢复(因此为 Suggestion 而非 Critical),但"它的 cleanup 会释放租约"的承诺恰好对这一场景不成立。修复:让退出清扫仅按 sessionId 匹配(会话退出清扫释放该会话自身持有的任何租约,正是按会话所有权模型所承诺的),或在 review-worktree-lease.ts:316-322 中将 promptId 匹配记录为有意的失败关闭残留。

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — PR #9211 (no action)

No actionable feedback this round — no code change was made and no commit was created.

Why, point by point:

  • Critical-only mode is active. This PR has completed 5 change-producing rounds, so the workflow's deterministic brake engaged: only Critical/Required feedback is actionable in this window.
  • The only new feedback since the last evaluation (2026-08-16T18:07:30Z) is non-Critical. The automated reviewer submitted a round-8 review with three **[Suggestion]** findings (R8-1 in fetch-pr.ts:605, R8-2 in cleanup.ts:440, R8-6 in fetch-pr.ts:651), all severity S in the review ledger. The review body itself reports nothing further to act on.
  • Those three suggestions were excluded by the brake and remain open for human follow-up (listed in this round's Deferred non-Critical feedback audit section). Per the Critical-only rules they were not implemented, their threads were not resolved, and no reply comments were posted on them.
  • No Critical/Required findings, no failed checks, and no still-red checks. All reported check conclusions are SUCCESS, SKIPPED, or CANCELLED; none failed.
  • No maintainer feedback and no base-conflict resolution were requested this round (--conflict false), so no merge of origin/main was performed.
  • The only other new issue-level comment is the autofix bot's own previous-round status report, which is not actionable feedback.

The deferred suggestions stay open in the PR thread and in the deferred-findings tracking for a maintainer to schedule.

中文说明

Autofix 审查轮次 — PR #9211(无操作)

本轮没有可处理的反馈——未做任何代码改动,也未创建提交。

逐点说明原因:

  • 当前处于仅处理 Critical 的模式。 本 PR 已完成 5 个产生改动的轮次,工作流的确定性刹车已触发:本窗口内只有 Critical/Required 级别的反馈才可处理。
  • 自上次评估(2026-08-16T18:07:30Z)以来的唯一新反馈均为非 Critical。 自动审查器提交了第 8 轮审查,包含三条 **[Suggestion]** 级别的发现(R8-1 位于 fetch-pr.ts:605,R8-2 位于 cleanup.ts:440,R8-6 位于 fetch-pr.ts:651),在审查账本中均为 S 级别。审查正文本身也声明没有更多需要处理的内容。
  • 这三条建议已被刹车排除,保持开放留待人工跟进(列在本轮的 Deferred non-Critical feedback 审计区域)。按照 Critical-only 规则,它们未被实现,对应线程未被标记解决,也未在其上发布回复评论。
  • 没有 Critical/Required 发现,没有失败的检查,也没有持续红色的检查。 所有报告的检查结论均为 SUCCESS、SKIPPED 或 CANCELLED,无一失败。
  • 本轮没有维护者反馈,也没有要求解决与 base 分支的冲突--conflict false),因此未执行 origin/main 的合并。
  • 唯一另一条新的 issue 级评论是 autofix 机器人自己上一轮的状态报告,不属于可处理的反馈。

被延后的建议保持开放,留存在 PR 线程和延后发现跟踪中,由维护者安排处理。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

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


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

@wenshao

wenshao commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 16, 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: 70 passed · 0 failed · 70 total

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

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

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

Verification report

PR #9211 Deep Verification — fix(review): lock the PR review worktree lease against concurrent sessions

Verdict: merge-ready — 70/70 scripted assertions passed (A/B harness 55, mutation matrix 10, edge probe 5), zero survivors in the mutation matrix, all targeted gates green. Verified head: b4d4f1d7772b786b1ddd7801e261bc0de5945042 (git rev-parse HEAD^2), merge checkout a4a087b0e3, base tip 195128a17a. One nit finding (whitespace session-id trim mismatch, safe failure direction, contrived input).

中文摘要

结论:merge-ready。中心主张(租约即锁:并发同 PR 审查被拒绝/跳过,而不是互相摧毁)经 A/B 实测证明:base 侧 5/5 破坏性控制单元全部按预期复现破坏(租约被抢占、A 的 worktree/报告/未提交状态被摧毁、裸 ENOENT),head 侧 14 个单元全部拒绝或跳过且 A 的状态逐字节不变(共 55 条脚本断言全过)。10 个针对新增守卫的单点突变全部被 PR 自带测试击杀(0 存活,击杀归属与守卫一一对应)。未覆盖:逐 commit 归因(浅克隆 depth=2,仅验证聚合 diff)、审计窗口期间的实时租约注入(由 mock 单测钉住)、SKILL.md 文案对模型行为的实际影响。唯一发现是一个 nit:带空白/仅空白的 QWEN_CODE_SESSION_ID 会因"原样存储、trim 后比较"而自我锁死(失败方向安全,输入形状不现实)。

Scope

Central claim: the PR-review worktree lease now doubles as a lock — fetch-pr refuses with an actionable error before any destructive step when another session holds the lease, and cleanup skips the whole target; the holder's state survives byte-identical (#9205).

Secondary claims: (1) ownership is per-session, not per-prompt — drift restarts and later rounds re-take their own lease; missing identity fails closed; malformed pr_number is refused before the gate; lease acquisition is atomic (wx); failure rollbacks never erase a foreign lease. (2) A worktree removed mid-review fails repo-context with an actionable message instead of a bare ENOENT.

Commit reachability: the checkout is depth 2 (git rev-parse --is-shallow-repository → true); only 1 of the 11 commits in the metadata snapshot is locally reachable (git rev-list HEAD^1..HEAD^2b4d4f1d7 only). Per-commit attribution was out of reach; the aggregate HEAD^1..HEAD diff (10 files, +1769/−605) is what was verified.

Central claim — A/B proof

Mock-free harness (01-ab-harness.mjs, run under tsx): both arms import the real review-command sources — head tree = merge checkout, base tree = HEAD^1 worktree — and drive them against real scratch git repositories with a real bare "remote" carrying refs/pull/42/head, real lease files, and a hermetic gh shim (the external GitHub boundary only). Session A's state (lease + worktree + branch + side files + uncommitted in-worktree marker) is created through the arm's own lease writer; oracles are before/after sha256 snapshots of .qwen/tmp plus branch/worktree listings. Witness: evidence/01-ab-cells-base-vs-head.png (the run as it printed, 55/55).

# Cell Arm Scenario (env) Observable oracle Result
1 B fetch-pr vs A base session-b lease holder, A's diff/report markers, uncommitted in-worktree state destroyed & stolen (control RED by design): lease → session-b, all A markers gone
2 B fetch-pr vs A head session-b refusal text + full state snapshot refused: "already being reviewed by another session (session session-a)", names lease file; A state byte-identical
3 B fetch-pr, no identity head (no env) fail-closed refusal refused naming QWEN_CODE_SESSION_ID; state untouched
4 A re-fetch, new prompt head session-a / prompt-a2 self-lockout absent succeeds; lease refreshed to prompt-a2, persists after success
5/6 malformed 5/., 0 head session-b pre-gate validation refused ("must be a positive integer"); A state untouched
7 B fetch-pr, no identity base (no env) fail-closed absent base ran on, wiped A's in-worktree state while the lease still named session-a (lease ignored, not consulted)
8 malformed 5/. vs pr-5 holder base session-b gate-bypass destruction path.join normalized onto review-pr-5; A's pr-5 worktree destroyed lease-less — the exact bypass the validation closes
9 B cleanup base session-b destruction worktree, branch, side files, lease all deleted
10 B cleanup head session-b skip note + snapshot note: skipped cleanup for "pr-42" naming holder + lease file; state byte-identical
11 cleanup, no identity head (no env) fail-closed skip skipped; state untouched
12 A's own cleanup head session-a release worktree/branch/side files/lease all removed; audit ran clean via shim
13 fresh session C after release head session-c unblocked full fetch-pr success; lease now session-c
14 cleanup of file target named lease head session-b sweep grammar target's own side files swept; the real pr-42 lease survives (R2-1 shape)
15/16 missing worktree → repo-context head / base error text head: "worktree … is missing — recreate … re-run qwen review fetch-pr"; base: bare ENOENT with no remedy (control RED by design)
17 --worktree through a regular file head ENOTDIR handling rethrown as ENOTDIR, NOT absorbed into the missing-worktree message
18/19 lease atomic acquire head real fs EEXIST path foreign second writer refused naming the holder, winner's lease intact; same-session re-create refreshes prompt id

Base-arm cells reproduce every "Before" shape the PR description claims (worktree destruction mid-review, silent lease steal, bare ENOENT) — the A/B difference is attributable to the diff alone (see Methodology).

Reviewer Test Plan, per step: 1 (setup creates worktree+lease — exercised in every cell's setup) ✅ · 2 (B refused, A untouched) ✅ cell 2 · 3 (B cleanup skips with note) ✅ cell 10 · 4 (A re-fetch with new prompt succeeds; missing worktree → actionable error) ✅ cells 4, 15 · 5 (A's cleanup releases; fresh C proceeds) ✅ cells 12, 13. No step was structurally unreachable.

Guard pinning — mutation matrix and vacuity

Unmutated baseline on the head tree: the four touched suites pass 234/234 (evidence/03-baseline-234-green.png). Each guard the PR introduces was then disabled by a single interface-preserving mutant (value swaps / if (false), never deleted declarations) in a scratch worktree, run against its suite, and reverted (02-mutation-matrix.sh; evidence/02-mutation-matrix-10-of-10-killed.png):

Mutant Guard disabled Suite result Killed by (named test)
M1 fetch-pr lease-conflict refusal 2 failed / 130 "refuses with an actionable error…", "names the lease file…"
M2 reviewLeaseHeldByAnotherSession → always false 2 failed / 20 "blocks another session", "blocks a process that has no session id…"
M3 cleanup pre-audit skip gate 3 failed / 33 "skips the whole target when another session holds the lease (#9205)"
M4 flag: 'wx' atomic create 1 failed / 21 "refuses to overwrite a lease another session acquired first"
M5 identity requirement 1 failed / 131 "refuses to run when the lease cannot register for lack of identity"
M6 pr_number validation 2 failed / 130 "refuses a malformed pr_number…", "refuses a zero pr_number…"
M7 rollback holder === null guard 1 failed / 131 "keeps a pre-existing same-session lease when a re-fetch fails"
M8 cleanup release keyed to failedDestruction 1 failed / 35 "clears the lease when only a side file fails to delete"
M9 repo-context ENOENT actionable message 1 failed / 43 "fails actionably when the worktree vanished mid-review (#9205)"
M10 cleanup post-audit re-check (TOCTOU) 2 failed / 34 "re-checks the lease after the network-bound audit…"

10/10 killed, 0 survivors. Each guard's primary kill is the test named for it (attribution holds). M3/M10 also cross-kill two adjacent cleanup tests via mock-queue consumption order (queued predicate values consumed at shifted call sites) — expected wiring overlap, not misattribution. Positive control: the M1 mutant reproduced manually outside the matrix kills the same two tests; the unmutated baseline is green, so the kills mean the suite pins the behavior. The rollback test's revert-run fails the intended assertion (the rejects.toThrow / call-expectation mismatch names expected vs actual), not an import — vacuity excluded. Test names checked against their fixtures: "refuses a zero pr_number the regex disjunct alone accepts" uses '0'; "writes the lease before the stale-clean and the first git call" arms refExists so both destructive legs of cleanStale run and are ordered against — names match inputs.

Sibling sweep (other destroyers of the same fixed paths): comment-status only reads the worktree and handles absence; probe/base sibling trees are created/removed within their own session's review; the end-of-session crash sweep only touches leases matching its own session+prompt. fetch-pr's stale-clean and cleanup are the only cross-session destroyers, and both are gated. No ungated door found.

Corrections (to the PR description)

  • The Risk & Scope section says two same-PR setups starting within the same millisecond "could still interleave". At the gate read, yes — but the wx acquire is step 0 inside the rollback try, before cleanStale (step 1), and the ordering is pinned by the invocation-order test. A loser of the acquire race throws at step 0 and its rollback compares ownership before deleting (cell 18's loser never reaches a destructive step). So no interleaving past the gate can destroy another session's state; the residual window yields a "retry" error instead of the holder-named refusal. The tradeoff is narrower than the description states — in the author's favor.

Findings

Nit — whitespace session ids: stored raw, compared trimmed → self-lockout. fetch-pr writes the lease with the raw QWEN_CODE_SESSION_ID, while reviewLeaseHeldByAnotherSession compares process.env[...].trim() against the stored value. A padded id (' x') therefore reads back as a foreign holder on the session's own re-fetch; a whitespace-only id (' ') passes the raw-truthiness identity check, writes a lease, and can never re-enter (trimmed to empty ⇒ fail-closed). Measured on the real predicate (03-whitespace-edge.mjs, 5/5; evidence/04-whitespace-edge-probe.png, logs-03-edge.txt):

node_modules/.bin/tsx tmp/pr9211-verify-20260816-235553/03-whitespace-edge.mjs
# padded id: same session is treated as ANOTHER (trim mismatch: ' x' vs 'x') → true

Severity is low: session ids are generated by session infrastructure (never whitespace-padded in practice), and the failure direction is a self-lockout with an actionable message — it cannot destroy another session's state, and the whitespace-only case is arguably correct fail-closed (no non-empty identity to prove). Fix direction (not applied/measured): normalize once at the boundary — const sessionId = process.env['QWEN_CODE_SESSION_ID']?.trim() plus refusing empty-after-trim in the identity check, so writer and gate agree.

Not covered

  • Per-commit attribution — 11 commits in the snapshot, depth-2 shallow checkout; only the aggregate HEAD^1..HEAD diff was exercised (reachability comparison in Scope).
  • Live TOCTOU injection during the network-bound audit — a lease appearing while auditPrWrites runs was not driven live (no injection seam without mocks); the post-audit re-check is pinned by the mocked unit test instead (M10 kills it). The shape, not the trigger, is what is proven.
  • SKILL.md / code-review.md prose effects — the instructions relayed to the model (lease-conflict relay, emptyDiff cleanup) are untestable here; the command behaviors they invoke are all covered above.
  • EACCES-on-side-file lease release live — pinned by unit test (M8); no live unreadable-file scenario constructed (container user is non-root, but the unit test already exercises the exact branch).
  • Windows behavior (PR marks Windows/Linux ⚠️) — everything here ran on Linux; the ENOTDIR cell included (POSIX shape).
  • Repo-wide test suite and other review subcommands beyond the sibling sweep — targeted gates only, per budget.

Methodology

Environment: node:22-bookworm-class container, node v22.23.2, merge-ref checkout (HEAD = merge commit a4a087b0e3, HEAD^1 = base tip 195128a17a, HEAD^2 = verified head b4d4f1d7). The A/B harness drove compiled-source behavior via tsx against two trees: the merge checkout (head) and a git worktree of HEAD^1 (base), calling the real fetchPrCommand.handler / runCleanup / runRepoContext / lease-service exports with per-session env against fresh scratch git repositories; a local bare repo served as the PR remote (refs/pull/42/head planted with git update-ref) so both arms ran the full fetch→worktree→diff→report path hermetically, and a PATH shim doubled for gh (auth/pr-view/api) — the only test double, at the external boundary. Control purity: package.json/package-lock.json are untouched by the diff (0 files), so the shared root node_modules is a clean control; readlink -f node_modules/@qwen-code/qwen-code-core resolves into the head tree for both arms, and the PR's core change is markdown-only (SKILL.md), so no core TS differs between arms. Mutation runs used a third HEAD worktree with the packages' nested node_modules symlinked in (the root ajv is a different major than packages/core/node_modules/ajv — resolution fails in a bare worktree); every mutant was reverted (git status clean afterwards). Raw logs: logs-01-ab.txt, logs-02-mutations.txt, logs-03-edge.txt; harnesses 01-ab-harness.mjs, 02-mutation-matrix.sh, 03-whitespace-edge.mjs are rerunnable from the artifact dir. Transparency note: two early harness expectations were wrong about base semantics (base's lease write also no-ops without ids; a rebuilt worktree masks destruction unless uncommitted in-worktree state is the witness); the oracles were corrected and the final run is the one counted. Gates: four touched suites 234/234; npm run typecheck -w cli -w core exit 0; ESLint clean on all 8 changed files with a live-gate proof (a planted no-unused-vars violation was caught, then removed).

Evidence images

01-ab-cells-base-vs-head

02-mutation-matrix-10-of-10-killed

03-baseline-234-green

04-whitespace-edge-probe

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

Qwen Code · sandboxed verification

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix round summary — PR #9211 (no action)

Critical-only mode is active for this PR: 5 change-producing rounds are complete, so the workflow's deterministic brake excluded all non-Critical feedback from this round's actionable sections. The deferred items remain open for human follow-up and were not touched — no code changes, no thread resolutions, and no comment replies for them, per the Critical-only rules.

This round had no actionable work:

  • Reviews: none
  • Inline comments: none
  • Issue-level comments: none
  • Failed checks: none
  • Still-red checks: none

No code was changed and no commit was made; the PR head remains at the commit evaluated at round start (b4d4f1d777 on fix/review-worktree-lease-lock), and the working tree stayed clean throughout the round.

中文说明

Autofix 轮次总结 — PR #9211(无操作)

本 PR 已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次,因此工作流的确定性刹车已将所有非 Critical 反馈排除在本轮可执行区域之外。被延后的条目保持开放,留待人工跟进,本轮未做任何处理——按照 Critical-only 规则,不修改代码、不解决线程、也不针对这些条目撰写评论回复。

本轮没有可执行的工作:

  • 评审(Reviews):无
  • 行内评论(Inline comments):无
  • Issue 级评论(Issue-level comments):无
  • 失败的检查(Failed checks):无
  • 持续失败的检查(Still-red checks):无

未修改任何代码,也未产生任何提交;PR 的 head 保持在轮次开始评估时的提交(fix/review-worktree-lease-lock 分支上的 b4d4f1d777),整个轮次期间工作树保持干净。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

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


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

@wenshao
wenshao enabled auto-merge August 17, 2026 00:14

@doudouOUC doudouOUC 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 head b4d4f1d. No blockers found. Checked the review worktree lease acquire/ownership/rollback paths, fetch-pr gate before stale cleanup, cleanup skip + audit recheck behavior, repo-context missing-worktree errors, docs/SKILL alignment, diff whitespace, and CI status. The cancelled CI entry is superseded by a later successful Qwen Code CI run on the same head.

@wenshao
wenshao added this pull request to the merge queue Aug 17, 2026
Merged via the queue into QwenLM:main with commit 7091b8c Aug 17, 2026
106 of 123 checks passed

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved after verifying head b4d4f1d against the open review threads.

All seven unresolved Critical findings are addressed at head:

  • Lease-sweep guard: cleanup's side-file sweep now skips real lease shapes via isReviewLeaseFile (cleanup.ts), so a target named 'lease' can no longer delete live PR leases; covered by dedicated collision tests.
  • Malformed pr_number bypass: runFetchPr validates pr_number (positive-integer gate) before the lease gate and cleanStale, with tests for '5/.' and '0'.
  • R6-1 TOCTOU acquire: lease write now uses flag 'wx' atomic create; EEXIST path refuses a foreign session and lets the same session refresh.
  • R6-2 blind rollback clear: failure rollback is gated on holder === null and uses clearReviewWorktreeLeaseIfOwned (session+prompt ownership compare), so a run never deletes a lease it does not own.
  • R6-3 silent lease-less run: fetch-pr now fails closed when QWEN_CODE_SESSION_ID/PROMPT_ID are missing.
  • R6-4: the lock assertions now compare against the real worktreePath() helper instead of a hardcoded POSIX literal.
  • ENOTDIR repo-context test is gated skipIf(win32), matching libuv's ENOENT mapping.

The residual cleanup TOCTOU (between the post-audit re-check and destruction) is the read-then-write tradeoff explicitly disclosed in the PR body and is microsecond-scale after the re-check; acceptable. Remaining Suggestion threads are test-hardening/edge-case items, deferred. CI on this head is green (windows/macos matrix jobs are conditionally skipped, not failures); scope matches the PR description. Nice work iterating through eight rounds.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.13.

qwen-code-dev-bot added a commit that referenced this pull request Aug 17, 2026
Resolve the fetch-pr.test.ts conflict between this PR's round-cap clock
test (#9256) and main's worktree-lease lock/rollback suites (#9211): both
blocks land at the same insertion point, so keep both. The import conflict
is the union of both sides. SKILL.md merged cleanly — main's lease prose
and this PR's round-cap prose occupy distinct sections.
wenshao added a commit that referenced this pull request Aug 17, 2026
main's #9211 (review worktree lease) wrapped the whole of `runFetchPr`
in a try whose catch clears the lease and rethrows, which moved every
line of the report assembly one level deeper — so the tail of the
function conflicted wholesale against this branch's `reviewModelId`
stamp. Resolved by taking main's structure and re-applying this
branch's three additions on top: the report field, `roundModelIdFrom`,
and the stamp itself, now inside the try.
wenshao added a commit that referenced this pull request Aug 17, 2026
…tal/3-blob-verdicts

Same shape as the base PR's own merge: main's #9211 wrapped `runFetchPr`
in a lease-clearing try, moving the whole report assembly one level
deeper, so this branch's tail conflicted wholesale. Took main's
structure and re-applied this PR's additions on top — the `blobPairs` /
`atomicWriteFileSync` imports, the `cacheCandidatePath` report field,
the candidate-write block, and its report entry — all now inside the
try.
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+) review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/review: concurrent same-PR reviews race on the fixed worktree path (worktree deleted mid-run)

5 participants