fix(acp): sweep review worktree leases at the end of each prompt turn#7694
Conversation
The ACP prompt path never entered promptIdContext — only the TUI (useGeminiStream) and headless (nonInteractiveCli) entry points do — so shell subprocesses in daemon sessions saw an empty QWEN_CODE_PROMPT_ID and `qwen review fetch-pr` silently skipped recording its worktree lease. A cancelled or errored /review in a Web Shell session therefore left .qwen/tmp/review-pr-<n> and the qwen-review/pr-<n> branch behind until the next review of the same PR happened to clean them up. - Bind promptIdContext in #executePromptInner (enterWith, mirroring the sessionIdContext wrapper in #executePrompt) so lease creation works and shell subprocesses can identify the prompt that spawned them. - Sweep the prompt's leases in the turn-wide finally, unconditionally like the headless path: the ACP loop runs whole turns, so unlike the TUI's per-continuation submitQuery this can never fire mid-review. No-op when the review's own cleanup step already released the lease.
|
Thanks for the PR! Template: the body uses custom headings (Problem / Fix / Tests / Not covered) instead of the repo template's (What this PR does / Why it's needed / Reviewer Test Plan / Risk & Scope / Linked Issues). The content is thorough and covers the same ground, so not blocking on this — but worth aligning next time. Problem: observed bug with a clear causal chain. The ACP prompt path never enters Direction: aligned — cleaning up after interrupted operations is basic reliability for the daemon/ACP surface. No CHANGELOG reference needed for a resource-leak fix. Size: not applicable — all changes are in Approach: the scope is tight. Two changes in Session.ts — bind the prompt ID via Moving on to code review. 🔍 中文说明感谢贡献! 模板: PR 正文使用了自定义标题(Problem / Fix / Tests / Not covered),而非仓库模板的标题(What this PR does / Why it's needed / Reviewer Test Plan / Risk & Scope / Linked Issues)。内容充实,覆盖了相同的信息,因此不阻塞——但下次建议对齐模板格式。 问题: 已观测到的 bug,因果链清晰。ACP 的 prompt 路径从未进入 方向: 对齐——中断操作后的清理是 daemon/ACP 层面的基本可靠性保障。 规模: 不适用——所有改动在 方案: 范围紧凑。Session.ts 中两处改动——通过 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code ReviewIndependent proposal: given the root cause (ACP never enters Comparison with the diff: the PR does exactly this. No simpler path missed. Session.ts — Session.ts — Session.worktree.test.ts — Session.review-lease.test.ts: four focused tests (RL1–RL4) covering the binding, the sweep, the error path, and consecutive prompts. Reuses the No correctness bugs, security holes, or regressions found. No AGENTS.md violations — the change is minimal, follows existing patterns, and stays in the right package. CI Test EvidenceFetched via the checks API for commit
The Node test suite on ubuntu was still running after ~5 minutes of polling. All completed checks pass. No failures. Not verified: real-scenario tmux testing (CI run — a maintainer can trigger the isolated 中文说明代码审查独立方案: 根据根因(ACP 从未进入 与 diff 对比: PR 正是这样做的。没有遗漏更简路径。 Session.ts — Session.ts — 测试: 四个聚焦测试(RL1–RL4)覆盖绑定、清理、错误路径和连续 prompt。复用 未发现正确性 bug、安全漏洞或回归。无 AGENTS.md 违规。 CI 测试证据通过 checks API 获取 commit 未验证:真实场景 tmux 测试(CI 运行——如需验证 ACP lease 行为,维护者可触发隔离的 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 5/5 — clean, minimal fix that closes a real resource leak in the ACP path by mirroring the existing TUI/headless patterns exactly. This is the kind of PR that's easy to review and easy to maintain. 25 production lines, two changes that each have a clear analogue in the codebase, and four focused tests. The root cause analysis in the PR description is precise — it traces the exact chain from missing My independent proposal matched the PR's approach exactly — I didn't find a simpler path. The If I had to maintain this in six months, I'd thank the author — the comments explain the why, the tests cover the key paths, and the change doesn't add any new abstraction. 中文说明置信度:5/5 — 干净、最小化的修复,通过精确镜像 TUI/headless 的现有模式,关闭了 ACP 路径中的真实资源泄漏。 这是一个容易审查、容易维护的 PR。25 行生产代码,两处改动各有明确的代码库先例,四个聚焦测试。PR 描述中的根因分析精确——追踪了从缺失 我的独立方案与 PR 完全一致——没有找到更简路径。 — Qwen Code · qwen3.8-max-preview Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
中文说明
— qwen3.7-max via Qwen Code /review
Code Review —
|
doudouOUC
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
doudouOUC
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: You are review agent 1c — Agent 1c: Cross-file tracer., You are review agent 5 — Agent 5: Test coverage., You are review agent 6b — Agent 6b: Undirected audit — ..., You are review agent 7 — Agent 7: Build & test verifica..., You are review agent 1a — Agent 1a: Line-by-line correc..., You are review agent 3 — Agent 3: Code quality., You are review agent 2 — Agent 2: Security., You are review agent 0 — Agent 0: Issue fidelity & root..., You are review agent 6a — Agent 6a: Undirected audit — ... — the agent made no tool call: it read nothing. Not reviewed: Agent 0: Issue fidelity & root-cause ownership — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/0.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 1a: Line-by-line correctness — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/1a.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 2: Security — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/2.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 3: Code quality — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/3.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 5: Test coverage — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/5.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 6a: Undirected audit — attacker mindset — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/6a.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 6b: Undirected audit — 3 AM oncall mindset — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/6b.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 1c: Cross-file tracer — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/1c.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: Agent 7: Build & test verification — never opened its brief (/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7694-fetch-prompts/7.brief.md), so it reviewed without the instructions it was launched to follow. Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries. Not reviewed: verification — the review posts findings, but no verifier was launched with a prompt this skill builds — they were ruled on, if at all, without the verdict bar its brief carries.
— qwen3.7-max via Qwen Code /review
| cleanupReviewWorktreeLeases({ | ||
| sessionId: this.config.getSessionId(), | ||
| promptId, | ||
| repositoryRoot: this.config.getProjectRoot(), | ||
| }); |
There was a problem hiding this comment.
[Critical] The sweep fires unconditionally, but stopAfterPermissionCancel is a resumable stop — the turn returns from inside the try at line ~2970 after #preserveStoppedToolRun deliberately keeps the tool run in history for the user to resume. This finally then deletes .qwen/tmp/review-pr-<n> and git branch -D qwen-review/pr-<n>. When the user says "continue", review agents pinned to working_dir: "<worktreePath>" by SKILL.md Step 3 operate against a deleted directory.
This is exactly why the TUI gates its sweep behind cleanupReviewLease, set only on UserCancelled, loopDetected, and the error path — never on an ordinary tool-loop stop.
Failure scenario: user clicks Reject on a permission prompt during /review → stopAfterPermissionCancel: true → this finally deletes the worktree → user says "continue" → agents fail on missing directory.
| cleanupReviewWorktreeLeases({ | |
| sessionId: this.config.getSessionId(), | |
| promptId, | |
| repositoryRoot: this.config.getProjectRoot(), | |
| }); | |
| if (!toolRun?.stopAfterPermissionCancel) { | |
| cleanupReviewWorktreeLeases({ | |
| sessionId: this.config.getSessionId(), | |
| promptId, | |
| repositoryRoot: this.config.getProjectRoot(), | |
| }); | |
| } |
Secondary concern: the sweep is fully synchronous (execFileSync with 120 s default timeout + recursive rmSync) on the daemon's event loop, blocking every other session and the ACP transport. Consider passing a short gitTimeout (the headless path already uses gitTimeout: 1_000 at nonInteractiveCli.ts:405-407).
— qwen3.7-max via Qwen Code /review
| // The prompt turn's finally sweeps review-worktree leases against the | ||
| // project root (see Session.review-lease.test.ts). | ||
| getProjectRoot: vi.fn().mockReturnValue('/tmp'), |
There was a problem hiding this comment.
[Suggestion] Session.worktree.test.ts, Session.test.ts, and acpAgent.test.ts do not mock ../../services/review-worktree-lease.js — only the new Session.review-lease.test.ts does. Every prompt turn in those suites now runs the real sweep against the mocked project root (/tmp), executing real existsSync/readdirSync, execFileSync('git', …), and recursive rmSync.
Concrete cost: a developer with /tmp/.qwen/tmp/review-pr-<n> on disk gets real git subprocesses and directory deletions from a unit test run.
Suggested fix: add vi.mock('../../services/review-worktree-lease.js') to those three suites as well.
— qwen3.7-max via Qwen Code /review
Problem
Cancelling (or crashing out of) a
/reviewin a Web Shell / daemon session leaves the review worktree.qwen/tmp/review-pr-<n>and theqwen-review/pr-<n>branch behind. They stay on disk until the next review of the same PR runs its stale-state cleanup, or someone runsqwen review cleanup pr-<n>by hand.The root cause is one level below the missing cleanup call: the ACP prompt path never enters
promptIdContext. Only the TUI (useGeminiStream.ts) and headless (nonInteractiveCli.ts) entry points wrap the turn inpromptIdContext.run, so in daemon sessionsgetShellContextEnvVarsemits an emptyQWEN_CODE_PROMPT_ID, andcreateReviewWorktreeLease(called byqwen review fetch-pr) silently no-ops — the lease that interruption cleanup depends on is never recorded in the first place.Fix
Two small changes in
Session.ts, mirroring the existing surfaces:#executePromptInnerright after it is computed, viapromptIdContext.enterWith(promptId)— the analogue of thesessionIdContext.runwrapper in#executePrompt.enterWith(rather thanrun) keeps the ~500-line turn body unnested; the binding dies with the turn's async scope. Shell subprocesses now see the real prompt ID and the lease gets recorded.finally(the one that already emitsConversationFinishedEvent, which runs on every terminal path: end_turn, cancelled, thrown). Unconditional like the headlessfinallyinnonInteractiveCli.ts— the ACP loop runs whole turns, so unlike the TUI's per-continuationsubmitQuerythis can never fire mid-review. When the review completed normally, its own cleanup step already released the lease and the sweep is a no-op.config.getProjectRoot()is the correct lease root even for worktree-isolated sessions:relocateWorkingDirectoryupdatestargetDirwhen the daemon relocates a session into its worktree, matching theprocess.cwd()thatfetch-prrecords.Tests
New
Session.review-lease.test.ts(reuses theSession.worktree.test.tsharness — realSession, no module-level core mock):promptIdContext(the binding shell subprocesses inherit).{sessionId, promptId, repositoryRoot}triple.The
Session.worktree.test.tsmock config gainsgetProjectRoot(the newfinallycalls it on every turn;Session.test.tsandacpAgent.test.tsmocks already had it).Full
src/acp-integrationsuite: 1064/1064 passing.Not covered (intentionally)
promptIdContext; they don't run/review, so kept out of scope.finally; the next same-PR review's stale-state cleanup remains the backstop, as before.中文版
问题
在 Web Shell / daemon 会话中取消(或异常中断)一次
/review,会把 review worktree.qwen/tmp/review-pr-<n>和分支qwen-review/pr-<n>留在磁盘上,直到下次审同一个 PR 时的陈旧状态清理兜底,或者手动执行qwen review cleanup pr-<n>。根因比"少了一处清理调用"更深一层:ACP prompt 通路从未进入
promptIdContext。只有 TUI(useGeminiStream.ts)和 headless(nonInteractiveCli.ts)入口用promptIdContext.run包裹整个 turn,因此 daemon 会话里getShellContextEnvVars注入的QWEN_CODE_PROMPT_ID是空串,qwen review fetch-pr调用的createReviewWorktreeLease静默 no-op —— 中断清理所依赖的 lease 从一开始就没有被记录。修复
Session.ts里两处小改动,与既有通路对齐:#executePromptInner计算出promptId后立即promptIdContext.enterWith(promptId),对应#executePrompt里已有的sessionIdContext.run包裹。用enterWith(而非run)是为了不给约 500 行的 turn 主体增加一层嵌套;绑定随 turn 的 async 作用域消亡。此后 shell 子进程能看到真实的 prompt ID,lease 得以记录。finally(即已发出ConversationFinishedEvent的那个,覆盖 end_turn / cancelled / 抛错所有终止路径)。与 headless 的nonInteractiveCli.ts一样无条件执行 —— ACP 循环一次跑完整个 turn,不像 TUI 的submitQuery按 tool-result continuation 分次调用,因此绝不会在 review 进行中途触发。review 正常完成时其自身的 cleanup 步骤已释放 lease,这里的清扫是 no-op。对 worktree 隔离会话,
config.getProjectRoot()也是正确的 lease 根:daemon 把会话迁入 worktree 时relocateWorkingDirectory会更新targetDir,与fetch-pr记录的process.cwd()一致。测试
新增
Session.review-lease.test.ts(复用Session.worktree.test.ts的 harness —— 真实Session,不做模块级 core mock):promptIdContext中的 prompt ID(即 shell 子进程继承的绑定)。{sessionId, promptId, repositoryRoot}三元组清扫 lease。Session.worktree.test.ts的 mock config 补充了getProjectRoot(新的finally每个 turn 都会调用它;Session.test.ts和acpAgent.test.ts的 mock 原本就有)。src/acp-integration全套:1064/1064 通过。有意不覆盖
promptIdContext;它们不会运行/review,故不在本次范围内。finally依然无法执行;和之前一样,由下次审同一 PR 的陈旧状态清理兜底。