Skip to content

fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi - #7620

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/web-shell-ansi-extended-color
Jul 26, 2026
Merged

fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi#7620
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/web-shell-ansi-extended-color

Conversation

@chinesepowered

@chinesepowered chinesepowered commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What this PR does

parseAnsi renders shell tool output in the web shell. It split each SGR escape on ; and treated every parameter as a standalone code — but the arguments of 38/48/58 (extended foreground/background/underline color) are not codes. This PR consumes those arguments instead of re-reading them, and resolves the foreground to a hex color.

Why it's needed

Feeding the color arguments back into the code loop corrupts the output — and not only for the color itself, but for styling that was already correct:

sequence before after
\e[38;5;2m (256-color green) dim: true, no color #48bb78
\e[1m\e[38;2;0;128;255m (bold + truecolor) bold: false, no color bold: true, #0080ff
\e[1m\e[48;5;22m (bold + 256-color bg) bold: false bold: true

Three distinct failures, all from the same root cause:

  • 38;5;2 → the index 2 is read as SGR 2, setting dim and producing no color.
  • Truecolor almost always contains a zero channel (any component at 0). That 0 hits the code === 0 branch and resets color, bold and dim mid-line.
  • A background like 48;5;22 feeds 22 to the reset-intensity branch, so setting a background silently un-bolds the text.

parseAnsi feeds ToolGroup.tsx, which renders the output of shell tools, so this affects any 256-color CLI — which is most of them.

The fix

When a parameter is 38, 48 or 58, read its argument form (5;<index> or 2;<r>;<g>;<b>) and advance past it. The foreground (38) resolves to hex:

  • 0–15 map onto the existing themed ANSI_COLORS palette, so 38;5;2 and 32 render as the same green.
  • 16–231 map onto the xterm 6×6×6 cube (levels 0, 95, 135, 175, 215, 255).
  • 232–255 map onto the 24-step grayscale ramp.

Background (48) and underline (58) color are parsed but not rendered — Segment has no field for them — so their arguments still cannot leak into the code stream. Malformed sequences (out-of-range index, truncated argument list, unknown form) yield no color rather than a bogus one and never fall through to the plain-code branches.

Reviewer Test Plan

How to verify

  • cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts → 9/9.
  • ansi.test.ts is new — there was no test for this module. Five cases assert the fixed behavior and pass only with this change; four more pin the pre-existing basic-color / bold / dim / reset behavior so the change can't regress it.
  • Reverting only ansi.ts fails exactly the five new cases:
    • does not read 256-color arguments as SGR codes
    • does not let truecolor channels reset the style
    • keeps background and underline color out of the code stream
    • drops malformed extended-color sequences without corrupting state
    • leaves an already-set color alone when the sequence is malformed
  • The palette-mapping assertions are checkable by hand: cube index 208 → (5,2,0) → #ff8700, corners 16 → #000000 and 231 → #ffffff, grayscale 232 → #080808 and 255 → #eeeeee.

Evidence (Before & After)

  • Before: a tool that prints a 256-color prompt (e.g. \e[38;5;208m➜\e[0m) renders the glyph uncolored and, if it was inside a bold run, un-bolded.
  • After: it renders orange (#ff8700) with bold preserved.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS: ansi.test.ts 9/9. In the full packages/web-shell run (1160 tests) this branch is 1151 passed / 9 failed — byte-identical failures to a clean checkout of the same tree, which is 1150 passed / 9 failed; the only delta is the one test this change adds. Those 9 pre-existing failures are all build artifact — package boundary cases that need a built dist, and the 47 test files that fail to load do so on unresolved workspace modules (@qwen-code/sdk/daemon, @qwen-code/webui/daemon-react-sdk) — neither is touched here. tsc --noEmit reports zero errors in ansi.ts / ansi.test.ts; eslint and prettier clean. Pure string parsing with no platform-dependent behavior, so no manual QA is required; CI covers Windows/Linux.

Environment (optional)

Node v24; @qwen-code/web-shell workspace; vitest 3.2.

Risk & Scope

  • Main risk or tradeoff: 256/truecolor foreground now renders as a computed hex rather than falling through to the (wrong) old behavior. The mapping is the standard xterm one; the 0–15 range deliberately reuses the existing palette so basic colors are unchanged whether written as 32 or 38;5;2.
  • Not validated / out of scope: background and underline color are still not rendered — Segment has no field for them. This PR only stops their arguments from corrupting the code stream; surfacing them is a separate feature. Cursor-movement and other non-SGR escapes are likewise untouched.
  • Breaking changes / migration notes: none — the only behavior change is that previously-corrupted output now renders correctly.

Linked Issues

None — found while reading parseAnsi against the SGR extended-color grammar.

中文说明

本 PR 的作用

parseAnsi 负责在 web shell 中渲染 shell 工具输出。它按 ; 拆分每个 SGR 转义序列并把每个参数都当作独立的 code——但 38/48/58(扩展前景/背景/下划线颜色)后面的参数并不是 code。本 PR 改为消费这些参数,并将前景色解析为 hex。

为什么需要

把颜色参数重新塞回 code 循环会破坏输出——而且不仅是颜色本身,连原本正确的样式也会被破坏:

序列 修复前 修复后
\e[38;5;2m(256 色绿) dim: true,无颜色 #48bb78
\e[1m\e[38;2;0;128;255m(粗体 + 真彩) bold: false,无颜色 bold: true#0080ff
\e[1m\e[48;5;22m(粗体 + 256 色背景) bold: false bold: true

三种不同的失效,根因相同:

  • 38;5;2 → 索引 2 被当作 SGR 2,从而设置 dim 且无颜色。
  • 真彩几乎总含有为 0 的通道(某个分量为 0)。该 0 命中 code === 0 分支,在行中途重置颜色、bold 与 dim
  • 48;5;22 这样的背景会把 22 送入「重置粗细」分支,于是设置背景会悄悄取消粗体

parseAnsi 供给 ToolGroup.tsx,后者渲染 shell 工具的输出,因此凡是 256 色 CLI(也就是绝大多数)都会受影响。

修复方式

当参数为 384858 时,读取其参数形式(5;<索引>2;<r>;<g>;<b>)并跳过。前景(38)解析为 hex:

  • 0–15 映射到既有的主题化 ANSI_COLORS 调色板,因此 38;5;232 渲染为同一种绿。
  • 16–231 映射到 xterm 6×6×6 色立方(各档为 0, 95, 135, 175, 215, 255)。
  • 232–255 映射到 24 级灰阶。

背景(48)与下划线(58)颜色被解析但不渲染——Segment 没有相应字段——因此它们的参数仍不会泄漏进 code 流。畸形序列(越界索引、参数截断、未知形式)返回无颜色而非错误颜色,且绝不会落到普通 code 分支。

复核测试计划

如何验证

  • cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts → 9/9 通过。
  • ansi.test.ts 为新增——此前该模块没有测试。五个用例断言修复后的行为,仅在本改动下通过;另有四个固定既有的基础色 / bold / dim / reset 行为,防止回归。
  • 仅还原 ansi.ts 时,恰好这五个新用例失败:
    • does not read 256-color arguments as SGR codes
    • does not let truecolor channels reset the style
    • keeps background and underline color out of the code stream
    • drops malformed extended-color sequences without corrupting state
    • leaves an already-set color alone when the sequence is malformed
  • 调色板映射可手工核对:色立方 208 → (5,2,0) → #ff8700,角点 16 → #000000231 → #ffffff,灰阶 232 → #080808255 → #eeeeee

证据(修复前后对比)

  • 修复前:打印 256 色提示符(如 \e[38;5;208m➜\e[0m)的工具,其字形无颜色,且若处于粗体段内还会被取消粗体。
  • 修复后:渲染为橙色(#ff8700)并保留粗体。

测试环境

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS:ansi.test.ts 9/9。在 packages/web-shell 完整运行(1160 个测试)中,本分支为 1151 通过 / 9 失败——与同一棵树的干净检出(1150 通过 / 9 失败)失败项完全一致,唯一差异是本改动新增的那个测试。这 9 个既有失败全部是需要已构建产物的 build artifact — package boundary 用例;另有 47 个测试文件因无法解析工作区模块(@qwen-code/sdk/daemon@qwen-code/webui/daemon-react-sdk)而加载失败——两者本 PR 均未触及。tsc --noEmitansi.ts / ansi.test.ts 零报错;eslint 与 prettier 干净。纯字符串解析、无平台相关行为,故无需人工 QA;Windows/Linux 由 CI 覆盖。

运行环境(可选)

Node v24;@qwen-code/web-shell 工作区;vitest 3.2。

风险与影响范围

  • 主要风险或权衡:256/真彩前景现在渲染为计算得到的 hex,而不再落回(错误的)旧行为。所用映射是标准 xterm 映射;0–15 区间刻意复用既有调色板,因此基础色无论写作 32 还是 38;5;2 都保持不变。
  • 未验证 / 范围之外:背景与下划线颜色仍未渲染——Segment 没有相应字段。本 PR 只是阻止其参数破坏 code 流;将其呈现出来是另一项功能。光标移动等非 SGR 转义同样未触及。
  • 破坏性变更 / 迁移说明:无——唯一的行为变化是此前被破坏的输出现在能正确渲染。

关联 Issue

无——在对照 SGR 扩展色文法阅读 parseAnsi 时发现。

parseAnsi read every ';'-separated SGR parameter as a standalone code,
but the arguments of 38/48/58 (extended foreground/background/underline
color) are not codes. `38;5;<n>` and `38;2;<r>;<g>;<b>` were fed back
into the code loop, so:

- `38;5;2` set dim (from color index 2) and produced no color;
- any truecolor value with a zero channel hit the `code === 0` branch
  and wiped the color, bold and dim already set on the line;
- a background such as `48;5;22` fed 22 to the reset-intensity branch,
  silently un-bolding the text.

Consume the 38/48/58 arguments instead of reading them as codes, and
resolve the foreground to hex: 0-15 map onto the existing themed palette,
16-231 onto the 6x6x6 cube, 232-255 onto the grayscale ramp. Background
and underline color are parsed but not rendered (Segment has no such
field) so their arguments still cannot leak into the code stream.

parseAnsi feeds shell tool-output rendering in ToolGroup, so this
affects any 256-color CLI. Adds ansi.test.ts (none existed); the four
new cases fail on the previous parser.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head f1bd14f. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

No screenshot changes against the PR base.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: this is a real, observed bug — not theoretical. The existing parseAnsi splits every SGR parameter on ; and treats each as an independent code, but the arguments of 38/48/58 (extended color) are not codes. The PR demonstrates three concrete failures with specific sequences: 38;5;2 sets dim instead of green, a truecolor zero channel triggers a mid-line reset, and 48;5;22 silently un-bolds text. The new test file reproduces all three and passes only with the fix.

Direction: aligned. The web shell renders shell tool output via parseAnsiToolGroup.tsx, and 256-color/truecolor sequences are ubiquitous in modern CLI tools. Correct ANSI parsing is core to the web shell's job.

Size: not applicable — packages/web-shell/client/utils/ is not a core module path.

Approach: the scope is tight — two files in one module, no unrelated changes. Consuming the extended-color arguments in the code loop is the standard fix for this class of bug. Foreground resolves to hex; background and underline are parsed but not rendered (Segment has no field for them), which is the right call — surfacing them is a separate feature. Nothing to cut.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是一个真实的、已观测到的 bug,而非理论性加固。现有的 parseAnsi; 拆分每个 SGR 参数并将每个都当作独立 code,但 38/48/58(扩展颜色)的参数并不是 code。PR 用具体序列展示了三种具体失效:38;5;2 设置了 dim 而非绿色,真彩的零通道触发行中途重置,48;5;22 悄悄取消粗体。新增测试文件复现了全部三种情况,且仅在修复后通过。

方向:对齐。Web shell 通过 parseAnsiToolGroup.tsx 渲染 shell 工具输出,256 色/真彩序列在现代 CLI 工具中无处不在。正确的 ANSI 解析是 web shell 的核心职责。

规模:不适用——packages/web-shell/client/utils/ 不是核心模块路径。

方案:范围紧凑——同一模块的两个文件,无无关改动。在 code 循环中消费扩展颜色参数是此类 bug 的标准修复方式。前景色解析为 hex;背景和下划线颜色被解析但不渲染(Segment 没有相应字段),这是正确的做法——呈现它们是另一项功能。没有需要砍掉的部分。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal: I would change the for...of loop to an indexed loop, detect 38/48/58, consume their 5;<index> or 2;<r>;<g>;<b> arguments, and resolve foreground to hex using the standard xterm mapping (0–15 → existing palette, 16–231 → 6×6×6 cube, 232–255 → grayscale ramp). Background and underline arguments would be consumed but not rendered, since Segment has no field for them.

Comparison: the PR matches this proposal exactly. No simpler path was missed.

The implementation is clean:

  • toHex() validates each channel (integer, 0–255) and returns undefined on any invalid input — no bogus colors.
  • xterm256() maps correctly: 0–7 → ANSI_COLORS[30+i], 8–15 → ANSI_COLORS[90+(i-8)] (so 38;5;2 and 32 render identically), 16–231 → cube via floor(v/36), floor(v/6)%6, v%6 with CUBE_LEVELS = [0,95,135,175,215,255], 232–255 → grayscale 8 + (i-232)*10. Hand-checked: 208 → (5,2,0) → #ff8700 ✓, 232 → #080808 ✓, 255 → #eeeeee ✓.
  • The main loop advances i past consumed arguments. Unknown mode (not 5 or 2) → break, dropping the rest of the sequence rather than guessing where the color ends — safe. A malformed value leaves the current color alone (ignored, not reset) — correct.
  • Only code === 38 writes to color; 48 and 58 consume arguments without side effects.

No correctness bugs, no security concerns, no convention violations. Comments explain why (the corruption mechanism), not what. The test file covers the five new behaviors plus four pre-existing ones, and the assertions are specific enough to catch regressions (whole-segment equality, not just individual fields).

Nothing to flag.

Testing

CI test evidence (fetched via API for f1bd14f0f9af4064bfccbeaa097277a49b821c5a):

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

All checks completed — no failures. macOS/Windows tests and CLI integration tests were skipped (expected for a web-shell-only change). The ubuntu unit suite and web-shell E2E smoke both passed.

Real-scenario (tmux): N/A — the change is in the web-shell browser component (parseAnsiToolGroup.tsx), not the terminal TUI. The unit tests and CI web-shell E2E smoke are the appropriate verification surface.

中文说明

代码审查

独立方案: 我会将 for...of 循环改为索引循环,检测 38/48/58,消费其 5;<索引>2;<r>;<g>;<b> 参数,并用标准 xterm 映射将前景色解析为 hex(0–15 → 既有调色板,16–231 → 6×6×6 色立方,232–255 → 灰阶)。背景和下划线参数被消费但不渲染,因为 Segment 没有相应字段。

对比: PR 与此方案完全一致,没有遗漏更简路径。

实现干净:toHex() 验证每个通道;xterm256() 映射正确(手工核对 208 → #ff8700232 → #080808255 → #eeeeee 均正确);主循环正确跳过已消费的参数;未知模式 → break;畸形值不重置颜色。仅 38 写入 color,48/58 无副作用。

无正确性 bug、无安全隐患、无规范违反。

测试

CI 证据(通过 API 获取):ubuntu 单元测试 ✅、web-shell E2E Smoke ✅、web-shell 视觉捕获 ✅、precheck ✅。macOS/Windows 测试和 CLI 集成测试按预期跳过。无失败。

实际场景(tmux):不适用——改动在 web-shell 浏览器组件中,非终端 TUI。单元测试和 CI web-shell E2E smoke 是合适的验证面。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, focused bug fix with a real reproduction, standard approach, and thorough tests.

This is exactly the kind of PR that's easy to say yes to. The bug is concrete and well-demonstrated — three distinct failures from one root cause (extended-color arguments read as SGR codes), each with a specific sequence and expected-vs-actual output. The fix is the standard approach: consume the arguments instead of re-reading them. The implementation is minimal (two files, one module), the xterm mapping is correct (hand-verified against the spec), and the test file pins both the new behavior and the pre-existing behavior so nothing regresses silently.

The scope discipline is good: background and underline color are parsed but not rendered, with a clear note that surfacing them is a separate feature. No drive-by changes, no over-abstraction. If I had to maintain this in six months, the comments and test names would tell me exactly what each branch does and why.

CI is green across the board on f1bd14f0f9af4064bfccbeaa097277a49b821c5a.

中文说明

置信度:5/5 — 干净、聚焦的 bug 修复,有真实复现、标准方案和充分测试。

这是一个容易说"是"的 PR。Bug 具体且展示充分——同一根因(扩展颜色参数被当作 SGR code 读取)导致三种不同失效,每种都有具体序列和预期/实际输出对比。修复是标准方案:消费参数而非重新读取。实现最小化(两个文件、一个模块),xterm 映射正确(已手工核对规范),测试文件同时固定了新行为和既有行为,防止静默回归。

范围控制良好:背景和下划线颜色被解析但不渲染,并明确说明呈现它们是另一项功能。无顺手改动、无过度抽象。

CI 在 f1bd14f0f9af4064bfccbeaa097277a49b821c5a 上全绿。

Qwen Code · qwen3.8-max-preview

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

Comment thread packages/web-shell/client/utils/ansi.ts Outdated
}
// Only the foreground maps onto a Segment. Background and underline
// color are still parsed so their arguments cannot leak into the loop.
if (code === 38) color = value;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] A malformed extended-color sequence clears a previously valid foreground color — Failure scenario: \x1b[31m\x1b[38;5;300mtext sets red via code 31, then xterm256(300) returns undefined (index > 255), and color = value unconditionally overwrites — the text renders in the default color instead of red. The same occurs for truncated truecolor (\x1b[31m\x1b[38;2;1;2mtext). The existing malformed-sequence test only starts from color: undefined (bold but no prior color), so this regression is invisible to the test suite.

Suggested change
if (code === 38) color = value;
if (code === 38 && value !== undefined) color = value;

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 32684dd — this one was a regression I introduced, not just a gap.

Before this PR, \x1b[31m\x1b[38;5;300m left the red intact: 38, 5 and 300 all fell through the code branches untouched. My version consumed the arguments correctly but then assigned the result unconditionally, so a sequence that failed to resolve actively cleared a color that had been set correctly — strictly worse than the behavior I was replacing, and the opposite of what the test named "without corrupting state" was supposed to be guarding.

Taken as suggested:

if (code === 38 && value !== undefined) color = value;

An unreadable sequence is now ignored rather than treated as a reset, which is also what a real terminal does with an out-of-range parameter.

Your read on the test was exactly right — it started from color: undefined, so a cleared color was invisible to it. Added the case it could not catch, covering both malformed forms you named plus the rest of the malformed set:

it('leaves an already-set color alone when the sequence is malformed', () => {
  for (const seq of ['38;5;300', '38;5', '38;2;1;2', '38;7;1', '38']) {
    expect(parseAnsi(`${ESC}31m${ESC}${seq}mtext`)).toEqual([
      { text: 'text', color: '#fc8181', bold: false, dim: false },
    ]);
  }
  // A well-formed sequence still replaces it.
  expect(parseAnsi(`${ESC}31m${ESC}38;5;21mtext`)[0]!.color).toBe('#0000ff');
});

The trailing assertion pins the other side, so "ignore malformed" can't silently degrade into "ignore everything". Fail-before/pass-after: reverting only ansi.ts fails exactly this new case, 1 failed | 8 passed; with the fix, 9/9.

Comment thread packages/web-shell/client/utils/ansi.ts Outdated
const mode = codes[i + 1];
let value: string | undefined;
if (mode === 5) {
value = xterm256(codes[i + 2]!);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Non-null assertions (!) on array accesses that can be undefined at runtime lie to the type system — toHex and xterm256 declare number parameters but receive undefined for truncated sequences like \x1b[38;5m. Today the Number.isInteger() guards catch it, but a future refactor that trusts the declared type (e.g. Math.round(index) before validation) would silently produce wrong output. The ! hides the type mismatch from the compiler.

Suggested change
value = xterm256(codes[i + 2]!);
value = xterm256(codes[i + 2]);

Widen the parameter types to match:

function toHex(r: number | undefined, g: number | undefined, b: number | undefined): string | undefined {
function xterm256(index: number | undefined): string | undefined {

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — taken in 32684dd. toHex and xterm256 now declare number | undefined, which is what a truncated sequence like \x1b[38;5m actually hands them, and the ! assertions at the call sites are gone.

I went one step further than the suggestion inside toHex. Widening the parameters alone still left ! in the body, since Number.isInteger() is not a type predicate and so doesn't narrow number | undefined — the assertion would have just moved rather than gone away. Restructuring the validation narrows properly instead:

function toHex(
  r: number | undefined,
  g: number | undefined,
  b: number | undefined,
): string | undefined {
  let hex = '#';
  for (const v of [r, g, b]) {
    if (v === undefined || !Number.isInteger(v) || v < 0 || v > 255) {
      return undefined;
    }
    hex += v.toString(16).padStart(2, '0');
  }
  return hex;
}

xterm256 takes the same shape (index === undefined || !Number.isInteger(index) first), which also lets the CUBE_LEVELS[...] lookups drop their assertions, since number | undefined is now the honest parameter type. There are no non-null assertions left in the module, so the refactor hazard you describe — a future change trusting the declared type — is now a compile error rather than silently wrong output.

tsc --noEmit reports zero errors in ansi.ts and ansi.test.ts; eslint clean.

…e is malformed

An unreadable 38/48/58 sequence was assigning its undefined result straight
to the color, so `\x1b[31m\x1b[38;5;300m` dropped the red that code 31 had
already set. Ignore the sequence instead and leave the color as-is; a
well-formed one still replaces it.

Also widen toHex/xterm256 to accept `number | undefined` so the truncated
arguments they are actually handed match their declared types, and drop the
non-null assertions that were hiding that mismatch from the compiler.

Add the regression case the existing malformed-sequence test could not catch:
it started from no color, so a cleared color was invisible to it.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — merge reference

Verified as maintainer on a real local build, not by reading the diff. Two isolated worktrees: PR head 32684dd6b and base 32c491fc6 (merge-base with main). macOS 15.6 · Node v22.23.1 · vitest 3.2.4.

Verdict: the fix is correct, matches a real terminal emulator byte for byte, and regresses nothing. Recommend merge. One documentation nit and three non-blocking follow-ups below.


1. The PR's own tests, and its A/B claim

utils/ansi.test.ts9/9 passed on the PR head. Reverting only ansi.ts to base while keeping the new test file fails exactly the five tests the description names — the other four (basic colour / bold / dim / reset / segment splitting) still pass, so the new tests are pinned to the fix and not to incidental behaviour.

⚠️ The verify command in the PR description does not run.
npx vitest run --root packages/web-shell --config vitest.config.ts client/utils/ansi.test.ts fails with
Cannot find module .../packages/web-shell/test/setup.ts. The config already sets root: 'client', so the CLI --root overrides it and setupFiles: ['./test/setup.ts'] resolves to the wrong directory. The working form is:

cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts

Description-only; the code is unaffected.

2. Mutation testing — do those tests actually hold the fix down?

13 mutants of ansi.ts, each run against the PR's unmodified test file: 10 caught, 3 survived. Cube levels, grayscale offset, channel order, both argument-advance counts, the break-on-unknown-form and the "malformed value must not reset the colour" rule are all genuinely pinned.

A/B and mutation matrix

The three survivors are test-coverage gaps, not defects — the shipped code behaves correctly in all three cases:

mutant why it survives shipped behaviour
M1 drop 58 from the trio the only 58 assertion checks bold; dropping the branch corrupts dim instead correct — \e[1m\e[58;5;2mdim:false (emulator agrees); base gives dim:true
M10 raise the index bound past 255 toHex's channel guard already rejects what 38;5;300 produces, so only index 256 would discriminate correct
M11 drop toHex's 0–255 guard no test uses an out-of-range truecolour channel correct — 38;2;999;0;0 yields no colour

Tightening the 58;5;2 assertion from [0]!.bold).toBe(true) to a full toEqual({ text: 'text', color: undefined, bold: true, dim: false }) would close M1. Optional.

3. Independent oracles — is the mapping actually right?

The reference table is not retyped by hand: it is the DEFAULT_ANSI_COLORS IIFE lifted verbatim out of the shipped @xterm/xterm bundle and evaluated with stub colour helpers. The differential run then feeds identical bytes to @xterm/headless — the real xterm.js VT parser — and to parseAnsi, comparing per-character (fg, bold, dim) cell by cell.

Independent oracles

  • Palette: 240/240 cube + grayscale indices (16–255) identical to xterm's own table. 38;5;<i> equals the basic SGR code for all 16 standard indices, so the deliberate themed-palette reuse holds. Truecolour 7/7 exact.
  • Differential: 1112 character cells across six corpora — full 38;5;<idx> sweeps (plain, bold-prefixed, and as a background), a truecolour grid, interleaved sequences, and real bytes captured from git diff --color=always and tput setaf 208 on this machine. The PR disagrees with the emulator on 0 cells. Base disagrees on 844.
  • Base quantified over all 256 indices of 38;5;<idx>: 240 render with no colour at all, 16 render a wrong colour (the index collided with an SGR colour code), and 1 wrongly sets dim.

4. Real render — the actual <ToolGroup>, not a mock

The real <ToolGroup> was mounted in jsdom on both checkouts with a Shell tool whose output is the genuine git diff --color=always byte stream plus a 256-colour prompt line, then the produced DOM was screenshotted under the repo's own ToolChrome.module.css and shadcn tokens. Only the CSS-module hash suffixes were rewritten so the source stylesheet applies; every inline style in the image is the component's own.

Before/after, light theme
Before/after, dark theme

Note the two rows that lose bold on mainBOLD TRUECOLOUR (zero channel hits the code === 0 reset) and bold on a 256-colour background (48;5;22 feeds 22 to reset-intensity). That is the part of this bug that damages output which was previously correct, and it is the strongest argument for merging.

5. Regression check

Full packages/web-shell suite, same command on both trees:

test files tests
base 32c491fc6 8 failed | 117 passed (125) 9 failed | 1727 passed (1736)
PR 32684dd6b 8 failed | 118 passed (126) 9 failed | 1736 passed (1745)

The delta is exactly +9 passing tests — the new file — with an identical failure profile. All 9 failing tests live in build-artifact.test.ts, which needs a built dist; unrelated and unchanged.

eslint and prettier --check are clean on both changed files. tsc --noEmit reports 60 errors in this local checkout, but the set is byte-identical on base and PR and none are in ansi.ts / ansi.test.ts — they come from workspace type deps that aren't built locally. CI builds first, and Test (ubuntu-latest, Node 22.x) is green.

6. Follow-ups — none block this PR

Follow-ups

  1. Colon-separated extended colour is not handled at all\e[38:5:208m and \e[38:2::255:140:0m (the ISO 8613-6 form emitted by tmux, delta and some ls builds). The CSI regex is [0-9;]*, so those sequences never match and the raw escape is rendered as literal text. Identical on base and PR — pre-existing, out of scope, worth a separate issue.
  2. Non-SGR escapes leak the same way\e[2K, bare \r progress rewrites, cursor movement. Also pre-existing.
  3. Out-of-range values diverge from the emulator by choice — xterm masks (38;2;999;0;0#e70000, 38;5;256 → palette 0); this PR drops to no colour. Defensible for malformed input, and strictly better than base; just noting the difference.
中文版本

本地验证报告 —— 合并参考

以维护者身份在本地真实构建验证,而非仅阅读 diff。使用两个隔离 worktree:PR head 32684dd6bbase 32c491fc6(与 main 的 merge-base)。macOS 15.6 · Node v22.23.1 · vitest 3.2.4。

结论:修复正确,与真实终端模拟器逐字节一致,且无回归。建议合并。 下面有 1 处文档问题与 3 项不阻塞的后续项。


1. PR 自带测试与其 A/B 声明

utils/ansi.test.ts 在 PR head 上 9/9 通过。仅将 ansi.ts 还原到 base、保留新测试文件时,恰好是描述中列出的那 5 个用例失败——另外 4 个(基础色 / bold / dim / reset / 分段)仍然通过,说明新测试确实锚定在这次修复上,而非附带行为。

⚠️ PR 描述里的验证命令跑不起来。
npx vitest run --root packages/web-shell --config vitest.config.ts client/utils/ansi.test.ts 会报
Cannot find module .../packages/web-shell/test/setup.ts。配置里已经设了 root: 'client',命令行 --root 覆盖了它,于是 setupFiles: ['./test/setup.ts'] 解析到了错误目录。可用形式:

cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts

仅影响描述,代码本身没问题。

2. 变异测试 —— 这些测试是否真的锁住了修复?

ansi.ts 构造 13 个变异体,逐个针对 PR 原样的测试文件运行:10 个被抓出,3 个存活。色立方档位、灰阶偏移、通道顺序、两处参数步进计数、未知形式的 break,以及「畸形值不得重置颜色」这条规则,都被真正锁住了。

3 个存活项是测试覆盖缺口,而非缺陷——这三种情况下发布的代码行为都是正确的:

变异体 存活原因 实际行为
M1 从三元组中去掉 58 唯一的 58 断言只检查 bold;去掉该分支破坏的是 dim 正确——\e[1m\e[58;5;2mdim:false(与模拟器一致);base 给出 dim:true
M10 把索引上界抬到 255 以上 toHex 的通道守卫已经拒绝了 38;5;300 产出的值,只有索引 256 能区分 正确
M11 去掉 toHex 的 0–255 守卫 没有测试使用越界的真彩通道值 正确——38;2;999;0;0 不返回颜色

58;5;2 那条断言从 [0]!.bold).toBe(true) 收紧为完整的 toEqual({ text: 'text', color: undefined, bold: true, dim: false }) 即可覆盖 M1。可选。

3. 独立 oracle —— 映射到底对不对?

参考表并非手工誊抄:它是从已发布的 @xterm/xterm 产物包中原样提取DEFAULT_ANSI_COLORS IIFE,再以桩函数求值得到。差分运行则把同样的字节同时喂给 @xterm/headless(真正的 xterm.js VT 解析器)与 parseAnsi,逐字符比较 (前景色, bold, dim)

  • 调色板:色立方 + 灰阶(16–255)共 240/240 与 xterm 自带表完全一致。全部 16 个标准索引上 38;5;<i> 与基础 SGR code 相等,说明刻意复用主题调色板的做法成立。真彩 7/7 精确。
  • 差分:跨 6 组语料共 1112 个字符单元——完整的 38;5;<idx> 扫描(纯色、bold 前缀、以及作为背景)、真彩网格、交错序列,以及本机 git diff --color=alwaystput setaf 208 真实输出的字节。PR 与模拟器的分歧为 0 个单元;base 为 844 个
  • base 在 38;5;<idx> 全部 256 个索引上的量化结果:240 个完全没有颜色、16 个渲染出错误颜色(索引与某个 SGR 颜色码撞车),另有 1 个错误地设置了 dim

4. 真实渲染 —— 真正的 <ToolGroup>,不是 mock

在两个检出上分别用 jsdom 挂载真实的 <ToolGroup>,输入是真实的 git diff --color=always 字节流外加一行 256 色提示符,然后在仓库自带的 ToolChrome.module.css 与 shadcn token 下对产出的 DOM 截图。只重写了 CSS module 的哈希后缀以便源样式生效;图中每一处内联样式都出自组件本身。

注意在 main 上丢失 bold 的那两行——BOLD TRUECOLOUR(零通道命中 code === 0 重置)与 bold on a 256-colour background48;5;2222 送进了重置粗细分支)。这正是本 bug 中会破坏「原本正确」输出的部分,也是最有力的合并理由。

5. 回归检查

在两棵树上以相同命令跑完整的 packages/web-shell 套件:

测试文件 用例
base 32c491fc6 8 失败 | 117 通过(125) 9 失败 | 1727 通过(1736)
PR 32684dd6b 8 失败 | 118 通过(126) 9 失败 | 1736 通过(1745)

差异恰好是 +9 个通过用例——即新增文件——失败画像完全一致。9 个失败用例全在 build-artifact.test.ts,它需要已构建的 dist;与本 PR 无关且未被改动。

eslintprettier --check 在两个改动文件上均干净。tsc --noEmit 在本地检出报 60 个错误,但base 与 PR 上的错误集合逐字节一致,且没有一个出自 ansi.ts / ansi.test.ts——它们来自本地未构建的工作区类型依赖。CI 会先构建,Test (ubuntu-latest, Node 22.x) 为绿。

6. 后续项 —— 均不阻塞本 PR

  1. 冒号分隔的扩展色完全未被处理 —— \e[38:5:208m\e[38:2::255:140:0m(ISO 8613-6 形式,tmux、delta 及部分 ls 版本会发出)。CSI 正则是 [0-9;]*,这些序列根本匹配不上,于是原始转义序列被当作纯文本渲染出来。base 与 PR 表现一致——属既有问题、范围之外,值得单开 issue。
  2. 非 SGR 转义同样会泄漏 —— \e[2K、裸 \r 进度重写、光标移动等。同为既有问题。
  3. 越界值与模拟器的差异属于取舍 —— xterm 会做掩码(38;2;999;0;0#e7000038;5;256 → palette 0),本 PR 则退化为无颜色。对畸形输入而言这是合理的,且严格优于 base;此处仅作记录。

@gwinthis

Copy link
Copy Markdown
Collaborator

Review + verification report (Linux, real tmux-captured ANSI, before/after harness)

Verdict: correct, spec-conformant, and demonstrably fixes two real state-corruption bugs — not just missing colors. The tests genuinely pin the fix (5/9 fail on base).

Code review reasoning

  1. The old parser didn't just ignore extended colors — it corrupted state. 38/48/58 arguments were read as standalone codes: 38;5;2 fed 2 to the loop and turned on dim; a truecolor value with a zero channel (38;2;255;0;128) hit the 0 branch and reset bold/color that was already correct. Consuming the arguments in-place is the only correct shape for this loop.
  2. Failure semantics are right. Malformed/truncated sequences leave the current color untouched (ignored, not treated as reset), and an unrecognized mode aborts the rest of the sequence rather than guessing an argument count — matching the comment's reasoning that there is no safe resume point.
  3. Deliberate palette mapping. Indices 0–15 route to the app's themed palette so 38;5;2 and 32 render identically — a sensible consistency choice, documented in-line.
  4. Background/underline colors are parsed but not rendered — correct minimal scope: their arguments must be consumed to protect the loop, and Segment has no background field today.

Verification evidence (commit 32684dd)

Before/after harness (base parser from merge-base vs PR parser, same inputs):

Input Old parser New parser
ESC[38;5;2mGREEN {dim: true} — phantom dim, no color {color: #48bb78}
ESC[1m ESC[38;2;255;0;128mHOT bold lost, no color (zero channel reset state) {color: #ff0080, bold: true}
ESC[48;5;196mBG no leak no leak (args consumed)
ESC[31mRED ESC[38;5mBROKEN (truncated) keeps red keeps red (same, graceful)

Spec conformance: programmatic sweep of all indices 16–255 against the xterm reference formula (6×6×6 cube levels 0,95,135,175,215,255; grayscale 8+10k): 0 mismatches.

Real terminal data: captured a live tmux pane (printf swatches + ls --color, tmux normalizes to 38;5;N): old parser produced 4 phantom-dim segments and 14 colored; new parser: 0 phantom dims, 23 colored, same 46-segment structure, no errors.

Tests: 9/9 pass on the PR; running the same tests against the base parser fails 5/9 — the suite pins the new behavior rather than restating existing behavior.

No concerns. Nice, tightly-scoped fix with honest comments.

The 58;5;2 case asserted only bold, so dropping 58 from the extended-color
trio left the test green: the leaked 2 argument turns on dim, not bold.
Assert the whole segment instead.

No case used an out-of-range truecolor channel either, so toHex's 0-255
guard was unpinned. Add 38;2;999;0;0 to both malformed-sequence loops.

Both gaps found by wenshao's mutation run in review.
@chinesepowered

Copy link
Copy Markdown
Contributor Author

Thank you for this — the differential run against @xterm/headless with the palette lifted out of the shipped bundle is a much stronger oracle than my hand-written table, and "base disagrees on 844 of 1112 cells" is the number I should have produced myself.

The broken verify command is my error — corrected in the description. I reproduced it before editing: the published form dies with Cannot find module .../packages/web-shell/test/setup.ts, because the CLI --root overrides the config's own root: 'client' and setupFiles: ['./test/setup.ts'] then resolves one directory too high. Your form runs and gives 9/9. Both the English and 中文 sections now read:

cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts

Took the M1 tightening too, plus M11 — pushed as f1bd14f (test-only). You were right that the assertion was the weak part, and I checked the mutants rather than assuming the change closed them:

mutant before after
M1 drop 58 from the trio 9 passed — survived 1 failedkeeps background and underline color out of the code stream
M11 drop toHex's 0–255 channel guard 9 passed — survived 2 failed — both malformed-sequence tests

M1 is exactly your diagnosis: 58;5;2 leaks its 2 into dim, so a .bold assertion could never see it. Asserting the whole segment catches it. For M11 I added 38;2;999;0;0 to both malformed loops — an out-of-range channel rather than an index, which is the case nothing covered. M10 I left alone: as you say only index 256 discriminates, and a test pinning that would be asserting the boundary rather than any behaviour a terminal can produce.

On the three follow-ups — agreed, all out of scope here, and (1) is the one I'd want to fix next. Colon-separated 38:5:208 is what tmux and delta actually emit, and the failure mode is worse than a missing colour: the CSI regex [0-9;]* doesn't match at all, so the raw escape lands in the DOM as literal text. Happy to open a separate PR for the ISO 8613-6 form (and an issue for the non-SGR \e[2K / bare \r class) unless you'd rather have them tracked differently.

On (3), out-of-range values: noted, and I'll keep dropping to no colour rather than masking. Masking is what a terminal does because it must render something; here rendering nothing is the honest signal that the sequence was malformed, and it can't invent a colour that misleads. Strictly better than base either way, as you say.

Full suite on this branch matches your numbers — 9 failing tests, all in build-artifact.test.ts, unchanged on both sides.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

— qwen3.7-max via Qwen Code /review

@chinesepowered

Copy link
Copy Markdown
Contributor Author

@qwen-code /review

CI has since landed green on f1bd14f0f — the last review was a downgrade-to-Comment while the ubuntu suite was still running, so re-requesting to clear it. Test (ubuntu-latest, Node 22.x), web-shell E2E Smoke and Capture web-shell visuals all pass. No code changes since; the M1/M11 test tightening @gwinthis asked for is already in.

@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Re-verification at f1bd14f0f — merge reference

Follow-up to my previous local verification (head 32684dd6b). Re-run as maintainer on a real local build across five isolated worktrees — base, PR, mutation, current main, and a trial merge. macOS 15.6 · Node v22.23.1 · vitest 3.2.7.

Verdict: merge-ready. 0 blockers. The one new commit does exactly what it claims, closes both gaps I reported, weakens nothing, and the production file is untouched.


1. Previous findings → status at f1bd14f0f

Mutant IDs are re-numbered this round (I widened the matrix), so both labels are given: prior → this round.

prior # previous finding severity status now
M1M1 58;5;2 case asserted only bold, so dropping 58 from the extended-color trio left the suite green coverage gap fixed by f1bd14f0f — assertion widened to the whole segment; the mutant now dies
M11M12 no test used an out-of-range truecolor channel, so toHex's 0–255 guard was unpinned coverage gap fixed by f1bd14f0f38;2;999;0;0 added to both malformed loops; the mutant now dies
M10M11 raising the palette index bound past 255 survives (only index 256+ discriminates) coverage gap, code correct still open, still not a defect — see §3
Doc the verify command in the PR description does not run documentation fixed — the description now carries the working form; I re-ran it, 9/9 (§5)
F1–F3 colon-separated SGR, non-SGR escape leakage, out-of-range divergence from xterm pre-existing / out of scope ⚪ unchanged, still pre-existing on main

The production file did not change. packages/web-shell/client/utils/ansi.ts is byte-identical between the head I reviewed and this one:

sha256  048ba69d48df9530e2972139e8d8a0c9294c1b4a4e3452da7cccd268f7a324f4   @ 32684dd6b
sha256  048ba69d48df9530e2972139e8d8a0c9294c1b4a4e3452da7cccd268f7a324f4   @ f1bd14f0f

So all correctness evidence from the previous round carries over unmodified, and every difference measured below is attributable to the test change alone.

2. Does the new test actually hold the fix down?

13 single-point mutants of the unmodified ansi.ts, each run against two test files: the one at 32684dd6b and the one at f1bd14f0f. Same harness, same mutants, only the test file swapped.

Mutation A/B matrix

8/13 → 10/13 killed. No mutant regressed from caught to survived.

The two that flipped are exactly the two the commit targets, and — importantly — the killing assertion in each case is the one the commit message names, not some unrelated test that happened to go red:

  • M1 (drop 58 from the trio) is killed by it('keeps background and underline color out of the code stream') — the test whose 58;5;2 assertion was widened.
  • M12 (drop toHex's channel guard) is killed by it('drops malformed extended-color sequences…') and it('leaves an already-set color alone…') — the two loops that gained 38;2;999;0;0.

M7 (truecolor argument advance) also got strictly better: the number of tests that catch it rose from 2 to 4. The unmutated control is 9/9 green, so no kill above is noise.

3. The 3 remaining survivors are coverage gaps, not defects

I ran a deeper mutation sweep this round, which surfaced two survivors I had not probed before (M2, M13) alongside the known M11. All three are cases the unit suite does not assert — in all three the shipped code is correct, and I verified that independently rather than by inspection:

Independent oracles and survivor adjudication

The reference palette is not hand-written: it is xterm.js's own DEFAULT_ANSI_COLORS table lifted verbatim out of the shipped @xterm/xterm bundle. The behavioural oracle is @xterm/headless — the real xterm.js VT parser — fed identical bytes and compared per character.

  • Palette: all 240 cube + grayscale indices (16–255) identical to xterm's table; all 16 themed indices render the same as their plain SGR code. 38;5;7#e0e6f0 = SGR 37 (settles M13); every index 256–1000 → no color (settles M11); cube level 215 is reachable at indices 20/26/32 and correct there (settles M2).
  • Differential: 4575 character cells across seven corpora — full 38;5;<idx> sweeps (plain, bold-prefixed, and as a background), a truecolor grid including zero channels, an interleaved stateful sequence, and real bytes captured on this machine from git diff --color=always (which emits genuine 1;38;5;208, 38;5;245 and 38;2;255;0;136) and tput setaf 208.
    PR disagrees with the emulator on 0 cells. Base disagrees on 3764.

None of these three is new to this PR, and none was worsened by the new commit. They are assertions the unit suite does not make; the 256-index sweep above is what covers that ground.

4. Regression, merge risk, and gates

Regression and gates

  • base vs PR: +9 passing, +0 failing. Failing test files and failing test names are byte-identical on both sides — the 10 pre-existing failures are 9 × ENOENT on an unbuilt dist/ in build-artifact.test.ts plus 1 composer-icon URL expectation, all present on main too.
  • Merged into current main (ecd86421c, 145 commits ahead): trial merge is clean, 0 conflict markers, main has never touched either file since the merge-base. Ansi suite 9/9 on the merged tree; full suite +9 passing, +0 failing vs main alone. So the stale base is not hiding anything.
  • eslint exit 0 with no output, and I confirmed it was actually running by planting an unused variable — which it correctly reported. prettier --check clean on both files.

5. The documentation nit from last round is fixed

The PR description now carries the working form, and I re-ran it verbatim at this head:

cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts
→ Test Files 1 passed (1) · Tests 9 passed (9)

For the record, the form that was in the description last round still fails, which is why the correction mattered — the config already sets root: 'client', so a CLI --root overrides it and setupFiles: ['./test/setup.ts'] resolves to the wrong directory:

npx vitest run --root packages/web-shell --config vitest.config.ts client/utils/ansi.test.ts
→ Cannot find module .../packages/web-shell/test/setup.ts

Nothing outstanding here.

6. What the fix looks like in the real component

Reproduced from the previous round and still exactly validansi.ts is byte-identical (§1) and the base is the same merge-base, so these renders are unchanged by definition. The real <ToolGroup> mounted in jsdom on both checkouts with a Shell tool whose output is the genuine git diff --color=always byte stream, screenshotted under the repo's own ToolChrome.module.css and shadcn tokens. Every inline style in the image is the component's own.

Before/after, light theme
Before/after, dark theme

Note the two rows that lose bold on mainBOLD TRUECOLOUR (a zero channel hits the code === 0 reset) and bold on a 256-colour background (48;5;22 feeds 22 to reset-intensity). That is the part of this bug that damages output which was previously correct, and it remains the strongest argument for merging.


Recommendation: merge. The follow-up commit is a clean, honest response to review — it fixes the two assertions that were not pinning what they appeared to pin, and my re-run confirms it rather than taking the commit message's word for it.

中文版本

f1bd14f0f 上的复验 —— 合并参考

这是对我上一轮本地验证(head 32684dd6b)的跟进。以维护者身份在真实本地构建上重跑,使用五个隔离的 worktree —— base、PR、变异测试、当前 main,以及一次试探性合并。macOS 15.6 · Node v22.23.1 · vitest 3.2.7。

结论:可以合并,0 个阻塞项。 新增的这一个提交确实做到了它声称的事:补上了我上一轮报告的两个缺口,没有削弱任何已有覆盖,并且生产代码文件完全没有改动。


1. 上一轮的问题 → 在 f1bd14f0f 上的状态

本轮我扩大了变异矩阵,因此变异体编号有所变动,下表同时给出两轮的编号:上一轮 → 本轮

上一轮编号 上一轮的问题 严重度 当前状态
M1M1 58;5;2 用例只断言了 bold,因此把 58 从扩展颜色三元组里删掉时测试仍然全绿 覆盖缺口 已修复 —— 断言扩大到整个 segment,该变异体现在会被杀死
M11M12 没有任何用例使用越界的 truecolor 通道值,toHex 的 0–255 守卫没有被钉住 覆盖缺口 已修复 —— 两个畸形序列循环都加入了 38;2;999;0;0,该变异体现在会被杀死
M10M11 把调色板索引上界从 255 抬高后变异体存活(只有索引 256+ 才有区分度) 覆盖缺口,代码本身正确 仍存在,但仍不是缺陷 —— 见 §3
Doc PR 描述里给出的验证命令跑不起来 文档问题 已修复 —— 描述里现在是可用的写法,我重跑确认 9/9(见 §5)
F1–F3 冒号分隔的 SGR、非 SGR 转义序列泄漏、越界值与 xterm 的行为差异 既有问题 / 超出范围 ⚪ 无变化,在 main 上同样存在

生产代码文件没有任何改动。 packages/web-shell/client/utils/ansi.ts 在我上轮评审的 head 与本次 head 之间逐字节相同:

sha256  048ba69d48df9530e2972139e8d8a0c9294c1b4a4e3452da7cccd268f7a324f4   @ 32684dd6b
sha256  048ba69d48df9530e2972139e8d8a0c9294c1b4a4e3452da7cccd268f7a324f4   @ f1bd14f0f

因此上一轮的全部正确性证据原样继续成立,而下面测得的每一处差异都只能归因于测试文件的改动。

2. 新增的测试是否真的钉住了这个修复?

未修改ansi.ts 构造 13 个单点变异体,每个都分别针对两份测试文件运行:32684dd6b 的那份和 f1bd14f0f 的那份。同一套 harness、同一批变异体,只替换测试文件。

杀死率 8/13 → 10/13,没有任何变异体从"被杀死"退化为"存活"。

翻转的两个正是这个提交所针对的两个,并且更重要的是——每个变异体的致死断言都正是提交信息所指名的那一个,而不是碰巧变红的无关测试:

  • M1(从三元组中删除 58)由 it('keeps background and underline color out of the code stream') 杀死 —— 正是 58;5;2 断言被扩大的那个用例。
  • M12(删除 toHex 的通道守卫)由 it('drops malformed extended-color sequences…') it('leaves an already-set color alone…') 共同杀死 —— 正是新增了 38;2;999;0;0 的那两个循环。

M7(truecolor 参数步进)也严格变强了:能捕获它的测试数量从 2 个增加到 4 个。未变异的对照组是 9/9 全绿,所以上述"杀死"都不是噪声。

3. 剩下 3 个存活的变异体是覆盖缺口,不是缺陷

本轮我做了更深的变异扫描,因此额外浮现出两个之前没有探测过的存活变异体(M2M13),加上已知的 M11。这三个都是单元测试没有断言到的情形——三种情形下已发布的代码行为都是正确的,而且我是独立验证的,不是靠读代码下结论:

参考调色板不是手工抄写的:它是从已发布的 @xterm/xterm bundle 中逐字提取出来的 xterm.js 自己的 DEFAULT_ANSI_COLORS 表。行为层面的对照物是 @xterm/headless——真正的 xterm.js VT 解析器——喂入完全相同的字节,逐字符比对。

  • 调色板:全部 240 个色立方 + 灰阶索引(16–255)与 xterm 的表完全一致;全部 16 个主题化索引的渲染结果与其对应的普通 SGR 码一致。38;5;7#e0e6f0 等于 SGR 37(解决 M13);索引 256–1000 全部返回无颜色(解决 M11);色阶 215 在索引 20/26/32 处可达且正确(解决 M2)。
  • 差分比对:七组语料共 4575 个字符单元——完整的 38;5;<idx> 扫描(普通、加粗前缀、以及作为背景色)、包含零通道的 truecolor 网格、交错的有状态序列,以及在本机真实抓取的字节:来自 git diff --color=always(会产生真实的 1;38;5;20838;5;24538;2;255;0;136)和 tput setaf 208
    PR 与模拟器在 0 个单元上存在分歧;base 上有 3764 个。

这三个都不是本 PR 引入的,也没有被这个新提交恶化。它们是单元测试没有做出的断言;上面这个 256 索引全覆盖的扫描才是覆盖这块地面的手段。

4. 回归、合并风险与仓库门禁

  • base vs PR:+9 通过,+0 失败。两侧失败的测试文件和失败的测试名称逐字节相同——那 10 个既有失败是 build-artifact.test.ts 中 9 个因未构建 dist/ 导致的 ENOENT,外加 1 个 composer 图标 URL 的期望,在 main 上同样存在。
  • 合并进当前 mainecd86421c,领先 145 个提交):试探性合并干净,0 个冲突标记,自 merge-base 以来 main 从未触碰过这两个文件。合并后的树上 ansi 套件 9/9 通过;相对 main 本身,完整套件 +9 通过,+0 失败。因此陈旧的 base 并没有掩盖任何问题。
  • eslint 退出码 0 且无输出;我通过植入一个未使用变量确认它确实在运行——它正确地报出了该问题。prettier --check 在两个文件上均干净。

5. 上一轮的文档问题已修复

PR 描述里现在给出的是可用的写法,我在本次 head 上原样重跑确认:

cd packages/web-shell && npx vitest run --config vitest.config.ts utils/ansi.test.ts
→ Test Files 1 passed (1) · Tests 9 passed (9)

作为记录:上一轮描述里的那种写法现在仍然会失败,这也正是这处修正有意义的原因——配置里已经设置了 root: 'client',命令行的 --root 会覆盖它,导致 setupFiles: ['./test/setup.ts'] 解析到错误的目录:

npx vitest run --root packages/web-shell --config vitest.config.ts client/utils/ansi.test.ts
→ Cannot find module .../packages/web-shell/test/setup.ts

此项已无遗留。

6. 这个修复在真实组件里的效果

沿用上一轮的结果,并且依然完全有效——ansi.ts 逐字节相同(§1),base 也是同一个 merge-base,所以这两张渲染图按定义不会发生变化。在两个 checkout 上分别用 jsdom 挂载真实的 <ToolGroup>,其 Shell 工具的输出是真实的 git diff --color=always 字节流,然后在仓库自己的 ToolChrome.module.css 与 shadcn tokens 下截图。图中每一处内联样式都来自组件本身。

请注意在 main 上丢失加粗的那两行——BOLD TRUECOLOUR(零通道命中了 code === 0 的重置分支)和 bold on a 256-colour background48;5;2222 喂给了重置强度)。这正是此 bug 会破坏"本来正确的输出"的部分,也依然是支持合并的最有力理由。


建议:合并。 这个跟进提交是对评审的一次干净、诚实的回应——它修好了那两条看起来在钉住行为、实际却没有钉住的断言,而我的复跑是对它的验证,而不是采信提交信息的说法。

@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

wenshao pushed a commit that referenced this pull request Jul 26, 2026
…te liveness

Fold techniques from the round-2 verification on #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
@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

The stage comments above were updated with the latest result. View workflow run.

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Correct fix for SGR extended-color parsing: 38/48/58 arguments are now consumed instead of leaking into the code loop. xterm 256-color mapping verified by hand (cube, grayscale, standard range). Defensive handling for truncated/malformed/out-of-range sequences is solid. 9 tests cover both the fix and pre-existing behavior. 0 findings (manual audit).

@wenshao
wenshao added this pull request to the merge queue Jul 26, 2026
Merged via the queue into QwenLM:main with commit f43a2e4 Jul 26, 2026
70 checks passed
github-merge-queue Bot pushed a commit that referenced this pull request Jul 26, 2026
…7737)

A maintainer approved PR #7620 three minutes before re-triggering
`@qwen-code /triage`. The run reviewed the PR, scored it 5/5, and then
reported "✅ Approved (5/5) — existing approval from prior run still
valid, head SHA unchanged" without ever calling the approve API. The
approval it read was the maintainer's, on the same head commit; the
bot's own latest review there was a `/review` downgrade to COMMENTED,
and its earlier approval had been dismissed by a push. The PR sat at 1
of the 2 required approvals with nothing in the run log marked wrong.

The skill documents an "already exists, skip re-submitting" rule for
CHANGES_REQUESTED only, and that snippet filters on the bot's login.
Nothing covered approvals, so the rule was generalized to them with the
author filter dropped. Since the maintainer's habit is to approve and
then ask triage for the second vote, this reproduces on every re-run.

Spell the approval check out instead of leaving it to inference: the
skip applies only to the bot's own APPROVED review pinned to the exact
reviewed commit — another account's approval is a different vote, a
DISMISSED review is not an approval, and an approval on an earlier
commit was already voided by the push. Keep the skip itself, so three
re-runs still don't stack three approvals.

Back it with a workflow check, since the failure is silent by nature.
"Notify silent triage re-run" already detected that no review was added;
it just said so in wording that read as a normal ending. It now reports
whether the bot has a review of its own on the head commit, and warns
when it does not.

Also paginate the CHANGES_REQUESTED probe. An unpaginated read sees only
the first page, and re-runs happen on exactly the heavily-reviewed PRs
where the gating review has scrolled past it.

Co-authored-by: verify <verify@local>
pull Bot pushed a commit to Stars1233/qwen-code that referenced this pull request Jul 26, 2026
* 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>
wenshao added a commit to wenshao/qwen-code that referenced this pull request Jul 27, 2026
…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>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants