Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/users/features/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ When reviewing a PR, `/review` creates a temporary git worktree (`.qwen/tmp/revi
- Build and test commands run in isolation without polluting your local build cache
- 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. Clean stops release it: a finished review and the early stops (empty diff, no new changes since the last review) all run `cleanup`, which releases the lease
- The worktree is leased to its session: a second `/review` of a PR that is already under review refuses to start (naming the holder) rather than tear down the running review's worktree
- Review reports and cache are saved to the main project directory (not the worktree)

## Cross-repo PR Review
Expand Down
237 changes: 237 additions & 0 deletions packages/cli/src/commands/review/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';

const mocks = vi.hoisted(() => ({
execFileSync: vi.fn(),
Expand All @@ -16,6 +17,8 @@ const mocks = vi.hoisted(() => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
clearReviewWorktreeLease: vi.fn(),
readReviewWorktreeLease: vi.fn((): unknown => null),
reviewLeaseHeldByAnotherSession: vi.fn((_lease: unknown): boolean => false),
refExists: vi.fn(() => true),
// The parameter is declared so `mock.calls` is typed `[string][]` rather than
// `[][]` — the paths it was asked to free are the assertion in the sweep test.
Expand Down Expand Up @@ -64,6 +67,12 @@ vi.mock('../../utils/stdioHelpers.js', () => ({

vi.mock('../../services/review-worktree-lease.js', () => ({
clearReviewWorktreeLease: mocks.clearReviewWorktreeLease,
readReviewWorktreeLease: mocks.readReviewWorktreeLease,
reviewLeaseHeldByAnotherSession: mocks.reviewLeaseHeldByAnotherSession,
reviewLeasePath: (repositoryRoot: string, target: string) =>
`${repositoryRoot}/.qwen/tmp/qwen-review-lease-${target}.json`,
isReviewLeaseFile: (fileName: string) =>
/^qwen-review-lease-pr-\d+\.json$/.test(fileName),
}));

vi.mock('./lib/git.js', () => ({
Expand All @@ -83,6 +92,7 @@ vi.mock('./lib/paths.js', () => ({
probeWorktreePath: (path: string) => `${path}-probe`,
baseWorktreePath: (path: string) => `${path}-base`,
reviewBranch: (prNumber: string) => `qwen-review/pr-${prNumber}`,
LEASE_PREFIX: 'qwen-review-lease-',
REVIEW_TMP_DIR: '/repo/.qwen/tmp',
tmpFile: (target: string, suffix: string) =>
`/repo/.qwen/tmp/qwen-review-${target}-${suffix}`,
Expand All @@ -107,6 +117,9 @@ describe('runCleanup', () => {
freed: false,
reason: undefined,
});
// clearAllMocks keeps implementations a prior test set — drop them so a
// throwing rmSync cannot leak into tests that expect deletion to work.
mocks.rmSync.mockReset();
Comment on lines +120 to +122

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 beforeEach resets only mocks.rmSync, but this PR's new tests also set persistent implementations on mocks.readdirSync (mockReturnValue([...]) in the four sweep tests) and a throwing implementation on mocks.execFileSync; under the installed Vitest 3.2.4, vi.clearAllMocks() is mockClear-only — implementations survive — so they leak into every later test in this describe. Today the leak is masked because beforeEach re-pins existsSync to false, short-circuiting the sweep before readdirSync is consulted. — Concrete cost: probe — an appended test that sets existsSync true without re-declaring readdirSync swept the phantom entry queued by a preceding test (rmSync was called with qwen-review-local-diff.txt it never declared) — future assertions pass or fail by test ORDER rather than the code under test. Baseline plus four shuffled-seed runs confirm no CURRENT test is affected (34/34 green in every order); the author's own comment names this hazard class and patches one of the three overridden mocks.

Witness: probe — appended leak test FAILS as-is (rmSync called 1× with the phantom entry); adding the two mockReset() lines below → 35/35 green.

Suggested change
// clearAllMocks keeps implementations a prior test set — drop them so a
// throwing rmSync cannot leak into tests that expect deletion to work.
mocks.rmSync.mockReset();
// clearAllMocks keeps implementations a prior test set — drop them so a
// throwing rmSync cannot leak into tests that expect deletion to work.
mocks.rmSync.mockReset();
mocks.readdirSync.mockReset();
mocks.execFileSync.mockReset();
中文说明

[Suggestion] 这个 beforeEach 只重置了 mocks.rmSync,但本 PR 的新测试还给 mocks.readdirSync 设置了持久实现(四个清扫测试中的 mockReturnValue([...])),并给 mocks.execFileSync 设置了抛错实现;在安装的 Vitest 3.2.4 下,vi.clearAllMocks() 只做 mockClear——实现会保留——因此它们会泄漏进该 describe 中所有后续测试。目前泄漏被掩盖,是因为 beforeEachexistsSync 重新固定为 false,使清扫在查询 readdirSync 之前就被短路。 — 具体代价:探针——追加一个把 existsSync 设为 true 且不重新声明 readdirSync 的测试,会清扫到前一个测试排队的幽灵条目(rmSync 被调用时带着它从未声明过的 qwen-review-local-diff.txt)——未来的断言将按测试顺序而非被测代码决定成败。基线加四次随机种子乱序运行确认当前没有测试受影响(每种顺序下均 34/34 全绿);作者自己的注释已经点名了这一隐患类别,并修补了三个被覆盖 mock 中的一个。

证据:探针——追加的泄漏测试现状下失败(rmSync 以幽灵条目被调用 1 次);加入下方两行 mockReset() 后 → 35/35 全绿。

建议修复:在同一个 beforeEach 中补上另外两个 mock 的 mockReset(),见上方 suggestion。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred to the next round, not dropped. This round ran in critical-only mode with the ~8-finding cap, and the batch went to the five Criticals (atomic lease acquire, guarded rollback, fail-closed identity, platform-safe assertion, win32-gated ENOTDIR test) plus the three cheapest coherent Suggestions. R5-3 is pure test-order hygiene, and the finding's own probe confirms no current test is affected (34/34 green across four shuffled-seed orders), so it deferred safely. The beforeEach in cleanup.test.ts already resets rmSync for exactly this hazard class; extending it to readdirSync/execFileSync remains a small, agreed change for the follow-up round this thread stays open for.

中文说明

延后到下一轮,不会丢弃。本轮处于仅处理 Critical 的模式并受约 8 个发现的上限约束,批次给了五个 Critical(原子租约获取、有守卫的回滚、fail-closed 身份校验、平台安全断言、win32 门控的 ENOTDIR 测试)以及三个最廉价且与主线一致的 Suggestion。R5-3 是纯测试顺序卫生,且该发现自己的探针确认当前无测试受影响(四次乱序种子下均 34/34 全绿),因此可以安全延后。cleanup.test.tsbeforeEach 已为同一隐患类别重置了 rmSync;把它扩展到 readdirSync/execFileSync 仍是本线程保持开放所对应的小幅后续改动。

});

it('keeps the lease when branch deletion fails', () => {
Expand Down Expand Up @@ -138,6 +151,163 @@ describe('runCleanup', () => {
);
});

it('clears the lease when only a side file fails to delete', () => {
// The lease guards the worktree and branch, not side files: once those
// are freed, a residue a later sweep retries must not keep the lock held
// — a leftover lease refuses every later fetch-pr of this PR and skips
// every later cleanup, and nothing sweeps it automatically.
mocks.execFileSync.mockReturnValue(Buffer.from(''));
mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-pr-123-diff.txt']);
mocks.rmSync.mockImplementation(() => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
});

runCleanup('pr-123');

expect(mocks.writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('Failed to remove'),
);
expect(mocks.clearReviewWorktreeLease).toHaveBeenCalledWith(
process.cwd(),
'pr-123',
);
});

it('skips the whole target when another session holds the lease (#9205)', () => {
// The incident shape: session B cleans up while session A is mid-review.
// Nothing of A's may be touched — worktree, siblings, branch, side files,
// audit window, or the lease itself.
Comment on lines +178 to +180

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] R4-3 (round-4 re-review — still stands): the lease-skip test's side-file safety assertion is vacuous — beforeEach pins mocks.existsSync.mockReturnValue(false) and this test never overrides it, while the per-target side-file sweep is gated on existsSync(REVIEW_TMP_DIR), so the sweep body can never execute here; the "nothing of A's side files was touched" claim observes an impossible state. — Concrete cost: probe — a mutant adding a side-file sweep ABOVE the lease gate (which in production deletes the holder's side files — exactly what this test's comment forbids) keeps 34/34 green; a regression that sweeps the holder's side files before or without the lease check passes this suite and strips a mid-review session of its diff/plan/receipts (the #9205 shape on the side-file leg). Nuance: the rmSync-not-called assertion is NOT vacuous against a gate-removed-wholesale mutant (the ungated base-lock rmSync would trip it); the vacuity is specific to the side-file sweep this comment claims to pin.

Witness: probe — mutant adding a pre-gate side-file sweep → 34/34 green (the suite cannot see the holder's side files being swept).

Suggested change
// The incident shape: session B cleans up while session A is mid-review.
// Nothing of A's may be touched — worktree, siblings, branch, side files,
// audit window, or the lease itself.
// The incident shape: session B cleans up while session A is mid-review.
// Nothing of A's may be touched — worktree, siblings, branch, side files,
// audit window, or the lease itself. Populate the tmp dir so the sweep
// actually runs, making the side-file leg of the skip observable.
mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-pr-123-diff.txt']);
中文说明

[Suggestion] R4-3(第 4 轮复审——仍然成立):租约跳过测试的附属文件安全断言是空洞的——beforeEach 固定了 mocks.existsSync.mockReturnValue(false),而本测试从未覆盖它;按目标的附属文件清扫又以 existsSync(REVIEW_TMP_DIR) 为门禁,因此清扫主体在这里永远不可能执行,“A 的附属文件未被触碰”这一断言观察的是一个不可能出现的状态。 — 具体代价:探针——在租约门禁之上加入一段附属文件清扫的变异体(生产环境下这会删除持有者的附属文件——正是本测试注释所禁止的行为)仍能让 34/34 全绿;一个在租约检查之前或绕过租约检查清扫持有者附属文件的回归将通过本套件,使审查中途的会话失去其 diff/计划/回执(#9205 形态在附属文件这条腿上的复现)。细节说明:rmSync 未被调用的断言对“整体移除门禁”的变异体并非空洞(未设门禁的 base-lock rmSync 会触发它);空洞仅针对本注释声称要钉住的附属文件清扫。

证据:探针——在门禁前加入附属文件清扫的变异体 → 34/34 全绿(套件看不到持有者的附属文件被清扫)。

建议修复:在测试中填充 tmp 目录使清扫真正运行,从而让跳过逻辑的附属文件一侧可观察,见上方 suggestion。

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

const lease = {
sessionId: 'session-a',
promptId: 'prompt-a',
target: 'pr-123',
repositoryRoot: '/repo',
worktreePath: '/repo/.qwen/tmp/review-pr-123',
branch: 'qwen-review/pr-123',
};
mocks.readReviewWorktreeLease.mockReturnValueOnce(lease);
mocks.reviewLeaseHeldByAnotherSession.mockImplementationOnce(
(l: unknown) => l === lease,
);
// Populate the tmp dir so the per-target side-file sweep actually runs
// once past the skip gate: a refactor that moves the sweep above the
// gate would reach for the holder's side files and trip the
// rmSync-not-called assertion below.
mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-pr-123-diff.txt']);

runCleanup('pr-123');

// The skip must key on THIS target's lease: mockReturnValueOnce is
// argument-blind, so an unwired read consults another PR's lease.
expect(mocks.readReviewWorktreeLease).toHaveBeenCalledWith(
process.cwd(),
'pr-123',
);
expect(mocks.releaseWorktree).not.toHaveBeenCalled();
expect(mocks.execFileSync).not.toHaveBeenCalled();
expect(mocks.rmSync).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] R4-3 (round-4 ledger finding, re-asserted — still stands): this test's side-file safety assertion (expect(mocks.rmSync).not.toHaveBeenCalled()) is vacuous — beforeEach pins mocks.existsSync.mockReturnValue(false) and this test never overrides it, so the per-target side-file sweep (gated on existsSync(REVIEW_TMP_DIR)) can never run here. — Failure scenario: if the skip gate's early return were removed or moved below the sweep in a refactor, this assertion stays green (existsSync=false keeps the sweep from ever calling rmSync), so the regression — the skip no longer protecting the holder's side files — escapes the very test written to pin it. The other not-called assertions (releaseWorktree, execFileSync) still discriminate via different legs; this one does not.

Suggested fix — arm the sweep in this test so the assertion discriminates:

mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-pr-123-diff.txt']);
中文说明

[Suggestion] R4-3(第 4 轮账本发现,重新断言——仍然存在):该测试的附属文件安全断言(expect(mocks.rmSync).not.toHaveBeenCalled())是空洞的——beforeEach 钉住了 mocks.existsSync.mockReturnValue(false),而本测试从未覆盖它,因此按目标的附属文件清扫(以 existsSync(REVIEW_TMP_DIR) 为门)在这里永远无法运行。失败场景:如果跳过门禁的提前返回被移除、或在重构中被移到清扫之后,该断言仍然为绿(existsSync=false 使清扫永远不会调用 rmSync),于是“跳过不再保护持有者附属文件”的回归恰好逃过为它而写的测试。其余 not-called 断言(releaseWorktree、execFileSync)经由其他环节仍有判别力;这一条没有。建议修复见上方代码块——在本测试中武装清扫,使断言具有判别力。

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

expect(mocks.ghApiAll).not.toHaveBeenCalled();
Comment on lines +210 to +211

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 lease-skip test's side-file safety assertion is vacuous: beforeEach pins mocks.existsSync.mockReturnValue(false) and the skip test never overrides it, so the per-target side-file sweep — gated on existsSync(REVIEW_TMP_DIR) — can never reach rmSync. The test passes whether the gate skips the sweep wholesale or not, leaving the "skips the target wholesale" contract (the exact behavior the SKILL.md sentence this PR adds documents) unpinned. Distinct from the gate-ordering gap above: this one is about the sweep leg, not the audit leg. — Failure scenario: a refactor narrows the skip so the sweep still runs (e.g. hoists the sweep above the gate): the shipped skip test stays green while production cleanup deletes the holder session's in-flight diff, plan and findings files — and the fetch report carrying the audit window — re-creating the #9205 mid-run destruction through the side-file path. Witness (probe): sweep-hoist mutation + shipped test = green; mutation + fixed test = AssertionError: expected "spy" to not be called at all, but actually been called 1 times (rmSync); unmutated + fixed test = 34 passed (34).

Fix — make the sweep reachable in the skip test and assert it does not run:

mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-pr-123-diff.txt']);
// keep, now meaningful:
expect(mocks.rmSync).not.toHaveBeenCalled();
中文说明

[Suggestion] lease 跳过测试中"附属文件安全"的断言是空的:beforeEach 钉住 mocks.existsSync.mockReturnValue(false),跳过测试从未覆盖它,因此以 existsSync(REVIEW_TMP_DIR) 为门禁的按目标附属文件清扫永远触达不到 rmSync。无论门禁是否整体跳过清扫,该测试都通过——"整体跳过目标"这一契约(本 PR 新增 SKILL.md 语句所记载的确切行为)未被钉住。与上一条门禁顺序缺口不同:这条针对清扫腿,而非审计腿。 — 失败场景:一次重构把跳过收窄、让清扫仍然执行(例如把清扫提升到门禁之上):现有跳过测试保持绿色,而生产中的 cleanup 会删除持有者会话正在使用的 diff、plan 与 findings 文件——连同承载审计窗口的 fetch 报告——经由附属文件路径重演 #9205 的审查中途破坏。证据(探针):清扫上移变异 + 现有测试 = 绿;变异 + 修复后的测试 = AssertionError: expected "spy" to not be called at all, but actually been called 1 times(rmSync);未变异 + 修复后的测试 = 34 passed (34)

修复:在跳过测试中让清扫可达并断言其未运行(见上方代码),使部分跳过回归变红。

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

expect(mocks.clearReviewWorktreeLease).not.toHaveBeenCalled();
Comment on lines +211 to +212

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 lease-skip test does not pin the gate-before-audit ordering: it runs with no fetch report (the default readFileSync mock throws ENOENT), so auditPrWrites short-circuits on its stderr skip-note and never calls ghApiAll. Moving the lease gate below auditPrWrites keeps every assertion green, while in production — where the holder's fetch report IS on disk — the reordered gate would run the network-bound gh audit of the holder's window before refusing, the exact thing the test's own comment claims to guard. — Failure scenario: a refactor hoists the gate below the audit → the shipped test stays green → cleanup audits the holder's receipts and emits warning: lines computed against an audit window the skipping session never wrote. Witness (probe): mutation + shipped tests = Tests 34 passed (34); mutation + fixed test = red at expect(mocks.ghApiAll).not.toHaveBeenCalled() (Number of calls: 2).

Fix — give the skip test a fetch report so a reordered gate reaches ghApiAll:

// in the skip test, as the audit tests do:
mocks.readFileSync.mockReturnValue(fetchReport);
// the existing assertion then turns red under the mutation:
expect(mocks.ghApiAll).not.toHaveBeenCalled();
中文说明

[Suggestion] lease 跳过测试没有钉住"门禁先于审计"的顺序:该测试未提供 fetch 报告(readFileSync 默认 mock 抛出 ENOENT),因此 auditPrWrites 在其 stderr 跳过提示处短路、从不调用 ghApiAll。把租约门禁移到 auditPrWrites 之下,所有断言仍然全绿;而在生产中——持有者的 fetch 报告就在盘上——被降位的门禁会先对持有者的审计窗口执行网络受限的 gh 审计、再拒绝,恰恰违背了该测试自身注释声称要守护的东西。 — 失败场景:一次重构把门禁提升到审计之下 → 现有测试保持绿色 → cleanup 对持有者的回执执行审计,并按一个跳过会话从未写过的审计窗口输出 warning: 行。证据(探针):变异 + 现有测试 = Tests 34 passed (34);变异 + 修复后的测试 = 在 expect(mocks.ghApiAll).not.toHaveBeenCalled() 处变红(Number of calls: 2)。

修复:在跳过测试中提供 fetch 报告(与审计测试相同),使被降位的门禁真的触达 ghApiAll,现有断言即在变异下变红。

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

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

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)

// The note must name the lease file itself — the operator cannot act on
// "delete the lease file" without knowing which file that is.
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen-review-lease-pr-123.json'),
);
Comment on lines +219 to +223

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 operator-facing skip note's repositoryRoot argument to reviewLeasePath is unpinned — the mock is a plain arrow function (not a spy, unlike readReviewWorktreeLease in the same test), and the assertion checks only the filename substring, which the target argument alone determines.

Failure scenario: a refactor replacing reviewLeasePath(process.cwd(), target) in cleanup.ts's skip note with a stale resolved-root variable, os.homedir(), or any other root ships green — the note then names a lease path that does not exist; the operator deletes nothing, the refusal wedge persists, and the suite still certifies the message. The same test pins the read's arguments for precisely this reason (its own comment names the argument-blind hazard).

Witness: changed only the skip note's root arg to '/stale-root' → suite 36/36 green (survives); the full-path pin below fails under the mutation (the note names /stale-root/…lease-pr-123.json) and passes against real code.

Suggested change
// The note must name the lease file itself — the operator cannot act on
// "delete the lease file" without knowing which file that is.
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen-review-lease-pr-123.json'),
);
// The note must name the lease file itself — the operator cannot act on
// "delete the lease file" without knowing which file that is.
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining(
join(process.cwd(), '.qwen', 'tmp', 'qwen-review-lease-pr-123.json'),
),
);
中文说明

面向操作者的跳过提示中,reviewLeasePathrepositoryRoot 参数未被钉住——该 mock 是普通箭头函数(不同于同一测试中的 readReviewWorktreeLease,它不是 spy),断言只检查文件名片段,而该片段仅由 target 参数决定。失败场景:把 cleanup.ts 跳过提示中的 reviewLeasePath(process.cwd(), target) 替换为陈旧解析根、os.homedir() 或任何其他根,测试仍全绿——提示将指向一个不存在的租约路径;操作者什么都删不掉,拒绝僵局持续,而套件仍为消息背书。同一测试正是为此钉住了读取的参数(其注释明确点名参数盲视风险)。证据:仅把跳过提示的根参数改为 '/stale-root' 后套件 36/36 全绿(变异存活);下方全路径钉住在变异下失败(提示指向 /stale-root/…)、在真实代码上通过。建议修复见上方 suggestion。

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

});

it('proceeds when the lease belongs to this session', () => {
const lease = {
sessionId: 'session-b',
promptId: 'prompt-b',
target: 'pr-123',
repositoryRoot: '/repo',
worktreePath: '/repo/.qwen/tmp/review-pr-123',
branch: 'qwen-review/pr-123',
};
mocks.readReviewWorktreeLease.mockReturnValueOnce(lease);
mocks.reviewLeaseHeldByAnotherSession.mockReturnValueOnce(false);
mocks.execFileSync.mockReturnValue(Buffer.from(''));

runCleanup('pr-123');

expect(mocks.releaseWorktree).toHaveBeenCalledTimes(3);
expect(mocks.clearReviewWorktreeLease).toHaveBeenCalledWith(
process.cwd(),
'pr-123',
);
});

it('re-checks the lease after the network-bound audit and skips if a session moved in during it (#9205)', () => {
// The gate above reads the lease BEFORE the audit, but the audit spawns
// network-bound gh processes (seconds-scale). A review of the same PR that
// starts inside that window — reading no lease, then writing its own —
// must not be destroyed by this cleanup: re-read the lease after the audit,
// before any destructive step, and take the same skip path.
const lease = {
sessionId: 'session-b',
promptId: 'prompt-b',
target: 'pr-123',
repositoryRoot: '/repo',
worktreePath: '/repo/.qwen/tmp/review-pr-123',
branch: 'qwen-review/pr-123',
};
// First read (the gate): no lease yet. Second read (post-audit): session B
// has acquired one.
mocks.readReviewWorktreeLease
.mockReturnValueOnce(null)
.mockReturnValueOnce(lease);
mocks.reviewLeaseHeldByAnotherSession
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
Comment on lines +267 to +269

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 post-audit re-check test pins both lease READS' arguments (toHaveBeenNthCalledWith), but reviewLeaseHeldByAnotherSession's argument is left unpinned — mockReturnValueOnce is argument-blind. A refactor of cleanup.ts's second gate to if (reviewLeaseHeldByAnotherSession(holder)) — reusing the stale pre-audit variable instead of holderAfterAudit — still performs two reads with the pinned arguments and still consumes the blind second value true, so all assertions pass. In production reviewLeaseHeldByAnotherSession(null) returns false, the skip never fires, and cleanup destroys the session that acquired the lease during the audit — re-opening exactly the #9205 destruction the re-gate was added to close. — Failure scenario: the stale-holder mutation ships with the suite green (probe: mutation + shipped suite = 34 passed (34); mutation + argument pin = expected 2nd "spy" call to have been called with [ { sessionId: 'session-b', …(5) } ]).

Suggested change
mocks.reviewLeaseHeldByAnotherSession
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
mocks.reviewLeaseHeldByAnotherSession
.mockImplementationOnce((lease: unknown) => lease !== null)
.mockImplementationOnce((lease: unknown) => lease !== null);

Argument-sensitive once-mocks (production semantics: null → false, lease → true): the stale-holder mutation now feeds null to the second call, gets false, skips nothing, and the not-called assertions turn red. Alternatively pin the arguments explicitly: expect(mocks.reviewLeaseHeldByAnotherSession).toHaveBeenNthCalledWith(1, null) / (2, lease).

中文说明

[Suggestion] 这个审计后重查测试钉住了两次租约读取的参数(toHaveBeenNthCalledWith),但 reviewLeaseHeldByAnotherSession 的入参未被钉住——mockReturnValueOnce 对参数视而不见。把 cleanup.ts 的第二道门禁重构为 if (reviewLeaseHeldByAnotherSession(holder))——复用审计前的过期变量而非 holderAfterAudit——仍会执行两次参数已被钉住的读取、并消费掉盲设的第二个返回值 true,于是所有断言照旧通过。而在生产中 reviewLeaseHeldByAnotherSession(null) 返回 false,跳过永不触发,cleanup 会销毁在审计期间获取租约的会话——恰恰重新打开了为重关 #9205 而加的这道重查门禁所要封堵的破坏。 — 失败场景:该"过期 holder"变异可在套件全绿下合入(探针:变异 + 现有套件 = 34 passed (34);变异 + 参数钉住 = expected 2nd "spy" call to have been called with [ { sessionId: 'session-b', …(5) } ])。

建议修复:把两个一次性 mock 换成参数敏感的实现(生产语义:null → false,租约 → true,见上方 suggestion);过期 holder 变异在第二次调用传入 null、得到 false、不再跳过,未调用断言随即变红。也可直接钉住参数:toHaveBeenNthCalledWith(1, null) / (2, lease)

— 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)

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)

// Pin the ARGUMENTS of both reads: mockReturnValueOnce is argument-blind,
// so a re-check that reads a malformed target stays green here while
// failing open in production (validTarget rejects it -> null -> not held).
expect(mocks.readReviewWorktreeLease).toHaveBeenNthCalledWith(
1,
process.cwd(),
'pr-123',
);
expect(mocks.readReviewWorktreeLease).toHaveBeenNthCalledWith(
2,
process.cwd(),
'pr-123',
);
// And the second read must come AFTER the audit, not merely exist:
// hoisting it above auditPrWrites keeps every other assertion green while
// the seconds-long audit again runs after the last lease check (#9205).
// Here the audit no-ops on the missing fetch report and names that skip
// on stderr — the note's position pins the audit inside the window.
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]!,
);
// Nothing of B's may be touched.
expect(mocks.releaseWorktree).not.toHaveBeenCalled();
expect(mocks.execFileSync).not.toHaveBeenCalled();
expect(mocks.rmSync).not.toHaveBeenCalled();
expect(mocks.clearReviewWorktreeLease).not.toHaveBeenCalled();
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('acquired the lease'),
);
});

it('releases the review worktree AND both disposable siblings', () => {
// `base-tree` deliberately leaves its tree standing for the whole review
// (a later verifier may need it, and a base that failed to build is kept as
Expand Down Expand Up @@ -170,6 +340,73 @@ describe('runCleanup', () => {
);
});

it('never sweeps lease files, even for a target whose name collides with the lease prefix (#9205)', () => {
// `safeTarget` flattens `lease` (and `./lease`) to `lease`, so a
// file-review target with that name sweeps with a prefix that IS the
// lease prefix: unguarded, the rmSync below deletes every live PR lease
// — including another session's — and defeats the lock this PR adds.
// Lease removal belongs to `clearReviewWorktreeLease` alone.
mocks.execFileSync.mockReturnValue(Buffer.from(''));
mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-lease-pr-123.json']);

runCleanup('lease');

expect(mocks.rmSync).not.toHaveBeenCalledWith(
join('/repo/.qwen/tmp', 'qwen-review-lease-pr-123.json'),
expect.anything(),
);
expect(
mocks.writeStdoutLine.mock.calls.map((c) => String(c[0])).join('\n'),
).not.toContain('qwen-review-lease-pr-123.json');
});

it('sweeps the side files of a lease-named target that share the lease prefix', () => {
// The guard keys on the real lease shape, not the bare prefix: a
// file-review target named `lease` flattens to exactly the lease prefix,
// so keying on the prefix alone skips its OWN side files and nothing else
// ever removes them (`clearReviewWorktreeLease` no-ops off `pr-\d+`) —
// permanent residue. Only files shaped `…-pr-<n>.json` are real leases.
mocks.execFileSync.mockReturnValue(Buffer.from(''));
mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue([
'qwen-review-lease-diff.txt',
'qwen-review-lease-pr-999.json',
]);

runCleanup('lease');

const sideFile = join('/repo/.qwen/tmp', 'qwen-review-lease-diff.txt');
expect(mocks.rmSync).toHaveBeenCalledWith(sideFile, {
recursive: true,
force: true,
});
// A live foreign lease survives the very same sweep.
expect(mocks.rmSync).not.toHaveBeenCalledWith(
join('/repo/.qwen/tmp', 'qwen-review-lease-pr-999.json'),
expect.anything(),
);
});

it('still sweeps side files that match the target prefix', () => {
// The positive control for the lease guard: the skip keys on the lease
// prefix, not on the sweep itself.
mocks.execFileSync.mockReturnValue(Buffer.from(''));
mocks.existsSync.mockReturnValue(true);
mocks.readdirSync.mockReturnValue(['qwen-review-local-diff.txt']);

runCleanup('local');

const sideFile = join('/repo/.qwen/tmp', 'qwen-review-local-diff.txt');
expect(mocks.rmSync).toHaveBeenCalledWith(sideFile, {
recursive: true,
force: true,
});
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
`Removed temp file: ${sideFile}`,
);
});

it('keeps the record directory of a NON-CONVERGED reverse audit (#9206)', () => {
// The loop writes its stop marker inside the record directory when it
// runs to the round cap (or the budget) without converging, and clears
Expand Down
Loading
Loading