perf(core): Lazy-load first-use dependencies - #7686
Conversation
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
E2E and performance reportFinal commit
The 2C4G acceptance run applies to prototype artifact SHA-256
The remote host had 2 vCPUs, 3.5 GiB RAM, no swap, and Node.js 22.23.1. It had no Git executable, so remote first-use verification covered the The prototype artifact reduced the ACP static closure from 13,405,027 to 12,314,617 bytes and attributed zero static bytes to all three target packages. The final rebased commit retains the zero-byte condition through the production bundle guard; its latency numbers are not being represented as a fresh 30-pair rerun. |
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 4 scenario(s). — Qwen Code · serve A/B |
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Fixed the Ubuntu test failure in ac96c57. The lazy Validation:
|
doudouOUC
left a comment
There was a problem hiding this comment.
Review — perf(core): lazy-load first-use dependencies
Overall this is a clean, well-scoped change. The lazy-loader pattern (module-scoped single-flight promise + CommonJS interop unwrap) is consistent across all three packages, the sync encoding API is preserved via the sync-file-encoding compatibility module, and the bundle guard + metafile-based test make the invariant hard to regress. Static value imports of iconv-lite, @xterm/headless, and simple-git are all gone from the source (only import type remains), and the async read/write GBK paths and loader edge cases are covered by tests. Nice work.
A few non-blocking observations below; none of them block landing.
Correctness / behavior
- The read-side fallback correctly folds a failed
loadIconvLite()into the existing UTF-8-replacement path (with the warn), and the write side rejects rather than corrupting bytes — matches the stated failure contract. - Loader rejections are cached for the process lifetime (
??=on a rejected promise). This is intentional per the design doc (a missing bundled chunk can't recover), so no change requested — just calling it out for reviewers.
Minor / maintainability
- Some inline notes below on duplication and a couple of small error-handling scope changes.
Nit
- On the non-UTF-8 write path,
prepareTextFileContentAsynccallsprepareTextFileContenttwice, so CRLF normalization runs twice before the iconv retry. Negligible (rare path), just noting.
|
|
||
| const debugLogger = createDebugLogger('SYNC_FILE_ENCODING'); | ||
|
|
||
| export function decodeBufferWithEncodingInfo(full: Buffer): FileReadResult { |
There was a problem hiding this comment.
The decode logic here is now a near-verbatim copy of decodeBufferWithEncodingInfoAsync in fileUtils.ts (BOM → valid-UTF-8 → chardet+iconv → UTF-8 fallback). Two copies of the same branching will drift over time — a future fix/edge-case in one won't reach the other.
Given the sync/async split is inherent (dynamic import() is async), full dedup is awkward, but you could at least factor out the shared non-iconv parts (detectBOM/decodeBOMBuffer/bomEncodingToName/isValidUtf8/detectEncodingFromBuffer sequencing) into a helper that takes the resolved iconv module as a parameter, so only the load step differs. Not blocking, but worth a follow-up.
| try { | ||
| const { simpleGit } = await loadSimpleGit(); | ||
| const worktreeGit = simpleGit(worktreePath); | ||
| const base = |
There was a problem hiding this comment.
Behavior change worth confirming is intentional: resolveBaseline() / getCurrentBranch() were previously computed outside the try, so an error there propagated to the caller. They're now inside the try, so such an error is swallowed into the "Error getting diff: …" string return. Same pattern applies to applyWorktreeChanges (baseline/merge-base resolution moved inside the try). This is probably fine / arguably an improvement, but it does change the contract from "throws" to "returns a diagnostic string" for those sub-steps.
| // against the *correct* directory. | ||
| const probe = new GitWorktreeService(this.targetDir); | ||
| const root = (await probe.getRepoTopLevel()) ?? this.targetDir; | ||
| const root = findGitRoot(this.targetDir) ?? this.targetDir; |
There was a problem hiding this comment.
Swapping new GitWorktreeService(...).getRepoTopLevel() (git rev-parse --show-toplevel) for findGitRoot() removes an eager simple-git construction here, which is good. Note the two aren't strictly equivalent: findGitRoot walks up for a .git entry and returns that directory as-is, whereas --show-toplevel returns the real (symlink-resolved) working-tree root. Under a symlinked repo path the resulting .qwen/worktrees directory could differ from before. For the stale-sweep fast-bail this is almost certainly harmless (the fs.access just no-ops on mismatch), but flagging in case symlink-normalization was relied upon.
|
Thanks for the PR — re-running the gate at the current head. Template looks good ✓ Problem: this is a measured, observed problem, not a theoretical one. Issue #7264 (candidate 5) tracks Direction: aligned. Cold-start latency and RSS are things users actually feel, and this is one bounded candidate inside a tracked, gated effort — not a solution looking for a problem. CHANGELOG has no direct reference, but the startup-performance area is clearly relevant. Size: this touches core paths, so the breakdown matters — 647 production logic lines, 290 test lines, 160 docs lines (35 files). Because production logic is ≥500 lines on a core change, this carries a maintainer-awareness flag per our core-module policy. It is a Approach: the scope feels right for the goal. The interesting constraint is Moving on to code review. 🔍 中文说明感谢贡献!在当前 head 上重新跑门禁。 模板完整 ✓ 问题:这是一个已测量、已观测到的问题,而非理论性问题。Issue #7264(候选 5)指出 方向:对齐。冷启动延迟与 RSS 是用户真实能感受到的,且这是一个有边界、属于已跟踪门禁工作的候选,不是"为问题找方案"。CHANGELOG 无直接引用,但启动性能这个方向显然相关。 规模:触及核心路径,因此需要拆分统计——647 行生产逻辑、290 行测试、160 行文档(35 个文件)。由于核心改动的生产逻辑 ≥500 行,按核心模块策略带有"维护者关注"标记。它是 方案:范围对目标而言合理。最关键的约束是 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code reviewIndependent proposal: for this problem I would (1) write a small memoized loader per package (promise Comparison with the diff: the PR does exactly this. No simpler path was missed. Findings — no critical blockers. Two observations:
Everything else is clean: the loaders are uniform and defensive, the abort-during-load race is handled and tested, the sync compat module is proven to agree with the async path (table test), the Files changed (30 of 35 shown)
CI test evidenceAll checks on
Real-scenario testingN/A — no user-visible or TUI changes. The PR is a startup-path optimization; the collaborator's Linux E2E report (GBK encode/decode, PTY shell, Git worktree via the real bundle) covers the behavioral surface. 中文说明代码审查独立方案: 针对此问题,我会 (1) 为每个包写一个小的记忆化加载器(promise 与 diff 对比: PR 完全这样做了。没有遗漏更简单的路径。 发现——无关键阻断项。两个观察:
其余均干净:加载器统一且稳健,加载期间的 abort 竞态已处理并有测试,同步兼容模块被证明与异步路径一致(表格测试), CI 测试证据
真实场景测试不适用——无用户可见或 TUI 变更。PR 是启动路径优化;协作者的 Linux E2E 报告(GBK 编解码、PTY shell、通过真实 bundle 的 Git worktree)覆盖了行为面。 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 3/5 — clean review across every stage, but this is a 647-production-line core change, so the core-module policy caps the bot at a defer and hands the merge call to a maintainer. This is a policy cap, not doubt. Stepping back: this is genuinely good work. The motivation is real and measured — not "this could theoretically be slow" but concrete bundle-closure and 30-pair cold-start numbers against a tracked issue (#7264 candidate 5). The implementation matches my independent proposal exactly: three uniform memoized loaders, a sync compat facade for the one public-API constraint ( The checks I want before trusting a perf PR are all satisfied: the sync/async encoders are proven to agree (table test), the abort-during-load race is handled and tested, the tree-shake plugin is proven load-bearing, and the A/B measurement shows the three packages at zero bytes in the ACP closure with a consistent cold-path latency win (30/30 pairs). A collaborator independently verified all three first-use paths end-to-end on Linux with a real bundle. If I had to maintain this in six months I'd thank the author — the design doc alone makes the intent and failure audit clear. The only reason this isn't a 5/5 approve is scope-by-policy: a core change of this size gets a human's merge sign-off rather than an auto-approval, and that gate is doing exactly what it's supposed to. ⏸️ Deferring to @wenshao — the review is clean; per the core-module policy the merge call is yours rather than an auto-approval. Nothing outstanding from my side. 中文说明置信度:3/5 —— 各阶段审查均干净,但这是一处对核心、647 行生产逻辑的改动,按核心模块策略,机器人封顶为"转交维护者",合并决定权交给维护者。这是策略封顶,而非质疑。 退一步看:这是真正高质量的工作。动机真实且有测量——不是"理论上可能慢",而是针对已跟踪 issue(#7264 候选 5)的具体 bundle 闭包与 30 组冷启动数据。实现与我的独立方案完全一致:三个统一的记忆化加载器、为唯一的公开 API 约束( 信任一个 perf PR 所需的检查全部满足:同步/异步编码器被证明一致(表格测试)、加载期间被 abort 的竞态已处理并有测试、tree-shake 插件被证明真正起作用、A/B 测量显示三个包在 ACP 闭包中为 0 字节且冷路径延迟有稳定收益(30/30 对)。协作者在 Linux 上用真实 bundle 独立验证了所有三个首次使用路径。如果六个月后由我维护,我会感谢作者——光设计文档就把意图与失败审计讲清楚了。 唯一不是 5/5 直接批准的原因是策略上的规模:这种规模的核心改动需要人类的合并签字,而非自动批准,这道门禁正在做它该做的事。 ⏸️ 转交 @wenshao —— 审查干净;按核心模块策略,合并决定权归你,而非自动批准。我这边没有未决项。 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
⏸️ Deferring to maintainers — cc @tanzhenxin @wenshao @yiliang114 @LaZzyMan Re-run at
🔄 转交维护者 —— 已在 — Qwen Code · qwen3.8-max-preview |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— qwen3.7-max via Qwen Code /review
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Addressed the latest automated review at 2208817.
Validation:
|
Review —
|
| Metric | base 2051a4173 |
head 2208817ac |
Δ |
|---|---|---|---|
| ACP static-closure outputs | 145 | 143 | −2 |
| ACP static-closure bytes | 13,214,217 | 12,120,272 | −1,093,945 |
iconv-lite attributed |
552,742 | 0 | −552,742 |
@xterm/headless attributed |
213,120 | 0 | −213,120 |
simple-git attributed |
146,526 | 0 | −146,526 |
node scripts/check-serve-fast-path-bundle.js passes on that real bundle. sync-file-encoding.ts and iconvHelper.ts appear in zero outputs, so the esbuild plugin genuinely performs the tree-shake rather than merely relocating the code; iconv-lite survives only in chunks reachable by dynamic import. The bundle claim reproduces on the rebased base at the reported magnitude (−1.09 MB), so re-running the bundle gate on the final commit is not needed. The 2C4G latency numbers remain tied to the prototype artifact — that's a separate call.
2. Sync/async encoding equivalence. The compat module duplicates ~35 lines of decode logic, so I ran both implementations side by side over 11 decode cases (empty, ASCII, UTF-8 CJK, UTF-8/UTF-16LE/UTF-16BE/UTF-32LE BOM, GBK, Big5, Shift_JIS, invalid bytes) and 9 encode cases (incl. gbk+BOM, utf-16le+BOM, CRLF metadata, unsupported codec). Byte-identical in every case — decodeBufferWithEncodingInfo ≡ decodeBufferWithEncodingInfoAsync, encodeTextFileContent ≡ encodeTextFileContentAsync.
3. Mutation check on the new abort test. Deleting the post-import if (abortSignal.aborted) recheck in shellExecutionService.ts fails exactly the new test (1 failed / 132 passed). Discriminating, not incidentally green.
4. Tests / format. 577 focused Core tests pass (load-*, fileUtils, fileSystemService, read-text-range, shellExecutionService, gitWorktreeService, worktreeCleanup, extensionManager, github); 29 bundle-guard tests pass; Prettier clean across all 34 changed files.
Findings
1 · The esbuild plugin's dist branch can never match the file that holds the import. esbuild.config.js:126 gates on packages/core/(src|dist)/index.(ts|js). Only the src branch ever fires, because packages/cli/tsconfig.json:10 maps @qwen-code/qwen-code-core → ../core/src/index.ts and esbuild honours tsconfig paths — I confirmed packages/core/src/index.ts is the input in the real metafile. If that mapping is ever dropped, resolution falls to package.json exports → dist/index.js, which contains only export * from './src/index.js'; the actual export { … } from './utils/sync-file-encoding.js' lives in dist/src/index.js, which the regex does not match. So the dist half is dead today and would not work if reached. Either widen it to also match dist/src/index.js, or drop it and add a comment that the plugin covers the tsconfig-paths-resolved source path only. The bundle guard fails loudly in that scenario, so this is robustness/maintainability, not a silent regression.
2 · iconvHelper.ts:26 re-export is a re-entry point for the exact regression this PR guards against. export { isUtf8CompatibleEncoding } from './encoding.js'; has zero consumers repo-wide — the only remaining importers of iconvHelper.js are sync-file-encoding.ts and two test files, all for iconvEncode/iconvDecode/iconvEncodingExists. Keeping it means a contributor writing the historically-correct import { isUtf8CompatibleEncoding } from './iconvHelper.js' silently re-establishes a static edge to iconv-lite. Deleting it makes the compiler point them at encoding.js.
3 · Orphaned JSDoc. iconvHelper.ts now ends at line 63 with a doc block (57–63) describing isUtf8CompatibleEncoding, which moved to encoding.ts. Leftover from the extraction.
4 · loadIconvLite is the only loader without a shape guard, and its test covers only one shape. load-simple-git.ts and load-xterm-headless.ts both probe named exports before falling back to default, and each has a test per shape. load-iconv-lite.ts:17 does an unconditional module.default as unknown as IconvLite. I verified this is correct today — Node yields { default } only for iconv-lite, and esbuild's __toESM also sets default — so this is consistency, not a live bug. The asymmetric failure mode is what makes it worth three lines: if default were ever absent, loadIconvLite() resolves to undefined, and decodeBufferWithEncodingInfoAsync's try/catch swallows the resulting TypeError and silently returns UTF-8-replacement mojibake with nothing surfaced. Adding the guard plus the missing named-export test case makes all three loaders uniform.
5 · getWorktreeDiff quietly widened its catch. On base, resolveBaseline() / getCurrentBranch() ran before the try and threw to the caller. They are now inside it (gitWorktreeService.ts:824), so a baseline/branch resolution failure returns the string `Error getting diff: …` as the diff body instead of rejecting. Arguably an improvement, but it's a contract change unrelated to lazy loading and isn't in Risk & Scope. applyWorktreeChanges got the same treatment for its two simpleGit() constructions (much lower impact). If intentional, worth one line in the PR body.
6 · findGitRoot() substitution — agree it's safe, one nuance worth a comment. I agree with your earlier reply: git rev-parse --show-toplevel also returns the linked worktree root, so that case is preserved. Two remaining differences: findGitRoot is purely lexical (path.resolve + existsSync, no symlink resolution), so a repo reached through a symlinked path anchors at the symlinked path rather than the realpath; and it ignores GIT_DIR/GIT_WORK_TREE. Both only change which <root>/.qwen/worktrees the hygiene sweep scans, so the worst case is a no-op sweep — not data loss. Fine as-is, but config.ts:2806 sits under a comment block entirely about having fixed a "sweep was permanently a no-op" bug, so a one-liner noting the lexical-vs-rev-parse difference would help the next reader.
7 · Two unreachable throws. fileSystemService.ts:280 and sync-file-encoding.ts:78 both throw new Error('iconv-lite did not prepare non-UTF-8 text content'). Once iconvLite is supplied, prepareTextFileContent always returns a value — an unsupported codec falls through to the UTF-8 return rather than undefined — so neither can fire. Harmless, but reads like a real failure mode.
8 · The direct unit tests migrated onto the compat path. fileUtils.test.ts:547,558 and fileSystemService.test.ts:212,219 now exercise sync-file-encoding.ts, the copy with zero production callers in this repo. The live async variants keep only indirect coverage via readFileWithEncodingInfo / StandardFileSystemService.writeTextFile, and nothing asserts the two implementations agree. They do agree today (I checked — §2 above), but a small table-driven equivalence test is the cheap way to stop them drifting, since the whole point of the compat module is that it can never be deleted casually.
9 · Maintainer call: the sync compat exports have no in-repo consumers. decodeBufferWithEncodingInfo and encodeTextFileContent are referenced only by packages/core/src/index.ts and tests. Everything that buys them — the esbuild plugin, the widened fileUtils surface (isValidUtf8, decodeBOMBuffer, bomEncodingToName, BOMInfo, UnicodeEncoding), the exported prepareTextFileContent with its undefined sentinel, and the duplicated decode logic — exists purely to keep the published @qwen-code/qwen-code-core surface stable for external consumers. That may well be the right trade, but it is the single largest source of complexity in the diff and deserves an explicit decision rather than being inherited. (Not something to change in this PR.)
Quality, performance, security
- Style/conventions: consistent with the repo — Apache headers on new files,
createDebugLoggertags,??=single-flight matching existing loader idioms, narrow re-export modules type-checked by use (core-runtime.ts's 26 symbols exactly match the 26core.*references inrun-qwen-serve.ts). - Module identity:
core-runtime.ts/deferred-core-runtime.tsre-export from the same bundled Core instance, soinstanceofand class identity are preserved — confirmed via the metafile (singlepackages/core/src/index.tsinput). - Performance: win is real and reproduced above. The added per-call cost is one already-resolved promise
awaiton non-UTF-8 encode/decode, PTY start, and Git operations — negligible against the work that follows.prepareTextFileContentruns twice on the non-UTF-8 write path (once to detect the missing codec, once with it), butneedsCrlfLineEndingsis pure and the extra work is one string normalisation. - Security: no new surface. All three
import()calls take string literals; no dynamic specifier, nocreateRequire, nonode_modulesruntime dependency.allowUnsafeHooksPathusage is unchanged. - Rejection stickiness: the loader promises cache rejections for the process lifetime. Correct for a missing/corrupt bundled chunk (it cannot heal), and documented in the design doc — noting it only so it isn't rediscovered later.
- Windows: still the main unexercised axis, since PTY +
@xterm/headlessdeferral is exactly the Windows-sensitive path. Worth a green Windows CI run before merge, as already noted.
Verdict: the mechanism is sound, the numbers hold on the current base, and the behavioural contracts I probed (encoding equivalence, PTY abort/fallback, Git structured failures) are preserved. Findings 1–4 are the ones I'd fix before merge; they're all a few lines each.
中文说明
在 2208817ac 上审查。我没有直接采信 PR 中的数据,而是重新构建并复测,包括针对当前 PR base(2051a4173)做了一次全新的 bundle A/B —— 这正是此前 triage 留下的开放问题。无阻塞项,以下均为 Low 或提示性意见。
概述
将 iconv-lite、@xterm/headless、simple-git 移出 ACP 子进程的启动期静态导入闭包,通过三个 single-flight 包级 loader 在首次真实使用时各加载一次。同步的公开编码辅助函数通过兼容模块 utils/sync-file-encoding.ts 保留,并由 esbuild sideEffects: false resolver 插件从 Core 根导出中 tree-shake 掉;内部文件服务路径改用 …Async 变体。两个窄化的 CLI runtime 入口模块替代了对 Core 根的延迟 namespace import,check-serve-fast-path-bundle.js 新增这三个包为 ACP 静态闭包禁止项。
我实际执行的验证(macOS 26 arm64,Node 22.23.1)
1. 针对真实 PR base 的 bundle A/B。 对 2051a4173(base)与 2208817ac(head)分别执行 DEV=true node esbuild.config.js,然后从 ACP entry output 出发,仅沿静态边遍历 esbuild metafile:
| 指标 | base 2051a4173 |
head 2208817ac |
Δ |
|---|---|---|---|
| ACP 静态闭包 outputs | 145 | 143 | −2 |
| ACP 静态闭包字节 | 13,214,217 | 12,120,272 | −1,093,945 |
iconv-lite 归因 |
552,742 | 0 | −552,742 |
@xterm/headless 归因 |
213,120 | 0 | −213,120 |
simple-git 归因 |
146,526 | 0 | −146,526 |
node scripts/check-serve-fast-path-bundle.js 在该真实 bundle 上通过。sync-file-encoding.ts 与 iconvHelper.ts 在任何 output 中都不出现,说明 esbuild 插件确实完成了 tree-shake 而非仅仅搬运代码;iconv-lite 只存在于动态 import 可达的 chunk 中。bundle 结论在 rebase 后的 base 上以相同量级复现(−1.09 MB),因此最终提交无需重跑 bundle 门禁。2C4G 的延迟数据仍绑定 prototype artifact —— 那是另一个判断。
2. 同步/异步编码等价性。 兼容模块复制了约 35 行解码逻辑,因此我并排运行了两套实现:11 个解码用例(空、ASCII、UTF-8 中文、UTF-8/UTF-16LE/UTF-16BE/UTF-32LE BOM、GBK、Big5、Shift_JIS、非法字节)与 9 个编码用例(含 gbk+BOM、utf-16le+BOM、CRLF 元数据、不支持的 codec)。全部逐字节一致。
3. 对新增 abort 测试的变异检验。 删除 shellExecutionService.ts 中 import 之后的 if (abortSignal.aborted) 复检后,恰好只有新增的那条测试失败(1 失败 / 132 通过)。说明该测试具备判别力,而非偶然通过。
4. 测试 / 格式。 577 个聚焦 Core 测试通过;29 个 bundle guard 测试通过;34 个改动文件 Prettier 全部干净。
发现
1 · esbuild 插件的 dist 分支永远匹配不到真正含有该 import 的文件。 esbuild.config.js:126 以 packages/core/(src|dist)/index.(ts|js) 作为判定条件。实际只有 src 分支会触发,因为 packages/cli/tsconfig.json:10 将 @qwen-code/qwen-code-core 映射到 ../core/src/index.ts,而 esbuild 会遵循 tsconfig paths —— 我在真实 metafile 中确认输入即为 packages/core/src/index.ts。若该映射被移除,解析会回落到 package.json exports → dist/index.js,而该文件只有 export * from './src/index.js';真正的 export { … } from './utils/sync-file-encoding.js' 位于 dist/src/index.js,正则匹配不到。因此 dist 分支今天是死代码,且即使被走到也不生效。建议要么扩展为同时匹配 dist/src/index.js,要么删除它并注释说明该插件只覆盖 tsconfig-paths 解析出的源码路径。该场景下 bundle guard 会明确失败,所以这属于健壮性/可维护性问题,不是静默回归。
2 · iconvHelper.ts:26 的 re-export 正是本 PR 所防范回归的再入口。 export { isUtf8CompatibleEncoding } from './encoding.js'; 全仓零消费者 —— iconvHelper.js 仅剩 sync-file-encoding.ts 与两个测试文件引用,且都是为了 iconvEncode/iconvDecode/iconvEncodingExists。保留它意味着后续贡献者按历史习惯写 import { isUtf8CompatibleEncoding } from './iconvHelper.js' 会静默重建到 iconv-lite 的静态边。删除后编译器会把他们导向 encoding.js。
3 · 悬空 JSDoc。 iconvHelper.ts 现在停在第 63 行,末尾(57–63)是描述已迁往 encoding.ts 的 isUtf8CompatibleEncoding 的文档块,属提取遗留。
4 · loadIconvLite 是唯一没有形状守卫的 loader,其测试也只覆盖一种形状。 load-simple-git.ts 与 load-xterm-headless.ts 都会先探测具名导出再回退 default,且每种形状各有测试。load-iconv-lite.ts:17 则无条件 module.default as unknown as IconvLite。我验证过当前是正确的 —— Node 对 iconv-lite 只给出 { default },esbuild 的 __toESM 同样会设置 default —— 所以这是一致性问题而非现存 bug。值得花三行的原因在于其失败模式不对称:若 default 缺失,loadIconvLite() 解析为 undefined,而 decodeBufferWithEncodingInfoAsync 的 try/catch 会吞掉由此产生的 TypeError,静默返回 UTF-8 替换字符的乱码且不向上暴露任何信息。补上守卫与缺失的具名导出测试用例,可让三个 loader 保持一致。
5 · getWorktreeDiff 悄悄扩大了 catch 范围。 base 上 resolveBaseline() / getCurrentBranch() 在 try 之前执行并向调用方抛出。现在它们被移入 try(gitWorktreeService.ts:824),因此 baseline/分支解析失败会把 `Error getting diff: …` 字符串作为 diff 内容返回而非 reject。这也许是改进,但属于与 lazy load 无关的契约变化,且未列入 Risk & Scope。applyWorktreeChanges 的两处 simpleGit() 构造也做了同样处理(影响小得多)。若为有意为之,建议在 PR 描述中补一行。
6 · findGitRoot() 替换 —— 认同其安全性,但有一点值得加注释。 同意你此前的回复:git rev-parse --show-toplevel 在 linked worktree 中同样返回该 worktree 根,因此该场景行为保持不变。仍存在两点差异:findGitRoot 是纯词法的(path.resolve + existsSync,不解析符号链接),所以通过符号链接路径进入的仓库会锚定在符号链接路径而非真实路径;且它忽略 GIT_DIR/GIT_WORK_TREE。二者只影响清理任务扫描哪个 <root>/.qwen/worktrees,最坏情况是 sweep 变成 no-op,不会丢数据。维持现状即可,但 config.ts:2806 所处的注释块通篇都在讲修复"sweep 永久 no-op"的 bug,加一行说明词法遍历与 rev-parse 的差异会对后来者更友好。
7 · 两处不可达的 throw。 fileSystemService.ts:280 与 sync-file-encoding.ts:78 都 throw new Error('iconv-lite did not prepare non-UTF-8 text content')。一旦传入 iconvLite,prepareTextFileContent 必然返回值 —— 不支持的 codec 会落到 UTF-8 返回分支而非 undefined —— 因此两处都不可能触发。无害,但读起来像是真实故障模式。
8 · 直接单测被迁移到了兼容路径上。 fileUtils.test.ts:547,558 与 fileSystemService.test.ts:212,219 现在测的是 sync-file-encoding.ts,即本仓库中零生产调用方的那份副本。真正在用的 async 变体仅保留经由 readFileWithEncodingInfo / StandardFileSystemService.writeTextFile 的间接覆盖,且没有任何断言保证两套实现一致。它们今天确实一致(见上文 §2 我的验证),但补一个表驱动的等价性测试是防止二者漂移的低成本手段 —— 毕竟兼容模块的意义就在于它不能被随手删除。
9 · 需维护者决策:同步兼容导出在仓库内没有消费者。 decodeBufferWithEncodingInfo 与 encodeTextFileContent 仅被 packages/core/src/index.ts 和测试引用。为它们付出的全部代价 —— esbuild 插件、被放宽的 fileUtils 导出面(isValidUtf8、decodeBOMBuffer、bomEncodingToName、BOMInfo、UnicodeEncoding)、导出的带 undefined 哨兵值的 prepareTextFileContent、以及重复的解码逻辑 —— 完全是为了让已发布的 @qwen-code/qwen-code-core 对外部消费者保持稳定。这可能确实是正确取舍,但它是本 diff 中最大的复杂度来源,应当被显式决策而非默认继承。(不建议在本 PR 中改动。)
质量、性能与安全
- 风格/约定: 与仓库一致 —— 新文件带 Apache 头、
createDebugLoggertag、??=single-flight 沿用既有 loader 惯例、窄化 re-export 模块由使用处做类型校验(core-runtime.ts的 26 个符号与run-qwen-serve.ts中 26 处core.*引用完全对应)。 - 模块同一性:
core-runtime.ts/deferred-core-runtime.ts从同一个被 bundle 的 Core 实例 re-export,instanceof与类同一性得以保持 —— 已通过 metafile 确认(唯一packages/core/src/index.ts输入)。 - 性能: 收益真实且已复现。新增的单次调用成本是在非 UTF-8 编解码、PTY 启动与 Git 操作上多
await一个已 resolve 的 promise,相对其后的工作可忽略。非 UTF-8 写入路径会执行两次prepareTextFileContent(一次探测缺失 codec,一次带上它),但needsCrlfLineEndings是纯函数,额外开销仅一次字符串归一化。 - 安全: 无新增攻击面。三处
import()均为字符串字面量;无动态 specifier、无createRequire、无运行期node_modules依赖。allowUnsafeHooksPath用法未变。 - rejection 粘性: loader promise 会在进程生命周期内缓存 rejection。对于缺失/损坏的 bundled chunk 这是正确的(无法自愈),设计文档也已说明 —— 此处只是记录,避免日后被重新"发现"。
- Windows: 仍是主要未验证维度,因为 PTY +
@xterm/headless延迟加载恰是 Windows 敏感路径。如已指出,合并前值得跑一次绿色的 Windows CI。
结论: 机制可靠,数据在当前 base 上成立,我探测过的行为契约(编码等价性、PTY abort/fallback、Git 结构化失败返回)均得以保持。发现 1–4 是我建议合并前修掉的,每项都只需几行。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
中文说明
未发现问题。LGTM!✅
— qwen3.7-max via Qwen Code /review
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Thanks @wenshao — I checked each point against the current head and pushed Addressed before merge:
Documentation/disposition:
Validation passed: full workspace typecheck, 219 focused Core tests, 29 bundle-guard tests, targeted Prettier/ESLint, the CLI-only production build and bundle, and the real startup bundle closure guard. The review was top-level rather than inline, so there were no new review threads to resolve (0/0). |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— qwen3.7-max via Qwen Code /review
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Addressed the latest automated review in
Validation passed: full workspace typecheck, 139 focused Core tests, the CLI-only production build and bundle, and the real startup bundle closure guard. |
|
Latest review disposition (no code change):
Both inline threads contain the exact rationale. Per the repository's roughly-five-round rule, further changes on this PR are limited to Critical correctness, security, data-loss, or regression fixes. |
Re-verification at
|
| # | Finding at 2208817ac |
Sev | Status at fc99f5975 |
|---|---|---|---|
| 1 | esbuild plugin's dist branch could never match the file holding the import |
Low | Fixed in 769ceac29 — and I verified the new branch is genuinely load-bearing, not cosmetic (below) |
| 2 | iconvHelper.ts re-export was a re-entry point for the exact regression this PR guards |
Low | Fixed — re-export removed; isUtf8CompatibleEncoding now only reachable via encoding.js |
| 3 | Orphaned JSDoc left by the extraction | Nit | Fixed — file now ends at the correct iconvEncodingExists doc |
| 4 | loadIconvLite was the only loader without a shape guard |
Low | Fixed, and gone further than I asked: fc99f5975 gives all three loaders a shape predicate + invalid-module test |
| 5 | getWorktreeDiff quietly widened its catch, not in Risk & Scope |
Low | Documented — Risk & Scope now discloses that lazy Git setup happens inside the existing diff/apply failure envelopes |
| 6 | findGitRoot() lexical-vs-rev-parse nuance |
Info | Declined with rationale (intentional best-effort sync probe). I agree — worst case is a no-op sweep |
| 7 | Two unreachable throws |
Nit | Declined (kept for TS narrowing). Stands, harmless — I found a third dead branch of the same family, see N1 |
| 8 | Nothing asserted the sync and async encoders agree | Low | Fixed — new 9-case table test; I mutation-checked it and it discriminates |
| 9 | Sync compat exports have no in-repo consumers | Maintainer call | Documented as an explicit external-consumer compatibility tradeoff |
1 · Bundle A/B re-measured on the new head
Rebuilt both sides and walked the esbuild metafile from the ACP entry output over static-only edges.
| Metric | base 2051a4173 |
head fc99f5975 |
Δ |
|---|---|---|---|
| ACP static-closure outputs | 145 | 143 | −2 |
| ACP static-closure bytes | 13,214,217 | 12,121,575 | −1,092,642 |
iconv-lite attributed |
552,742 | 0 | −552,742 |
@xterm/headless attributed |
213,120 | 0 | −213,120 |
simple-git attributed |
146,526 | 0 | −146,526 |
node scripts/check-serve-fast-path-bundle.js passes on the real bundle. The closure is 1,303 bytes larger than at 2208817ac — that is the three new shape predicates themselves, which live in the static closure while the packages they load do not. Expected and correct.
2 · Finding 1's fix is load-bearing, not cosmetic
The change is one regex alternation ((?:src|dist) → (?:src|dist[\\/]src)), so it's worth showing it actually does something. I removed the @qwen-code/qwen-code-core mapping from packages/cli/tsconfig.json paths, which forces esbuild to resolve Core through package.json exports → dist/index.js → dist/src/index.js — precisely the scenario the dist branch exists for (confirmed: packages/core/dist/index.js becomes a bundle input in both arms).
| Arm | sync-file-encoding in bundle |
iconv-lite in ACP closure |
guard |
|---|---|---|---|
| A — new regex | tree-shaken, absent | 0 | exit 0 ✓ |
| B — old regex | core/dist/src/utils/sync-file-encoding.js pulled in |
552,724 | exit 1 ✗ |
Arm B's guard names the offending edge: dist/chunks/acpAgent-XZ6BC7W7.js -> dist/chunks/chunk-34T4CTM7.js. So the one-line change is what keeps the tree-shake working if that tsconfig mapping is ever dropped — and if it somehow still failed, the guard fails loudly rather than silently regressing.
3 · The new shape guards, checked against the real modules
fc99f5975's guards are only tested with vi.doMock. The risk that introduces is the opposite of the one I raised: a predicate that is too strict would throw at first real use — in production, in a shipped bundle, on a path no unit test covers. So I checked the real shapes three ways.
- Real modules through the PR's own compiled loaders (no mocks): 14/14, including a real GBK decode + byte-identical round-trip, a real
Terminalconstructing and replaying output, and realcheckIsRepo/revparse/statuscalls. - Same harness through esbuild's
__toESMinterop: 14/14. - The predicates applied to the actual shipped dynamic chunks — the decisive one:
| Package | Shipped chunk | predicate(namespace) |
Branch taken | Verdict |
|---|---|---|---|---|
iconv-lite |
lib-GFEPFPNS.js |
false | .default |
accepted |
@xterm/headless |
xterm-headless-V6AWZL7D.js |
false | .default |
accepted |
simple-git |
esm-IIIWUUC5.js |
true | namespace | accepted |
Two of the three genuinely need the .default fallback, so the branch isn't dead weight. The compiled loaders in dist/chunks reference ./lib-*.js, ./xterm-headless-*.js and ./esm-*.js as real dynamic chunk imports, which is the deferral itself showing up in the shipped artifact.
4 · Mutation matrix — the new tests discriminate
Every new test earns its place: reverting each loader's guard to its pre-fc99f5975 implementation fails that loader's new test, and both sync-decoder mutations fail the new equivalence table.
| Mutation | Result | |
|---|---|---|
M1 revert iconv-lite shape guard |
2 failed / 1 passed | killed |
M2 revert simple-git shape guard |
1 failed / 2 passed | killed |
M3 revert @xterm/headless shape guard |
1 failed / 2 passed | killed |
| MD1 sync decoder drops the BOM flag | 1 failed / 8 passed | killed |
| MD2 sync decoder skips the iconv decode | 1 failed / 8 passed | killed |
ME1 sync encoder ignores prepared.encoding |
9 passed | survived |
ME1 survived, so I probed whether it was a coverage gap. It isn't — it's an equivalent mutant. Whenever prepared.data is a string, prepared.encoding is always "utf-8"; every non-UTF-8 case returns a Buffer and takes the early return on the line above. The mutation is unobservable by construction.
Two small new observations (neither blocking)
N1 · sync-file-encoding.ts:81's ?? 'utf-8' fallback is dead. Fallout of the ME1 probe above — the nullish branch can never be taken, for the reason given. Same family as finding 7; mentioning it only so the probe table isn't misread as a gap.
N2 · Where the new guard throws land (informational, and it's an improvement). I traced all three. loadXtermHeadless() is awaited inside the existing PTY try (shellExecutionService.ts:715), so a guard rejection keeps the documented child_process fallback. On the read path, loadIconvLite() sits inside the existing try/catch (fileUtils.ts:216-229), so a rejection is logged via debugLogger.warn and degrades to UTF-8 replacement — still user-invisible unless debug logging is on, but that's pre-existing, and it's now a named error instead of the opaque TypeError the old unconditional module.default cast would have produced. On the write path (fileSystemService.ts:276) the rejection propagates, which is the right call — better to fail than to write wrong bytes.
Regression suites at fc99f5975
586 focused Core tests · 130 focused CLI tests (1 pre-existing skip) · 29 bundle-guard tests · Prettier clean across all 35 changed files · ESLint clean · packages/core tsc --noEmit clean.
(The workspace-wide typecheck fails in my symlinked verification worktree with 61 ink selectable/selectionFlow prop-type errors, all in packages/cli/src/ui, none touched by this PR — an artifact of my setup, not the change. CI's own typecheck is green.)
What I did not verify
- The 2C4G latency and RSS numbers — those need the 2-vCPU reference host. What I can say is that the bundle half of the claim reproduces at full magnitude on the current base at the current head, so re-running the bundle gate on the final commit is not needed; whether the latency table needs a fresh 30-pair run against
fc99f5975rather than the prototype artifact remains the open maintainer call from triage. - Windows — still the one unexercised axis, and PTY +
@xterm/headlessdeferral is exactly the Windows-sensitive path. Worth a green Windows CI run before merge.
Verdict: everything I flagged in round 1 is either fixed or explicitly dispositioned, the fixes hold up under adversarial checks rather than just looking right, and nothing new rises above a nit. Environment: macOS 26 arm64, Node 22.23.1, isolated worktree at fc99f5975, base 2051a4173.
中文说明
在 fc99f5975 上的复验 —— 四项待修问题全部修复,且已证明修复 #1 是真正起作用的
这是对我第一轮审查(2208817ac)的后续。此后新增两个提交:769ceac29(处理该轮审查)与 fc99f5975(模块形状守卫)。PR base 未变(2051a4173),因此这是同一组 A/B 的干净复测。我重新构建、重新 bundle 并重跑了全部验证,而不是只对比 diff 的 diff。
无阻塞项,从我这边看可以合并。
上一轮发现 → 新 head 上的状态
| # | 2208817ac 上的发现 |
级别 | fc99f5975 上的状态 |
|---|---|---|---|
| 1 | esbuild 插件的 dist 分支永远匹配不到真正含有该 import 的文件 |
Low | 已修复(769ceac29)—— 并且我验证了新分支确实起作用,而非装饰性改动(见下) |
| 2 | iconvHelper.ts 的 re-export 正是本 PR 所防范回归的再入口 |
Low | 已修复 —— re-export 已删除,isUtf8CompatibleEncoding 现在只能经由 encoding.js 获得 |
| 3 | 提取遗留的悬空 JSDoc | Nit | 已修复 —— 文件现在正确结束于 iconvEncodingExists 的文档 |
| 4 | loadIconvLite 是唯一没有形状守卫的 loader |
Low | 已修复,且做得比我建议的更多:fc99f5975 为三个 loader 都加了形状断言与非法模块测试 |
| 5 | getWorktreeDiff 悄悄扩大了 catch 范围,且未列入 Risk & Scope |
Low | 已补充说明 —— Risk & Scope 现已披露懒加载 Git 的初始化发生在既有 diff/apply 失败包络内 |
| 6 | findGitRoot() 词法遍历与 rev-parse 的差异 |
Info | 已附理由婉拒(有意保留的同步 best-effort 探测)。我认同 —— 最坏情况只是 sweep 变成 no-op |
| 7 | 两处不可达的 throw |
Nit | 婉拒(保留用于 TS narrowing)。维持现状,无害 —— 我另发现同类的第三处死分支,见 N1 |
| 8 | 没有任何断言保证同步与异步编码实现一致 | Low | 已修复 —— 新增 9 组表驱动测试;我做了变异检验,确认其具备判别力 |
| 9 | 同步兼容导出在仓库内没有消费者 | 维护者决策 | 已明确记录为面向外部使用者的兼容性取舍 |
1 · 在新 head 上重测 bundle A/B
重新构建两侧,并从 ACP entry output 出发、仅沿静态边遍历 esbuild metafile。
| 指标 | base 2051a4173 |
head fc99f5975 |
Δ |
|---|---|---|---|
| ACP 静态闭包 outputs | 145 | 143 | −2 |
| ACP 静态闭包字节 | 13,214,217 | 12,121,575 | −1,092,642 |
iconv-lite 归因 |
552,742 | 0 | −552,742 |
@xterm/headless 归因 |
213,120 | 0 | −213,120 |
simple-git 归因 |
146,526 | 0 | −146,526 |
node scripts/check-serve-fast-path-bundle.js 在真实 bundle 上通过。闭包比 2208817ac 时大 1,303 字节 —— 这正是三个新增的形状断言本身:它们位于静态闭包中,而它们所加载的包不在。符合预期。
2 · 修复 #1 确实起作用,并非装饰性改动
该改动只是一处正则分支((?:src|dist) → (?:src|dist[\\/]src)),因此值得证明它真的有效。我从 packages/cli/tsconfig.json 的 paths 中移除了 @qwen-code/qwen-code-core 映射,迫使 esbuild 经由 package.json exports → dist/index.js → dist/src/index.js 解析 Core —— 这正是 dist 分支存在的场景(已确认:两个 arm 中 packages/core/dist/index.js 都成为 bundle 输入)。
| Arm | bundle 中的 sync-file-encoding |
ACP 闭包中的 iconv-lite |
门禁 |
|---|---|---|---|
| A —— 新正则 | 已被 tree-shake,不存在 | 0 | exit 0 ✓ |
| B —— 旧正则 | core/dist/src/utils/sync-file-encoding.js 被引入 |
552,724 | exit 1 ✗ |
Arm B 的门禁明确指出了违规边:dist/chunks/acpAgent-XZ6BC7W7.js -> dist/chunks/chunk-34T4CTM7.js。因此这一行改动正是在该 tsconfig 映射日后被移除时,保证 tree-shake 仍然生效的关键;而即便仍然失效,门禁也会明确报错,而不是静默回归。
3 · 针对真实模块检验新的形状守卫
fc99f5975 的守卫只用 vi.doMock 做了测试。这引入的风险与我提出的问题正好相反:过严的断言会在首次真实使用时抛错 —— 发生在生产环境、在已 bundle 的产物中、在单测覆盖不到的路径上。因此我用三种方式检验了真实形状。
- 用 PR 自己编译出的 loader 加载真实模块(无 mock):14/14 通过,包含真实 GBK 解码与逐字节一致的往返、真实
Terminal构造并回放输出,以及真实的checkIsRepo/revparse/status调用。 - 同一套 harness 经过 esbuild
__toESMinterop:14/14 通过。 - 将断言应用于真实发布的动态 chunk —— 这是决定性的一项:
| 包 | 发布的 chunk | predicate(namespace) |
走的分支 | 结论 |
|---|---|---|---|---|
iconv-lite |
lib-GFEPFPNS.js |
false | .default |
接受 |
@xterm/headless |
xterm-headless-V6AWZL7D.js |
false | .default |
接受 |
simple-git |
esm-IIIWUUC5.js |
true | namespace | 接受 |
三者中有两个确实需要 .default 回退分支,说明该分支并非冗余。dist/chunks 中编译后的 loader 以 ./lib-*.js、./xterm-headless-*.js、./esm-*.js 的形式引用真实动态 chunk —— 这正是延迟加载本身在发布产物中的体现。
4 · 变异矩阵 —— 新增测试具备判别力
每个新测试都名副其实:把各 loader 的守卫回退到 fc99f5975 之前的实现,都会让该 loader 的新测试失败;两处同步解码器的变异也都会让新的等价性表格测试失败。
| 变异 | 结果 | |
|---|---|---|
M1 回退 iconv-lite 形状守卫 |
2 失败 / 1 通过 | 已杀死 |
M2 回退 simple-git 形状守卫 |
1 失败 / 2 通过 | 已杀死 |
M3 回退 @xterm/headless 形状守卫 |
1 失败 / 2 通过 | 已杀死 |
| MD1 同步解码器丢弃 BOM 标记 | 1 失败 / 8 通过 | 已杀死 |
| MD2 同步解码器跳过 iconv 解码 | 1 失败 / 8 通过 | 已杀死 |
ME1 同步编码器忽略 prepared.encoding |
9 通过 | 存活 |
ME1 存活,因此我进一步探测它是否属于覆盖缺口。并不是 —— 它是等价变异体。只要 prepared.data 是字符串,prepared.encoding 恒为 "utf-8";所有非 UTF-8 情形都返回 Buffer 并在上一行提前返回。该变异在构造上就不可观测。
两点新的小观察(均不阻塞)
N1 · sync-file-encoding.ts:81 的 ?? 'utf-8' 回退是死代码。 这是上述 ME1 探测的副产物 —— 出于同样的原因,该空值合并分支永远走不到。与发现 7 同类;此处提出只是为避免把探测表格误读成覆盖缺口。
N2 · 新守卫抛出的错误落在哪里(提示性,且属于改进)。 三处我都追踪了。loadXtermHeadless() 在既有 PTY try 内被 await(shellExecutionService.ts:715),因此守卫抛错会保留既定的 child_process fallback。读取路径上,loadIconvLite() 位于既有 try/catch 内(fileUtils.ts:216-229),因此抛错会经 debugLogger.warn 记录并降级为 UTF-8 替换字符 —— 未开启 debug 日志时对用户仍不可见,但这是既有行为;且现在是一个具名错误,而非旧的无条件 module.default 转换所产生的、令人费解的 TypeError。写入路径上(fileSystemService.ts:276)抛错会向上传播,这是正确的选择 —— 失败好过写入错误的字节。
fc99f5975 上的回归测试
586 个聚焦 Core 测试 · 130 个聚焦 CLI 测试(1 个既有 skip)· 29 个 bundle guard 测试 · 35 个改动文件 Prettier 全部干净 · ESLint 干净 · packages/core 的 tsc --noEmit 干净。
(全 workspace typecheck 在我的符号链接验证 worktree 中会报 61 个 ink selectable/selectionFlow 属性类型错误,全部位于 packages/cli/src/ui,均非本 PR 触及 —— 这是我环境的产物,不是本次改动的问题。CI 自身的 typecheck 是绿的。)
我未验证的部分
- 2C4G 的延迟与 RSS 数据 —— 这需要 2 vCPU 参考机器。我能确认的是,该结论中的 bundle 部分在当前 base、当前 head 上以完整量级复现,因此最终提交无需重跑 bundle 门禁;至于延迟表格是否需要针对
fc99f5975而非 prototype artifact 重新跑一次 30 组配对,仍是 triage 留下的、需维护者判断的开放问题。 - Windows —— 仍是唯一未验证的维度,而 PTY +
@xterm/headless延迟加载恰是 Windows 敏感路径。合并前值得跑一次绿色的 Windows CI。
结论: 我在第一轮提出的问题要么已修复、要么已明确表态处置;这些修复在对抗性检验下站得住,而不只是「看起来对」;新发现的问题均不超过 nit 级别。环境:macOS 26 arm64、Node 22.23.1、隔离 worktree、head fc99f5975、base 2051a4173。
|
@qwen-code /triage |
|
Triage re-run completed without a new review. The stage comments above were updated with the latest result. View workflow run. |
…rules Borrow the image-evidence and quantified-verification patterns from hand-run rounds (#7265, #7471, #7686 r2 and the pr-assets convention): - publish-verify now hosts agent-produced evidence/*.png on the pr-assets branch (verify/pr<N>-<run>-<attempt>/) and appends them below the escaped report. Untrusted-payload discipline: strict filename allowlist, 8-image / 2 MB caps enforced in the find predicates, racing-push retry, and every failure degrades to a text-only comment. VERIFY_ASSETS_REMOTE is a test seam; the block was dry-run against a local bare remote covering hosting, hostile filenames, oversize files, dotfiles, missing branch, and no-image runs - skill: evidence images are named as kebab-case captions binding image to claim, before/after pairs over lone after-shots; follow-up rounds lead with a previous-finding status table (fixed/stands/superseded/declined, with adjudication) and re-measure instead of diffing the old report; size/perf claims get measured-metric Δ tables with residual deltas accounted for; unreachable branches get the configuration that reaches them constructed; defensive guards get their accept path checked against real production artifacts, not just mocked rejects
|
@qwen-code /triage |
|
Triage re-run completed without a new review. The stage comments above were updated with the latest result. View workflow run. |
Review + Linux E2E verification report (real build, tmux)Verdict: the lazy-loading mechanics are correct and verified working end-to-end on Linux — all three first-use paths (iconv-lite, @xterm/headless, simple-git) function correctly from the real bundle, and the bundle-closure guard passes. One robustness suggestion and one pre-existing (not PR-caused) finding below. Code review reasoning
Verification evidence (Linux, commit fc99f59, full build + bundle)
Suggestion (non-blocking)All three loaders cache a rejected promise forever: Pre-existing finding (not caused by this PR)While exercising the compiled output I hit a TDZ crash when importing Process notePer the repo's core-infrastructure triage policy, this is an external PR touching |
|
@qwen-code /triage |
|
Triage re-run completed without a new review. The stage comments above were updated with the latest result. View workflow run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and not exercised locally; PR notes Windows as unexercised. Not reviewed: chunk 4, chunk 1, chunk 3, chunk 5, chunk 6, chunk 2 — launched with a prompt that is not the one the CLI built. Not reviewed: Agent 0: Issue fidelity & root-cause ownership, Test coverage matrix (whole-diff), Agent 1b: Removed-behavior audit, Agent 1c: Cross-file tracer, Agent 7: Build & test verification — its prompt was built, but no agent on record was launched with it. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.
— qwen3.7-max via Qwen Code /review
ytahdn
left a comment
There was a problem hiding this comment.
LGTM. Well-structured lazy-loading refactor: iconv-lite, @xterm/headless, and simple-git moved out of the ACP static closure (~1MB reduction). All fallback paths preserved (decoding fallback, child-process fallback, structured Git failure returns). Comprehensive test coverage (461 core + 374 CLI + 29 bundle guard). Solid benchmark data. No blocking findings after OCR + manual audit.
* feat(triage): add sandboxed /verify deep-verification lane @qwen-code /verify on a PR now runs a local-verification-style evidence round in the isolated /tmux sandbox contract (container, token-free agent env, loopback model proxy, author-write gate) and publishes the report via a separate PR-code-free job: - new verify job: merge-ref checkout at depth 2 (base tip + PR head for A/B), skills pinned from base so the tree under test can never rewrite its own verifier, PR-planted tmp/*-verify-* artifacts dropped, git exec-vector sweep for the persistent workspace, agent verdict allowlisted before it reaches workflow outputs - new publish-verify job: upserts one marker comment (running status -> final report), HTML-escapes the untrusted report, reports skip/na/ prepare-fail/infra outcomes explicitly since /verify is always an explicit request - new verify-pr skill: A/B load-bearing proof, vacuity check on new tests, mock-free wire-oracle harnesses, targeted gates, fixed report/ verdict/assertions artifact contract, counts-are-sacred rules - triage skill Stage 2c now names /verify (not just /tmux) as the trigger to recommend when a PR's central claim needs behavioral evidence The verify check-runs ride the issue_comment event, which the finalize workflow's event == "pull_request" universe structurally excludes, so they cannot pollute the CI table or the deferred-approval gate. * feat(triage): teach /verify round continuity and artifact-matched methods Fold two more hand-verification patterns into the verify lane: - round continuity: the resolve step snapshots the previous verify report (if any) into the agent context before the status upsert overwrites it, and the skill re-checks each prior finding at the new head (fixed/stands/superseded), scoping new probes to the delta - harness quality: prefer configuration seams over module interception, encode the upstream's real semantics in the fake peer, add decoy targets - artifact-matched methods: per-commit load-bearing tables for multi-commit PRs; workflow/CI PRs get embedded-script replay against real data, repo lint gates, and day-one trigger cost math from real event history; every new config knob must trace to an observable effect, and default-path dispatch combinations get probed - findings quality: blockers enumerate blast radius, demonstrate the sharpest consequence end-to-end when budget allows, and carry a collapsed minimal suggested fix preserving the original commit's intent * feat(triage): host /verify evidence images and encode quantified-A/B rules Borrow the image-evidence and quantified-verification patterns from hand-run rounds (QwenLM#7265, QwenLM#7471, QwenLM#7686 r2 and the pr-assets convention): - publish-verify now hosts agent-produced evidence/*.png on the pr-assets branch (verify/pr<N>-<run>-<attempt>/) and appends them below the escaped report. Untrusted-payload discipline: strict filename allowlist, 8-image / 2 MB caps enforced in the find predicates, racing-push retry, and every failure degrades to a text-only comment. VERIFY_ASSETS_REMOTE is a test seam; the block was dry-run against a local bare remote covering hosting, hostile filenames, oversize files, dotfiles, missing branch, and no-image runs - skill: evidence images are named as kebab-case captions binding image to claim, before/after pairs over lone after-shots; follow-up rounds lead with a previous-finding status table (fixed/stands/superseded/declined, with adjudication) and re-measure instead of diffing the old report; size/perf claims get measured-metric Δ tables with residual deltas accounted for; unreachable branches get the configuration that reaches them constructed; defensive guards get their accept path checked against real production artifacts, not just mocked rejects * fix(triage): address /review suggestions on the verify lane - skill: local invocation resolves --repo and passes it to every gh call - skill: call out the dependency confound when the base A/B side reuses the PR-installed node_modules and the PR touches package.json/lockfile - workflow: document the pin step's bootstrap logic — issue_comment jobs run the default branch's YAML, so base always carries the verify-pr skill by the time this job exists * fix(triage): harden /verify gate, comment budget, and evidence hosting per review Address review round 5078770575 items 1-3 plus the cheap follow-ups: - authorize: /verify now requires write from BOTH the PR author (whose code runs) and the commenter (who spends a scarce runner slot + model budget) — a drive-by account can no longer burn 45 minutes of ecs-qwen on someone else's PR; duplicates check once; /tmux and /triage gates unchanged. Replayed 8 principal scenarios against a stubbed gh - authorize acks /verify with the eyes reaction from the always-hosted job, so a queued/saturated sandbox pool no longer means total silence - publish: emit_block escapes FIRST and caps the escaped size (45 KB for the report) — a raw-side cap let dense <>& content inflate past GitHub's 65,536-char comment limit, 422 the post, and strand the running status with no report at all; iconv -c keeps a UTF-8 sequence split by the byte cut (likely, given the mandated 中文 summary) from shipping broken; replayed: 50 KB dense report -> 45,873-byte body - publish: image cap is byte-exact (-size -2097153c; find's -2M rounds sizes UP to MiB, silently making the documented 2 MB cap 1 MiB), bytes must carry the PNG magic (extension is attacker-choosable), duplicate sanitized names dedupe instead of overwriting + double-rendering, and dropped images are reported in the comment instead of vanishing - publish: weak terminal notices (cancelled/infra/skipped/n-a) only replace this run's own running status; a previous round's real report survives as the marker comment and the notice posts fresh - publish: report.md/assertions.json lookups pin the artifact-dir shape and sort (bare find -name order is filesystem-dependent); the verify job's verdict.txt lookup sorts likewise - verify: global npm install runs from RUNNER_TEMP (the persistent workspace still holds the PREVIOUS run's tree, whose .npmrc would apply to a root install); both cleanup passes remove leftover tmp/ worktrees (git worktree prune alone only drops metadata); the run step no longer re-chowns 50k node_modules files; pr-assets clone sets its committer identity once so the racing-push rebase retry can commit - skill: worktree guidance now tells the agent to remove its base tree itself, with the workflow sweep as backstop only * fix(triage): close runtime-plant and stale-RUNNER_TEMP channels in /verify Address review round 2 (comment 5079157987) and the CHANGES_REQUESTED round on the verify lane: - run step re-sweeps tmp/*-verify-* AFTER npm ci/build and before the agent starts: the pin step's sweep runs before PR lifecycle scripts (postinstall etc.), which could re-plant a fake artifact dir whose zeroed timestamp deterministically wins the sorted collector. From the sweep on, only the agent writes those dirs; a steered agent forging its own artifacts remains the documented advisory-report residual - RUNNER_TEMP verify-results/verify-context are rm'd before mkdir: the pool is persistent and runner temp hygiene is runner-managed — a stale report or previous-report.md from ANOTHER PR must never ride along - symlinks are stripped from verify-results before upload: actions/upload-artifact dereferences them, so a node-planted link would exfiltrate whatever it points at into the artifact - a trusted commenter invoking /verify on a PR whose author lacks write now gets an explanation comment from the hosted authorize job instead of total silence (the commenter is checked first; drive-by accounts and API errors still get nothing); job timeout 45->60 so a slow install can never let the JOB limit kill the agent past its own graceful 25m budget - stale tmp/base-tree (skill's canonical scratch worktree) is removed by name at job start — a plain dir isn't git-registered, so the worktree sweep alone misses it and the next worktree add would fail - scripts/tests/qwen-triage-workflow.test.js gains a verify-lane describe block: an 8-arm stub-gh replay of the dual principal gate (drive-by deny, author-without-write deny + explain flag, self-comment dedupe, 404 fail-closed, /tmux and /triage unchanged) plus guards for the post-prepare sweep placement, the symlink strip, and the RUNNER_TEMP resets — the replay found this commit's sweep edit had silently not applied, which is exactly the regression class it exists to catch * fix(triage): close proxy-hijack, gate-bypass, and false-verdict paths in /verify Address the Codex /review round (19 findings) and the bot's follow-up. Each fix was replayed locally; the proxy fix has a decisive A/B. Gate and routing: - the shell command match is case-insensitive: GitHub Actions expression comparisons ignore case, so `@QWEN-CODE /VERIFY` reached the step and fell through to the commenter-only branch — running the PR author's code with the author never checked - the verify ack and denial notice require github.event.issue.pull_request: /verify on a plain issue was acknowledged but could never report - publish-verify joins the verify job's per-PR concurrency group, and a failed PATCH falls back to posting fresh instead of going silent Untrusted-input paths: - the model proxy binds an EPHEMERAL port, reports it through a root-owned file, and its health check must echo a per-run nonce with the recorded PID alive. A/B with a squatter on 8787: the old code's proxy dies EADDRINUSE yet still reports enabled and points qwen at the squatter; the new code comes up unaffected on an ephemeral port - worktree-scoped git config is deleted before hooksPath is resolved: `extensions.worktreeConfig` is allowlisted and .git/config.worktree is invisible to `git config --local`, so a prior run could set core.hooksPath=/ and make the hook sweep's recursive delete walk / as root (verified locally). The sweep now also refuses any hooks path outside the repository's git dir - marker-comment lookups accept only bot-owned comments that START with the marker: any user can paste the marker and divert the bot into PATCHing a stranger's comment - the upload staging dir is re-flushed after npm lifecycle scripts Honest verdicts: - the docs-only classifier no longer uses a pipeline (grep -q made the writer take SIGPIPE, so under pipefail a long file list with an early code file classified a code PR as docs-only and skipped verification), and executable markdown/YAML (.qwen, .github/workflows, scripts) is classified as behavioral before the extension rule - tee's status is checked alongside qwen's: a full results volume made a truncated evidence stream publish as pass - 137 is split by elapsed budget into watchdog timeout vs crash/OOM - the agent's verdict is honored only for VERDICT=pass with a report and zero failed assertions; otherwise the process outcome headlines and the scope paragraph says the run did not complete - verdict.txt is read through a bounded prefix (SIGPIPE under pipefail) Skill contract corrections: per-commit tables only when the commits are reachable at depth 2 (else aggregate + Not covered); internal workspace symlinks must have their realpaths asserted before a base control is trusted; repo lint gates and event-history cost math are qualified to what the token-free container can actually run; --repo is never inferred from `origin` (a fork holds a different PR under the same number). Tests: 9 new guards, all mutation-verified (reverting each fix turns one red), including an executable escaping/size-cap/UTF-8 test for the publisher's own emit_block and a fix to the earlier command-file test, which matched the tmux job's identically named step. * fix(triage): re-establish the /verify trust boundary after PR code runs Third review round (31 findings). The unifying defect: everything the lane pinned or swept happened BEFORE npm ci/build executed PR-authored lifecycle scripts as node, so each control could be undone afterwards. Trust boundary, rebuilt in order before the agent starts: - kill every process owned by the build user and fail the step if any survives — a detached postinstall child could otherwise wait out each one-shot sweep and re-plant afterwards - re-pin .qwen from the base commit again, now root-owned and read-only: the prepare step chowns the workspace to node, so a lifecycle script could rewrite the very skill that defines /verify-pr - give the agent a fresh HOME/QWEN_HOME: qwen loads user-scope file commands from $HOME/.qwen, and /home/node belongs to the build user, so a planted commands/verify-pr.toml could shadow the pinned skill - the model proxy now requires a per-run bearer token, closing the blind-localhost-scan path to an unauthenticated signer for the real model credential (a command the agent itself launches still inherits it — documented residual, not closed) Authorization and lifecycle: - re-verify the PR author's write permission at execution time and pin the authorized head OID; refuse if the checked-out HEAD^2 differs, so a push during the runner wait cannot smuggle in unreviewed code - validate each principal separately: an empty author vanished in word splitting and left only the commenter checked - honor MAINTAINER_ECS_RUNNER_DISABLED with an explicit notice instead of queueing forever against a disabled pool - status comments carry a machine state marker; inferring 'running' from prose let a report quoting that sentence be overwritten - previous-report.md snapshots the newest substantive report, never a weak/cancelled notice, so prior findings survive into the next round - bot-identity lookup failures fail closed instead of widening the ownership filter to every user's comments - publish-verify uses a per-run concurrency group: a per-PR group holds only one pending job, so a second /verify could cancel a completed run's pending publisher Correctness: - install/build failures are classified: signals, ENOSPC, registry and network errors are infra-error, not a PR verdict - watchdog classification measures the child's own elapsed time, not shell-global $SECONDS which includes proxy setup - assertions.json must be three non-negative integers with a positive total and total == pass + fail before it counts as evidence - the proxy keeps its upstream deadline armed until the body ends and aborts upstream when the client disconnects - cleanups remove .qwen/tmp itself: PR code can make it a symlink, and globbing below it deleted the target's contents as root (verified) - emit_block materializes the escaped text and truncates on a character boundary via node — iconv -c passes an incomplete trailing sequence through on BSD (measured), which the new test caught Skill: local mode requires the same isolation CI provides and must not assume HEAD^1/HEAD^2 on a plain head checkout; shallow boundaries make rev-list counts unreliable for per-commit claims; never run scripts/lint.js with no arguments (it runs prettier --write and rewrites the tree under the harnesses); a vacuity check must fail the intended assertion, not the import. pr-workflow.md now says both sandboxed lanes need the author to have write, so triage stops recommending a guaranteed denial on external PRs. Tests: 9 more guards, all mutation-verified, including executable replays of the docs-only classifier (SIGPIPE + executable-markdown cases), the uppercase-command gate, the empty-principal deny, and the untrusted-image hosting path against a bare pr-assets remote. * test(triage): pass the classifier fixture through a file, not argv The new docs-only classifier replay passed on macOS and failed on CI with `Cannot read properties of undefined (reading 'trim')`: its 60,001-entry fixture is ~889 KB and was passed as a single argv element. Linux caps one argument at MAX_ARG_STRLEN (128 KB), so the spawn failed with E2BIG and stdout was undefined; macOS has no per-argument limit and only a ~1 MB total, so the same call succeeded locally (verified both). Write the list to a temp file and pass the path. The harness now also asserts the spawn succeeded, so a future spawn failure reports itself instead of surfacing as a TypeError on undefined output. * fix(triage): make the /verify report match what the run actually produced Three publisher findings, all introduced by my own previous round: - an artifact download failure (the step is continue-on-error) let the full-report path run with no results: the headline read 'completed' and the scope paragraph claimed the A/B, the harnesses and the gates had run when nothing had been delivered. The download outcome is now an input, and its failure gets its own body saying the results could not be retrieved - the prepare-failure branch ignored the verdict the prepare step had just computed, so an install killed by a registry outage or OOM (classified infra-error) still told the author 'this is treated as a PR failure verdict rather than an infrastructure failure' — the exact opposite. It now branches on the verdict, and an infra-classified prepare failure is a weak body that cannot overwrite a real report - weak notices were being snapshotted as the follow-up round's previous-report.md: they lack the running marker, so 'newest non-running comment' selected them. Bodies that carry findings now mark themselves (qwen-triage:verify-substantive) and the snapshot selects on that marker. A/B on the real jq: report A then cancelled B now snapshots A (101), the old filter picked B (102) Tests: 4 more guards, all mutation-verified — the publisher is rendered for each outcome with a stubbed gh and the assertions read the body it would post, and the snapshot test runs the workflow's own jq program verbatim against a paginate-shaped fixture. * fix(triage): stop PR build output from masquerading as an infra failure Two review findings plus a test-helper hazard: - classify_failure grepped the prepare log for bare words like ENOSPC and ETIMEDOUT, but that log is written by PR-controlled code: a genuine build failure that merely prints 'expected ETIMEDOUT to equal ok' would be published as an infrastructure incident, telling the author to re-run something that fails identically. The patterns are now anchored to lines only npm's reporter or the kernel emits ('npm ERR! code E…', 'npm ERR! network …', kernel OOM, bare 'Killed'); a signal exit still needs no log evidence. Replayed 10 cells: four PR-authored logs quoting infra words stay 'fail', five real diagnostics and one signal exit are 'infra-error' - the two execution-time controls added last round — re-verifying the author's permission after the runner wait, and refusing a head that moved since authorization — had no tests. Both are now executed: the re-auth snippet against a stubbed permission API (write proceeds and pins head_oid; read skips with a publishable reason), and the pin step against a real git repo with a real merge commit (matching head proceeds, moved head exits non-zero) - add a stepIn(job, step) test helper. Several step names exist in both the tmux and verify jobs, and the unscoped step() returns the first match, so a verify-lane assertion silently tests the tmux copy — that has now bitten this suite three times, including in this commit. * docs(triage): teach verify-pr test-only PRs, differential oracles, gate liveness Fold techniques from the round-2 verification on QwenLM#7620 (an ANSI parser PR) that the skill had no equivalent for: - test-only PRs get their own method: a mutation A/B across TEST FILES (same mutants of the unmodified production file, only the test file swapped), reporting killed/total on both sides, requiring that no mutant regressed from killed to survived, checking that the killing assertion is the one the commit claims to have strengthened, and adjudicating every survivor as coverage gap or defect with independent evidence rather than by inspection - when the code emulates a known implementation, that implementation is the oracle: feed identical input to both and report disagreement counts per side, lift reference tables verbatim out of the shipped dependency, and build the corpus from bytes captured off a real producer alongside synthesized sweeps - prove a gate is live before citing it: plant a violation the linter must catch, confirm it is reported, remove it — a linter that matched no files exits 0 exactly like one that passed - attribute pre-existing failures by byte-identical failing file AND test names on both sides, with deltas, not just totals - when the base is far behind, verify the merge: trial-merge into current main, confirm it is conflict-free, and re-run the affected suite on the merged tree - round continuity gains its one legitimate shortcut: a production file proven byte-identical (sha256 quoted at both heads) carries prior evidence forward by construction * style(triage): reflow verify-pr skill to prettier's markdown wrapping The previous commit's added paragraphs were hand-wrapped and prettier --check flagged the file; the repo runs prettier over all of it. * test(triage): cover the disabled-runner-pool notice The kill-switch path had no test: a refactor could drop the notice and leave a /verify request acknowledged with 👀 but permanently unanswered, since the verify job refuses to start and publish-verify skips with it. Fold the step into the existing PR-guard loop (now scoped through stepIn, so it cannot match a same-named step in another job) and assert the parts that make the answer useful — the kill-switch and permission conditions, both languages, the alternative it points at, and the verify job's own exclusion of the disabled pool. All three mutations turn it red: removing the step, dropping its PR guard, or letting the verify job queue against the disabled pool. * fix(triage): repair a step-killing PIPESTATUS read and six forgeable controls Sixth review round, 12 findings. Several are regressions from my own two previous rounds; the first would have broken every single run. - `AGENT_STATUS=${PIPESTATUS[0]}` is itself a command and resets PIPESTATUS, so the next line's ${PIPESTATUS[1]} was unset and `set -u` aborted the step immediately after the agent finished — before artifact collection, the verdict, or anything else. Verified by replaying the exact structure: 'PIPESTATUS[1]: unbound variable'. Both elements are now snapshotted in one command - concurrency predicates were broader than the job conditions they guard, and GitHub evaluates concurrency BEFORE the job `if`: a /verify comment entered the triage job's shared per-PR group (where it could displace a pending /triage and then skip), and a /verify queued while the runner kill switch was on did the same to a real verification. Both predicates now match their job's runnable set exactly - an outward-resolving .git/hooks entry was only warned about and left in place, so the next root-owned git command would run it. It is now unlinked without traversing its target, a root-owned hooks directory is restored, and core.hooksPath is unset - the second .qwen pin re-derived HEAD^1 from git metadata after the workspace, including .git, had been handed to the build user. The base OID is now recorded while .git is still root-owned and the re-pin archives that content-addressed OID - classify_failure took both of its inputs from PR-controlled sources: a lifecycle script can exit with a signal status and can print any line the log patterns matched, turning its own deterministic breakage into 'infrastructure, please re-run' — which hid the failure and preserved a stale report. No infra verdict is derivable there, so the prepare step reports `fail` and lets the embedded log speak for itself - cleanups descended through PR-writable parents: `.qwen` itself can be a symlink, and the worktree sweep trusted git metadata with only a lexical prefix check. Symlinks are unlinked without traversal and worktree paths must canonicalize inside the workspace. Replayed all three escapes - skipped and docs-only outcomes upload no artifact, so the new download-failure branch pre-empted them and made their real reason unreachable; they are answered first now - a run that crashed before writing report.md still claimed the substantive marker, letting a headline overwrite the previous round's evidence. The marker now requires a report Skill: the byte-identical shortcut needs the whole input closure, not one file hash; the credential-free local path cannot call `gh` at all (fetch the metadata outside and mount it read-only); and the A/B base is `baseRefOid` in local mode, not `HEAD^1`. Tests: 7 new guards plus 4 updated to the new shapes, all mutation-verified (50/50). * fix(triage): answer dropped /verify requests and prove the proxy rejects Maintainer review (yiliang114), 7 items: - a third /verify while two runs are in flight is dropped by the concurrency group with no job and therefore no comment. The hosted authorize job now counts this workflow's other in-flight runs and says so; an API hiccup leaves the request alone rather than denying it - the proxy's bearer check had no executable test. It now starts the real proxy against a real upstream and issues real requests: no header and a wrong token are 401, this run's token is 200, and a route other than /chat/completions is 403 — with the health endpoint echoing the nonce - the 502 path forwarded the raw upstream error, which can name resolved hosts and TLS detail to PR code. It logs server-side and returns a generic failure - publish-verify inherited the 360-minute default; it downloads one artifact and posts one comment, so it is bounded at 10 - removing the log classifier last round left the comment block it replaced, which still said failures are classified from the exit status and the log. Deleted - that removal also left every install failure reported as the PR's fault, including a registry outage. There is exactly one signal here PR code cannot write — asking the registry ourselves, as root, with the container's resolver — so an install failure is downgraded to infra-error only when that probe fails. It proves reachability now rather than at failure time, so it can only ever downgrade, never confirm; a build failure has no equivalent and stays the tree's problem - the skill's local-invocation warning ran into the preceding sentence, which GFM renders as one paragraph Tests: 5 new guards, all mutation-verified (55/55). * fix(triage): resolve hooks hermetically and mirror symlink guards at job end Maintainer review round (doudouOUC), 6 findings. Two were Critical and both reproduced: - the hooks sweep resolved its path with the ambient git config in play. With a global core.hooksPath set — which the reviewer has and I do not, which is why my earlier replay showed a false pass — `git rev-parse --git-path hooks` returns that global path, the in-git-dir guard reads 'outside', and a planted `.git/hooks` symlink survives untouched. A/B: old code leaves the symlink under a global hooksPath, new code removes it in both environments and never touches the link target. Resolution now runs with GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null - the END-of-job cleanup still used the bare `rm -rf .qwen/tmp` that the start-of-job cleaner was hardened against two rounds ago. The agent executes PR code between the two, so the end is no safer than the start: it now unlinks symlinks without descending and canonicalizes worktree paths inside the workspace before deleting Plus four suggestions, all valid: - the saturation notice counted this workflow's in-flight runs across every PR while the concurrency group is per-PR, so a run on another PR would trigger a warning about a queue that does not exist. It now matches on the PR title (the only per-PR handle an issue_comment run record carries) and stays silent when that cannot be resolved - the skill recommended `require.resolve` for the workspace-realpath check; these packages are ESM-only with import-only exports, so it throws ERR_PACKAGE_PATH_NOT_EXPORTED and reads like a missing module. Verified, and replaced with `readlink -f node_modules/@qwen-code/...` - the symlink-escape test inherited the developer's git config, which is what hid the first finding. It now runs with global/system config neutralized AND repeats the case with a global core.hooksPath planted - the publisher's build-phase arm was never rendered by any test (every case used 'install'), so a typo in that command name would have shipped. Now covered, along with an unrecognized phase Mutation-verified 4/4. The hooks guard needed a discriminating assertion: git's own `*.sample` files must survive the sweep, because the outward-path fallback removes the whole directory and would otherwise satisfy a bare 'planted hook is gone' check. * fix(triage): count only /verify runs for saturation, and test the PATCH arm Bot review round, 2 suggestions, both valid: - the saturation notice matched runs by PR title, which narrowed to this PR but not to /verify. /triage and /tmux live in their own concurrency groups, so two of those in flight would warn about a verify queue that is actually empty. It now also requires the run to have a job named 'verify' — the run record carries no command, but its job list does. Replayed: two non-verify runs stay silent, two verify runs warn - every publish fixture returned an empty comments listing, so the PATCH arm was never executed: a broken PATCH would have stranded the running status comment and posted a duplicate below it, with the suite green. The publisher now runs against a stubbed listing and the test asserts which verb went to which comment id — bot-owned live status is PATCHed in place, an absent comment posts fresh, and a marker comment owned by someone else is left alone and posted around Mutation-verified 3/3: counting every command, never PATCHing, and accepting foreign-owned markers each turn one test red. Two stub bugs found while writing these, both mine and both silent: ${*#pattern} applies per positional parameter rather than to the joined string (yielding a wrong run id), and the paginate fixture needs one array per page, not an array of pages. * fix(triage): fix the real silent drop and drop the step built on a wrong premise Review round 4. The blocker was mine twice over: the saturation notice I added last round had GitHub's concurrency semantics backwards, and the silent drop it claimed to cover was somewhere else entirely. - GitHub cancels the OLDER pending run in a group and admits the new one (confirmed against the workflow-syntax reference). My step told the person who had just typed /verify that their request might be dropped, when theirs is the one that runs — and said nothing to the person whose queued run actually died. This PR already had it right in publish-verify's own comment, so the file contradicted itself and the user-facing copy followed the wrong half. The step is removed rather than reworded: with the fix below there is nothing left for it to warn about, and it cost 2+N API calls on every /verify. - the actual drop: a verify job cancelled while still PENDING never reaches a runner, so its outputs block — where the "|| github.event.issue.number" fallback lived — is never evaluated. publish-verify then read an empty PR_NUMBER, hit its own guard and exited 0, making the cancelled branch unreachable in exactly the scenario that produces cancellations. The fallback now lives where the value is read. Reproduced both arms by executing the real step: with a number the cancelled notice posts, with an empty one it only warns. - same one-line class in publish-tmux, fixed alongside. Two copy defects from the classifier removal, both mis-attribution pointed the other way: - the infra-error body still named a signal/OOM kill and a full disk, none of which the current prepare step can produce — infra-error now requires npm ci to fail AND the registry probe to fail. It names that condition only, and offers a re-run instead of asserting it is the fix. - the code comment above it still described the deleted classifier. Also fixes the indentation break an earlier scripted edit left in the publish body builder, and replaces the saturation test with one that executes the cancelled path. Mutation-verified 2/2; the copy needed its own guard, since reverting the wording alone left every test green. * docs(triage): teach verify-pr survivor accounting and observability regressions Fold techniques from the re-verification on QwenLM#7709 that the skill had no equivalent for: - the mutation matrix must report the mutations that changed NOTHING, not only the ones that failed. Each survivor gets classified as an ordinary coverage gap or as dead code — a guard whose deletion leaves every test green is one of those two, and the difference is what the author needs. Survivors mirroring a pre-existing gap are labelled as such, and the set is framed as completeness reporting rather than merge conditions - the sharper case that report demonstrates: a test that passes for the WRONG REASON. If deleting the new guard leaves its own new test green, that test is pinned by an earlier early-return, not by the change, and asserts nothing about it. Name what actually pins it - and do not generalize from one dead guard to its siblings: the same report shows a clause that is unreachable on one path while being the only protection on another. Check each, report the contrast - observability regressions: when a change suppresses output, follow the value before calling the suppression correct. A bare catch on the path plus a field with no readers anywhere in the repo means the cause is now unobservable even in devtools — a real loss that no behavioural assertion can see - report structure gains a Corrections section: when an earlier round or bot comment described the code inaccurately, state the correct fact with evidence and label it as a correction to the description, not a request to change code. A wrong description left standing costs the next reader more than the original finding did --------- Co-authored-by: wenshao <wenshao@example.com>
…n omits the Han flag (QwenLM#7739) * fix(review): recover the bilingual register from the live PR when the plan omits the Han flag The posted review body renders bilingually (English, with the full Chinese version collapsed under it) only when the plan `compose-review` reads carries `prDescriptionHasHan: true`. That flag is written in exactly one place — `fetch-pr` — and read in exactly one place, from a plan path the orchestrator supplies. Two gaps follow: a `plan-diff` plan never records the flag, and a run that improvises the pipeline can hand `compose-review` a plan that is not `fetch-pr`'s report at all. Either way the switch fails safe to English, and a Chinese-authored PR gets an English-only review — observed on QwenLM#7686, where the four bot reviews off a proper plan were bilingual and the one off an improvised plan was not. When the flag is absent but the plan still names the PR (`ownerRepo` + `prNumber`), recover the signal from the live description with a single `gh pr view`, and test that recovered text for Han. This runs only on the absent-flag path: a recorded `true`/`false` is authoritative and spends no network, so every healthy `fetch-pr` review is unchanged. The signal stays the CLI's own — the real PR body, which the caller cannot forge — so the recovery tightens the "caller cannot toggle the register" property rather than loosening it, and any failure of the fetch falls back to English so the language can never take the review down. * refactor(review): reuse roster's isPositivePrNumber in the bilingual recovery `bilingualFromPlan`'s local `positivePrNumber` duplicated the exact PR-number validation already in `roster.ts` (positive integer, all-digit string, reject null/0/empty). Two copies of the same rule invite a silent divergence: a future change to how a PR number is validated, applied to only one, would make the bilingual recovery and `requiredAgents` disagree on the same plan's PR identity. Export the roster helper and reuse it, coercing to the string form the `gh pr view` call needs at the one call site. * fix(review): route compose-review's gh call via the PR host on GitHub Enterprise The bilingual body-language recovery added a `gh pr view` call inside compose-review, but its CLI handler never called `setGhHost` — unlike fetch-pr/submit/pr-context/comment-status/presubmit. On a GitHub Enterprise PR whose plan lacks `prDescriptionHasHan` but carries the PR identity, the recovery fetch would target github.com, fail, and compose an English-only body that disagrees with the bilingual body `submit` (which does route by host) posts. Give compose-review a `--host` option and call `setGhHost(host)` in the handler, mirroring the sibling subcommands; add it to the skill's Enterprise host list and the Step 6 invocation. Covered by a test that drives the handler with --host and asserts the routing took. * fix(review): strip the prBodyFetcher test seam at the compose-review boundary `prBodyFetcher` is a unit-test seam, but unlike `env` it was not stripped from the model-written state JSON. A state JSON carrying `"prBodyFetcher": "suppress"` survives `JSON.parse`, reaches `bilingualFromPlan`, is called, throws, and drops the Chinese fold through the fail-safe — letting the caller suppress a fold that the plan's own signal would have rendered. Strip it in the handler the same way `env` is stripped, and correct the field doc, which wrongly claimed a model could not supply one. * fix(review): strip prBodyFetcher at the submit boundary; pin fetchPrBodyViaGh and handler stripping with tests (QwenLM#7739) * fix(review): pin the submit-boundary prBodyFetcher strip; soften SKILL.md wording (QwenLM#7739) --------- Co-authored-by: verify <verify@local> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
…LM#7753) * feat(triage): add sandboxed /verify deep-verification lane @qwen-code /verify on a PR now runs a local-verification-style evidence round in the isolated /tmux sandbox contract (container, token-free agent env, loopback model proxy, author-write gate) and publishes the report via a separate PR-code-free job: - new verify job: merge-ref checkout at depth 2 (base tip + PR head for A/B), skills pinned from base so the tree under test can never rewrite its own verifier, PR-planted tmp/*-verify-* artifacts dropped, git exec-vector sweep for the persistent workspace, agent verdict allowlisted before it reaches workflow outputs - new publish-verify job: upserts one marker comment (running status -> final report), HTML-escapes the untrusted report, reports skip/na/ prepare-fail/infra outcomes explicitly since /verify is always an explicit request - new verify-pr skill: A/B load-bearing proof, vacuity check on new tests, mock-free wire-oracle harnesses, targeted gates, fixed report/ verdict/assertions artifact contract, counts-are-sacred rules - triage skill Stage 2c now names /verify (not just /tmux) as the trigger to recommend when a PR's central claim needs behavioral evidence The verify check-runs ride the issue_comment event, which the finalize workflow's event == "pull_request" universe structurally excludes, so they cannot pollute the CI table or the deferred-approval gate. * feat(triage): teach /verify round continuity and artifact-matched methods Fold two more hand-verification patterns into the verify lane: - round continuity: the resolve step snapshots the previous verify report (if any) into the agent context before the status upsert overwrites it, and the skill re-checks each prior finding at the new head (fixed/stands/superseded), scoping new probes to the delta - harness quality: prefer configuration seams over module interception, encode the upstream's real semantics in the fake peer, add decoy targets - artifact-matched methods: per-commit load-bearing tables for multi-commit PRs; workflow/CI PRs get embedded-script replay against real data, repo lint gates, and day-one trigger cost math from real event history; every new config knob must trace to an observable effect, and default-path dispatch combinations get probed - findings quality: blockers enumerate blast radius, demonstrate the sharpest consequence end-to-end when budget allows, and carry a collapsed minimal suggested fix preserving the original commit's intent * feat(triage): host /verify evidence images and encode quantified-A/B rules Borrow the image-evidence and quantified-verification patterns from hand-run rounds (QwenLM#7265, QwenLM#7471, QwenLM#7686 r2 and the pr-assets convention): - publish-verify now hosts agent-produced evidence/*.png on the pr-assets branch (verify/pr<N>-<run>-<attempt>/) and appends them below the escaped report. Untrusted-payload discipline: strict filename allowlist, 8-image / 2 MB caps enforced in the find predicates, racing-push retry, and every failure degrades to a text-only comment. VERIFY_ASSETS_REMOTE is a test seam; the block was dry-run against a local bare remote covering hosting, hostile filenames, oversize files, dotfiles, missing branch, and no-image runs - skill: evidence images are named as kebab-case captions binding image to claim, before/after pairs over lone after-shots; follow-up rounds lead with a previous-finding status table (fixed/stands/superseded/declined, with adjudication) and re-measure instead of diffing the old report; size/perf claims get measured-metric Δ tables with residual deltas accounted for; unreachable branches get the configuration that reaches them constructed; defensive guards get their accept path checked against real production artifacts, not just mocked rejects * fix(triage): address /review suggestions on the verify lane - skill: local invocation resolves --repo and passes it to every gh call - skill: call out the dependency confound when the base A/B side reuses the PR-installed node_modules and the PR touches package.json/lockfile - workflow: document the pin step's bootstrap logic — issue_comment jobs run the default branch's YAML, so base always carries the verify-pr skill by the time this job exists * fix(triage): harden /verify gate, comment budget, and evidence hosting per review Address review round 5078770575 items 1-3 plus the cheap follow-ups: - authorize: /verify now requires write from BOTH the PR author (whose code runs) and the commenter (who spends a scarce runner slot + model budget) — a drive-by account can no longer burn 45 minutes of ecs-qwen on someone else's PR; duplicates check once; /tmux and /triage gates unchanged. Replayed 8 principal scenarios against a stubbed gh - authorize acks /verify with the eyes reaction from the always-hosted job, so a queued/saturated sandbox pool no longer means total silence - publish: emit_block escapes FIRST and caps the escaped size (45 KB for the report) — a raw-side cap let dense <>& content inflate past GitHub's 65,536-char comment limit, 422 the post, and strand the running status with no report at all; iconv -c keeps a UTF-8 sequence split by the byte cut (likely, given the mandated 中文 summary) from shipping broken; replayed: 50 KB dense report -> 45,873-byte body - publish: image cap is byte-exact (-size -2097153c; find's -2M rounds sizes UP to MiB, silently making the documented 2 MB cap 1 MiB), bytes must carry the PNG magic (extension is attacker-choosable), duplicate sanitized names dedupe instead of overwriting + double-rendering, and dropped images are reported in the comment instead of vanishing - publish: weak terminal notices (cancelled/infra/skipped/n-a) only replace this run's own running status; a previous round's real report survives as the marker comment and the notice posts fresh - publish: report.md/assertions.json lookups pin the artifact-dir shape and sort (bare find -name order is filesystem-dependent); the verify job's verdict.txt lookup sorts likewise - verify: global npm install runs from RUNNER_TEMP (the persistent workspace still holds the PREVIOUS run's tree, whose .npmrc would apply to a root install); both cleanup passes remove leftover tmp/ worktrees (git worktree prune alone only drops metadata); the run step no longer re-chowns 50k node_modules files; pr-assets clone sets its committer identity once so the racing-push rebase retry can commit - skill: worktree guidance now tells the agent to remove its base tree itself, with the workflow sweep as backstop only * fix(triage): close runtime-plant and stale-RUNNER_TEMP channels in /verify Address review round 2 (comment 5079157987) and the CHANGES_REQUESTED round on the verify lane: - run step re-sweeps tmp/*-verify-* AFTER npm ci/build and before the agent starts: the pin step's sweep runs before PR lifecycle scripts (postinstall etc.), which could re-plant a fake artifact dir whose zeroed timestamp deterministically wins the sorted collector. From the sweep on, only the agent writes those dirs; a steered agent forging its own artifacts remains the documented advisory-report residual - RUNNER_TEMP verify-results/verify-context are rm'd before mkdir: the pool is persistent and runner temp hygiene is runner-managed — a stale report or previous-report.md from ANOTHER PR must never ride along - symlinks are stripped from verify-results before upload: actions/upload-artifact dereferences them, so a node-planted link would exfiltrate whatever it points at into the artifact - a trusted commenter invoking /verify on a PR whose author lacks write now gets an explanation comment from the hosted authorize job instead of total silence (the commenter is checked first; drive-by accounts and API errors still get nothing); job timeout 45->60 so a slow install can never let the JOB limit kill the agent past its own graceful 25m budget - stale tmp/base-tree (skill's canonical scratch worktree) is removed by name at job start — a plain dir isn't git-registered, so the worktree sweep alone misses it and the next worktree add would fail - scripts/tests/qwen-triage-workflow.test.js gains a verify-lane describe block: an 8-arm stub-gh replay of the dual principal gate (drive-by deny, author-without-write deny + explain flag, self-comment dedupe, 404 fail-closed, /tmux and /triage unchanged) plus guards for the post-prepare sweep placement, the symlink strip, and the RUNNER_TEMP resets — the replay found this commit's sweep edit had silently not applied, which is exactly the regression class it exists to catch * fix(triage): close proxy-hijack, gate-bypass, and false-verdict paths in /verify Address the Codex /review round (19 findings) and the bot's follow-up. Each fix was replayed locally; the proxy fix has a decisive A/B. Gate and routing: - the shell command match is case-insensitive: GitHub Actions expression comparisons ignore case, so `@QWEN-CODE /VERIFY` reached the step and fell through to the commenter-only branch — running the PR author's code with the author never checked - the verify ack and denial notice require github.event.issue.pull_request: /verify on a plain issue was acknowledged but could never report - publish-verify joins the verify job's per-PR concurrency group, and a failed PATCH falls back to posting fresh instead of going silent Untrusted-input paths: - the model proxy binds an EPHEMERAL port, reports it through a root-owned file, and its health check must echo a per-run nonce with the recorded PID alive. A/B with a squatter on 8787: the old code's proxy dies EADDRINUSE yet still reports enabled and points qwen at the squatter; the new code comes up unaffected on an ephemeral port - worktree-scoped git config is deleted before hooksPath is resolved: `extensions.worktreeConfig` is allowlisted and .git/config.worktree is invisible to `git config --local`, so a prior run could set core.hooksPath=/ and make the hook sweep's recursive delete walk / as root (verified locally). The sweep now also refuses any hooks path outside the repository's git dir - marker-comment lookups accept only bot-owned comments that START with the marker: any user can paste the marker and divert the bot into PATCHing a stranger's comment - the upload staging dir is re-flushed after npm lifecycle scripts Honest verdicts: - the docs-only classifier no longer uses a pipeline (grep -q made the writer take SIGPIPE, so under pipefail a long file list with an early code file classified a code PR as docs-only and skipped verification), and executable markdown/YAML (.qwen, .github/workflows, scripts) is classified as behavioral before the extension rule - tee's status is checked alongside qwen's: a full results volume made a truncated evidence stream publish as pass - 137 is split by elapsed budget into watchdog timeout vs crash/OOM - the agent's verdict is honored only for VERDICT=pass with a report and zero failed assertions; otherwise the process outcome headlines and the scope paragraph says the run did not complete - verdict.txt is read through a bounded prefix (SIGPIPE under pipefail) Skill contract corrections: per-commit tables only when the commits are reachable at depth 2 (else aggregate + Not covered); internal workspace symlinks must have their realpaths asserted before a base control is trusted; repo lint gates and event-history cost math are qualified to what the token-free container can actually run; --repo is never inferred from `origin` (a fork holds a different PR under the same number). Tests: 9 new guards, all mutation-verified (reverting each fix turns one red), including an executable escaping/size-cap/UTF-8 test for the publisher's own emit_block and a fix to the earlier command-file test, which matched the tmux job's identically named step. * fix(triage): re-establish the /verify trust boundary after PR code runs Third review round (31 findings). The unifying defect: everything the lane pinned or swept happened BEFORE npm ci/build executed PR-authored lifecycle scripts as node, so each control could be undone afterwards. Trust boundary, rebuilt in order before the agent starts: - kill every process owned by the build user and fail the step if any survives — a detached postinstall child could otherwise wait out each one-shot sweep and re-plant afterwards - re-pin .qwen from the base commit again, now root-owned and read-only: the prepare step chowns the workspace to node, so a lifecycle script could rewrite the very skill that defines /verify-pr - give the agent a fresh HOME/QWEN_HOME: qwen loads user-scope file commands from $HOME/.qwen, and /home/node belongs to the build user, so a planted commands/verify-pr.toml could shadow the pinned skill - the model proxy now requires a per-run bearer token, closing the blind-localhost-scan path to an unauthenticated signer for the real model credential (a command the agent itself launches still inherits it — documented residual, not closed) Authorization and lifecycle: - re-verify the PR author's write permission at execution time and pin the authorized head OID; refuse if the checked-out HEAD^2 differs, so a push during the runner wait cannot smuggle in unreviewed code - validate each principal separately: an empty author vanished in word splitting and left only the commenter checked - honor MAINTAINER_ECS_RUNNER_DISABLED with an explicit notice instead of queueing forever against a disabled pool - status comments carry a machine state marker; inferring 'running' from prose let a report quoting that sentence be overwritten - previous-report.md snapshots the newest substantive report, never a weak/cancelled notice, so prior findings survive into the next round - bot-identity lookup failures fail closed instead of widening the ownership filter to every user's comments - publish-verify uses a per-run concurrency group: a per-PR group holds only one pending job, so a second /verify could cancel a completed run's pending publisher Correctness: - install/build failures are classified: signals, ENOSPC, registry and network errors are infra-error, not a PR verdict - watchdog classification measures the child's own elapsed time, not shell-global $SECONDS which includes proxy setup - assertions.json must be three non-negative integers with a positive total and total == pass + fail before it counts as evidence - the proxy keeps its upstream deadline armed until the body ends and aborts upstream when the client disconnects - cleanups remove .qwen/tmp itself: PR code can make it a symlink, and globbing below it deleted the target's contents as root (verified) - emit_block materializes the escaped text and truncates on a character boundary via node — iconv -c passes an incomplete trailing sequence through on BSD (measured), which the new test caught Skill: local mode requires the same isolation CI provides and must not assume HEAD^1/HEAD^2 on a plain head checkout; shallow boundaries make rev-list counts unreliable for per-commit claims; never run scripts/lint.js with no arguments (it runs prettier --write and rewrites the tree under the harnesses); a vacuity check must fail the intended assertion, not the import. pr-workflow.md now says both sandboxed lanes need the author to have write, so triage stops recommending a guaranteed denial on external PRs. Tests: 9 more guards, all mutation-verified, including executable replays of the docs-only classifier (SIGPIPE + executable-markdown cases), the uppercase-command gate, the empty-principal deny, and the untrusted-image hosting path against a bare pr-assets remote. * test(triage): pass the classifier fixture through a file, not argv The new docs-only classifier replay passed on macOS and failed on CI with `Cannot read properties of undefined (reading 'trim')`: its 60,001-entry fixture is ~889 KB and was passed as a single argv element. Linux caps one argument at MAX_ARG_STRLEN (128 KB), so the spawn failed with E2BIG and stdout was undefined; macOS has no per-argument limit and only a ~1 MB total, so the same call succeeded locally (verified both). Write the list to a temp file and pass the path. The harness now also asserts the spawn succeeded, so a future spawn failure reports itself instead of surfacing as a TypeError on undefined output. * fix(triage): make the /verify report match what the run actually produced Three publisher findings, all introduced by my own previous round: - an artifact download failure (the step is continue-on-error) let the full-report path run with no results: the headline read 'completed' and the scope paragraph claimed the A/B, the harnesses and the gates had run when nothing had been delivered. The download outcome is now an input, and its failure gets its own body saying the results could not be retrieved - the prepare-failure branch ignored the verdict the prepare step had just computed, so an install killed by a registry outage or OOM (classified infra-error) still told the author 'this is treated as a PR failure verdict rather than an infrastructure failure' — the exact opposite. It now branches on the verdict, and an infra-classified prepare failure is a weak body that cannot overwrite a real report - weak notices were being snapshotted as the follow-up round's previous-report.md: they lack the running marker, so 'newest non-running comment' selected them. Bodies that carry findings now mark themselves (qwen-triage:verify-substantive) and the snapshot selects on that marker. A/B on the real jq: report A then cancelled B now snapshots A (101), the old filter picked B (102) Tests: 4 more guards, all mutation-verified — the publisher is rendered for each outcome with a stubbed gh and the assertions read the body it would post, and the snapshot test runs the workflow's own jq program verbatim against a paginate-shaped fixture. * fix(triage): stop PR build output from masquerading as an infra failure Two review findings plus a test-helper hazard: - classify_failure grepped the prepare log for bare words like ENOSPC and ETIMEDOUT, but that log is written by PR-controlled code: a genuine build failure that merely prints 'expected ETIMEDOUT to equal ok' would be published as an infrastructure incident, telling the author to re-run something that fails identically. The patterns are now anchored to lines only npm's reporter or the kernel emits ('npm ERR! code E…', 'npm ERR! network …', kernel OOM, bare 'Killed'); a signal exit still needs no log evidence. Replayed 10 cells: four PR-authored logs quoting infra words stay 'fail', five real diagnostics and one signal exit are 'infra-error' - the two execution-time controls added last round — re-verifying the author's permission after the runner wait, and refusing a head that moved since authorization — had no tests. Both are now executed: the re-auth snippet against a stubbed permission API (write proceeds and pins head_oid; read skips with a publishable reason), and the pin step against a real git repo with a real merge commit (matching head proceeds, moved head exits non-zero) - add a stepIn(job, step) test helper. Several step names exist in both the tmux and verify jobs, and the unscoped step() returns the first match, so a verify-lane assertion silently tests the tmux copy — that has now bitten this suite three times, including in this commit. * docs(triage): teach verify-pr test-only PRs, differential oracles, gate liveness Fold techniques from the round-2 verification on QwenLM#7620 (an ANSI parser PR) that the skill had no equivalent for: - test-only PRs get their own method: a mutation A/B across TEST FILES (same mutants of the unmodified production file, only the test file swapped), reporting killed/total on both sides, requiring that no mutant regressed from killed to survived, checking that the killing assertion is the one the commit claims to have strengthened, and adjudicating every survivor as coverage gap or defect with independent evidence rather than by inspection - when the code emulates a known implementation, that implementation is the oracle: feed identical input to both and report disagreement counts per side, lift reference tables verbatim out of the shipped dependency, and build the corpus from bytes captured off a real producer alongside synthesized sweeps - prove a gate is live before citing it: plant a violation the linter must catch, confirm it is reported, remove it — a linter that matched no files exits 0 exactly like one that passed - attribute pre-existing failures by byte-identical failing file AND test names on both sides, with deltas, not just totals - when the base is far behind, verify the merge: trial-merge into current main, confirm it is conflict-free, and re-run the affected suite on the merged tree - round continuity gains its one legitimate shortcut: a production file proven byte-identical (sha256 quoted at both heads) carries prior evidence forward by construction * style(triage): reflow verify-pr skill to prettier's markdown wrapping The previous commit's added paragraphs were hand-wrapped and prettier --check flagged the file; the repo runs prettier over all of it. * test(triage): cover the disabled-runner-pool notice The kill-switch path had no test: a refactor could drop the notice and leave a /verify request acknowledged with 👀 but permanently unanswered, since the verify job refuses to start and publish-verify skips with it. Fold the step into the existing PR-guard loop (now scoped through stepIn, so it cannot match a same-named step in another job) and assert the parts that make the answer useful — the kill-switch and permission conditions, both languages, the alternative it points at, and the verify job's own exclusion of the disabled pool. All three mutations turn it red: removing the step, dropping its PR guard, or letting the verify job queue against the disabled pool. * fix(triage): repair a step-killing PIPESTATUS read and six forgeable controls Sixth review round, 12 findings. Several are regressions from my own two previous rounds; the first would have broken every single run. - `AGENT_STATUS=${PIPESTATUS[0]}` is itself a command and resets PIPESTATUS, so the next line's ${PIPESTATUS[1]} was unset and `set -u` aborted the step immediately after the agent finished — before artifact collection, the verdict, or anything else. Verified by replaying the exact structure: 'PIPESTATUS[1]: unbound variable'. Both elements are now snapshotted in one command - concurrency predicates were broader than the job conditions they guard, and GitHub evaluates concurrency BEFORE the job `if`: a /verify comment entered the triage job's shared per-PR group (where it could displace a pending /triage and then skip), and a /verify queued while the runner kill switch was on did the same to a real verification. Both predicates now match their job's runnable set exactly - an outward-resolving .git/hooks entry was only warned about and left in place, so the next root-owned git command would run it. It is now unlinked without traversing its target, a root-owned hooks directory is restored, and core.hooksPath is unset - the second .qwen pin re-derived HEAD^1 from git metadata after the workspace, including .git, had been handed to the build user. The base OID is now recorded while .git is still root-owned and the re-pin archives that content-addressed OID - classify_failure took both of its inputs from PR-controlled sources: a lifecycle script can exit with a signal status and can print any line the log patterns matched, turning its own deterministic breakage into 'infrastructure, please re-run' — which hid the failure and preserved a stale report. No infra verdict is derivable there, so the prepare step reports `fail` and lets the embedded log speak for itself - cleanups descended through PR-writable parents: `.qwen` itself can be a symlink, and the worktree sweep trusted git metadata with only a lexical prefix check. Symlinks are unlinked without traversal and worktree paths must canonicalize inside the workspace. Replayed all three escapes - skipped and docs-only outcomes upload no artifact, so the new download-failure branch pre-empted them and made their real reason unreachable; they are answered first now - a run that crashed before writing report.md still claimed the substantive marker, letting a headline overwrite the previous round's evidence. The marker now requires a report Skill: the byte-identical shortcut needs the whole input closure, not one file hash; the credential-free local path cannot call `gh` at all (fetch the metadata outside and mount it read-only); and the A/B base is `baseRefOid` in local mode, not `HEAD^1`. Tests: 7 new guards plus 4 updated to the new shapes, all mutation-verified (50/50). * fix(triage): answer dropped /verify requests and prove the proxy rejects Maintainer review (yiliang114), 7 items: - a third /verify while two runs are in flight is dropped by the concurrency group with no job and therefore no comment. The hosted authorize job now counts this workflow's other in-flight runs and says so; an API hiccup leaves the request alone rather than denying it - the proxy's bearer check had no executable test. It now starts the real proxy against a real upstream and issues real requests: no header and a wrong token are 401, this run's token is 200, and a route other than /chat/completions is 403 — with the health endpoint echoing the nonce - the 502 path forwarded the raw upstream error, which can name resolved hosts and TLS detail to PR code. It logs server-side and returns a generic failure - publish-verify inherited the 360-minute default; it downloads one artifact and posts one comment, so it is bounded at 10 - removing the log classifier last round left the comment block it replaced, which still said failures are classified from the exit status and the log. Deleted - that removal also left every install failure reported as the PR's fault, including a registry outage. There is exactly one signal here PR code cannot write — asking the registry ourselves, as root, with the container's resolver — so an install failure is downgraded to infra-error only when that probe fails. It proves reachability now rather than at failure time, so it can only ever downgrade, never confirm; a build failure has no equivalent and stays the tree's problem - the skill's local-invocation warning ran into the preceding sentence, which GFM renders as one paragraph Tests: 5 new guards, all mutation-verified (55/55). * fix(triage): resolve hooks hermetically and mirror symlink guards at job end Maintainer review round (doudouOUC), 6 findings. Two were Critical and both reproduced: - the hooks sweep resolved its path with the ambient git config in play. With a global core.hooksPath set — which the reviewer has and I do not, which is why my earlier replay showed a false pass — `git rev-parse --git-path hooks` returns that global path, the in-git-dir guard reads 'outside', and a planted `.git/hooks` symlink survives untouched. A/B: old code leaves the symlink under a global hooksPath, new code removes it in both environments and never touches the link target. Resolution now runs with GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null - the END-of-job cleanup still used the bare `rm -rf .qwen/tmp` that the start-of-job cleaner was hardened against two rounds ago. The agent executes PR code between the two, so the end is no safer than the start: it now unlinks symlinks without descending and canonicalizes worktree paths inside the workspace before deleting Plus four suggestions, all valid: - the saturation notice counted this workflow's in-flight runs across every PR while the concurrency group is per-PR, so a run on another PR would trigger a warning about a queue that does not exist. It now matches on the PR title (the only per-PR handle an issue_comment run record carries) and stays silent when that cannot be resolved - the skill recommended `require.resolve` for the workspace-realpath check; these packages are ESM-only with import-only exports, so it throws ERR_PACKAGE_PATH_NOT_EXPORTED and reads like a missing module. Verified, and replaced with `readlink -f node_modules/@qwen-code/...` - the symlink-escape test inherited the developer's git config, which is what hid the first finding. It now runs with global/system config neutralized AND repeats the case with a global core.hooksPath planted - the publisher's build-phase arm was never rendered by any test (every case used 'install'), so a typo in that command name would have shipped. Now covered, along with an unrecognized phase Mutation-verified 4/4. The hooks guard needed a discriminating assertion: git's own `*.sample` files must survive the sweep, because the outward-path fallback removes the whole directory and would otherwise satisfy a bare 'planted hook is gone' check. * fix(triage): count only /verify runs for saturation, and test the PATCH arm Bot review round, 2 suggestions, both valid: - the saturation notice matched runs by PR title, which narrowed to this PR but not to /verify. /triage and /tmux live in their own concurrency groups, so two of those in flight would warn about a verify queue that is actually empty. It now also requires the run to have a job named 'verify' — the run record carries no command, but its job list does. Replayed: two non-verify runs stay silent, two verify runs warn - every publish fixture returned an empty comments listing, so the PATCH arm was never executed: a broken PATCH would have stranded the running status comment and posted a duplicate below it, with the suite green. The publisher now runs against a stubbed listing and the test asserts which verb went to which comment id — bot-owned live status is PATCHed in place, an absent comment posts fresh, and a marker comment owned by someone else is left alone and posted around Mutation-verified 3/3: counting every command, never PATCHing, and accepting foreign-owned markers each turn one test red. Two stub bugs found while writing these, both mine and both silent: ${*#pattern} applies per positional parameter rather than to the joined string (yielding a wrong run id), and the paginate fixture needs one array per page, not an array of pages. * fix(triage): fix the real silent drop and drop the step built on a wrong premise Review round 4. The blocker was mine twice over: the saturation notice I added last round had GitHub's concurrency semantics backwards, and the silent drop it claimed to cover was somewhere else entirely. - GitHub cancels the OLDER pending run in a group and admits the new one (confirmed against the workflow-syntax reference). My step told the person who had just typed /verify that their request might be dropped, when theirs is the one that runs — and said nothing to the person whose queued run actually died. This PR already had it right in publish-verify's own comment, so the file contradicted itself and the user-facing copy followed the wrong half. The step is removed rather than reworded: with the fix below there is nothing left for it to warn about, and it cost 2+N API calls on every /verify. - the actual drop: a verify job cancelled while still PENDING never reaches a runner, so its outputs block — where the "|| github.event.issue.number" fallback lived — is never evaluated. publish-verify then read an empty PR_NUMBER, hit its own guard and exited 0, making the cancelled branch unreachable in exactly the scenario that produces cancellations. The fallback now lives where the value is read. Reproduced both arms by executing the real step: with a number the cancelled notice posts, with an empty one it only warns. - same one-line class in publish-tmux, fixed alongside. Two copy defects from the classifier removal, both mis-attribution pointed the other way: - the infra-error body still named a signal/OOM kill and a full disk, none of which the current prepare step can produce — infra-error now requires npm ci to fail AND the registry probe to fail. It names that condition only, and offers a re-run instead of asserting it is the fix. - the code comment above it still described the deleted classifier. Also fixes the indentation break an earlier scripted edit left in the publish body builder, and replaces the saturation test with one that executes the cancelled path. Mutation-verified 2/2; the copy needed its own guard, since reverting the wording alone left every test green. * docs(triage): teach verify-pr survivor accounting and observability regressions Fold techniques from the re-verification on QwenLM#7709 that the skill had no equivalent for: - the mutation matrix must report the mutations that changed NOTHING, not only the ones that failed. Each survivor gets classified as an ordinary coverage gap or as dead code — a guard whose deletion leaves every test green is one of those two, and the difference is what the author needs. Survivors mirroring a pre-existing gap are labelled as such, and the set is framed as completeness reporting rather than merge conditions - the sharper case that report demonstrates: a test that passes for the WRONG REASON. If deleting the new guard leaves its own new test green, that test is pinned by an earlier early-return, not by the change, and asserts nothing about it. Name what actually pins it - and do not generalize from one dead guard to its siblings: the same report shows a clause that is unreachable on one path while being the only protection on another. Check each, report the contrast - observability regressions: when a change suppresses output, follow the value before calling the suppression correct. A bare catch on the path plus a field with no readers anywhere in the repo means the cause is now unobservable even in devtools — a real loss that no behavioural assertion can see - report structure gains a Corrections section: when an earlier round or bot comment described the code inaccurately, state the correct fact with evidence and label it as a correction to the description, not a request to change code. A wrong description left standing costs the next reader more than the original finding did * fix(triage): carry the /verify lane's hardening across to /tmux The /tmux job executes the same untrusted PR code, as the same user, on the same persistent self-hosted pool as the /verify lane that QwenLM#7710 hardened. Five of those controls had no equivalent here. Each was found on the verify side by reproducing an attack or a failure, not by reading the code, so the same evidence applies unchanged. - the model proxy bound a FIXED port (8787). PR lifecycle scripts run before it, so a detached child can squat that port: the real proxy then dies with EADDRINUSE while the health probe succeeds against the squatter, and the agent takes its chat completions. Now an ephemeral port published through a root-owned file, a per-run nonce the health endpoint must echo, and a liveness check on the PID we started. Replayed with 8787 occupied: the proxy comes up on an OS-chosen port and answers with the nonce. - nothing swept planted artifact directories. npm ci/build run the PR's lifecycle scripts, which can create tmp/<name>-tmux-<ts>/ holding a report.md and a transcript; the collector globs *-tmux-* and the publisher takes the first match, so a planted directory could supply the comment's contents. Swept after the last PR-controlled process and before the agent. - the global npm install ran with the workspace as cwd, where the PREVIOUS run's checked-out tree still sits. npm reads a cwd .npmrc, and a --registry flag does not override script-shell or hooks, so that config reached a root-privileged install. It now runs from RUNNER_TEMP. - the end-of-job cleanup globbed below .qwen/tmp. PR code ran in this workspace, so either .qwen or .qwen/tmp can be a symlink out of the tree — verified on the verify lane, where the glob deleted the link target's contents as root. Symlinks are unlinked without descending. - emit_block capped the raw log then escaped it. Escaping inflates every & < > by 4-5 bytes, so dense content can push the assembled body past GitHub's 65,536-character comment limit, 422 the post, and leave no comment at all. It now escapes first, caps the escaped bytes, and truncates on a character boundary via node — BSD iconv -c passes an incomplete trailing UTF-8 sequence through unchanged. Tests: a tmux-lane-parity suite, all six mutations verified (restoring the fixed port, dropping the sweep, moving the install back, dropping the symlink guard, reverting to a raw-side cap, and dropping the character-boundary truncation each turn one test red; a no-op control correctly changes nothing). One pre-existing assertion updated: it pinned emit_block's old inline-capture shape, and the guarantee it protects — a render failure is caught — is asserted in the new form. Also adds the regression guard for the publish-tmux PR_NUMBER fallback that landed in QwenLM#7710 without one: a job cancelled while pending never evaluates its outputs, so without the fallback the result comment silently does not post. * fix(triage): address review — symlink guard, artifact strip, bearer auth (QwenLM#7753) * fix(triage): address R2 review — proxy parity, bearer wire tests, process kill (QwenLM#7753) * fix(triage): address R3 review — publisher parity, dedup ownership, cap budget tests (QwenLM#7753) * fix(triage): address R4 review — drop redundant tmux-lane .mjs guards (QwenLM#7753) * fix(triage): address R5 review — tmp symlink sweep guard, proxy timer clear (QwenLM#7753) * fix(triage): address R6 review — hoist proxy timer out of try, dead-upstream 502 tests (QwenLM#7753) * fix(triage): address R7 review — make proxy watchdog idle, end stalled response (QwenLM#7753) --------- Co-authored-by: wenshao <wenshao@example.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
|
Released in v0.21.1. |



What this PR does
This PR moves
iconv-lite,@xterm/headless, andsimple-gitout of the ACP child's eager static import closure and loads each dependency once at its first real use. It preserves the synchronous package-root encoding helpers through a compatibility entry, uses asynchronous lazy variants on internal file-service paths, defers terminal construction until the PTY path is selected, and keeps Git service construction side-effect-free until an operation actually needs Git.Narrow deferred Core runtime entries prevent namespace imports from retaining the synchronous encoding compatibility path. The ACP bundle guard now rejects any static path from the ACP runtime to these three packages while allowing dynamic-only chunks.
Why it's needed
Issue #7264 identified these dependencies as approximately 890 KiB of direct package input in every cold ACP child even though most sessions do not use non-UTF-8 codecs, PTY terminal replay, or Git worktree operations during bootstrap. On the measured prototype artifact, the ACP static closure decreased from 13,405,027 to 12,314,617 bytes and all three packages reached zero bytes in the static closure.
On the 2-vCPU reference host, 30 alternating cold pairs measured process-to-first-session P50 improving from 1877.7 ms to 1733.3 ms,
channel.initializeP50 improving from 896.2 ms to 831.5 ms, and peak process-tree RSS P50 decreasing from 417.0 MB to 408.1 MB. These performance numbers apply to the recorded prototype artifact SHA-256f0ac7edc7665752efac7b7bfbb4fb055ce2d8ef1a8ae5dd1af630305a2c84d28; the final commit was rebased onto currentmainand locally rebuilt and guarded, but was not presented as a fresh 30-pair remote rerun.Reviewer Test Plan
How to verify
Build and bundle the CLI, then run the serve fast-path bundle check. The check should pass and a metafile traversal from the ACP runtime should attribute zero static-closure bytes to
iconv-lite,@xterm/headless, andsimple-git.Read and write ordinary UTF-8 text, then read and write a GBK file while preserving its encoding metadata. UTF-8 should complete without the codec chunk; the non-UTF-8 operation should load the codec once and preserve the decoded text and encoded bytes, including existing BOM behavior.
Run a shell command through the PTY path and through the child-process fallback. PTY output and terminal replay should remain unchanged, an abort during first use should not spawn a process, and a terminal chunk failure should retain the existing child-process fallback policy.
Exercise repository detection, worktree create/diff/apply/cleanup, and a Git-backed extension operation. The Git package should load once on the first real operation, structured failure-returning methods should retain their failure contracts, and construction alone should not load Git.
Evidence (Before & After)
N/A — no user-visible or TUI changes.
Tested on
Environment (optional)
Local verification used macOS 26.4.1 arm64, Node.js 22.22.3, and npm 10.9.8. It passed 461 focused Core tests, 374 focused CLI tests with one existing skip, 29 bundle-guard tests, targeted ESLint and Prettier checks, the CLI-only production build and bundle, the real startup bundle closure guard, and the full workspace typecheck.
Remote performance verification used Linux on a 2-vCPU host with 3.5 GiB RAM, no swap, and Node.js 22.23.1. It completed a smoke run, 30 alternating serial cold pairs, 30 alternating preheated pairs, concurrent first-session checks, telemetry-disabled startup, legacy single-session startup, and residual-process checks. The remote host did not have a Git executable, so it verified the
simple-gitdynamic chunk and factory there; real Git initialization and service behavior were verified locally.Risk & Scope
iconv-lite, while the targeted bundle plugin prevents that entry from entering the ACP startup closure.Linked Issues
Relates to #7264
中文说明
本 PR 做了什么
本 PR 将
iconv-lite、@xterm/headless和simple-git移出 ACP 子进程的启动期静态导入闭包,并在首次真实使用时分别只加载一次。它通过兼容入口保留包根级同步编码辅助函数,通过异步懒加载变体处理内部文件服务路径,在选中 PTY 路径后才加载终端实现,并确保 Git 服务构造本身无副作用,直到实际 Git 操作才加载依赖。窄化的延迟 Core runtime 入口避免 namespace import 保留同步编码兼容路径。ACP bundle guard 现在会拒绝 ACP runtime 到这三个包的任何静态路径,同时允许仅通过动态 import 到达的 chunk。
为什么需要
#7264 识别出这些依赖在每个冷启动 ACP 子进程中合计占用约 890 KiB 的直接包输入,尽管大多数会话在启动阶段不会使用非 UTF-8 编解码、PTY 终端回放或 Git worktree 操作。在已测 prototype artifact 上,ACP 静态闭包从 13,405,027 bytes 降至 12,314,617 bytes,三个包在静态闭包中的归因字节均降为 0。
在 2 vCPU 参考机器上,30 组交替冷启动配对测试显示:process-to-first-session P50 从 1877.7 ms 改善到 1733.3 ms,
channel.initializeP50 从 896.2 ms 改善到 831.5 ms,进程树峰值 RSS P50 从 417.0 MB 降至 408.1 MB。这些性能数据仅对应已记录的 prototype artifact SHA-256f0ac7edc7665752efac7b7bfbb4fb055ce2d8ef1a8ae5dd1af630305a2c84d28;最终提交已 rebase 到当前main并在本地重新构建和执行门禁,但没有被描述为一次新的远程 30 组配对复测。Reviewer 测试计划
如何验证
构建并 bundle CLI,然后运行 serve fast-path bundle 检查。检查应通过,并且从 ACP runtime 出发遍历 metafile 后,
iconv-lite、@xterm/headless和simple-git在静态闭包中的归因字节应全部为 0。先读写普通 UTF-8 文本,再读写一个保留编码元数据的 GBK 文件。UTF-8 路径应无需加载 codec chunk;非 UTF-8 操作应只加载一次 codec,并保持解码文本和编码字节不变,包括既有 BOM 行为。
分别通过 PTY 路径和 child-process fallback 运行 shell 命令。PTY 输出和终端回放应保持不变,首次使用期间发生 abort 时不应启动进程,终端 chunk 加载失败时应保留既有 child-process fallback 策略。
验证仓库识别、worktree 创建/diff/apply/cleanup 以及 Git 支持的扩展操作。Git 包应在首次真实操作时只加载一次,仅构造服务不应加载 Git,具有结构化失败返回值的方法应保留原有失败契约。
证据(修改前后)
N/A — 没有用户可见或 TUI 变化。
已测试平台
环境(可选)
本地验证环境为 macOS 26.4.1 arm64、Node.js 22.22.3 和 npm 10.9.8。已通过 461 个聚焦 Core 测试、374 个聚焦 CLI 测试(另有 1 个既有 skip)、29 个 bundle guard 测试、目标 ESLint 与 Prettier 检查、CLI-only 生产构建与 bundle、真实启动 bundle 闭包门禁,以及全 workspace typecheck。
远程性能验证使用 Linux 2 vCPU、3.5 GiB RAM、无 swap、Node.js 22.23.1 的机器。已完成 smoke、30 组交替串行冷启动配对、30 组交替 preheated 配对、并发首次 session、关闭 telemetry、legacy 单 session 和残留进程检查。远程机器没有 Git 可执行文件,因此远程只验证了
simple-git动态 chunk 和 factory;真实 Git 初始化与服务行为已在本地验证。风险与范围
iconv-lite支持的小型同步兼容入口,而定向 bundle 插件会阻止该入口进入 ACP 启动闭包。关联 Issue
Relates to #7264