Skip to content

fix(cli): repaint the TUI after OS sleep/wake or SIGCONT - #7265

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
wenshao:fix/wake-repaint
Jul 20, 2026
Merged

fix(cli): repaint the TUI after OS sleep/wake or SIGCONT#7265
wenshao merged 3 commits into
QwenLM:mainfrom
wenshao:fix/wake-repaint

Conversation

@wenshao

@wenshao wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a useWakeRepaint hook that detects when the process resumes after OS-level suspend (macOS display sleep, system sleep, laptop lid close, or Ctrl+Zfg) and forces a full terminal repaint via the existing refreshStatic path (clear screen + remount static history). Detection is two-pronged: a heartbeat timer that flags any gap > 10 s between 5 s ticks (the event loop was frozen), and a SIGCONT listener for explicit process suspension. The timer is .unref()'d so it never keeps the process alive.

Why it's needed

After macOS display-sleep or system-sleep the terminal emulator's screen buffer may be reset or rearranged, but Ink's internal frame-diff state still reflects the pre-sleep output. The next render then moves the cursor to the wrong row and the erase-and-redraw cycle strands border / separator characters on screen — the user sees the entire viewport filled with repeated ──── horizontal lines. The existing useResizeSettleRepaint only fires when the terminal width changes; if the terminal wakes with the same dimensions no repaint fires and the corrupted frame persists until the user manually presses Ctrl+L.

Reviewer Test Plan

How to verify

  1. Start qwen-code in a terminal (tmux recommended).
  2. Send a message so there is conversation content on screen.
  3. Suspend the process: kill -STOP <pid> (or Ctrl+Z if job control is available).
  4. Wait a few seconds, then resume: kill -CONT <pid> (or fg).
  5. Expected: the screen clears and redraws cleanly — no stray horizontal lines or border artifacts.
  6. Alternatively, close the laptop lid for > 10 s, reopen, and confirm the TUI repaints without artifacts.

Evidence (Before & After)

Before — normal rendering prior to suspend:

before

After — SIGCONT sent, useWakeRepaint triggers refreshStatic, UI repaints cleanly with no artifacts:

after

Tested on

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

Environment (optional)

npm run dev inside tmux 3.5a on macOS (Apple Silicon). Suspend/resume simulated via kill -STOP / kill -CONT to the tsx CLI process.

Risk & Scope

  • Main risk or tradeoff: the heartbeat timer fires every 5 s (lightweight Date.now() comparison, .unref()'d). A false-positive repaint is harmless — it just clears and redraws.
  • Not validated / out of scope: VP mode (ui.useTerminalBuffer) uses Ink 7 native viewport clipping and may not exhibit the same artifact; the hook still calls refreshStatic which bumps the remount key but skips the physical clearTerminal in VP mode. Windows/Linux suspend semantics may differ but SIGCONT + timer are POSIX-standard.
  • Breaking changes / migration notes: none.

Linked Issues

中文说明

本 PR 做了什么

新增 useWakeRepaint hook,在进程从操作系统级挂起(macOS 显示器休眠、系统休眠、合盖、Ctrl+Zfg)恢复后,通过已有的 refreshStatic 路径(清屏 + 重挂载静态历史)强制完整重绘终端。检测采用双通道:心跳定时器(每 5 秒 tick 一次,若两次 tick 间隔 > 10 秒则判定事件循环被冻结)和 SIGCONT 信号监听(处理显式进程挂起)。定时器使用 .unref() 确保不会阻止进程退出。

为什么需要

macOS 显示器休眠或系统休眠后,终端模拟器的 screen buffer 可能被重置或重排,但 Ink 的内部帧 diff 状态仍反映休眠前的输出。下次渲染时光标移动到错误行,erase-and-redraw 循环会将边框/分隔符字符残留在屏幕上——用户看到整个视口被重复的 ──── 水平线填满。现有的 useResizeSettleRepaint 仅在终端宽度变化时触发;如果唤醒后尺寸不变,就不会重绘,损坏的帧会持续存在,直到用户手动按 Ctrl+L

审阅者测试计划

如何验证

  1. 在终端中启动 qwen-code(推荐 tmux)。
  2. 发送一条消息,使屏幕上有对话内容。
  3. 挂起进程:kill -STOP <pid>(或 Ctrl+Z)。
  4. 等待几秒,然后恢复:kill -CONT <pid>(或 fg)。
  5. 预期:屏幕清除并干净地重绘——没有残留的水平线或边框伪影。
  6. 或者,合上笔记本盖子 > 10 秒,重新打开,确认 TUI 无伪影地重绘。

证据(前后对比)

之前 — 挂起前的正常渲染:

before

之后 — 发送 SIGCONT 后,useWakeRepaint 触发 refreshStatic,UI 干净重绘,无伪影:

after

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

在 macOS (Apple Silicon) 的 tmux 3.5a 中使用 npm run dev。通过 kill -STOP / kill -CONT 模拟挂起/恢复。

风险与范围

  • 主要风险或权衡:心跳定时器每 5 秒触发一次(轻量级 Date.now() 比较,使用 .unref())。误触发的重绘是无害的——只是清屏并重绘。
  • 未验证/超出范围:VP 模式(ui.useTerminalBuffer)使用 Ink 7 原生视口裁剪,可能不会出现相同的伪影;hook 仍会调用 refreshStatic,但在 VP 模式下跳过物理 clearTerminal。Windows/Linux 的挂起语义可能不同,但 SIGCONT + 定时器是 POSIX 标准的。
  • 破坏性变更/迁移说明:无。

关联 Issue

After macOS display-sleep or system-sleep the terminal's screen buffer
is reset but Ink's internal frame-diff state still reflects the
pre-sleep output, so the next render strands border characters on
screen (the repeated horizontal-lines artifact).

Add a useWakeRepaint hook that detects resume via a heartbeat timer
(gap > 10 s between 5 s ticks) and SIGCONT, then calls refreshStatic
to clear the terminal and remount the static history.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: Observed bug with evidence — before/after screenshots show the horizontal-line artifact after OS sleep/wake. The root cause is well-described: Ink's frame-diff state goes stale when the terminal buffer is reset during sleep, and the existing useResizeSettleRepaint only fires on width changes, so a same-dimension wake leaves the corrupted frame in place.

Direction: Aligned — TUI rendering quality is core to the product. Claude Code's CHANGELOG has multiple wake-from-sleep fixes (credential store logout, MCP reconnection), confirming this is a real problem area for terminal-based agents.

Size: Not applicable (no core paths touched). 75 production lines + 106 test lines across 3 files — small and focused.

Approach: The scope feels right. Two-pronged detection is necessary: the heartbeat timer catches display sleep / lid close (no signal delivered), while SIGCONT catches explicit Ctrl+Zfg. Reusing the existing refreshStatic path keeps it minimal — no new rendering logic. The .unref() on the timer is the right call. No unrelated changes or scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 已观测到的 bug,有证据——before/after 截图展示了系统休眠/唤醒后的水平线伪影。根因描述清楚:休眠时终端 buffer 被重置,但 Ink 的帧 diff 状态仍然过时,而现有的 useResizeSettleRepaint 仅在宽度变化时触发,所以同尺寸唤醒后损坏的帧会持续存在。

方向: 对齐——TUI 渲染质量是产品核心。Claude Code 的 CHANGELOG 中有多个休眠唤醒相关的修复(凭证存储登出、MCP 重连),确认这是终端 agent 的真实问题领域。

规模: 不适用(未触及核心路径)。3 个文件,75 行生产代码 + 106 行测试——小而专注。

方案: 范围合理。双通道检测是必要的:心跳定时器捕获显示器休眠/合盖(无信号传递),SIGCONT 捕获显式 Ctrl+Zfg。复用现有的 refreshStatic 路径保持最小化——没有新的渲染逻辑。定时器的 .unref() 是正确的做法。无无关改动或范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: For "TUI shows artifacts after OS sleep/wake because Ink's frame-diff state is stale", I would create a hook with a heartbeat timer (setInterval, detect gap > 2× interval) plus a SIGCONT listener, calling the existing refreshStatic on detection. Use a ref for the callback, .unref() the timer, clean up on unmount. Integrate alongside useResizeSettleRepaint in AppContainer.

Comparison: The PR matches this approach exactly — I don't see a materially simpler path. Two-pronged detection is necessary: the heartbeat catches display sleep / lid close (no signal delivered), SIGCONT catches explicit Ctrl+Zfg. The implementation is clean:

  • use-wake-repaint.ts (68 lines): well-structured, follows the same ref-callback pattern as useResizeSettleRepaint. The SIGCONT handler correctly resets lastTick before repainting to prevent a double-repaint from the next heartbeat tick. The timer.unref?.() with optional chaining is a nice touch for cross-platform safety. Empty deps array [] with ref pattern ensures listeners are armed once and always use the latest callback.
  • AppContainer.tsx (+7 lines): import in correct alphabetical position, hook called right after useResizeSettleRepaint — logical grouping. The comment explains the why concisely.
  • use-wake-repaint.test.ts (106 lines): 6 tests covering normal heartbeat (no repaint), gap exceeding threshold (repaint), SIGCONT (repaint), SIGCONT after unmount (no repaint), timer cleanup on unmount, and callback ref update. Good coverage.
  • File naming: use-wake-repaint.ts uses kebab-case ✓ (correct for new files per AGENTS.md; the existing useResizeSettleRepaint.ts is legacy-allowlisted).

No critical blockers or AGENTS.md violations found.

Unit tests: 6/6 pass ✓

Real-Scenario Testing (tmux)

Tested SIGSTOP/SIGCONT on the node CLI process in tmux (200×50). The heartbeat timer path (display sleep) can't be triggered in headless CI, but the SIGCONT path exercises the same refreshStatic call and is covered by unit tests for the timer path.

Before (main branch — no fix)

Screen is identical before and after SIGCONT — no repaint triggered:

npm run dev
github-runner@iZt4neqpisqczs6hsm7xn2Z:~/actions-runner-5/_work/qwen-code/qwen-code$ npm run dev

> @qwen-code/qwen-code@0.20.0 dev
> node scripts/dev.js

DEV is set to true, but the React DevTools server is not running. Start it with:

$ npx react-devtools


   ▄▄▄▄▄▄  ▄▄     ▄▄ ▄▄▄▄▄▄▄ ▄▄▄    ▄▄   ┌──────────────────────────────────────────────────────────┐
  ██╔═══██╗██║    ██║██╔════╝████╗  ██║  │ >_ Qwen Code (v0.20.0)                                   │
  ██║   ██║██║ █╗ ██║█████╗  ██╔██╗ ██║  │                                                          │
  ██║▄▄ ██║██║███╗██║██╔══╝  ██║╚██╗██║  │ API Key | qwen3.8-max-preview (/model to change)         │
  ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║  │ ~/actions-runner-5/_work/qwen-code/qwen-code             │
   ╚══▀▀═╝  ╚══╝╚══╝ ╚══════╝╚═╝  ╚═══╝  └──────────────────────────────────────────────────────────┘

  Tips: Type / to open the command popup; Tab autocompletes slash commands and saved prompts.

(↑ identical after SIGCONT — no repaint)

After (this PR)

Screen clears and redraws after SIGCONT — startup logs are wiped, fresh TUI rendered:

Before SIGCONT:

npm run dev
github-runner@iZt4neqpisqczs6hsm7xn2Z:~/actions-runner-5/_work/qwen-code/qwen-code/.qwen/worktrees/triage$ npm run dev

> @qwen-code/qwen-code@0.20.0 dev
> node scripts/dev.js

DEV is set to true, but the React DevTools server is not running. Start it with:

$ npx react-devtools


   ▄▄▄▄▄▄  ▄▄     ▄▄ ▄▄▄▄▄▄▄ ▄▄▄    ▄▄   ┌──────────────────────────────────────────────────────────┐
  ██╔═══██╗██║    ██║██╔════╝████╗  ██║  │ >_ Qwen Code (v0.20.0)                                   │
  ██║   ██║██║ █╗ ██║█████╗  ██╔██╗ ██║  │                                                          │
  ██║▄▄ ██║██║███╗██║██╔══╝  ██║╚██╗██║  │ API Key | qwen3.8-max-preview (/model to change)         │
  ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║  │ ~/.../_work/qwen-code/qwen-code/.qwen/worktrees/triage   │
   ╚══▀▀═╝  ╚══╝╚══╝ ╚══════╝╚═╝  ╚═══╝  └──────────────────────────────────────────────────────────┘

  Tips: You can resume a previous conversation by running qwen --continue or qwen --resume.

After SIGCONT (screen cleared, startup logs gone, fresh TUI):

▄▄▄▄▄▄  ▄▄     ▄▄ ▄▄▄▄▄▄▄ ▄▄▄    ▄▄   ┌──────────────────────────────────────────────────────────┐
  ██╔═══██╗██║    ██║██╔════╝████╗  ██║  │ >_ Qwen Code (v0.20.0)                                   │
  ██║   ██║██║ █╗ ██║█████╗  ██╔██╗ ██║  │                                                          │
  ██║▄▄ ██║██║███╗██║██╔══╝  ██║╚██╗██║  │ API Key | qwen3.8-max-preview (/model to change)         │
  ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║  │ ~/.../_work/qwen-code/qwen-code/.qwen/worktrees/triage   │
   ╚══▀▀═╝  ╚══╝╚══╝ ╚══════╝╚═╝  ╚═══╝  └──────────────────────────────────────────────────────────┘

  Tips: Add a QWEN.md file to give Qwen Code persistent project context.

The refreshStatic path fired on SIGCONT: the screen was cleared (clearTerminal incl. ESC[3J) and the static history remounted — startup logs wiped, TUI redrawn cleanly.

中文说明

代码审查

独立方案: 针对"系统休眠/唤醒后 TUI 出现伪影,因为 Ink 的帧 diff 状态过时"的问题,我会创建一个 hook,使用心跳定时器(setInterval,检测间隔 > 2× 间隔)加 SIGCONT 监听器,在检测到时调用现有的 refreshStatic。使用 ref 保存回调,.unref() 定时器,卸载时清理。在 AppContainer 中与 useResizeSettleRepaint 并列集成。

对比: PR 的方案与我的独立提案完全一致——我没有找到更简单的路径。双通道检测是必要的:心跳捕获显示器休眠/合盖(无信号传递),SIGCONT 捕获显式 Ctrl+Zfg。实现干净:

  • use-wake-repaint.ts(68 行):结构良好,遵循与 useResizeSettleRepaint 相同的 ref-callback 模式。SIGCONT 处理器在重绘前正确重置 lastTick,防止下次心跳触发双重绘。timer.unref?.() 使用可选链确保跨平台安全。空依赖数组 [] 配合 ref 模式确保监听器只注册一次且始终使用最新回调。
  • AppContainer.tsx(+7 行):导入位置按字母排序正确,hook 紧跟 useResizeSettleRepaint 调用——逻辑分组合理。注释简洁地解释了原因。
  • use-wake-repaint.test.ts(106 行):6 个测试覆盖正常心跳(不重绘)、间隔超阈值(重绘)、SIGCONT(重绘)、卸载后 SIGCONT(不重绘)、卸载时定时器清理、回调 ref 更新。覆盖良好。
  • 文件命名:use-wake-repaint.ts 使用 kebab-case ✓(新文件符合 AGENTS.md 规范)。

未发现关键阻塞问题或 AGENTS.md 违规。

单元测试: 6/6 通过 ✓

真实场景测试(tmux)

在 tmux(200×50)中对 node CLI 进程测试 SIGSTOP/SIGCONT。心跳定时器路径(显示器休眠)无法在无头 CI 中触发,但 SIGCONT 路径调用了相同的 refreshStatic,定时器路径由单元测试覆盖。

之前(main 分支——无修复)

SIGCONT 前后屏幕完全相同——未触发重绘。

之后(本 PR)

SIGCONT 后屏幕清除并重绘——启动日志被清除,TUI 重新渲染。refreshStatic 路径在 SIGCONT 时触发:屏幕被清除(clearTerminal 含 ESC[3J),静态历史重挂载——启动日志被清除,TUI 干净重绘。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — Clean across every stage; would merge without hesitation.

This is a textbook small fix: 75 production lines that solve a real, observed problem (TUI corruption after OS sleep/wake) by reusing the existing refreshStatic path. The two-pronged detection (heartbeat timer + SIGCONT) is the minimum needed — neither alone covers both sleep and explicit suspension. The code follows the same ref-callback pattern as useResizeSettleRepaint, the tests cover all the important paths, and the tmux before/after confirms SIGCONT triggers a clean repaint where the main branch does nothing.

No reservations. If I had to maintain this in six months I'd thank the author — it's well-documented, minimal, and does exactly one thing.

中文说明

置信度:5/5 — 每个阶段都很干净;毫不犹豫地合并。

这是一个教科书式的小修复:75 行生产代码,通过复用现有的 refreshStatic 路径解决了一个已观测到的真实问题(系统休眠/唤醒后 TUI 损坏)。双通道检测(心跳定时器 + SIGCONT)是所需的最小方案——单独任何一个都无法同时覆盖休眠和显式挂起。代码遵循与 useResizeSettleRepaint 相同的 ref-callback 模式,测试覆盖了所有重要路径,tmux before/after 确认 SIGCONT 触发了干净的重绘,而 main 分支无任何反应。

没有保留意见。如果六个月后我要维护这段代码,我会感谢作者——文档完善、最小化、只做一件事。

Qwen Code · qwen3.7-max

Reviewed at 10d3f88991b16c7e96c224a08adffd26f07071c3 · 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. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

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

Comment thread packages/cli/src/ui/hooks/use-wake-repaint.ts Outdated

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

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

Comment thread packages/cli/src/ui/hooks/use-wake-repaint.test.ts
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 20, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

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

Comment thread packages/cli/src/ui/hooks/use-wake-repaint.test.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

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

@QwenLM QwenLM deleted a comment from qwen-code-dev-bot Jul 20, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Could not address the latest feedback automatically (round 1/100). A human should take over this PR.

What I found before stopping:

No action needed — all suggestions already addressed

All three review suggestions on PR #7265 were resolved in earlier commits on this branch:

# Suggestion Author Resolution
1 JSDoc incorrectly claims repaint must be referentially stable @qwen-code-ci-bot Fixed in 4701c827a — JSDoc now states repaint is read through a ref and does not need to be stable
2 Missing test for SIGCONT↔heartbeat interaction (double-repaint prevention) @qwen-code-ci-bot Added in 9ab57524a — test advances past a normal tick, fires SIGCONT with a clock jump, asserts one repaint, then asserts the next heartbeat does not double-repaint
3 Missing test for timer.unref() @wenshao Added in 9ab57524a — spies on globalThis.setInterval and asserts unref() is called exactly once

No further code changes are required.

中文说明

无需操作 — 所有建议已在先前提交中解决

PR #7265 上的三条审查建议均已在本分支的早期提交中解决:

# 建议 作者 解决方式
1 JSDoc 错误地声称 repaint 必须保持引用稳定 @qwen-code-ci-bot 已在 4701c827a 中修复 — JSDoc 现在正确说明 repaint 通过 ref 读取,无需保持引用稳定
2 缺少 SIGCONT↔心跳交互测试(防止双重重绘) @qwen-code-ci-bot 已在 9ab57524a 中添加 — 测试先推进一个

Run log: https://github.com/QwenLM/qwen-code/actions/runs/29718309973


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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review paused — model quota exhausted. Qwen review stopped: the model API quota is exhausted (reset at 07-20 07:32:00 UTC.). Transient errors auto-retry, but a quota reset is too far out to wait on a runner. Re-run once it resets by commenting @qwen-code /review. See workflow logs.

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification report

I built this branch and verified it end-to-end on Linux with the real bundled CLI, not mocks. Summary: the fix works and I'd merge it. Two things worth a look before/after merge — a double repaint when the suspension exceeds the 10 s threshold, and confirmation that the repaint is a complete no-op under ui.useTerminalBuffer.

Setup

Item Value
Branch fix/wake-repaint @ 9ab5752 (merge-base 59042ec)
Build node scripts/build.js --cli-only + npm run bundle → real dist/cli.js
Harness integration-tests/terminal-capture (node-pty + headless xterm.js) + integration-tests/fake-openai-server.ts
Platform Linux x86-64, Node 22.22.2

How I reproduced the bug without a Mac

I can't put a Linux container to sleep, so I reproduced the mechanism rather than the trigger:

  1. Boot the real TUI against a scripted fake OpenAI server and complete one conversation turn, so there is committed <Static> history on screen.
  2. SIGSTOP the CLI — verified truly frozen (0 bytes of PTY output while stopped).
  3. While it is frozen, write stray ──── rows straight into the xterm buffer. This is the faithful part: the screen changes without the application being told, so Ink's frame-diff state is now stale exactly the way it is after a real display-sleep/wake. The rows land over the banner, which lives in the already-committed <Static> area — the region Ink will never redraw on its own, which is precisely why only a full clear + remount can repair it.
  4. SIGCONT.
  5. Count the ESC[2J ESC[3J ESC[H sequences the app emits and diff the rendered screen.

Results

Run useTerminalBuffer Hook Suspend Clears after resume Artifact rows after (baseline = 2)
green default (off) wired 1.5 s 1 2 — clean
red (control) default (off) call site removed 1.5 s 0 10 — artifacts persist
long-suspend default (off) wired 14 s 2 2 — clean
vp on wired 1.5 s 0 10 — artifacts persist
vp-off off (explicit) wired 1.5 s 1 2 — clean

The RED control is the same bundle with only useWakeRepaint(refreshStatic) replaced by void 0 — every other byte identical.

Before — clean TUI with conversation history:

before

Frozen + screen buffer clobbered — the reported artifact:

corrupted

After SIGCONT, hook wired — banner, history and prompt fully restored:

after

RED control, hook removed — artifacts survive the resume:

red

A detail I like: in the RED run the "corrupted" and "after-resume" PNGs are byte-identical (md5 58e81899…), and the rotating tip line is unchanged — nothing re-rendered at all. In the GREEN run the tip changes from "Type / to open the command popup…" to "Add a QWEN.md file…", which proves the screen was genuinely re-rendered rather than restored from a cached frame.

The TUI is still interactive afterwards (typing lands in the prompt), so the <Static> remount doesn't leave the input handler wedged:

interactive

Hook-level probe: real process, real clock, real suspension

Separately I mounted the real useWakeRepaint from packages/cli/dist in a live process (Ink renderer, real timers, no fake clock) and suspended it for real:

Mode Setup Repaints
unref-exit nothing else holds the loop process exits on its own after 666 ms → the heartbeat is genuinely .unref()'d in the real runtime (the effect definitely ran — it had registered its SIGCONT listener)
heartbeat SIGCONT listener stripped after mount, 12 s SIGSTOP 1 — the gap detector works on its own with a real wall clock
both as shipped, 12 s SIGSTOP 2 (both at offset 21423 ms)
control no suspension, ~31 s runtime 0 — no spurious repaints in normal operation

The heartbeat mode is the one the E2E can't isolate, since SIGCONT always accompanies a real resume. Stripping the listener leaves only the timer, and it fires — so both detection paths are independently proven, not just the signal one.

Findings

1. Double repaint when the suspension exceeds WAKE_THRESHOLD_MS (minor, non-blocking)

Measured three times, two independent ways: a >10 s suspension produces two full clear + remount cycles. On resume the overdue timer and SIGCONT both land. The order is deducible from the measurement: if SIGCONT had run first it would have reset lastTick, the timer would then have seen a ~0 gap, and we'd see one repaint (that's exactly what the existing test asserts). We see two — so the heartbeat runs first, repaints, and onSigcont then repaints again unconditionally.

The existing test "does not double-repaint when the heartbeat follows a SIGCONT" covers the opposite order — SIGCONT first, then heartbeat — which is not the order that happens in practice. The real order is unguarded.

User-visible impact is small (one extra flash, and ESC[3J runs twice), so this isn't a merge blocker. If you want to close it, coalescing both paths through one guard is enough:

let lastRepaintAt = 0;
const repaintOnce = () => {
  const now = Date.now();
  lastTick = now;
  if (now - lastRepaintAt < HEARTBEAT_INTERVAL_MS) return;
  lastRepaintAt = now;
  repaintRef.current();
};

…called from both the timer and onSigcont, plus a test for the heartbeat→SIGCONT order.

2. VP mode (ui.useTerminalBuffer) — repaint is a complete no-op (confirmed, worth a follow-up)

The PR lists this as "not validated"; I validated it, and it does not work. With useTerminalBuffer: true the app emits 0 bytes after SIGCONT and the artifacts persist unchanged. refreshStatic skips the physical clear in VP mode and nothing in the VP render path is keyed by historyRemountKey, so the wake repaint has no observable effect at all.

I ran an explicit useTerminalBuffer: false control through the same settings-file path (1 clear, clean screen) to confirm the difference tracks the setting and not run-to-run noise.

Worth noting because VP mode is what we recommend to users who report flicker — i.e. the population most likely to hit this bug is the one the fix doesn't reach. Not a blocker for this PR; a follow-up issue would be fair.

3. ESC[3J wipes the terminal's own scrollback on every wake (trade-off, worth stating)

ansiEscapes.clearTerminal is \x1b[2J\x1b[3J\x1b[H. The in-session history is re-emitted by the <Static> remount, but anything the user had in the terminal's scrollback before the session is gone. useResizeSettleRepaint has the same behaviour and documents it — the difference is that resize is an explicit user gesture, whereas this now fires automatically after every lid-close. Acceptable, but it belongs in the hook's doc comment.

4. Date.now() is the right clock here (affirming a design choice)

Worth recording so nobody "fixes" it later: performance.now() would be the instinctive choice for elapsed-time checks, but it is monotonic — and CLOCK_MONOTONIC / mach_absolute_time do not advance across a real system suspend. Wall-clock Date.now() is what actually detects sleep. The trade-off is a false positive on an NTP step or manual clock change, which just costs a harmless repaint.

5. Naming nit (non-blocking)

use-wake-repaint.ts is kebab-case; the hook it sits next to and parallels (useResizeSettleRepaint.ts) is camelCase, as are 85 of the 89 use* hook files in that directory. Four kebab-case files already exist, so it's not unprecedented — take it or leave it.

Gates

Check Result
use-wake-repaint.test.ts 8/8 pass
AppContainer.test.tsx 119/119 pass
packages/cli src/ui/hooks (full dir) 65 files, 1262/1262 pass
eslint --max-warnings 0 on the 3 changed files clean
tsc --build via scripts/build.js --cli-only clean
Signal-listener leak none — no MaxListenersExceededWarning across the 119-test AppContainer suite
Does a SIGCONT listener pin the event loop? no — verified empirically, process still exits

Scope limits — what I did not verify

  • Real macOS display-sleep / lid-close was not reproduced. This is Linux; I exercised SIGSTOP/SIGCONT plus a real 12 s event-loop freeze. The SIGCONT path and the heartbeat path are both covered, but the actual macOS wake trigger remains verified only by you.
  • Windows is untested here.
  • The screen-buffer corruption is simulated by writing into the emulator's buffer while the process is frozen. That reproduces the stale-frame-diff mechanism, not the exact byte pattern a real terminal produces on wake.

Verdict: LGTM to merge. Finding 1 is a nice-to-have follow-up; finding 2 deserves its own issue.

中文说明

本地验证报告

我在本地构建了该分支,并用真实的打包 CLI(非 mock)在 Linux 上做了端到端验证。结论:修复有效,我同意合并。 有两点值得关注 —— 挂起时间超过 10 秒阈值时会重绘两次,以及确认了在 ui.useTerminalBuffer 下重绘完全不生效。

环境

项目
分支 fix/wake-repaint @ 9ab5752(merge-base 59042ec
构建 node scripts/build.js --cli-only + npm run bundle → 真实 dist/cli.js
测试框架 integration-tests/terminal-capture(node-pty + 无头 xterm.js)+ integration-tests/fake-openai-server.ts
平台 Linux x86-64、Node 22.22.2

没有 Mac 如何复现这个 bug

我无法让 Linux 容器进入休眠,因此我复现的是机制而非触发条件:

  1. 用脚本化的假 OpenAI 服务启动真实 TUI,完成一轮对话,使屏幕上有已提交的 <Static> 历史。
  2. 对 CLI 发送 SIGSTOP —— 已验证进程确实被冻结(挂起期间 PTY 输出为 0 字节)。
  3. 在其冻结期间,把杂乱的 ────直接写入 xterm 缓冲区。这是关键的还原点:屏幕内容变了但应用毫不知情,于是 Ink 的帧 diff 状态就变成了陈旧状态,与真实休眠唤醒后完全一致。这些行覆盖在 banner 上,而 banner 位于已提交的 <Static> 区域 —— 正是 Ink 自身永远不会重绘的区域,所以只有完整的清屏 + 重挂载才能修复。
  4. 发送 SIGCONT
  5. 统计应用输出的 ESC[2J ESC[3J ESC[H 序列数量,并 diff 渲染后的屏幕。

结果

运行 useTerminalBuffer hook 挂起时长 恢复后清屏次数 残留伪影行数(基线 = 2)
green 默认(关) 已接入 1.5 秒 1 2 —— 干净
red(对照) 默认(关) 移除调用点 1.5 秒 0 10 —— 伪影残留
long-suspend 默认(关) 已接入 14 秒 2 2 —— 干净
vp 已接入 1.5 秒 0 10 —— 伪影残留
vp-off 显式关闭 已接入 1.5 秒 1 2 —— 干净

RED 对照组用的是同一份 bundle,仅把 useWakeRepaint(refreshStatic) 替换为 void 0,其余字节完全一致。

之前 —— 带对话历史的干净 TUI:

before

冻结 + 屏幕缓冲区被破坏 —— 复现所报告的伪影:

corrupted

SIGCONT 之后(hook 已接入) —— banner、历史与输入框完全恢复:

after

RED 对照组(移除 hook) —— 伪影在恢复后依然存在:

red

一个我很喜欢的细节:RED 运行中"被破坏"与"恢复后"两张 PNG 字节完全一致md5 58e81899…),且轮播提示行没有变化 —— 说明根本没有发生任何重绘。而 GREEN 运行中提示行从 "Type / to open the command popup…" 变成了 "Add a QWEN.md file…",证明屏幕是真正重新渲染的,而非从缓存帧恢复。

之后 TUI 仍可交互(输入能正常进入输入框),说明 <Static> 重挂载没有卡住输入处理:

interactive

Hook 层探针:真实进程、真实时钟、真实挂起

我另外把 packages/cli/dist真实的 useWakeRepaint 挂载到一个活跃进程里(Ink 渲染器、真实定时器、无假时钟),并真实挂起它:

模式 设置 重绘次数
unref-exit 没有其他东西持有事件循环 进程在 666 毫秒后自行退出 → 心跳定时器在真实运行时中确实被 .unref() 了(effect 确实执行了 —— 它已注册了 SIGCONT 监听器)
heartbeat 挂载后剥离 SIGCONT 监听器,挂起 12 秒 1 —— 间隔检测在真实挂钟下独立生效
both 按 PR 原样,挂起 12 秒 2(两次都在 21423 ms 偏移)
control 完全不挂起,运行约 31 秒 0 —— 正常运行下无误触发

heartbeat 模式是 E2E 无法隔离的那条路径,因为真实恢复时 SIGCONT 必然同时到达。剥离监听器后只剩定时器,而它确实触发了 —— 所以两条检测路径都被独立证明,而不只是信号那一条。

发现

1. 挂起超过 WAKE_THRESHOLD_MS 时重绘两次(次要,不阻塞合并)

用两种独立方法测量了三次:挂起超过 10 秒会产生两次完整的清屏 + 重挂载。恢复时超时的定时器与 SIGCONT 都会到达。顺序可以从测量结果推出:如果 SIGCONT 先执行,它会重置 lastTick,随后定时器看到的间隔约为 0,就只会有一次重绘(这正是现有测试所断言的)。而我们观测到两次 —— 所以是心跳先执行并重绘,随后 onSigcont 又无条件重绘一次。

现有测试 "does not double-repaint when the heartbeat follows a SIGCONT" 覆盖的是相反的顺序 —— 先 SIGCONT 再心跳 —— 而这并不是实际发生的顺序。真实顺序没有被防护。

用户可见影响很小(多闪一次,ESC[3J 多执行一次),所以不阻塞合并。如果想收掉它,把两条路径合并到一个守卫里即可:

let lastRepaintAt = 0;
const repaintOnce = () => {
  const now = Date.now();
  lastTick = now;
  if (now - lastRepaintAt < HEARTBEAT_INTERVAL_MS) return;
  lastRepaintAt = now;
  repaintRef.current();
};

…在定时器和 onSigcont 中都调用它,并补一个心跳→SIGCONT 顺序的测试。

2. VP 模式(ui.useTerminalBuffer)—— 重绘完全无效(已确认,建议后续跟进)

PR 中把这点列为"未验证";我验证了,它确实不生效。当 useTerminalBuffer: true 时,SIGCONT 之后应用输出 0 字节,伪影原封不动。refreshStatic 在 VP 模式下跳过物理清屏,而 VP 渲染路径中没有任何东西以 historyRemountKey 为 key,因此唤醒重绘没有任何可观测效果。

我通过同一条设置文件路径跑了一个显式 useTerminalBuffer: false 的对照(1 次清屏、屏幕干净),确认差异来自设置本身而非运行间噪声。

之所以值得提,是因为 VP 模式正是我们推荐给报告闪烁问题用户的方案 —— 也就是说,最可能遇到这个 bug 的人群恰恰是这个修复覆盖不到的。这不阻塞本 PR,但开一个后续 issue 比较合理。

3. ESC[3J 会在每次唤醒时清掉终端自身的 scrollback(权衡,值得写明)

ansiEscapes.clearTerminal\x1b[2J\x1b[3J\x1b[H。会话内的历史会由 <Static> 重挂载重新输出,但用户在会话之前留在终端 scrollback 中的内容就没了。useResizeSettleRepaint 有同样的行为并做了说明 —— 区别在于 resize 是显式的用户操作,而这里会在每次合盖之后自动触发。可以接受,但建议写进 hook 的文档注释里。

4. 这里用 Date.now() 是正确的(确认一个设计选择)

记录下来以免日后有人"顺手改掉":做耗时检查时 performance.now() 是本能选择,但它是单调时钟 —— 而 CLOCK_MONOTONIC / mach_absolute_time 在真实系统休眠期间不会推进。挂钟 Date.now() 才能真正检测到休眠。代价是 NTP 校时或手动改时钟会误触发,而那只不过是一次无害的重绘。

5. 命名小建议(不阻塞)

use-wake-repaint.ts 用的是 kebab-case;而它紧邻并对标的 useResizeSettleRepaint.ts 是 camelCase,该目录 89 个 use* hook 文件中有 85 个是 camelCase。已经存在 4 个 kebab-case 文件,所以并非没有先例 —— 采纳与否随意。

检查项

检查 结果
use-wake-repaint.test.ts 8/8 通过
AppContainer.test.tsx 119/119 通过
packages/cli src/ui/hooks(整个目录) 65 个文件,1262/1262 通过
3 个变更文件的 eslint --max-warnings 0 无问题
scripts/build.js --cli-onlytsc --build 无问题
信号监听器泄漏 无 —— 119 个测试的 AppContainer 套件中没有出现 MaxListenersExceededWarning
SIGCONT 监听器会不会钉住事件循环? 不会 —— 已实测,进程仍能正常退出

范围限制 —— 我没有验证的部分

  • 没有复现真实的 macOS 显示器休眠 / 合盖。 这是 Linux 环境;我做的是 SIGSTOP/SIGCONT 加上真实的 12 秒事件循环冻结。SIGCONT 路径和心跳路径都覆盖到了,但 macOS 真实唤醒这个触发条件仍然只有你验证过。
  • Windows 在这里未测试。
  • 屏幕缓冲区破坏是通过在进程冻结时写入模拟器缓冲区来模拟的。它复现的是"帧 diff 状态陈旧"这一机制,而非真实终端唤醒时产生的确切字节模式。

结论:LGTM,同意合并。 发现 1 适合作为后续优化;发现 2 值得单开一个 issue。


🤖 Verified locally with Claude Code (Opus 4.8)

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM✅~~

@wenshao
wenshao added this pull request to the merge queue Jul 20, 2026
Merged via the queue into QwenLM:main with commit 63e8827 Jul 20, 2026
92 of 94 checks passed
wenshao pushed a commit that referenced this pull request Jul 25, 2026
…rules

Borrow the image-evidence and quantified-verification patterns from
hand-run rounds (#7265, #7471, #7686 r2 and the pr-assets convention):

- publish-verify now hosts agent-produced evidence/*.png on the pr-assets
  branch (verify/pr<N>-<run>-<attempt>/) and appends them below the
  escaped report. Untrusted-payload discipline: strict filename allowlist,
  8-image / 2 MB caps enforced in the find predicates, racing-push retry,
  and every failure degrades to a text-only comment. VERIFY_ASSETS_REMOTE
  is a test seam; the block was dry-run against a local bare remote
  covering hosting, hostile filenames, oversize files, dotfiles, missing
  branch, and no-image runs
- skill: evidence images are named as kebab-case captions binding image to
  claim, before/after pairs over lone after-shots; follow-up rounds lead
  with a previous-finding status table (fixed/stands/superseded/declined,
  with adjudication) and re-measure instead of diffing the old report;
  size/perf claims get measured-metric Δ tables with residual deltas
  accounted for; unreachable branches get the configuration that reaches
  them constructed; defensive guards get their accept path checked against
  real production artifacts, not just mocked rejects
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.20.1.

pull Bot pushed a commit to Stars1233/qwen-code that referenced this pull request Aug 11, 2026
QwenLM#8831)

* fix(cli): clear the VP viewport on wake/SIGCONT repaints

useWakeRepaint (QwenLM#7265) repaints via refreshStatic after sleep/wake or
SIGCONT, but in VP mode (default) refreshStatic neither cleared the screen
nor repainted anything (<Static> is not rendered in VP), so Ink's next
relative erase ran against a stale/rearranged terminal buffer: frame-top
residue (banner), frame-height jumps and high-frequency flicker on every
terminal. Blank the alternate-screen viewport (2J+H, no 3J so scrollback /
Warp block history survives) before the remount-driven repaint; static mode
keeps its existing clearTerminal.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): repaint VP from a clean viewport after width shrinks

On shrink the terminal reflows the printed frame into more physical rows
than Ink's stale eraseLines count, so every subsequent redraw under-erases
and strands the frame top (banner) as stacked duplicates on all terminals
(issue QwenLM#8557). For a short window after a shrink, start each VP redraw from
a clean viewport (2J+H); Static mode keeps a conservative reflow-aware
amplification so committed scrollback is never touched.

* fix(cli): enable DEC synchronized output on Warp to reduce redraw flicker

Warp answers the DECRQM 2026 probe with status 2 (recognized, reset), so
synchronized updates are available there. Without them Warp renders ink's
erase-then-rewrite frame pattern as visible flicker (issue QwenLM#8557). Add
WarpTerminal to the synchronized-output allowlist; the existing
QWEN_CODE_DISABLE_SYNCHRONIZED_OUTPUT escape hatch covers regressions.

* fix(cli): guarantee VP wake repaint by replaying the last frame

Ink skips redraws whose output is unchanged, so the VP wake/SIGCONT path's
viewport clear could leave the screen blank until the next state change
(review QwenLM#8831). The resize-reflow wrapper now caches the last frame that
reached the terminal and repaint() replays it over a clean viewport; the
stale design-rationale comment is rewritten to match.

* fix(cli): address QwenLM#8831 review — LIFO teardown, grow reset, bare-redraw model handoff, wake-only repaint

- Unwind the stdout.write wrapper stack in LIFO order so the identity-
  guarded restores do not leak wrappers (Critical).
- Reset a pending static-mode amplification on grow so a stale count can
  never over-erase into committed scrollback (Critical).
- Hand the frame model over to Ink's bare post-shrink redraw (log.clear
  resets its counter, so the redraw carries no erase prefix); consecutive
  shrinks now amplify from the actual post-shrink frame (Critical).
- repaint() skips the replay when the cached frame's width differs from the
  current viewport (Critical).
- Route the clear-and-replay through useWakeRepaint only; refreshStatic's VP
  branch stays write-free for ordinary callers (/clear, model change, ...)
  so stale frames never flash back (Critical).
- Use ansi-escapes exports instead of hand-rolled ANSI constants; drop the
  duplicated WarpTerminal test row; add tests for the grow reset, the bare-
  redraw handoff, the MIN_FRAME_LINES guard and the clear-window expiry.

* fix(cli): address QwenLM#8831 R3 review — model fidelity and wake remount

- Frame model now lazy and terminal-faithful: per-character greedy packing
  (wide chars waste a row-tail cell), physical-row segmentation on shrink
  (terminals re-wrap rows without re-joining), and Ink's cursor-below line
  included for frames ending with a newline (R3-3, R3-4, R3-5, R3-9).
- expectFrame handoff survives Ink's real write sequence: standalone
  synchronized-output control writes no longer consume it, and consecutive
  bare writes re-model with last-wins so static commits model the live
  frame, not the transcript (R3-1, R3-14).
- VP wake path bumps historyRemountKey again so one-shot <Static> history
  (agent tabs) is re-emitted over the clear; selection extracted into
  buildWakeRepaint for unit coverage (R3-2, R2-8).
- Shared erase grammar helpers exported from terminalRedrawOptimizer
  (R3-13); tests added for the escape hatch, repaint fallbacks, BSU
  sequences, static commits, trailing newlines and CJK packing
  (R3-10, R3-11, R3-12).

* fix(cli): address QwenLM#8831 R3 review — raw-repack reflow model, wake remount bump, hardened frame handoff

* fix(cli): address QwenLM#8831 R4 review — stripped-width model, legacy wake write-free, untrusted anchors, full-reset resets

- Model widths from ANSI-stripped content (SGR bytes are not cells) while
  repaint replays the raw styled frame.
- QWEN_CODE_LEGACY_RESIZE_ERASE VP wake stays write-free (remount bump only)
  instead of blanking via a bare viewport clear.
- Erase-prefixed printable writes re-model unconditionally (live region can
  legitimately shrink below MIN_FRAME_LINES); bare full-reset redraws
  (clearTerminal + full static history) reset the model instead of poisoning
  it; second printable bare write (live frame after static append) bypasses
  the line-count guard.
- Skip amplification when the return-to-bottom prefix carries cursorDown
  computed from pre-reflow geometry (untrusted anchor).
- Tests for all R4 scenarios plus wrapper-stack contracts (stacked install
  order, LIFO teardown) and the AppContainer wake wiring.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address QwenLM#8831 R4 review — stripped-width modeling, trusted-anchor amplification, wake/legacy hardening, close test gaps

* test(cli): force the sync wrapper in the LIFO teardown test for CI determinism

* refactor(cli): drop unused exports from terminalRedrawOptimizer

* fix(cli): address QwenLM#8831 R6 review — trusted-anchor amplify with prefix delta, ungated full-reset, grapheme/tab packing, VP shrink remount, test hardening

* fix(cli): address QwenLM#8831 R7 review — bounded bare-write handoff, window-end static re-bump, deps-capture wiring test

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants