Skip to content

feat(review): add review run — headless review with a machine-readable verdict - #7983

Merged
wenshao merged 9 commits into
QwenLM:mainfrom
wenshao:feat/review-headless-run
Jul 30, 2026
Merged

feat(review): add review run — headless review with a machine-readable verdict#7983
wenshao merged 9 commits into
QwenLM:mainfrom
wenshao:feat/review-headless-run

Conversation

@wenshao

@wenshao wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Part of #7981 (item P2-8, "first-class headless mode").

What

qwen review run [target] — run a full /review non-interactively and get the verdict back as a contract: machine-readable output on stdout, progress on stderr, and exit codes a CI gate can branch on.

qwen review run --effort high --json            # review the local working tree
qwen review run 7724 --fail-on request-changes  # gate CI on a PR review

Why

The review pipeline already runs headless — qwen --prompt "/review …" expands the bundled skill, launches the dimension agents, and honors the approval mode. What that path lacks is a contract:

  • the verdict lives in the model's prose and in artifact files whose names the caller must simply know;
  • the process exit code says nothing about the review's outcome;
  • piped stdin silently defeats slash-command detection (the runner prepends piped input, so the leading / is no longer the first character and the skill never expands).

Every consumer that wants "run a review, tell me what it decided" — benchmarks, CI, cron — has been re-deriving those facts by scraping a terminal.

How

review run is a thin wrapper and nothing more:

  • assembles the /review invocation from typed flags (--effort, --comment), so callers never hand-type prompt strings;
  • re-enters this build's own CLI in a child process (process.execPath + the running entry — the same version-skew rule the skill's subprocesses follow via QWEN_CODE_CLI), with stdin closed so piped input cannot break slash detection;
  • streams the child's progress to stderr (suppress with --quiet); stdout carries only the result (--json for the full object);
  • reads the verdict from the artifact compose-review wrote — the same JSON the skill treats as the verdict authority — never from anything the model printed. Artifact discovery is scoped to this run by an mtime cutoff (with slack for coarse filesystem clocks), so a stale composed JSON from an earlier review can never be republished as this run's outcome;
  • --timeout-minutes (default 120) terminates a wandering run; --approval-mode defaults to yolo because a headless run cannot answer confirmation prompts (anything unapproved would be auto-denied mid-review) — overridable.

Exit codes: 0 = the review completed (whatever it decided); 1 = it never reached a verdict (child failure, timeout, or no composed artifact — a clean child exit without one is a run that wandered off, not an approve); 3 = completed AND --fail-on request-changes AND the event is REQUEST_CHANGES. 3 rather than 2, so "the review is blocking" is distinguishable from yargs usage errors and shell-reserved codes without parsing anything.

Tests

run.test.ts pins the contract: prompt assembly; artifact discovery (stale artifacts ignored, newest-of-this-run wins, missing directory tolerated); the 0/1/3 exit split including "incomplete run is 1 even under --fail-on"; spawn wiring (stdin closed, --prompt passed, approval mode threaded); and the wandered-off case (clean child exit, no composed artifact → failure). 12 tests, all green; eslint --max-warnings 0 clean; tsc clean for the touched files.

中文说明

关联 #7981(P2-8 "一等公民 headless 模式")。

做了什么

qwen review run [target] —— 非交互地执行完整 /review,并以契约形式返回结果:stdout 输出机器可读裁决、stderr 输出进度、退出码可直接供 CI 分支判断。

qwen review run --effort high --json            # 评审本地工作区
qwen review run 7724 --fail-on request-changes  # 以 PR 评审结果做 CI 门禁

为什么

评审流水线本来就能 headless 跑——qwen --prompt "/review …" 会展开 bundled skill、启动各维度 agent、遵循审批模式。缺的是契约:裁决藏在模型的文字叙述和调用方必须"恰好知道"文件名的产物里;进程退出码不反映评审结果;管道 stdin 会静默破坏斜杠命令识别(runner 会把管道输入拼在前面,/ 不再是第一个字符,skill 不会展开)。所有想要"跑一次评审,告诉我结论"的消费者(基准测试、CI、定时任务)都在靠刮终端输出自行还原这些事实。

怎么做

review run 是一层薄包装,仅此而已:

  • 从类型化参数(--effort--comment)组装 /review 调用,调用方无需手拼 prompt;
  • 在子进程里重入当前构建自身的 CLI(process.execPath + 正在运行的入口——与 skill 子进程通过 QWEN_CODE_CLI 遵循的同一条"防版本漂移"规则),并关闭 stdin,管道输入无法破坏斜杠识别;
  • 子进程进度转发到 stderr(--quiet 可静音);stdout 只承载结果(--json 输出完整对象);
  • 裁决读取自 compose-review 落盘的产物——即 skill 视为裁决权威的那份 JSON——绝不读模型打印的内容。产物发现以本次运行的 mtime 为界(对粗粒度文件系统时钟留余量),上一次评审的陈旧 composed JSON 绝不会被当作本次结果重新发布;
  • --timeout-minutes(默认 120)终止跑飞的评审;--approval-mode 默认 yolo(headless 无法应答确认弹窗,未预批准的工具会在评审中途被自动拒绝),可覆盖。

退出码:0 = 评审完成(无论结论);1 = 未达成裁决(子进程失败、超时、或无 composed 产物——子进程干净退出但没有产物属于"跑偏",不是 approve);3 = 完成且 --fail-on request-changes 且事件为 REQUEST_CHANGES。用 3 而非 2,使"评审给出阻断结论"与 yargs 用法错误、shell 保留码可区分,无需解析任何输出。

测试

run.test.ts 固定契约:prompt 组装;产物发现(忽略陈旧产物、取本次最新、目录缺失容忍);0/1/3 退出码划分(含"未完成时即使 --fail-on 命中也返回 1");spawn 接线(stdin 关闭、--prompt 传入、审批模式透传);以及跑偏场景(子进程干净退出但无 composed 产物 → 失败)。12 个测试全绿;eslint --max-warnings 0 干净;涉及文件 tsc 干净。

…ble verdict

The review pipeline already runs non-interactively: `qwen --prompt "/review …"`
expands the bundled skill, launches the dimension agents, and honors the
approval mode. What that path lacks is a contract. The verdict lives in the
model's prose and in files whose names the caller must simply know, the exit
code says nothing about the outcome, and piped stdin silently defeats
slash-command detection (the runner prepends piped input, so the leading `/` is
no longer first). Anyone who wants "run a review, tell me what it decided" ends
up scraping a terminal.

`qwen review run [target]` is that contract and nothing more. It assembles the
/review invocation from typed flags (--effort, --comment), re-enters this
build's own CLI in a child process with stdin closed, streams the child's
progress to stderr, and then reads the verdict from the artifact compose-review
wrote — the same JSON the skill treats as the verdict authority — never from
anything the model printed. stdout carries only the result (human lines, or the
full JSON with --json).

Exit codes make the outcome scriptable without parsing: 0 = the review
completed (whatever it decided), 1 = it never reached a verdict (child failure,
timeout, or no composed artifact — a clean child exit without one is a run that
wandered off, not an approve), 3 = completed AND --fail-on request-changes AND
the event is REQUEST_CHANGES, so a CI gate can tell "blocking verdict" from
"the tool broke".

Artifact discovery is scoped to this run (mtime cutoff with a small slack for
coarse filesystem clocks): a stale composed JSON from an earlier review says
whatever THAT review decided, which is exactly the wrong thing to republish.
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 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 Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR — re-run after 5 autofix rounds.

Template: the body uses its own headings rather than the template's exact ones, but the content is substantively complete — what, why, how, test evidence, and the linked issue (#7981) are all there. Not blocking.

