Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,32 @@ jobs:
chmod -R u+w "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || true
rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || echo "::warning::leaked .qwen; runner needs manual cleanup"
fi
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations for
# directories the rm above removed; worktree remove --force then
# clears the directories that survived the rm (root-owned or
# read-only leftovers), since a branch checked out in a live
# worktree cannot be deleted. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
git -C "$GITHUB_WORKSPACE" worktree prune -v || true
git -C "$GITHUB_WORKSPACE" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
git -C "$GITHUB_WORKSPACE" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
git -C "$GITHUB_WORKSPACE" worktree prune -v || true
git -C "$GITHUB_WORKSPACE" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
git -C "$GITHUB_WORKSPACE" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi

# On PRs, check out refs/pull/N/head (the immutable PR head, published the
# instant the branch is pushed) instead of github.ref. github.ref is the
Expand Down Expand Up @@ -523,6 +549,43 @@ jobs:
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"

# Same pre-checkout recovery as the test job: this job lands on the
# same reused pool, so leftover review worktrees and branches from an
# interrupted review would break this checkout too.
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
if [ -d "$GITHUB_WORKSPACE/.qwen" ] && [ ! -L "$GITHUB_WORKSPACE/.qwen" ]; then
chmod -R u+w "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || true
rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || echo "::warning::leaked .qwen; runner needs manual cleanup"
fi
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations for
# directories the rm above removed; worktree remove --force then
# clears the directories that survived the rm (root-owned or
# read-only leftovers), since a branch checked out in a live
# worktree cannot be deleted. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
git -C "$GITHUB_WORKSPACE" worktree prune -v || true
git -C "$GITHUB_WORKSPACE" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
git -C "$GITHUB_WORKSPACE" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
git -C "$GITHUB_WORKSPACE" worktree prune -v || true
git -C "$GITHUB_WORKSPACE" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
git -C "$GITHUB_WORKSPACE" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi

- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
Expand Down Expand Up @@ -815,6 +878,43 @@ jobs:
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"

# Same pre-checkout recovery as the test job: this job lands on the
# same reused pool, so leftover review worktrees and branches from an
# interrupted review would break this checkout too.
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
if [ -d "$GITHUB_WORKSPACE/.qwen" ] && [ ! -L "$GITHUB_WORKSPACE/.qwen" ]; then
chmod -R u+w "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || true
rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen" 2>/dev/null || echo "::warning::leaked .qwen; runner needs manual cleanup"
fi
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations for
# directories the rm above removed; worktree remove --force then
# clears the directories that survived the rm (root-owned or
# read-only leftovers), since a branch checked out in a live
# worktree cannot be deleted. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
git -C "$GITHUB_WORKSPACE" worktree prune -v || true
git -C "$GITHUB_WORKSPACE" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
git -C "$GITHUB_WORKSPACE" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
git -C "$GITHUB_WORKSPACE" worktree prune -v || true
git -C "$GITHUB_WORKSPACE" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
git -C "$GITHUB_WORKSPACE" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi

- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
Expand Down
34 changes: 33 additions & 1 deletion .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ jobs:
if [ -n "$stale_ref" ]; then
git branch -D "$stale_ref" || true
fi
done
done || true
git worktree prune -v || true
echo "stale agent state cleaned"

Expand Down Expand Up @@ -989,6 +989,38 @@ jobs:
--repo "$GITHUB_REPOSITORY" \
--body "$body"

# A cancelled or timed-out review may not reach the CLI's process cleanup.
# Remove both the worktree directories and Git's worktree registrations so
# the next job on this reused runner can delete qwen-review/* branches.
- name: 'Clean review worktrees'
if: 'always()'
timeout-minutes: 5
run: |-
set -uo pipefail
if [ ! -e .git ]; then
echo "no Git checkout; nothing to clean"
exit 0
fi

git worktree prune -v || true
git worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
git worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
rm -rf .qwen/tmp/review-pr-* 2>/dev/null || true
git worktree prune -v || true
git for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r review_ref; do
[ -n "$review_ref" ] || continue
Comment on lines +1035 to +1037

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.

Declined for this round, with thanks for the thorough reproduction. The holder state this targets cannot be produced by this repo's own tooling — all three worktree add sites match the pinned filter, and the finding itself bounds the trigger to another actor with runner-user code execution (the same bounded prerequisite class as the traversal finding). Two concrete costs tipped the call: the retry ladder would add ~32 lines across the four test-pinned byte-identical recipe copies, and its rm -rf "$holder" operates on exactly the untrusted registered path the traversal finding warns about — it would itself need the path guard landed in this round before it could ship. Meanwhile the failure stays visible and recoverable: every affected run emits ::warning::could not remove review branch: … naming the branch, so a one-time manual git worktree remove clears it. The direction we did take narrows the sweep's destructive guarantees (prefix + traversal guard) rather than widening its reach for a state we cannot produce. Happy to revisit with the guard composed in if a maintainer wants the holder recovery regardless.

中文说明

本轮暂不采纳,感谢详尽的复现。该建议针对的 holder 状态无法由本仓库自身工具产生——三处 worktree add 调用点都匹配被钉住的过滤器,且该发现本身将触发条件限定为另一个拥有 runner 用户代码执行权限的参与者(与路径穿越发现同属有界前提类别)。有两个具体成本促成了这一决定:重试阶梯需要在四份被测试钉住逐字节一致的配方副本中各增加约 32 行;且其中的 rm -rf "$holder" 操作的正是路径穿越发现所警告的不受信任注册路径——它自己必须先套上本轮落地的路径守卫才能上线。与此同时,该失败始终可见且可恢复:每次受影响的运行都会输出点名分支的 ::warning::could not remove review branch: …,一次手动 git worktree remove 即可清除。我们选择的方向是收窄清扫的破坏性保证(前缀 + 穿越守卫),而不是为我们无法产生的状态扩大其触及范围。如果维护者仍希望加上 holder 恢复逻辑,把它与守卫组合起来后我们很乐意再议。

git branch -D "$review_ref" ||
echo "::warning::could not remove review branch: $review_ref"
done || true
rm -f .qwen/tmp/qwen-review-lease-pr-*.json 2>/dev/null || true
echo "review worktrees cleaned"

resolve-pr:
needs: ['authorize']
if: |-
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/review/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export const REVIEW_TMP_DIR = join('.qwen', 'tmp');
export const REVIEWS_DIR = join('.qwen', 'reviews');
export const REVIEW_CACHE_DIR = join('.qwen', 'review-cache');

/**
* Filename prefix for review-worktree lease files under `REVIEW_TMP_DIR`.
* Lives here, not in `review-worktree-lease.ts`, because the review
* workflow's cleanup sweep deletes leases by glob — the sweep pattern and
* the lease writer must share one definition (the cleanup spec pins both).
*/
export const LEASE_PREFIX = 'qwen-review-lease-';

/**
* Where the skill tees `qwen review parse-args`'s verdict (SKILL Step 0). A fixed,
* conventional name so a capture command can read back the effort the parser
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/services/review-worktree-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import {
} from 'node:fs';
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { REVIEW_TMP_DIR, reviewBranch } from '../commands/review/lib/paths.js';
import {
LEASE_PREFIX,
REVIEW_TMP_DIR,
reviewBranch,
} from '../commands/review/lib/paths.js';

const LEASE_PREFIX = 'qwen-review-lease-';
const GIT_TIMEOUT_MS = 120_000;
const debugLogger = createDebugLogger('REVIEW_WORKTREE_LEASE');

Expand Down
145 changes: 145 additions & 0 deletions scripts/tests/review-worktree-cleanup-workflow.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
import {
LEASE_PREFIX,
REVIEW_TMP_DIR,
reviewBranch,
worktreePath,
} from '../../packages/cli/src/commands/review/lib/paths.js';

// The cleanup steps in ci.yml and qwen-code-pr-review.yml hard-code the
// review-artifact layout owned by paths.ts: worktreePath()/reviewBranch()
// and LEASE_PREFIX. Derive the expected patterns from that module so
// renaming the layout there fails the build here instead of silently
// no-op-ing the sweeps on the shared runners — a suffix rename already
// broke a sweeper once (see paths.ts).
const probePr = 12345;
const toPosix = (value) => value.replace(/\\/g, '/');
const worktreePrefix = toPosix(worktreePath(probePr)).slice(
0,
-`${probePr}`.length,
);
const branchFamily = toPosix(reviewBranch(probePr)).slice(
0,
-`pr-${probePr}`.length,
);

const ciYaml = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
// Every ci.yml job that checks out on the shared self-hosted pool inherits a
// possibly dirty workspace. Enumerate by pool + checkout instead of job name
// so the next such job fails here instead of on the runners.
const ciCleanSteps = Object.entries(ciYaml.jobs)
.filter(
([, job]) =>
JSON.stringify(job['runs-on']).includes('ubuntu_runner') &&
job.steps.some((s) => String(s.uses ?? '').includes('actions/checkout')),
)
.map(([id, job]) => ({
id,
run: job.steps.find((s) => s.name === 'Clean stale .qwen before checkout')
?.run,
}));
const reviewYaml = parse(
readFileSync('.github/workflows/qwen-code-pr-review.yml', 'utf8'),
);
const reviewCleanStep = reviewYaml.jobs['review-pr'].steps.find(
(s) => s.name === 'Clean review worktrees',
).run;
const agentStateCleanStep = reviewYaml.jobs['review-pr'].steps.find(
(s) => s.name === 'Clean stale agent state',
).run;

// prune (sync registrations) -> force-remove -> prune (drop now-stale
// entries) -> delete branches: a branch checked out in a live worktree
// cannot be deleted, so worktree removal must precede the branch sweep.
function expectCleanupRecipe(run) {
expect(run).toContain(`index($0, "/${worktreePrefix}")`);
expect(run).toContain('worktree remove --force');
expect(run).toContain(`refs/heads/${branchFamily}*`);
// Order assertions below cover the commands only: comments may name the
// recipe pieces out of order when explaining them.
const code = run
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.join('\n');
const remove = code.indexOf('worktree remove --force');
const firstPrune = code.indexOf('worktree prune');
expect(firstPrune).toBeGreaterThan(-1);
expect(firstPrune).toBeLessThan(remove);
expect(code.indexOf('worktree prune', remove)).toBeGreaterThan(remove);
expect(code.indexOf(`refs/heads/${branchFamily}*`)).toBeGreaterThan(remove);
}

const awkAvailable = spawnSync('awk', ['BEGIN { exit 0 }']).status === 0;

describe('review worktree cleanup steps', () => {
it('keeps every shared-pool ci.yml checkout sweep pinned to paths.ts', () => {
expect(ciCleanSteps.map(({ id }) => id)).toEqual(
expect.arrayContaining([
'test',
'web_shell_e2e_smoke',
'integration_cli',
]),
);
for (const { id, run } of ciCleanSteps) {
expect(
run,
`job "${id}" checks out on the shared pool and must clean stale .qwen state first`,
).toBeDefined();
expectCleanupRecipe(run);
}
});

it('keeps the review-job cleanup sweep pinned to paths.ts', () => {
expectCleanupRecipe(reviewCleanStep);
// Fallback for worktree directories Git no longer knows about.
expect(reviewCleanStep).toContain(`rm -rf ${worktreePrefix}*`);
// Leases are session+prompt scoped so a stale one is inert, but the glob
// must stay in sync with LEASE_PREFIX or it silently never matches.
expect(reviewCleanStep).toContain(
`rm -f ${toPosix(REVIEW_TMP_DIR)}/${LEASE_PREFIX}pr-*.json`,
);
});

it('keeps the pre-checkout agent-state sweep pinned to paths.ts', () => {
// Directories are rm -rf'd first there, so no `worktree remove` to pin.
expect(agentStateCleanStep).toContain(`rm -rf ${worktreePrefix}*`);
expect(agentStateCleanStep).toContain(`refs/heads/${branchFamily}*`);
});

it('uses one identical worktree filter at every list-driven sweep', () => {
const filter = reviewCleanStep.match(/awk '([^']+)'/)?.[1];
expect(filter).toBeTruthy();
for (const { id, run } of ciCleanSteps) {
expect(run, id).toContain(`awk '${filter}'`);
}
});

it.skipIf(!awkAvailable)(
'filter selects review worktrees only, never the main checkout',
() => {
const filter = reviewCleanStep.match(/awk '([^']+)'/)?.[1];
const main = '/home/runner/work/qwen-code/qwen-code';
const review = `${main}/.qwen/tmp/review-pr-42`;
const out = spawnSync('awk', [filter], {
input: [
`worktree ${main}`,
`worktree ${review}`,
'branch qwen-review/pr-42',
'',
].join('\n'),
encoding: 'utf8',
});
expect(out.status).toBe(0);
expect(out.stdout.trim()).toBe(review);
},
);
});
Loading