Skip to content

fix(test): stop forwarding a cleaned-up PTY session into stdout (#11002) - #11007

Closed
qwen-code-dev-bot wants to merge 5 commits into
mainfrom
autofix/issue-11002
Closed

fix(test): stop forwarding a cleaned-up PTY session into stdout (#11002)#11007
qwen-code-dev-bot wants to merge 5 commits into
mainfrom
autofix/issue-11002

Conversation

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

What this PR does

The integration test harness now detaches an interactive terminal session's output forwarding as part of cleaning up, rather than only signalling the spawned CLI to exit. Each session the harness spawns is recorded together with the handle that lets its byte-forwarding listener be removed, and cleanup removes that listener before signalling the child. After cleanup returns, no byte from that session can reach the test worker's own standard output.

A witness test pins the new behaviour with a stand-in child that traps the hangup signal exactly the way the CLI does and keeps printing a unique marker, asserting both that the child is genuinely still alive once cleanup has returned and that none of its output reaches standard output afterwards.

Why it's needed

The E2E Interactive - OpenTUI renderer (bun) leg has been reddening main intermittently without naming a failing test, which leaves the main-CI detector nothing to dedupe on and files one issue per commit. Run 33834473606 is the latest such red, and the same commit passed the same leg in run 33836390526, whose step started 7.8 minutes after the failing one and took 301 seconds against the failing step's 306 — so the suite ran to completion and the failure is not a deterministic defect in the code under test.

The earlier fix for this class made cleanup kill the sessions a test never closed, but killing does not close the window. A signal-less kill through the pseudo-terminal library sends a hangup, and the CLI deliberately traps that into an asynchronous graceful shutdown, so the child is still running and still rendering after cleanup has returned. The harness was still forwarding every byte that child produced into the worker's standard output, because this leg enables verbose output keeping. Once the test runner tears the worker down, the reader end of that pipe is gone, so the next forwarded write raises a broken-pipe error that nothing listens for, and Node escalates it to an uncaught exception — the run exits non-zero having printed no test failure at all.

This is fatal on that leg alone. The integration configuration deliberately keeps unhandled errors fatal on GitHub-hosted Linux so local and nightly runs retain the signal, and that leg is the only Linux lane which is GitHub-hosted, since the shards moved to the self-hosted pool and macOS is exempt by platform. So the same race is swallowed everywhere else and only ever reddens this one job.

Whether the race is lost depends on whether the child happens to emit a byte in the narrow window between worker teardown and its own exit, which is exactly why the same commit can pass minutes later. Detaching the forwarding removes the dependency on winning that race: a byte that never reaches standard output cannot break the pipe, however long the child's graceful shutdown takes.

Reviewer Test Plan

How to verify

The behaviour under test is the harness itself, so it is verifiable without bun, without the OpenTUI renderer, and without any model access:

  1. Run the harness suite: cd integration-tests && npx vitest run test-helper.test.ts. Expect 8 passing, including the new case that a cleaned-up session stops forwarding output. It completes in well under a second of test time.
  2. Confirm the witness has teeth by deleting the single line in cleanup that disposes the forwarding handle, then re-running with npx vitest run test-helper.test.ts -t "detaches" --retry=0. Expect it to fail, reporting roughly 25 marker chunks still written to standard output in the 500ms after cleanup. Restore the line and the file goes green again.
  3. Confirm the precondition the witness asserts is real rather than decorative: the same probe with a child that does not trap the hangup signal dies within 500ms, which is why the test first asserts the child is still alive — without that, the forwarding assertion could pass vacuously on a child that had already exited.
  4. For the surrounding blast radius, the neighbouring harness specs are worth a run: cd integration-tests && npx vitest run test-helper.test.ts renderer-matrix.test.ts globalSetup.test.ts fake-openai-server.test.ts — 4 files, 33 passing.
  5. The end-to-end confirmation is that this leg stops producing red runs with no named test. That is intermittent by nature, so it can only be observed over subsequent main runs rather than on demand.

Nothing in the interactive suite reads accumulated session output after cleanup — in every interactive spec the cleanup call is the last statement of the teardown hook — so detaching the listener at that point cannot hide output an assertion still needs.

Evidence (Before & After)

N/A — no user-visible or TUI change. The observable difference is in CI: before, this leg could exit non-zero with every test passing and no FAIL line, which is what produced the per-commit issue; after, a session that outlives cleanup can no longer write into the worker's standard output.

The mutation probe is the concrete before/after, and step 2 of the test plan above is how to reproduce it: with the disposal removed the witness fails showing roughly 25 marker chunks forwarded after cleanup, and with it in place the witness passes showing zero.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ tested

Environment (optional)

GitHub-hosted Linux runner image, Node v22.23.2, npm 10.9.8. The exact failing leg could not be reproduced here: the OpenTUI renderer path requires bun, which is not installed on this runner, and the original job log is only readable with admin rights. The change is in renderer-independent harness code and the witness runs on the default ink/node path, which exercises the same spawn-then-cleanup code that leg uses. Run, job and annotation metadata from the public API was used to establish the failure signature instead of the log text.

Risk & Scope

  • Main risk or tradeoff: cleanup now stops accumulating a session's output as well as stopping to forward it. Any future test that expects a session to keep producing readable output after cleanup would see a frozen buffer. No current test does — every interactive spec calls cleanup last in its teardown hook — and the accumulated string already holds everything produced up to that point.
  • Not validated / out of scope: this leg has a second, independent source of red runs with no named test — the test runner's fixed 60-second worker-to-main RPC budget, which a stall under load can exceed and surface as an unhandled error. It is already exempted off-Linux and on the self-hosted pool, but this leg is GitHub-hosted Linux and so is still exposed while running up to four parallel forks of pseudo-terminal-heavy tests. Widening that exemption was deliberately not done: it would silence the unhandled-error signal for the whole integration suite, including the broken-pipe class fixed here, and the existing configuration comment records the narrowing as intentional. That tradeoff is a maintainer decision, and the leg may therefore stay intermittently red for that separate reason.
  • Breaking changes / migration notes: none. The change is confined to the integration test harness; no product code, CI workflow, or test configuration changed, and no test was deleted or weakened.

Linked Issues

Fixes #11002

Related, for context on the failure class rather than as a closing reference: #10969 introduced the cleanup-time kill this PR completes, #10112 and #10325 established the unhandled-error exemption policy that makes this leg the only exposed Linux lane, and #10085 moved the other Linux shards to the self-hosted pool.

中文说明

这个 PR 做了什么

集成测试脚手架现在会在清理阶段摘除某个交互式终端会话的输出转发,而不仅仅是向派生的 CLI 发送退出信号。脚手架派生的每个会话都会连同「可移除其字节转发监听器的句柄」一起被记录下来,清理时会先移除该监听器、再向子进程发信号。清理返回之后,该会话的任何字节都无法再到达测试 worker 自身的标准输出。

一个见证测试(witness test)用一个替身子进程钉住了这一新行为:该子进程像 CLI 一样捕获挂断信号(hangup),并持续打印一个唯一标记;测试同时断言「清理返回后子进程确实仍然存活」以及「此后它的输出不会到达标准输出」。

为什么需要它

E2E Interactive - OpenTUI renderer (bun) 这个检查项一直在间歇性地把 main 弄红,却不指出任何失败的测试,这使得 main-CI 检测器无从去重,只能按提交逐个建 issue。运行 33834473606 就是最近这样一次红灯,而同一个提交在运行 33836390526 中通过了同一个检查项;后者的步骤比失败的那次晚开始 7.8 分钟,耗时 301 秒对失败步骤的 306 秒 —— 说明测试套件是跑完的,失败并不是被测代码里的确定性缺陷。

针对这一类问题的先前修复让清理阶段去杀掉测试从未关闭的会话,但杀掉进程并没有关闭这个时间窗口。通过伪终端库发送的不带信号的 kill 会发出一个挂断信号,而 CLI 有意把它捕获为一次异步的优雅退出,因此子进程在清理已经返回之后仍在运行、仍在渲染。而脚手架仍在把该子进程产生的每个字节转发进 worker 的标准输出,因为这个检查项开启了输出保留(verbose output keeping)。一旦测试运行器拆除 worker,该管道的读端就消失了,于是下一次转发写入会抛出一个无人监听的 broken-pipe 错误,Node 随即将其升级为 uncaughtException —— 整个运行以非零退出,却没有打印任何测试失败。

这只在那一个检查项上是致命的。集成测试配置有意在 GitHub 托管的 Linux 上保持「未处理错误即致命」,以便本地运行和 nightly 运行保留该信号;而该检查项是唯一由 GitHub 托管的 Linux 通道,因为分片已迁往自托管机器池,macOS 则因平台而被豁免。所以同一个竞态在其他地方都被吞掉,只会把这一个 job 弄红。

是否输掉这个竞态,取决于子进程是否恰好在「worker 被拆除」与「它自己退出」之间的狭窄窗口里发出了一个字节 —— 这正是为什么同一个提交几分钟后就能通过。摘除转发消除了对「赢得该竞态」的依赖:一个从未到达标准输出的字节不可能打破管道,无论子进程的优雅退出耗时多久。

审阅者测试计划

如何验证

被测对象是脚手架本身,因此无需 bun、无需 OpenTUI 渲染器、也无需任何模型访问权限即可验证:

  1. 运行脚手架测试套件:cd integration-tests && npx vitest run test-helper.test.ts。预期 8 个通过,其中包含新增的「已清理的会话停止转发输出」用例。测试耗时远低于一秒。
  2. 通过删除清理逻辑中 dispose 转发句柄的那一行来确认该见证测试是有效的,然后用 npx vitest run test-helper.test.ts -t "detaches" --retry=0 重跑。预期它会失败,并报告在清理之后的 500 毫秒内仍有约 25 个标记数据块被写入标准输出。恢复该行后文件重新变绿。
  3. 确认见证测试所断言的前提是真实的而非装饰性的:同样的探针若换成一个捕获挂断信号的子进程,会在 500 毫秒内死亡 —— 这正是测试要先断言子进程仍存活的原因;否则转发断言可能在一个早已退出的子进程上空洞地通过。
  4. 就周边影响范围而言,邻近的脚手架规格测试也值得一跑:cd integration-tests && npx vitest run test-helper.test.ts renderer-matrix.test.ts globalSetup.test.ts fake-openai-server.test.ts —— 4 个文件,33 个通过。
  5. 端到端的确认是:该检查项不再产生「没有指出测试名的红灯运行」。这本质上是间歇性的,因此只能在后续的 main 运行中观察,无法按需触发。

交互式套件中没有任何测试会在清理之后读取累积的会话输出 —— 在每个交互式规格文件中,清理调用都是 teardown 钩子的最后一条语句 —— 所以在该时点摘除监听器不会隐藏任何断言仍需要的输出。

证据(改动前与改动后)

N/A —— 没有用户可见或 TUI 的变化。可观察的差异体现在 CI 上:改动前,该检查项可能在所有测试都通过、且没有任何 FAIL 行的情况下以非零退出,这正是产生按提交归档 issue 的原因;改动后,一个比清理活得更久的会话已无法再写入 worker 的标准输出。

变异探针(mutation probe)就是具体的前后对比,上方测试计划的第 2 步即其复现方式:移除 disposal 后见证测试失败并显示清理之后仍转发了约 25 个标记数据块;保留它则见证测试通过并显示为零。

测试环境

OS Status
🍏 macOS ⚠️ 未测试
🪟 Windows ⚠️ 未测试
🐧 Linux ✅ 已测试

环境(可选)

GitHub 托管的 Linux runner 镜像,Node v22.23.2,npm 10.9.8。此处无法复现完全一致的失败检查项:OpenTUI 渲染器路径需要 bun,而本 runner 上没有安装;原始 job 日志也只有管理员权限才能读取。改动位于与渲染器无关的脚手架代码中,且见证测试运行在默认的 ink/node 路径上,执行的正是该检查项所使用的同一套「派生—清理」代码。已改用公开 API 的运行、job 与注解元数据来确立失败特征,以替代日志正文。

风险与范围

  • 主要风险或取舍:清理现在不仅停止转发,也会停止累积会话输出。任何未来期望会话在清理之后仍继续产生可读输出的测试,都会看到缓冲区被冻结。目前没有测试这样做 —— 每个交互式规格文件都在其 teardown 钩子的最后调用清理 —— 而且累积字符串中已经保留了截至该时点产生的全部内容。
  • 未验证 / 范围之外:该检查项还有第二个、彼此独立的「无测试名红灯」来源 —— 测试运行器固定的 60 秒 worker→main RPC 预算,负载下的停顿可能超出它并以未处理错误的形式浮现。它在非 Linux 平台和自托管机器池上已被豁免,但本检查项是 GitHub 托管的 Linux,因此仍然暴露在外,同时还会以最多四个并行 fork 运行伪终端密集型测试。我们刻意没有扩大该豁免:那会让整个集成测试套件的未处理错误信号被静默掉,其中就包括此处修复的 broken-pipe 一类;而且现有配置注释已把这种收窄记录为有意为之。这个取舍属于维护者决定,因此该检查项仍可能因这个独立原因保持间歇性红灯。
  • 破坏性变更 / 迁移说明:无。改动仅限于集成测试脚手架;没有产品代码、CI workflow 或测试配置发生变化,也没有删除或弱化任何测试。

关联 Issue

Fixes #11002

相关(用于说明该类失败问题的背景,而非作为关闭引用):#10969 引入了本 PR 所完善的「清理时杀进程」;#10112#10325 确立了未处理错误的豁免策略,正是该策略使本检查项成为唯一暴露的 Linux 通道;#10085 把其他 Linux 分片迁往了自托管机器池。

The `E2E Interactive - OpenTUI renderer (bun)` leg reddened run 33834473606
without printing a single `FAIL` line, so the main-CI detector had no test to
dedupe on and filed it per commit. The same SHA passed the same leg nine
minutes later (run 33836390526, its step 301s against the failing step's
306s), which rules out a deterministic defect at HEAD and matches the class
#10969 described: every test passes, then an unhandled error exits the run
red. It is fatal on this leg alone because `dangerouslyIgnoreUnhandledErrors`
is off for github-hosted Linux, and the OpenTUI job is the only Linux lane
that is github-hosted — the shards moved to the self-hosted pool (#10085) and
macOS is exempt by platform.

#10969 closed one source of that class by killing the interactive children
`cleanup()` inherits, but killing does not close the window. node-pty's
signal-less `kill()` sends SIGHUP, and the CLI traps SIGHUP into an
asynchronous graceful shutdown (`beginExit` -> `runExitCleanup()` -> `exit`),
so the child keeps rendering after `cleanup()` returns while the rig's
`onData` handler keeps forwarding every byte into `process.stdout` under this
leg's `KEEP_OUTPUT`/`VERBOSE`. One such write after vitest destroys the
worker's stdout pipe raises EPIPE, and with no listener Node escalates it to
an uncaught exception.

Detach the forwarding listener in `cleanup()` rather than relying on the child
to die first: a byte that never reaches `process.stdout` cannot EPIPE it,
however long the shutdown takes. Measured — a stand-in child that swallows
SIGHUP survives `kill()` for over a second, while a child that does not dies
within 500ms; and `dispose()` on node-pty's `onData` removes only that
listener, leaving the child and the master descriptor alone.

The witness pins both halves. It asserts the child is still alive after
`cleanup()`, so the forwarding check cannot pass vacuously on a child that
died instantly, and then that no canary byte reaches stdout. Removing the
`dispose()` call reddens it with roughly 25 canary writes in the 500ms window.

Out of scope: this leg's other no-`FAIL`-line source, vitest's 60s
worker-to-main `onTaskUpdate` RPC timeout under load, is exempted for
self-hosted and off-Linux lanes but not for this github-hosted one. Widening
that exemption would silence the very signal this fix preserves, so it is left
to a maintainer.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator Author

Autofix E2E Report — Issue #11002

Issue: Main CI failed: E2E Tests on 56f75adf2992
Failing leg: E2E Interactive - OpenTUI renderer (bun) — step Run interactive E2E tests (OpenTUI)
Run: 33834473606 on commit 56f75adf29926783855a890e46b6cf3a32e6c986
Fix commit: d1e250dd42 on branch autofix/issue-11002

What failed

The leg exited non-zero without printing a single FAIL line. That is evidence rather than assumption: the issue was filed through the detector's per-commit path, which renderPerCommitBody only reaches when analyzeLogs extracted zero failing tests from the downloaded job log. A real assertion failure or a test timeout would have produced a FAIL <file> > <suite> > <case> line, which extractFailingTests matches, and that would have deduped this onto an existing test-keyed issue instead of opening a per-commit one.

Evidence gathered

The job log text itself is not retrievable from here — GET /actions/jobs/<id>/logs returns 403 Must have admin rights to Repository, and this agent holds no GitHub credentials by design. The public read-only run metadata was available, and the diagnosis rests on it:

Observation Source Why it matters
Step conclusion failure; sole annotation Process completed with exit code 1. check-run annotations API vitest exited 1 and no test was named
Failing step ran 306s (04:20:40 → 04:25:46 UTC) jobs API Comparable to a healthy run, so the suite ran to completion instead of hanging; also rules out the job's 60-minute timeout
The same SHA passed the same leg in run 33836390526, step 301s (04:28:28 → 04:33:29 UTC) jobs API Rules out a deterministic defect at HEAD — the failure is intermittent
Every other job in run 33834473606 succeeded jobs API Isolated to this leg

The same-SHA green run is the decisive fact, and the two legs' steps started 7.8 minutes apart. It excludes both candidate signatures that a code change at HEAD would have had to explain: the /quit mid-turn composer race that commit 56f75adf29 had itself just fixed, and the "30s boot/first-delta timeout on a loaded runner" that its commit message explicitly left out of scope. Either would have failed deterministically, and either would have printed a FAIL line.

What remains is exactly the class 60161cb64a (#10969) documented for this leg: every test passes, then the run dies on what vitest reports as an unhandled error rather than a failure. That is fatal on this leg alone, because integration-tests/vitest.config.ts sets dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux' || isSelfHostedRunner, and the OpenTUI job is the only Linux lane that is GitHub-hosted — the shards moved to the self-hosted pool (#10085) and macOS is exempt by platform.

Root cause

60161cb64a closed one source of that class by making TestRig.cleanup() kill the interactive PTY children a test never closed. Killing does not actually close the window:

  1. node-pty's signal-less kill() is process.kill(this.pid, signal || 'SIGHUP') — verified at node_modules/@lydell/node-pty-linux-x64/lib/unixTerminal.js:228.
  2. The CLI traps SIGHUP: packages/cli/src/llm.tsx registers handleSighupbeginExit('SIGHUP')await runExitCleanup()process.exit(129). That is an asynchronous graceful shutdown, so the child keeps running and keeps rendering the TUI after cleanup() has already returned.
  3. cleanup() neither awaited that exit nor stopped the rig's onData handler, which forwards every PTY byte into process.stdout under this leg's KEEP_OUTPUT=true / VERBOSE=true.
  4. Once vitest tears the worker down the reader end of that pipe is gone, so the next forwarded write raises EPIPE as an 'error' event on process.stdout. With no listener, Node escalates it to an uncaught exception and the run exits 1 having printed no FAIL line.

Whether the race is lost depends on whether the child happens to emit a byte in the narrow window between worker teardown and its own exit — which is why the same SHA passes when the leg runs again.

Two probes confirmed the mechanism instead of assuming it:

  • A standalone node-pty probe: a child that swallows SIGHUP survives kill() for over a second and keeps producing bytes, while a child that does not trap it dies within 500ms. Calling dispose() on the onData subscription removed only that listener — the child stayed alive and the parent stopped receiving its bytes.
  • The harness's other PTY driver was ruled out: InteractiveSession is used only by cron-interactive.test.ts, which this leg excludes, and its onData never writes to process.stdout. ctrl-c-exit.test.ts, the one interactive spec with no cleanup call, is entirely it.skip.

The fix

TestRig now pairs each spawned PTY with the IDisposable returned by ptyProcess.onData, and cleanup() disposes that listener before killing. After cleanup() no PTY byte can reach process.stdout, so the EPIPE mechanism is impossible however long the child's graceful shutdown takes — the fix no longer depends on winning a race against the child's exit.

Two files changed, 80 insertions and 6 deletions, both inside the integration harness. No product code, no CI workflow, no vitest configuration, and no test was deleted or weakened.

Witness test

detaches a session's output forwarding during cleanup in integration-tests/test-helper.test.ts spawns a stand-in child that traps SIGHUP exactly like the real CLI and prints a unique canary every 20ms. It asserts both halves of the claim:

  • the child is still alive after cleanup() — without this, the forwarding check could pass vacuously on a child that died instantly;
  • no canary byte reaches process.stdout in the 500ms after cleanup().

Out of scope

This leg has a second, distinct source of no-FAIL-line reds: vitest's worker→main onTaskUpdate RPC has a fixed 60s budget, and under resource pressure a longer stall surfaces as an unhandled [vitest-worker]: Timeout calling "onTaskUpdate" error. That is precisely what 2bd0ff923e (#10112) exempted off-Linux and what the later isSelfHostedRunner clause exempted for the self-hosted pool — but this leg is GitHub-hosted Linux, so it is still exposed, and it runs up to 4 forks of PTY-heavy tests.

That was deliberately not changed. Widening dangerouslyIgnoreUnhandledErrors to GitHub-hosted Linux would silence the unhandled-error signal for the whole integration suite, including the very EPIPE class this PR fixes, and the existing config comment records the narrowing as a deliberate decision. That tradeoff belongs to a maintainer, not to this fix. It is noted here so the leg's residual intermittency stays visible rather than being assumed away.

Verification

Commands actually run in this checkout (GitHub-hosted runner image, Node v22.23.2, npm 10.9.8):

  • npm run buildpassed (exit 0, ~509s)
  • npm run typecheckpassed (exit 0); includes typecheck:integration (tsc -p integration-tests/tsconfig.json), which covers both changed files
  • npm run lintpassed (exit 0); includes eslint integration-tests
  • npx prettier --check integration-tests/test-helper.ts integration-tests/test-helper.test.tspassed ("All matched files use Prettier code style!")
  • cd integration-tests && npx vitest run test-helper.test.ts8 passed, including the new witness (~0.7s of test time)
  • cd integration-tests && npx vitest run test-helper.test.ts renderer-matrix.test.ts globalSetup.test.ts fake-openai-server.test.ts4 files, 33 passed
  • Mutation probe: removed session.forwardOutput.dispose(); from cleanup(), then npx vitest run test-helper.test.ts -t "detaches" --retry=0failed as required, reporting ~25 canary chunks still written to process.stdout in the 500ms after cleanup(). Restored the line and re-ran the full file — 8 passed. The witness therefore has teeth.
  • Pre-commit hook (node scripts/pre-commit.js / lint-staged) — passed; the committed content was re-verified after the hook ran.
  • npm run generate:settings-schemanot run, correctly: no settings source changed.

Checks that could not be run here

  • The exact failing leg. QWEN_E2E_RENDERER=opentui requires bun — resolveE2eCliCommand probes bun --version and throws otherwise — and bun 1.3.14 is not installed on this runner; installing it is a networked package operation outside this skill's allowed commands. The fix is in renderer-independent harness code, and the witness runs on the default ink/node path, so it exercises the same spawn-then-cleanup code the OpenTUI leg uses.
  • The original job log. Requires admin rights (403); the public run, job and annotation metadata tabulated above was used instead.
  • The interactive suite's model-backed cases were not run end-to-end here. The workflow's independent CI remains the final verification gate.

One correction to the commit message

The commit message says the same SHA passed the same leg "nine minutes later". The measured gap between the two legs' step starts is 7.8 minutes (04:20:40 vs 04:28:28 UTC); the two runs were created 32.3 minutes apart. The substantive claim is unaffected — the same SHA passed the same leg shortly afterwards — but the figure is rounded up. This report states the precise timestamps. The branch history is additive-only under this skill's rules, so the message was not amended to correct it.

中文说明

Autofix E2E 报告 — Issue #11002

Issue: Main CI failed: E2E Tests on 56f75adf2992
失败的检查项: E2E Interactive - OpenTUI renderer (bun) —— 步骤 Run interactive E2E tests (OpenTUI)
运行记录: 33834473606,提交 56f75adf29926783855a890e46b6cf3a32e6c986
修复提交: 分支 autofix/issue-11002 上的 d1e250dd42

失败的是什么

该检查项以非零状态退出,但没有打印任何一行 FAIL。这是证据而非假设:该 issue 是通过检测器的按提交(per-commit)路径创建的,而 renderPerCommitBody 只有在 analyzeLogs 从已下载的 job 日志中提取到零个失败测试时才会走到这条路径。真正的断言失败或测试超时都会产生一行 FAIL <file> > <suite> > <case>extractFailingTests 能匹配到它,那样就会把本次失败去重合并到已有的、按测试名归档的 issue 上,而不是新建一个按提交归档的 issue。

已收集的证据

job 日志正文在此处无法获取 —— GET /actions/jobs/<id>/logs 返回 403 Must have admin rights to Repository,而本 agent 按设计不持有任何 GitHub 凭据。但公开的只读运行元数据可以获取,本次诊断正是建立在这些数据之上:

观察结果 来源 为什么重要
步骤结论为 failure;唯一的注解是 Process completed with exit code 1. check-run annotations API vitest 以 1 退出,且没有指出任何测试名
失败步骤运行了 306 秒(04:20:40 → 04:25:46 UTC) jobs API 与一次健康运行的时长相当,说明测试套件是跑完的而不是卡住了;同时也排除了该 job 的 60 分钟超时
同一个 SHA 在运行 33836390526 中通过了同一个检查项,步骤耗时 301 秒(04:28:28 → 04:33:29 UTC) jobs API 排除了 HEAD 上存在确定性缺陷的可能 —— 该失败是间歇性的
运行 33834473606 中其他所有 job 都成功 jobs API 问题局限于这一个检查项

「同一 SHA 变绿」是决定性的事实,而且两次检查项的步骤开始时间相差 7.8 分钟。它排除了两个本来必须由 HEAD 上的代码改动来解释的候选特征:一是提交 56f75adf29 自己刚刚修掉的 /quit 回合中(mid-turn)输入框竞态,二是该提交信息中明确列为范围之外的「在负载较高的 runner 上 30 秒启动/首个 delta 超时」。这两者要么会确定性失败,要么会打印出 FAIL 行。

剩下的恰好就是 60161cb64a#10969)为该检查项记录过的那一类:所有测试都通过,然后整个运行死于 vitest 报告为 unhandled error(未处理错误)而非失败的东西。这一类只在这个检查项上是致命的,因为 integration-tests/vitest.config.ts 设置的是 dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux' || isSelfHostedRunner,而 OpenTUI 这个 job 是唯一一个由 GitHub 托管的 Linux 通道 —— 分片已经迁到自托管机器池(#10085),macOS 则因平台而被豁免。

根因

60161cb64a 通过让 TestRig.cleanup() 杀掉测试自己从未关闭的交互式 PTY 子进程,堵住了该类问题的一个来源。但仅仅杀掉进程并没有真正关闭这个时间窗口:

  1. node-pty 不带信号的 kill() 实际是 process.kill(this.pid, signal || 'SIGHUP') —— 已在 node_modules/@lydell/node-pty-linux-x64/lib/unixTerminal.js:228 核实。
  2. CLI 捕获了 SIGHUP:packages/cli/src/llm.tsx 注册了 handleSighupbeginExit('SIGHUP')await runExitCleanup()process.exit(129)。这是一个异步的优雅退出,因此子进程在 cleanup() 已经返回之后仍在运行,并仍在渲染 TUI。
  3. cleanup() 既没有等待子进程退出,也没有停止 rig 的 onData 处理器;而在本检查项的 KEEP_OUTPUT=true / VERBOSE=true 下,该处理器会把每一个 PTY 字节转发进 process.stdout
  4. 一旦 vitest 拆除 worker,该管道的读端就消失了,于是下一次转发写入会以 'error' 事件的形式在 process.stdout 上抛出 EPIPE。由于没有监听者,Node 会把它升级为 uncaughtException,运行随即以 1 退出,且没有打印任何 FAIL 行。

是否会输掉这个竞态,取决于子进程是否恰好在「worker 被拆除」与「它自己退出」之间的那个狭窄窗口里发出了一个字节 —— 这也正是为什么该检查项再次运行时,同一个 SHA 就能通过。

有两个探针证实了该机制,而不是靠假设:

  • 一个独立的 node-pty 探针:吞掉 SIGHUP 的子进程kill() 之后仍存活超过一秒并持续产生字节,而不捕获该信号的子进程会在 500 毫秒内死亡。对 onData 订阅调用 dispose() 只移除了那个监听器 —— 子进程依然存活,而父进程不再收到它的字节。
  • 脚手架中另一个 PTY 驱动已被排除:InteractiveSession 只被 cron-interactive.test.ts 使用,而本检查项排除了该文件;并且它的 onData 从不写入 process.stdoutctrl-c-exit.test.ts 是唯一没有清理调用的交互式规格文件,而它整体是 it.skip

修复方案

TestRig 现在把每个派生的 PTY 与 ptyProcess.onData 返回的 IDisposable 配对保存,并在 cleanup() 中先 dispose 该监听器再杀进程。cleanup() 之后任何 PTY 字节都无法再到达 process.stdout,因此无论子进程的优雅退出耗时多久,EPIPE 机制都不可能发生 —— 该修复不再依赖于「抢在子进程退出之前」赢得竞态。

改动了两个文件,80 行新增、6 行删除,全部位于集成测试脚手架内。没有改动产品代码、CI workflow、vitest 配置,也没有删除或弱化任何测试。

见证测试(Witness test)

integration-tests/test-helper.test.ts 中的 detaches a session's output forwarding during cleanup 会派生一个像真实 CLI 一样捕获 SIGHUP 的替身子进程,并每 20 毫秒打印一个唯一的 canary 字符串。它同时断言该主张的两个部分:

  • 子进程在 cleanup() 之后仍然存活 —— 没有这一条,转发检查可能会在一个瞬间就死掉的子进程上空洞地通过;
  • cleanup() 之后的 500 毫秒内没有任何 canary 字节到达 process.stdout

范围之外

该检查项还有第二个、彼此独立的「无 FAIL 行」红灯来源:vitest 的 worker→main onTaskUpdate RPC 有固定的 60 秒预算,在资源压力下更长的停顿会以未处理的 [vitest-worker]: Timeout calling "onTaskUpdate" 错误浮现。这正是 2bd0ff923e#10112)为非 Linux 平台豁免、后来的 isSelfHostedRunner 分支为自托管机器池豁免的东西 —— 但本检查项是 GitHub 托管的 Linux,因此仍然暴露在外,而且它最多会以 4 个 fork 并行运行 PTY 密集型测试。

这一点被刻意没有改动。把 dangerouslyIgnoreUnhandledErrors 扩大到 GitHub 托管的 Linux,会让整个集成测试套件的未处理错误信号被静默掉,其中就包括本 PR 所修复的这一类 EPIPE;而现有配置注释已把这种收窄记录为一个有意为之的决定。这个取舍属于维护者,而不属于本次修复。在此说明是为了让该检查项残留的间歇性保持可见,而不是被当作不存在。

验证

在本次检出中实际执行过的命令(GitHub 托管 runner 镜像,Node v22.23.2,npm 10.9.8):

  • npm run build —— 通过(退出码 0,约 509 秒)
  • npm run typecheck —— 通过(退出码 0);其中包含 typecheck:integrationtsc -p integration-tests/tsconfig.json),覆盖了两个被改动的文件
  • npm run lint —— 通过(退出码 0);其中包含 eslint integration-tests
  • npx prettier --check integration-tests/test-helper.ts integration-tests/test-helper.test.ts —— 通过("All matched files use Prettier code style!")
  • cd integration-tests && npx vitest run test-helper.test.ts —— 8 个通过,含新增见证测试(测试耗时约 0.7 秒)
  • cd integration-tests && npx vitest run test-helper.test.ts renderer-matrix.test.ts globalSetup.test.ts fake-openai-server.test.ts —— 4 个文件,33 个通过
  • 变异探针(Mutation probe):cleanup() 中移除 session.forwardOutput.dispose();,然后执行 npx vitest run test-helper.test.ts -t "detaches" --retry=0 —— 按要求失败,报告在 cleanup() 之后的 500 毫秒内仍有约 25 个 canary 数据块被写入 process.stdout。恢复该行并重跑整个文件 —— 8 个通过。因此该见证测试是有效的。
  • pre-commit 钩子(node scripts/pre-commit.js / lint-staged)—— 通过;钩子执行后已重新核实提交内容。
  • npm run generate:settings-schema —— 未运行,这是正确的:没有 settings 源文件发生变化。

在此处无法运行的检查

  • 完全一致的失败检查项。 QWEN_E2E_RENDERER=opentui 需要 bun —— resolveE2eCliCommand 会探测 bun --version,否则抛错 —— 而本 runner 上没有安装 bun 1.3.14;安装它属于本 skill 允许命令之外的联网包操作。修复位于与渲染器无关的脚手架代码中,且见证测试运行在默认的 ink/node 路径上,因此它执行的正是 OpenTUI 检查项所使用的同一套「派生—清理」代码。
  • 原始 job 日志。 需要管理员权限(403);已改用上方表格所列的公开 run、job 与 annotation 元数据。
  • 交互式测试套件中依赖真实模型的用例未在此处端到端运行。workflow 的独立 CI 仍是最终的验证关口。

对提交信息的一处更正

提交信息中写的是同一个 SHA 在「九分钟后」通过了同一个检查项。实测两次检查项步骤开始时间的间隔是 7.8 分钟(04:20:40 对 04:28:28 UTC);两个运行的创建时间则相差 32.3 分钟。其实质性主张不受影响 —— 同一个 SHA 在不久之后通过了同一个检查项 —— 但该数字被向上取整了。本报告给出的是精确时间戳。按照本 skill 的规则,分支历史只能是增量式的,因此没有通过 amend 去更正该提交信息。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 4, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed, not theoretical — and the evidence holds up under independent checking. I pulled both runs cited in the description rather than taking them on faith:

Run Event Conclusion Run interactive E2E tests (OpenTUI) step
33834473606 push failure 04:20:40 → 04:25:46 = 306 s
33836390526 schedule success 04:28:28 → 04:33:29 = 301 s

Same head SHA 56f75adf2992, same job name, 7 m 48 s apart, near-identical durations. Every number in the description matches the API exactly, and a suite that runs to completion in 301 s and then fails in 306 s without naming a test is the signature of a teardown-time unhandled error rather than a defect in the code under test. Issue #11002 is open and was filed by the main-CI detector for exactly this run.

I also checked the two load-bearing premises in the codebase rather than trusting the prose:

  • The CLI really does trap the hangup into an async shutdown. packages/cli/src/llm.tsx installs process.on('SIGHUP', handleSighup)beginExit('SIGHUP'), which is a .catch().finally(() => process.exit(...)) chain — so the child is genuinely still alive and still rendering after the harness's kill() returns.
  • This leg really is the only exposed Linux lane. integration-tests/vitest.config.ts sets dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux' || isSelfHostedRunner. The shards run on the self-hosted ECS pool (exempt), macOS is exempt by platform, the nightly isolated legs are continue-on-error — and e2e-interactive-opentui is runs-on: ubuntu-latest with KEEP_OUTPUT: 'true' and VERBOSE: 'true'. Fatal, forwarding, GitHub-hosted. The claim checks out.

Direction: aligned. This is CI reliability for the repo's own harness, and it fixes a failure mode that costs maintainer attention every time it fires (one auto-filed issue per commit, with nothing to dedupe on). Worth noting the repo already learned this lesson once in a different corner — packages/cli/src/agent-view/pty-host.ts carries the comment "node-pty's signal-less kill falls back to SIGHUP on POSIX" and deliberately sends SIGTERM instead. This PR completes the same reasoning on the harness side.

Size: not applicable — no core paths touched. For the record: 86 changed lines total, 23 in integration-tests/test-helper.ts and 63 in its colocated spec. Nothing near the 500/1000-line thresholds, and Stage 0's two-tier gate does not engage.

Approach: the scope feels right, and I want to be explicit about the alternative I considered first, because it is the obvious one and it does not work. My initial instinct was "kill harder" — send SIGTERM the way pty-host.ts does, or SIGKILL, so the child stops rendering before the pipe goes away. Reading llm.tsx killed that idea: the CLI traps SIGTERM as well as SIGHUP into the same async beginExit, so switching signals just changes which signal is trapped and the window stays open. Only SIGKILL is untrappable, and paying for teardown determinism with a hard kill would forfeit the graceful shutdown that reaps the CLI's own children (MCP servers, daemons) — a worse trade than the one being fixed. Removing the write path instead of racing the child's exit is the correct choice, and it is the smaller blast radius.

The one thing I'd ask you to sit with, not as a blocker: a narrower variant exists that keeps the listener attached and just guards the write with a cleanedUp flag — a few lines fewer, no restructure of the session list, and it preserves _interactiveOutput accumulation, which is precisely the tradeoff your own Risk section names as the main one. I landed on the dispose version being better anyway — it releases the closure, cannot be defeated by a second forwarding path, and mirrors the disposables array pty-host.ts already maintains — but since the frozen buffer is the cost you're paying, it's worth a sentence in the description saying the flag variant was considered.

No unrelated changes, no drive-by refactors: the interactiveProcessesinteractiveSessions rename is forced by the payload change, and I confirmed by grep that those were the only three references in the repo.

Risk: no elevated risk signals — neither changed file matches the revert-correlated path list. The residual risk is the one you already documented (a future test expecting readable output after cleanup), and it is bounded: I checked every consumer, see the code review for that.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

**问题:**是已观测到的问题,不是理论性加固——而且证据经得起独立核查。我没有直接采信描述里的数字,而是自己拉取了所引用的两次运行:

运行 触发事件 结论 Run interactive E2E tests (OpenTUI) 步骤
33834473606 push failure 04:20:40 → 04:25:46 = 306 秒
33836390526 schedule success 04:28:28 → 04:33:29 = 301 秒

同一个 head SHA 56f75adf2992、同一个 job 名、相隔 7 分 48 秒、耗时几乎一致。描述里的每一个数字都与 API 完全吻合;一个跑满 301 秒成功、却在 306 秒失败且不指出任何测试名的套件,正是「teardown 阶段未处理错误」的特征,而不是被测代码里的缺陷。Issue #11002 仍处于 open 状态,且正是 main-CI 检测器针对这次运行建的。

描述里两个关键前提我也在代码库中核实过,而不是只看文字:

  • CLI 确实把挂断信号捕获成一次异步退出。packages/cli/src/llm.tsx 里装了 process.on('SIGHUP', handleSighup)beginExit('SIGHUP'),那是一条 .catch().finally(() => process.exit(...)) 链——所以脚手架的 kill() 返回之后,子进程确实还活着、还在渲染。
  • 这个检查项确实是唯一暴露的 Linux 通道。integration-tests/vitest.config.ts 里是 dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux' || isSelfHostedRunner。分片跑在自托管 ECS 机器池(豁免),macOS 因平台豁免,nightly 隔离通道是 continue-on-error——而 e2e-interactive-opentuiruns-on: ubuntu-latest,并且带 KEEP_OUTPUT: 'true'VERBOSE: 'true'。既致命、又转发、又是 GitHub 托管。这个判断成立。

**方向:**对齐。这是仓库自身脚手架的 CI 可靠性问题,而它修掉的失败模式每次发生都要消耗维护者的注意力(检测器无从去重,只能按提交逐个建 issue)。值得一提的是,仓库在另一个角落已经吃过一次同样的亏——packages/cli/src/agent-view/pty-host.ts 里就写着注释「node-pty 不带信号的 kill 在 POSIX 上会退化为 SIGHUP」,并因此刻意改发 SIGTERM。本 PR 是在脚手架这一侧把同一套推理补完。

**规模:**不适用——没有触及核心路径。作为记录:共改动 86 行,其中 integration-tests/test-helper.ts 23 行、其同目录测试文件 63 行。远未达到 500/1000 行的阈值,Stage 0 的两级门禁不触发。

**方案:**范围合理。我想明确说明我最初考虑的替代方案,因为那是最直觉的一个,而它行不通。我一开始的想法是「杀得更狠」——像 pty-host.ts 那样发 SIGTERM,或者直接 SIGKILL,让子进程在管道消失之前就停止渲染。读完 llm.tsx 之后这个想法被否掉了:CLI SIGTERMSIGHUP 一视同仁,都捕获进同一个异步 beginExit,所以换信号只是换了个被捕获的信号,时间窗口依然开着。只有 SIGKILL 无法被捕获,但用硬杀来换 teardown 的确定性,会牺牲掉那次优雅退出——而正是它负责回收 CLI 自己的子进程(MCP server、daemon)——这个代价比要修的问题更糟。不去和子进程的退出抢时间、而是直接移除写入路径,是正确的选择,影响面也更小。

有一点想请你考虑,但不是阻塞项:存在一个更窄的变体——保留监听器,只用一个 cleanedUp 标志守住写入。它少几行、不需要重构会话列表,而且能保住 _interactiveOutput 的累积——而后者恰恰是你自己在「风险与范围」里点出的主要取舍。我最终仍认为 dispose 版本更好:它释放了闭包、不会被第二条转发路径绕过、也与 pty-host.ts 已经在维护的 disposables 数组相呼应。但既然冻结缓冲区就是你要付的代价,描述里加一句「已考虑过标志位变体」会更完整。

没有夹带无关改动,也没有顺手重构:interactiveProcessesinteractiveSessions 的重命名是载荷结构变化所必需的,而且我用 grep 确认了全仓库只有那三处引用。

**风险:**无升级风险信号——两个改动文件都不匹配与 revert 相关的路径清单。剩余风险就是你已经写下的那一条(未来某个测试期望清理之后仍能读到输出),而且是有界的:我核查了所有消费方,详见代码审查。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Code review

I wrote down my own fix before opening the diff, from the title and the "Why it's needed" section alone: stop the write path rather than try to win the race against the child's exit — either keep the IDisposable that onData hands back and dispose it during cleanup, or leave the listener attached and gate the process.stdout.write on a cleanedUp flag. The PR implements the first of those. It matches my proposal, and having now read both the diff and the surrounding code I think it's the better of the two — a flag would leave the closure subscribed and the accumulation growing for a child nobody is reading any more.

No critical blockers, and no AGENTS.md violations. What follows is what I checked and the two nits I'd leave to your judgement.

The pattern is already the repo's own. packages/cli/src/agent-view/pty-host.ts captures const dataDisposable = ptyProcess.onData(...) into a disposables array and drains it in dispose() immediately after ptyProcess.kill(...); terminal-bridge.ts does the same. So this isn't a new invention in the harness — it brings the harness in line with how the product side already tears a PTY session down. That's the reuse check satisfied rather than a parallel helper added.

The rename is complete. interactiveProcesses had exactly three references repo-wide (declaration, cleanup loop, push site) and all three are in the diff. Nothing dangles.

Disposing the data listener doesn't break the exit path. This was my main correctness worry going in — onData and onExit are independent subscriptions, only the former is disposed, so the onExit resolve in runInteractive still fires. The related worry, that removing the subscriber stalls the master fd and hangs the child, doesn't hold either: node-pty's read loop is wired at spawn and fires into its own emitter regardless of subscriber count.

The frozen-buffer risk is real but bounded — I checked every consumer. runInteractive is called from exactly eight interactive/*.test.ts specs plus test-helper.test.ts; no cli/, hook-integration/, sdk-typescript/ or terminal-bench/ caller. In all eight, rig.cleanup() sits in the afterEach and every await promise / rig._interactiveOutput read is in a test body far below it, so nothing reads accumulated output after cleanup. The two internal readers are safe for the same reason: waitForText() polls _interactiveOutput but is never called after cleanup, and the onExit resolve would return a truncated output for a post-cleanup exit but no spec awaits that promise after cleanup. ctrl-c-exit.test.ts reads result.output but is it.skip'd and never calls cleanup, and external-context-mem0-write.test.ts spawns through its own local pty.spawn helper that bypasses interactiveSessions entirely — unaffected by this change, and it already closes its window by awaiting the exit in a finally.

One correction to the description, not to the code: it says cleanup is "the last statement of the teardown hook" in every interactive spec. In external-context-auto-recall.test.ts the afterEach restores saved environment variables after await rig.cleanup(). Harmless — that block only touches process.env — so the conclusion stands, but the claim as written is a shade too strong.

The witness is well built. It asserts the child is still alive before asserting nothing was forwarded, which is what stops it passing vacuously on an already-dead child — the failure mode that would make the whole test decorative. The KEEP_OUTPUT precondition genuinely reaches the forwarding guard: test-helper.ts does import { env } from 'node:process', and that is the same object as process.env, so the test's mutation is visible to it (the two pre-existing KEEP_OUTPUT specs already rely on this). It reuses the neighbouring spec's stand-in-CLI pattern (rig.bundlePath = rig.createFile(...)) rather than building a new harness, and the assertion filters the captured chunks for the canary so unrelated worker stdout can't produce a false failure. The finally SIGKILLs the 30-second stand-in so a failing run can't leak it.

Two nits, neither blocking:

  • Aliveness is asserted immediately after cleanup() but not again after the 500 ms window. A child that died at +100 ms would still pass, with five suppressed chunks instead of twenty-five. Your mutation probe covers this, but a second isProcessAlive check after the sleep would make the witness self-contained rather than relying on step 2 of the test plan to prove it has teeth.
  • The process.stdout.write mock is worker-global and stays live across the 500 ms sleep, so anything else the worker writes in that window is captured and dropped rather than printed. Harmless in practice — vitest reports over IPC, forks are separate processes, and the canary filter isolates the assertion — but it is a global side effect worth knowing about.

The window being closed

sequenceDiagram
    participant P1 as TestRig cleanup
    participant P2 as PTY child (CLI)
    participant P3 as Worker stdout pipe
    participant P4 as Vitest worker
    P1->>P3: dispose the onData listener (this PR)
    P1->>P2: kill with no signal, sends SIGHUP
    P2->>P2: trap SIGHUP, begin async graceful exit
    P4->>P3: tear down worker, read end gone
    P2->>P1: still rendering, emits bytes
    Note over P1,P3: no subscriber left, bytes dropped, no write, no EPIPE
    P2->>P2: exits on its own schedule
Loading

Before this PR, the step after "still rendering, emits bytes" was a process.stdout.write into a pipe whose read end was already gone — an unlistened EPIPE, escalated by Node to an uncaught exception, non-zero exit with no FAIL line anywhere in the log. Disposing the listener first removes the write, so there is nothing left to lose the race.

Test evidence

This is an unattended CI run, so per the gate rules I did not build or execute anything from this PR. Everything below is the PR's own CI, read through the API for the reviewed commit.

The important one is green, and it's green on the specific new test rather than just the file:

✓ test-helper.test.ts (8 tests) 617ms
  ✓ TestRig > detaches a session's output forwarding during cleanup  606ms

 Test Files  21 passed (21)
   Duration  114.66s

— from Integration Tests (no-AK, No Sandbox) (job 100935044407). Eight tests in that file matches the count your test plan predicts, and there is no FAIL, Unhandled, or EPIPE line anywhere in the 167 KB log. The neighbouring pre-existing witness, kills an interactive session a test never closed during cleanup, also passed through the same cleanup path that now disposes first — so the reordering doesn't regress the #10969 behaviour.

The one red check is not yours. Dependency CVE audit failed with:

npm warn audit 503 Service Unavailable - POST https://registry.npmjs.org/-/npm/v1/security/audits/quick - Service Unavailable
{ error: 'Service Unavailable' }
npm error audit endpoint returned an error

twice, at 06:43:58 and 06:51:03. That's the npm registry's audit endpoint being unavailable, and I classified it as infra from the diff and the check identity rather than from anything the log claims about itself: this PR touches two .ts files under integration-tests/ and no manifest, so there is no dependency change for a CVE audit to react to.

Final CI results for d1e250d (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Dependency CVE audit ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Two coverage gaps I want to state plainly rather than let a green table imply otherwise:

The specs that actually consume the changed cleanup path don't run on this PR at all. e2e.yml triggers on push to main, schedule, and workflow_dispatch — not pull_request. So neither the OpenTUI leg being fixed nor the Linux shards that would run test-helper.test.ts under KEEP_OUTPUT=true execute here. The eight interactive/*.test.ts specs are the real consumers of rig.cleanup(), and the first time this change meets them is post-merge on main. That's structural, not something you did wrong, but it does mean the blast-radius analysis above is static reasoning plus one green harness file — not an observed green interactive suite.

Not verified: that the witness actually fails with the dispose line removed. Your step 2 mutation probe is the only evidence for that, and it's your claim, not something CI reproduces — a suite that passes identically with and without the fix is green and worthless, and nothing in the check table distinguishes those two cases. Also not verified: that a child which writes still exits normally after its data listener is disposed. The pre-existing witness proves kill-after-dispose terminates a child, but its stand-in is idle, and your new stand-in is SIGKILLed in the finally rather than polled to death — so the writing-child case sits between the two tests rather than inside either.

Sandboxed verification would settle both: @qwen-code /verify — specifically the A/B mutation arm, that removing the single session.forwardOutput.dispose() line turns the new witness red with roughly 25 canary chunks forwarded after cleanup, and that a writing stand-in child still reaches exit once its data listener is disposed. Neither is observable from the diff, and this PR's suite passes without proof of either. You have write access, so this is a direct run rather than a sponsored one. /tmux is not the right lane here — there is no TUI surface, and the failure lives in worker teardown.

中文说明

代码审查

我在打开 diff 之前,只凭标题和「为什么需要它」写下了自己的修法:不要去和子进程的退出抢时间,而是直接切断写入路径——要么保留 onData 返回的 IDisposable、在清理时 dispose 掉,要么保留监听器、只用一个 cleanedUp 标志守住 process.stdout.write。本 PR 实现的是前者。它与我的提案一致;读完 diff 和周边代码之后,我认为两者中它更好——标志位方案会让闭包一直挂着,让一个再没人读的字符串继续增长。

没有阻塞性问题,也没有违反 AGENTS.md。下面是我核查过的内容,以及两个留给你判断的小点。

这个模式本来就是仓库自己的。packages/cli/src/agent-view/pty-host.ts 就是把 const dataDisposable = ptyProcess.onData(...) 收进 disposables 数组,并在 dispose() 里紧跟 ptyProcess.kill(...) 之后逐个排空;terminal-bridge.ts 同理。所以这不是在脚手架里新发明的东西,而是把脚手架对齐到产品侧本来就在用的 PTY 会话拆除方式。复用检查因此是满足的,而不是又加了一个平行 helper。

重命名是完整的。interactiveProcesses 全仓库只有三处引用(声明、清理循环、push 点),三处都在 diff 里,没有遗漏。

**dispose 数据监听器不会破坏退出路径。**这是我进来时主要的正确性顾虑——onDataonExit 是两个独立订阅,被 dispose 的只有前者,所以 runInteractive 里的 onExit resolve 仍会触发。相关的另一个顾虑(移除订阅者会卡住 master fd、把子进程挂死)也不成立:node-pty 的读取循环是在 spawn 时接好的,无论订阅者数量如何都会向它自己的 emitter 派发。

缓冲区冻结的风险是真实的,但有界——我核查了每一个消费方。runInteractive 的调用方恰好是八个 interactive/*.test.ts 加上 test-helper.test.tscli/hook-integration/sdk-typescript/terminal-bench/ 里都没有。这八个里,rig.cleanup() 都在 afterEach 中,而所有 await promise / rig._interactiveOutput 的读取都在远低于它的测试体内,所以没有任何地方在清理之后读取累积输出。两个内部读取方同理安全:waitForText() 会轮询 _interactiveOutput,但从不在清理之后被调用;onExit 的 resolve 对「清理之后才退出」的情况会返回被截断的 output,但没有哪个 spec 在清理之后 await 那个 promise。ctrl-c-exit.test.ts 读了 result.output,但它是 it.skip,且从不调用 cleanup;external-context-mem0-write.test.ts 用自己本地的 pty.spawn helper 派生,完全绕过 interactiveSessions——不受本改动影响,而且它已经在 finally 里 await 退出、自己关掉了那个窗口。

对描述(不是对代码)的一处更正:它说在每个交互式 spec 里 cleanup 都是「teardown 钩子的最后一条语句」。在 external-context-auto-recall.test.ts 里,afterEachawait rig.cleanup() 之后还会还原保存的环境变量。无害——那段只动 process.env——所以结论不变,但这个说法写得略强了些。

**见证测试写得扎实。**它先断言子进程仍然存活、再断言没有任何东西被转发,这正是防止它在一个早已死掉的子进程上空洞通过的关键——那种失效会让整个测试变成装饰品。KEEP_OUTPUT 这个前提确实能到达转发判断:test-helper.ts 里是 import { env } from 'node:process',而它与 process.env 是同一个对象,所以测试对它的修改是可见的(两个已有的 KEEP_OUTPUT spec 早就依赖这一点)。它复用了相邻 spec 的替身 CLI 写法(rig.bundlePath = rig.createFile(...)),没有另造一套脚手架;断言还会对捕获到的数据块按 canary 过滤,所以 worker 里无关的 stdout 不会造成误报。finally 会 SIGKILL 那个 30 秒的替身,失败时不会泄漏进程。

两个小点,都不阻塞:

  • 存活性是在 cleanup() 之后立刻断言的,但没有在那 500 毫秒窗口之后再断言一次。一个在 +100 毫秒就死掉的子进程仍然会让测试通过,只是被抑制的数据块是 5 个而不是 25 个。你的变异探针覆盖了这一点,但在 sleep 之后再加一次 isProcessAlive 会让见证测试自成闭环,而不必依赖测试计划的第 2 步来证明它有牙齿。
  • process.stdout.write 的 mock 是 worker 全局的,并且在那 500 毫秒 sleep 期间一直生效,所以该窗口内 worker 写的任何其他内容都会被捕获并丢弃、而不是打印出来。实践中无害——vitest 通过 IPC 上报、fork 是独立进程、canary 过滤又把断言隔离开了——但这确实是一个值得知道的全局副作用。

被关上的那个时间窗口

上方的时序图展示了本 PR 之后的路径:清理先 dispose 掉 onData 监听器,再发出不带信号的 kill(即 SIGHUP);子进程把 SIGHUP 捕获成一次异步优雅退出,vitest 随后拆除 worker、管道读端消失,而子进程仍在渲染、仍在发字节——但已经没有订阅者了,字节被丢弃,不产生写入,也就不会有 EPIPE。

改动之前,「仍在渲染、发出字节」之后的那一步是往一个读端已消失的管道里做 process.stdout.write:一个无人监听的 EPIPE,被 Node 升级为 uncaught exception,运行以非零退出,而日志里没有任何 FAIL 行。先 dispose 监听器就移除了这次写入,于是再没有东西需要去赢那场竞态。

测试证据

这是一次无人值守的 CI 运行,因此按门禁规则我没有构建或执行本 PR 的任何代码。下面全部内容都是本 PR 自己的 CI,通过 API 针对被审查的那个提交读取。

关键的那一项是绿的,而且是绿在具体那个新测试上、不只是文件级别:Integration Tests (no-AK, No Sandbox)(job 100935044407)报告 test-helper.test.ts (8 tests) 617ms,其中 TestRig > detaches a session's output forwarding during cleanup 606ms 通过,整套 Test Files 21 passed (21),耗时 114.66 秒。八个测试与你测试计划里预测的数字一致,167 KB 的日志里没有任何 FAILUnhandledEPIPE 行。相邻那个已有的见证测试 kills an interactive session a test never closed during cleanup 也通过了同一条现在会先 dispose 的清理路径——所以这个顺序调整没有让 #10969 的行为回退。

唯一的红灯不是你的。Dependency CVE audit 失败于 npm registry 的审计端点返回 503(06:43:58 与 06:51:03 各一次)。我把它归为基础设施问题,依据是 diff 和检查项本身的性质,而不是日志正文里的任何自我声明:本 PR 只改了 integration-tests/ 下两个 .ts 文件,没有动任何 manifest,因此没有任何依赖变化可供 CVE 审计反应。

上方的 CI 表格已用机器可读的区域标记包裹,CI 跑完之后 finalize 任务会就地更新它。

有两个覆盖缺口我想直接说明,而不是让一张绿色的表暗示出不存在的结论:

真正消费这条被改动清理路径的那些 spec,在本 PR 上根本没有运行。e2e.yml 的触发条件是 push 到 mainscheduleworkflow_dispatch——不包含 pull_request。所以被修的那个 OpenTUI 检查项、以及在 KEEP_OUTPUT=true 下会跑 test-helper.test.ts 的 Linux 分片,在这里都不会执行。八个 interactive/*.test.ts 才是 rig.cleanup() 的真实消费方,而本改动第一次遇到它们,是合并之后在 main 上。这是结构性的,不是你做错了什么,但确实意味着上面对影响范围的分析是静态推理加一个变绿的脚手架文件——而不是一个被观测到变绿的交互式套件。

未验证:把 dispose 那一行删掉之后见证测试是否真的会失败。你的第 2 步变异探针是这一点唯一的证据,而它是你的说法,CI 并不会复现它——一个「有改动」和「没改动」都同样通过的套件既是绿的、也是没有价值的,而检查表里没有任何东西能区分这两种情况。同样未验证:一个会写输出的子进程在其数据监听器被 dispose 之后是否仍能正常退出。已有的见证测试证明了「先 dispose 再 kill」能终止子进程,但它的替身是空闲的;而你的新替身是在 finally 里被 SIGKILL 的,并没有被轮询到自然死亡——所以「会写的子进程」这一情形落在两个测试之间,而不在任何一个之内。

沙箱验证可以把这两点都定下来:@qwen-code /verify——具体是 A/B 变异那一臂,即删掉 session.forwardOutput.dispose() 这一行是否会让新见证测试变红、并显示清理之后仍转发了约 25 个 canary 数据块;以及一个会写输出的替身子进程在其数据监听器被 dispose 之后是否仍能走到退出。这两点都无法从 diff 观察,而本 PR 的套件在两者都没有被证明的情况下就通过了。你有 write 权限,所以这是一次直接运行,而不是需要维护者背书的 sponsored run。/tmux 在这里不是合适的通道——没有 TUI 界面,失效发生在 worker teardown 里。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the change itself is clean and I would merge it on its own merits; what I can't decide is which of two competing open PRs should own these lines.

⏸️ Deferring to a maintainer rather than approving. Not because of anything wrong in this diff — because approving here would silently settle a choice that isn't mine to make.

The thing that stopped me

I ran the "is this part of a pattern?" check the gate asks for, and it found something. This PR's author has eleven PRs open, three of them fix(test) in the last two hours. Two of those three are competing fixes for the same failure window in the same function:

#11007 (this PR) #11001
Title stop forwarding a cleaned-up PTY session into stdout wait for interactive PTY sessions to end during cleanup
Opened 06:36Z 04:58Z — 1 h 38 m earlier
Linked issue #11002 (04:59Z) #10990 (03:22Z)
Files integration-tests/test-helper.ts + its spec the identical two files
Strategy dispose the onData listener, then kill kill, then await the child's exit (10 s grace)
Session payload { ptyProcess, forwardOutput: pty.IDisposable } { ptyProcess, exited: Promise<unknown> }

They rewrite the same three sites — the interactiveProcesses field at line 203, the cleanup loop at 500–506, and the push site at 947 — with incompatible payload shapes. Both are MERGEABLE against main right now, because GitHub computes that per-PR; the collision lands the moment the second one merges. Three more concrete overlaps:

And neither PR mentions the other. This one's Related list names #10969, #10112, #10325 and #10085 — not #11001, not #10990.

The two linked issues are the same failure: both name workflow E2E Tests, job E2E Interactive - OpenTUI renderer (bun), step Run interactive E2E tests (OpenTUI), differing only in commit (b7815a7e1a82 vs 56f75adf2992). That is precisely the per-commit issue churn with nothing to dedupe on that this PR's own description opens by complaining about — and the autofix pipeline answered it by filing twice and producing two mutually exclusive fixes.

This bot already triaged #11001: Confidence 4/5, with a deferred approval that was then withheld when its CI didn't settle. So both PRs currently sit at "the bot liked this one", and they cannot both land. main requires two approving reviews; a bot approval on each of two mutually exclusive PRs is worse than no approval, because it reads as two independent endorsements of one decision that was never made.

The substantive part of the choice

This isn't just merge mechanics — the two strategies differ in a way that matters for this specific leg, and it's the reason I'd want a human to pick rather than let merge order decide.

#11001 makes cleanup() block until each session's child actually exits, bounded by INTERACTIVE_EXIT_GRACE_MS = 10_000, awaited per session inside every interactive spec's afterEach. That genuinely closes the window — a child that is gone cannot write.

But this PR's own Risk section names the leg's second independent source of red runs with no named test: "the test runner's fixed 60-second worker-to-main RPC budget, which a stall under load can exceed", while the leg runs "up to four parallel forks of pseudo-terminal-heavy tests". Adding up to ten seconds of awaited teardown per session, in the most PTY-heavy suite, on the most loaded lane, pushes directly against that budget. So on the evidence in this PR's description, #11001's strategy risks feeding the failure mode this PR deliberately declined to widen the exemption for.

Detaching costs zero teardown latency and closes the window unconditionally — a byte that never reaches stdout cannot break a pipe however long the graceful shutdown takes — at the price of freezing _interactiveOutput, which I verified nothing reads.

My read: this PR's strategy is the better of the two for this leg. If a maintainer wants both, they compose well, and the ordering matters: dispose first, then await. Once nothing can EPIPE, the await is no longer safety-critical, so its grace can drop well below ten seconds — you'd get #11001's guarantee that no child leaks past teardown without paying its latency in the RPC budget. But that's a design call with a tradeoff attached, and it isn't one an approval on either PR should settle by accident.

Why I'm not approving

Not the code. The code is minimal, correct, mirrors pty-host.ts's existing teardown pattern, and its witness is green on CI at 606 ms with 21 files passing. My two nits are in Stage 2 and neither blocks.

I'm deferring because the merge decision is contested and I'd be breaking the tie silently. What I'd ask a maintainer to do:

  1. Pick one strategy for TestRig.cleanup() — or take the compose-both option above — and reconcile the other PR against it rather than letting merge order decide.
  2. Close the loser against the survivor so the work isn't stranded, and dedupe Main CI failed: E2E Tests on b7815a7e1a82 #10990 / Main CI failed: E2E Tests on 56f75adf2992 #11002, which are one failure filed twice. The detector's per-commit keying is what produced this, and it will keep producing it until the leg stops reddening.
  3. If this one is the survivor, @qwen-code /verify is still worth running for the reason in Stage 2: nothing in CI proves the witness has teeth, and the writing-child-still-exits-after-dispose case sits between the two tests rather than inside either.

One small corroboration for the red check: Dependency CVE audit also went red on #11001, whose author bot then recorded that it passes on current main. That's a second, independent source for my Stage 2 classification — registry 503, pre-existing infra, clears on re-run.

No @mention, deliberately. I ran the deterministic owner resolver rather than eyeballing one: $QWEN_MAINTAINER_HANDLE is unset, this PR carries only review/self-reported so no area in .github/issue-owners.json matches, and there is no human reviewer to fall back to. The resolver returned nothing, and guessing a login would notify the wrong person — so this comment goes out unaddressed. Adding an area label would route it, and whoever picks it up: the question is the strategy choice above, not the diff.

中文说明

Confidence: 3/5 —— 改动本身是干净的,单看它我会合并;我无法判断的是「这两个互相竞争的开放 PR,哪一个才该拥有这几行代码」。

⏸️ **转交维护者,而不是批准。**不是因为这份 diff 有任何问题——而是因为在这里批准,等于悄悄替别人做了一个不该由我做的选择。

让我停下来的那件事

我执行了门禁要求的「这是否属于某种模式」检查,结果发现了问题。本 PR 的作者有 11 个开放 PR,其中三个是最近两小时内的 fix(test)。而这三个里有两个,是对同一个函数里同一个失效窗口的竞争性修复

#11007(本 PR) #11001
标题 stop forwarding a cleaned-up PTY session into stdout wait for interactive PTY sessions to end during cleanup
开启时间 06:36Z 04:58Z——早 1 小时 38 分
关联 issue #11002(04:59Z) #10990(03:22Z)
文件 integration-tests/test-helper.ts 及其 spec 完全相同的两个文件
策略 dispose 掉 onData 监听器,然后 kill kill,然后 await 子进程退出(10 秒宽限)
会话载荷 { ptyProcess, forwardOutput: pty.IDisposable } { ptyProcess, exited: Promise<unknown> }

它们改写了同样的三处——第 203 行的 interactiveProcesses 字段、500–506 行的清理循环、947 行的 push 点——而且载荷结构互不兼容。两者目前对 main 都显示 MERGEABLE,因为 GitHub 是按 PR 单独计算的;冲突会在第二个合并的那一刻才落地。另外三处具体重叠:

而且两个 PR 都没有提到对方。本 PR 的「相关」清单点了 #10969#10112#10325#10085——没有 #11001,也没有 #10990

这两个关联 issue 是同一次失效:都指向 workflow E2E Tests、job E2E Interactive - OpenTUI renderer (bun)、step Run interactive E2E tests (OpenTUI),只有提交不同(b7815a7e1a8256f75adf2992)。这恰恰就是本 PR 描述开篇所抱怨的「按提交逐个建 issue、检测器无从去重」——而 autofix 流水线的回应是建了两次、并产出了两个互斥的修复。

本 bot 已经对 #11001 做过 triage:Confidence 4/5,随后因其 CI 未收敛而撤回了延迟批准。所以两个 PR 目前都停在「bot 认为这个不错」的状态,而它们不可能同时落地。main 需要两个批准;对两个互斥的 PR 各给一个 bot 批准,比不批准更糟——因为它看起来像是对一个从未被做出过的决定的两次独立背书。

这个选择里真正有实质内容的部分

这不只是合并机制问题——两种策略的差异对这个具体检查项是有影响的,这也是我希望由人来挑、而不是让合并顺序来定的原因。

#11001cleanup() 阻塞到每个会话的子进程真正退出为止,上界是 INTERACTIVE_EXIT_GRACE_MS = 10_000,并且是在每个交互式 spec 的 afterEach 里按会话逐个 await。这确实关上了窗口——已经消失的子进程不可能再写。

但本 PR 自己的「风险与范围」点出了该检查项第二个彼此独立的「无测试名红灯」来源:「测试运行器固定的 60 秒 worker→main RPC 预算,负载下的停顿可能超出它」,而该检查项会「以最多四个并行 fork 运行伪终端密集型测试」。在最重伪终端的套件里、在最拥挤的通道上,按会话增加最多十秒的 await teardown,正是直接顶着那个预算。所以按本 PR 描述里的证据,#11001 的策略有喂养「本 PR 刻意拒绝为其扩大豁免」那个失效模式的风险。

摘除转发的 teardown 延迟是零,并且无条件关上窗口——一个从未到达 stdout 的字节不可能打破管道,无论优雅退出耗时多久——代价是冻结 _interactiveOutput,而我已核实没有任何地方读它。

**我的判断:就这个检查项而言,本 PR 的策略是两者中更好的一个。**如果维护者想要两者,它们可以很好地组合,而且顺序很重要:先 dispose,再 await。一旦不可能 EPIPE,那个 await 就不再是安全关键的,它的宽限时间就可以远低于十秒——你既能得到 #11001 的「没有子进程泄漏到 teardown 之后」的保证,又不必在 RPC 预算里付出它的延迟。但这是一个带着取舍的设计决定,不该由对任一 PR 的一次批准来意外地拍板。

为什么我不批准

不是因为代码。代码是最小化的、正确的、与 pty-host.ts 已有的 teardown 模式相呼应,而且它的见证测试在 CI 上是绿的:606 毫秒,21 个文件通过。我的两个小点在 Stage 2 里,都不构成阻塞。

我之所以转交,是因为合并决定存在竞争,而我会在无人察觉的情况下打破这个平局。我希望维护者做的:

  1. TestRig.cleanup() 选定一种策略——或者采用上面的「两者组合」方案——并让另一个 PR 与之对齐,而不是让合并顺序来决定。
  2. 把落选的那个对着胜出者关闭,免得工作被搁浅;并对 Main CI failed: E2E Tests on b7815a7e1a82 #10990 / Main CI failed: E2E Tests on 56f75adf2992 #11002 去重,它们是同一次失效被建了两遍。检测器按提交建 issue 的键控方式正是成因,而在那个检查项停止变红之前,它会继续这样产出。
  3. 如果本 PR 是胜出者,@qwen-code /verify 仍值得跑一次,理由见 Stage 2:CI 里没有任何东西能证明这个见证测试有牙齿,而「会写输出的子进程在监听器被 dispose 之后仍能退出」这一情形落在两个测试之间,而不在任何一个之内。

关于那个红灯检查项的一点旁证:Dependency CVE audit#11001 上也变红了,而其作者 bot 随后记录它在当前 main 上是通过的。这为我 Stage 2 的归类提供了第二个独立来源——registry 503,既有基础设施问题,重跑即可恢复。

**刻意不做 @mention。**我运行的是确定性的 owner 解析器,而不是凭眼力挑一个:$QWEN_MAINTAINER_HANDLE 未设置,本 PR 只带 review/self-reported,因此 .github/issue-owners.json 里没有任何 area 匹配,也没有可回退的人类审阅者。解析器什么都没返回,而猜一个 login 只会通知到错误的人——所以这条评论不点名发出。加上一个 area 标签就能把它路由出去;无论谁接手:需要判断的是上面那个策略选择,而不是这份 diff。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at d1e250dd4265fe6cee6cf6c54f5d68ccbbdf7fe5 · 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.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread integration-tests/test-helper.test.ts Outdated
Comment thread integration-tests/test-helper.test.ts Outdated
@qwen-code-dev-bot

qwen-code-dev-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

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

中文说明

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

…path (#11002)

The witness d1e250d added for #11002 had two ways to stay green while
certifying nothing. Both were reproduced before either was changed.

It never asserted that stdout forwarding was live before `cleanup()` was
asked to stop it. The first poll pins `rig._interactiveOutput`, which the
`onData` handler appends unconditionally ahead of the `KEEP_OUTPUT` gate, so
it proves only that the handler fired — never that the gated
`process.stdout.write(data)` ran. The spy is installed after that poll and
`forwarded.length = 0` discards whatever it captured, so replacing the gated
write with a no-op left the case green in 735ms with forwarding entirely
dead. Poll the spy buffer for the canary before cleanup, through the
harness's own gate rather than a re-implemented condition; that mutant now
reddens with "stdout forwarding never went live before cleanup".

It also certified the producer alive only at the instant `cleanup()`
returned. Removing `dispose()` and SIGKILLing the child right after that
assertion left the 500ms window empty for a reason that has nothing to do
with detaching — a dead child emits no bytes — and the case passed in 714ms.
Re-check liveness at the end of the window with the same `isProcessAlive`
helper; that mutant now reddens on the re-check.

Removing only `dispose()`, with the producer alive, still reddens on the
window itself with 24-25 canary chunks, so neither addition blunts the check
the witness exists to make. Both are additive assertions: the intact tree
stays green in 658ms.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator Author

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

Autofix round summary — PR #11007 (issue #11002)

Commit 2116973523test(integration): stop the PTY-forwarding witness passing on a dead path (#11002). One file changed, 14 insertions, no deletions. Additive only: no production source changed this round.

Feedback triage

[rv:5110726423] Partial-review disclosure — No action

The review body discloses that the reviewer's reverse audit did not run inside its time budget and points at its inline suggestions. It requests no change, so there is nothing to implement; the actionable content is the two inline findings below. The undisclosed reverse-audit gap is noted for the maintainer rather than treated as clean coverage.

[rc:3932168671] R1-1 — witness has no positive control → Implemented (claim reproduced first)

The finding claims the witness can pass vacuously if the gated forwarding path is ever silently disabled. Reproduced on the pre-round code before touching anything:

  • Mutant: inside runInteractive, replaced the gated process.stdout.write(data) with a no-op (void data), leaving the KEEP_OUTPUT gate, the _interactiveOutput append, and everything else intact.
  • Result on the pre-round test: ✓ detaches a session's output forwarding during cleanup 735ms — green, with the forwarding path entirely dead and no canary byte reaching stdout. The suite's only guard over the Main CI failed: E2E Tests on 56f75adf2992 #11002 fix certified nothing.

Fix: poll the spy buffer for the canary before rig.cleanup(), so the assertion is only satisfiable by a chunk that actually travelled through the harness's own env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true' gate — not a locally re-implemented condition, as the finding required. forwarded.length = 0 still runs after cleanup, so the window semantics are unchanged.

Acceptance re-measured against the same mutant: × ... 60342ms, AssertionError: stdout forwarding never went live before cleanup: expected false to be true. The positive control is its own pin.

[rc:3932168686] R1-4 — liveness certified only at cleanup return → Implemented (claim reproduced first)

The finding claims the empty-forwarded assertion cannot distinguish "forwarding was detached" from "the producer died". Reproduced on the pre-round code:

  • Mutant: removed session.forwardOutput.dispose() from cleanup() and SIGKILLed the producer immediately after the existing liveness assertion, i.e. inside the 500 ms observation window.
  • Result on the pre-round test: ✓ ... 714ms — vacuous green. A dead child emits no bytes, so the regression witness shipped while certifying nothing.

Fix: re-check liveness at the end of the window with the same isProcessAlive helper the existing check uses, keeping the helper single-sourced as the finding required.

Acceptance re-measured against the same mutant: × ... 2054ms, AssertionError: expected false to be true from the new re-check.

The discrimination the witness exists for was re-probed too, so neither addition blunts it: removing only dispose(), with the producer alive, still fails on the window itself with cleanup() left the session forwarding PTY bytes into stdout: expected [ …(24) ] to deeply equal [] (24–25 canary chunks, matching the count the previous round recorded).

Failed check: Dependency CVE auditEscalated to a maintainer; not implementable in this PR

This check is red, and it cannot be caused by this PR:

  • The job (.github/workflows/security-checks.yml) runs npm ci --ignore-scripts then npm audit --omit=dev --audit-level=high against the root lockfile plus each vendored packages/*/package-lock.json. Its entire input set is manifests and lockfiles.
  • git diff origin/main...HEAD --name-only for this PR is exactly integration-tests/test-helper.test.ts and integration-tests/test-helper.ts — no package.json, no package-lock.json, no patches/. The audit's inputs are byte-identical to the base branch, so its verdict on base and on head is the same by construction.
  • History confirms the gate trips on newly published advisories against the committed lockfile rather than on a PR's source: main carries 2a428054c4 chore(deps): bump fast-uri to 3.1.7 to clear the high-severity audit gate (#10862), an explicit dependency bump landed for exactly this reason.
  • The check also ran at 06:36–06:51Z against the previous head, before this round's commit existed.

Clearing it means bumping a dependency and regenerating a lockfile. Lockfiles and patches/ are supply-chain areas this workflow is not permitted to modify, and which package or version to take is a maintainer's call, so it is escalated rather than implemented. No npm audit was run locally: it is a networked package command and is outside this round's allowed set; the static input-set argument above is the evidence offered instead.

Files changed

File Change
integration-tests/test-helper.test.ts +14: pre-cleanup positive control poll on the spy buffer; post-window isProcessAlive re-check; one short "why" comment each

Footprint: unchanged from the PR's own — the single file touched is one the PR already touches. No CI, workflow, husky, skill, script, lockfile, or workspace-manifest area entered. No test was deleted or weakened; both changes are added assertions.

Verification

Every command below was actually run in this checkout; results are quoted from their real output.

Required checks:

  • npm run buildpassed (exit 0)
  • npm run typecheckpassed (TYPECHECK_EXIT=0)
  • npm run typecheck:integrationpassed (TYPECHECK_INTEGRATION_EXIT=0)
  • npm run lintpassed (LINT_EXIT=0; eslint . --ext .ts,.tsx && eslint integration-tests, no findings)
  • npx prettier --check integration-tests/test-helper.test.ts integration-tests/test-helper.tspassed ("All matched files use Prettier code style!")
  • Focused integration-harness run, cd integration-tests && QWEN_SANDBOX=false npx vitest run test-helper.test.ts8 passed (8), witness case ✓ 658ms on the committed tree. Re-run after the build so the green result is from the exact committed content: git show HEAD:integration-tests/test-helper.test.ts is byte-identical (md5 897a32c3…) to the tree that was tested, and the pre-commit hook made no content change.

Mutation probes — each guard this round adds has its own witness, and every mutant was reverted afterwards (git status --short clean, test-helper.ts md5 5ae38b00… restored to its committed state after each probe):

# Mutant Pre-round test Post-round test
A gated process.stdout.write(data) → no-op ✓ 735ms (vacuous) × 60342ms — "stdout forwarding never went live before cleanup"
B1 session.forwardOutput.dispose() removed, producer alive × 1956ms — 24/25 canary chunks in window × 2151ms — 24/24/25 chunks, discrimination preserved
B2 dispose() removed and producer SIGKILLed inside the window ✓ 714ms (vacuous) × 2054ms — end-of-window liveness re-check

Not run, with reasons:

  • npm run bundle + the broader integration suites — not required this round. The change is confined to assertions inside test-helper.test.ts, and that case deliberately overrides rig.bundlePath with a stand-in script, so it exercises the harness without the bundled CLI. The harness file test-helper.ts is untouched this round, and CI's Integration Tests (no-AK, No Sandbox) was green on it at the previous commit.
  • npm run generate:settings-schema — not required: no settings source changed.
  • npm audit — networked package command, outside the allowed set; see the escalation above for the evidence offered in its place.
中文说明

Autofix 轮次总结 — PR #11007(issue #11002

提交 2116973523test(integration): stop the PTY-forwarding witness passing on a dead path (#11002)。改动 1 个文件,新增 14 行,无删除。本轮纯增量:未改动任何生产源码。

反馈分类处理

[rv:5110726423] 部分审查披露 — 无需处理

该审查正文说明审查者的反向审计未能在其时间预算内完成,并指向其行内建议。它没有要求任何改动,因此没有可实现的内容;可执行的部分是下面的两条行内发现。未披露完成的反向审计缺口已提请维护者注意,而不被视为覆盖完整。

[rc:3932168671] R1-1 — 见证测试缺少阳性对照 → 已实现(先复现该论断)

该发现称:一旦受门控的转发路径被静默禁用,见证测试就会空洞地通过。在改动任何代码之前,先在轮次前的代码上复现:

  • 变异体:在 runInteractive 内部,把受门控的 process.stdout.write(data) 替换为空操作(void data),保留 KEEP_OUTPUT 门、_interactiveOutput 追加以及其他一切不变。
  • 轮次前测试的结果:✓ detaches a session's output forwarding during cleanup 735ms — 绿灯,而转发路径已完全失效,没有任何 canary 字节抵达 stdout。整个套件中针对 Main CI failed: E2E Tests on 56f75adf2992 #11002 修复的唯一守卫什么都没证明。

修复:在 rig.cleanup() 之前轮询 spy 缓冲区中的 canary,因此该断言只能由真正经过 harness 自身 env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true' 门的 chunk 满足 —— 而不是本地重新实现的条件,正如该发现所要求的。forwarded.length = 0 仍在 cleanup 之后执行,所以观察窗口的语义保持不变。

针对同一变异体重新度量验收标准:× ... 60342msAssertionError: stdout forwarding never went live before cleanup: expected false to be true。阳性对照本身就是钉桩。

[rc:3932168686] R1-4 — 只在 cleanup 返回时刻证明存活 → 已实现(先复现该论断)

该发现称:空的 forwarded 断言无法区分"转发已被摘除"与"生产进程已死亡"。在轮次前的代码上复现:

  • 变异体:从 cleanup() 中移除 session.forwardOutput.dispose()并且在现有存活断言之后立即 SIGKILL 生产进程,即在 500 ms 观察窗口之内。
  • 轮次前测试的结果:✓ ... 714ms — 空洞绿灯。死掉的子进程不会发出任何字节,因此这个回归见证测试在未证明任何东西的情况下通过了。

修复:在窗口末尾使用与现有检查相同的 isProcessAlive 辅助函数复查存活,按该发现的要求保持辅助函数单一来源。

针对同一变异体重新度量验收标准:× ... 2054ms,新增复查抛出 AssertionError: expected false to be true

同时也重新探测了这个见证测试本应具备的区分能力,以确认两处新增都没有削弱它:仅移除 dispose()、生产进程存活时,仍然在窗口断言上失败并报 cleanup() left the session forwarding PTY bytes into stdout: expected [ …(24) ] to deeply equal [](24–25 个 canary chunk,与上一轮记录的数量一致)。

失败检查:Dependency CVE audit升级给维护者;本 PR 无法实现

该检查是红的,而它不可能由本 PR 造成:

  • 该作业(.github/workflows/security-checks.yml)先执行 npm ci --ignore-scripts,再对根 lockfile 以及每个 vendored packages/*/package-lock.json 执行 npm audit --omit=dev --audit-level=high。它的全部输入集合就是各类 manifest 与 lockfile。
  • 本 PR 的 git diff origin/main...HEAD --name-only 恰好是 integration-tests/test-helper.test.tsintegration-tests/test-helper.ts —— 没有 package.json、没有 package-lock.json、没有 patches/。审计的输入与基线分支逐字节相同,因此按构造其在 base 与 head 上的结论必然一致。
  • 历史也印证该门是被针对已提交 lockfile 新公布的公告触发,而非被某个 PR 的源码触发:main 上有 2a428054c4 chore(deps): bump fast-uri to 3.1.7 to clear the high-severity audit gate (#10862),正是为同一原因专门落地的一次依赖版本提升。
  • 该检查运行于 06:36–06:51Z,针对的是上一个 head,彼时本轮提交尚不存在。

清除它意味着提升某个依赖并重新生成 lockfile。lockfile 与 patches/ 属于本工作流不允许改动的供应链区域,而选用哪个包或哪个版本属于维护者的决定,因此升级处理而非自行实现。本地未运行 npm audit:它是联网的包管理命令,不在本轮允许的命令集合内;上文给出的静态输入集合论证即为替代证据。

变更文件

文件 改动
integration-tests/test-helper.test.ts +14:cleanup 前对 spy 缓冲区的阳性对照轮询;窗口结束后的 isProcessAlive 复查;各一条简短的"为什么"注释

Footprint:与 PR 自身的 footprint 一致 —— 唯一触及的文件正是 PR 已经触及的文件。未进入任何 CI、workflow、husky、skill、脚本、lockfile 或 workspace manifest 区域。没有删除或削弱任何测试;两处改动都是新增断言。

验证

下面每条命令都在本 checkout 中真实执行过;结果直接引自其真实输出。

必需检查:

  • npm run build通过(exit 0)
  • npm run typecheck通过TYPECHECK_EXIT=0
  • npm run typecheck:integration通过TYPECHECK_INTEGRATION_EXIT=0
  • npm run lint通过LINT_EXIT=0eslint . --ext .ts,.tsx && eslint integration-tests,无任何问题)
  • npx prettier --check integration-tests/test-helper.test.ts integration-tests/test-helper.ts通过("All matched files use Prettier code style!")
  • 定向集成 harness 运行:cd integration-tests && QWEN_SANDBOX=false npx vitest run test-helper.test.ts8 passed (8),见证用例在已提交的树上为 ✓ 658ms。构建之后重新运行过,因此绿灯结果来自将要提交的确切内容:git show HEAD:integration-tests/test-helper.test.ts 与被测树逐字节相同(md5 897a32c3…),且 pre-commit 钩子未做任何内容改动。

变异探测 —— 本轮新增的每个守卫都有自己的见证,且每个变异体事后都已还原(git status --short 干净,test-helper.ts 的 md5 在每次探测后都恢复为其已提交状态 5ae38b00…):

# 变异体 轮次前测试 轮次后测试
A 受门控的 process.stdout.write(data) → 空操作 ✓ 735ms(空洞) × 60342ms — "stdout forwarding never went live before cleanup"
B1 移除 session.forwardOutput.dispose(),生产进程存活 × 1956ms — 窗口内 24/25 个 canary chunk × 2151ms — 24/24/25 个 chunk,区分能力保留
B2 移除 dispose() 并且在窗口内 SIGKILL 生产进程 ✓ 714ms(空洞) × 2054ms — 窗口末尾的存活复查

未运行项及原因:

  • npm run bundle 与更大范围的集成套件 —— 本轮不需要。改动仅限于 test-helper.test.ts 内部的断言,而该用例刻意用替身脚本覆盖 rig.bundlePath,因此它在不依赖打包 CLI 的情况下检验 harness。harness 文件 test-helper.ts 本轮未触及,且 CI 的 Integration Tests (no-AK, No Sandbox) 在上一个提交时对它是绿的。
  • npm run generate:settings-schema —— 不需要:没有 settings 源发生变化。
  • npm audit —— 联网包管理命令,不在允许集合内;替代证据见上文的升级说明。

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

🧵 Resolved all 2 selected review thread(s). · 已关闭全部选中的 2 条评审线程。

Re-review when you have a moment. After round 10 this bot stops and leaves the PR for a human. · 有空请复审;第 10 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI failing: web-shell E2E Smoke (ubuntu-latest, Node 22.x), Dependency CVE audit. Reviewed.

Not explored to full depth (tool budget reached): "agent 3b": none — no checks were cut short.; "agent 3a": none — no check was cut short by the tool budget..

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • integration-tests/test-helper.ts:205 — [probe] D2-1 cleanup() silently freezes _interactiveOutput/promise.output capture for a child outliving cleanup; field name and comments describe only the stdout-forwarding half
  • integration-tests/test-helper.test.ts:146 — [review] D2-2 witness asserts Unix-only SIGHUP-trap semantics; deterministically red on Windows local runs — guard with it.skipIf(process.platform === 'win32')
中文说明

⚠️ 已从批准降级为评论:CI failing: web-shell E2E Smoke (ubuntu-latest, Node 22.x), Dependency CVE audit。 已审查。

未探索到全部深度(达到工具调用预算):"agent 3b"none — no checks were cut short."agent 3a"none — no check was cut short by the tool budget.

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator Author

🔀 Base updated: red check(s) [Dependency CVE audit] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Dependency CVE audit] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

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

Partially reviewed — gaps disclosed.

Not reviewed: reverse audit — stopped before round 4 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:反向审计——评审时间预算不足,未能开始第 4 轮。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread integration-tests/test-helper.test.ts Outdated
Comment thread integration-tests/test-helper.test.ts Outdated
The witness 2116973 added had two ways to certify nothing, plus one
platform where it reddens without the behaviour under test being wrong.
Both defects were reproduced before either was changed.

It proved its producer alive across the 500ms no-bytes window, but alive
is not delivered: node-pty reads the pty fd in the loop's poll phase, so a
worker stalled across the window resumes into an already-expired sleep
timer and asserts against a buffer the pending bytes have not reached.
With dispose() removed and an 800ms synchronous stall started from the
check phase, the case passed green while a canary landed one loop turn
after the assertion. Register a second onData witness instead — node-pty
returns an independent disposable per registration and fires listeners in
registration order, so the harness's handler runs before the witness for
the same chunk — and poll it for proven delivery before asserting the
harness forwarded nothing. That mutant now reddens, as does removing
dispose() alone (2 canary chunks forwarded) and removing it while
SIGKILLing the child after the first liveness check (the poll times out).
The last is the mutant the deleted end-of-window isProcessAlive re-check
was added for, so the delivery poll subsumes it; the intact tree stays
green.

It also hard-asserted POSIX-only SIGHUP survival, which is deterministically
red on Windows local runs: node-pty's kill() terminates the child
unconditionally there and its typings document the signal argument as
unsupported. Guard the case with it.skipIf(process.platform === 'win32')
like its platform-dependent siblings under integration-tests/interactive/.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator Author

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

Autofix round summary — PR #11007 (issue #11002)

Outcome: one additive commit, 8340b5ff00test(integration): harden the PTY-forwarding witness (#11002). One file changed: integration-tests/test-helper.test.ts (94 insertions, 71 deletions — most of it re-indentation from wrapping the case in it.skipIf(...); the real net growth is ~10 lines). integration-tests/test-helper.ts is byte-identical to the pre-round branch: both findings were defects in the witness, not in the harness fix it witnesses. No conflict resolution was needed (--conflict false).

Both actionable findings were reproduced before anything was changed, and every fix was mutation-probed afterwards.

Feedback points and decisions

[rc:3934185242] R3-1 — Critical: witness hard-asserts POSIX-only SIGHUP survival → Implemented

Claim (checkable): on Windows the stand-in child cannot survive cleanup(), so both expect(isProcessAlive(...)).toBe(true) assertions go red even though the dispose behaviour under test is correct.

Verification. Windows is not executable on this runner, so the claim was verified against the pinned dependency and the suite's own conventions rather than guessed at:

  • package.json:178-183 pins @lydell/node-pty@1.2.0-beta.10 and ships @lydell/node-pty-win32-{x64,arm64} — Windows is an install target for this suite, not a hypothetical.
  • The pinned package's own typings, node_modules/@lydell/node-pty/node-pty.d.ts:184-188: @param signal The signal to use, defaults to SIGHUP. This parameter is not supported on Windows. @throws Will throw when signal is used on Windows. There is no trappable SIGHUP on the Windows path, so the child cannot outlive cleanup() and the test's stated premise does not hold there.
  • The POSIX half of the premise is confirmed in the installed implementation: @lydell/node-pty-linux-x64/lib/unixTerminal.js:228process.kill(this.pid, signal || 'SIGHUP').
  • House convention already guards exactly this class: integration-tests/interactive/file-system-interactive.test.ts:38, protocol-tags-interactive.test.ts:37, context-compress-interactive.test.ts:21,109 all use it.skipIf(process.platform === 'win32')(.

Fix. Guarded the case the same way, with a one-line comment naming why the premise is POSIX-only. The sibling case above it (kills an interactive session a test never closed) keeps no guard on purpose: it asserts the child is dead after cleanup(), which holds on both platforms.

Probe note, reported honestly. Stubbing process.platform to 'win32' in a worker setup file cannot witness the guard on this runner: @lydell/node-pty/index.js:1 resolves its implementation by process.platform at import time, so the stub makes the module load fail with Cannot find module '@lydell/node-pty-win32-x64' before the test file is collected (Test Files 1 failed, Tests no tests). That failure is itself confirmation of the platform dispatch the finding rests on, but it means the guard's skip half is witnessed by convention and by the dependency's documented semantics, not by a red/green differential on this machine. Removing the guard here stays green, because the runner is POSIX.

[rc:3934185254] R3-2 — Suggestion: the 500ms window proves liveness, not delivery → Reproduced, implemented (with one correction to the suggested snippet)

Reproduction. The finding claims the window can pass green with forwarding still attached. Reproduced on this runner at the reviewed commit, with session.forwardOutput.dispose() removed from cleanup() to stand in for a future regression:

Arm Result
regression + await sleep(500) as committed green — witness void
same, instrumented atAssertion=0, afterOneTurn=1 — zero canaries forwarded when the assertion ran, one loop turn later the canary arrived
regression + 800ms synchronous spin, window started from the check phase green ×3 (retry: 2)

The ordering the finding names is what happens: when the expired sleep timer resumes the test before the loop's poll phase reads the pty fd, toEqual([]) asserts against an empty buffer. Two earlier arms are worth recording because they bound the claim — an 800ms spin that resumes into the poll phase still detected (atAssertion=1), and so did a window shrunk to 20ms. So the defect is real but conditional on which phase the stalled loop resumes in, which is exactly why a fixed window is the wrong oracle: the test does not control that ordering, and the config already documents resource pressure on these shards.

Fix. Register a second onData witness before cleanup(), reset both buffers at the same synchronous instant after it, and poll the witness for a provably delivered canary before asserting the harness forwarded none. The inference is sound by construction and does not depend on timing: @lydell/node-pty-linux-x64/lib/eventEmitter2.js pushes each listener and returns a fresh disposable whose dispose() splices only that listener (so the witness survives cleanup() detaching the harness's), and fire() snapshots and calls listeners in registration order — the harness's handler was registered first, inside runInteractive(), so for any chunk the witness received, the harness handler already ran with it if it was still attached. A stall can now only stretch the poll, never void the oracle.

Correction to the suggested snippet. As written it registers delivered before cleanup() and polls delivered.some(...) without resetting it, so it would satisfy itself immediately on the canaries that arrived before cleanup and prove nothing. The committed version resets delivered.length = 0 alongside forwarded.length = 0, which keeps the two buffers covering the identical post-cleanup interval. Timeout is 10s as suggested.

Subtractive part of the change. The sleep helper is gone (its only caller was this window), and so is the end-of-window isProcessAlive re-check. That re-check was added deliberately by 2116973523 to catch a specific mutant — dispose removed and the child SIGKILLed right after the first liveness assertion, which left the window empty because a dead child emits nothing. The delivery poll subsumes it: proven delivery is strictly stronger evidence than liveness, and non-delivery now fails with the child that outlived cleanup() delivered no further bytes. Probed below rather than argued.

[rv:5113204380] CHANGES_REQUESTED — "Partially reviewed — gaps disclosed" → No code action

The body's only content is a disclosed coverage gap ("reverse audit — stopped before round 4 by the review time budget"), not a finding. Its two ledger findings are the inline comments above, both now addressed. Nothing to implement for the gap itself; the reverse audit simply has not run.

[rv:5111974045] COMMENTED — downgrade over two failing checks → One now green, one deferred

  • web-shell E2E Smoke (ubuntu-latest, Node 22.x): checks.json now records SUCCESS for that exact job. It was red when round 2 downgraded the review and has since passed; nothing in this PR's two-file diff touches web-shell. No action.
  • Dependency CVE audit: still FAILURE (checks.json, completed 2026-09-04T11:10:42Z). Verified as not caused by this PR and not fixable inside it, and recorded in deferred-findings.json. Evidence: .github/workflows/security-checks.yml runs npm ci --ignore-scripts then npm audit --omit=dev --audit-level=high over the root lockfile and each packages/*/package-lock.json, so the job's entire input is the dependency tree; git diff origin/main...HEAD --name-only is exactly integration-tests/test-helper.test.ts and integration-tests/test-helper.ts, and the diff against package-lock.json/package.json/**/package.json is empty. The audited tree on this branch is therefore byte-identical to main's (HEAD is a merge of main), which makes the failure a property of main's tree — a newly published advisory — not of this diff. Worth flagging to a maintainer: main-ci-failure-issue.yml watches only E2E Tests, SDK Python, and Qwen Code CI, so a red Security Checks run on main files no issue by itself and this can go unnoticed. Fixing it means editing lockfiles, which is both outside this PR's footprint and inside the supply-chain area this bot must not touch.
  • D2-2 (deferred by the reviewer in round 2): superseded — it is the same defect the reviewer re-derived and confirmed as R3-1 above, and it is fixed.
  • D2-1 (deferred by the reviewer in round 2, not re-raised in round 3): Declined. It observes that cleanup() disposing the harness's single listener also freezes _interactiveOutput, and hence the output that runInteractive()'s promise resolves with, for a child that outlives cleanup. That is real — test-helper.ts:972-973 resolves { output: this._interactiveOutput } from the same handler — but it is the intended semantics of detaching a session, not a defect, and no consumer reads output after cleanup: the await promise; sites (e.g. interactive/file-system-interactive.test.ts:176, protocol-tags-interactive.test.ts:136, external-context-mem0-write.test.ts:288, submitted-prompt-provenance.test.ts:194, external-context-auto-recall.test.ts:242) all discard the resolved value and use the await only to drain the child, and every assertion on rendered text happens before cleanup. The remaining ask was naming/comment coverage for the second half of the listener's job, which is narration the code already states. The reviewer recorded it as "not requested in this round"; splitting the listener into two would grow the harness for no observed consumer.

Verification

Commands actually run in this checkout, in order:

  • git diff origin/main...HEAD / --name-only / lockfile-scoped diff — read; confirmed the round stays inside the PR's existing two-file footprint and touches no dependency manifest.
  • QWEN_SANDBOX=false npx vitest run --root ./integration-tests ./test-helper.test.ts8 passed, on the pre-round tree (baseline), after the fix, three consecutive times on the fixed tree, and once more against the committed tree.
  • npm run buildpassed (exit 0, 530s).
  • npm run typecheckpassed (exit 0; includes tsc -p integration-tests/tsconfig.json).
  • npm run lintpassed (exit 0; includes eslint integration-tests).
  • npx prettier --check integration-tests/test-helper.test.tspassed ("All matched files use Prettier code style").
  • npm run generate:settings-schemanot run, not required: no settings source changed.
  • Integration tests after npm run bundlenot run: the changed file is itself the integration-harness test, run directly through the integration vitest root above, and no bundled-CLI behaviour changed.
  • web-shell E2E Smoke and Dependency CVE audit are CI-only jobs and cannot run on this runner; see the dispositions above.

Mutation probes (each temporarily applied, run, then restored; git status --porcelain=v1 --untracked-files=all is empty and test-helper.ts is byte-identical to the pre-round branch after every restore):

# Mutation Expected Observed
Pre-fix dispose() removed + 800ms stall started from the check phase, window as committed green = witness void green ×3, atAssertion=0, afterOneTurn=1
A dispose() removed, fixed witness red redexpected [ Array(2) ] to deeply equal []
B dispose() removed + 300ms stall injected into every poll iteration, fixed witness red redexpected [ …(4) ] to deeply equal []
B-control dispose() restored, stall kept green (no false positive) green
C dispose() removed + child SIGKILLed right after the first liveness assertion — the mutant the deleted isProcessAlive re-check was added for red redthe child that outlived cleanup() delivered no further bytes

Two probes could not be run and are reported rather than assumed: the win32 redness R3-1 names (no Windows runner, and the platform stub breaks node-pty's module load before collection), and the Dependency CVE audit job itself (CI-only; its input is provably unchanged by this diff).

One environment note, not a code issue: this checkout had no git identity, so the commit initially failed with fatal: unable to auto-detect email address. Set user.name/user.email locally (not --global) to qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>, matching the author of this branch's previous autofix commit 2116973523. The pre-commit hook ran and made no modifications.

中文说明

Autofix 本轮总结 — PR #11007(issue #11002

结果: 一个增量提交 8340b5ff00test(integration): harden the PTY-forwarding witness (#11002)。只改动一个文件:integration-tests/test-helper.test.ts(新增 94 行、删除 71 行——其中大部分是把用例包进 it.skipIf(...) 带来的重新缩进;真实净增约 10 行)。integration-tests/test-helper.ts 与本轮开始前的分支逐字节一致:两条发现都是「见证测试」自身的缺陷,而不是它所见证的脚手架修复的缺陷。本轮无需处理冲突(--conflict false)。

两条需要处理的发现都先复现、后修改,改完后又都做了变异探针验证。

各条反馈与处置

[rc:3934185242] R3-1 — Critical:见证测试硬断言仅 POSIX 的 SIGHUP 存活语义 → 已实现

主张(可检验): 在 Windows 上替身子进程无法在 cleanup() 之后存活,因此两个 expect(isProcessAlive(...)).toBe(true) 断言都会变红,尽管被测的 dispose 行为本身是正确的。

验证。 本 runner 无法执行 Windows,所以该主张是对照被钉住的依赖和套件自身惯例验证的,而不是凭空猜测:

  • package.json:178-183 钉住 @lydell/node-pty@1.2.0-beta.10,并提供了 @lydell/node-pty-win32-{x64,arm64} —— Windows 是本套件的安装目标,不是假想场景。
  • 该被钉住包自己的类型声明 node_modules/@lydell/node-pty/node-pty.d.ts:184-188@param signal The signal to use, defaults to SIGHUP. This parameter is not supported on Windows. @throws Will throw when signal is used on Windows.(信号参数在 Windows 上不受支持,使用时会抛错。)Windows 路径上不存在可捕获的 SIGHUP,所以子进程不可能活过 cleanup(),测试声明的前提在那里不成立。
  • 前提的 POSIX 一侧在已安装实现中得到确认:@lydell/node-pty-linux-x64/lib/unixTerminal.js:228process.kill(this.pid, signal || 'SIGHUP')
  • 仓库惯例本就对这一类做了守卫:integration-tests/interactive/file-system-interactive.test.ts:38protocol-tags-interactive.test.ts:37context-compress-interactive.test.ts:21,109 都用了 it.skipIf(process.platform === 'win32')(

修复。 按同样方式守卫该用例,并加一行注释说明为什么该前提只在 POSIX 成立。它上面那个兄弟用例(kills an interactive session a test never closed)刻意不加守卫:它断言子进程在 cleanup() 之后已死亡,这在两个平台上都成立。

探针说明(如实报告)。 在 worker setup 文件里把 process.platform 打成 'win32' 无法在本 runner 上见证这个守卫:@lydell/node-pty/index.js:1 在导入时就按 process.platform 解析实现包,因此该 stub 会让模块加载直接失败并报 Cannot find module '@lydell/node-pty-win32-x64',发生在测试文件被收集之前(Test Files 1 failed, Tests no tests)。这个失败本身恰好印证了该发现所依赖的平台分派机制,但也意味着守卫的「跳过」一侧是靠惯例和依赖的文档语义见证的,而不是靠本机的红绿差分。在本机去掉守卫仍是绿的,因为 runner 是 POSIX。

[rc:3934185254] R3-2 — Suggestion:500ms 窗口证明的是「存活」而非「已送达」 → 已复现并实现(对建议代码片段做了一处修正)

复现。 该发现主张:即使转发仍挂着,窗口也可能绿灯通过。已在被审查的那个提交上、在本 runner 复现——把 cleanup() 里的 session.forwardOutput.dispose() 移除,以模拟未来的回归:

实验臂 结果
回归 + 按提交原样的 await sleep(500) 绿 —— 见证失效
同上,加插桩 atAssertion=0afterOneTurn=1 —— 断言执行时转发缓冲里 0 个 canary,一个循环轮次之后 canary 才到
回归 + 800ms 同步停顿、窗口从 check 阶段启动 连续 3 次绿(retry: 2

发生的正是该发现指出的顺序:当过期的 sleep 定时器在循环的 poll 阶段读取 pty fd 之前恢复测试时,toEqual([]) 是对着一个空缓冲做断言。另外两个实验臂也值得记录,因为它们界定了该主张的边界——从 poll 阶段恢复的 800ms 停顿仍然检出了回归(atAssertion=1),把窗口缩到 20ms 也检出了。所以缺陷是真实的,但取决于停顿后循环从哪个阶段恢复;而这恰恰说明固定窗口是错误的预言:测试无法控制这个顺序,且配置文件本就记录了这些分片上的资源压力。

修复。cleanup() 之前注册第二个 onData 见证器,在 cleanup 之后同一同步时刻重置两个缓冲,然后轮询见证器直到出现一个可证明已送达的 canary,再断言脚手架一个都没转发。这个推断由构造保证、不依赖时序:@lydell/node-pty-linux-x64/lib/eventEmitter2.js 会 push 每个监听器并返回一个全新的 disposable,其 dispose() 只摘除该监听器(所以见证器能在 cleanup() 摘掉脚手架那个之后继续存活),而 fire() 会先快照再按注册顺序调用监听器——脚手架的处理器注册在先(在 runInteractive() 内部),因此见证器收到的任何一个 chunk,只要脚手架的监听器还挂着,它的处理器就已经先跑过了。于是停顿只会拉长轮询,永远不会让预言失效。

对建议片段的修正。 建议代码在 cleanup() 之前注册 delivered,且轮询 delivered.some(...) 时不重置它,于是它会立刻被 cleanup 之前到达的 canary 满足,什么也证明不了。提交的版本在 forwarded.length = 0 的同一处也做 delivered.length = 0,让两个缓冲覆盖完全相同的 cleanup 后区间。超时按建议取 10s。

本次改动中「做减法」的部分。 sleep 辅助函数已删除(唯一调用方就是这个窗口),窗口末尾的 isProcessAlive 复检也删了。那个复检是 2116973523 为捕获一个特定变异体而刻意加上的——移除 dispose 并且在第一个存活断言之后立刻 SIGKILL 子进程,这会让窗口因为「死掉的子进程不产生字节」而变空。送达轮询把它涵盖了:证明已送达严格强于证明存活,而未送达现在会以 the child that outlived cleanup() delivered no further bytes 失败。这一点是用下面的探针验证的,不是靠论证。

[rv:5113204380] CHANGES_REQUESTED —「仅部分审查,缺口已披露」→ 无需改代码

该审查正文的唯一内容是披露的覆盖缺口(「反向审计——在第 4 轮之前被审查时间预算打断」),不是发现。它账本里的两条发现就是上面那两条行内评论,均已处理。缺口本身无可实现之物;只是反向审计尚未运行。

[rv:5111974045] COMMENTED — 因两个失败检查而降级 → 一个已转绿,一个已转入延后队列

  • web-shell E2E Smoke (ubuntu-latest, Node 22.x): checks.json 现在记录该 job 为 SUCCESS。第 2 轮降级时它是红的,此后已通过;本 PR 两文件的 diff 完全不触及 web-shell。无需处理。
  • Dependency CVE audit: 仍为 FAILUREchecks.json,完成于 2026-09-04T11:10:42Z)。已验证不是本 PR 造成、也无法在本 PR 内修复,并记入 deferred-findings.json。证据:.github/workflows/security-checks.ymlnpm ci --ignore-scripts,再对根 lockfile 和每个 packages/*/package-lock.json 执行 npm audit --omit=dev --audit-level=high,因此该 job 的全部输入就是依赖树;而 git diff origin/main...HEAD --name-only 恰好是 integration-tests/test-helper.test.tsintegration-tests/test-helper.ts,针对 package-lock.json/package.json/**/package.json 的 diff 为空。所以本分支被审计的依赖树与 main 的逐字节一致(HEAD 是一次 main 合并),该失败是 main 依赖树的属性——新公布的安全公告——而不是本 diff 的。有一点值得提醒维护者:main-ci-failure-issue.yml 只监听 E2E TestsSDK PythonQwen Code CI,所以 main 上一次红的 Security Checks 不会自动开 issue,这可能无人察觉。修复它意味着改 lockfile,这既超出本 PR 的 footprint,又落在本机器人不得触碰的供应链区域内。
  • D2-2(第 2 轮被审查者延后): 已被取代——它与审查者重新推导并在上面确认为 R3-1 的是同一缺陷,已修复。
  • D2-1(第 2 轮被审查者延后,第 3 轮未再提出): 拒绝(Declined)。 它指出 cleanup() dispose 掉脚手架唯一的监听器,也会冻结 _interactiveOutput,进而冻结 runInteractive() 的 promise 所解析出的 output(对那些活过 cleanup 的子进程)。这一现象是真实的——test-helper.ts:972-973 正是用同一个处理器解析 { output: this._interactiveOutput }——但这是「断开会话」的预期语义,不是缺陷,而且没有消费者在 cleanup 之后读取输出:那些 await promise; 的位置(例如 interactive/file-system-interactive.test.ts:176protocol-tags-interactive.test.ts:136external-context-mem0-write.test.ts:288submitted-prompt-provenance.test.ts:194external-context-auto-recall.test.ts:242)全部丢弃解析值,只用这个 await 来排空子进程,而所有针对渲染文本的断言都发生在 cleanup 之前。剩下的诉求是为该监听器的「另一半职责」补命名/注释,而那属于代码本身已表达清楚的叙述性内容。审查者自己把它记为「本轮不要求修改」;把监听器拆成两个会让脚手架为一个观察不到的消费者而膨胀。

验证(Verification)

本 checkout 中实际按序运行的命令:

  • git diff origin/main...HEAD / --name-only / 限定 lockfile 的 diff —— 已读取;确认本轮留在 PR 既有的两文件 footprint 内,且未触及任何依赖清单。
  • QWEN_SANDBOX=false npx vitest run --root ./integration-tests ./test-helper.test.ts —— 8 passed:在本轮开始前的树上(基线)、修复之后、修复后的树上连续 3 次、以及提交后的树上又 1 次。
  • npm run build —— 通过(exit 0,530s)。
  • npm run typecheck —— 通过(exit 0;包含 tsc -p integration-tests/tsconfig.json)。
  • npm run lint —— 通过(exit 0;包含 eslint integration-tests)。
  • npx prettier --check integration-tests/test-helper.test.ts —— 通过(“All matched files use Prettier code style”)。
  • npm run generate:settings-schema —— 未运行,也不需要:没有改动任何 settings 源。
  • npm run bundle 之后的集成测试 —— 未运行:被改动的文件本身就是集成脚手架的测试,已通过上面的集成 vitest root 直接运行,且没有任何打包后 CLI 的行为发生变化。
  • web-shell E2E SmokeDependency CVE audit 是仅 CI 可跑的 job,本 runner 无法执行;处置见上。

变异探针(每一个都是临时施加、运行、随后还原;每次还原后 git status --porcelain=v1 --untracked-files=all 为空,且 test-helper.ts 与本轮开始前的分支逐字节一致):

# 变异 预期 实测
修复前 移除 dispose() + 从 check 阶段启动的 800ms 停顿,窗口按提交原样 绿 = 见证失效 连续 3 次绿atAssertion=0afterOneTurn=1
A 移除 dispose(),修复后的见证 —— expected [ Array(2) ] to deeply equal []
B 移除 dispose() + 在每次轮询迭代中注入 300ms 停顿,修复后的见证 —— expected [ …(4) ] to deeply equal []
B 对照 恢复 dispose(),保留停顿 绿(无误报) 绿
C 移除 dispose() + 在第一个存活断言后立刻 SIGKILL 子进程——即被删掉的 isProcessAlive 复检当初要捕获的那个变异体 —— the child that outlived cleanup() delivered no further bytes

有两个探针无法运行,在此如实报告而非假设:R3-1 所指的 Windows 红灯(没有 Windows runner,且平台 stub 会在收集之前破坏 node-pty 的模块加载),以及 Dependency CVE audit job 本身(仅 CI 可跑;其输入已被证明未被本 diff 改变)。

一个环境说明(非代码问题):本 checkout 没有配置 git 身份,因此提交最初以 fatal: unable to auto-detect email address 失败。已在仓库局部(非 --global)把 user.name/user.email 设为 qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>,与本分支上一个 autofix 提交 2116973523 的作者一致。pre-commit 钩子已运行且未做任何修改。

🦷 Gate advisory — this round resolves a Critical/Request-changes finding with test-only changes (machine-measured): the bite check cannot verify a test-side fix, so the resolution rests on the round summary alone. · 本轮以纯测试改动解决 Critical/Request-changes 反馈(门自动测量):bite 检查无法验证测试侧修复,该解决仅以轮次摘要为凭。

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

🧵 Resolved all 2 selected review thread(s). · 已关闭全部选中的 2 条评审线程。

Re-review when you have a moment. After round 10 this bot stops and leaves the PR for a human. · 有空请复审;第 10 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

Convergence: round 4 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: integration-tests/test-helper.test.ts (findings in round 3; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

已审查。 建议见行内评论。

收敛情况:第 4 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:integration-tests/test-helper.test.ts(第 3 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +110 to +111
rig.bundlePath = rig.createFile(
'slow-exit-cli.js',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-1: Under the harness's supported INTEGRATION_TEST_USE_INSTALLED_GEMINI=true mode (the npm-bundle verification mode documented in _getCommandAndArgs's JSDoc), rig.bundlePath is silently dropped from argv — runInteractive spawns the installed qwen CLI instead of this stand-in script, which never emits the canary. A release-validation run of the suite in that mode then burns the full 20s poll (×3 with retry: 2) and fails with the stand-in CLI never produced output — a diagnostic that points at the stand-in file that was never spawned, sending whoever debugs it to a file that is not in the failure path. No lane in this repo sets the variable today, so nothing automated goes red; the cost lands on whoever runs the suite in installed-release mode.

Witness:

Probe (scratch tree at 8340b5ff0, real installed qwen):
DEFAULT arm  ✓ test-helper.test.ts (8 tests | 7 skipped) 158ms
BUG arm (INTEGRATION_TEST_USE_INSTALLED_GEMINI=true)
             × detaches a session's output forwarding during cleanup 20011ms
               AssertionError: the stand-in CLI never produced output:
               expected false to be true
FIX arm (skipIf extended, installed mode)  Tests 8 skipped (8)
FIX arm (skipIf extended, default mode)    1 passed | 7 skipped (8)

Extend the existing skip to cover that mode:

it.skipIf(
  process.platform === 'win32' ||
    process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true',
)(

The guard must key on the exact switch runInteractive honors: process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true' (integration-tests/test-helper.ts:943-944; same string at :279).

中文说明

在脚手架自身支持的 INTEGRATION_TEST_USE_INSTALLED_GEMINI=true 模式(_getCommandAndArgs 的 JSDoc 所载的 npm 包验证模式)下,rig.bundlePath 会被静默地从 argv 中丢弃——runInteractive 会改为派生已安装的 qwen CLI,而不是这个替身脚本,于是 canary 永远不会出现。在该模式下运行发布验证时,测试会耗满整个 20 秒轮询(retry: 2 下重复约 3 次),并以 the stand-in CLI never produced output 失败——这条诊断指向一个从未被派生的替身文件,会把排查者引向失败路径之外的文件。目前仓库里没有任何检查项设置该变量,因此自动化流程不会变红;代价落在以已安装发布模式运行套件的人身上。

修复:扩展现有的 skip 以覆盖该模式(见上方代码块)。守卫必须绑定 runInteractive 实际读取的开关:process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'integration-tests/test-helper.ts:943-944:279 处为同一字符串)。

— qwen3.8-max via Qwen Code /review (v0.23.0)


// Skipped on Windows: node-pty's kill() terminates the child unconditionally
// there, so it cannot outlive cleanup() and the premise below does not hold.
it.skipIf(process.platform === 'win32')(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-2: Disclosure rather than a code defect: this file sits outside every npm workspace, so this review run executed zero tests against the diff — build-test ran zero suites and the efficacy probe ran 0 revert probes, 0 mutants and 0 hunk probes (harnessValidated: null). The only collector of this file is the CI Integration Tests (no-AK, No Sandbox) check (root script test:integration:no-ak:sandbox:none, ci.yml integration_no_ak job), which was still pending on this PR's head at review time. If the new test itself is defective — assertions pass for the wrong reason, or it hangs or flakes under CI's KEEP_OUTPUT environment — nothing in this review run would surface it; land the PR only after that check completes green on the current head, since it is the only witness the #11002 fix has.

Witness:

build-test report: ok:true, buildSet:[], test:[], testScope.workspaces: []
  caveat: "1 changed file(s) sit outside every workspace and are not inert
  (e.g. integration-tests/test-helper.test.ts)"
gh pr checks at review time: "Integration Tests (no-AK, No Sandbox)" pending

No code change required.

中文说明

这是披露而非代码缺陷:该文件位于所有 npm workspace 之外,因此本轮审查没有对该 diff 执行任何测试——build-test 运行了 0 个套件,有效性探针运行了 0 个还原探针、0 个变异体、0 个 hunk 探针(harnessValidated: null)。该文件唯一的收集者是 CI 的 Integration Tests (no-AK, No Sandbox) 检查项(根脚本 test:integration:no-ak:sandbox:noneci.ymlintegration_no_ak 作业),而在审查时它在本 PR 的 head 上仍处于 pending 状态。如果新测试本身有缺陷——断言因错误的原因通过,或在 CI 的 KEEP_OUTPUT 环境下挂起或抖动——本轮审查无法发现;请等该检查项在当前 head 上变绿后再合并,因为它是 #11002 修复的唯一见证。无需代码改动。

— qwen3.8-max via Qwen Code /review (v0.23.0)

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.

Disclosure acknowledged — it asks for no code change and none was made. Recording the evidence that closes its ask, and leaving the thread open because that ask is a merge-time condition no commit in this round can discharge.

The named collector did run green on the reviewed head. Integration Tests (no-AK, No Sandbox) (root script test:integration:no-ak:sandbox:none, the only collector of integration-tests/test-helper.test.ts) completed SUCCESS at 2026-09-04T16:16:59Z, having started at 15:46:29Z — after head d1d375bed4 was committed at 15:45:58Z. So the sole witness for the #11002 fix exists and passed on the head this review read.

Local witness for the same file, this round. npx vitest run --root ./integration-tests ./test-helper.test.ts (default mode, repo config including retry: 2) → 8 passed (8), 290ms. In installed-release mode after R4-1's fix → 7 passed | 1 skipped, 79ms.

The disclosed gap is by design, not missing coverage. Integration Tests (CLI, No Sandbox) is merge_group-only and shows SKIPPED on every pull request; ci.yml records that the separate no-AK check was given its own name precisely so a skipped CLI lane is not read as "the changed integration test never ran" (#9895 round 15).

Why this stays open. This round pushes a new head, so "green on the head that lands" has to be re-met by the no-AK check on that commit — a merge-time gate for the maintainer.

中文说明

已知悉该披露——它未要求任何代码改动,本轮也未做代码改动。此处记录可以满足其诉求的证据;该线程保持未解决,因为它的诉求是一个合并时点条件,本轮任何提交都无法代为完成。

它所点名的收集检查项已在被审查的 head 上跑绿。 Integration Tests (no-AK, No Sandbox)(根脚本 test:integration:no-ak:sandbox:none,是 integration-tests/test-helper.test.ts 的唯一收集者)于 2026-09-04T16:16:59Z 以 SUCCESS 完成,开始于 15:46:29Z——晚于 head d1d375bed4 的提交时间 15:45:58Z。因此 #11002 修复的唯一见证确实存在,并且在本次审查所读取的 head 上通过了。

本轮针对同一文件的本地见证。 npx vitest run --root ./integration-tests ./test-helper.test.ts(默认模式,沿用仓库配置,含 retry: 2)→ 8 passed (8),290ms。在按 R4-1 修复后的已安装发布模式下 → 7 passed | 1 skipped,79ms。

该披露的审查缺口是设计使然,并非覆盖缺失。 Integration Tests (CLI, No Sandbox) 仅在 merge_group 触发,在每个 pull request 上都显示 SKIPPEDci.yml 中记载,单独设立 no-AK 检查项并赋予其独立名称,正是为了避免把 CLI 检查项的 skip 读成"改动的集成测试从未运行"(#9895 第 15 轮)。

为何保持未解决。 本轮会推送新的 head,因此"在最终合入的 head 上跑绿"这一条件需要由 no-AK 检查项在该提交上重新满足——这是留给维护者的合并时点关卡。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator Author

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

中文说明

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

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

Partially reviewed — gaps disclosed.

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

Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:

  • integration-tests/test-helper.test.ts:111 — [review] R4-1: in INTEGRATION_TEST_USE_INSTALLED_GEMINI=true mode the witness stand-in is silently dropped from argv; the test burns the 20s poll and fails with a misleading diagnostic
  • integration-tests/test-helper.test.ts:97 — [test] R4-2: disclosure — file sits outside every npm workspace; this review's build-test ran zero suites against it; the CI no-AK gate is its only collector
中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

@yiliang114

Copy link
Copy Markdown
Collaborator

Closing as a misdiagnosis of run 33834473606 — replaced by #11037.

This PR's premise is that run 33834473606 reddened "without naming a failing test" via a post-cleanup broken pipe. The job log contradicts that:

  • The run ended with named failures: ❯ interactive/mid-turn-submit-interactive.test.ts (4 tests | 2 failed), both AssertionError: Held response never reached the screen, so the turn is not mid-stream, summary Test Files 1 failed | 8 passed | 1 skipped (10) / Tests 2 failed | 16 passed | 2 skipped (20).
  • There is no EPIPE, broken-pipe, or uncaught-exception anywhere in the full job log (all 5461 lines of job 100909628476).
  • The PTY dumps printed by the failing attempts show what actually happened to the submissions: > Start the review.✖︎ Chat not initialized — the CLI rejected the first prompt because chat initialization had not completed, so the held marker the tests waited for never existed. The same artifact appears in run 33829764813 (Main CI failed: E2E Tests on b7815a7e1a82 #10990).

The actual race: the OpenTUI input prompt mounts before any config.initialize() runs; its mount-time command-registry load starts the first flight, and a prompt submitted mid-flight calls initialize() again, which throws Config was already initialized (the flag is set synchronously before the work runs), the submit path's catch {} swallows it, and the turn proceeds into a client with no chat yet. #11037 fixes it by letting a concurrent caller join the in-flight initialization, with unit coverage that fails on the pre-fix code with exactly the swallowed error.

@yiliang114 yiliang114 closed this Sep 4, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator Author

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

Round 5 — review feedback addressed

Both actionable findings this round are **[Suggestion]** items from the automated review, both on integration-tests/test-helper.test.ts. One is implemented, one is answered with evidence (it explicitly asks for no code change). No Critical and no CHANGES_REQUESTED finding is open, feedback.md reported no failed and no still-red checks, and --conflict false, so no base merge was performed. One additive commit: c35844b8e3 — +6 / −1, one file, entirely inside the PR's existing footprint. Nothing was pushed (the workflow owns network writes).

[rc:3935531189] R4-1 — installed-release mode silently drops the stand-in CLI → Implemented

Reproduced before changing anything

Run on the pre-round tree (d1d375bed4), in the harness's npm-bundle verification mode:

env PATH="/tmp/installed-shim:$PATH" SHIM_ARGV_LOG=/tmp/installed-shim/argv.log \
    QWEN_SANDBOX=false INTEGRATION_TEST_USE_INSTALLED_GEMINI=true \
  npx vitest run --root ./integration-tests ./test-helper.test.ts --retry=0

× TestRig > detaches a session's output forwarding during cleanup  20051ms
Caused by: AssertionError: the stand-in CLI never produced output:
           expected false to be true
Tests  1 failed | 7 passed (8)

Exactly the reported failure, including the misleading diagnostic. The installed CLI was a 10-line stand-in placed first on PATH that logs its own argv, emits output that is not the canary, and dies on SIGHUP like an ordinary CLI — chosen over driving this machine's real installed qwen because the harness copies the host's ~/.qwen/settings.json and oauth_creds.json into the run's hermetic QWEN_HOME, i.e. real credentials, and a shim keeps the probe deterministic. It exercises the identical harness path (command = 'qwen', initialArgs without bundlePath). Its argv log is the direct proof that the stand-in script was dropped, and that this test — not its sibling — spawned it:

{"argv":["/tmp/installed-shim/qwen","--no-chat-recording","--yolo"],
 "cwd":"…/.integration-tests/1788543980293/cleanup-detaches-interactive-output"}

No slow-exit-cli.js in argv; the cwd is this test's own directory.

The fix

it.skipIf(process.platform === 'win32' || INSTALLED_RELEASE_MODE)(, with the switch hoisted next to the file's other module constant:

/** npm-bundle verification mode: spawns the installed CLI, drops bundlePath. */
const INSTALLED_RELEASE_MODE =
  process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';

Same exact === 'true' comparison against the same switch runInteractive honors (integration-tests/test-helper.ts:279 and :943), so the guard cannot drift from the harness's own notion of the mode.

Why the shape differs from the suggested snippet

The inline condition makes the it.skipIf(...) call too long for one line, and prettier then re-hangs the whole test body off the closing )( — a ~90-line reindent. prettier --check failed on that form (it passes on the pre-round file), and this repo's lint-staged pre-commit hook runs prettier --write on staged files, so committing the snippet verbatim would have shipped that reindent inside a round whose test-diff budget is being watched. Hoisting the same expression into a named constant keeps the call on one line: prettier --check passes and the round's diff stays at +6 / −1.

Root-cause triage (answering the convergence observation)

The review observed that findings keep returning to this file and suggested triaging a shared root cause before fixing instances. Triage result: the shared premise is "a test substitutes rig.bundlePath with a stand-in script", and installed-release mode voids that premise for both stand-in tests in the file — but only one of them asserts anything the stand-in is the sole source of.

  • kills an interactive session a test never closed during cleanup (pre-existing) asserts only process liveness: something was spawned and cleanup() ended it. That holds for the installed CLI as well. Probed, not assumed: it passes in installed mode (26 ms pre-fix, and it is one of the 7 that pass post-fix).
  • detaches a session's output forwarding during cleanup (this PR) asserts on canary bytes that only the stand-in writes. It is the only test the mode breaks.

So there is no further shared fix to make in this file and no sibling finding should follow from this cause. Here the instance is the root cause: the defect is "this test's oracle depends on a script that installed-release mode never spawns", which is a property of this test alone. Widening the skip to the pre-existing liveness test would have removed real coverage in that mode for no gain.

Mutation probe (the guard has its own witness)

Guard clause removed from the committed test, installed-release arm re-run, then restored:

MUTATED: INSTALLED_RELEASE_MODE clause removed from skipIf
× TestRig > detaches a session's output forwarding during cleanup  20053ms
Caused by: AssertionError: the stand-in CLI never produced output
Tests  1 failed | 7 skipped (8)

Restore verified byte-exact (sha256sum -c → OK), and the committed blob is byte-identical to the probed file. The guard is therefore not dead weight: delete it and the mode fails; keep it and the mode skips.

[rc:3935531200] R4-2 — disclosure: no collector ran against this file in the review → Answered with evidence; thread left open

No code change (the finding asks for none). Its ask is a merge-time condition, and the evidence now satisfies it for the reviewed head:

  • The named collector ran green on the reviewed head. Integration Tests (no-AK, No Sandbox) (root script test:integration:no-ak:sandbox:none, the only collector of integration-tests/test-helper.test.ts) is COMPLETED / SUCCESS, started 2026-09-04T15:46:29Z and completed 16:16:59Z, inside CI run 33891362120 — the run triggered by head d1d375bed4 (committed 15:45:58Z; the same run's Classify PR started 15:46:07Z). That run's Lint & Static and Test (ubuntu-latest, Node 22.x) are SUCCESS too, so the Main CI failed: E2E Tests on 56f75adf2992 #11002 fix's only witness exists and passed on the head this review read.
  • The disclosed gap is by design, not missing coverage. Integration Tests (CLI, No Sandbox) has if: github.event_name == 'merge_group', so it shows SKIPPED on every pull request; ci.yml records that the no-AK check was given its own name precisely so that skip is not read as "the changed integration test never ran" (feat(daemon): support scoped workspace memory tasks #9895 round 15).
  • A local witness was added this round, which the review run could not produce: the collector's own run of this file in default mode — 8 passed (8), 390 ms, with the Main CI failed: E2E Tests on 56f75adf2992 #11002 test taking 311 ms and asserting (not merely skipping).
  • Why the thread stays open: this round pushes a new head (c35844b8e3), so "green on the head that lands" has to be re-met by the no-AK check on that commit. That is a gate for the maintainer at merge time, not something a code change here can discharge. A reply carrying this evidence is recorded in comment-replies.json for that thread.

Review-body items (no thread to reply on)

  • rv:5114995804 (round 4, COMMENTED): its two inline findings are the two handled above. Its convergence observation is answered by the root-cause triage. Its remaining suggestions — batching fixes, dropping this PR's reviews to --severity-floor critical, or landing and moving Suggestions to a follow-up issue — are review-policy calls for the maintainer, not code changes; the part that was mine (batch both findings into one verified round) is done.
  • rv:5115785592 (round 5, COMMENTED, "Partially reviewed — gaps disclosed"): carries no findings. Its disclosed gap is the merge_group-only CLI lane, covered above.

Scope and boundary notes

  • No finding was deferred to a follow-up (nothing verified lies outside this PR's footprint) and none was escalated for a maintainer decision.
  • No test was deleted or weakened; the round only narrows when one test runs, and the default-mode arm — the one CI actually executes — still runs and passes it.
  • No protected area was touched. .github/workflows/ci.yml, .github/scripts/run-autofix-review-verification.sh, scripts/lint.js, scripts/pre-commit.js, and package.json were read only, to confirm which lane collects this file, what the gate re-runs, and that prettier is enforced through the commit hook.
  • Diff growth this round: source 0 / test +5 net lines, against budgets of 400 / 400.
  • git status --porcelain=v1 is empty after the commit; the commit is additive (no amend, rebase, reset, or history rewrite).

Verification

Commands actually run this round, with results:

  • npm run buildpassed (BUILD_EXIT=0). Run on this round's tree; the round's only change is integration-tests/test-helper.test.ts, which scripts/build.js does not compile (the string integration-tests does not occur in it), so the result covers the committed tree.
  • npm run typecheckpassed (TYPECHECK_EXIT=0) on the final tree; includes typecheck:integration (tsc -p integration-tests/tsconfig.json), which is what the CI no-AK gate runs before the suite.
  • npm run lintpassed (LINT_EXIT=0) on the final tree; includes eslint integration-tests.
  • npx prettier --check integration-tests/test-helper.test.tspassed (failed on the reviewer's inline-condition shape; see above).
  • Focused integration run, default mode (the no-AK lane's collector for this file): env QWEN_SANDBOX=false npx vitest run --root ./integration-tests ./test-helper.test.ts8 passed (8), 390 ms.
  • Focused integration run, installed-release mode, guard in place: env PATH="/tmp/installed-shim:$PATH" QWEN_SANDBOX=false INTEGRATION_TEST_USE_INSTALLED_GEMINI=true npx vitest run --root ./integration-tests ./test-helper.test.ts7 passed | 1 skipped, 99 ms.
  • Mutation probe, guard clause removed, installed-release mode, focused on the changed test — 1 failed | 7 skipped after 20053 ms with the stand-in CLI never produced output; guard restored and re-verified byte-exact, then re-run green in both modes.
  • Pre-fix reproduction on d1d375bed4, installed-release mode — 1 failed | 7 passed, 20051 ms, same assertion.
  • git commit ran the repo's lint-staged pre-commit hook (prettier --write + eslint --fix --max-warnings 0 on the staged file) — passed, no modifications applied; committed blob verified byte-identical to the probed file by sha256sum -c.
  • npm run generate:settings-schemanot required: no settings source (packages/cli/src/config/settingsSchema.ts / settings.ts) changed.
  • Integration tests after npm run bundlenot required: the touched behavior is exercised by the integration harness directly through npx vitest --root ./integration-tests, which is how the CI no-AK lane runs it, not through the bundled CLI.
中文说明

第 5 轮——评审反馈处理结果

本轮两条可执行发现均来自自动化评审的 **[Suggestion]**,都落在 integration-tests/test-helper.test.ts。一条已实现修复,另一条以证据作答(它本身明确不要求代码改动)。当前没有未决的 Critical,也没有 CHANGES_REQUESTED 发现;feedback.md 未报告失败检查项或持续变红的检查项;--conflict false,因此未执行 base 合并。本轮一个追加式提交:c35844b8e3——+6 / −1,单文件,完全落在本 PR 既有的 footprint 内。未执行任何推送(网络写操作由工作流负责)。

[rc:3935531189] R4-1——已安装发布模式会静默丢弃替身 CLI → 已实现

先复现,再改动

在本轮开始前的树(d1d375bed4)上,以脚手架的 npm 包验证模式运行:

env PATH="/tmp/installed-shim:$PATH" SHIM_ARGV_LOG=/tmp/installed-shim/argv.log \
    QWEN_SANDBOX=false INTEGRATION_TEST_USE_INSTALLED_GEMINI=true \
  npx vitest run --root ./integration-tests ./test-helper.test.ts --retry=0

× TestRig > detaches a session's output forwarding during cleanup  20051ms
Caused by: AssertionError: the stand-in CLI never produced output:
           expected false to be true
Tests  1 failed | 7 passed (8)

与所报告的失败完全一致,包括那条误导性的诊断信息。这里的"已安装 CLI"是一个放在 PATH 最前面的 10 行替身脚本:它记录自己被派生时的 argv,输出不是 canary 的内容,并像普通 CLI 一样在 SIGHUP 下退出。之所以不用本机真实安装的 qwen,是因为脚手架会把宿主机的 ~/.qwen/settings.jsonoauth_creds.json 复制进本次运行的隔离 QWEN_HOME,也就是真实凭据;用替身脚本可以让探针保持确定性。它走的是完全相同的脚手架路径(command = 'qwen'initialArgs 中不含 bundlePath)。它的 argv 日志正是"替身脚本被丢弃"的直接证据,并且证明是这条测试(而非它的兄弟测试)派生了它:

{"argv":["/tmp/installed-shim/qwen","--no-chat-recording","--yolo"],
 "cwd":"…/.integration-tests/1788543980293/cleanup-detaches-interactive-output"}

argv 中没有 slow-exit-cli.js;cwd 正是这条测试自己的目录。

修复

it.skipIf(process.platform === 'win32' || INSTALLED_RELEASE_MODE)(,并把该开关提升到与文件中另一个模块常量并列的位置:

/** npm-bundle verification mode: spawns the installed CLI, drops bundlePath. */
const INSTALLED_RELEASE_MODE =
  process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';

runInteractive 实际读取的开关(integration-tests/test-helper.ts:279:943)使用完全相同的 === 'true' 比较,因此守卫不会与脚手架自身对该模式的判定发生漂移。

为什么形态与建议代码片段略有不同

内联写法会让 it.skipIf(...) 调用超出一行,prettier 随后会把整个测试体重新挂到结尾的 )( 上——约 90 行的重新缩进。prettier --check 在该形态下失败(在本轮开始前的文件上是通过的),而本仓库的 lint-staged pre-commit 钩子会对暂存文件执行 prettier --write,因此原样提交该片段就会在一个测试 diff 预算正被关注的轮次里带上这段重排。把同一表达式提升为具名常量后,调用可以留在一行内:prettier --check 通过,本轮 diff 保持 +6 / −1。

根因定位(回应收敛观察)

评审观察到发现反复回到该文件,并建议在逐个修复实例之前先定位共同根因。定位结果是:共同前提是"测试用替身脚本替换 rig.bundlePath",而已安装发布模式会让文件中两条替身测试的这一前提同时失效——但只有其中一条断言了替身脚本是唯一来源的内容。

  • kills an interactive session a test never closed during cleanup(既有测试)只断言进程存活状态:有东西被派生了,且 cleanup() 结束了它。这对已安装 CLI 同样成立。这是实测而非推断:它在已安装模式下通过(修复前 26 ms,修复后它是通过的 7 条之一)。
  • detaches a session's output forwarding during cleanup(本 PR)断言的是只有替身脚本才会写出的 canary 字节。它是该模式唯一会打破的测试。

因此该文件中没有更多可做的共同修复,这一根因也不应再产生兄弟发现。此处"实例"本身就是根因:缺陷是"这条测试的判定依据依赖一个已安装发布模式永不派生的脚本",而这只是这条测试自身的属性。把 skip 扩大到既有的存活状态测试,只会在该模式下白白移除真实覆盖。

变异探针(守卫拥有自己的见证)

从已提交的测试中移除守卫子句,重跑已安装发布模式分支,然后恢复:

MUTATED: INSTALLED_RELEASE_MODE clause removed from skipIf
× TestRig > detaches a session's output forwarding during cleanup  20053ms
Caused by: AssertionError: the stand-in CLI never produced output
Tests  1 failed | 7 skipped (8)

恢复经过逐字节校验(sha256sum -c → OK),且提交的 blob 与被探针验证过的文件逐字节一致。因此该守卫并非无用负担:删掉它,该模式即失败;保留它,该模式即跳过。

[rc:3935531200] R4-2——披露:评审运行未对该文件执行任何收集器 → 以证据作答;线程保持未解决

无代码改动(该发现本身不要求改动)。它的诉求是一个合并时点条件,而现有证据已针对被审查的 head 满足了它:

  • 它所点名的收集器已在被审查的 head 上跑绿。 Integration Tests (no-AK, No Sandbox)(根脚本 test:integration:no-ak:sandbox:none,是 integration-tests/test-helper.test.ts 的唯一收集者)状态为 COMPLETED / SUCCESS,开始于 2026-09-04T15:46:29Z,完成于 16:16:59Z,属于 CI run 33891362120——即由 head d1d375bed4 触发的那次运行(该 head 提交于 15:45:58Z;同一 run 的 Classify PR 开始于 15:46:07Z)。同一 run 的 Lint & StaticTest (ubuntu-latest, Node 22.x) 亦为 SUCCESS。所以 Main CI failed: E2E Tests on 56f75adf2992 #11002 修复的唯一见证确实存在,并在本次审查所读取的 head 上通过。
  • 所披露的审查缺口是设计使然,并非覆盖缺失。 Integration Tests (CLI, No Sandbox) 的条件是 if: github.event_name == 'merge_group',因此在每个 pull request 上都显示 SKIPPEDci.yml 中记载,单独设立 no-AK 检查项并赋予独立名称,正是为了避免把这一 skip 读成"改动的集成测试从未运行"(feat(daemon): support scoped workspace memory tasks #9895 第 15 轮)。
  • 本轮补充了本地见证,这是评审运行无法产出的:该收集器对这一文件在默认模式下的运行结果——8 passed (8),390 ms,其中 Main CI failed: E2E Tests on 56f75adf2992 #11002 测试耗时 311 ms,是真正执行断言(而非仅仅跳过)。
  • 线程为何保持未解决: 本轮会推送新的 head(c35844b8e3),因此"在最终合入的 head 上跑绿"需要由 no-AK 检查项在该提交上重新满足。这是留给维护者的合并时点关卡,不是此处代码改动能代为完成的。针对该线程的回复与上述证据已记入 comment-replies.json

评审正文条目(无线程可回复)

  • rv:5114995804(第 4 轮,COMMENTED):其两条行内发现即上文处理的两条。其收敛观察由根因定位一节作答。其余建议——把修复攒成一批、将本 PR 的评审降到 --severity-floor critical、或合入后把 Suggestion 转到后续 issue——属于维护者的评审策略决定,而非代码改动;其中属于我的部分(把两条发现攒成一个已验证的轮次)已完成。
  • rv:5115785592(第 5 轮,COMMENTED,"仅完成部分审查,缺口已披露"):不含任何发现。其披露的缺口即上文说明的 merge_group 专属 CLI 检查项。

范围与边界说明

  • 没有发现被延后到后续处理(没有任何已验证的发现位于本 PR footprint 之外),也没有发现被上报给维护者决定。
  • 没有删除或削弱任何测试;本轮只是收窄了一条测试的运行时机,而 CI 实际执行的默认模式分支仍然运行并通过该测试。
  • 未触碰任何受保护区域。.github/workflows/ci.yml.github/scripts/run-autofix-review-verification.shscripts/lint.jsscripts/pre-commit.jspackage.json 仅被读取,用于确认哪条检查项收集该文件、门禁会重跑哪些命令,以及 prettier 是否通过提交钩子强制执行。
  • 本轮 diff 增长:源码 0 行 / 测试净 +5 行,预算为 400 / 400。
  • 提交后 git status --porcelain=v1 为空;提交为追加式(无 amend、rebase、reset 或历史重写)。

验证(Verification)

本轮实际执行的命令及结果:

  • npm run build通过BUILD_EXIT=0)。在本轮的树上执行;本轮唯一改动是 integration-tests/test-helper.test.ts,而 scripts/build.js 不编译它(该文件中不出现 integration-tests 字样),因此该结果覆盖已提交的树。
  • npm run typecheck通过TYPECHECK_EXIT=0),在最终树上执行;包含 typecheck:integrationtsc -p integration-tests/tsconfig.json),也就是 CI no-AK 门禁在跑套件之前执行的那一步。
  • npm run lint通过LINT_EXIT=0),在最终树上执行;包含 eslint integration-tests
  • npx prettier --check integration-tests/test-helper.test.ts通过(在评审建议的内联条件形态下会失败,见上文)。
  • 聚焦集成运行,默认模式(no-AK 检查项对该文件的收集方式):env QWEN_SANDBOX=false npx vitest run --root ./integration-tests ./test-helper.test.ts8 passed (8),390 ms。
  • 聚焦集成运行,已安装发布模式,守卫就位:env PATH="/tmp/installed-shim:$PATH" QWEN_SANDBOX=false INTEGRATION_TEST_USE_INSTALLED_GEMINI=true npx vitest run --root ./integration-tests ./test-helper.test.ts7 passed | 1 skipped,99 ms。
  • 变异探针,移除守卫子句、已安装发布模式、聚焦被改动的测试 — 1 failed | 7 skipped,耗时 20053 ms,报错 the stand-in CLI never produced output;随后恢复守卫并逐字节校验,两种模式下重跑均为绿。
  • 修复前复现,在 d1d375bed4 上、已安装发布模式 — 1 failed | 7 passed,20051 ms,同一断言。
  • git commit 触发了仓库的 lint-staged pre-commit 钩子(对暂存文件执行 prettier --writeeslint --fix --max-warnings 0)——通过,未产生任何修改;提交的 blob 经 sha256sum -c 校验与被探针验证过的文件逐字节一致。
  • npm run generate:settings-schema无需执行:未改动任何 settings 源(packages/cli/src/config/settingsSchema.ts / settings.ts)。
  • npm run bundle 之后跑集成测试 — 无需执行:被改动的行为由集成脚手架通过 npx vitest --root ./integration-tests 直接验证,这也正是 CI no-AK 检查项的运行方式,并不经过打包后的 CLI。

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

⚠️ Review-thread resolution skipped — guard: live-head drift; resolved 0 of 1 selected thread(s), 1 left for a later round. · 评审线程关闭被跳过——守卫:live-head drift;选中 1 条,本轮关闭 0 条,其余 1 条留待后续轮次。

Re-review when you have a moment. After round 10 this bot stops and leaves the PR for a human. · 有空请复审;第 10 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02

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

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Main CI failed: E2E Tests on 56f75adf2992

3 participants