Problem: this is a feature addition (part of #7981 P2-8, "first-class headless mode"), not a bug fix. The gap is real and observable: the review pipeline already runs headless via the prompt flag, but the verdict lives in model prose, the exit code is meaningless, and piped stdin silently breaks slash-command detection. Every CI consumer has been scraping a terminal to recover what the tool already knows. That's a concrete contract gap, not a theoretical concern.

Direction: aligned. Headless review with a machine-readable verdict is squarely within the #7981 roadmap item. CHANGELOG: no direct reference to this command, but the headless/CI direction is well-established.

Size: 1137 additions / 3 deletions across 6 files. No core module paths touched (packages/cli/src/commands/review/ only). Production logic: ~515 lines. Test code: ~616 lines. Not applicable for Stage 0.

Approach: the scope is tight — a thin wrapper that assembles the /review invocation, spawns the CLI with stdin closed, polls for the composed verdict artifact (surviving the Step 9 cleanup sweep), and maps outcomes onto exit codes. Every edit in the diff serves the stated goal; no drive-by refactors or unrelated changes. The docs update is appropriate for a new user-facing command.

Risk: no elevated risk signals — no high-risk paths matched.

Moving on to code review. 🔍

中文说明

感谢贡献——这是经过 5 轮 autofix 后的重新审查。

模板:PR body 使用了自定义标题而非模板的精确标题,但内容实质完整——做了什么、为什么、怎么做、测试证据、关联 issue (#7981) 均有覆盖。不阻断。

问题:这是功能新增(#7981 P2-8 "一等公民 headless 模式"),不是 bug 修复。差距是真实可观测的:评审流水线已经能 headless 运行,但裁决藏在模型文字里、退出码无意义、管道 stdin 会静默破坏斜杠命令识别。所有 CI 消费者都在刮终端输出来还原工具已知的信息。这是具体的契约缺口,不是理论性问题。

方向:对齐。Headless 评审 + 机器可读裁决完全在 #7981 路线图内。

规模:6 个文件,1137 行新增 / 3 行删除。未触及核心模块路径。生产逻辑约 515 行,测试代码约 616 行。Stage 0 不适用。

方案:范围紧凑——薄包装层,组装 /review 调用、关闭 stdin 生成子进程、轮询 composed 产物(绕过 Step 9 清理)、映射退出码。diff 中每个编辑都服务于既定目标,无顺手重构或无关改动。

风险:无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal first: given "run a /review headlessly and return a machine-readable verdict with meaningful exit codes", I would: (1) add a review run subcommand under the existing review parent; (2) build the /review prompt from typed flags, validating the target against re-tokenization injection; (3) spawn the CLI's own entry point with the prompt flag, stdin closed, detached for process-group control; (4) poll for the composed verdict artifact during the child's lifetime (since Step 9 cleanup sweeps it before exit); (5) map outcomes onto 0/1/3 exit codes; (6) handle timeout with group kill and forward parent signals.

Comparison with the diff: the PR's approach matches this proposal almost exactly. No simpler path was missed.

Findings — no critical blockers. Specific observations:

  • Security: buildReviewPrompt correctly rejects targets with whitespace, leading dashes, or quotes — preventing re-tokenization into extra args (e.g. a target carrying a flag would silently authorise a post the run never asked for). The validation is tight and well-tested.
  • Correctness: the capture-poll design (250ms setInterval snapshotting the verdict while the child runs) is the key insight — it survives the Step 9 cleanup race that would otherwise make every completed review read as a failure. The fallback disk scan after close covers children that die before cleanup. The 2-second mtime slack handles coarse filesystem clocks without admitting stale artifacts from earlier reviews (which are minutes old).
  • Process management: detached spawn + negative-pid group kill (POSIX) / taskkill /T /F (Windows) correctly reaches the relaunch wrapper's grandchild. Parent signal forwarding (SIGHUP/SIGINT/SIGTERM with 128+signum exit) prevents orphaned reviews burning API calls. The SIGKILL escalation timer is unref'd so it doesn't keep the parent alive.
  • EPIPE safety: exit code is set before the stdout write, so a broken pipe cannot downgrade a blocking verdict (exit 3) to yargs' generic failure (exit 1).
  • Reuse: uses existing writeStdoutLine/writeStderrLineSafe (stdioHelpers), REVIEW_TMP_DIR/REVIEWS_DIR (lib/paths), EFFORT_LEVELS (parse-args). No new dependencies.
  • Conventions: ESM, no any, kebab-case filenames, collocated tests, vi.hoisted() for mocks — all matching project conventions.
  • Tests: 12+ tests pinning the full contract — prompt assembly, artifact discovery (stale/newest/missing), 0/1/3 exit split, spawn wiring (stdin closed, --expose-gc first, --prompt passed), timeout + SIGKILL escalation, signal forwarding with 128+signum, EPIPE survival, corrupt artifact handling, the cleanup race regression, and the timeout-after-verdict race. Thorough.

No AGENTS.md violations found.

Testing

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
review-pr ✅ success
review-scan ✅ success
Integration Tests (CLI, No Sandbox) ⏭️ skipped

Ubuntu unit suite green. macOS/Windows tests skipped (fork PR CI limitation). Integration tests skipped. No failures.

Sandboxed verification would settle this: @qwen-code /verify — the central claim is that review run correctly captures the composed verdict before Step 9 cleanup sweeps it and maps it onto the 0/1/3 exit contract. The unit tests mock spawn and exercise the logic, but an A/B run against the base build would confirm the end-to-end spawn, poll, verdict, exit path works with a real CLI child process. This is a sponsored run (fork PR): a maintainer's @qwen-code /verify comment approves the head it was written against, and the run carries a pre-execution risk screen and full workspace wipe — read the resulting report with the same skepticism as the fork's CI logs.

中文说明

代码审查

独立方案:给定"headless 运行 /review 并返回机器可读裁决 + 有意义的退出码",我会:(1) 在现有 review 父命令下添加 review run 子命令;(2) 从类型化参数构建 /review prompt,验证 target 防止重分词注入;(3) 以关闭 stdin、detached 方式生成 CLI 自身入口的子进程;(4) 在子进程存活期间轮询 composed 产物(因为 Step 9 清理会在退出前扫除它);(5) 映射 0/1/3 退出码;(6) 超时组杀 + 父信号转发。

与 diff 对比:PR 方案与独立提案几乎完全一致。未发现更简路径。

发现——无关键阻断项:

  • 安全性:buildReviewPrompt 正确拒绝含空白、前导短横线或引号的 target,防止重分词注入。
  • 正确性:capture-poll 设计(250ms 轮询快照裁决)是核心洞察——绕过 Step 9 清理竞态。2 秒 mtime 余量处理粗粒度时钟。
  • 进程管理:detached 生成 + 负 pid 组杀(POSIX)/ taskkill /T /F(Windows)正确到达重启包装器的孙进程。父信号转发防止孤儿评审。
  • EPIPE 安全:退出码在 stdout 写入之前设置。
  • 复用:使用现有 stdioHelpers、lib/paths、parse-args。无新依赖。
  • 规范:ESM、无 any、kebab-case 文件名、同目录测试、vi.hoisted() mock——全部符合项目规范。
  • 测试:12+ 个测试固定完整契约。覆盖全面。

测试

Ubuntu 单元测试绿色。macOS/Windows 跳过(fork PR CI 限制)。集成测试跳过。无失败。

沙盒验证可确认:@qwen-code /verify——核心声明是 review run 在 Step 9 清理前正确捕获 composed 裁决并映射到 0/1/3 退出契约。单元测试 mock 了 spawn,A/B 运行可确认端到端路径。这是赞助运行(fork PR):maintainer 的 @qwen-code /verify 评论批准其写入的 head,运行带有预执行风险筛查和完整工作区清除。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal wrapper that solves a real contract gap; the only reservation is that end-to-end behavior rests on mocked-spawn unit tests rather than a live CLI child.

Stepping back: this PR does exactly what it says — wraps the existing headless review path in a typed contract (stdout result, stderr progress, 0/1/3 exit codes) without adding any new review logic. The implementation is 495 lines of production code, most of which is process lifecycle management (spawn, poll, timeout, signal forwarding) that genuinely needs to exist for the contract to be trustworthy. The test suite is thorough — 12+ tests covering the full contract including the two race conditions (cleanup sweep, timeout-after-verdict) that would silently break a naive implementation.

Going back to my independent proposal: the PR matches it almost exactly. I didn't find a simpler path it missed. The capture-poll design is the non-obvious piece, and it's well-motivated — without it, every completed review would read as a failure because Step 9 cleanup sweeps the verdict before the child exits.

Is every change necessary? Yes — the docs update documents a new user-facing command, the cli.ts/review.ts changes are minimal registration, and the two new files (run.ts, run.test.ts) are the feature itself. No scope creep.

Would I maintain this in six months? Yes — the code is straightforward, the comments explain the non-obvious "why" (version-skew rule, cleanup race, EPIPE safety, process group semantics), and the test suite pins the contract clearly.

The 4/5 (not 5/5) is honest: the unit tests mock spawn, so the end-to-end path (real CLI child, real compose-review, real cleanup, verdict capture) is not exercised by the suite. The /verify lane named in Stage 2 would close that gap. But the logic is sound, the mocking is careful, and the CI suite is green.

Approving. ✅

中文说明

置信度:4/5 —— 干净、最小化的包装层,解决了真实的契约缺口;唯一的保留是端到端行为依赖 mock-spawn 单元测试而非真实 CLI 子进程。

退一步看:这个 PR 完全做到了它声称的——将现有 headless 评审路径包装为类型化契约(stdout 结果、stderr 进度、0/1/3 退出码),没有添加任何新的评审逻辑。实现是 495 行生产代码,大部分是进程生命周期管理(生成、轮询、超时、信号转发),这些对于契约的可信度是真正必要的。测试套件全面——12+ 个测试覆盖完整契约,包括两个竞态条件(清理扫除、超时后裁决)。

与独立提案对比:PR 几乎完全匹配。未发现更简路径。capture-poll 设计是非显而易见的关键部分。

每个改动都是必要的吗?是的——文档更新记录新的用户命令,cli.ts/review.ts 是最小注册,两个新文件是功能本身。无范围蔓延。

六个月后维护?可以——代码直白,注释解释了非显而易见的"为什么",测试套件清晰固定契约。

4/5(而非 5/5)是诚实的:单元测试 mock 了 spawn,端到端路径未被套件执行。Stage 2 中命名的 /verify 通道可弥合该差距。但逻辑合理,mock 仔细,CI 套件绿色。

批准。✅

Qwen Code · qwen3.8-max-preview

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

@wenshao

wenshao commented Jul 29, 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 Jul 29, 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. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment thread packages/cli/src/commands/review/run.ts
Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment on lines +207 to +211
const composedPath = newestArtifactSince(
REVIEW_TMP_DIR,
/^qwen-review-.*composed\.json$/,
cutoffMs,
);

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 artifact discovery regex matches every target's composed verdict file in the shared .qwen/tmp/ directory. Two concurrent review run invocations for different PRs in the same workspace can have their verdicts misattributed — the slower one picks up the faster one's artifact.

Concrete cost: CI pipeline reviewing two PRs in the same checkout — the CI gate records PR #A's verdict against PR #B.

Suggested fix: scope the regex to the current target using safeTarget(args.target ?? 'local').

中文说明

产物发现正则表达式匹配所有目标的 composed 裁决文件。在同一工作区并发运行两个 review run 时,较慢的调用会拾取较快调用的裁决,导致 CI 门禁将 PR #A 的裁决错误地记录到 PR #B。建议将正则表达式限定为当前目标。

— qwen3.7-max via Qwen Code /review

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. The {target} token in the composed filename is chosen by the skill orchestrator, not by this command: a PR review writes qwen-review-pr-<number>-composed.json and a local review writes qwen-review-local-composed.json (see SKILL.md Step 7). That token is not equal to safeTarget(args.target) — for qwen review run 7724 the run command's target is 7724 while the file is qwen-review-pr-7724-composed.json. Scoping the regex to safeTarget(args.target ?? 'local') would therefore never match a PR review's verdict and would silently make completed always false on exactly the path this command exists for.

The misattribution this targets is already mitigated: newestArtifactSince only considers files whose mtime is at or after the run's start (minus a 2s clock-slack), so a previous review's composed JSON is invisible. The residual case is two concurrent runs in the same checkout whose start times fall inside each other's slack window; a correct fix needs the run command and the skill to agree on the filename token first, so it is deferred rather than guessed here.

中文说明

本轮予以拒绝。composed 文件名中的 {target} token 由 skill 编排器决定,而非本命令:PR 评审写 qwen-review-pr-<number>-composed.json,本地评审写 qwen-review-local-composed.json(见 SKILL.md Step 7)。该 token 并不等于 safeTarget(args.target) —— 对 qwen review run 7724,run 命令的 target 是 7724,而文件是 qwen-review-pr-7724-composed.json。因此把正则限定为 safeTarget(args.target ?? 'local') 永远不会匹配到 PR 评审的裁决,会在本命令存在的核心路径上静默地使 completed 恒为 false。

该发现针对的误认问题已被缓解:newestArtifactSince 只考虑 mtime 不早于本次运行起始时间(减去 2 秒时钟余量)的文件,因此上一次评审的 composed JSON 是不可见的。残留场景是同一 checkout 中两个并发运行的起始时间互相落在对方的余量窗口内;正确的修复需要先让 run 命令与 skill 就文件名 token 达成一致,故此处推迟而非猜测。

Comment thread packages/cli/src/commands/review/run.test.ts
Comment thread packages/cli/src/commands/review/run.ts
@qwen-code-dev-bot

qwen-code-dev-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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

中文说明

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

Comment thread packages/cli/src/commands/review.ts
Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment thread packages/cli/src/commands/review.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

中文说明

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

QwenLM#7983)

- Use writeStderrLineSafe in the timeout and spawn-error handlers and guard
  the progress stream, so an EPIPE on stderr can no longer skip the child
  kill, hang the promise, or orphan the review.
- Reject a review target carrying whitespace or a leading dash before it is
  re-tokenized by the child CLI (e.g. `123 --comment` silently authorising
  posting).
- Constrain --approval-mode to the same choices as the top-level CLI.
- Capture the child's exit signal and surface it (OOM/SIGKILL vs spawn fail).
- Sync the top-level `qwen --help` review description with the command.
- Register `run` in the review.test.ts subcommand expectation and add tests
  for the timeout branch, the readComposed guard, and target rejection.
@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 轮)。改动内容与我反驳保留之处如下:

Autofix review round — PR #7983

Addressed all three Critical findings and five of the six Suggestions; declined
one Suggestion with a recorded reason. No base-conflict merge was needed
(--conflict false).

Critical

  • EPIPE before child kill / orphaned review (run.ts timeout, spawn-error,
    and progress handlers) — fixed. The timeout and spawn-error handlers now
    use writeStderrLineSafe, so an EPIPE on stderr can no longer throw past
    child.kill('SIGTERM') (leaving the child review running indefinitely) or
    past resolvePromise(null) (hanging runReview). The progress-stream
    data handlers are wrapped in a writeProgress guard so an EPIPE there no
    longer crashes the parent and orphans the child. writeStderrLine is no
    longer imported.
  • Broken subcommand test (review.test.ts) — fixed. Added 'run' as
    the first entry of the expected subcommand array, matching the builder order
    in review.ts. This was the cause of the red Test (ubuntu-latest, Node 22.x) check; the file now passes.
  • Target token injection (buildReviewPrompt) — fixed. A target
    containing whitespace or a leading dash is now rejected before it is
    interpolated into the /review prompt, so 123 --comment can no longer
    re-tokenize in the child CLI and silently authorise posting. Added a
    regression test.

Suggestions

  • --approval-mode choicesimplemented. Added
    choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'], matching the
    top-level CLI in config.ts, so an invalid value fails fast at this command
    with a clear yargs error.
  • Timeout-branch testimplemented. Added a fake-timer test that fires
    the timeout, asserts completed: false, timedOut: true, exit 1, and that
    the child is killed with SIGTERM — pinning the && !timedOut guard.
  • readComposed guard testimplemented. Added a test where the
    composed file carries a non-string event, asserting it is treated as no
    verdict (completed: false, exit 1).
  • Capture child exit signalimplemented. The close handler now
    captures the signal; RunReviewResult gains childSignal: string | null,
    and the non-JSON failure message appends (killed by <signal>), so an
    OOM/SIGKILL is distinguishable from a spawn failure (pattern matches
    scripts/dev.js).
  • Sync top-level help (cli.ts) — implemented. The TOP_LEVEL_COMMANDS
    description for review <command> now matches the command's own describe,
    so qwen --help and qwen review --help agree and mention run.
  • Scope artifact-discovery regex to the current targetdeclined. The
    {target} token in the composed filename is chosen by the skill orchestrator
    (e.g. pr-<number> for a PR, local for a local review), which is not equal
    to safeTarget(args.target) (e.g. 7724). Scoping the regex to the run
    command's own target would silently make completed always false for PR
    reviews. The existing mtime cutoff in newestArtifactSince already prevents
    stale-artifact misattribution; the residual same-window concurrent-run case is
    narrow and a correct fix must be coordinated with the skill's filename
    convention rather than guessed here.

Verification

  • npm run typecheck — passed
  • npm run build — passed
  • npm run lint (full repo) — passed
  • npx eslint packages/cli/src/commands/review/run.ts …/run.test.ts …/review.test.ts packages/cli/src/cli.ts — passed
  • cd packages/cli && npx vitest run src/commands/review/run.test.ts src/commands/review.test.ts — 20 passed (15 in run.test.ts incl. 3 new, 5 in review.test.ts)
中文说明

Autofix 评审轮次 — PR #7983

已处理全部三个 Critical 发现以及六个 Suggestion 中的五个;对一个 Suggestion 给出记录在案的理由后予以拒绝。无需 base 冲突合并(--conflict false)。

Critical

  • kill 子进程前的 EPIPE / 孤儿 reviewrun.ts 的超时、spawn 错误以及进度处理器)— 已修复。超时与 spawn 错误处理器现改用 writeStderrLineSafe,因此 stderr 上的 EPIPE 不会再越过 child.kill('SIGTERM') 抛异常(导致子 review 无限运行),也不会越过 resolvePromise(null)(导致 runReview 挂起)。进度流的 data 处理器被包进 writeProgress 守卫,此处的 EPIPE 不再使父进程崩溃并孤儿子进程。writeStderrLine 已不再被引入。
  • 被破坏的子命令测试review.test.ts)— 已修复。在期望的子命令数组首项加入 'run',与 review.ts 中 builder 的顺序一致。这正是 Test (ubuntu-latest, Node 22.x) 检查变红的原因;该文件现已通过。
  • target token 注入buildReviewPrompt)— 已修复。含空白或以短横线开头的 target 现在会在被拼入 /review prompt 之前被拒绝,因此 123 --comment 不会再在子 CLI 中被重新切分并悄悄授权发帖。已添加回归测试。

Suggestions

  • --approval-mode 取值约束已实现。添加 choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],与 config.ts 中顶层 CLI 保持一致,使无效值在本命令处即以清晰的 yargs 错误快速失败。
  • 超时分支测试已实现。新增一个假定时器测试:触发超时,断言 completed: falsetimedOut: true、退出码 1,且子进程被以 SIGTERM 杀死 —— 固定住 && !timedOut 守卫。
  • readComposed 守卫测试已实现。新增测试:composed 文件携带非字符串 event 时,断言其被视为无裁决(completed: false、退出码 1)。
  • 捕获子进程退出信号已实现close 处理器现捕获 signal;RunReviewResult 新增 childSignal: string | null,非 JSON 失败信息追加 (killed by <signal>),从而 OOM/SIGKILL 可与启动失败区分(写法与 scripts/dev.js 一致)。
  • 同步顶层帮助cli.ts)— 已实现TOP_LEVEL_COMMANDSreview <command> 的描述现与该命令自身的 describe 一致,使 qwen --helpqwen review --help 保持一致并提及 run
  • 将产物发现正则限定为当前 target已拒绝。composed 文件名中的 {target} token 由 skill 编排器决定(PR 为 pr-<number>、本地评审为 local),并不等于 safeTarget(args.target)(如 7724)。把正则限定到 run 命令自身的 target 会使 PR 评审的 completed 静默地恒为 false。newestArtifactSince 中现有的 mtime 截止已能防止过期产物被误认;残留的同窗口并发场景很窄,且正确的修复必须与 skill 的文件名约定协调,而非在此处猜测。

验证

  • npm run typecheck — 通过
  • npm run build — 通过
  • npm run lint(全仓库)— 通过
  • npx eslint packages/cli/src/commands/review/run.ts …/run.test.ts …/review.test.ts packages/cli/src/cli.ts — 通过
  • cd packages/cli && npx vitest run src/commands/review/run.test.ts src/commands/review.test.ts — 20 通过(run.test.ts 15 个含 3 个新增,review.test.ts 5 个)

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-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/cli/src/commands/review/run.test.ts
Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment thread packages/cli/src/commands/review/run.ts Outdated
@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification report — real build, real CLI, real /review skill

I built this PR locally and drove qwen review run end to end. Setup: a fresh worktree at the PR head (ea935cc3, 2 non-merge commits over main, 5 files, +646/−3), a real npm ci + bundle, isolated HOME, and an OpenAI-compatible mock endpoint so the child review is the real CLI on the real bundled /review skill while the model's turns stay deterministic. Every composed verdict below was written by the real qwen review compose-review, never hand-authored.

Verdict: the contract holds, with one blocking defect in --timeout-minutes.


1. The motivating bug is real, and closing stdin fixes it

The PR claims piped stdin defeats slash detection. It does — measured at the wire, by recording what the model was actually sent:

piped stdin defeats /review

Same build, same prompt, three invocations. With a pipe on stdin the model receives 13,287 characters of prose — literally "please review\n\n\n/review --effort low" — instead of the 200,442-character expanded skill. review run, given the same pipe, still gets 200,560 characters: stdio[0] = 'ignore' is doing exactly what the header comment says.

2. The exit-code contract, end to end

exit code contract

All six cases behave as documented. Worth calling out two:

  • COMMENT + --fail-on request-changes → 0. The gate fires on the event, not on "something was found".
  • A clean child exit with no composed artifact → 1, even under --fail-on. "The tool broke" never reads as an approve. Confirmed with a model that answers "The changes look fine to me." and stops.

Exit 3 survives the whole launch chain (scripts/cli-entry.jsspawnSyncprocess.exit(status)), so a CI if [ $? -eq 3 ] works as advertised.

The republished fields are the real ones — event, verdictLine, baseEvent, cappedBy come out of the artifact compose-review wrote, byte for byte, including a genuinely uncapped REQUEST_CHANGES (produced with a deterministic [build] body Critical, which skips the verification cap).

3. The mtime cutoff is load-bearing — A/B on one build

I seeded a 10-minute-old qwen-review-local-composed.json saying Verdict: Approve, then let this run's model wander off without composing anything.

stale artifact A/B

Neutralising the single line if (mtime < startMs) continue; in dist/chunks/ flips the outcome from exit 1, no verdict to exit 0, "event": "APPROVE" — last week's approval republished as this run's. The guard is the only thing standing between a wandered-off run and a green CI gate.

4. The target guard is load-bearing too

review run <target> result
7724 --effort high <skill-args>7724 --effort high</skill-args>
7724 --effort high --comment <skill-args>7724 --effort high --comment</skill-args>
"123 --comment" refused, exit 1, 0 model requests — the child is never launched
"123 --comment", guard neutralised <skill-args>123 --comment</skill-args> — posting authorised by an argument nobody passed

5. Everything else

  • --json stdout parses as JSON with the child's progress on stderr; without --json, stdout is the verdict line + report path.
  • packages/cli/src/commands/review/run.test.ts: 15 passed. Whole src/commands/review suite: 40 files / 1013 tests passed (--retry=0).
  • eslint --max-warnings 0 clean on the four touched source files; tsc --noEmit -p packages/cli clean.

Blocker: --timeout-minutes neither stops the review nor bounds the command

timeout finding

gemini.tsx always calls relaunchAppInChildProcess() with stdio: 'inherit' ("so we always have a child process that can be internally restarted"). So the process review run spawns is a thin relaunch wrapper; the actual review is its child:

node dist/cli.js review run --timeout-minutes 0.08     <- this command
└─ node dist/cli.js --prompt /review …                 <- relaunch wrapper (the only pid child.kill() reaches)
   └─ node dist/cli.js --prompt /review …              <- the real review

child.kill('SIGTERM') kills the wrapper. The review is reparented to PPID 1 and keeps running — and it still holds the stdout/stderr pipes this command created, so 'close' does not fire either. The setTimeout(() => child.kill('SIGKILL'), 10_000) escalation is a no-op: it re-signals a pid that is already dead.

Measured with --timeout-minutes 0.08 (4.8 s) and a model scripted to answer 12 s late:

  • review run printed timeout … — terminating the review, reported "timedOut": true, "childSignal": "SIGTERM" — and returned after 13.4 s.
  • The "terminated" review went on to receive the model's answer and execute a run_shell_command, which left a timestamped marker on disk after the run had reported itself dead.
  • With a model that never answers at all, the same 4.8 s timeout returned after 484 s.

Both halves of the flag's purpose fail: the wandering run is not stopped (it keeps burning model API calls — exactly what the code comment says must not happen), and review run's own wall clock is unbounded, which is the property a CI job actually depends on.

Two fixes, both verified locally on this build:

  1. Kill the process group (preferred — keeps the relaunch, which carries the --max-old-space-size args):

    const child = spawn(process.execPath, [...], { stdio: ['ignore','pipe','pipe'], detached: true });
    // on timeout:
    process.kill(-child.pid, 'SIGTERM');
    setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 10_000).unref();

    Measured: 'close' fires at 4836 ms for a 4800 ms timeout, no survivors, no post-timeout tool execution.

  2. Suppress the relaunch in the child env — QWEN_CODE_NO_RELAUNCH: 'true' — so there is a single pid for child.kill() to reach. Measured: one process, SIGTERM stops it, no post-timeout tool execution. Simpler, but the child then loses the memory-args relaunch.

A belt-and-braces third option: resolve the outcome promise on 'exit' rather than 'close', so a lingering grandchild can never hold the command open regardless.

Suggestion: artifact discovery is scoped by mtime, but not by target

newestArtifactSince(REVIEW_TMP_DIR, /^qwen-review-.*composed\.json$/, cutoff) accepts any target's artifact. Reproduced: review run 7724 where the run wrote qwen-review-pr-999-composed.json republished that file as 7724's verdict and exited 0.

The mtime cutoff already handles the "last week's review" case; this is the "two reviews sharing one checkout" case (and the "the model composed for the wrong target" case). review run knows its target, so the pattern could be narrowed to it — falling back to the wide pattern only when the target is unknown, since the skill's own suffix (pr-<n> / local / filename) is derived, not passed in.


Nothing else blocking from my side — the design is right and the guards that matter are real. Happy to approve once the timeout actually terminates the review.

中文版

本地验证报告 —— 真实构建、真实 CLI、真实 /review skill

我在本地把这个 PR 完整跑了一遍。环境:PR HEAD(ea935cc3,相对 main 2 个非 merge 提交,5 个文件,+646/−3)的独立 worktree,真实 npm ci + bundle,隔离的 HOME,以及一个 OpenAI 兼容的 mock 端点——子进程评审是真实的 CLI 跑真实的 bundled /review skill,只是模型回合是确定性的。下面所有 composed 裁决都由真实的 qwen review compose-review 写出,没有一处是手写的。

结论:契约成立,但 --timeout-minutes 有一个阻断性缺陷。

1. 动机中的 bug 真实存在,关闭 stdin 确实修好了它

PR 说管道 stdin 会破坏斜杠命令识别。确实如此——直接在网络层记录模型实际收到了什么(截图 1):同一构建、同一 prompt、三种调用方式。stdin 是管道时,模型收到的是 13,287 字符的散文——字面就是 "please review\n\n\n/review --effort low",而不是 200,442 字符的展开 skill。review run 在同样的管道下仍然拿到 200,560 字符:stdio[0] = 'ignore' 正如头部注释所说地在起作用。

2. 退出码契约,端到端

六个用例全部符合文档(截图 2)。两点值得单独说:

  • COMMENT + --fail-on request-changes → 0:门禁看的是 event,不是"有没有发现问题"。
  • 子进程干净退出但没有 composed 产物 → 1,即使带 --fail-on:「工具坏了」永远不会被读成 approve。用一个只回「The changes look fine to me.」就停的模型验证过。

退出码 3 能穿过整条启动链(scripts/cli-entry.jsspawnSyncprocess.exit(status)),CI 里 if [ $? -eq 3 ] 如宣称般可用。

被重新发布的字段是真的:eventverdictLinebaseEventcappedBy 逐字来自 compose-review 落盘的产物,其中包含一个真正未被 cap 的 REQUEST_CHANGES(用确定性的 [build] body Critical 构造,它绕开验证 cap)。

3. mtime 界限是承重的 —— 同一构建上的 A/B

我预置了一个 10 分钟前、写着 Verdict: Approveqwen-review-local-composed.json,然后让本次运行的模型跑偏、什么都不写(截图 3)。

dist/chunks/ 里那一行 if (mtime < startMs) continue; 置空,结果就从 exit 1、无裁决 翻转为 exit 0、"event": "APPROVE"——上周的批准被当成本次结果重新发布。这道防线是「跑偏的运行」和「CI 绿灯」之间唯一的东西。

4. target 校验同样是承重的

review run <target> 结果
7724 --effort high <skill-args>7724 --effort high</skill-args>
7724 --effort high --comment <skill-args>7724 --effort high --comment</skill-args>
"123 --comment" 拒绝,exit 1,0 次模型请求——子进程根本没启动
"123 --comment",校验被置空 <skill-args>123 --comment</skill-args>——没人传过的参数授权了发帖

5. 其他

  • --json 时 stdout 可被 JSON 解析,子进程进度在 stderr;不带 --json 时 stdout 是裁决行 + 报告路径。
  • run.test.ts15 通过;整个 src/commands/review 套件:40 文件 / 1013 测试通过--retry=0)。
  • 四个改动源文件 eslint --max-warnings 0 干净;tsc --noEmit -p packages/cli 干净。

阻断问题:--timeout-minutes 既不终止评审,也不约束命令时长

gemini.tsx 总是stdio: 'inherit' 调用 relaunchAppInChildProcess()(「这样我们总有一个可以被内部重启的子进程」)。于是 review run 拉起的那个进程只是一层重启包装,真正的评审是它的子进程:

node dist/cli.js review run --timeout-minutes 0.08     <- 本命令
└─ node dist/cli.js --prompt /review …                 <- 重启包装(child.kill() 唯一能触到的 pid)
   └─ node dist/cli.js --prompt /review …              <- 真正的评审

child.kill('SIGTERM') 杀掉的是包装层。评审进程被 reparent 到 PPID 1 并继续运行——而且它仍持有本命令创建的 stdout/stderr 管道,所以 'close' 也不会触发。setTimeout(() => child.kill('SIGKILL'), 10_000) 这一层升级是空转:它对着一个已经死掉的 pid 再发一次信号。

--timeout-minutes 0.08(4.8 秒)+ 一个延迟 12 秒才回答的模型实测(截图 4):

  • review run 打印了 timeout … — terminating the review,报告 "timedOut": true, "childSignal": "SIGTERM",然后在 13.4 秒后才返回
  • 那个"已被终止"的评审继续收到了模型回复,并执行了一次 run_shell_command——在本次运行已宣告自己死亡之后,往磁盘上留下了带时间戳的标记文件。
  • 换成永不回答的模型,同样 4.8 秒的超时在 484 秒后才返回。

这个 flag 的两半用途都落空了:跑飞的评审没有被停下(继续烧模型 API 调用——正是代码注释说必须避免的),而 review run 自身的墙钟时间不受约束——恰恰是 CI 任务真正依赖的那个性质。

两种修法,均已在本构建上验证:

  1. 杀进程组(推荐——保留 relaunch,它携带 --max-old-space-size 参数):

    const child = spawn(process.execPath, [...], { stdio: ['ignore','pipe','pipe'], detached: true });
    // 超时时:
    process.kill(-child.pid, 'SIGTERM');
    setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 10_000).unref();

    实测:4800 ms 的超时下 'close'4836 ms 触发,无残留进程,超时后没有工具执行。

  2. 抑制 relaunch:子进程环境里设 QWEN_CODE_NO_RELAUNCH: 'true',这样只有一个 pid 给 child.kill() 触达。实测:单进程,SIGTERM 能停住,超时后没有工具执行。更简单,但子进程会失去 memory-args 重启。

第三个可叠加的保险:把结果 promise 改为在 'exit' 而非 'close' 上 resolve,这样无论如何都不会有孙进程把命令挂住。

建议:产物发现按 mtime 划界,但没有按 target 划界

newestArtifactSince(REVIEW_TMP_DIR, /^qwen-review-.*composed\.json$/, cutoff) 接受任意 target 的产物。已复现:review run 7724 的运行写出了 qwen-review-pr-999-composed.json,结果那份文件被当作 7724 的裁决发布,退出码 0。

mtime 界限已经覆盖了「上周那次评审」的情形;这里是「两次评审共用一个 checkout」以及「模型给错 target 组装了裁决」的情形。review run 知道自己的 target,可以把 pattern 收窄到它——只在 target 未知时退回宽 pattern,因为 skill 内部的后缀(pr-<n> / local / 文件名)是推导出来的、不是传入的。

除此之外我这边没有别的阻断点——设计方向是对的,关键的几道防线都是真的在起作用。等超时真的能终止评审,我就批准。

wenshao pushed a commit that referenced this pull request Jul 29, 2026
…retrying

The review-address push is one-shot: when anything pushes to the PR head
during the agent's ~50-minute window, the final push dies 'fetch first'
and the entire verified agent run is discarded. The per-PR head-write
concurrency group cannot prevent this — it serialises this repo's
workflows, not the PR author or the fork side. Observed twice in one day
(#7983 after a 56-minute run, #7985 after 43 minutes).

On rejection, fetch the moved head, merge it into the local line, and
retry (bounded at 3 attempts). Merge rather than rebase: the agent's own
conflict-resolution rounds create merge commits, and a rebase would
flatten them and can silently re-introduce the conflicts they resolved.
The merge result descends from the remote head, so the retried push is a
fast-forward and rewrites nothing. A genuine content conflict aborts the
merge and falls through to the existing failure path unchanged.

When a salvage merge happened, the round report discloses that the
round's verification predates the merge so mid-run commits get
re-checked by a human.
qwen-code-bot added 2 commits July 29, 2026 10:44
…QwenLM#7983)

The CLI relaunches itself in a child process (for --max-old-space-size),
so child.kill() only reached the relaunch wrapper — the real review was
reparented to PID 1 and kept burning API calls. Spawn with detached:true
and kill the process group (-pid) so the timeout actually terminates the
review.

Also: clamp negative --timeout-minutes to a 1-minute floor, distinguish
a corrupt composed artifact from a missing one in human-readable output,
and add test coverage for the default (non-JSON) output path.
@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 summary (verification repair)

The previous commit (3a32e5a9a) addressed all feedback points but was rejected by deterministic verification due to a TypeScript build error: ReturnType<typeof vi.spyOn> produced a generic MockInstance type incompatible with the specific vi.spyOn(process, 'kill') return type (TS2322).

Fix: replaced the annotation with MockInstance<typeof process.kill> — the project's established convention for typed spies (used throughout acpAgent.test.ts and others) — and added the type MockInstance import from vitest.

Inline: rc:3672648644 — human-readable output path has zero coverage

Decision: Implemented (previous commit, re-verified). The test 'prints the verdict line and report path in human-readable mode' covers the json: false path. Still passing.

Inline: rc:3672648652 — negative --timeout-minutes fires immediately

Decision: Implemented (previous commit, re-verified). The handler clamps with Math.max(1, ...). The test 'clamps a negative timeout to the 1-minute floor' verifies. Still passing.

Inline: rc:3672648675 — corrupt artifact reads as "no composed verdict"

Decision: Implemented (previous commit, re-verified). The failure path distinguishes "found but could not be parsed" from "no composed verdict was produced" using composedPath. The test 'distinguishes a corrupt composed artifact from a missing one' verifies. Still passing.

Issue-level: @wenshao's blocker — timeout must kill the process group

Decision: Fixed (previous commit, re-verified). Spawn uses detached: true; timeout kills via process.kill(-pid, ...). Test 'reports a timed-out run as incomplete and kills the process group' verifies. Still passing.

Issue-level suggestion: artifact discovery scoped by target

Decision: Declined (out of scope, previous round). The artifact suffix is derived inside the skill, not passed to review run. Narrowing the pattern couples run.ts to the skill's naming convention and needs a design decision. The mtime cutoff already prevents the primary failure mode. Better addressed in a follow-up.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npx eslint packages/cli/src/commands/review/run.test.ts --max-warnings 0 — passed (0 warnings)
  • vitest run src/commands/review/run.test.ts (packages/cli) — 18 passed
  • vitest run src/commands/review.test.ts (packages/cli) — 5 passed
中文说明

审查轮次总结(验证修复)

上一个提交(3a32e5a9a)已处理所有反馈点,但被确定性验证拒绝,原因是 TypeScript 构建错误:ReturnType<typeof vi.spyOn> 产生了通用的 MockInstance 类型,与 vi.spyOn(process, 'kill') 的特定返回类型不兼容(TS2322)。

修复方式: 将类型标注替换为 MockInstance<typeof process.kill>——这是项目中类型化 spy 的既定约定(在 acpAgent.test.ts 等处广泛使用)——并从 vitest 添加 type MockInstance 导入。

行内评论:rc:3672648644 — 人类可读输出路径零覆盖

决定:已实现(上一个提交,已重新验证)。 测试 'prints the verdict line and report path in human-readable mode' 覆盖了 json: false 路径。仍然通过。

行内评论:rc:3672648652 — 负的 --timeout-minutes 会立即触发超时

决定:已实现(上一个提交,已重新验证)。 handler 用 Math.max(1, ...) 做下限钳制。测试 'clamps a negative timeout to the 1-minute floor' 验证。仍然通过。

行内评论:rc:3672648675 — 损坏的产物被报告为"未生成裁决"

决定:已实现(上一个提交,已重新验证)。 失败路径利用 composedPath 区分"找到但无法解析"与"未生成 composed 裁决"。测试 'distinguishes a corrupt composed artifact from a missing one' 验证。仍然通过。

Issue 级:@wenshao 的阻断问题 — 超时必须杀掉进程组

决定:已修复(上一个提交,已重新验证)。 spawn 使用 detached: true;超时通过 process.kill(-pid, ...) 杀进程组。测试 'reports a timed-out run as incomplete and kills the process group' 验证。仍然通过。

Issue 级建议:按 target 划界产物发现

决定:拒绝(超出范围,上一轮)。 产物后缀在 skill 内部推导,不传给 review run。收窄 pattern 会让 run.ts 耦合 skill 的命名约定,且需要设计决策。mtime 界限已覆盖主要失败模式。更适合后续 PR 解决。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npx eslint packages/cli/src/commands/review/run.test.ts --max-warnings 0 — 通过(0 警告)
  • vitest run src/commands/review/run.test.ts(packages/cli)— 18 通过
  • vitest run src/commands/review.test.ts(packages/cli)— 5 通过

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-preview

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review: feat(review): add review run — headless review with a machine-readable verdict

Verdict: Request changes — one blocking defect: on the happy path this command reports the opposite of what happened.

The design is right. Reading the verdict from compose-review's artifact rather than the model's prose, closing stdin so slash detection survives, re-entering this build's own CLI, and splitting 0/1/3 so a CI gate never confuses "blocking" with "broken" — all of that is the correct shape, and the hardening commits (target-injection guard, --approval-mode choices synced with the top-level CLI, EPIPE-safe writes, signal capture, corrupt-vs-missing artifact) are real improvements. The problem is the artifact this contract rests on does not exist by the time the parent looks for it.


🔴 Critical — a review that completes reports completed: false and exits 1

packages/cli/src/commands/review/run.ts:258 reads the verdict after the child exits. But SKILL.md Step 9 — a mandatory step of every review — runs qwen review cleanup <target>, and cleanup deletes exactly that file.

cleanup.ts:433-457 removes every .qwen/tmp/ entry matching tmpPrefix(target) = qwen-review-<target>-. The composed verdict is written to .qwen/tmp/qwen-review-{target}-composed.json (SKILL.md Step 7, line 671) — same prefix. So the child composes the verdict, saves the report, sweeps its own temp files, and exits 0; the parent then finds nothing and concludes the run "wandered off".

The failure is inverted in exactly the way that matters: review run exits 0/3 only when the review fails to finish, and 1 whenever it succeeds.

Verified end-to-end with this PR's own run.ts (real handler, real cleanup binary; the only stub is a child that performs a completed review's final three actions — write composed verdict → write report → run Step 9 cleanup → exit 0):

[child] wrote composed verdict + report
Removed temp file: .qwen/tmp/qwen-review-pr-9999-composed.json
[child] Step 9 cleanup exit=0

{ "completed": false, "event": null, "verdictLine": null,
  "composedPath": null,
  "reportPath": ".../.qwen/reviews/2026-07-29-120000-pr-9999.md",
  "childExitCode": 0, "timedOut": false }
process.exitCode = 1        # with --fail-on request-changes, on a REQUEST_CHANGES verdict

Note reportPath is found — .qwen/reviews/ survives cleanup. The run demonstrably reached Step 8, and is still classified "never reached a verdict".

Mutation control — identical child, Step 9 removed, single variable:

{ "completed": true, "event": "REQUEST_CHANGES",
  "verdictLine": "Verdict: Request changes", "composedPath": ".../qwen-review-pr-9999-composed.json" }
process.exitCode = 3

Corroborating field evidence: this repo has zero *-composed.json files in .qwen/tmp/ despite months of dogfooded reviews, while .qwen/reviews/*.md accumulates. The leftovers that do survive (qwen-review-pr-6457-*, pr-6766-*, pr-7855-*) are all from runs that died before Step 9 — and none of them includes a composed artifact.

The unit tests don't catch this because armChild (run.test.ts:186) models a child that writes the composed artifact and never runs cleanup — a child the real pipeline never produces.

Fix options, roughly in order of robustness:

  1. Have review run capture the artifact while the child runs (fs.watch / poll .qwen/tmp/ and copy the first matching composed JSON aside). Survives cleanup, no protocol change.
  2. Have compose-review additionally write a durable copy somewhere cleanup does not sweep (.qwen/reviews/<stamp>-<target>-composed.json), and read that.
  3. Pass an explicit out-path down to the child. Requires threading through the skill, so weakest of the three.

Whichever you pick, please add a regression test whose fake child runs the real cleanup — that is the fixture that would have caught this.


🟠 High — detached: true orphans the review on Ctrl-C and on CI cancellation

run.ts:182 puts the child in its own process group so the timeout kill can reach the relaunch wrapper's grandchild. Correct for the timeout — but nothing forwards the parent's own termination signals, and a terminal's Ctrl-C only signals the foreground process group, which the child is no longer in.

Verified:

pgid(review run)=593630   pgid(review child)=593907    <-- different groups
$ kill -TERM <review run>
review-run alive?    NO
review child alive?  YES — reparented to PID 1
  593907  1  593907  node ... --prompt "/review 9999" --approval-mode yolo

That is the exact failure mode commit 3a32e5a was written to eliminate ("reparented to PID 1 and kept burning API calls"), just reached through Ctrl-C or a cancelled CI job instead of through the timeout. Before detached: true the child shared the group and Ctrl-C reached it; now it cannot.

Fix: install SIGINT/SIGTERM/SIGHUP handlers in the parent that run the same process.kill(-pid, …) escalation before exiting.

🟠 High — the timeout kill is a no-op on Windows

process.kill(-pid, 'SIGTERM') at run.ts:219/:225 is unconditional. Win32 has no POSIX process groups; a negative pid is not a group there, the call fails, and both catch blocks swallow it — so on Windows the command prints terminating the review, terminates nothing, and exits 1 while the review runs on. detached: true also gives the child its own console on win32, so no console-control path reaches it either.

The skill already tells Windows users to run reviews from git-bash, but git-bash is still win32 Node, so the path is live. Please branch on process.platform === 'win32' (taskkill /pid <pid> /T /F) or state the limitation in --timeout-minutes' help text. (Reasoned from the code path — not executed on Windows here.)


🟡 Medium — artifact discovery isn't scoped to the target

/^qwen-review-.*composed\.json$/ (run.ts:261) matches any target. The mtime cutoff scopes to this time window, not this review: a second review run in the same workspace, or a /review the user kicked off in another terminal, produces a composed artifact this run will happily republish as its own verdict — the precise thing the cutoff comment says must never happen. reportPath's /\.md$/ over .qwen/reviews/ has the same hole.

The command knows its target. Scope the pattern to it (qwen-review-<safeTarget(target)>-composed.json, local when no target) and the window narrows from "anything recent" to "this review".

🟡 Medium — --expose-gc is silently dropped for the child

scripts/cli-entry.js:342 spawns [--expose-gc, cliPath, ...args] specifically so memoryPressureMonitor can call global.gc() in its critical tier (memoryPressureMonitor.ts:695-703, which otherwise logs trigger_gc requested but global.gc is not available). run.ts:172 spawns [process.argv[1], …] — i.e. cliPath directly — so the flag is gone.

A full review is the longest, most memory-hungry session this CLI runs, and it's the one session that loses critical-tier cleanup. Add '--expose-gc' ahead of process.argv[1].


🔵 Low / nits

  • --timeout-minutes 0 becomes 120, not 1. Math.max(1, Number(argv['timeout-minutes']) || 120) (run.ts:367) — 0 || 120 short-circuits before the clamp. -5 correctly floors to 1, but 0 silently means "two hours". Use Number.isFinite(n) ? Math.max(1, n) : 120. (Or make 0 mean "no timeout", if that's the intent — either is fine; the current behaviour is neither.)
  • Target help text disagrees with itself. The positional describes target as "a PR number, or omit to review the local working tree", while buildReviewPrompt's rejection message advertises "a single PR number, PR URL, or file path". /review does accept file-path targets; the validation is right, the two strings should agree.
  • No user docs. docs/users/features/code-review.md is the home for this; the exit-code contract currently lives only in the PR body and code comments, and it's the part CI authors need most.

中文摘要

结论:Request changes —— 一个阻断缺陷:正常完成的评审会被报成失败。

🔴 Critical: run.ts:258 在子进程退出之后才去读 composed 产物,但 SKILL.md Step 9(每次评审的必经步骤)会执行 qwen review cleanup <target>,而 cleanup.ts:433qwen-review-<target>- 前缀删除 .qwen/tmp/ 下所有文件 —— 恰好包含 qwen-review-{target}-composed.json。结果是契约反转:评审真正跑完 → 退出 1;评审跑飞 → 才可能退出 0/3

已用本 PR 的真实 run.ts + 真实 cleanup 二进制端到端验证:完整完成的子进程 → completed:falseevent:nullexitCode=1(即使 --fail-on request-changes 且裁决就是 REQUEST_CHANGES);变异对照(仅移除 Step 9)→ completed:trueREQUEST_CHANGESexitCode=3。旁证:本仓库 .qwen/tmp/ 下 composed 产物为 0 个,而 .qwen/reviews/*.md 一直在累积。单测没抓到,是因为 armChild 模拟的子进程从不执行 Step 9。

修法建议:①运行期间就地捕获产物(watch/轮询后复制);②让 compose-review 另写一份 cleanup 不扫的副本;③向下透传显式 out-path。并补一个真的调用 cleanup 的回归测试。

🟠 High ×2: detached: true 让子进程独立进程组,但父进程没有转发 SIGINT/SIGTERM —— 已验证:kill -TERM 父进程后,评审子进程存活并被 PID 1 收养,正是 3a32e5a 想消灭的那个失败态,只是入口换成了 Ctrl-C / CI 取消。另外 process.kill(-pid) 在 Windows 上不是进程组语义,超时终止会静默失效(该路径无平台分支)。

🟡 Medium ×2: 产物发现只按 mtime 不按 target,并发评审会互相冒领裁决;子进程丢了 --expose-gc,内存压力监控的 critical 层 global.gc() 失效。

🔵 Low: --timeout-minutes 0 会变成 120 分钟而非 1 分钟下限;target 帮助文案与校验报错文案不一致;缺 docs/users/features/code-review.md 文档。

设计方向是对的 —— 以 compose-review 产物而非模型叙述为裁决权威、关闭 stdin、重入本构建自身 CLI、0/1/3 划分,几次 hardening 提交也都是实打实的改进。问题只在于:这份契约所依赖的产物,在父进程去读它的时候已经被删掉了。

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

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-preview via Qwen Code /review

Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment thread packages/cli/src/commands/review/run.ts
Comment thread packages/cli/src/commands/review/run.ts
Comment thread packages/cli/src/commands/review/run.ts
Comment thread packages/cli/src/commands/review/run.ts Outdated
Comment thread packages/cli/src/commands/review/run.ts

@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 — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/commands/review/run.test.ts
@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 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed

Addressed all seven inline findings and every point in @wenshao's issue-level review. The centerpiece is the Critical: a review that completed was reported as failed because its verdict artifact was deleted before the parent read it. One commit: aab806e22.

Critical

  • [rc:3674322948] The composed verdict is deleted by the child's Step 9 cleanup before the parent reads it, so a successful review exits 1. Fixed. review run now polls .qwen/tmp/ while the child runs and snapshots the first composed verdict whose mtime is at or after the run start into memory; the post-close read prefers that snapshot and falls back to a disk scan only for a child that died before cleanup ran. No change to the shared skill, compose-review, or cleanup contracts. Added a regression test whose fake child runs the real runCleanup('local') before emitting close — the fixture that was missing. Mutation-checked: disabling the capture poll makes that test fail with completed: false (the exact reported bug); with the fix it passes.
  • [rc:3674322955] No signal handler cleans up the detached child group when the parent is terminated. Fixed. The parent now installs SIGINT/SIGTERM/SIGHUP handlers that kill the child's process group (mirroring the timeout path) before exiting with the conventional code (130/143/129), and removes the listeners once the child closes normally. A cancelled run no longer orphans the review.

Suggestions (all implemented)

  • [rc:3674322962] Added a handler test that emits error (spawn ENOENT) on the fake child and asserts completed === false, exit 1, and the "failed to launch" stderr line.
  • [rc:3674322971] Added a { quiet: false } handler test asserting child progress lands on process.stderr.write and never in the stdout capture.
  • [rc:3674322975] --timeout-minutes 0 now floors to 1 minute instead of silently becoming 120: the default-vs-value decision uses Number.isFinite rather than || 120. Added a 0 test alongside the existing -5 one.
  • [rc:3674322994] process.exitCode is now assigned before the result-output block, and the stdout writes are wrapped in try/catch, so an EPIPE on the result write can no longer downgrade a blocking verdict (exit 3) to yargs' generic exit 1. Added a test that makes process.stdout.write throw and asserts the exit code is preserved.
  • [rc:3674641768] The timeout test now advances fake timers a further 10 s and asserts the SIGKILL escalation (process.kill(-12345, 'SIGKILL')), not just the initial SIGTERM.

Issue-level review (@wenshao)

  • 🔴 Critical (composed verdict deleted) — fixed, same change as rc:3674322948 (option 1: capture during the run, no protocol change).
  • 🟠 High (detached child orphaned on Ctrl-C / CI cancel) — fixed, same change as rc:3674322955.
  • 🟠 High (timeout kill is a no-op on Windows) — fixed. The group kill is centralized in killProcessGroup, which branches on process.platform === 'win32' to taskkill /pid <pid> /T /F (tree kill) and uses process.kill(-pid, …) elsewhere; both the timeout path and the new signal handlers go through it. Added a win32 unit test (mocked platform) asserting the taskkill argv, and a POSIX test asserting the negative-pid kill.
  • 🟡 Medium (artifact discovery isn't scoped to the target)declined. Scoping to the exact target suffix requires replicating the skill's target grammar (PR number → pr-<n>, PR URL → pr-<n>, file path → flattened filename, none → local), which lives in SKILL.md prose and parse-args's classified union; there is no shared raw-target→suffix helper, so a copy here would drift. The capture-during-run fix already confines discovery to this run: it snapshots the first composed verdict at or after the run start (the existing 2 s cutoff excludes prior reviews), closing the stale-artifact case. The residual hole — a concurrent review in the same workspace composing inside this run's window — is outside the CI-gate use case (fresh workspace per job); happy to take it as a follow-up if it proves to matter.
  • 🟡 Medium (--expose-gc dropped for the child) — fixed. The child argv now leads with --expose-gc, matching the relaunch wrapper in cli-entry.js, so the memory-pressure monitor's critical tier keeps global.gc(). Asserted in the spawn-wiring test.
  • 🔵 Low (--timeout-minutes 0 → 120) — fixed, same as rc:3674322975.
  • 🔵 Low (target help text disagrees with itself) — fixed. The positional now reads "a PR number, a PR URL, or a file path; omit to review the local working tree", matching the validation message.
  • 🔵 Low (no user docs) — fixed. Added a "Headless runs (qwen review run)" section to docs/users/features/code-review.md documenting the command and the 0/1/3 exit-code contract.

Review verdict (@qwen-code-ci-bot)

  • CHANGES_REQUESTED — integration tests skipped in CI / not run locally. This records that the CLI integration suite did not run, not a code defect. This round's behavior is exercised by the unit suite (mocked spawn) plus the regression test that drives the real cleanup. Per the workflow's trusted-command policy I ran build, typecheck, lint, and focused Vitest rather than launching the CLI; the deterministic gate re-runs these same commands, and the full integration suite remains CI-gated.

Verification

  • npm run build — passed (after fixing a closure-narrowing type error the build's tsc --build surfaced; npm run typecheck alone had not caught it)
  • npm run typecheck — passed
  • npm run lint — passed (full repo + integration-tests)
  • npx eslint packages/cli/src/commands/review/run.ts run.test.ts — passed
  • npx prettier --write on the three changed files — applied (run.ts rewrapped)
  • vitest run src/commands/review/run.test.ts (touched) — 25 passed
  • vitest run src/commands/review/cleanup.test.ts — 25 passed
  • Mutation check: neutering the capture poll fails the new regression test (completed: false), restoring it passes — the test is load-bearing
中文说明

已处理的评审反馈

处理了全部七条行内发现,以及 @wenshao 在 issue 级评审中的每一点。核心是那个 Critical:一次实际完成的评审被报告为失败,因为其裁决产物在父进程读取之前就被删掉了。单次提交:aab806e22

Critical(阻断)

  • [rc:3674322948] composed 裁决会在父进程读取之前被子进程的 Step 9 cleanup 删除,导致成功的评审 exit 1。 已修复。review run 现在会在子进程运行期间轮询 .qwen/tmp/,并把第一个 mtime 不早于本次运行起始时间的 composed 裁决快照进内存;close 之后的读取优先使用该快照,仅在子进程于 cleanup 之前就已死亡时才回退到磁盘扫描。未改动共享的 skill、compose-reviewcleanup 契约。新增了一个回归测试,其 mock 子进程在 emit close 之前真实执行 runCleanup('local')——正是此前缺失的那个 fixture。已做变异验证:禁用捕获轮询会让该测试以 completed: false(即上报的那个 bug)失败;带上修复后通过。
  • [rc:3674322955] 父进程被终止时,没有任何信号处理器去清理 detached 子进程组。 已修复。父进程现在安装 SIGINT/SIGTERM/SIGHUP 处理器,在以约定退出码(130/143/129)退出之前杀掉子进程组(与超时路径一致),并在子进程正常关闭后移除这些监听器。被取消的运行不会再使评审进程成为孤儿。

Suggestion(建议,全部实现)

  • [rc:3674322962] 新增处理器测试:在 fake child 上 emit errorspawn ENOENT),断言 completed === false、exit 1,以及 stderr 的 "failed to launch" 信息。
  • [rc:3674322971] 新增 { quiet: false } 处理器测试,断言子进程进度落到 process.stderr.write,且从不出现在 stdout 捕获里。
  • [rc:3674322975] --timeout-minutes 0 现在会命中 1 分钟下限,而不是静默变成 120:默认值与取值之分改用 Number.isFinite 判断,而非 || 120。在已有 -5 用例旁新增了 0 用例。
  • [rc:3674322994] process.exitCode 现在在结果输出块之前赋值,且 stdout 写入用 try/catch 包住,因此结果写入时的 EPIPE 不会再把阻断裁决(exit 3)降级为 yargs 的通用 exit 1。新增测试:让 process.stdout.write 抛异常,断言退出码被保留。
  • [rc:3674641768] 超时测试现在会再把假定时器推进 10 秒,并断言 SIGKILL 升级(process.kill(-12345, 'SIGKILL')),而不只是初始的 SIGTERM

issue 级评审(@wenshao

  • 🔴 Critical(composed 裁决被删) —— 已修复,与 rc:3674322948 同一处改动(方案 1:运行期间捕获,不改协议)。
  • 🟠 High(Ctrl-C / CI 取消时 detached 子进程成为孤儿) —— 已修复,与 rc:3674322955 同一处改动。
  • 🟠 High(超时终止在 Windows 上是空操作) —— 已修复。进程组 kill 集中到 killProcessGroup,它在 process.platform === 'win32' 时分支到 taskkill /pid <pid> /T /F(树形 kill),其余平台用 process.kill(-pid, …);超时路径和新增的信号处理器都走它。新增了 win32 单测(mock 平台)断言 taskkill 的 argv,以及一个 POSIX 测试断言负 pid 的 kill。
  • 🟡 Medium(产物发现未按 target 限定范围) —— 拒绝(declined)。 精确限定到 target 后缀需要复刻 skill 的 target 语法(PR 号 → pr-<n>、PR URL → pr-<n>、文件路径 → 拍平的文件名、无 → local),而该语法存在于 SKILL.md 的散文和 parse-args 的分类联合类型里;并没有共享的“原始 target → 后缀”helper,因此在这里复制一份会产生漂移。运行期间捕获的修复已经把发现限定到本次运行:它快照第一个 mtime 不早于运行起始时间的 composed 裁决(既有的 2 秒 cutoff 会排除此前的评审),从而闭合了“读到陈旧产物”这一情形。残余的空隙——同一 workspace 里另一个并发评审在本次运行窗口内 compose 出裁决——属于 CI 门禁场景之外(每个 job 都是全新 workspace);如果它确实重要,乐意作为后续跟进。
  • 🟡 Medium(子进程丢了 --expose-gc —— 已修复。子进程 argv 现在以 --expose-gc 打头,与 cli-entry.js 里的 relaunch wrapper 一致,使内存压力监控的 critical 层保留 global.gc()。已在 spawn 接线测试中断言。
  • 🔵 Low(--timeout-minutes 0 → 120) —— 已修复,与 rc:3674322975 相同。
  • 🔵 Low(target 帮助文案自相矛盾) —— 已修复。positional 现在写作 “a PR number, a PR URL, or a file path; omit to review the local working tree”,与校验报错文案一致。
  • 🔵 Low(缺用户文档) —— 已修复。在 docs/users/features/code-review.md 新增 “Headless runs (qwen review run)” 一节,记录该命令及 0/1/3 退出码契约。

评审核验结论(@qwen-code-ci-bot

  • CHANGES_REQUESTED —— 集成测试在 CI 被跳过 / 本地未运行。 这记录的是 CLI 集成测试套件没有运行,而非代码缺陷。本轮改动的行为由单测套件(mock spawn)加上驱动真实 cleanup 的回归测试覆盖。按照本工作流的可信命令策略,我运行了 build、typecheck、lint 和聚焦的 Vitest,而没有去启动 CLI;确定性门禁会重新运行这些相同命令,完整集成测试套件仍由 CI 把关。

验证(Verification)

  • npm run build —— 通过(在修复了 build 的 tsc --build 暴露出的一个闭包窄化类型错误之后;单独跑 npm run typecheck 并未捕获它)
  • npm run typecheck —— 通过
  • npm run lint —— 通过(整个仓库 + integration-tests)
  • npx eslint packages/cli/src/commands/review/run.ts run.test.ts —— 通过
  • 对三个改动文件执行 npx prettier --write —— 已应用(run.ts 重新折行)
  • vitest run src/commands/review/run.test.ts( touched )—— 25 通过
  • vitest run src/commands/review/cleanup.test.ts —— 25 通过
  • 变异检查:禁用捕获轮询会使新增回归测试失败(completed: false),恢复后通过——该测试是真正起作用的(load-bearing)

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-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not 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-preview via Qwen Code /review

Comment thread packages/cli/src/commands/review/run.ts
Comment thread packages/cli/src/commands/review/run.ts
Comment thread packages/cli/src/commands/review/run.test.ts Outdated
Comment thread packages/cli/src/commands/review/run.ts Outdated
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — PR #7983

All four inline findings are addressed in code (commit 1d55e113d). No base-conflict merge was needed (--conflict false).

Findings

  • [rc:3676234129] Target guard does not reject quote characters — FIXED. Confirmed against tokenizeArgs (packages/cli/src/commands/review/parse-args.ts): it treats both ' and " as quoting delimiters and strips them, so src/it's-a-file.ts re-tokenizes to src/its-a-file.ts, silently re-targeting the review. The guard in buildReviewPrompt now also rejects any target containing a quote (/['"]/.test(args.target)), matching its "single clean token" intent, and the adjacent comment records the reason. Added a buildReviewPrompt test covering both single- and double-quote targets.

  • [rc:3676234115] Result-mapping for five composed fields unguarded — FIXED. The "republishes the composed verdict" test now feeds non-default values (baseEvent: 'REQUEST_CHANGES', cappedBy: ['unreviewed-dimension'], downgraded: true, downgradedFrom: 'Request changes', remediation: ['do x'], with event: 'COMMENT' so the run still exits 0) and asserts all five round-trip through run.ts's composed?.X ?? default mappings. A dropped or hard-coded mapping can no longer pass.

  • [rc:3676234112] Parent-signal forwarding path untested — FIXED. Added a handler test that spies on process.on, asserts all three signals (SIGHUP/SIGINT/SIGTERM) are registered, invokes the registered handler, and asserts the child process group is killed (process.kill(-12345, 'SIGTERM')), each signal maps to its 128+signum exit code (129/130/143), and the timeout timer is cleared (advancing past it does not fire the timeout path). This pins both the registration and the SIGNAL_EXIT_CODES values the finding worried could silently regress.

  • [rc:3676234099] --json omits findings/model/disclosures — RESOLVED via documentation (the finding's offered alternative). Verified in compose-review.ts that the composed artifact emits only event/body/verdictLine/baseEvent/cappedBy/downgraded/downgradedFrom/remediation — it does not emit findings, model, or disclosures as discrete fields, so RunReviewResult cannot republish them without first extending the composed artifact. Extending compose-review is a scope expansion well beyond this focused review run contract PR, so I took the finding's other option: the ComposedVerdict doc-comment now records that these three fields (named by review: hardening and capability gaps surfaced by multi-model dogfooding of /review #7981) are deliberately absent and why, so the gap is documented and tracked against review: hardening and capability gaps surfaced by multi-model dogfooding of /review #7981 rather than silently inherited.

Conflict notes

None — --conflict false; no merge performed.

Verification

  • npx vitest run src/commands/review/run.test.ts (packages/cli) — 27 passed
  • npx vitest run src/commands/review/ (packages/cli) — 1017 passed, 3 skipped
  • npm run typecheck — passed
  • npx eslint packages/cli/src/commands/review/run.ts packages/cli/src/commands/review/run.test.ts — passed (no findings)
  • npm run lint — passed
  • npm run build — passed
  • No settings source changed, so npm run generate:settings-schema was not required. The touched behavior (target guard, signal forwarding, result mapping) is exercised directly by the unit tests above, not only through the bundled CLI, so no integration run was required.
中文说明

Autofix 审查轮次 — PR #7983

四条行内发现均已在代码中处理(提交 1d55e113d)。无需合并 base 冲突(--conflict false)。

发现

  • [rc:3676234129] target 校验未拒绝引号字符 — 已修复。 已对照 tokenizeArgspackages/cli/src/commands/review/parse-args.ts)核实:它把 '" 都当作定界符并剥掉,因此 src/it's-a-file.ts 会被重新切分为 src/its-a-file.ts,静默地重定向评审。buildReviewPrompt 中的校验现在还会拒绝任何包含引号的 target(/['"]/.test(args.target)),与其"单个干净 token"的意图一致,相邻注释也记录了原因。新增了一个 buildReviewPrompt 测试,覆盖单引号和双引号两种 target。

  • [rc:3676234115] 五个 composed 字段的结果映射无人守护 — 已修复。 "republishes the composed verdict" 测试现在填入非默认值(baseEvent: 'REQUEST_CHANGES'cappedBy: ['unreviewed-dimension']downgraded: truedowngradedFrom: 'Request changes'remediation: ['do x'],并将 event 设为 'COMMENT' 使运行仍以 0 退出),并断言这五个字段都能经由 run.tscomposed?.X ?? default 映射往返。删掉或写死任一映射都无法再通过。

  • [rc:3676234112] 父进程信号转发路径无测试 — 已修复。 新增一个处理器测试:spy process.on,断言三种信号(SIGHUP/SIGINT/SIGTERM)均已注册,调用已注册的处理器,并断言子进程组被杀(process.kill(-12345, 'SIGTERM'))、每种信号映射到对应的 128+signum 退出码(129/130/143)、且超时定时器已被清理(推进到超时之后不会触发超时路径)。这同时钉住了注册行为,以及该发现担心可能被静默改错的 SIGNAL_EXIT_CODES 取值。

  • [rc:3676234099] --json 缺少 findings/model/disclosures — 通过文档化处理(即该发现提供的备选方案)。 已在 compose-review.ts 中核实:composed 产物只输出 event/body/verdictLine/baseEvent/cappedBy/downgraded/downgradedFrom/remediation,并未把 findingsmodeldisclosures 作为独立字段输出,因此 RunReviewResult 在不先扩展 composed 产物的情况下无法重新发布它们。扩展 compose-review 是一次远超本 PR(聚焦 review run 契约)的范围扩张,所以我采用了该发现提供的另一个选项:在 ComposedVerdict 的文档注释中记录这三个字段(由 review: hardening and capability gaps surfaced by multi-model dogfooding of /review #7981 点名)是刻意缺省的及其原因,使该缺口被记录并挂到 review: hardening and capability gaps surfaced by multi-model dogfooding of /review #7981 跟进,而不是被静默继承。

冲突说明

无 — --conflict false,未执行合并。

验证

  • npx vitest run src/commands/review/run.test.ts(packages/cli)— 27 通过
  • npx vitest run src/commands/review/(packages/cli)— 1017 通过,3 跳过
  • npm run typecheck — 通过
  • npx eslint packages/cli/src/commands/review/run.ts packages/cli/src/commands/review/run.test.ts — 通过(无发现)
  • npm run lint — 通过
  • npm run build — 通过
  • 未改动任何 settings 源,因此无需运行 npm run generate:settings-schema。所改动的行为(target 校验、信号转发、结果映射)由上述单元测试直接覆盖,并非仅经由打包后的 CLI 行使,因此无需集成测试。

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-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not 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-preview via Qwen Code /review

Comment thread packages/cli/src/commands/review/run.ts Outdated
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review feedback addressed

One inline suggestion was raised this round; it is a genuine correctness defect
against the command's documented contract, so it is implemented.

[rc:3677334987] completed discards a verdict captured before the timeout fires — Implemented

packages/cli/src/commands/review/run.ts computed
completed = composed !== null && !timedOut. When --timeout-minutes fires
after compose-review writes the verdict (Step 6) and the 250 ms capture poll
snapshots it, but before the child exits (Steps 7–9), timedOut flips a
captured verdict to completed: false. That contradicts the contract the option
help states ("Terminate the review after this long without a verdict
(exit 1)") and the exitCodeFor doc ("1 = it never reached a verdict"): the run
did reach a verdict. The concrete costs the reviewer named are real — exit 1
instead of 0 (so a CI gate fails a build the review approved), the
human-readable path prints "Review did not complete: timed out." and suppresses
the verdict line, and the --json payload is self-contradictory
(completed: false beside a populated event).

Change: const completed = composed !== null;. The existing timedOut
field already surfaces "the timer fired" without overriding completion, so a
consumer can still distinguish a killed-after-verdict run
(completed: true, timedOut: true) from a clean one. The exitCodeFor doc
comment is reworded ("timed out with no verdict captured") so the stated
contract matches the new behavior. No other path changes: when no verdict was
captured, composed is still null and the run still reports incomplete /
exit 1.

Regression test added (run.test.ts): "keeps a captured verdict when the
timeout fires after compose-review" writes the composed verdict on spawn, lets
the capture poll snapshot it, fires the timeout, then exits the child — and
asserts completed: true, timedOut: true, event: APPROVE, exit 0. Verified
load-bearing: it fails against the old expression and passes with the fix. The
pre-existing timeout tests (which set up no captured verdict) still assert
completed: false / exit 1 and are unaffected.

Conflict notes

--conflict false; no merge performed.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check (touched files) — passed
  • vitest packages/cli src/commands/review/run.test.ts (touched) — 28 passed
  • Load-bearing check: reverted the one-line fix, the new test failed
    (completed false), restored the fix, 28 passed
中文说明

已处理的评审反馈

本轮收到一条行内建议;它是针对该命令所声明契约的真实正确性缺陷,因此予以实现。

[rc:3677334987] completed 会丢弃超时触发前已捕获的裁决 —— 已实现

packages/cli/src/commands/review/run.ts 原先计算
completed = composed !== null && !timedOut。当 --timeout-minutes
compose-review 已写出裁决(Step 6)、250 ms 捕获轮询已快照到它之后、子进程退出
(Steps 7–9)之前触发时,timedOut 会把一个已捕获的裁决翻转为 completed: false
这与选项帮助所声明的契约(“在该时长后仍无裁决则终止(exit 1)”)以及
exitCodeFor 文档(“1 = 从未达成裁决”)相矛盾:该运行确实达成了裁决。评审者指出的
具体代价是真实的 —— 退出码变成 1 而非 0(于是 CI 门禁会把一个评审实际已批准的构建判为
失败),人类可读路径打印 "Review did not complete: timed out." 并吞掉裁决行,--json
载荷自相矛盾(completed: false 与已填充的 event 并存)。

改动: const completed = composed !== null;。已有的 timedOut 字段本就用于表达
“定时器已触发”,无需覆盖完成状态,因此消费者仍可区分“出裁决后被杀”的运行
completed: true, timedOut: true)与正常完成的运行。exitCodeFor 的文档注释被改写为
“timed out with no verdict captured”,使所声明的契约与新行为一致。其他路径不变:当未捕获
到裁决时,composed 仍为 null,运行仍报告未完成 / 退出码 1。

新增回归测试run.test.ts):“keeps a captured verdict when the timeout fires
after compose-review” 在 spawn 时写出 composed 裁决,让捕获轮询快照到它,触发超时,然后
让子进程退出 —— 并断言 completed: truetimedOut: trueevent: APPROVE、退出码 0。
已验证其有效性:在旧表达式下该测试失败,应用修复后通过。原有的超时测试(未预置已捕获裁决)
仍断言 completed: false / 退出码 1,不受影响。

冲突说明

--conflict false;未执行合并。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx prettier --check(涉及文件)— 通过
  • vitest packages/cli src/commands/review/run.test.ts(涉及包)— 28 通过
  • 有效性检查:回退单行修复后新测试失败(completed 为 false),恢复修复后 28 通过

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-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — no action needed

No new actionable feedback was found for PR #7983 in this round.

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

The only feedback present is in the Deferred non-Critical feedback section (critical-only mode is active after 5 change-producing rounds). Per policy, those items remain open for human follow-up and are not acted on by this bot.

中文说明

Autofix 审查轮次 — 无需操作

本轮未在 PR #7983 中发现新的可操作反馈。

  • 审查意见:
  • 行内评论:
  • Issue 级别评论:
  • 失败的检查:
  • 持续失败的检查:

唯一的反馈位于 已延迟的非 Critical 反馈 部分(经过 5 个产生改动的轮次后,已进入仅处理 Critical 的模式)。按照策略,这些条目保持开放,留待人工跟进,本机器人不会对其执行操作。

Deferred non-Critical feedback

Critical-only mode is active after 5 change-producing rounds. Any items listed below stay open for human follow-up; do not modify code, resolve threads, or reply on their behalf.

中文说明

完成 5 个产生改动的轮次后,进入仅处理 Critical 的模式。以上内容保持开放,留待人工跟进;不要为其修改代码、解决线程或代为回复。

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


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

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

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

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

LGTM — capture-poll design is the key insight, exit code contract is clean (0=completed, 1=no verdict, 3=fail-on match, avoiding yargs/shell collisions). EPIPE resilience, timeout floor, signal forwarding, and Windows taskkill all solid.

Non-blocking: validate effort against EFFORT_LEVELS inside the exported buildReviewPrompt to close the programmatic-caller gap.

@wenshao
wenshao added this pull request to the merge queue Jul 30, 2026
Merged via the queue into QwenLM:main with commit 0a9db38 Jul 30, 2026
61 checks passed
wenshao pushed a commit that referenced this pull request Jul 30, 2026
…retrying

The review-address push is one-shot: when anything pushes to the PR head
during the agent's ~50-minute window, the final push dies 'fetch first'
and the entire verified agent run is discarded. The per-PR head-write
concurrency group cannot prevent this — it serialises this repo's
workflows, not the PR author or the fork side. Observed twice in one day
(#7983 after a 56-minute run, #7985 after 43 minutes).

On rejection, fetch the moved head, merge it into the local line, and
retry (bounded at 3 attempts). Merge rather than rebase: the agent's own
conflict-resolution rounds create merge commits, and a rebase would
flatten them and can silently re-introduce the conflicts they resolved.
The merge result descends from the remote head, so the retried push is a
fast-forward and rewrites nothing. A genuine content conflict aborts the
merge and falls through to the existing failure path unchanged.

When a salvage merge happened, the round report discloses that the
round's verification predates the merge so mid-run commits get
re-checked by a human.
pull Bot pushed a commit to edisplay/qwen-code that referenced this pull request Jul 30, 2026
…retrying (QwenLM#8042)

* fix(autofix): salvage race-lost pushes by merging the moved head and retrying

The review-address push is one-shot: when anything pushes to the PR head
during the agent's ~50-minute window, the final push dies 'fetch first'
and the entire verified agent run is discarded. The per-PR head-write
concurrency group cannot prevent this — it serialises this repo's
workflows, not the PR author or the fork side. Observed twice in one day
(QwenLM#7983 after a 56-minute run, QwenLM#7985 after 43 minutes).

On rejection, fetch the moved head, merge it into the local line, and
retry (bounded at 3 attempts). Merge rather than rebase: the agent's own
conflict-resolution rounds create merge commits, and a rebase would
flatten them and can silently re-introduce the conflicts they resolved.
The merge result descends from the remote head, so the retried push is a
fast-forward and rewrites nothing. A genuine content conflict aborts the
merge and falls through to the existing failure path unchanged.

When a salvage merge happened, the round report discloses that the
round's verification predates the merge so mid-run commits get
re-checked by a human.

* fix(autofix): address salvage-loop review findings

- Gate the PUSH_RACE_MERGED disclosure on HEAD actually advancing: a
  transient push failure (upload timeout, 503) on an unmoved branch
  no-ops the merge ('Already up to date') and must not tell the
  reviewer to re-check mid-run commits that never existed.
- Annotate the salvage fetch failure with ::error:: like the two
  adjacent failure paths, so a deleted fork branch or network error
  does not kill the step with an unannotated exit 128 under bash -e.
- Re-pin the same-repo push URL construction in tests: it lost its old
  'origin "${BRANCH}"' pin in this rework, leaving a ${REPO}→${HEAD_REPO}
  mutation (malformed remote in the same-repo case) unkillable.

* test(autofix): restore dropped mutation-killing pins and add structural assertions (QwenLM#8042)

* test(autofix): pin exit 1 in the give-up guard regex to kill the deletion mutation (QwenLM#8042)

* test(autofix): pin exit 1 in the fetch-failure and merge-conflict salvage paths (QwenLM#8042)

* test(autofix): strengthen salvage-test pins to kill init-value and capture-order mutations (QwenLM#8042)

---------

Co-authored-by: verify <verify@local>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants