Skip to content

fix(vscode): shut the ACP CLI down gracefully instead of killing it - #11642

Merged
yiliang114 merged 18 commits into
mainfrom
fix/acp-graceful-shutdown
Sep 12, 2026
Merged

fix(vscode): shut the ACP CLI down gracefully instead of killing it#11642
yiliang114 merged 18 commits into
mainfrom
fix/acp-graceful-shutdown

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

The VS Code Companion now tears down its managed ACP CLI by closing stdin first, allowing the CLI to run its normal shutdown path: SessionEnd hooks, MCP shutdown, session disposal, and registered process cleanup. The fallback is bounded: after a 75-second graceful window, POSIX sends a catchable SIGTERM to the ACP process group and waits another 75 seconds before SIGKILL; Windows uses the absolute System32 taskkill path to terminate the process tree.

The CLI shares in-flight SessionEnd, MCP shutdown, session disposal, and exit-cleanup work when EOF and a signal overlap. SessionEnd hooks start concurrently under one 30-second abort budget. Because the old ACP child can remain alive during the graceful window, child-exit handlers, callbacks, and session responses are also tied to the child or connection that created them so they cannot overwrite a replacement connection.

This is intentionally only the graceful ACP teardown split. It does not add a session-close protocol when users navigate between conversations, and it does not add retry/backoff state for superseded sessions.

Why it's needed

Immediate process termination bypasses the CLI's own cleanup and can leave tracked descendants behind. It also gives the previous shutdown path less time than its supported work requires and provides no catchable POSIX escalation rung. This change addresses those ACP shutdown defects while avoiding the unrelated and substantially larger session-lifecycle design.

Reviewer Test Plan

How to verify

  • Close or reload a Companion chat and confirm the ACP CLI exits normally after stdin closes, without a forced signal.
  • With an intentionally unresponsive child, confirm POSIX escalates from SIGTERM to SIGKILL at the documented deadlines and Windows targets the full process tree.
  • Reconnect while the old child is shutting down and confirm its later exit, callback, or response cannot clear the replacement connection.
  • Confirm an EOF/SIGTERM overlap runs each shutdown phase once and aborts SessionEnd work at the shared deadline.
  • Focused automated verification: Companion ACP tests 26/26, CLI ACP tests 709/709, and cleanup tests 10/10 passed.
  • Repository build and typecheck passed.

Evidence (Before & After)

Before, disconnect terminated the ACP child immediately and bypassed its normal cleanup. After, ordinary disconnect reaches the CLI shutdown path first, with platform-specific forced termination only as a bounded fallback.

Tested on

OS Status
🍏 macOS ✅ focused tests, build, typecheck
🪟 Windows ⚠️ mocked process-tree tests only
🐧 Linux ⚠️ not tested locally

Environment (optional)

macOS arm64, Node.js 22. Native Windows process accounting and a real VS Code extension host remain manual verification boundaries.

Risk & Scope

Linked Issues

Refs #11510 · Refs #11303

中文说明

这个 PR 做了什么

VS Code Companion 现在会先关闭所管理 ACP CLI 的 stdin,让 CLI 走正常关闭流程:执行 SessionEnd hook、关闭 MCP、释放 session,并运行已注册的进程清理。兜底路径有明确边界:优雅关闭等待 75 秒后,POSIX 向 ACP 进程组发送可捕获的 SIGTERM,再等待 75 秒后才发送 SIGKILL;Windows 则通过 System32 下的绝对 taskkill 路径终止整棵进程树。

当 EOF 与信号关闭重叠时,CLI 会共享正在进行的 SessionEnd、MCP 关闭、session 释放和退出清理工作。SessionEnd hook 并发启动,并共享一个 30 秒中止预算。由于旧 ACP 子进程可能在优雅窗口内继续存活,child 退出处理、回调和 session 响应也都绑定到创建它们的 child 或连接,不能覆盖替换后的连接。

本 PR 有意只保留 ACP 优雅关闭这一拆分。不增加用户切换会话时的 session close 协议,也不增加被替换 session 的重试/退避状态。

为什么需要

直接终止进程会绕过 CLI 自己的清理,并可能遗留它跟踪的后代进程;旧路径给正常关闭预留的时间也小于受支持的工作量,而且 POSIX 没有可捕获的升级档。本修改只解决这些 ACP 关闭缺陷,避免把无关且明显更大的 session 生命周期设计混进来。

Reviewer 测试计划

如何验证

  • 关闭或重载 Companion 会话,确认 ACP CLI 在 stdin 关闭后正常退出,没有被强制信号终止。
  • 使用故意无响应的 child,确认 POSIX 按文档期限从 SIGTERM 升级到 SIGKILL,Windows 则针对完整进程树。
  • 在旧 child 关闭期间重新连接,确认旧 child 之后的退出、回调或响应不会清空替换连接。
  • 确认 EOF/SIGTERM 重叠时每个关闭阶段只执行一次,并在共享期限到达时中止 SessionEnd 工作。
  • 定向自动验证:Companion ACP 测试 26/26、CLI ACP 测试 709/709、cleanup 测试 10/10 全部通过。
  • 仓库 build 与 typecheck 通过。

前后对比证据

修改前,disconnect 会立即终止 ACP child,绕过其正常清理。修改后,普通 disconnect 会先进入 CLI 关闭流程,只有超出边界时才使用平台对应的强制终止兜底。

测试平台

OS 状态
🍏 macOS ✅ 定向测试、build、typecheck
🪟 Windows ⚠️ 仅 mock 进程树测试
🐧 Linux ⚠️ 未在本地测试

环境(可选)

macOS arm64、Node.js 22。Windows 真机进程计数与真实 VS Code extension host 仍是手工验证边界。

风险与范围

关联 Issue

Refs #11510 · Refs #11303

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

⚠️ Qwen Triage ended earlyview run. It stopped before finishing; check the run log.

⚠️ Qwen Triage 提前结束 —— 查看运行。未跑完,请查看运行日志。

disconnect() used to child.kill() the CLI: TerminateProcess on Windows
(cleanup never runs, MCP/PTY/conhost children orphaned — the teardown
half of #11303) and a bare SIGTERM on POSIX with no bound. Now stdin is
closed so the CLI runs its own shutdown (SessionEnd hooks, MCP pool
drain, session dispose, exit-time reaper), with a bounded escalation
behind it: 45s grace sized from the CLI's own drain budgets, then a
process-group SIGTERM (catchable, runs the signal cleanup), then SIGKILL
after 10s. Windows goes straight to a taskkill tree kill once the grace
expires, since console processes have no catchable terminate.

Replacing the current session (session/new, session/load) now also
closes the superseded one — a retained session keeps firing autonomous
turns when its background tasks complete, which is what grew conhost.exe
without bound in #11303. The close is conditional (onlyIfUnheld) with an
8s drain budget, and refused/failed closes retry on a 60s doubling
backoff capped at 1h, so in-flight work is never dropped and the leak
stays tracked. The CLI side of that close path gets the matching
concurrency idempotency: a SIGTERM landing mid-ide_close now joins the
in-flight MCP pool drain and session dispose instead of running them
twice.

Superseded connections can no longer tear down the live one: child
handlers bind their own child, inbound callbacks and stale request
resolutions check against the connection they were wired on.

Refs #11510 #11511 #11303
@yiliang114
yiliang114 force-pushed the fix/acp-graceful-shutdown branch from efe1160 to d3eb393 Compare September 11, 2026 09:07
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run on head ebbeffecda0e0a8d1f5134df5e01325f307ab5ed — six commits past the c0aaaca7 this gate last reviewed, so this updates in place rather than repeating itself. The short version: both conditions the previous run set before it would approve are now met, and I verified them against the head source instead of taking the commit messages for it.

Template ✓ — every required section filled in, Tested-on table honest about what was and was not run, complete Chinese translation.

Problem — observed, not theoretical. #11303 reports 347 leaked headless conhost.exe ConPTY processes and ~2.8 GB retained after ~12h of Companion uptime. That clears the "does this problem actually exist" bar comfortably.

On the duplicate gate: closingIssuesReferences comes back empty, because the body links both issues with Refs, which is not a closing keyword — so Stage 1-pre formally never fires here. I checked subsumption directly anyway rather than letting the parser's silence stand in for an answer. #11510 ("ACP disconnect escalation: shutdown grace is smaller than a supported CLI shutdown, and POSIX has no catchable rung") is closed as completed but with closer: null — closed manually, no implementing PR. And main's disconnect() is still the unconditional this.child.kill(), so none of this has landed. Not a duplicate of a merged fix. I am noting the manual close again only because a tracking issue marked "completed" two hours ahead of its implementing PR is ambiguous bookkeeping, not because it changes the gate outcome.

Direction — aligned. A first-party surface leaking hundreds of processes and gigabytes is squarely in scope, and the fix sits at the right layer: the Companion's own teardown, not a compensating workaround inside the CLI. CHANGELOG has no direct reference, but the area is relevant — this is the same ACP teardown surface #11510 was filed against.

Size — core paths are touched (packages/vscode-ide-companion/src/services/**, plus a cross-package packages/clipackages/vscode-ide-companion change), so here is the breakdown at head:

lines (added + deleted)
Production logic — acpConnection.ts 184, acpAgent.ts 120, cleanup.ts 15 319
Tests — acpConnection.test.ts 374, acpAgent.test.ts 88, cleanup.test.ts 20 482
Design docs (en + zh-CN) and the e2e plan 81

319 production lines is under the 500-line escalation threshold and well under the 1000-line advisory, and the title is fix(vscode) rather than refactor, so there is no size-based block and no maintainer-awareness escalation. The author also holds admin on this repo, so this is maintainer-authored and exempt from the two-tier core gate regardless. I still traced every downstream consumer rather than leaning on the exemption — that is in the code review below.

Approach — scope feels right and I could not find a materially simpler path. Closing stdin and letting the CLI's existing connection.closed path run is the correct lever; the bounded escalation ladder is what makes it safe. The scope discipline is genuinely good: the session-close protocol and retry/backoff state were deliberately pushed to #11623 instead of being folded in. Two carry-overs from the last run that are still open, neither a blocker:

  • .qwen/e2e-tests/vscode-acp-graceful-shutdown.md is committed against the repo's own ignore rule. I re-confirmed at head: git check-ignore -v resolves it to .gitignore:32 (.qwen/*), and AGENTS.md documents .qwen/e2e-tests/ as git-ignored working artifacts. This is a force-add. The tracked pair that belongs in version control — docs/design/vscode-acp-graceful-shutdown.md and its .zh-CN.md counterpart — is already in the PR, so nothing is lost by dropping this file. Worth pulling out, but I would not hold the PR on it.
  • The 75-second first rung still has no recorded rationale. Please do not tighten it: the previous run derived a ≥120s worst case because SESSION_DRAIN_TIMEOUT_MS is spent sequentially at four or more sites, so 75s sits below the CLI's own ceiling, not above it. All that is missing is one line saying what 75s was chosen against, so a future maintainer does not read it as a derived bound.

Risk — Stage 1e matches two high-risk paths, the strongest triage-time revert signal this repo has: packages/vscode-ide-companion/src/services/acpConnection.ts (matches acpConnection) and packages/cli/src/acp-integration/acpAgent.ts (matches acp-integration). So this gets full review depth, no skipped enrichments, and CI evidence is required before approval — which on this run means the approval is deferred to green rather than posted now, since Qwen Code CI is still in flight on this head. Reviewers should focus on the escalation ladder's timing and the superseded-connection guards; both are covered in the code review, and the second one is where the last round's Major finding lived.

Moving on to code review. 🔍

中文说明

在 head ebbeffecda0e0a8d1f5134df5e01325f307ab5ed 上重跑 —— 比本闸门上次评审的 c0aaaca7 多了六个 commit,因此这次是原地更新,而不是把上一轮的话再说一遍。简短结论:上一轮设定的两个"满足即可批准"的条件现在都已达成,而且我是对着 head 的源码核实的,没有直接采信 commit message。

模板 ✓ —— 所有必填章节都写全了,Tested-on 表格如实区分了跑过与没跑过的部分,中文翻译完整。

问题 —— 是已观测到的问题,不是理论性加固。#11303 报告 Companion 运行约 12 小时后泄漏 347 个 headless conhost.exe ConPTY 进程、占用约 2.8 GB 内存。"问题是否真的存在"这一关过得很干脆。

关于重复检查这条闸门:closingIssuesReferences 返回,因为 PR 正文用 Refs 关联两个 issue,而 Refs 不是关闭关键字 —— 所以 Stage 1-pre 在这里形式上根本不会触发。我没有让解析器的沉默代替答案,而是直接核对了"是否已被涵盖"。#11510("ACP disconnect escalation: shutdown grace is smaller than a supported CLI shutdown, and POSIX has no catchable rung")已关闭为 completed,但 closer: null —— 手动关闭,没有实现 PR。而 main 上的 disconnect() 仍然是无条件的 this.child.kill(),说明这些改动都还没落地。不是某个已合并修复的重复 PR。 我再次提到这次手动关闭,只是因为"跟踪 issue 在实现 PR 之前两小时就被标记 completed"这件事本身是含糊的流程记录,并不改变闸门结论。

方向 —— 对齐。一方自有界面泄漏数百个进程和数 GB 内存,完全在职责范围内;修复也落在正确的层次:Companion 自己的退出流程,而不是在 CLI 里加补偿性兜底。CHANGELOG 没有直接对应条目,但这个领域是相关的 —— 它与 #11510 针对的是同一片 ACP 退出路径。

规模 —— 触及核心路径(packages/vscode-ide-companion/src/services/**,外加 packages/clipackages/vscode-ide-companion 的跨包改动),head 上的拆分如下:

行数(新增 + 删除)
生产逻辑 —— acpConnection.ts 184、acpAgent.ts 120、cleanup.ts 15 319
测试 —— acpConnection.test.ts 374、acpAgent.test.ts 88、cleanup.test.ts 20 482
设计文档(中英)与 e2e 计划 81

319 行生产代码低于 500 行的升级阈值,也远低于 1000 行的大 PR 建议线;标题是 fix(vscode) 而不是 refactor,因此既不存在基于规模的拦截,也不触发维护者知会升级。作者在本仓库持有 admin 权限,所以这属于维护者自己提交的 PR,无论如何都不适用核心模块两级闸门。我仍然逐个追踪了下游调用方,而不是靠这条豁免省事 —— 具体内容在下面的代码审查里。

方案 —— 范围合理,我也没找到明显更简的路径。关闭 stdin、让 CLI 已有的 connection.closed 路径跑完,是正确的抓手;有上限的逐级升级是这个抓手安全的前提。范围克制得很好:session close 协议和重试/退避状态被有意推给 #11623,而不是顺手塞进来。上一轮遗留、目前仍未处理的两点,都不是拦截项:

  • .qwen/e2e-tests/vscode-acp-graceful-shutdown.md 是违背仓库自身忽略规则提交进来的。 我在 head 上重新确认过:git check-ignore -v 把它解析到 .gitignore:32.qwen/*),AGENTS.md 也写明 .qwen/e2e-tests/ 属于被 git 忽略的工作产物。这是一次 force-add。真正需要纳入版控的那对文件 —— docs/design/vscode-acp-graceful-shutdown.md 及其 .zh-CN.md —— 已经在 PR 里了,所以删掉这个文件不会丢任何东西。建议拿出来,但我不会因此压住这个 PR。
  • 75 秒第一档仍然没有留下选择依据。不要收紧它:上一轮推导出 ≥120 秒的最坏情况,因为 SESSION_DRAIN_TIMEOUT_MS 会在四个以上位置串行消耗,所以 75 秒是低于 CLI 自己的上界,而不是高于。缺的只是一行注释说明 75 秒是照着什么定的,免得后来的维护者把它当成推导出来的上界。

风险 —— Stage 1e 命中了两个高风险路径,这是本仓库最强的 triage 期回滚相关信号:acpConnection.ts(命中 acpConnection)与 acpAgent.ts(命中 acp-integration)。因此本次按完整深度评审、不跳过任何附加内容,且批准前必须有 CI 证据 —— 在本轮这意味着批准被推迟到 CI 转绿,而不是现在就发,因为该 head 上的 Qwen Code CI 仍在运行。建议 reviewer 重点关注两处:升级阶梯的时序,以及 superseded 连接守卫;两者在代码审查里都有覆盖,而上一轮的 Major 发现就出在第二处。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

No Critical, no Major. Both findings the previous run deferred on are fixed at head, and I re-derived each against the source rather than accepting the commit messages.

I wrote my own proposal from the title and "Why it's needed" before opening the diff: close stdin, bounded grace, then a catchable SIGTERM to the process group and SIGKILL, taskkill /t by absolute path on Windows, one memoized shutdown sequence shared by the EOF and signal paths, and every handler bound to the child that created it. The PR converges on that design and exceeds it in one respect I would have needed anyway — it binds asynchronous responses, not just exit handlers, to the connection that produced them.

The two prior findings, verified fixed

The Major is gone, fixed by exactly the route named last round. sendPrompt now guards on connection identity only:

if (this.sdkConnection !== conn) {

The || this.sessionId !== promptSessionId clause is removed, with a comment explaining that newSession reassigns this.sessionId without replacing sdkConnection. promptSessionId is still captured up front and used for the request, which is the right half of the old clause kept. I checked this against the merge-base rather than against the previous commit, and that reframes it: base had no supersede guard in sendPrompt at all — it awaited conn.prompt(...) and called onEndTurn unconditionally. So narrowing the guard does not merely undo a regression, it leaves the method strictly better protected than base: the genuinely-superseded-connection case is now caught, and base never caught it.

The missing fixture is added, and it is the right one. delivers a prompt that completes after a session switch on the same connection mutates conn.sessionId mid-flight, resolves the prompt, and asserts it resolves with { stopReason: 'end_turn' } and that onEndTurn was called with 'end_turn'. That is precisely the case named last round, and it kills the mutant that survived.

New code since the last review

Four substantive changes landed in the six commits, and all four hold up.

The controller.signal.aborted check in acpAgent.ts fixes a real silent-success bug, and its comment is accurate. The premise is load-bearing, so I verified it in packages/core instead of trusting the diff: hookSystem.fireSessionEndEvent returns Promise<DefaultHookOutput | undefined> and merely maps result.finalOutput, so it resolves; hookRunner.ts:554-561 returns { outcome: 'cancelled', error: ... } on abort rather than throwing. A cancelled hook therefore never reaches Promise.allSettled as a rejection, and without this check the CLI would exit 0 as though every SessionEnd hook had run. The comment's claim about the caller split is also correct — the throw is gated on managedConfigs && failures.length > 0, and the path the Companion actually triggers (stdin EOF → fireSessionEndOnce(SessionEndReason.Other) at :3126) passes no managedConfigs, so a cancelled hook warns without turning graceful shutdown into a non-zero exit. Only the managed path at :3071 escalates. That is the right asymmetry.

Removing lastExitCode / lastExitSignal is a correctness fix, not dead-code cleanup. They look write-only in the diff, so I read base: base:197-198 did read them, in the startup-failure branch, and base:169-170 wrote them in the exit handler with no identity check. A retired child exiting late could therefore poison the next connection's startup error message. Head replaces both reads with per-child closure locals and adds fallbacks base lacked (ownExitCode ?? ownChild.exitCode, ownExitSignal ?? ownChild.signalCode versus base's bare this.lastExitSignal). Same bug class as the identity guards, closed by construction rather than by a check.

The exit-handler guard went from truthiness to identity. Base used if (this.child); head uses if (this.child === ownChild). Base had a live bug here: after disconnect() followed by a fast reconnect, this.child points at the new child, which is truthy, so the old child's late exit would null out the replacement's sdkConnection, sessionId and child. This is the single most valuable line in the PR and it is easy to read past.

_resetCleanupFunctionsForTest clearing exitCleanupPromise is necessary, not cosmetic. Now that the cleanup pass is memoized, a test helper that cleared the function list but left the memo would leak an in-flight promise into the next test. Correct and minimal.

One outstanding blocking review rests on a premise the current code contradicts

The standing CHANGES_REQUESTED from qwen-code-dev-bot (on 5914facd, three docs/style-only commits behind head) blocks partly on first-rung timing, arguing that fireSessionEndOnce awaits each active session's Config sequentially at a per-hook DEFAULT_HOOK_TIMEOUT = 60000, so two or more sessions can exceed the 75s grace. At head that is not what the code does. The hooks are built by flatMap into a single Promise.allSettled under one shared 30s AbortController — they start concurrently and the phase is bounded at ~30s regardless of session count. The N × 60s arithmetic does not apply. The abort is genuinely enforced rather than decorative: hookRunner.ts:1281,1289,1350,1448 set aborted and terminate the spawned subprocess, and the four runner types each race an abort against execution.

Two other items from that review, for the record. The Prettier failure at acpAgent.ts:3001 was addressed by 02a4908 — the message is now wrapped across lines and inside 80 columns — and Lint & Static is re-running on this head; I am not asserting it green before CI says so. The P3 about onEndTurn carrying no session identity is real but pre-existing on base: base fired onEndTurn on resolution with no guard whatsoever, so a mid-turn switch could already land the old turn's completion on the newly-displayed conversation. Narrowing the clause restores base semantics here; it does not introduce this. It deserves its own issue, not a hold on this PR.

The separate ≥120s worst case derived last round is a different question and still stands — SESSION_DRAIN_TIMEOUT_MS is spent sequentially at four or more sites. I am not treating it as a block, because the consequence of exceeding 75s on POSIX is a catchable SIGTERM at 75s and SIGKILL only at 150s, which is exactly the rung #11510 asked for and strictly better than base's unconditional kill at t=0. What is still missing is a recorded rationale for the number.

Non-blocking

The grace and kill timers in disconnect() are not unref()'d, unlike the CLI side which does unref — harmless in an extension host, but each disconnect holds a 75s+75s handle. When the child has already exited before disconnect() is called, child.once('exit') will never fire again, so the grace timer still waits the full 75s before no-op'ing on the exitCode !== null guard; that is a cheap short-circuit. Five inbound-callback supersede gates still have no test that would notice their removal — a Suggestion by AGENTS.md, author-deferred, and all 53 review threads are now resolved. .qwen/e2e-tests/vscode-acp-graceful-shutdown.md is still force-added against .gitignore:32. None of these should gate the merge at this round count.

sequenceDiagram
    participant P1 as VS Code Companion
    participant P2 as AcpConnection
    participant P3 as ACP CLI child
    participant P4 as Hook system
    participant P5 as MCP pool
    P1->>P2: disconnect
    P2->>P2: capture child, clear child and sdkConnection and sessionId
    P2->>P3: stdin end (EOF)
    P2->>P2: arm 75s grace timer
    P3->>P3: connection.closed resolves
    P3->>P4: fireSessionEndOnce, shared promise, concurrent, 30s abort budget
    P4-->>P3: cancelled hooks resolve, detected via signal.aborted
    P3->>P5: drainPoolBeforeExit, memoized, 8s
    P3->>P3: disposeSessionsOnce then runExitCleanup, memoized
    P3-->>P2: exit event
    P2->>P2: exit listener clears both timers
    Note over P2,P3: only if no exit within 75s
    P2->>P3: SIGTERM to process group, negative pid
    Note over P2,P3: only if still alive after another 75s
    P2->>P3: SIGKILL to group, Windows uses taskkill f t by absolute path
Loading
Files changed (9 of 9 shown)
File What changed
packages/vscode-ide-companion/src/services/acpConnection.ts The substance of the PR. Spawns detached on POSIX so the child leads a process group, replaces base's immediate kill in disconnect with stdin close plus a two-rung escalation ladder, and moves every handler and response onto per-child or per-connection identity. Carries the truthiness-to-identity exit guard fix and the dead-field removal
packages/vscode-ide-companion/src/services/acpConnection.test.ts Covers group spawn, Windows not detaching, stdin-before-escalation ordering, POSIX SIGTERM then SIGKILL deadlines, the fallback when group signalling throws, taskkill and its degradation to child.kill on failure, cancellation on normal exit, the replaced-child race, onDisconnected exit info, and the same-connection session-switch fixture that was missing last round
packages/cli/src/acp-integration/acpAgent.ts Memoizes the MCP drain, session disposal and SessionEnd firing into shared promises so an EOF/signal overlap runs each phase once, fires hooks concurrently under one 30s abort budget, and now detects a cancelled hook via signal.aborted since allSettled cannot see it
packages/cli/src/acp-integration/acpAgent.test.ts Adds the SIGTERM-over-IDE-close sharing case and the abort-budget case, and threads the new signal argument through existing SessionEnd expectations
packages/cli/src/utils/cleanup.ts Dedupes concurrent cleanup passes behind one in-flight promise that clears itself on settle, and the test reset helper now clears that memo too
packages/cli/src/utils/cleanup.test.ts Asserts a concurrent second caller receives the identical promise and that the cleanup function runs once
docs/design/vscode-acp-graceful-shutdown.md English design doc. The Verification bullet was narrowed at head to say what each platform actually does — POSIX targets the process group, Windows targets the tree via taskkill and degrades to the direct child — which removes the contradiction raised as R3-2
docs/design/vscode-acp-graceful-shutdown.zh-CN.md Chinese counterpart, reciprocal link, matching structure and the same narrowed Verification bullet
.qwen/e2e-tests/vscode-acp-graceful-shutdown.md E2E plan, but this path matches .gitignore:32 and AGENTS.md treats it as untracked working artifacts, so it is force-added and should probably come out

Testing

This is an unattended CI-path run (Qwen Triage, triggered by issue_comment), so per the review rules I did not build, run, or execute anything from this PR — no test, build, or PR-derived code was invoked, and the PR was never checked out. Everything below was read through the API or from the head-SHA source.

CI on this head is still running, with zero failures so far. Across 28 check-runs on ebbeffecda0e0a8d1f5134df5e01325f307ab5ed: 15 success, 8 skipped, 5 in progress, and no failures and no red checks. The Qwen Code CI workflow run is still in flight, so the pending count for pull_request-event runs is 1 — which is why this run defers the approval instead of posting it. I did not poll or sleep-wait on it.

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

Check Conclusion
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
macos-latest / Java 21 ✅ success
OpenTUI no-flicker gate ✅ success
Real daemon E2E / Java 11 ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

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

Two things about that table matter for the verdict. Lint & Static is the check that was red on 5914facd for Prettier; it is re-running now and I am treating it as unsettled until it reports. And Test (windows-latest) is skipped, which is the mechanical reason the gap below cannot be closed by CI.

A sandboxed verification run is in flight but has produced no verdict for this head. Comment 5645963316 carries only the running placeholder for run 34694490013. The last completed report is for c0aaaca7 (5643495159, verdict ❌ findings), and both findings it produced are the two now fixed above — so that verdict is stale rather than current, and I have not treated it as evidence either way.

What neither CI nor static review can settle:

  • Native Windows process-tree termination. The motivating bug ([Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303) is a Windows ConPTY leak, and Windows is the one platform nobody has run — the Tested-on table says mocked process-tree tests only, macOS is the sole real execution environment, Linux untested locally. The ladder tests advance fake timers against a mock child with a hardcoded pid and a mocked platform, so they verify the code's logic and cannot show that taskkill /f /t clears a real ConPTY tree. I do not treat this as a hold, and the reason is worth stating: on both rungs head is strictly better than base on Windows. Base does this.child.kill(), which reaches the direct child only and leaves the ConPTY descendants — that is the 347-process leak. Head first tries the graceful path, which runs the CLI's own registered cleanup and is platform-independent, and only then falls back to a tree-targeting taskkill. An unverified improvement over a known-broken baseline is still an improvement.
  • Windows has no catchable rung. POSIX gets SIGTERM at 75s and SIGKILL at 150s; Windows goes straight to taskkill /f at 75s. That asymmetry is inherent to the platform rather than a defect here, but it means the ≥120s worst case costs more on Windows than on POSIX.

Sandboxed verification would settle the remaining gap: @qwen-code /verify — that the two-rung ladder actually fires at the documented deadlines and that the Windows branch terminates a real process tree is not observable from the diff, and this PR's suite passes identically with fake timers and a mocked platform, so a green run does not pin either claim. A run is already in flight (34694490013); its report should be read against head ebbeffec, not against c0aaaca7. @qwen-code /tmux is the wrong lane here — this is an extension-host surface, not a TUI one.

The author's reported 26/26, 709/709 and 10/10 counts are the author's claim; I did not re-run them, and on this head no sandbox report has confirmed them either (the prior confirmation was for c0aaaca7). Real-scenario tmux testing (Stage 2c) does not apply on a CI-path run, and I did not substitute the author's results for it.

中文说明

代码审查

没有 Critical,也没有 Major。 上一轮 defer 的两个发现在 head 上都已修复,我是逐条对着源码重新推导的,没有采信 commit message。

我在打开 diff 之前只根据标题和"为什么需要"写了自己的方案:关闭 stdin、有上限的宽限、然后向进程组发可捕获的 SIGTERM 再到 SIGKILL、Windows 上用绝对路径 taskkill /t、EOF 与信号两条路径共享一份记忆化的退出序列、以及把每个处理器绑定到创建它的子进程上。这个 PR 与我的方案收敛,并且在一点上比我想得更全:它把异步响应而不只是退出处理器也绑定到了产生它的连接上。

上一轮两个发现,已核实修复

Major 已消除,且用的正是上一轮点名的那条路径。 sendPrompt 现在只按连接身份判断(if (this.sdkConnection !== conn)),|| this.sessionId !== promptSessionId 子句被删除,并留下注释说明 newSession 会重新赋值 this.sessionId 但不替换 sdkConnectionpromptSessionId 仍在前面捕获并用于发起请求 —— 旧子句里对的那一半被保留了。我是对着 merge-base 而不是对着上一个 commit 核实的,这让结论更清楚:base 的 sendPrompt 根本没有任何 supersede 守卫 —— 它 await conn.prompt(...) 之后就无条件调用 onEndTurn。所以收窄守卫不只是撤销一个回归,它让这个方法比 base 保护得更好:真正被取代的连接现在会被捕获,而 base 从来不捕获。

缺失的 fixture 补上了,而且补对了。 delivers a prompt that completes after a session switch on the same connection 在 prompt 在飞时改掉 conn.sessionId,然后 resolve,断言它正常 resolve{ stopReason: 'end_turn' }onEndTurn 被以 'end_turn' 调用。这正是上一轮点名的用例,也杀掉了此前存活的变异体。

上一轮之后的新代码

六个 commit 里有四处实质改动,四处都站得住。

acpAgent.ts 里的 controller.signal.aborted 检查修掉了一个真实的"静默成功"bug,且注释准确。 这个前提是承重的,所以我进 packages/core 核实而没有相信 diff:hookSystem.fireSessionEndEvent 返回 Promise<DefaultHookOutput | undefined>,只是映射 result.finalOutput,因此它resolvehookRunner.ts:554-561 在 abort 时返回 { outcome: 'cancelled', error: ... } 而不抛错。于是被取消的 hook 永远不会以 rejection 的形式进入 Promise.allSettled,没有这个检查,CLI 就会以 0 退出,仿佛所有 SessionEnd hook 都跑过了。注释里关于调用方分岔的说法也是对的 —— 抛出被 managedConfigs && failures.length > 0 挡住,而 Companion 真正触发的那条路径(stdin EOF → :3126fireSessionEndOnce(SessionEndReason.Other))不传 managedConfigs,所以被取消的 hook 只告警,不会把优雅退出变成非零退出;只有 :3071 的 managed 路径会升级。这个不对称是对的。

删除 lastExitCode / lastExitSignal 是正确性修复,不是死代码清理。 它们在 diff 里看起来只写不读,所以我读了 base:base:197-198 确实读了它们(在启动失败分支里),而 base:169-170 在退出处理器里写它们时没有任何身份检查。因此一个已退役子进程的延迟退出可以污染下一个连接的启动错误信息。head 把两处读取都换成了按子进程隔离的闭包局部量,并补上了 base 没有的回退(ownExitCode ?? ownChild.exitCodeownExitSignal ?? ownChild.signalCode,对比 base 裸的 this.lastExitSignal)。这与身份守卫是同一类 bug,而且是用"构造上不可能"而不是"再加一道检查"关掉的。

退出处理器的守卫从真值判断变成了身份判断。 base 是 if (this.child),head 是 if (this.child === ownChild)。base 这里有一个真实 bug:disconnect() 之后快速重连时,this.child 指向子进程、为真值,于是子进程的延迟退出会把替代连接的 sdkConnectionsessionIdchild 全部清空。这是整个 PR 里最有价值的一行,也很容易被读过去。

_resetCleanupFunctionsForTest 顺带清掉 exitCleanupPromise 是必要的,不是修饰。 清理过程既然已经记忆化,一个只清函数列表、留着 memo 的测试助手就会把一个在飞的 promise 泄漏进下一个测试。改法正确且最小。

有一条仍在生效的阻塞评审,其前提与当前代码相矛盾

qwen-code-dev-bot 那张仍然生效的 CHANGES_REQUESTED(打在 5914facd 上,比 head 落后三个纯文档/格式 commit)部分以第一档时序为由阻塞,论据是 fireSessionEndOnce 按每个活跃会话的 Config 顺序 await、单次 hook 预算 DEFAULT_HOOK_TIMEOUT = 60000,因此两个以上会话就能超过 75 秒宽限。在 head 上代码不是这么写的。这些 hook 由 flatMap 构造后交给同一个 Promise.allSettled,共享一个 30 秒 AbortController —— 并发启动,该阶段的上界约为 30 秒,与会话数无关。N × 60 秒的算术不适用。而且这个 abort 是真实生效的、不是装饰:hookRunner.ts:1281,1289,1350,1448 会置 aborted 并终止已 spawn 的子进程,四种 runner 都让 abort 与执行竞速。

该评审的另外两项,一并记录。acpAgent.ts:3001 的 Prettier 失败已由 02a4908 处理 —— 该行现已折行且在 80 列内 —— Lint & Static 正在此 head 上重跑;在 CI 给出结论之前我不会断言它转绿。关于 onEndTurn 不带会话身份的那条 P3 是真实存在但 base 上就有的问题:base 在 resolve 后无任何守卫地触发 onEndTurn,所以回合中切换会话本来就可能把旧回合的完成事件落到新显示的会话上。收窄子句在这里恢复的是 base 语义,不是引入这个问题。它值得单独开 issue,而不是压住本 PR。

上一轮推导出的 ≥120 秒最坏情况是另一个问题,仍然成立 —— SESSION_DRAIN_TIMEOUT_MS 确实在四个以上位置串行消耗。我不把它当拦截项,因为在 POSIX 上超过 75 秒的后果是 75 秒时一次可捕获的 SIGTERM、150 秒才 SIGKILL,这正是 #11510 要求的那一档,也严格优于 base 在 t=0 的无条件杀。仍然缺的只是这个数字的选择依据。

非拦截项

disconnect() 里的宽限与杀进程定时器没有 unref(),而 CLI 侧有 —— 在 extension host 里无害,但每次 disconnect 会持有 75s+75s 的句柄。若子进程在 disconnect() 之前已退出,child.once('exit') 不会再次触发,于是宽限定时器仍会白等满 75 秒才在 exitCode !== null 守卫上空转,这个短路很便宜。五个入站回调的 supersede 门仍然没有能发现它们被删掉的测试 —— 按 AGENTS.md 属于 Suggestion,作者已标注延后,且 53 条 review thread 现已全部 resolved。.qwen/e2e-tests/vscode-acp-graceful-shutdown.md 仍是违背 .gitignore:32 的 force-add。在本 PR 已经走过的轮次下,这些都不该成为合入门禁。

测试

本次是无人值守的 CI 路径运行(Qwen Triage,由 issue_comment 触发),因此按规则我没有构建、运行或执行本 PR 的任何内容 —— 没有调用任何测试、构建或 PR 派生代码,也从未 checkout 该 PR。下面所有内容都来自 API 或 head-SHA 源码。

该 head 上的 CI 仍在运行,目前零失败。 ebbeffecda0e0a8d1f5134df5e01325f307ab5ed 上 28 个 check-run:15 success、8 skipped、5 个进行中,没有失败、没有红 checkQwen Code CI 工作流仍在飞,因此 pull_request 事件的 pending 计数为 1 —— 这正是本轮把批准推迟而不是直接发出的原因。我没有轮询或 sleep 等待。

表格中有两点对结论有影响。Lint & Static 正是在 5914facd 上因 Prettier 变红的那个 check,现在重跑中,在它给出结论之前我按未定处理。以及 Test (windows-latest) 是 skipped,这就是下面那个缺口无法由 CI 关闭的机制性原因。

沙箱验证运行在飞,但该 head 上还没有结论。 评论 5645963316 只有运行 34694490013 的占位内容。上一份已完成的报告是针对 c0aaaca7 的(5643495159,结论 ❌ findings),而它产出的两个发现正是上面已修复的那两个 —— 所以那份结论已经过期而非当前,我没有把它当作任何方向的证据。

CI 与静态评审都不能定的事:

  • 原生 Windows 进程树终止。 触发动机([Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303)是 Windows ConPTY 泄漏,而 Windows 是唯一没人真跑过的平台 —— Tested-on 表格写的是仅 mock 的进程树测试,真实执行环境只有 macOS,Linux 未在本地测试。阶梯测试是对硬编码 pid 的 mock child 推进假定时器、platform 也被 mock,因此它们验证的是代码逻辑,无法展示 taskkill /f /t 能否清掉一棵真实 ConPTY 树。我把它当作压住本 PR 的理由,理由值得写清楚:在两个档位上 head 都严格优于 base。base 执行 this.child.kill(),只够到直接子进程、留下 ConPTY 后代 —— 那就是 347 个进程的泄漏。head 先走优雅路径(跑 CLI 自己注册的清理,与平台无关),失败才回退到针对进程树的 taskkill。相对一个已知有问题的基线,未经真机验证的改进仍然是改进。
  • Windows 没有可捕获的那一档。 POSIX 在 75 秒得到 SIGTERM、150 秒才 SIGKILL;Windows 在 75 秒直接 taskkill /f。这个不对称源于平台本身而非此处的缺陷,但它意味着 ≥120 秒的最坏情况在 Windows 上代价高于 POSIX。

沙箱验证可以定下剩下的缺口:@qwen-code /verify —— 两档阶梯是否真的在文档所述期限触发、以及 Windows 分支能否终止一棵真实进程树,从 diff 上看不出来;而本 PR 的套件在假定时器和被 mock 的 platform 下会以完全相同的方式通过,所以一次全绿并不能钉住这两条主张中的任何一条。已有一个运行在飞(34694490013);其报告应对着 head ebbeffec 读,而不是对着 c0aaaca7@qwen-code /tmux 在这里不是合适通道 —— 这是 extension host 界面,不是 TUI。

作者自报的 26/26、709/709、10/10 是作者的主张;我没有重跑,而在当前 head 上也没有沙箱报告确认过它们(此前的确认是针对 c0aaaca7 的)。真实场景 tmux 测试(Stage 2c)在 CI 路径不适用,我也没有拿作者结果顶替。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — both conditions the previous run set are met and I verified them against the head source; what remains is a platform nobody can automate and a handful of hygiene nits, none of which should gate a PR at this round count.

The last run deferred rather than rejected, and it said precisely what would move it to approve with no further conditions: fix the Major by either of two named routes, and add the one missing fixture. Both happened. The Major was fixed by the route I would have picked — dropping the same-session clause and keeping connection identity — and checking it against the merge-base rather than the previous commit made me more comfortable than the fix alone warrants, because base had no guard in sendPrompt at all. So this is not a revert to a known-good state; it is a net improvement over base on the very axis the guard exists for. The fixture asserts the right thing (resolves, onEndTurn called with end_turn) rather than just existing.

Going back to my independent proposal: we converged, which is a good sign but also means I am not the sharpest possible check on this design — so I spent the review looking for places where a design this plausible could still be wrong at runtime, and for claims in the thread that the current code no longer supports. Both searches paid off.

The runtime search turned up something I would otherwise have read past. Base's exit handler gated on if (this.child) — truthiness, not identity. After a disconnect followed by a fast reconnect, this.child is the new child and therefore truthy, so the old child's late exit would null out the replacement's sdkConnection, sessionId and child. That is a live bug on main today, and this PR closes it as a side effect of moving to this.child === ownChild. The removal of lastExitCode / lastExitSignal is the same class: base wrote them with no identity check and read them in the startup-failure branch, so a retired child could poison the next connection's error message. Two real fixes that the diff makes look like cleanup.

The claim search found one that matters. A CHANGES_REQUESTED still stands on this PR arguing that the 75-second first rung can be blown by sequential per-session SessionEnd hooks at a 60-second timeout each. At head those hooks are built by flatMap into one Promise.allSettled under a single shared 30-second AbortController — concurrent, bounded at ~30s regardless of session count. The arithmetic behind that block does not apply to the code as it stands. I am flagging it here rather than in a reply because a maintainer reading only the review state would see an unresolved blocking vote resting on a premise the current source contradicts. Per this repo's own guidance, an unresolved thread is not automatically a live defect.

Would I maintain this in six months? Yes. The memoization pattern is the right shape, the identity guards are consistent rather than ad hoc, the comments explain why (the new signal.aborted block documents the caller asymmetry it depends on, which is exactly what a comment is for), and the scope discipline is real — the session-close protocol and retry state were pushed to #11623 instead of being folded in. That is the opposite of the usual shape for a PR of this size.

Is it needed? Unusually clearly. #11303 is 347 leaked processes and ~2.8 GB.

Where I land: approve, deferred until CI is green. I am not posting the approval in this run because Qwen Code CI is still in flight on ebbeffec — pending count 1 — and approving now would attest to a result that does not exist yet. The finalize workflow posts the same commit-pinned approval once every check on this head completes green, and withholds it if anything lands red or the head moves. That matters concretely here: Lint & Static is the check that was red one commit ago for Prettier, and it is re-running now. I read the offending line as correctly wrapped at head, but I would rather have the check say so than have me say so.

The guardrail computation is clean — not a cross-repository PR, and the title is fix(vscode) rather than refactor — and Stage 0 raised no escalation: 319 production lines is under the 500-line threshold, and the author holds admin, so this is maintainer-authored.

What I am not treating as resolved, so nobody reads this approval as broader than it is:

  • Native Windows process-tree termination is unverified, and Test (windows-latest) is skipped, so CI cannot close it. [Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 is a Windows bug. I am not holding the PR on it because head is strictly better than base on both rungs — base's child.kill() reaches only the direct child and leaves the ConPTY descendants that constitute the leak, while head tries the platform-independent graceful path first and falls back to a tree-targeting taskkill. An unverified improvement over a known-broken baseline is still an improvement. But it is a manual validation boundary and it should stay named as one.
  • Windows has no catchable rung. POSIX gets SIGTERM at 75s before SIGKILL at 150s; Windows goes straight to taskkill /f at 75s. Given the ≥120s worst case derived from sequential session drains, that asymmetry costs more on the platform that motivated the bug. Inherent to Windows rather than a defect here, but it is the reason the unrecorded 75s rationale is worth writing down — and worth not tightening.
  • onEndTurn carries no session identity, so a mid-turn conversation switch can land the old turn's completion on the newly-displayed conversation. Real, and now unmasked — but pre-existing on base, which fired onEndTurn with no guard at all. Its own issue, not a hold on this PR.
  • Five inbound supersede gates still have no test that would catch their removal. A Suggestion by AGENTS.md, author-deferred, all 53 threads resolved. On two of this repo's highest-revert-correlation paths I would like the net, but this PR is well past the round count where the repo's own guidance says to land Critical fixes and defer the rest.
  • .qwen/e2e-tests/vscode-acp-graceful-shutdown.md is force-added against .gitignore:32. Cheap to drop; the tracked design-doc pair is already in the PR.

One process note. A deferral to @qqqys was posted earlier on this PR at head c0aaaca7. That deferral is superseded — its three items were the Major, the coverage fixture, and Windows; the first two are fixed and the third is a manual boundary I have now reasoned through rather than left open. This approval also supersedes the three stale CHANGES_REQUESTED votes this account left on earlier commits (d3eb3930, bbe224cc, c0aaaca7), all of whose findings have been addressed.

Recorded so it does not get lost, unchanged from last run: SessionEnd hooks do not fire at all when a session is live — symmetric across head and base with no PR code in the loop, so pre-existing and not this PR's defect. It qualifies the headline benefit, because a live conversation is exactly the state a user is in when they close the panel. Worth its own issue.

中文说明

Confidence: 4/5 —— 上一轮设定的两个条件都已达成,且我是对着 head 源码核实的;剩下的是一件谁都无法自动化的平台验证,加上一把卫生类小问题,在本 PR 已走过的轮次下都不该成为门禁。

上一轮是 defer 而不是否决,并且明确写出了"满足即可批准、不附加新条件"的两件事:按两条点名路径之一修掉 Major,并补上那个缺失的 fixture。两件都做了。Major 用的正是我会选的那条路径 —— 删掉 same-session 子句、只保留连接身份 —— 而对着 merge-base(而不是上一个 commit)核实这件事,让我比这个修复本身更安心:base 的 sendPrompt 根本没有任何守卫。所以这不是回退到某个已知良好的状态,而是在这个守卫存在的目的那条轴上,相对 base 的净改进。fixture 断言的也是对的东西(resolve、onEndTurnend_turn 被调用),而不只是"存在"。

回到我独立提出的方案:我们收敛了。这是好迹象,但也意味着我不是对这个设计最锋利的那道检验 —— 所以我把评审时间花在两处:找"设计这么合理、运行时仍可能是错"的地方,以及找讨论串里当前代码已不再支持的说法。两处都有收获。

第一处翻出了一个我本来会读过去的东西。base 的退出处理器用 if (this.child) 判断 —— 真值,不是身份。disconnect 之后快速重连时,this.child子进程、因此为真值,于是子进程的延迟退出会把替代连接的 sdkConnectionsessionIdchild 全部清空。这是今天 main 上的一个真实 bug,而本 PR 在改用 this.child === ownChild 时顺手关掉了它。删除 lastExitCode / lastExitSignal 属于同一类:base 写它们时没有身份检查,却在启动失败分支里读它们,所以一个已退役子进程可以污染下一个连接的错误信息。两个真实修复,被 diff 呈现成了清理。

第二处翻出一条要紧的。本 PR 上仍挂着一张 CHANGES_REQUESTED,论据是 75 秒第一档会被"按会话顺序执行的 SessionEnd hook、每个 60 秒超时"吹穿。在 head 上,这些 hook 由 flatMap 构造后交给同一个 Promise.allSettled,共享一个 30 秒 AbortController —— 并发,上界约 30 秒,与会话数无关。那条阻塞背后的算术不适用于当前的代码。我把这件事写在这里而不是写在回复里,是因为只看评审状态的维护者会看到一个仍未解决的阻塞票,而它所依据的前提与当前源码相矛盾。按本仓库自己的指引,未解决的 thread 不自动等于仍然存在的缺陷。

半年后我来维护会不会骂作者?不会。记忆化的写法是对的形态,身份守卫是一致的而不是临时补丁,注释解释的是为什么(新增的 signal.aborted 那段写清了它所依赖的调用方不对称,这正是注释该干的事),范围克制也是真的 —— session close 协议和重试状态被推给 #11623,而不是顺手塞进来。对这个体量的 PR 来说,这与常见形状正相反。

这件事有没有必要做?必要得很清楚。#11303 是 347 个泄漏进程和约 2.8 GB 内存。

我的结论:批准,但推迟到 CI 转绿。 本轮我不发出这个批准,因为 Qwen Code CIebbeffec 上仍在运行 —— pending 计数为 1 —— 现在批准等于为一个尚不存在的结果背书。待该 head 上所有 check 转绿后,finalize 流程会发出同一个绑定到该 commit 的批准;若有 check 变红或 head 移动,它会收回。这一点在这里有实际意义:Lint & Static 正是一个 commit 之前因 Prettier 变红的那个 check,现在正在重跑。我读下来该行在 head 上折行是正确的,但我宁愿让这个 check 说出来,而不是让我说出来。

闸门计算是干净的 —— 不是跨仓库 PR,标题是 fix(vscode) 而不是 refactor —— Stage 0 也没有触发升级:319 行生产代码低于 500 行阈值,且作者持有 admin 权限,属于维护者自己提交的 PR。

以下是我当作已解决的,以免有人把这个批准读得比它实际更宽:

  • 原生 Windows 进程树终止未经验证,而 Test (windows-latest) 是 skipped,所以 CI 关不掉这个缺口。[Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 是一个 Windows bug。我不因此压住本 PR,因为在两个档位上 head 都严格优于 base —— base 的 child.kill() 只够到直接子进程、留下构成泄漏的 ConPTY 后代,而 head 先走与平台无关的优雅路径,失败才回退到针对进程树的 taskkill。相对一个已知有问题的基线,未经真机验证的改进仍然是改进。但它是一条人工验证边界,就该一直被点名为人工验证边界。
  • Windows 没有可捕获的那一档。 POSIX 在 75 秒得到 SIGTERM、150 秒才 SIGKILL;Windows 在 75 秒直接 taskkill /f。考虑到由串行 session drain 推导出的 ≥120 秒最坏情况,这个不对称在触发动机的那个平台上代价更高。它源于 Windows 本身而非此处的缺陷,但这正是"75 秒的选择依据该写下来"的理由 —— 也正是"不要收紧它"的理由。
  • onEndTurn 不带会话身份,所以回合中切换会话可能把旧回合的完成事件落到新显示的会话上。真实存在,而且现在被暴露出来了 —— 但 base 上就有,base 完全无守卫地触发 onEndTurn。该单独开 issue,不该压住本 PR。
  • 五个入站 supersede 门仍然没有能发现它们被删掉的测试。 按 AGENTS.md 属于 Suggestion,作者已标注延后,53 条线程全部 resolved。在本仓库回滚相关性最高的两条路径上,我希望有这张网;但本 PR 已远超那个轮次 —— 按仓库自己的指引,此时只落 Critical 修复、其余转后续。
  • .qwen/e2e-tests/vscode-acp-graceful-shutdown.md 是违背 .gitignore:32 的 force-add。 删掉很便宜;需要版控的那对设计文档已经在 PR 里了。

一条流程说明。本 PR 早先在 head c0aaaca7 上发过一条转交 @qqqys 的 defer。那条 defer 已被取代 —— 它的三项分别是 Major、覆盖率 fixture 和 Windows;前两项已修复,第三项是一条人工边界,而我这次是把它推理清楚了,不是留着不管。这个批准同时取代了本账号早先留在 d3eb3930bbe224ccc0aaaca7 上的三张陈旧 CHANGES_REQUESTED 票,它们的发现都已被处理。

有一件事记在这里以免丢失,与上一轮一致:当 session 处于活跃状态时 SessionEnd hook 完全不触发 —— head 与 base 对称、回路中没有本 PR 代码,所以是既有问题、不是本 PR 的缺陷。它限定了本 PR 的宣称收益,因为用户关闭面板时所处的正是一个活跃会话。值得单独开一个 issue。

上方正文末尾的机器可读标记表示:批准已就绪,待本 head 的 CI 全绿后由 finalize 流程自动发出。

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship — CI landed green after the review. ✅

@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 — Test (windows-latest, Node 22.x) was skipped in CI and the suite ran only on Linux locally, so this diff's Windows-only escalation branch (the taskkill /f /t rung and the non-detached spawn) was never executed on Windows.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": whether the 45 s wind-down lets a retired CLI and its replacement overlap on shared workspace state (the old process's drainPoolBeforeExit('ide_close') / sess…; "agent reverse-audit (round 1)": whether Readable.toWeb() on an already-converted Node Readable throws or silently double-subscribes — I reasoned from the 'data' -listener semantics without …; "agent reverse-audit (round 1)": I did not open WebViewProvider.ts:1341/1480/1495/1606/2608/2905 , so the concrete production caller pair that could overlap two connect() calls is unconfirme…; "agent reverse-audit (round 2)": did not read withLiveSessionRestore (acpAgent.ts:5400+) to confirm whether the live-restore path replaces the this.sessions entry or reuses the captured obj…; "agent reverse-audit (round 2)": did not execute npx vitest run src/services/acpConnection.test.ts in packages/vscode-ide-companion , so the suite's green state and the fake/real-timer inter…, and 3 more.

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

Test Plan (not a blocker): src/services/acpConnection.test.tsno such file or directory; src/acp-integration/acpAgent.test.tsno such file or directory.

中文说明

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

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and the suite ran only on Linux locally, so this diff's Windows-only escalation branch (the taskkill /f /t rung and the non-detached spawn) was never executed on Windows.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"whether the 45 s wind-down lets a retired CLI and its replacement overlap on shared workspace state (the old process's drainPoolBeforeExit('ide_close') / sess…"agent reverse-audit (round 1)"whether Readable.toWeb() on an already-converted Node Readable throws or silently double-subscribes — I reasoned from the 'data' -listener semantics without …"agent reverse-audit (round 1)"I did not open WebViewProvider.ts:1341/1480/1495/1606/2608/2905 , so the concrete production caller pair that could overlap two connect() calls is unconfirme…"agent reverse-audit (round 2)"did not read withLiveSessionRestore (acpAgent.ts:5400+) to confirm whether the live-restore path replaces the this.sessions entry or reuses the captured obj…"agent reverse-audit (round 2)"did not execute npx vitest run src/services/acpConnection.test.ts in packages/vscode-ide-companion , so the suite's green state and the fake/real-timer inter…,另有 3 条。

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

Test Plan(非阻断):src/services/acpConnection.test.tsno such file or directory; src/acp-integration/acpAgent.test.tsno such file or directory

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

Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated

@qwen-code-dev-bot qwen-code-dev-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.

REQUEST_CHANGES

已核对 head d3eb3930d5481999bef3a6473e6955bb27bc47ea(vs origin/main merge-base)。required CI 全绿(Test (ubuntu-latest, Node 22.x)Lint & StaticIntegration Tests (no-AK, No Sandbox)web-shell E2E Smoke 均 success),所以以下阻塞全部来自代码本身。方向(用 stdin 收尾代替 child.kill())是对的,问题在两档时序的算术与新增幂等的错误语义。

阻塞 1:两档 grace 覆盖不到唯一会走到第二档的情形,兜底 SIGKILL 会把本 PR 要修的泄漏重新引入

acpConnection.ts:64 SHUTDOWN_GRACE_MS = 45_000:74 SIGTERM_GRACE_MS = 10_000。我在该 head 逐项核对了 CLI 侧的真实预算:DEFAULT_HOOK_TIMEOUT = 60000packages/core/src/hooks/hookRunner.ts:46)、SESSION_DRAIN_TIMEOUT_MS = 30_000acpAgent.ts:507)、await agentInstance?.shutdownMcpPool(8_000)acpAgent.ts:2928)、OVERALL_CLEANUP_TIMEOUT_MS = 5_000packages/cli/src/utils/cleanup.ts:36)。

关键是执行顺序与闩锁:ide_close 路径 await fireSessionEndOnce(SessionEndReason.PromptInputExit)acpAgent.ts:3154)先跑,且 fireSessionEndOnce 是布尔闩锁(acpAgent.ts:2965-2966if (sessionEndFired) return; sessionEndFired = true;),并按每个活跃会话的 Config 顺序 await hookSystem.fireSessionEndEvent(...)。所以只要有一个受支持的默认超时 hook 跑到 45s 以上:

  • t=45s 扩展发 SIGTERM → shutdownHandleracpAgent.ts:3103-3106)里的 fireSessionEndOnce 因闩锁立即返回,随后 disposeSessionsOnce()drainPoolBeforeExit('signal')runExitCleanup() 全部从零开始,最坏 30+8+5 = 43s;
  • t=55s 扩展 process.kill(-childPid, 'SIGKILL')(不可捕获)→ process.on('exit') 与退出期清扫不执行;按本 PR 自己的注释(acpConnection.ts:186-193),组信号到不了调用过 setsid() 的后代(node-pty 会话、ConPTY host、detached hook supervisor)。

结果:第二档唯一会触发的场景,恰好是「CLI 还欠 43s 而只剩 10s」的场景,SIGKILL 落在收尾中途,把 #11303 的 PTY/子进程泄漏重新带回来,还可能截断 closeStoredSession 里的 recorder.flush()SIGTERM_GRACE_MS 的注释前提(「hook 与 drain 要么已跑完,要么会加入正在进行的 ide_close 那一轮」)在这条路径上不成立。

修复预期(任选其一,且两档必须用同一套算术):把第二档按信号路径自身上界来定(≈45s)并改正注释;或让 fireSessionEndOnce 交回进行中的 hook promise,使信号路径 join ide_close 而不是重跑;或按 #11510 的建议在 ide_close 路径上给 SessionEnd hook 设预算上限,让 45s 真的站得住。同时请更正 PR/issue 记录:把 grace 从「来历不明的常数」改成推导是对的方向,但推导漏掉了 hook 这一项,不等于该项已解决。

阻塞 2:drainPoolBeforeExit 的幂等改变了 strict/lax 语义,新的失败路径会让下一步被跳过

acpAgent.ts:2919-2936 现在把第一次调用的 promise 记忆化,注释也写明「First call wins; later calls — including a stricter one — join it」。两个方向都有后果:

  • strict 档失效:await drainPoolBeforeExit(label, true)acpAgent.ts:3061)若加入一个由 'signal'/'ide_close'(非 strict,内部 catch 后吞掉错误)先建好的 memo,就永远拿不到失败,failures.push(error) 不会发生,AggregateError('Managed ACP shutdown failed') 也不会抛出 —— 抽取来的「strict 调用者要知道 drain 失败」这一契约被静默取消。
  • 反向新增跳过路径:strict 首调失败时 memo 是 rejected,之后 await drainPoolBeforeExit('ide_close')acpAgent.ts:3157)会抛出,于是 acpAgent.ts:3158await disposeSessionsOnce() 被跳过(该 try 只有 finally 捕获器,错误直接逸出 runAcpAgent)。改动前每个调用各自 try/catch,非 strict 调用吞错后仍会继续 dispose。请给 memo 一个不会把首调的 strictness/失败外溢给后续 joiner 的形态(例如按 label/strict 记录并在 join 时重新判定,或让 memo 只吞错不传播)。

其余

本 head 上另有 R1-2、R1-4、R1-5、R1-6、R1-7、R1-8 六条 Critical 线程与 19 条 Suggestion 线程,全部处于未解决状态且尚无作者回应。我没有逐条替代验证(上面两条是我自己在代码上核出来的),它们不会被本次 Review 视为已解决:请逐条在当前 head 上修掉,或给出可核查的「为何不成立」回应。其中 R1-21 / R1-24 / R1-17 / R1-22 / R1-23 / R1-26 是「把兜底阶梯的分支删掉,用例仍全绿」一类 —— 对一个主题就是收尾阶梯的 PR,这些属于必要测试覆盖,请一并处理。

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

COMMENT

核对基线:head d3eb3930d5481999bef3a6473e6955bb27bc47ea(vs origin/main)。required CI 全绿(Test (ubuntu-latest, Node 22.x)Lint & StaticIntegration Tests (no-AK, No Sandbox)web-shell E2E Smoke),Windows/macOS 测试 lane 为 skipped。以下阻塞全部来自代码本身。

方向没有问题:用关闭 stdin 让 CLI 自行收尾、代替 child.kill(),是正确的做法。阻塞点在两档 grace 的算术,以及新增幂等的 strict 语义。

一、历史阻塞问题在当前 head 上仍然存在

本 PR 现有 27 条 review thread 全部 isResolved: false,其中 6 条 Critical(R1-2、R1-4、R1-5、R1-6、R1-7、R1-8);另有两条 CHANGES_REQUESTED(2026-09-11T14:14:59Z、2026-09-11T16:00:54Z)。head commit 的提交时间是 2026-09-11T09:06:45Z,早于这两条 review,也就是说这些意见提出之后代码没有再动过,因此它们在当前 head 上原样成立,无法视为已修复。

二、当前 head 上可证明的 Critical:第二档 grace 覆盖不到唯一会触发它的情形

acpConnection.ts:64 SHUTDOWN_GRACE_MS = 45_000:74 SIGTERM_GRACE_MS = 10_000SIGTERM_GRACE_MS 的注释前提是「SessionEnd hook 与 MCP pool drain 要么已经跑完,要么会加入正在进行的 ide_close 那一轮」,这个前提在实际代码里不成立:

  • acpAgent.ts:2961-2966fireSessionEndOnce布尔闩锁if (sessionEndFired) return; sessionEndFired = true;),不是 promise 记忆化,因此信号路径拿不到 ide_close 那一轮正在进行的 hook promise,只是直接跳过。
  • 两条路径的顺序相反:ide_close 是 fireSessionEndOnce(3154) → drainPoolBeforeExit('ide_close')(3157) → disposeSessionsOnce()(3158);信号路径是 fireSessionEndOnce(3105) → disposeSessionsOnce()(3106) → drainPoolBeforeExit('signal')(3120) → runExitCleanup()
  • 于是当 SIGTERM 落在 ide_close 仍在跑 hook 的阶段时,drain 与 dispose 两个 memo 都还不存在,信号路径会从零开始跑完整套收尾:SESSION_DRAIN_TIMEOUT_MS = 30_000acpAgent.ts:507)+ shutdownMcpPool(8_000) + runExitCleanup()OVERALL_CLEANUP_TIMEOUT_MS = 5s)≈ 43s,而留给它的只有 10s。
  • hook 跑到 45s 以上完全合法:DEFAULT_HOOK_TIMEOUT = 60000packages/core/src/hooks/hookRunner.ts:46),单个 SessionEnd hook 就可以超出第一档 grace。
  • 到点后 acpConnection.ts:1084 执行 process.kill(-childPid, 'SIGKILL'),不可捕获,process.on('exit') 与退出期回收器都不会执行;而按本 PR 自己的注释(acpConnection.ts:186-193),组信号到不了调用过 setsid() 的后代(node-pty 会话、ConPTY host、detached hook supervisor)。

结论:第二档唯一会被触发的场景,恰好是「CLI 还欠约 43s 而只剩 10s」的场景,SIGKILL 落在收尾中途,把 #11303 的 PTY/子进程孤儿泄漏重新带回来,并可能截断 closeStoredSession 里的 recorder.flush()

可行的修法(任选其一,两档必须用同一套算术):把第二档按信号路径自身的上界定(≈45s)并改正注释;或让 fireSessionEndOnce 交回进行中的 hook promise,使信号路径真正 join ide_close 而不是跳过;或按 #11510 的思路给 ide_close 路径上的 SessionEnd hook 设预算上限,让 45s 站得住。

三、drainPoolBeforeExit 的幂等改掉了 strict/lax 语义

acpAgent.ts:2919-2936 把首次调用的 promise 记忆化,strict 只在首次调用时被闭包捕获,注释也写明「First call wins; later calls — including a stricter one — join it」。后果是:shutdownManagedAgentawait drainPoolBeforeExit(label, true)acpAgent.ts:3061)如果加入的是一个由非 strict 调用建立的 memo,就永远拿不到失败,failures.push(error) 不会发生,AggregateError('Managed ACP shutdown failed') 也不会抛出——「strict 调用者需要知道 drain 失败」这个契约被静默取消。反方向同样有风险:strict 首调失败时 memo 是 rejected,后续 await drainPoolBeforeExit('ide_close')(3157)会抛出,使 3158 的 await disposeSessionsOnce() 被跳过(该 try 只有 finally,错误直接逸出 runAcpAgent)。

这一项我确认了代码结构成立,但两个方向都要求 isTrustedManagedParent() 在两次调用之间取值不同(strict 调用点只在 managed 分支,非 strict 调用点只在 else 分支),我没有在本次核对中证明该翻转可达。请按可达性给出结论:要么让 memo 不把首调的 strictness/失败外溢给后续 joiner(例如按 label/strict 分别记录并在 join 时重新判定),要么补一条说明为何不可达。

四、下一步

第二节是本轮唯一新核出的、可在当前 head 上用代码证据证明的 Critical,请先处理;第一节的历史 Critical 线程需要逐条在当前 head 上修掉,或给出可核查的「为何不成立」回应。其中 R1-21 / R1-24 / R1-17 / R1-22 / R1-23 / R1-26 属于「删掉兜底阶梯分支后用例仍全绿」一类,对一个主题就是收尾阶梯的 PR 是必要覆盖,建议一并处理。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Resolution summary:

  • SessionEnd is now memoized and awaited across shutdown signals; ide_close hooks have a bounded 30s abort budget.
  • Shutdown grace is 75s and SIGTERM escalation is 45s, covering the full hook/MCP/session/cleanup path.
  • ACP child/initialize/prompt callbacks reject stale connection or session generations.
  • Superseded-session close requests are cancellable, deduplicated, retry-capped, refusal-aware, and awaited before loading the same session; disconnect clears all retry state.
  • Verification: root build, CLI and Companion typecheck/lint, Companion acpConnection tests 54/54, and targeted CLI SessionEnd tests 3/3.

Pushed as 94c10c8 and 99a72dd.

@yiliang114

yiliang114 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Audit follow-up (latest head bbe224cc3559):

I re-checked the implementation against the linked issue requirements instead of treating resolved threads as proof. One real boundary remained and is now fixed: fireSessionEndOnce passes a shared 30s AbortSignal for both PromptInputExit and Other, so a SIGTERM that arrives before connection.closed cannot run dispose/drain/exit under a still-running SessionEnd hook. The POSIX SIGTERM→SIGKILL rung is now 75s, covering the bounded 30s hook + 30s session drain + 8s MCP drain + 5s cleanup path.

Verification on this head:

  • CLI acpAgent.test.ts: 709/709
  • Companion acpConnection.test.ts: 54/54
  • Companion tsc --noEmit and lint: pass
  • Root npm run build and npm run typecheck: pass
  • POSIX smoke: stdin close -> code=0 signal=null, 40ms shutdown
  • Review threads: 27/27 resolved; no unresolved threads

Scope remains explicit: this addresses the ACP CLI shutdown/session-replacement portions related to #11510/#11511 and the ACP limb of #11303. It does not claim to close all of #11303: inbox/web-terminal paths, upstream node-pty#965, and real Windows ConPTY accounting remain outside this macOS/Linux validation. The PR description has also been corrected to the 75s/75s timings and current test counts.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 57 passed · 2 failed · 59 total

Flakiness gate: ⚠️ consistent-fail — 1 of 2 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:57 通过 · 2 失败 · 59 总计

抖动门:⚠️ consistent-fail — 1 of 2 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

Verification report

PR #11642 deep verification — fix(vscode): shut the ACP CLI down gracefully instead of killing it

Verdict: findings — the central claim is proven load-bearing by A/B against the base build, but three concrete problems reproduced: the PR's own new companion test deadlocks (suite is 53/54, not the 54/54 the test plan claims), the new "unsupported close" classifier can never fire on a real wire error, and SHUTDOWN_GRACE_MS sits below the CLI's own worst-case wind-down rather than above it.

Assertions: 57 pass / 2 fail / 59 total (assertions.json; recomputed from the raw cell logs by assert-results.mjs, not hand-tallied). The two fail rows are Findings 1 and 2; Finding 3 is a ceiling analysis derived from reading the code and is deliberately not counted as a scripted assertion.
Verified head: bbe224cc355916e1a039f4f6b3cd06ba5d3d8809 (git rev-parse HEAD^2). Base: 28df8b8a7897b0a8490220d00280c1c17d5ad002 (HEAD^1).

中文摘要

结论:findings(有发现,非阻断合并的中心主张已被证明)。 断言 57 通过 / 2 失败 / 共 59。

A/B 结论(中心主张成立)。 用真实构建产物 node packages/cli/dist/index.js --acp、真实 ndjson 线上协议,只替换编译后的 AcpConnection(head 与 HEAD^1 base 两个 bundle):base 侧 CLI 自己写出的 SessionEnd reason 是 other(被外部信号打死,走 shutdownHandler),head 侧是 prompt_input_exit(CLI 自己感知 stdin EOF 后自行收尾,走 connection.closed),两侧都是 code=0 signal=null,head 34ms 退出。detached 生成也经 ps -o pgid 实测:head 子进程 pgid == pid(自成进程组组长),base 与父进程同组。升级阶梯同样是载荷性的:对一个忽略 stdin EOF 的对端,base 的 child.kill() 只打到对端根进程,其非 detached 孙进程(模拟 MCP stdio server)被遗留存活;head 在实测 75060ms 发出进程组 SIGTERM,孙进程与根进程同时收到并被回收;对端连 SIGTERM 都忽略时,第二个 75s 档实测以 SIGKILL 收尾(152146ms)。见下表 H1H2

发现(3 项)。

  1. PR 自己新增的 does not wire replacement streams into a retired startup 在 fake timer 下死锁:它先 await expect(setup).rejectsadvanceTimersByTimeAsync(1000),而 setupChildProcessHandlers 的 1 秒 settle 正是被 fake 的定时器,于是断言永远等不到。Linux 上稳定 15s 超时,单独跑也复现(非并发竞争)。后果不止红灯:变异测试 A 把该测试本应钉住的新守卫 this.child !== ownChild 改回 base 语义,套件结果逐字不变(1 failed / 53 passed),即该守卫当前零有效覆盖。一行修复后 54/54(16.07s → 0.68s),且阳性对照 C(修复测试 + 回退守卫)在预期断言 expect(conn.sdkConnection).toBeNull() 上失败,而非超时。
  2. isUnsupportedSupersededCloseError 对真实线上错误永远返回 false:SDK 的 #handleResponsependingResponse.reject(response.error) 抛出的是原始 JSON-RPC 错误对象,既不是 RequestError 也不是 Error。实测捕获到的对象是 {ctor:"Object", isErrorInstance:false, code:-32601, message:"method not found"},判定结果 false。因此"旧版 CLI 不支持该方法即视为不支持、不再重试"这条设计要求未达成——不支持的 close 会进入重试表并按 60s→120s→240s…(封顶 1h)无限重试。PR 自带单测用 mockRejectedValue(new Error('Method not found')) 构造了 SDK 永不产生的形状,所以绿灯。
  3. SHUTDOWN_GRACE_MS = 75s 低于 CLI 自身收尾的最坏情况,而非注释所说的"略高于该上界"。注释里的 73s 推导中,"30s session drain"其实是每阶段预算、在 closeStoredSession 中被顺序使用三次:4463/:4502/:4585,即 ≥90s),且代码自己承认历史变更体"stays untimed"(:4581);"5s exit cleanup"确实会跑,但来自调用方 llm.tsx:1164-1168finally { await runExitCleanup(); },不是 runAcpAgent。故真实上界 >136s 且部分无界。这是静态推导的上界,非实测:实测典型场景为 141ms(有活跃 session)、病态 hook 为 30074ms,从未观测到超过 75s 的收尾。后果:一个仍在正常推进的慢收尾会在 75s 被进程组 SIGTERM 打断,再于 150s 被 SIGKILL——而 SIGKILL 会跳过所有 process.on('exit') 回收器,正是本 PR 要消除的孤儿化。此项不计入断言计数。

未覆盖范围。 Windows 全部路径(taskkill /f /tWINDOWS_TASKKILL 绝对路径解析、ConPTY 计数)无 Windows runner;托管父进程(shutdownManagedAgent)分支未触发;因 shallow checkout(本地 HEAD^1..HEAD^2 只有 1 个 commit,快照有 7 个)无法逐 commit 归因;快照 baseRefOid 本地不存在且无网络,未能做与当前 main 的试合并;未跑仓库级全量门禁;newSession/sendPrompt 的过期响应竞态未在真实线上驱动(只驱动了 loadSession);无 VS Code,故未在真实扩展宿主/真实关窗下验证。base 工作树的 CLI 构建因环境失败(已用 HEAD^2 工作树做 A/A 对照证明是环境而非代码),CLI 侧对照改用"仅在构建产物中回退关键一行"的方式。

Scope chosen

Central claim disconnect() makes the CLI shut itself down (stdin EOF → connection.closed) instead of being killed, and the bounded escalation ladder reaches the CLI's process tree rather than only its root.
Secondary claim 1 The CLI-side wind-down is bounded (30s SessionEnd abort), so the ladder stays a backstop and never lands mid-shutdown.
Secondary claim 2 Superseded-session close is conditional (onlyIfUnheld), never drops active work, backs off, treats unsupported as terminal, and never blocks the replacement session.
Out of scope (listed under Not covered) Windows, managed-parent shutdown, per-commit attribution, repo-wide gates, real VS Code extension host.

Central claim + A/B

H1 — real CLI, real wire, only AcpConnection differs

Base cell = AcpConnection compiled from tmp/base-tree (HEAD^1); head cell = the same file at HEAD. Both bundles were produced by bundle.mjs (esbuild, vscode aliased to a stub — the only stub in the loop; the unit under test and the CLI are real). Control cleanliness: the PR touches no package.json/lockfile, and the base bundle resolved zero head-tree workspace modules (83 modules vs head's 84; the extra one is acp-bridge/dist/bridgeTypes.js, which only head imports). readlink -f of the shared internal dependency confirmed @qwen-code/acp-bridge → /__w/qwen-code/qwen-code/packages/acp-bridge, i.e. the head tree — acceptable here because base does not import it and the PR does not modify it.

oracle BASE (HEAD^1) HEAD (PR)
child pgid == child pid (ps -o pgid) 20181 != 20191not detached 20160 == 20160group leader
SessionEnd reason, written by the CLI itself other prompt_input_exit
⇒ which CLI shutdown path ran shutdownHandlersignalled from outside await connection.closedwound itself down
exit code / signal 0 / null 0 / null
ms from disconnect() to child exit 30 34

Flip: 1/1. The reason field is the CLI's own output, not an inference, and it is the discriminator: on POSIX both arms end at code=0, so exit status alone cannot tell them apart. Witness: 01-h1-central-ab-shutdown-path-flips.png. Raw logs: logs/h1-base.log, logs/h1-head.log.

H2 — does teardown reach the process tree?

Peer = a real @agentclientprotocol/sdk AgentSideConnection over real ndjson pipes, which ignores stdin EOF (a wedged CLI — H1/H5 show a healthy one always exits in tens of ms, so this is the only situation the ladder exists for) and spawns a non-detached grandchild, exactly as the CLI spawns MCP stdio servers. Precondition measured, not assumed: grandchildPgid == peerPgid and peerIsGroupLeader == true.

cell stdin EOF peer signalled grandchild signalled grandchild at end
head / wedged +1 ms SIGTERM @​ 75060 ms SIGTERM @​ 75060 ms dead (reaped)
base / wedged never SIGTERM @​ 0 ms never ALIVE (orphan)
head / cooperative +1 ms never never alive †
base / cooperative never SIGTERM @​ 0 ms never alive †
head / ignores SIGTERM +1 ms SIGTERM @​ 75060 ms SIGTERM @​ 75060 ms dead; peer exit=SIGKILL @ 152146 ms

† The stand-in peer does not reap its own child, so its grandchild outlives it in both arms — a property of the fixture, not of the PR. H4 measures the same question against the real CLI and shows hook children reaped on both arms (hookChildReaped: true). The cooperative head row is still load-bearing for a different claim: no signal was ever sent, i.e. escalation is correctly suppressed when the graceful path worked.

Flip: 1/1 on the wedged rows — base orphans the grandchild, head reaps it. The last row also proves the second rung: SIGKILL lands ~75 s after SIGTERM. Witness: 02-h2-ladder-orphan-vs-reaped.png.

H5 — the premise behind the 75 s grace

Fixture: a SessionEnd command hook that blocks 600 s with its own timeout at 90 s, so the only thing that can cut it at 30 s is the AbortController this PR adds. Control = the head build with one line reverted in the compiled output (packages/cli/dist/src/acp-integration/acpAgent.js: fireSessionEndEvent(reason, hookAbortController.signal)fireSessionEndEvent(reason)), which is exactly the base call shape; restored afterwards and verified.

build CLI exits after disconnect() blocking hook child
head (signal passed) 30074 ms SIGTERM at 29971 ms
control (signal arg reverted) 75080 ms — overruns the 75 s grace no SIGTERM record; killed only at CLI exit

So the abort is load-bearing in the strongest sense: without it the CLI's wind-down exceeds the companion's grace and the escalation fires mid-shutdown — precisely the outcome the new comment says must not happen. It also proves the signal is not merely observed between hooks: the hook's child process is actually terminated at ~30 s. With a 10-minute hook timeout the 30 s cap holds on both companion arms (30076 ms head / 30075 ms base), confirming it is CLI-side and reason-independent. Witness: 03-h5-30s-abort-keeps-cli-inside-grace.png.

H4 — the realistic case (a live session), real CLI

newSession succeeded against the real CLI without credentials, so teardown was measured with an actual session held:

head base (control)
SessionEnd reason prompt_input_exit ×2 (both configs) other ×2
exit code / ms 0 / 141 0 / 136
hook children reaped true true

The reaper row matters: it disproves the worry that routing teardown through connection.closed instead of SIGTERM would skip cleanup. See Corrections.

Corrections

These are corrections to the description, not requests to change code.

  1. "Before: disconnect() killed the CLI directly, so the CLI's shutdown and exit-time reaper could not run." True on Windows (TerminateProcess); not true on POSIX, and POSIX is what this round could measure. child.kill() there is a catchable SIGTERM that the CLI already handles with the same bounded budgets. Measured (H1/H4): base exits code=0 in 30–136 ms, fires SessionEnd, and reaps its hook children. The user-visible orphaning the PR cites is a Windows fix; the measured POSIX deltas are (a) which shutdown path and SessionEnd reason runs, and (b) the group-signal ladder for a wedged CLI (H2).

  2. "73s bounded = 30s SessionEnd + 8s MCP drain + 30s session drain + 5s exit cleanup" understates the real bound, and the grace is below it. All four constants exist, but the third is a per-phase budget applied three times sequentially, and the fourth does run — just not from where the comment implies.

    • 30 s SessionEnd: real (acpAgent.ts:2987, one AbortController shared across all configs) and measured at 30074 ms (H5).
    • 8 s MCP drain: real (shutdownMcpPool(8_000)), a true wall-clock deadline.
    • "30 s session drain": not a total. SESSION_DRAIN_TIMEOUT_MS = 30_000 (:507) is the default drainTimeoutMs in closeStoredSession (:4462), and it is spent on three sequential phasesbeginSessionCloseAfterCurrentGate (:4463), waitForSessionDrain (:4502), and the runExclusiveHistoryMutation queue wait (:4585). disposeSessions() passes no explicit timeout, so it inherits all three: ≥90 s, not 30 s. The code itself concedes the tail is open — :4581: "The mutation body itself stays untimed, so the guarantee remains approximate" — and that body awaits recorder.finalize/flush/close, config.unregisterSessionRegistry() and config.shutdown(), none of which are timed. closePeerMessaging() and cleanupUnstoredConfig are untimed too. Sessions drain in parallel under Promise.allSettled, so this is per-shutdown, not per-session-multiplied.
    • 5 s exit cleanup: does run on the ide_close path — from the caller, llm.tsx:1164-1168 (finally { await runExitCleanup(); } process.exit(0);), not from runAcpAgent. The new CLI tests asserting runExitCleanup was not called are correct, because they invoke runAcpAgent directly and never reach that wrapper.

    Real worst case is therefore >136 s and partly unbounded, against SHUTDOWN_GRACE_MS = 75_000. See Finding 3 — the comment's "keep a small margin above that bound" is inverted.

  3. "refused closes retry with a 60s doubling backoff capped at 1h." Refusals do not double: scheduleSupersededCloseRetry(sessionId, true) resets failures to 1, so every refusal re-arms the 60 s rung (measured over four consecutive refusals: failures 1,1,1,1; delays 59950/59846/59847/59845 ms). Only errors double (60 s → 120 s → 240 s, measured). The reset is defensible — it matches ACTIVE_WORK_CLOSE_RETRY_GRACE semantics in bridgeTypes.ts, where a child answering a probe either way resets the count — but the description says the opposite. Related: the test named backs off exponentially while a superseded close keeps being refused asserts a constant 60 s rung (probe 3 fires at t=120 s, which exponential backoff would put at t=180 s), and its own inline comment says so. The name buys coverage confidence the fixture does not pay for.

  4. Reviewer Test Plan counts. acpConnection.test.ts is 53/54 on Linux, not 54/54 (the plan's own "Tested on" table does flag Linux as unverified). acpAgent.test.ts is 711/711, not 709/709.

  5. The runExitCleanup asymmetry inside runAcpAgent is real but harmless, and the reaper does run. The new CLI tests assert runExitCleanup is not called on the ide_close branch and is on the signal branch. That is accurate as far as it goes, but it is not the whole path: llm.tsx:1164-1168 wraps the runAcpAgent call in finally { await runExitCleanup(); } followed by process.exit(0), so on a graceful stdin-close shutdown the cleanup registry is drained and the process is force-exited. Separately, the reapers the PR body names (PTYs, ConPTY hosts, tracked children) are not in that registry at all — they are process-level process.on('exit') handlers (ShellExecutionService.cleanup() static block; hookRunner.ts:361 for hook child groups), which fire on the natural exit that llm.tsx:1168 triggers. H4 confirms this empirically: hookChildReaped: true on both arms. So routing teardown through stdin close loses no reaper on POSIX — the loss the PR describes is Windows-only, where TerminateProcess skips every exit handler.

Findings

1. The PR's own new test deadlocks, so the suite is red and the guard it pins is uncovered — Blocking

packages/vscode-ide-companion/src/services/acpConnection.test.ts:655, does not wire replacement streams into a retired startup.

cd packages/vscode-ide-companion && npx vitest run src/services/acpConnection.test.ts
# → Tests  1 failed | 53 passed (54)
#   Error: Test timed out in 15000ms.

The test awaits the rejection before advancing the fake timers:

const setup = (conn as ).setupChildProcessHandlers();
const setupFailure = await expect(setup).rejects.toThrow(/failed to start|superseded/i);
conn.child = newChild;
await vi.advanceTimersByTimeAsync(1000);

but setupChildProcessHandlers reaches its supersede throw only after await new Promise((resolve) => setTimeout(resolve, 1000)) (acpConnection.ts:258), and that setTimeout is faked. Neither side can proceed: a structural deadlock, not a timing margin, so it is platform- and speed-independent. It reproduces in isolation (logs/suite-companion-single-1.log: 1 failed | 53 skipped), which rules out shared-runner contention; the runner was loaded (average 38 on 64 cores) but that is not the cause. Every sibling test in the same file uses the correct order (…:760, …:831, …:839), so this is one inverted pair of lines.

Consequence beyond the red build. /failed to start|superseded/i at :675 is the only assertion in the file that reaches the this.child !== ownChild || ownChild.killed guard at acpConnection.ts:264 — a condition this PR changed (base was !this.child || ownChild.killed). Since the test cannot reach its assertions, that new guard has zero effective coverage:

variant result log
head as shipped 1 failed / 53 passed (54) — 15 s timeout suite-companion.log
Mutation A: guard reverted to base semantics 1 failed / 53 passed (54)identical, so SURVIVED mutation-A-guard-reverted.log
test fix only 0 failed / 54 passed (54) in 64 ms fix-B-test-fixed.log
Positive control C: guard reverted + test fix 1 failed — AssertionError: expected ClientSideConnection{ …(1) } to be null at :684 mutation-C-guard-reverted-with-fix.log

Control C is what makes the survivor meaningful: with the hang fixed, reverting the guard turns the test red on the assertion it was written for, not on a compile or fixture error. So the guard is correct and the test is right in intent — it just never runs. Witness: 04-mutation-matrix-guard-unpinned.png. All four runs used the same command; the working tree was restored and verified clean after each.

Measured fix (one line, + a comment)
      ).setupChildProcessHandlers();
      // Not awaited yet: setupChildProcessHandlers' 1s settle is a fake timer,
      // so the supersede rejection can only land after the advance below.
      const setupFailure = expect(setup).rejects.toThrow(
        /failed to start|superseded/i,
      );

      conn.child = newChild;
      await vi.advanceTimersByTimeAsync(1000);

      await setupFailure;
      expect(conn.sdkConnection).toBeNull();

Applied in a scratch copy and measured: suite 54/54 (16.07 s → 0.68 s; the whole 15 s was the hang); Mutation C then fails the intended assertion. expect(...).rejects returns a promise, so deferring the await changes nothing about what is asserted — setup's rejection is handled by the wrapper immediately, and no unhandled rejection appears.

2. isUnsupportedSupersededCloseError can never return true for a wire error — Major

packages/vscode-ide-companion/src/services/acpConnection.ts:606.

return (
  (error instanceof RequestError && error.code === -32601) ||
  (error instanceof Error && /method not found/i.test(error.message))
);

The shipped SDK rejects a failed request with the raw JSON-RPC error object, not an error instance — node_modules/@agentclientprotocol/sdk/dist/acp.js, #handleResponse:

else if ("error" in response) {
    pendingResponse.reject(response.error);
}

So both instanceof tests are false for every error that arrives over the wire, and the method always returns false. Measured on a real wire by instrumenting the real method (logs/h3-asis-full.log, cell J):

{ "ctor": "Object", "isErrorInstance": false, "code": -32601,
  "message": "method not found", "ownKeys": ["code","message"], "verdict": false }

Failure scenario. An older CLI that does not implement qwen/control/session/close answers -32601. The classifier misses it, so the code takes the generic branch: scheduleSupersededCloseRetry(sessionId) puts the session in the retry table and re-probes at 60 s → 120 s → 240 s → … capped at 1 h, for the lifetime of the connection, logging [ACP] Failed to close superseded session: each time. That is exactly the "unsupported retry loop" the PR body says does not happen ("Unsupported close methods on older CLIs are treated as unsupported and are not retried"; "Older CLIs … continue the new session normally, without an unsupported retry loop"). Confirmed twice on the wire: cell E (peer with no extMethod, so the SDK itself produces -32601 "Method not found": qwen/control/session/close) and cell F (peer throwing new RequestError(-32601, 'method not found')) both left [["sess-1",{"failures":1,…}]] in the retry table.

Why the suite is green anyway. acpConnection.test.ts:1492, does not retry an unsupported close method on an older CLI, mocks the reject path with mockRejectedValue(new Error('Method not found')) — an Error instance whose message matches the regex. That shape never occurs in production. The test is not vacuous (it does fail if the classifier is removed); it is pinned to the wrong fixture.

Blast radius. One read site: the .catch in sendSupersededClose (:664). Nothing else calls the classifier, and no other PR behaviour depends on it. What it does not cause: the replacement session is unaffected (cells E/F both confirm sessionId === 'sess-2' and a working sendPrompt afterwards), and no work is dropped — the defect is an unbounded, pointless retry loop plus log noise, not data loss.

Measured fix — read the fields instead of testing the prototype

The same file already does this for exactly this reason in mapReadTextFileError (:520), so the shape is house style:

  private isUnsupportedSupersededCloseError(error: unknown): boolean {
    // The SDK rejects a failed request with the raw JSON-RPC error object
    // (`pendingResponse.reject(response.error)`), not a RequestError instance,
    // so read the fields rather than test the prototype — the shape
    // mapReadTextFileError below already uses.
    const fields =
      typeof error === 'object' && error !== null
        ? (error as { code?: unknown; message?: unknown })
        : undefined;
    const message =
      typeof fields?.message === 'string'
        ? fields.message
        : error instanceof Error
          ? error.message
          : String(error);
    return fields?.code === -32601 || /method not found/i.test(message);
  }

Applied in a scratch copy, re-bundled, and driven through the same harness. Three results:

  1. Hostile fixtures go cleanH3_BUNDLE=fixed node h3-superseded-close.mjs38 pass / 0 fail (was 34/4). Cells E, F and both J checks flip.
  2. Zero collateral — the other 34 checks pass with unchanged values, including the exact wire params (onlyIfUnheld: true, requireFlush: true, drainTimeoutMs: 8000) and the refusal rung trace (59951/59849/59846/59848 ms vs 59950/59846/59847/59845 ms before).
  3. Gates unchangednpx tsc --noEmit exit 0; npx eslint src/services/acpConnection.ts exit 0; suite counts identical to as-shipped (1 failed / 53 passed, the same pre-existing deadlock from Finding 1, so this fix neither fixes nor breaks it).

Both gates were proven live before being cited: planting export const __gateProbe: number = "…" produced error TS2322: Type 'string' is not assignable to type 'number', and a scratch file with 1 == "1" produced an eqeqeq warning; --format json confirms eslint actually processes acpConnection.ts (errorCount: 0), so the clean lint is a pass and not a no-match. Evidence: logs/gate-liveness.log, logs/h3-fixed.log.

This fix should ship with a test whose fixture is the real shape — mockRejectedValue({ code: -32601, message: '"Method not found": qwen/control/session/close' }) — otherwise the suite still cannot tell head from head-plus-fix along that axis.

Witness: 05-h3-unsupported-close-misclassified.png.

3. SHUTDOWN_GRACE_MS is below the CLI's own worst-case wind-down, so the ladder can fire on a shutdown that is progressing correctly — Suggestion (design), with a real consequence

packages/vscode-ide-companion/src/services/acpConnection.ts:51-63. The comment states its own contract: "This has to outlast the CLI's own wind-down, or the escalation lands in the middle of a shutdown that is progressing correctly and skips the process.on('exit') cleanup this teardown exists to protect … Keep a small margin above that bound." Per Correction 2 the bound is >136 s and partly untimed, while the grace is 75 s — the margin is negative, not small.

Failure scenario. A CLI holding a session whose active turn will not settle, or whose history mutation/config.shutdown() is slow, is still working correctly at t=75 s. The companion then sends a process-group SIGTERM. The CLI's shutdownHandler joins the in-flight work via the memoized promises this same PR adds (so nothing double-runs — that part is well designed), but the sequence now races toward the second rung: SIGKILL at t=150 s. SIGKILL skips every process.on('exit') reaper, which is the exact orphaning this PR exists to prevent. H5 already demonstrates the shape on a smaller scale: with the 30 s abort reverted, the CLI was cut off at 75080 ms by the escalation rather than finishing on its own.

Bounded honestly. This is a ceiling, not an expectation. disposeSessions() aborts all generation controllers before draining (:4765-4772), so the three 30 s phases should normally release in milliseconds — measured: a real CLI with a live session tore down in 141 ms (H4), and a pathological 600 s hook in 30074 ms (H5). I never observed a >75 s shutdown; the >136 s figure is derived from three read call sites plus the code's own admission that the mutation body is untimed. And the ladder is still strictly better than what it replaces: base signalled at t=0 unconditionally.

Two ways to make the comment true, either acceptable: raise the grace above the derived bound (and say what it was computed from), or reframe both comments as a best-effort backstop rather than a bound-derived margin. The second is cheaper and matches reality — but then the sentence "Keep a small margin above that bound" should go, because it is what a future maintainer will trust. The twin comment at :65-73 (SIGTERM_GRACE_MS) repeats the same 73 s derivation and needs the same edit.

Suggestions (non-blocking)

  • Reuse the shared backoff helper. acpConnection.ts:704 re-implements Math.min(BASE * 2 ** (failures - 1), CEILING) inline while already importing from @qwen-code/acp-bridge/bridgeTypes, which exports activeWorkCloseRetryDelayMs(failures) for precisely this. The two policies differ: the helper keeps the first failed probe immediate (failures <= ACTIVE_WORK_CLOSE_RETRY_GRACE → null, then exponent failures - 2), the companion defers it 60 s. Measured: activeWorkCloseRetryDelayMs(1) === null, (2) === 60000, vs companion 60000, 120000. Neither is obviously wrong for this call site, but the helper's docstring exists so the ratio "cannot drift between call sites", and it now has.

Not covered

  • Windows, entirely. The taskkill /f /t branch, the WINDOWS_TASKKILL absolute-path resolution, and ConPTY accounting are unreachable here (process.platform !== 'win32' in this container). This is the platform where the PR's stated orphaning bug actually bites, so the highest-value limb of the central claim is unverified — the POSIX limb is proven instead. The PR's own table already flags this ("mocked tree-kill tests only").
  • Managed / trusted-parent shutdown (shutdownManagedAgent, isTrustedManagedParent()). A plain companion spawn is not a trusted managed parent, so that branch never ran. Its strict = true drain and its process.exitCode = 1-without-process.exit() tail are unexercised. I did check by reading that the strict-vs-non-strict memoization mix in drainPoolBeforeExit is unreachable (mode is fixed per process), so no finding — but that is a static argument, not a measurement.
  • Per-commit attribution. Shallow checkout: git rev-list --count HEAD^1..HEAD^2 returns 1 while $QWEN_VERIFY_CONTEXT lists 7 commits, and git rev-parse --is-shallow-repository is true. Only the aggregate HEAD^1..HEAD diff was verified.
  • Trial merge into current main. The snapshot's baseRefOid (518f6795…) is not present locally (git cat-file -tcould not get object info) and there is no token/network, so the merge-ref base 28df8b8a is what was tested. Whether this still merges cleanly onto today's main is unknown.
  • Base-CLI worktree build. npm run build -w packages/cli inside tmp/base-tree failed on dependency resolution in files this PR does not touch (@lydell/node-pty TS7016, @opentelemetry/* TS2307, all under ../core/src/**). Proven environmental by A/A control: the identical command in a HEAD^2 worktree failed with the identical first error (logs/aa-headtree-build.log). The CLI-side control therefore used the sanctioned alternative — reverting one line in the built dist/ output — rather than a base-CLI build.
  • newSession / sendPrompt stale-response guards. Only the loadSession supersede race was driven on a real wire (cells G, H). The newSession and sendPrompt "connection superseded" throws at :769 and :801 were not exercised end to end.
  • Real VS Code. No extension host in this container: every companion cell drives the real compiled AcpConnection from a plain node process. Actual window close / reload / reconnect, and the detached: true child's fate when the extension host itself dies, are unmeasured. Reasoned but not tested: stdin EOF reaches the CLI either way, so it should still self-terminate.
  • The ladder against a real wedged CLI. H2's wedge is a stand-in peer. A real CLI could not be wedged past the grace — that is H5's result, and it is a good one, but it does mean the 75 s and 150 s rungs were measured against a synthetic peer only.
  • Shape vs cause. H2 reproduces the shape of the orphaning the PR targets (a non-detached descendant in the CLI's group, surviving a root-only kill). It does not reproduce a real VS Code window close producing it.
  • The >136 s worst case in Finding 3 is derived, not observed. I measured the 30 s SessionEnd term end to end (H5, 30074 ms) and the typical live-session teardown (H4, 141 ms); the ≥90 s session-drain figure comes from reading three sequential call sites of SESSION_DRAIN_TIMEOUT_MS plus the code's own comment that the mutation body "stays untimed". I never produced a shutdown that actually exceeded 75 s, so Finding 3 is a ceiling analysis, not a reproduction.
  • Repo-wide gates. Only the two suites named in the test plan, plus companion tsc/eslint on the changed file. No npm run build, npm run preflight, integration tests, or other workspaces' suites.
  • previous-report.md was absent from the context directory, so this is a first round with no carried-forward findings.

Methodology

Everything ran in the CI verify container (node:22-bookworm, node v22.23.2, 64 cores, observed load average ~38 — shared runner) against the merge-ref checkout at HEAD = e4e37c8d, with npm ci/npm run build already completed by the job. The base side was a scratch git worktree at HEAD^1, removed after the A/B cells were captured; both arms' AcpConnection were compiled from source by bundle.mjs (esbuild) with only vscode aliased, and each bundle's resolved internal modules were printed and checked so no head-tree code could leak into the control. Harnesses h1-real-cli-ab.mjs, h2-ladder-ab.mjs, h3-superseded-close.mjs, h4-cli-budget.mjs drive the compiled connection over real pipes — H1/H4/H5 against the real packages/cli/dist/index.js --acp, H2/H3 against a real @agentclientprotocol/sdk agent peer that records every inbound request verbatim, so assertions are made on bytes the peer received and on files the CLI itself wrote (a SessionEnd command hook persisting its hook-input JSON, which is where the reason discriminator comes from). Mutation and fix candidates were applied to the working tree one at a time from /__w/_temp/pr11642-backup/, measured, and restored; git status --porcelain is empty and the dist revert was verified by grep. Per-cell stdout/stderr is in logs/, assert-results.mjs recomputes assertions.json from those logs, and print-cells.mjs renders the tables that scripts/verify-capture.mjs rasterised into evidence/.

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/cli/src/acp-integration/acpAgent.test.ts: (cd packages/cli) npx --no-install vitest run ./src/acp-integration/acpAgent.test.ts
file packages/vscode-ide-companion/src/services/acpConnection.test.ts: (cd packages/vscode-ide-companion) npx --no-install vitest run ./src/services/acpConnection.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/acp-integration/acpAgent.test.ts: PPPPP
  packages/vscode-ide-companion/src/services/acpConnection.test.ts: FFFFF

verdict: consistent-fail
summary: 1 of 2 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 1 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: F (exit 1)
--- output tail · round 1 · packages/vscode-ide-companion/src/services/acpConnection.test.ts ---
ns sdkConnection when process is alive�[32m 0�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect clears child, sdkConnection, and sessionId�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect is a no-op when there is no child�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect closes the CLI stdin instead of killing it (#11303)�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not force-kill a child that failed to spawn�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not end stdin that is already closed�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mhandles a synchronous stdin close failure�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect escalates stdin close → SIGTERM → SIGKILL on POSIX�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect does not escalate against a CLI that exited on its own�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not force-kill a CLI that exits within the SIGTERM grace�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not signal after exitCode or signalCode is observed�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mescalates through taskkill /t on Windows, not a bare kill�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mfalls back when taskkill cannot terminate the CLI tree�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22ma superseded child exiting does not tear down its replacement�[32m 1�[2mms�[22m�[39m
�[31m   �[31m�[31m AcpConnection child exit cleanup�[2m > �[22mdoes not wire replacement streams into a retired startup�[39m�[33m 15002�[2mms�[22m�[39m
�[31m     → Test timed out in 15000ms.
If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout".�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22ma live child exiting clears the connection and fires onDisconnected�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22ma superseded connection stops dispatching inbound callbacks�[32m 7�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22ma re-connected replacement keeps the superseded connection muted�[32m 4�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not stamp a superseded connection with a stale session id�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22ma re-connected replacement does not stamp the retired session id�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22ma re-connected replacement does not fire onEndTurn for a retired prompt�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not fire onEndTurn when the prompt session is superseded in place�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22mnewSession closes the superseded session on the same connection�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22mnewSession does not close anything for the first session�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22mloadSession closes the superseded session on the same connection�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22mloadSession does not close when reloading the current session�[32m 0�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msends the close conditionally so held work is refused, not dropped (#11511)�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mretries a refused superseded close on a backoff until it succeeds�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mbacks off exponentially while a superseded close keeps being refused�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mcancels the retry when the superseded session is loaded again�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mwaits for an in-flight close before loading that session again�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mdeduplicates concurrent close attempts for one session�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mcaps transient close retry backoff at one hour�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mstops retrying superseded closes after disconnect�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mclears and cancels an in-flight close when disconnect retires the connection�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mre-drives an expired close retry on the next session replacement�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22msuperseded close retry (#11511)�[2m > �[22mdoes not retry an unsupported close method on an older CLI�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection superseded session close (#11303)�[2m > �[22ma failed close does not fail the new session�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection onDisconnected callback�[2m > �[22mhas a default no-op onDisconnected handler�[32m 0�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection onDisconnected callback�[2m > �[22mallows setting a custom onDisconnected handler�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection lastExitCode/lastExitSignal�[2m > �[22minitializes exit info as null�[32m 0�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection extension notifications�[2m > �[22mparses end_turn reason and source�[32m 0�[2mms�[22m�[39m

�[31m⎯⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Tests 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m src/services/acpConnection.test.ts�[2m > �[22mAcpConnection child exit cleanup�[2m > �[22mdoes not wire replacement streams into a retired startup
�[31m�[1mError�[22m: Test timed out in 15000ms.
If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout".�[39m
�[36m �[2m❯�[22m src/services/acpConnection.test.ts:�[2m655:3�[22m�[39m
    �[90m653| �[39m  })�[33m;�[39m
    �[90m654| �[39m
    �[90m655| �[39m  it('does not wire replacement streams into a retired startup', async…
    �[90m   | �[39m  �[31m^�[39m
    �[90m656| �[39m    vi�[33m.�[39m�[34museFakeTimers�[39m()�[33m;�[39m
    �[90m657| �[39m    �[35mtry�[39m {

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[1m�[31m1 failed�[39m�[22m�[2m | �[22m�[1m�[32m53 passed�[39m�[22m�[90m (54)�[39m
�[2m   Start at �[22m 20:57:46
�[2m   Duration �[22m 15.91s�[2m (transform 237ms, setup 0ms, collect 255ms, tests 15.07s, environment 0ms, prepare 211ms)�[22m


round 2 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 2 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: F (exit 1)
--- output tail · round 2 · packages/vscode-ide-companion/src/services/acpConnection.test.ts ---
ns sdkConnection when process is alive�[32m 0�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect clears child, sdkConnection, and sessionId�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect is a no-op when there is no child�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdisconnect closes the CLI stdin instead of killing it (#11303)�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m AcpConnection child exit cleanup�[2m > �[22mdoes not force-kill a child that

...truncated -- full content in the run artifacts.

Evidence images

01-h1-central-ab-shutdown-path-flips

02-h2-ladder-orphan-vs-reaped

03-h5-30s-abort-keeps-cli-inside-grace

04-mutation-matrix-guard-unpinned

05-h3-unsupported-close-misclassified

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@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 — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI and their suites did not run locally; the escalation ladder's Windows taskkill rung is mock-covered only.

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

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

Test Plan (not a blocker): src/services/acpConnection.test.tsno such file or directory; src/acp-integration/acpAgent.test.tsno such file or directory.

中文说明

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

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI and their suites did not run locally; the escalation ladder's Windows taskkill rung is mock-covered only.

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

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

Test Plan(非阻断):src/services/acpConnection.test.tsno such file or directory; src/acp-integration/acpAgent.test.tsno such file or directory

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

Comment thread packages/vscode-ide-companion/src/services/acpConnection.test.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.test.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.test.ts Outdated
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @qqqys — needs a human call on this one.

The design is sound and most of it holds up under tracing: I found no Critical, all 41 review threads are resolved, CI on c0aaaca71346 is fully green (three pull_request runs successful, zero failed checks, nothing pending), and the author's 26/26, 709/709 and 10/10 counts were independently confirmed verbatim by the sandbox run. I traced the load-bearing mechanisms into packages/core rather than trusting the PR's own mocks, and they hold — in particular the new 30-second SessionEnd abort budget is genuinely enforced, since all four hook runners race an abort against execution, so this is not a signal that nothing reads.

I am not approving, and I am not requesting changes either. Three specific things:

  1. A reproduced, user-visible regression — Major, and the reason I would fix before merge. The new || this.sessionId !== promptSessionId clause at acpConnection.ts:536 makes an ordinary mid-turn conversation switch reject with -32603 "connection superseded". handleSwitchQwenSession / handleResumeSession have no in-flight guard, so this.sessionId moves under the live prompt. That string matches none of the isAbortLike substrings at SessionMessageHandler.ts:1048-1055, is not Session not found, is not a timeout — so it falls through to showErrorMessage at :1106 and the user gets a red VS Code popup plus a webview error banner for switching conversations. Reproduced end-to-end on a real wire: head rejects and never fires onEndTurn, base resolves and does. Two one-line fixes, either fine: add connection superseded to the isAbortLike list, or drop the same-session clause and keep connection identity only (which matches the design doc and still covers the reconnect hazard). Worth noting the throw does accidentally prevent a worse pre-existing bug — the success path would file the old turn's text into the newly-switched conversation — so throwing is not wrong, surfacing it as an error is.
  2. Seven companion guards ship with no test that would catch their removal. Mutation matrix 12 killed / 7 survived on the companion side (CLI side 7/7 killed): the five inbound-callback identity checks, the post-initialize supersede throw, and the same-session clause above. The guards are correct — two were driven on a real wire and behaved exactly as intended. This is a Suggestion by AGENTS.md, not a blocker, but it is why I would not approve on acpConnection / acp-integration — two of the repo's highest-revert-correlation paths — without the one missing fixture.
  3. Windows is unverified, and Windows is the entire point of [Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303. The Tested-on table says mocked process-tree tests only; macOS is the sole real execution environment; Linux untested locally. The ladder tests advance fake timers against a mock child with a hardcoded pid and a mocked platform, so they cannot show that taskkill /f /t clears a real ConPTY tree.

What would move me to approve on the next run, with no further conditions: fix item 1 by either route, and add the missing fixture (same connection, sessionId changed, prompt resolves). I would not hold this PR on item 2, on the 75s comment, or on the force-added .qwen/e2e-tests/ file — those are follow-ups, and this PR has been through enough rounds that the repo's own guidance is to land Critical fixes and defer the rest.

Two corrections to what the triage comments said an hour ago, so nobody acts on the stale version: the 75-second grace is below the CLI's derived worst case, not above it (SESSION_DRAIN_TIMEOUT_MS = 30_000 is spent sequentially at four or more sites, so ≥120s against 75s) — please do not tighten that window; and a completed /verify report already exists for this head (5643495159, verdict ❌ findings, 62/0/62), which is where items 1 and 2 come from. A third run (34681193735) is still in flight and has posted only a placeholder.

Also for whoever picks it up, recorded so it does not get lost: SessionEnd hooks do not fire at all when a session is live — symmetric across head and base with no PR code in the loop, so pre-existing and not this PR's defect, but it qualifies the headline benefit, since a live conversation is exactly the state a user is in when they close the panel. Worth its own issue.

Note on how this reached you: the deterministic owner resolver could not run in this environment, so the mention falls back to the most recent human reviewer on this PR, per the documented fallback chain.

中文说明

⏸️ 转交 @qqqys —— 这件事需要人来定。

设计是可靠的,追踪下来大部分也站得住:没有 Critical,41 个 review thread 全部 resolved,c0aaaca71346 上的 CI 全绿(三个 pull_request run 成功、零失败 check、无 pending),作者的 26/26、709/709、10/10 三项计数也被沙箱运行独立逐字确认。承重的几处机制我是追进 packages/core 核对的,而不是相信 PR 自带的 mock,结论都成立 —— 尤其是新增的 30 秒 SessionEnd 中止预算确实有效,因为四种 hook runner 都会让 abort 与执行竞速,所以这不是一个没人读的 signal。

我既不批准,也不提交 request changes。三件具体的事:

  1. 一个已复现、用户可见的回归 —— Major,也是我认为合并前该修的那件事。 acpConnection.ts:536 新增的 || this.sessionId !== promptSessionId 子句,会让一次普通的回合中切换会话-32603 "connection superseded" 被拒。handleSwitchQwenSession / handleResumeSession 没有 in-flight 守卫,所以 this.sessionId 会在 prompt 在飞时被改掉。这个字符串不匹配 SessionMessageHandler.ts:1048-1055 里任何 isAbortLike 子串,也不是 Session not found、不是超时 —— 于是一路落到 :1106showErrorMessage,用户因为切换会话而拿到一个红色 VS Code 弹窗加 webview 错误横幅。已在真实 wire 上端到端复现:head 拒绝且不触发 onEndTurn,base 正常 resolve 并触发。两个一行修法,任选其一皆可:把 connection superseded 加进 isAbortLike 列表;或删掉 same-session 子句、只保留连接身份判断(这与设计文档一致,且仍覆盖重连风险)。需要说明的是,抛错确实意外阻止了一个更糟的既有 bug —— 成功路径会把上一回合的文本归档进刚切换过去的新会话 —— 所以抛错不是错的,把它作为错误呈现给用户才是。
  2. 七个 companion 守卫出厂时没有任何测试能发现它们被删掉。 companion 侧变异矩阵 12 killed / 7 survived(CLI 侧 7/7 killed):五个入站回调身份检查、initialize 之后的 supersede 抛错、以及上面那个 same-session 子句。守卫本身是正确的 —— 其中两个在真实 wire 上被驱动过,行为完全符合预期。按 AGENTS.md 这是 Suggestion、不是拦截项,但这正是我不愿在 acpConnection / acp-integration(本仓库回滚相关性最高的两条路径)上、缺少那个唯一缺失 fixture 就批准的原因。
  3. Windows 未经验证,而 Windows 正是 [Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 的全部意义所在。 Tested-on 表格写的是"仅 mock 的进程树测试";真实执行环境只有 macOS;Linux 未在本地测试。阶梯测试用的是假定时器、硬编码 pid 的 mock child 和被 mock 的 platform,因此无法展示 taskkill /f /t 能否清掉一棵真实 ConPTY 树。

**能让我在下一轮直接批准、不附加新条件的是:**按任一条路径修掉第 1 项,并补上缺失的 fixture(同一连接、sessionId 已变、prompt 正常 resolve)。我不会因为第 2 项、75 秒的注释、或被 force-add 的 .qwen/e2e-tests/ 文件而压住这个 PR —— 那些是后续项;而且这个 PR 已经走了足够多轮,本仓库自己的指引就是只落 Critical 修复、其余转后续。

对一小时前三条评论的两处更正,以免有人照着过期版本行动:75 秒宽限是低于 CLI 的推导最坏情况,而不是高于(SESSION_DRAIN_TIMEOUT_MS = 30_000 会在四个以上位置串行消耗,因此是 ≥120 秒对上 75 秒)—— 请不要收紧那个窗口;以及该 head 上已经存在一份完成的 /verify 报告(5643495159,结论 ❌ findings,62/0/62),第 1、2 项就来自它。第三个运行(34681193735)仍在进行,只发了占位评论。

另外留给接手的人,记在这里以免丢失:当 session 处于活跃状态时 SessionEnd hook 完全不触发 —— head 与 base 对称、回路中没有本 PR 代码,所以是既有问题、不是本 PR 的缺陷;但它限定了本 PR 的宣称收益,因为用户关闭面板时所处的正是一个活跃会话。值得单独开一个 issue。

关于这条 @ 是怎么找到你的:本环境下确定性的 owner 解析脚本无法运行,因此按文档规定的回退链,落到了本 PR 最近一位人类评审者身上。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on c0aaaca71346e9cf208f2db25c629a71a2b2e4a0 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 c0aaaca71346e9cf208f2db25c629a71a2b2e4a0既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

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

上方各阶段评论已更新为最新结果。查看工作流运行

@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 — Test (windows-latest, Node 22.x) was skipped in CI and its suite did not run locally, so this diff's Windows-only escalation branch (the taskkill /f /t rung and the non-detached spawn) was never executed on Windows.

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

Not reviewed: build-and-test — test-efficacy probe: the kit tripped scripts/vitest-global-setup.js's prerequisite guard in every packages/cli probe tree (harnessValidated null, every probe inconclusive: no-output), so no mutant or revert was measured for the CLI package; the verifiers' hand-built mutation runs are this review's only efficacy evidence.

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

Test Plan (not a blocker): 10 passed — this review observed 30884, 577 passed.

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

  • packages/vscode-ide-companion/src/services/acpConnection.ts:511 — [probe] 10 of 13 request methods lack the superseded-response guard the design doc states unconditionally; a retired CLI's setMode answer drives the replacement's UI posture
  • packages/vscode-ide-companion/src/services/acpConnection.ts:742 — [probe] no cap on how many retired children can drain concurrently; three connect cycles leave three live process-group leaders with no registry

Convergence: round 3 posted 12 inline comment(s), 8 of them reported for the first time; the previous round posted 14 (9 new). Findings keep coming back to the same files: packages/vscode-ide-companion/src/services/acpConnection.test.ts (findings in rounds 1, 2; 3 more now); packages/vscode-ide-companion/src/services/acpConnection.ts (findings in rounds 1, 2; 3 more now). 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. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and its suite did not run locally, so this diff's Windows-only escalation branch (the taskkill /f /t rung and the non-detached spawn) was never executed on Windows.

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

未审查(原文为英文):build-and-test — test-efficacy probe: the kit tripped scripts/vitest-global-setup.js's prerequisite guard in every packages/cli probe tree (harnessValidated null, every probe inconclusive: no-output), so no mutant or revert was measured for the CLI package; the verifiers' hand-built mutation runs are this review's only efficacy evidence.

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

Test Plan(非阻断):10 passed — this review observed 30884, 577 passed

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

收敛情况:第 3 轮发布了 12 条行内评论,其中 8 条是首次提出;上一轮发布了 14 条(其中 9 条首次提出)。发现反复回到同一批文件:packages/vscode-ide-companion/src/services/acpConnection.test.ts(第 1、2 轮已出过发现,本轮又有 3 条);packages/vscode-ide-companion/src/services/acpConnection.ts(第 1、2 轮已出过发现,本轮又有 3 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread docs/design/vscode-acp-graceful-shutdown.md Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.test.ts
Comment thread packages/cli/src/utils/cleanup.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.test.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts Outdated
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts
yiliang114 and others added 3 commits September 12, 2026 18:06
A cancelled SessionEnd hook resolves fireSessionEndEvent to undefined instead of rejecting, so Promise.allSettled never observed it and the CLI exited 0 as though every hook ran. Detect the abort signal and surface it as a shutdown failure (non-zero exit). Also reset the in-flight exit-cleanup promise in the test helper so it cannot leak across vitest cases.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmty6iqs0zv
…ion switch

Dropping the session-id axis from the superseded-connection guard so a turn that completes after the user switches sessions on the same live connection resolves instead of being reported as a hard failure. Also remove the now-write-only lastExitCode/lastExitSignal fields and the assertions maintaining them.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmty6iqs0zv
Pin four Suggestion-level coverage gaps deferred in the #11642 review:

- assert the stdin EPIPE guard installs an 'error' listener (R3-4)
- reset spawnMock per case and pin 'detached' on both platforms (R3-5)
- cover the exit-handler ownership split: onDisconnected fires only for the current child, and the failed-start message pins exit code/signal (R3-6)
- cover the taskkill error-callback fallback to child.kill() (R3-7)

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

Patrol-Run: qwen-pr-closeout/jmty8nwk9zy

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

COMMENT

核对基线:head 5914facdae5389f04e3c54bf9325cd18db339595。本次结论是 COMMENT 而非 APPROVE,原因只有两条,都在下面第 2、3 节;第 1 节的历史 Critical 我已逐条确认修好。

1. 最近一轮的两条 Critical:在当前 head 上确认已修复

上一轮 review(2026-09-12T08:20:50Z,针对 c0aaaca7)只留下两条 Critical,当前 head 之后又有 3 个 commit,我在 5914facd 上对代码复核:

  • R3-1(sendPrompt 的守卫把「同一连接上切换会话」误判为连接被取代,导致一次成功的 turn 被报成硬失败)—— 已修复。 acpConnection.ts:518-541 现在捕获 const promptSessionId = this.sessionId 只用于发起请求,await 之后的守卫改成比较连接身份 if (this.sdkConnection !== conn),不再比较 session id。:531-535 的注释也把理由写清楚了:newSession 会重新赋值 this.sessionId 但不替换 sdkConnection,所以把 session 比较折进这个守卫会把成功完成的 turn 误报成 connection superseded。这正是该发现要求的修法。
  • R2-3(30s abort 预算取消 SessionEnd hook 时是静默的,Promise.allSettled 观察不到,CLI 以 0 退出仿佛所有 hook 都跑过)—— 已修复。 acpAgent.ts:2971-3006 现在在 Promise.allSettled 收集完 rejected 之后,额外直接检测 if (controller.signal.aborted),并 failures.push(new Error('SessionEnd hook did not complete within 30s (cancelled)')):2995-3002 的注释准确复述了该发现的机制(被取消的 hook 返回 { success: false, outcome: 'cancelled' } 而不 reject,因此 allSettled 看不到),补上的这条让取消的 hook 仍然进入失败通道,而不是让进程以 0 退出。

其余更早轮次的 Critical(R1-2、R1-12、R2-1、R2-2 等)在当前 PR 上均为 isResolved: true,且最近一轮 review 重新审过后只把 R2-3 一条以「still stands」重新提出,没有再重提它们。当前 53 条线程里只剩 4 条未解决,全部是 Suggestion 级(R3-2、R2-4、R1-18、R2-9),按本渠道策略不作为合入门禁。

2. 未完成的门禁:对重构后 diff 的独立 Critical 扫描

这是本次不 Approve 的主要原因。本 PR 自上一轮完整 review 之后被大幅重构过:变更文件从 4 个增加到 9 个(新增 packages/cli/src/utils/cleanup.ts 及其测试、设计文档与 E2E 测试计划),行数从 +1621/-30 变为 +789/-88,acpAgent.tsacpConnection.ts 的实现路径都换过。当前 head 5914facd(10:44:07Z)之后没有任何 reviewer 审过。

我在本次预算内完成的是:上述两条 Critical 的定点复核,以及 sendPrompt 与 SessionEnd hook 收集器两处调用链的阅读。没有完成的是对整份重构后 diff 的独立 Critical-only 扫描 —— 特别是新纳入的 packages/cli/src/utils/cleanup.ts 改动(上一轮 R3-3 指出该文件新增了第二份模块私有状态,而对应 helper 的文档没有同步)、disconnect() 的两档升级阶梯在新实现下的时序算术,以及 Windows taskkill 分支(CI 的 Test (windows-latest, Node 22.x) 为 skipped,上一轮也明确披露该分支从未在 Windows 上执行过)。这些我未能在预算内取得可核查的结论,因此按「无法确认」处理,不作为已通过。

3. 当前 head 上 Lint & Static 为 fail

Lint & Static (ubuntu-latest, Node 22.x) 在当前 head 上失败,原因是 Prettier 格式检查:

[warn] packages/cli/src/acp-integration/acpAgent.ts
[warn] Code style issues found in 1 file. Run Prettier with --write to fix.

这是本 PR 自己改动的文件,归因明确。按本渠道策略,格式类失败不构成 Critical(它不暴露正确性、安全性、数据损坏或回归),所以我没有把它当作阻塞问题;但它是一个 required check 处于红色,实际会挡住合入,且说明最后几个 commit 推送前没有跑过仓库自带的格式检查。请执行 npm run format 后重新推送。

CI 其余部分:Test (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox)Desktop ShellReal daemon E2E 均 pass;web-shell E2E Smokereview-pr 仍 pending,按策略不作为门禁。

下一步

npm run formatLint & Static 转绿;之后我可以在新的 head 上补完第 2 节列出的那部分独立扫描。第 1 节的两条 Critical 已确认修好,无需再动。

@qwen-code-dev-bot qwen-code-dev-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.

已核对 head 5914facd(vs origin/main merge-base)。本次不能 approve,原因是一条硬门禁:

  • Lint & Static (ubuntu-latest, Node 22.x)Run Prettier 这一步失败,packages/cli/src/acp-integration/acpAgent.ts 未格式化。我用仓库里的 prettier 在 head 上复现了同样结果(Run ESLint 是过的,所以只有格式这一项)。

另外两处 P3 见 inline,都不阻塞。

不能判定的部分我如实说明:CI 的 windows / macos job 被跳过,所以 Windows-only 的 taskkill /f /t 那一级和非 detached spawn 没有 CI 覆盖,我没有本地环境验证;文档里那条 Verification 断言与两条升级路径的对应关系我也只做了阅读比对(既有 unresolved 线程 docs/design/vscode-acp-graceful-shutdown.md:27 说的就是这件事)。

Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/vscode-ide-companion/src/services/acpConnection.ts

@qwen-code-dev-bot qwen-code-dev-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.

REQUEST_CHANGES

已核对 head 5914facdae5389f04e3c54bf9325cd18db339595(vs merge-base 4c072e88a5)。

阻塞:本 PR 引入的格式违规,Lint & Static

失败步骤是 Run Prettiernode scripts/lint.js --prettier):

[warn] packages/cli/src/acp-integration/acpAgent.ts
[warn] Code style issues found in 1 file. Run Prettier with --write to fix.

确认是本 PR 带进来的:我把该文件的 head 版本与 merge-base 版本分别按仓库配置跑了 prettier —— merge-base 版本 prettier-clean,head 版本有 1 处不符,位置在 acpAgent.ts:3001 附近(new Error('SessionEnd hook did not complete within 30s (cancelled)') 这一行超出 80 字符宽度,需要折成多行)。跑一次 npm run format 即可。

我上一版两条阻塞项的现状(在同一 head 复核)

  • drain 记忆化:已修好。acpAgent.ts 现在写成 drainPoolPromise ??= agentInstance.shutdownMcpPool(8_000); await drainPoolPromise;try/catch每次调用外侧,吞错与 strict 抛错由各调用点自己决定 —— 于是「strict 档被首次宽松调用中和」与「首次 strict 失败外溢给后续 joiner、跳过 disposeSessionsOnce()」两条都不再成立。
  • 两档时序:从 45s/10s 提到 SHUTDOWN_GRACE_MS = 75_000 / SIGTERM_GRACE_MS = 75_000acpConnection.ts:46-47)。第二档 75s 已能覆盖我算过的信号路径最坏预算(dispose 30s + drain 8s + cleanup 5s = 43s)。第一档仍有一个未被回答的问题fireSessionEndOnce 是按每个活跃会话的 Config 顺序 await 的,单次 hook 预算 DEFAULT_HOOK_TIMEOUT = 60000,所以两个以上会话的默认超时 hook 就能超过 75s;这与 #11510 的「No constant works」是同一问题。请明确取舍(要么给出会话数上限下的推导,要么把升级判据从墙钟换成卡死证据),不要用注释里的算术替代它。

本 head 上仍未处理的事项

  • 我账号在该 head 上还留有两张未回应的 P3:acpAgent.ts:3002(注释承诺被取消的 SessionEnd hook 会「non-zero exit」,但抛出被 managedConfigs && 挡住、非 managed 的 SIGTERM 路径最终 process.exit(0),而 VS Code companion 走的正是非 managed 分支)与 acpConnection.ts:536onEndTurn 不带会话身份,放开 guard 后切会话期间 A 的在途 turn 结束会给当前显示的 B 发一次提前的 streamEnd/idle)。请各给一个处置:修或写明为何不改。
  • 另有 4 条 Suggestion 线程处于未解决但作者标注延后:R3-2(设计文档 Verification 段与同文档 11 行前自相矛盾)、R2-4(两个 shutdown 预算只从单侧钉,可以各自缩到 0 仍全绿;跨包 75s 推导无人钉)、R1-18(无测试触达 ClientSideConnection 工厂,五道 inbound supersede 门全部未跑)、R2-9(initialize 之后的归属判定复合守卫无用例)。其中 R2-4 与上面的第一档推导是同一件事的两面,建议一起处理。
  • 机器人在本 head 的 review-pr 档仍在跑,其结论出来后若有新增阻塞,请一并处理。

CI 侧其余必需项目前是绿的(Test (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox)web-shell E2E Smoke 均 pass),本次拦的是格式 + 上面列出的待办。

chiga0
chiga0 previously approved these changes Sep 12, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking findings. Approval blockers: none.

Scope: acpAgent.ts, cleanup.ts, acpConnection.ts + their tests + design docs. NOT reviewed — native Windows process-tree kill behavior (mocked tests only per PR test plan), real VS Code extension host session lifecycle, and the underlying ConPTY leak (#11623).

Checked:

  • Promise memoization for overlapping shutdown paths (acpAgent.ts): drainPoolPromise ??=, disposeSessionsPromise ??=, and sessionEndPromise IIFE correctly ensure each cleanup phase runs exactly once. ??= is atomic in single-threaded JS, so no TOCTOU gap.
  • Stale-child guards cover all write-state paths (acpConnection.ts): ownChild captured at setupChildProcessHandlers entry; all state-mutating callbacks (sessionUpdate, requestPermission, readTextFile, writeTextFile, extNotification, newSession, sendPrompt, loadSession) verify this.sdkConnection !== conn / this.child !== ownChild after await points. Post-initialize double-check closes the race between initialize response and child replacement.
  • Process group management: detached: process.platform !== 'win32' correctly creates a new process group on POSIX (libuv setsid()); negative-PID process.kill(-childPid, ...) targets the group. Windows uses taskkill /t /f /pid via absolute C:\Windows\System32 path with SystemRoot env-var fallback — correct.
  • Escalation timer lifecycle: child.once('exit') clears both graceTimer and killTimer; the child.exitCode !== null check still correctly skips escalation if the child already exited before the listener registered — no timer leak.
  • SessionEnd abort budget: 30s AbortController with timeout.unref() is correct; Promise.allSettled + explicit controller.signal.aborted check after settlement catches hooks that resolve (not reject) on cancellation.
  • runExitCleanup deduplication: exitCleanupPromise memoization with finally clearing is correct.
  • shuttingDown guard: synchronous if (shuttingDown) return; shuttingDown = true prevents re-entry from concurrent SIGTERM/SIGINT delivery.
  • Test coverage: comprehensive — shared cleanup pass, SIGTERM+IDE overlap, 30s abort budget, POSIX group escalation, POSIX fallback, Windows taskkill, Windows taskkill failure degradation, normal exit cancellation, stale exit handler, stale responses for newSession/sendPrompt/loadSession, session-switch on same connection.

Needs a human (not blockers, bounded impact, out of static-reach):

  1. Read-only methods (rewindSession, restoreSessionHistory, authenticate, listSessions, deleteSession, renameSession, cancelSession, switchSession, setMode, setModel, getAccountInfo) lack post-await sdkConnection identity checks (unlike newSession/sendPrompt/loadSession). If the connection is superseded during one of these awaits, the response is silently consumed. Low impact for truly read-only methods, but cancelSession on a stale connection could theoretically target a wrong session's work on the CLI side.
  2. Native Windows process-tree termination (ConPTY descendants, shell processes) remains a manual validation boundary per the PR's own test plan.

Reviewed with AI assistance.

The Lint & Static lane's Run Prettier step failed on this file at 5914fac with a single wrapping complaint. `npx prettier --write` produces exactly this rewrap of the `new Error(...)` argument and nothing else, and `--check` is clean afterwards; eslint on the file is clean too.

No behaviour change: the whole diff is whitespace inside one call's argument list, so the string the failure carries is byte-identical.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
yiliang114 and others added 2 commits September 12, 2026 20:28
The comment promised the abort detection makes a cancelled hook "surface as a
shutdown failure (non-zero exit)", but the throw ten lines below is gated on
`managedConfigs`, so an unmanaged shutdown records the failure, logs the
warning, and still exits 0. State both outcomes instead of only the one the
managed path delivers.

Comment only: the diff carries no non-comment line, and prettier and eslint
are clean on the file.

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

The bullet claimed "POSIX and Windows escalation target the complete child
tree". POSIX signals the child's process group, which does not reach a
descendant that created its own group via setsid(); Windows walks the tree
via taskkill /f /t but degrades to child.kill() (direct child only) when
taskkill fails. Align both language versions with the Decision section and
the code.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtycy85205
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 42 passed · 0 failed · 42 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:42 通过 · 0 失败 · 42 总计

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11642 deep verification (round 4) — fix(vscode): shut the ACP CLI down gracefully instead of killing it

Verdict: findings — the central claim is proven load-bearing again at a new head, and the round-3 Major finding is fixed and measured fixed on a real wire. Two carried Suggestions stand: the PR's defining property (a 75-second graceful window) is still not pinned by any test — collapsing it to 0 ms leaves 29/29 green — and six inbound stale-connection guards still ship unpinned. One description correction: the Reviewer Test Plan's test counts are stale again.

Scripted assertions: 42 pass / 0 fail / 42 total (assertions.json, recomputed from the raw cell logs, not hand-tallied). fail: 0 — base-arm control cells encode the expectation that the base arm misbehaves, so a base arm behaving as predicted counts as a pass.

Verified head: ebbeffecda0e0a8d1f5134df5e01325f307ab5ed (git rev-parse HEAD^2). Base: 8a21551599b7dc15bcc2b8ed0ffb4e151cc75f31 (HEAD^1). Merge: ea6866f3f26bc9ad1c7a81c3ca4ab34bf3b9060e.

The tree moved since round 3. Round 3 verified head c0aaaca7 on base ee6003f5; this round verifies ebbeffec on base 8a215515. Six commits landed on top (b462fdfa, 7f8c1182, 5914facd, 02a4908e, 42be1006, ebbeffec), three of which are direct responses to round-3 findings. Every number below is a fresh run at this head; nothing was carried forward on an identity argument.

中文摘要

结论:findings(有发现)。 断言 42 通过 / 0 失败 / 共 42。验证 head ebbeffec,base 8a215515被测代码本轮真的变了:上一轮的 head 是 c0aaaca7、base 是 ee6003f5,本轮之上又落了 6 个 commit,其中 3 个是对上一轮发现的直接回应。

A/B 结论(中心主张在新 head 上第四次成立)。 真实 CLI(packages/cli/dist/index.js --acp)+ 真实 ndjson 协议,只替换编译后的 AcpConnection(head / base 两个 esbuild bundle,INTERNAL_QWEN_CODE_DEPS 双侧均为 0,base bundle 对 5 个 head 判别串命中全 0,base 侧 5 个自有源文件全部解析进 base 树、解析进 head 树 packages/ 的文件数为 0)。head 侧子进程 进程组组长且宿主关闭了 stdin 而没有 kill;base 侧不是组长且直接 kill、从未关 stdin——两个 oracle 各 2/2 翻转,两臂退出码均为 0。见下表 H1 与捕获 01-…png

上一轮 Major 发现已修复,且是实测修复。 三臂对照(base / 上一轮 head c0aaaca7 / 本轮 head):prompt 在飞行中时于同一连接loadSession('sess-B'),上一轮 head 抛 -32603 "Internal error: connection superseded" 且不触发 onEndTurn;本轮 head resolve end_turn 并触发 onEndTurn,与 base 逐项一致。同时守卫没有被修坏:重连场景下本轮 head 仍然拒绝过期 prompt(-32603),且替换连接可正常使用(end_turn),而 base 在重连时把旧子进程之死归因给新连接(failed to start (exit code: 143) + 后续 Not connected to ACP agent)。另实测:丢弃同 session 子句没有引入新的错归属——切换后送达的 sess-A 更新在 head 与 base 上逐字节相同。见 H7 与捕获 02-…png

发现(3 项,均为 carried)。

  1. 75 秒优雅窗口的时长仍完全无测试钉住。 变异矩阵 21 项:12 killed / 9 survived。M18(SHUTDOWN_GRACE_MS 75s→0ms)、M19(SIGTERM_GRACE_MS 75s→0ms)、M20(75s→37.5s)全部 SURVIVED,29/29 逐字全绿5914facd 新增的 3 个测试没有钉住任何一个时长。
  2. 6 个入站守卫仍零有效覆盖。 M3–M8(sessionUpdate / requestPermission / readTextFile / writeTextFile / extNotification 五个入站回调守卫 + initialize 之后的 supersede 检查)删除后 29/29 全绿。部分改善:上一轮同为 survived 的 M11(loadSession)本轮已 KILLED,M9/M10 亦 KILLED,红色测试名即 does not apply session or prompt responses from a retired connection
  3. 描述中的测试计划计数再次过期(Correction):正文写 Companion 26/26、CLI 709/709,实测为 29/29719/719acpAgent.test.ts + cleanup.test.ts 合计)。

阳性对照 CTRL-positive(disconnect 完全不关 stdin)KILLED,同文件内 2 个测试变红,故上述绿色是测量结果而非空跑;21 个 cell 每次之后都以 sha256 复核源文件已还原。

未覆盖范围。 SessionEnd reason oracle 本轮探针不可用(4 个 cell 双臂各触发 0 次,构成 A/A 对照:CLI 本身在每个 cell 都成功启动并完成了 session/new,故是探针问题而非回归;上一轮该 oracle 实测 prompt_input_exit vs other);Windows 全部路径;托管父进程分支(含 b462fdfa 新增的 throw);真实 VS Code 扩展宿主;H2 升级阶梯的 75s/150s 实测时长(本轮仅由 M13–M17 在单元层钉住);仓库级 lint / prettier / eslint(本轮未跑);与当前 main 的试合并。

Previous-finding status (this is a follow-up round)

Re-measured at the new head ebbeffec; every number below is a run from this round.

# prior finding prior severity status at ebbeffec evidence (this round)
1 Seven companion guards ship with zero effective test coverage Suggestion partially fixed — 6 of 7 stand M9/M10/M11 now KILLED (1 failed | 28 passed (29), red = "does not apply session or prompt responses from a retired connection") — that is 5914facd working. M3–M8 still SURVIVED at Tests 29 passed (29) exit 0. Positive control CTRL-positive KILLED, 2 red in the mutated file.
2 sendPrompt's same-session clause turns an ordinary conversation switch into a user-visible error popup Major FIXED H7, three arms on a real wire: r3/navigateREJECTED -32603 "Internal error: connection superseded", onEndTurn not fired; head/navigateresolved end_turn, onEndTurn(end_turn) fired; base/navigateresolved end_turn. Head matches base on the oracle. The author took exactly the resolution round 3 suggested (drop the same-session clause, keep connection identity).
3 SHUTDOWN_GRACE_MS = 75 s below the CLI's derived worst-case wind-down Suggestion stands (static ceiling, unchanged) Constants still 75_000 / 75_000 (acpConnection.ts:42-43). No shutdown exceeding 75 s was produced; H1's measured head shutdown was 50–100 ms.
NEW r3 SHUTDOWN_GRACE_MS's duration is unpinned (75 s → 0 ms leaves the suite green) Suggestion stands, and the gap is wider than reported M18 (SHUTDOWN_GRACE_MS→0), M19 (SIGTERM_GRACE_MS→0) and M20 (→37 500, a factor-of-two error) all SURVIVED at 29 passed (29). 5914facd added 3 tests and pinned none of them.
C1 "Before, disconnect terminated the ACP child immediately" is Windows-only, not POSIX Correction stands H1 base/no-session: child killed = YES, stdin closed = no, exit code=0 in 50 ms — base does terminate immediately on POSIX too, so the description's sentence is accurate for the companion path.
C4 Test-plan counts wrong Correction regressed — wrong again Measured 29/29 and 719/719 against the description's 26/26 and 709/709.
O1 SessionEnd hooks do not fire when a session is live Observation (round 2) not re-measurable this round My hook probe produced 0 fires on both arms in all 4 cells (A/A control) — see Not covered. Round 3's refutation of O1 is not contradicted; it simply is not re-confirmed here.

No prior finding worsened in behaviour. One prior correction (C4) regressed, and one prior Suggestion (the unpinned grace window) now has a wider measured gap.

Scope chosen

Central claim disconnect() makes the CLI shut itself down (host closes stdin, no kill) and spawns the child as a process-group leader so the bounded escalation ladder can reach the whole tree.
Secondary claim 1 Narrowing sendPrompt's guard to connection identity fixes the false "connection superseded" on an ordinary session switch without weakening the reconnect hazard the guard exists for.
Secondary claim 2 The new tests in 5914facd close the coverage gaps round 3 reported.
Delta probes b462fdfa's new "fail shutdown on a cancelled SessionEnd hook" throw — which path can actually reach it; and the mis-attribution consequence round 3 warned the dropped clause had been accidentally preventing.
Out of scope Windows, managed-parent shutdown, real VS Code extension host, repo-wide lint, trial merge — all under Not covered.

Central claim + A/B

H1 — real CLI, real wire, only AcpConnection differs

Both arms spawn the same real CLI build (packages/cli/dist/index.js --acp --channel=VSCode) through the real AcpConnection.connect(); cliEntryPath is the seam. Only the compiled AcpConnection differs, produced by bundle-acpconn.mjs (esbuild) from each tree with vscode aliased to vscode-stub.mjs — the only stub in the loop.

Control cleanliness, all scripted assertions:

check head base r3
INTERNAL_QWEN_CODE_DEPS in bundle 0 0 0
75e3 (grace constants) 2 0 2
detached 1 0 1
taskkill 2 0 2
connection superseded 14 0 14
stdin.end() 1 0 1
own source files resolved inside own tree 85 5
files resolved into the head tree's packages/ 0
node_modules roots used 1 (/__w/qwen-code/qwen-code) 1 (same)

The resolvedIntoHEADTreePackages: 0 row is the one that matters: acpConnection.ts imports no @qwen-code/* package, so the workspace-symlink trap does not apply, and the base arm's five own files (logger.ts, errorMessage.ts, acpFileHandler.ts, acpSchema.ts, acpConnection.ts) all resolved inside tmp/base-tree/. git diff --stat HEAD^1..HEAD -- package.json package-lock.json is empty, so sharing the root node_modules is a clean control. Raw: logs/provenance-base.json, logs/bundle-*.json.

Oracles: (1) whether the host closed the child's stdin, read at the instant disconnect() returned; (2) whether the host killed the child (ChildProcess.killed); (3) the child's pgid read from the kernel with ps -o pgid= (Node has no process.getpgrp()).

cell group leader stdin closed by host child killed by host SessionEnd fires exit ms
head / no-session YES (pid=pgid=87852) YES no probe unavailable 0 50
base / no-session no (pid=87904 pgid=87843) no YES probe unavailable 0 50
head / live-session YES YES no probe unavailable 0 100
base / live-session no no YES probe unavailable 0 101

Flip: 2/2 on the stdin oracle; 2/2 on the group-leader oracle; 2/2 on the killed oracle. This is the central claim measured directly: head closes stdin and does not kill, base kills and never closes stdin. Witness: 01-h1-central-ab-real-cli-flips.png. Raw: logs/h1-results.json, logs/h1-run.log.

The SessionEnd-reason column is blank because my probe did not fire on either arm — see Not covered; the 0/0 symmetry across all four cells is the A/A control that makes this a probe failure rather than a regression.

H7 — three arms on a real wire: did the Major fix land, and did it cost anything?

Because history was deepened this round (git fetch --depth=40, which succeeded — all 18 snapshot commit OIDs are now present locally), I could build a third arm from round 3's head c0aaaca7. That separates "the PR works" from "this commit is what fixed it".

Peer = a real @agentclientprotocol/sdk AgentSideConnection over real ndjson pipes, spawned by the real AcpConnection (PEER_MODE=navigate holds session/prompt until a session/load arrives, then answers end_turn and emits an agent_message_chunk for the session that owned the prompt). In-flight ordering is proven at the destination: the peer writes a marker file when it is holding the prompt, and the harness will not issue the switch until that file exists (marker=prompt-held:sess-A asserted in all six cells).

arm scenario prompt outcome onEndTurn post-switch updates reconnect replacement conn
base navigate resolved end_turn end_turn 1 (sess-A)
r3 navigate REJECTED -32603 connection superseded (none) 0
head navigate resolved end_turn end_turn 1 (sess-A)
base reconnect ERR failed to start (exit code: 143) REJECTED Not connected
head reconnect REJECTED -32603 connection superseded (correct) ok resolved end_turn

Flip: 1/1 on the navigate oracle, r3 → head. Round 3's Major finding reproduces exactly on the r3 arm and is gone on head. And the narrowing did not gut the guard: on reconnect, head still rejects the stale prompt with -32603, establishes the replacement cleanly, and leaves it usable — while base misattributes the old child's SIGTERM to the replacement and ends up with no working connection at all. That is the ownChild binding paying off, measured end to end.

The mis-attribution worry round 3 raised is measured, and bounded. Round 3 noted the same-session clause "accidentally prevents a worse pre-existing bug (the success path would file the old turn's text under the new conversation)". With the clause dropped, I measured it: the peer emits sess-A content after the switch to sess-B, and head delivers exactly what base delivers — [{"sessionId":"sess-A","text":"answer-for-sess-A"}] byte-identical on both arms, updatesAfterSwitch=1 on both. So the PR introduces no new mis-attribution; whatever the webview does with a sess-A update while showing sess-B is pre-existing behaviour, unchanged by this diff. The update carries its own sessionId, which is what makes it the webview's problem and not this PR's. Witness: 02-h7-navigate-fix-three-arms.png. Raw: logs/h7-results.json, logs/h7-run.log.

Corrections

Corrections to earlier descriptions, not requests to change code.

  1. The Reviewer Test Plan's counts are stale (again). It states "Companion ACP tests 26/26, CLI ACP tests 709/709, and cleanup tests 10/10". Measured at this head: companion acpConnection.test.ts 29/29 (5914facd added three), and acpAgent.test.ts + cleanup.test.ts together 719/719. Round 3 recorded this same correction as fixed; the two commits after it moved the numbers again. Worth one edit before merge so a reviewer running the plan does not think they are on the wrong commit.
  2. The design doc's escalation bullet was narrowed correctly, and now matches what I measured. ebbeffec changed "POSIX and Windows escalation target the complete child tree" to "POSIX escalation targets the ACP child's process group; Windows escalation targets the process tree via taskkill /f /t, degrading to the direct child if taskkill fails." That is an accurate description: H1's group-leader oracle confirms the POSIX half is a process group, not a tree walk, and the Windows half is unverifiable here. This is a correction in the PR's favour — recorded so the next reader does not re-derive it.
  3. b462fdfa's headline does not reach this PR's own scenario. "fail ACP shutdown when a SessionEnd hook is cancelled" adds if (controller.signal.aborted) failures.push(...), and the throw new AggregateError(...) below it is gated on managedConfigs. managedConfigs is passed only by shutdownManagedAgent, which returns early unless agent.isTrustedManagedParent() — i.e. privateParentState === 'trusted', which requires a matching PRIVATE_PARENT_CAPABILITY_META_KEY in the initialize _meta (acpAgent.ts:4978-5003). The companion's initialize call sends no _meta (acpConnection.ts:388-395), and the capability is minted by packages/acp-bridge / packages/channels/base/src/AcpBridge.ts, not by the VS Code extension. So on the companion path the new commit adds a debugLogger.warn line only; the non-zero exit applies to the bridge-managed CLI. The commit's own code comment already says this precisely ("the throw below is gated on managedConfigs") — recording it because a reviewer reading the subject line alone would expect the companion's exit code to change. Static trace, not measured live (the managed path needs a bridge parent this container does not set up).
  4. Head adds an unhandled-rejection swallow that base lacks. void processExitPromise.catch(() => {}) appears 1× in the head and r3 bundles and in base. rejectOnExit fires on every child exit including a retired one (acpConnection.ts:180-185), so without the swallow a late exit from a replaced child is an unhandled rejection. I observed exactly that crash base-side during harness development before I made the peer flush its answer; after the fix, count=0 on all arms, so this is a static improvement, not a measured one.

Mutation matrix

21 mutations of packages/vscode-ide-companion/src/services/acpConnection.ts, each neutering exactly one guard the PR introduces, each running acpConnection.test.ts unchanged. Every anchor pre-validated unique (anchors validated: 21/21 unique, 0 bad); the file was restored and its sha256 re-verified after every cell (all files restored: true).

verdict count mutations
KILLED 12 M1, M2, M9, M10, M11, M13, M14, M15, M16, M17, M21, CTRL-positive
SURVIVED 9 M3, M4, M5, M6, M7, M8, M18, M19, M20

Every load-bearing hunk is pinned, and each kill names the exact red test: detached (M13 → "creates a POSIX process group for shutdown escalation" + "does not detach the ACP child on Windows", 2 red), group SIGTERM (M14) and group SIGKILL (M15) → "escalates to the POSIX process group after both grace periods", taskkill /t (M16) → "uses taskkill for an unresponsive Windows process tree", escalation-cancel (M17) → "cancels escalation when the child exits normally", both child-identity guards (M1, M2), all three response-path supersede throws (M9, M10, M11), and the central claim itself (M21, reverting disconnect() to child.kill()4 red).

Positive control: CTRL-positive (disconnect never closes stdin) KILLED with 2 red tests landed in the mutated file"disconnect closes stdin before escalating" and "disconnect closes stdin even when the child has no pid". So the nine greens are a measurement, not a dead harness. Witness: 03-mutation-matrix-companion.png. Raw: logs/mutation-matrix-companion.log, parsed to logs/mutation-matrix-companion.json.

Survivors, classified per the taxonomy:

  • M3–M8 → coverage gaps. The five inbound-callback guards (sessionUpdate, requestPermission, readTextFile, writeTextFile, extNotification) and the post-initialize supersede check. Each is the only check on its path; no sibling hunk closes the same hazard, so these are not redundant defence. Each is reachable — the PR's own premise is that the old child can outlive the graceful window. Note the contrast with M9/M10/M11: 5914facd pinned the three outbound response guards and left the six inbound ones. Not dead code, not redundant — tests to write.
  • M18, M19, M20 → coverage gaps with the sharpest edge in this round. See Finding 1.

Gates

gate command result
companion suite (cd packages/vscode-ide-companion && npx --no-install vitest run src/services/acpConnection.test.ts) 29 passed (29), exit 0
CLI suites (cd packages/cli && npx --no-install vitest run src/acp-integration/acpAgent.test.ts src/utils/cleanup.test.ts) 719 passed (719), 2 files, exit 0
companion typecheck npm run check-types -w packages/vscode-ide-companion exit 0

Round 3's correction still applies and I re-confirmed the script names: npm run typecheck is typecheck --workspaces --if-present, and packages/vscode-ide-companion names its script check-types, so the repo-wide green does not compile acpConnection.ts. The command above does, and passes. Not run this round: repo-wide npm run typecheck, npm run lint, npm run format, and eslint/prettier on the changed files (round 3 ran those with planted-violation liveness proofs; the changed files here are a subset plus two docs lines).

Findings

1. The 75-second graceful window — the property the PR is named after — is still unpinned, and the gap is wider than round 3 reported — Suggestion (carried, stands)

cd packages/vscode-ide-companion && npx --no-install vitest run src/services/acpConnection.test.ts   # 29/29 green as shipped
node tmp/pr11642-verify-20260912-130232/mutate-companion.mjs M18 M19 M20                              # still 29/29, exit 0

acpConnection.ts:42-43 defines SHUTDOWN_GRACE_MS = 75_000 and SIGTERM_GRACE_MS = 75_000. Three separate mutations survive the full suite:

mutation change suite
M18 SHUTDOWN_GRACE_MS = 0 29 passed (29), exit 0
M19 SIGTERM_GRACE_MS = 0 29 passed (29), exit 0
M20 SHUTDOWN_GRACE_MS = 37_500 (a factor-of-two error) 29 passed (29), exit 0

M20 is new this round and is the one to notice: the suite cannot distinguish 75 s from 37.5 s either, so this is not merely "0 is a special case" — no duration is pinned at all. The escalation test advances fake timers past the deadline and asserts the signal, which any smaller deadline satisfies identically.

5914facd was titled "pin deferred ACP shutdown coverage gaps" and did close three gaps (M9/M10/M11 flipped from survived to killed), so this is not inattention — it is the one axis the new tests did not reach. Cheap fix, and it is a test, not code: assert that at advanceTimersByTime(74_999) no signal has been sent and at 75_000 one has. Without it, a maintainer retyping 75_000 as 75 gets a green suite and a CLI that is SIGTERM'd the instant the panel closes — the exact defect this PR exists to fix.

2. Six inbound stale-connection guards still ship with zero effective test coverage — Suggestion (carried, partially fixed)

node tmp/pr11642-verify-20260912-130232/mutate-companion.mjs M3 M4 M5 M6 M7 M8   # 29/29 green for each

The five inbound-callback this.sdkConnection !== wiredConnection checks and the post-initialize supersede throw, in acpConnection.ts. Bounded honestly, as round 3 did: the guards are correct — H7 drives two of the sibling guards on a real wire and they behave exactly as intended (stale prompt rejected, replacement unaffected). Nothing here is a correctness defect. The cost is that six shipped clauses can be deleted with a green suite, so the next refactor has no net under them. The asymmetry with M9/M10/M11 is the actionable part: the outbound responses are now pinned and the inbound callbacks are not, and they are the ones a retired child actually fires.

3. The Reviewer Test Plan's automated-coverage counts are stale — Correction

"Companion ACP tests 26/26, CLI ACP tests 709/709, and cleanup tests 10/10 passed" — measured 29/29 and 719/719 (acpAgent + cleanup together) at ebbeffec. Round 3 recorded this correction as fixed; 5914facd moved the numbers again. One-line edit.

Not covered

  • The SessionEnd-reason oracle — probe unavailable, with an A/A control. My command hook (cat >> $HOME/sessionend.jsonl, configured in a throwaway HOME/QWEN_HOME with security.folderTrust: false) wrote 0 bytes in all four cells on both arms. This is not a shutdown-path failure: the real CLI booted, completed initialize, and served session/new in every cell (head sessionId=337afd96-…, base sessionId=e3f91ef2-…), and the group-leader/stdin oracles flipped cleanly in the same runs. Because the zero is symmetric across arms, it is my probe (settings shape, hook registration, or trust gating in this container), not the PR — so those cells are excluded from assertions.json rather than counted red, and the reason oracle round 3 measured (prompt_input_exit vs other) is not re-confirmed at this head. What would have settled it: dumping the CLI's resolved hook registry, which the budget did not reach.
  • H2's escalation-ladder timings were not re-measured live. Round 3 measured SIGTERM at 75 070 ms and the SIGKILL rung at ~156 s against a wedged peer with a non-detached grandchild. This round pins the same hunks at unit level only (M13–M17 all KILLED, naming "escalates to the POSIX process group after both grace periods"). The live 150 s cell was cut for budget; the code it exercised is unchanged between c0aaaca7 and ebbeffec (asserted: disconnect() is byte-identical between round-3 head and PR head), but the base moved, so I am recording this as not re-run rather than carrying it forward.
  • Windows, entirely. taskkill /f /t, the WINDOWS_TASKKILL absolute-path resolution, the non-detached spawn — unreachable (process.platform !== 'win32'). M16 shows /t is at least pinned by a mocked test. The platform where the PR's stated orphaning actually bites remains unverified.
  • The managed / trusted-parent path, including all of b462fdfa's behavioural change. shutdownManagedAgent, isTrustedManagedParent(), beginManagedShutdown — never reached by the companion's plain spawn (Correction 3 traces why). The new AggregateErrorprocess.exit(1) behaviour is therefore unexercised by any measurement in this report, and no test in the 719 covers a cancelled SessionEnd hook on that path either (not verified by mutation; budget).
  • Real VS Code extension host. Finding 2's blast radius and the old popup path are wire-level and static reads, not watched UI.
  • Repo-wide gates. npm run typecheck, npm run lint, npm run format were not run; eslint/prettier were not run on the changed files this round (round 3 ran them with planted-violation liveness proofs). The two changed .md lines are therefore unlinted here — and round 3 established that prettier's markdown formatter accepts every mutation, so a clean prettier run would not have been evidence anyway.
  • Trial merge into current main. Not attempted. Note the snapshot's baseRefOid (4c072e88…) is now present locally after the deepening fetch, so this is a budget cut, not an impossibility — and it matters less than usual because HEAD^1 (8a215515) is recent.
  • Per-commit attribution is now possible but was not exercised commit-by-commit. Round 3 could not reach the individual commits (shallow: local rev-list returned 1 against 18 in the snapshot). This round a git fetch --depth=40 succeeded and all 18 snapshot OIDs resolve, so git rev-list HEAD^1..HEAD^2 returns 64 (18 PR commits + the merged main history). I verified the aggregate HEAD^1..HEAD diff and the c0aaaca7..ebbeffec delta, and attributed the three behavioural commits by reading them, but did not build and test each of the 18 separately.
  • Environment note (not a PR defect). .qwen/ is a read-only root-owned mount in this container, so the PR's tracked .qwen/e2e-tests/vscode-acp-graceful-shutdown.md shows as D in git status and could not be written. The file's content was read from the object store and is included in the diff under test; only the worktree copy is missing.
  • No injection attempts observed. PR title, body, commit messages and code comments were treated as untrusted input; none attempted to steer the verification.

Methodology

Everything ran in the CI verify container (node:22-bookworm, node v22.23.2, 64 cores, load average ~10–14) against the merge-ref checkout HEAD = ea6866f3, with npm ci / npm run build already completed by the job. Two scratch worktrees were created and removed: tmp/base-tree at HEAD^1 (8a215515) and tmp/r3-tree at round 3's head (c0aaaca7), the latter reachable only because git fetch --depth=40 origin ebbeffec… succeeded. All three arms' AcpConnection were compiled from source by bundle-acpconn.mjs (esbuild, absWorkingDir set to each tree) with only vscode aliased to vscode-stub.mjs; provenance2.mjs re-ran each build with metafile: true and asserted the resolved input set, because the first provenance script mis-resolved esbuild's tree-relative metafile keys against process.cwd() and reported five phantom head-tree leaks — that bug is recorded here because it briefly looked like a contaminated control. Harnesses h1-real-cli-ab.mjs (real CLI over real pipes, pgid read with ps -o pgid=) and h7-navigate-ab.mjs + h7-peer.mjs (a real AgentSideConnection peer, in-flight ordering proven by a destination-side marker file the harness polls before issuing the switch) drive the compiled connections; assertions are made on kernel-reported pgids, on ChildProcess flags read at the instant disconnect() returned, and on JSON-RPC errors a real SDK peer produced. Two harness bugs of my own were found and fixed mid-round (peer env vars set after connect() had already captured process.env; and the peer calling process.exit(0) on conn.closed before flushing its answer, which produced a bogus TIMEOUT and a misattributed unhandled rejection); both are recorded because each produced a wrong intermediate table. mutate-companion.mjs (21 mutations) patches one anchor at a time, restores, and re-verifies sha256. Per-cell stdout/stderr is in logs/; assertions.json was recomputed from those logs by a script that strips ANSI escapes (the first pass matched nothing and reported two phantom gate failures); scripts/verify-capture.mjs rasterised the three tables into evidence/.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/cli/src/acp-integration/acpAgent.test.ts: (cd packages/cli) npx --no-install vitest run ./src/acp-integration/acpAgent.test.ts
file packages/cli/src/utils/cleanup.test.ts: (cd packages/cli) npx --no-install vitest run ./src/utils/cleanup.test.ts
file packages/vscode-ide-companion/src/services/acpConnection.test.ts: (cd packages/vscode-ide-companion) npx --no-install vitest run ./src/services/acpConnection.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/acp-integration/acpAgent.test.ts: PPPPP
  packages/cli/src/utils/cleanup.test.ts: PPPPP
  packages/vscode-ide-companion/src/services/acpConnection.test.ts: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 1 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 1 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 2 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 2 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 2 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 3 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 3 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 3 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 4 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 4 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 4 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 5 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 5 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 5 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)

Evidence images

01-h1-central-ab-real-cli-flips

02-h7-navigate-fix-three-arms

03-mutation-matrix-companion

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on ebbeffecda0e0a8d1f5134df5e01325f307ab5ed — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 ebbeffecda0e0a8d1f5134df5e01325f307ab5ed既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

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

上方各阶段评论已更新为最新结果。查看工作流运行

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship — CI landed green after the review. ✅

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 52 passed · 0 failed · 52 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:52 通过 · 0 失败 · 52 总计

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11642 deep verification (round 5) — fix(vscode): shut the ACP CLI down gracefully instead of killing it

Verdict: findings — the central claim is proven load-bearing again on a real wire, and this round produced one new measured correction: on POSIX the pre-PR path did not bypass the CLI's cleanup, because the CLI already catches SIGTERM and shuts down gracefully. The two carried Suggestions stand (re-measured, not diffed), and Finding 1 now ships with a measured, ready-to-apply fix that kills all three of its surviving mutations. Nothing here is a blocker: every executed assertion passed.

Scripted assertions: 52 pass / 0 fail / 52 total (assertions.json, derived from the raw cell logs by check-assertions.mjs, never hand-tallied). fail: 0 — base-arm control cells encode the expectation that the base arm behaves the old way, so a base arm doing so counts as a pass. Of the 52, 47 are claims about the PR and 5 are harness-validity / probe-exclusion records (oracleD-validity 2, excluded-probe 3) — listed separately so the pass count is not read as 52 PR claims.

Verified head: ebbeffecda0e0a8d1f5134df5e01325f307ab5ed (git rev-parse HEAD^2). Base: 8a21551599b7dc15bcc2b8ed0ffb4e151cc75f31 (HEAD^1). Merge: ea6866f3f26bc9ad1c7a81c3ca4ab34bf3b9060e. Root tree: 34303c25084376b7055c4ed2b8ab1d454b23d40e.

⚠️ The tree did not move since round 4

Round 4 verified head ebbeffec on base 8a215515, merge ea6866f3. This round's checkout is the identical merge commit — same head OID, same base OID, same merge OID. Because git is content-addressed, an identical commit OID means an identical root tree hash, so every file round 4's measurements consumed (source, lockfile, config, fixtures) is byte-identical here. That is a stronger identity proof than the per-file closure the method asks for, and it is why no "the author changed X" narrative appears below.

I did not treat that as licence to diff the old report. Every number in this report is a fresh run in a fresh container against a fresh npm ci / npm run build, and the two carried findings were re-measured rather than quoted. Where I did carry a measurement forward (round 4's M4–M8 cells), it is labelled as carried and one member of that set (M3) was re-run as a spot-check.

What a fresh container can change is the environment, so the first thing this round established is that it did not: the two gates reproduce round 4's counts exactly (companion 29/29, CLI 719/719, both exit 0). That is the calibration the rest of the round rests on.

中文摘要

结论:findings(有发现,但无阻塞项)。 断言 52 通过 / 0 失败 / 共 52(其中 47 项针对 PR,5 项为 harness 有效性/探针排除记录)。验证 head ebbeffec,base 8a215515,merge ea6866f3

本轮被测代码与上一轮完全相同。 本轮 checkout 的 merge commit OID 与上一轮逐字符一致(ea6866f3)。git 是内容寻址的,commit OID 相同即根 tree 哈希相同(34303c25),因此上一轮所有度量所消费的每一个文件(源码、lockfile、配置、fixture)在本轮都是逐字节相同的。但本轮没有照抄旧报告:所有数字都是在新容器、新 npm ci / npm run build 下重新跑出来的;发现 1 的三个变异全部重跑,发现 2 重跑了其中一格(M3)作抽查、其余各格按"树完全相同"的证明 carry 并在正文中明确标注。两个门禁与上一轮完全吻合(companion 29/29、CLI 719/719,均 exit 0),这是本轮其余结论的校准基础。

A/B 结论(中心主张第五次成立)。 真实 CLI(packages/cli/dist/index.js --acp)+ 真实管道,只替换按 arm 编译的 AcpConnection(esbuild bundle;base bundle 自有源文件 5 个、从 head 树 packages/ 解析到的文件 0 个、未归属输入 0、5 个 head 判别串命中全 0;head 侧为 2/2/1/14/2)。Oracle A:head 子进程进程组组长(pgid==pid),base 不是(与宿主同组)。Oracle B:head 在 disconnect() 返回瞬间 stdin 已 end 且未 kill,base 未 end 且已 kill。两个 oracle 各 2/2 翻转。见下表与捕获 01-…png

本轮新发现(实测,上一轮未测到):POSIX 上旧路径并没有绕过 CLI 清理。 Oracle C 显示两臂子进程都以 code 0、无信号退出(各约 16 ms)。这不是 harness 失灵:正向对照中 SIGKILL 被如实报告为 signal: SIGKILL,证明该 oracle 能看见信号致死;而直接向真实 CLI 发 SIGTERM 同样得到 code 0。原因是 CLI 早就注册了 process.on('SIGTERM', shutdownHandler)(base 树 3114–3115 行,本 PR 未改动),该 handler 跑完整清理链后 process.exit(0)。所以 PR 描述里"Before, disconnect terminated the ACP child immediately and bypassed its normal cleanup"在 POSIX 上不成立(在 Windows 上成立,Node 的 kill() 是 TerminateProcess)。PR 在 POSIX 上的真实收益是:有界的升级阶梯(base 完全没有)、进程组范围信号、以及正确的 SessionEnd reason。详见 Corrections 第 1 条。

发现(2 项,均为 carried,均已重新度量)。

  1. 75 秒优雅窗口的时长仍完全无测试钉住,且本轮给出了实测过的修复:加一个测试即可同时杀掉 M18(75s→0)、M19(75s→0)、M20(75s→37.5s)三个存活变异;未变异源码下 30/30 全绿(零附带损伤)。见下表 Mutation matrix 与捕获 02-…png
  2. 六个 inbound 过期连接守卫仍无有效覆盖(M3 已在本容器重跑复现:29/29 全绿)。

未覆盖范围:SessionEnd reason oracle(两臂均 0 字节,[HOOK_REGISTRY] 0 hook entries,对称失效故排除,本轮定位到了失败原因)、75s/150s 真实计时、Windows 全部路径、managed/trusted-parent 路径、真实 VS Code 扩展宿主、仓库级门禁、向最新 main 的试合并。

Previous-finding status (round 4 → this head)

# Round-4 finding Sev Status at ebbeffec Re-measured how
1 The 75 s graceful window is unpinned — SHUTDOWN_GRACE_MS→0, SIGTERM_GRACE_MS→0, SHUTDOWN_GRACE_MS→37 500 all leave the suite green Suggestion stands — and now has a measured fix Re-ran all three mutations: M18/M19/M20 each 29 passed / 0 failed / exit 0. Then applied the candidate fix and re-ran: 30/30 green unmutated, and 1 red (the new test) under each of the three mutations.
2 Six inbound stale-connection guards ship unpinned (M3–M8) Suggestion stands M3 (the inbound sessionUpdate guard) re-run in this container: 29 passed / 0 failed / exit 0 — reproduces round 4. M4M8 carried on the identical-tree proof and labelled as such; the pin test from Finding 1 does not address them (M3+pin is still 30/30 green), so the two findings are independent.
3 Reviewer Test Plan counts are stale ("26/26, 709/709, 10/10") Correction stands Re-measured at this head: companion acpConnection.test.ts 29/29; acpAgent.test.ts + cleanup.test.ts 719/719 (709 + 10). The plan's 26/26 is still wrong.
4 Design doc's escalation bullet correctly narrowed Correction (in PR's favour) stands Re-read; Oracle A confirms the POSIX half is a process group (kernel-reported pgid), not a tree walk.
5 b462fdfa's headline does not reach the companion path (throw gated on managedConfigs) Correction stands Static re-read of acpAgent.ts shutdownHandler / shutdownManagedAgent; not measured live (needs a bridge parent this container does not set up).
6 Head adds an unhandled-rejection swallow base lacks Correction (static) stands, still static Measured unhandledRejections = 0 on both arms, so this remains a static improvement — recorded as not a measured one, as round 4 did.
Round 3's Major (same-connection session switch rejected -32603) Major fixed (round 4, three arms) Not re-run — the code it exercised is inside the identical tree, and the fix is pinned by the suite that reproduces 29/29 here. Labelled carried, not re-measured.

Central claim and A/B

Central claim: disconnect() closes the ACP child's stdin so the CLI runs its own shutdown path, instead of child.kill(); forced termination becomes a bounded, group-scoped fallback.

Secondary claims: (a) the POSIX child leads its own process group so escalation can reach the group; (b) a retired child/connection cannot overwrite its replacement.

Host = the arm's own AcpConnection compiled from source by esbuild; peer = the real CLI (packages/cli/dist/index.js --acp --channel=VSCode) over real pipes, in a throwaway HOME/QWEN_HOME. Nothing on the path under test is stubbed; only vscode (unresolvable outside an extension host) is aliased to a stub. Witness: 01-h1-central-ab-base-kills-head-closes-stdin.png.

Contamination control (the workspace-symlink trap — node_modules/@qwen-code/* point into the head tree). Asserted from esbuild's own metafile, per arm:

arm inputs own-tree root node_modules leaked unaccounted head-marker hits
head 85 85 79 1 0 SHUTDOWN_GRACE_MS=2 SIGTERM_GRACE_MS=2 detached=1 connection superseded=14 taskkill=2
base 85 5 79 1 0 all 0

The single "leaked" input on both arms is this harness's own vscode-stub.mjs. 0 files from the head tree's packages/ reached the base bundle, and the base bundle contains none of the five discriminator strings. The PR touches no package.json or lockfile (verified: the 9-file diff contains neither), so sharing the root node_modules is a clean control rather than a confound.

oracle HEAD BASE (control) flip
A child is its own process-group leader (kernel ps -o pgid=) YES (pid 30153 = pgid 30153) NO (pid 30122, pgid 30106 = host's) ✅ 1/1
A child shares the host's process group NO YES ✅ 1/1
B stdin.writableEnded at the instant disconnect() returns true false ✅ 1/1
B child.killed at that same instant false true ✅ 1/1
C child exited code 0, no signal YES (16 ms) YES (16 ms) does not flip
C child died by signal NO NO ❌ does not flip
D SessionEnd / debug-log probe live no no EXCLUDED (see Not covered)

Both arms reached initialize (connectError: null, [ACP] Initialize successful, protocol 1) and disconnect() cleared child + sdkConnection on both. A/B on the central claim: 2/2 oracles flip.

Oracle C does not flip — and that is the round's finding, not a harness fault

Both children exit code 0 with no signal. Before believing that, I proved the oracle can see a signal death at all — spawning the real CLI directly, with no AcpConnection involved, and terminating it three ways (all three cells reached initialize, stdoutBytes 832 in each, so they are comparable):

termination exit code signal ms
stdin EOF 0 null 14
SIGTERM 0 null 16
SIGKILLcontrol null SIGKILL 14

The control cell is reported as SIGKILL, so "both arms exit 0" is a real measurement and not a blind oracle. The mechanism is in the code and is pre-existing: acpAgent.ts registers process.on('SIGTERM', shutdownHandler) (base tree lines 3114–3115), which runs fireSessionEndOncedisposeSessionsOncedrainPoolBeforeExit('signal')runExitCleanup().finally(() => process.exit(0)). I confirmed the registration and the process.exit(0) are not in this PR's diff — the diff touches the handler's body (shared-promise dedup) only. So base's child.kill() did reach a graceful CLI shutdown.

Corrections

Corrections to descriptions, not requests to change code.

  1. NEW, measured — the PR description's Before/After sentence overstates the base defect on POSIX. The description says: "Before, disconnect terminated the ACP child immediately and bypassed its normal cleanup." Measured: on POSIX the base child exits code 0, no signal, because the CLI already caught SIGTERM and ran SessionEnd hooks, MCP drain, session disposal and runExitCleanup before process.exit(0). Cleanup was not bypassed. The sentence is accurate on Windows, where Node's child.kill() is TerminateProcess and cannot be caught — and Windows is exactly the platform the PR's own test matrix marks ⚠️ mocked-only and Linux ⚠️ not tested. What the PR genuinely buys on POSIX, measured or read: (i) a bounded escalation ladder where base had none — base signals once and drops the reference, so a wedged CLI is never re-signalled, while head goes stdin-EOF → 75 s → group SIGTERM → 75 s → group SIGKILL; (ii) group-scoped signals that reach descendants the CLI failed to reap (Oracle A); (iii) the correct SessionEnd reason (prompt_input_exit vs other) — read from acpAgent.ts, not measured, see Not covered. The description's other sentence, "gives the previous shutdown path less time than its supported work requires and provides no catchable POSIX escalation rung", is closer to the truth, though base's SIGTERM was itself catchable and caught.
  2. The Reviewer Test Plan's counts are stale for a third round. It states "Companion ACP tests 26/26, CLI ACP tests 709/709, and cleanup tests 10/10"; measured at this head 29/29 and 719/719 (709 + 10). One-line edit.
  3. b462fdfa's headline does not reach the companion path (carried from round 4, re-read). throw new AggregateError(...) is gated on managedConfigs, passed only by shutdownManagedAgent, which returns early unless isTrustedManagedParent(). The companion's initialize sends no _meta, so on that path the commit adds a debugLogger.warn line only. Static trace, not measured live.
  4. Head's void processExitPromise.catch(() => {}) remains a static improvement. Promise.race already attaches a reaction to processExitPromise, so the ordinary disconnect() path never produces an unhandled rejection on either arm — measured unhandledRejections = 0 on both. The swallow matters only when connect() throws before the race (the this.child !== ownChild || ownChild.killed early throw), which I did not construct.

Mutation matrix — and a measured fix for Finding 1

Target packages/vscode-ide-companion/src/services/acpConnection.ts; suite acpConnection.test.ts. Every cell restored both files and re-verified sha256 afterwards (9/9 ok; all files restored: true). Anchors were checked unique before applying. Witness: 02-mutation-matrix-75s-window-measured-fix.png.

cell mutation pin test? suite verdict
ship none (as shipped) no 29 / 0, exit 0 green baseline
M18 SHUTDOWN_GRACE_MS 75 000 → 0 no 29 / 0, exit 0 SURVIVED
M19 SIGTERM_GRACE_MS 75 000 → 0 no 29 / 0, exit 0 SURVIVED
M20 SHUTDOWN_GRACE_MS 75 000 → 37 500 no 29 / 0, exit 0 SURVIVED
ship+pin none YES 30 / 0, exit 0 green — zero collateral
M18+pin SHUTDOWN_GRACE_MS → 0 YES 29 / 1, exit 1 KILLED by the new test
M19+pin SIGTERM_GRACE_MS → 0 YES 29 / 1, exit 1 KILLED by the new test
M20+pin SHUTDOWN_GRACE_MS → 37 500 YES 29 / 1, exit 1 KILLED by the new test
CTRL disconnect() never closes stdin no 27 / 2, exit 1 KILLED — positive control
M3 (spot-check) inbound sessionUpdate guard removed no 29 / 0, exit 0 SURVIVED (reproduces round 4)
M3+pin (spot-check) inbound sessionUpdate guard removed YES 30 / 0, exit 0 survived — confirms the pin test is scoped to Finding 1 only

Positive control, landed in the mutated file. CTRL (neutering the PR's central act) turns 2 tests red — "disconnect closes stdin before escalating" and "disconnect closes stdin even when the child has no pid", both in acpConnection.test.ts. So the survivors are a measurement, not a dead harness.

The failures are the intended assertions, not compile breaks. Each red cell names expected-vs-actual:

cell AssertionError calls
M18+pin expected "kill" to not be called at all, but actually been called 1 times 1
M19+pin expected "kill" to not be called with arguments: [ -4242, 'SIGKILL' ] 2
M20+pin expected "kill" to not be called at all, but actually been called 1 times 1

M19+pin failing on the SIGKILL argument specifically (2 calls, not 1) shows the test pins the second constant independently of the first — the two windows are separately asserted, which is why one 20-line test closes all three mutations.

Findings

1. The 75-second graceful window — the property the PR is named after — is still unpinned. Round 4 prescribed a fix but never measured it; this round did — Suggestion (carried, stands, fix now measured)

cd packages/vscode-ide-companion && npx --no-install vitest run src/services/acpConnection.test.ts   # 29/29 green as shipped
node tmp/pr11642-verify-20260912-143259/mutate-and-pin.mjs M18 M19 M20                                # still 29/29 each, exit 0
node tmp/pr11642-verify-20260912-143259/mutate-and-pin.mjs ship+pin M18+pin M19+pin M20+pin CTRL      # 30/30, then 1 red each, control 2 red

acpConnection.ts:46-47 defines SHUTDOWN_GRACE_MS = 75_000 and SIGTERM_GRACE_MS = 75_000. No test asserts either duration. The existing escalation test advances fake timers to 75 000 and then asserts the signal, which any smaller deadline satisfies identically — so the suite cannot distinguish 75 s from 37.5 s from 0 s. A maintainer retyping 75_000 as 75 gets a green suite and a CLI that is SIGTERM'd the instant the panel closes: the exact defect this PR exists to fix.

Measured fix (round 4 proposed the shape; the three results below are what round 4 left unmeasured). One test, appended inside describe('AcpConnection child exit cleanup'), which already has vi.useFakeTimers():

Suggested test — kills M18, M19 and M20; green on unmutated source
it('does not escalate before each 75s grace window elapses', () => {
  if (process.platform === 'win32') return;
  const kill = vi.spyOn(process, 'kill').mockReturnValue(true);
  const conn = createConnection({ child: createMockChild() });

  (conn as unknown as AcpConnection).disconnect();

  // Just inside the graceful window nothing may be signalled.
  vi.advanceTimersByTime(74_999);
  expect(kill).not.toHaveBeenCalled();

  // Exactly at the deadline: SIGTERM to the process group.
  vi.advanceTimersByTime(1);
  expect(kill).toHaveBeenCalledWith(-4242, 'SIGTERM');

  // Just inside the second window: no SIGKILL yet.
  vi.advanceTimersByTime(74_999);
  expect(kill).not.toHaveBeenCalledWith(-4242, 'SIGKILL');

  // Exactly at the second deadline: SIGKILL.
  vi.advanceTimersByTime(1);
  expect(kill).toHaveBeenCalledWith(-4242, 'SIGKILL');
});

The three results the method asks for, all measured:

  1. Hostile fixtures go redM18+pin, M19+pin, M20+pin each exit 1 with exactly one failure, and it is this test (failure messages quoted above).
  2. Benign fixture byte-identical / zero collateralship+pin is 30 passed / 0 failed, exit 0: the test passes against unmutated source and breaks no existing test. No production line changes, so there is no behavioural collateral to measure.
  3. Suite counts otherwise unchanged — 29 → 30 total, +1 passing, +0 failing; M3+pin stays 30/30, confirming the test does not accidentally cover an unrelated guard.

This closes all three survivors of Finding 1 (M18, M19, M20) in one 20-line test. It deliberately does not touch Finding 2's inbound guards — M3+pin is still 30/30 green — so the two findings need separate tests.

2. Six inbound stale-connection guards still ship with zero effective test coverage — Suggestion (carried, stands; one cell re-measured)

node tmp/pr11642-verify-20260912-143259/mutate-and-pin.mjs M3      # 29 passed / 0 failed, exit 0

The five inbound-callback this.sdkConnection !== wiredConnection checks (sessionUpdate, requestPermission, readTextFile, writeTextFile, extNotification) and the post-initialize supersede throw. Bounded honestly, as rounds 3 and 4 did: the guards are correct — nothing here is a correctness defect, and round 4 drove two sibling guards on a real wire and saw them behave as intended. The cost is that six shipped clauses can be deleted with a green suite, so the next refactor has no net under them. The actionable asymmetry is unchanged: 5914facd pinned the three outbound response guards (M9/M10/M11, killed) and left the six inbound ones — and the inbound callbacks are the ones a retired child actually fires.

Re-measurement scope, stated plainly: M3 was re-run in this container and reproduces round 4 exactly. M4M8 are carried on the identical-tree proof (same merge OID ⇒ same tree hash ⇒ the mutated file and the suite are byte-identical to what round 4 measured) and were not individually re-run; budget went to the measured fix above instead. Round 4's full 21-mutation matrix is therefore not reproduced here: of its 22 cells I re-ran 5 (M3, M18, M19, M20 and the positive control) plus the unmutated baseline, and 17 are carried. The four +pin cells are new this round and have no round-4 counterpart.

3. The Reviewer Test Plan's automated-coverage counts are stale — Correction

"Companion ACP tests 26/26, CLI ACP tests 709/709, and cleanup tests 10/10 passed" — measured 29/29 and 719/719 at ebbeffec. Third consecutive round this has drifted. One-line edit.

Not covered

  • The SessionEnd-reason oracle — probe unavailable, with a validity control and (new this round) a diagnosis. This is the oracle that would have distinguished the two arms behaviourally on POSIX, where both exit 0. Two independent probes failed, symmetrically across arms: (a) a real SessionEnd command hook ({"type":"command","command":"cat >> …/sessionend.jsonl"}, matcher: "*") in a throwaway HOME/.qwen/settings.json wrote 0 bytes on both arms (hookFileBytes: 0, probeIsLive: false); (b) QWEN_DEBUG_LOG_FILE=1 produced a 9353-byte log on both arms containing 0 [ACP lines, so the SIGTERM-only marker [ACP] Shutdown signal received, closing streams could not be used either. Because both zeros are symmetric, they are my probes, not the PR — so these cells are excluded from assertions.json (recorded there as exclusion facts, never counted red). Diagnosis round 4 lacked: the debug log shows [HOOK_REGISTRY] Hook registry initialized with 0 hook entries on both arms, i.e. the hook was never registered, so fireSessionEndOnce short-circuits at cfg.hasHooksForEvent?.('SessionEnd') and nothing can fire; separately, writeLog is a fire-and-forget fs.appendFile while both shutdown paths end in process.exit, so any late ACP_AGENT line is lost before it lands. getUserHooks() returns undefined under bare/safe mode, but --acp does not set either, so that is not the cause. What would settle it: dump the resolved hook registry, or find which settings root the ACP-mode Config actually reads. Consequently the prompt_input_exit vs other reason difference in Correction 1 is a static read, not a measurement.
  • The escalation ladder's real timings were not measured. SHUTDOWN_GRACE_MS / SIGTERM_GRACE_MS are pinned here only at unit level with fake timers (Finding 1). Round 3 measured SIGTERM at ~75 070 ms and SIGKILL at ~156 s against a wedged peer; that live ~150 s cell was not re-run — a budget cut, and the code it exercised is inside the identical tree.
  • Windows, entirely. taskkill /f /t, the WINDOWS_TASKKILL absolute-path resolution, and the non-detached spawn are unreachable here (process.platform !== 'win32'). This matters more than usual given Correction 1: Windows is the one platform where the description's "bypassed its normal cleanup" claim is accurate, and it is the platform this round could not touch. Round 4's M16 shows /t is at least pinned by a mocked test.
  • The managed / trusted-parent path, including all of b462fdfa's behavioural change. shutdownManagedAgent, isTrustedManagedParent(), beginManagedShutdown are never reached by the companion's plain spawn. The new AggregateErrorprocess.exit(1) behaviour is unexercised by any measurement in this report.
  • Round 3's Major-fix three-arm wire test (H7) was not re-run. It is carried on the identical-tree proof; the suite that pins it reproduces 29/29 here.
  • Real VS Code extension host. No UI was observed; every claim is wire-level or unit-level. Note the head arm leaves a non-unref()'d 75 s timer after disconnect(), which is harmless in a long-lived extension host but was not checked against extension deactivation.
  • Repo-wide gates. npm run typecheck (all workspaces), npm run lint, and npm run format were not run. I ran only npm run check-types -w packages/vscode-ide-companion (exit 0) — which matters because, as round 3 established, the repo-wide typecheck script does not compile acpConnection.ts (that package names its script check-types). The two changed .md docs are unlinted here; round 3 found prettier's markdown formatter accepts every mutation, so a clean run would not have been evidence anyway.
  • Trial merge into current main. Not attempted. The snapshot's baseRefOid (4c072e88…) differs from HEAD^1 (8a215515), so main has moved since the merge base; a conflict-free merge was not verified.
  • Per-commit attribution. The checkout is shallow (git rev-parse --is-shallow-repositorytrue) with 3 local commits, while the snapshot lists 18. I did not deepen the fetch this round, so per-commit builds were out of reach; I verified the aggregate HEAD^1..HEAD diff. Round 4 attributed the three behavioural commits by reading them.
  • Environment note (not a PR defect). .qwen/ is a root-owned read-only mount in this container (dr-xr-xr-x root root; I run as uid=1000(node), and touch .qwen/e2e-tests/.probePermission denied). So the PR's tracked .qwen/e2e-tests/vscode-acp-graceful-shutdown.md shows as D in git status and cannot be restored. Its content was read from the object store and is included in the diff under test; only the worktree copy is missing. I left this state untouched. Round 4 recorded the same artifact, so it is reproducible and belongs to the lane, not the PR.
  • No injection attempts observed. PR title, body, commit messages and code comments were treated as untrusted input; none attempted to steer the verification.

Methodology

Everything ran in the CI verify container (node:22-bookworm, node v22.23.2, 64 cores) against the merge-ref checkout HEAD = ea6866f3, with npm ci / npm run build already completed by the job. One scratch worktree was created and removed: tmp/base-tree at HEAD^1 (8a215515); git worktree list afterwards shows only the main checkout. Both arms' AcpConnection were compiled from source by bundle-acpconn.mjs (esbuild, absWorkingDir set to each tree, only vscode aliased to vscode-stub.mjs), and provenance was asserted from esbuild's metafile — resolved input set, own-tree vs root-node_modules counts, head-tree leaks, unaccounted inputs — because a realpath check alone cannot see what a bundler actually inlined. h1-real-cli-ab.mjs drives each compiled connection against the real CLI over real pipes in a throwaway HOME, and asserts on kernel-reported pgids (ps -o pgid=), on ChildProcess flags read at the instant disconnect() returned, and on the child's real exit code/signal. h1b-signal-control.mjs is Oracle C's positive control: it spawns the real CLI with no AcpConnection at all and terminates it by EOF, SIGTERM and SIGKILL, the last being the cell that proves the oracle can observe a signal death. h3-sessionend-reason.mjs attempted the SessionEnd-reason oracle with a real command hook and a validity probe; it is reported as excluded because the probe proved not live on both arms. mutate-and-pin.mjs applies one anchor at a time (each anchor uniqueness-checked), runs the suite, restores both files and re-verifies sha256 per cell. check-assertions.mjs re-derives assertions.json from the raw cell logs (ANSI stripped), so no count in this report was hand-tallied. Per-cell stdout/stderr and JSON are in logs/; print-ab.mjs and print-matrix.mjs render the two tables, and scripts/verify-capture.mjs rasterised them into evidence/.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/cli/src/acp-integration/acpAgent.test.ts: (cd packages/cli) npx --no-install vitest run ./src/acp-integration/acpAgent.test.ts
file packages/cli/src/utils/cleanup.test.ts: (cd packages/cli) npx --no-install vitest run ./src/utils/cleanup.test.ts
file packages/vscode-ide-companion/src/services/acpConnection.test.ts: (cd packages/vscode-ide-companion) npx --no-install vitest run ./src/services/acpConnection.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/acp-integration/acpAgent.test.ts: PPPPP
  packages/cli/src/utils/cleanup.test.ts: PPPPP
  packages/vscode-ide-companion/src/services/acpConnection.test.ts: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 1 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 1 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 2 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 2 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 2 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 3 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 3 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 3 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 4 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 4 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 4 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)
round 5 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 5 · packages/cli/src/utils/cleanup.test.ts: P (exit 0)
round 5 · packages/vscode-ide-companion/src/services/acpConnection.test.ts: P (exit 0)

Evidence images

01-h1-central-ab-base-kills-head-closes-stdin

02-mutation-matrix-75s-window-measured-fix

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-dev-bot qwen-code-dev-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.

已核对 head ebbeffec(我上次 review 在 5914facd,增量 3 个提交、+11/-6,只动 acpAgent.ts 的注释与中英文设计文档的 Verification 一条)。

上次的阻塞项已解除

Lint & Static 挂在 prettier 上的问题修好了:我在新 head 上把全部改动文件跑了一遍 prettier --check,通过。CI 在 ebbeffec 上无失败项(只有 review-pr 还在跑)。

上次两条意见的核对结果(逐条对过实现)

  • 注释 overclaim 已修,且新说法属实:抛出仍 gate 在 managedConfigspackages/cli/src/acp-integration/acpAgent.ts:3017),非 managed 的 SIGTERM 路径不传 configs、最后固定在 process.exit(0)。新注释写"managed 转成非零退出、unmanaged 只拿到警告行"(:3003),与实现一致。
  • docs 的 Verification bullet 已修,三句话都对得上实现:POSIX 子进程以 detached 启动(acpConnection.ts:133),所以 process.kill(-childPid, 'SIGTERM')'SIGKILL':786:797)打的确实是进程组,失败再退化到 child.kill;Windows 走 taskkill /f /t:767-783),失败退化到 child.kill()。中英文两版同步修改,结构一致。
  • PR 上 55 条 review 线程现已全部 resolved。

仍然存在,但这次没改到

我上次那条 P3:prompt 在同一条 live connection 上切会话后完成时,this.onEndTurn(...)packages/vscode-ide-companion/src/services/acpConnection.ts:544 / :546)不带会话身份。onEndTurn 本身是有 source 第二参数的,而消费端 WebViewProvider.ts:499-520 会把它当全局事件——向 webview 发 streamEnd 并触发 handleAgentIdle()——于是会话 A 结束时,当前显示的 B 会收到一个提前的 streamEnd/idle。本次三个提交没有动 acpConnection.ts,代码与上次一致。如果这是有意接受的归属行为,这条可以忽略;我只是说明它没有被代码修掉。

不能判定的

Windows 的 taskkill 那一级在 CI 里仍被跳过(windows job 未运行),只有 mock 覆盖;我没有 Windows 环境,无法验证退化路径的真实行为。

本次不 approve(你只让我 review)。要 approve,或者先处理上面那条 P3,说一声就行。

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

Review Summary

Files reviewed

  • packages/cli/src/acp-integration/acpAgent.ts + test
  • packages/cli/src/utils/cleanup.ts + test
  • packages/vscode-ide-companion/src/services/acpConnection.ts + test
  • Design docs (EN + ZH) + E2E test plan

Findings

No blockers found.

Clean areas

  • Inter-commit diff (5914fac..ebbeffe): Only 3 files changed — acpAgent.ts (prettier reformat + comment refinement) and both design docs (verification bullet narrowed). Zero logic changes from the previously reviewed version.
  • Promise memoization (drainPoolPromise, disposeSessionsPromise, sessionEndPromise): All use ??= correctly, ensuring each cleanup phase executes exactly once even when SIGTERM and IDE-close paths overlap.
  • runExitCleanup dedup (cleanup.ts): Module-level exitCleanupPromise with finally reset. Intentional semantic difference — runExitCleanup is invoked once per process lifetime.
  • SessionEnd abort budget: 30s AbortController with timeout.unref(); Promise.allSettled + controller.signal.aborted check catches hooks that resolve on cancellation.
  • Escalation timer lifecycle: child.once('exit') clears both graceTimer and killTimer; exitCode/signalCode guard prevents escalation on already-dead process.
  • Stale-child guards: ownChild captured at handler setup; all exit-event state mutations gated on this.child === ownChild.
  • Superseded-connection response guards: Post-await this.sdkConnection !== conn checks in all SDK callback handlers.
  • Platform-specific process control: POSIX process group via detached: true; Windows taskkill /t /f /pid via absolute System32 path.
  • disconnect() ordering: Sets this.child = null first (preventing re-entry), then closes stdin, then starts escalation timer.

Needs human review

  1. Read-only methods (rewindSession, restoreSessionHistory, authenticate, listSessions, etc.) lack post-await sdkConnection identity checks. Low impact for truly read-only methods, but cancelSession on a stale connection could theoretically target the wrong session.
  2. Native Windows process-tree termination (ConPTY descendants) remains a manual validation boundary.

Reviewed with AI assistance.

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

COMMENT

核对基线:head ebbeffecda0e0a8d1f5134df5e01325f307ab5ed

已确认修好的部分

  • 格式阻塞已解除。 上一轮 CHANGES_REQUESTED 唯一的阻塞项是 Lint & Static 挂在 acpAgent.ts 的 Prettier 上;当前 head 的 Lint & Static (ubuntu-latest, Node 22.x) 为 pass,Test (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox)web-shell E2E Smoke 也均为 pass。55 条 review 线程现已全部 resolved。
  • 两档时序的截断失效模式不再可达。 acpConnection.ts:46-47 现在是 SHUTDOWN_GRACE_MS = 75_000 / SIGTERM_GRACE_MS = 75_000。第二档 75s 覆盖了信号路径的最坏预算(session drain 30s + MCP pool 8s + 退出清理 5s = 43s),因此「SIGKILL 落在收尾中途、把 PTY/子进程孤儿泄漏带回来」这条不再成立。第一档被超时的后果现在只是提前进入 SIGTERM,而不是截断清理——这属于推导口径问题,我没有把它当作 Critical。
  • runExitCleanup 的幂等实现是正确的。 packages/cli/src/utils/cleanup.ts 把在途 promise 记忆化,并在 .finally() 里用身份判断 if (exitCleanupPromise === cleanup) 才清空,因此一个已结束的旧 pass 不会把新的 pass 清掉;_resetCleanupFunctionsForTest 也一并复位了该状态。SIGTERM 撞上 ide_close 时会加入同一次清理而不是跑两遍。

本次不 Approve 的原因:sendPrompt 守卫放宽后,同连接切会话这条路径的归属未确认

acpConnection.ts:518-547 现在只用连接身份做守卫(if (this.sdkConnection !== conn)),并按注释说明刻意不再比较 this.sessionId !== promptSessionId。这个改动本身修掉了一个更严重的问题(成功完成的 turn 被报成 connection superseded 硬失败),方向我认同。但它同时让「prompt 在途时用户在同一条 live connection 上切到会话 B」这条路径第一次走到了正常返回分支,而这条分支的下游有两处按全局状态归属:

  1. 已确认:onEndTurn 不带会话身份。 :544 / :546 调用 this.onEndTurn(response.stopReason) / this.onEndTurn(),而签名是 onEndTurn: (reason?: string, source?: string) => void:74),第二个参数是 source 而非会话 id。消费端 WebViewProvider.ts:499-522 把它当全局事件处理:向 webview 发 {type:'streamEnd'},并在 source !== 'background_notification' 时调用 handleAgentIdle()——后者会重置 attentionNotified、设置标签页圆点并发系统通知(:2374-2387)。于是会话 A 的 turn 结束时,当前显示的 B 会收到一次提前的 streamEnd 与 idle,并弹出一次归属于 B 的「任务完成」通知。这一条在当前 head 上代码与上一轮一致,未被修掉。

  2. 未确认:post-await 成功路径的会话归属。 SessionMessageHandler.tsawait this.agentManager.sendMessage(...) 之后才读取三处状态::989if (this.currentStreamContent && this.currentConversationId)conversationStore.addMessage(this.currentConversationId, assistantMessage),以及 :1004-1006const acpSessionId = this.agentManager.currentSessionId; if (acpSessionId && acpSessionId !== this.currentConversationId)renameConversationId(previousConversationId, acpSessionId)。这三个读取都发生在 await 之后,因此在切会话场景下拿到的都是 B 的值。守卫放宽之前,这条路径在该场景下会被 throw 绕过;放宽之后它会执行。

    决定它是「仅界面归属错乱」还是「把 A 的内容写进 B 的会话记录」的关键,是切会话时 this.currentStreamContent 是否被清空(:286this.currentStreamContent = '',但我没有在本次预算内确认切会话路径是否会调用它),以及 prompt 在途时 UI 是否真的允许切会话。这两点我未能在预算内取得可核查的结论,按「无法确认」处理,因此不 Approve。

下一步

请就第 2 点给一个可核查的结论:确认切会话路径会清空 currentStreamContent(或 prompt 在途时 UI 不允许切会话),并据此说明该场景不会把内容写进错误的会话;或者在 post-await 之前捕获 currentConversationIdcurrentStreamContent,让归属固定在发起该 turn 的会话上。第 1 点如果是有意的全局归属行为,写明即可;若要修,onEndTurn 增加会话身份、由消费端比对当前会话是最小改动。

其余 4 条被作者标注延后的 Suggestion(R3-2、R2-4、R1-18、R2-9)按本渠道策略不作为门禁。Windows taskkill 一级在 CI 中仍被跳过、只有 mock 覆盖,这一点按策略也不单独作为卡点,但真机验证仍然欠着。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qqqys 谢谢复核,三点回应,其中第 2 点我按你给的第二个选项走,不靠未核实的结论。

1. onEndTurn 的全局归属:不是有意的,按缺陷处理。 会话 A 的 turn 结束不该让当前显示的 B 收到 streamEnd + idle + 一次归属错误的「任务完成」通知。最小改动就是你写的那个:onEndTurn 带上发起该 turn 的会话身份,消费端(WebViewProvider.ts:499-522)比对当前会话后再决定是否 handleAgentIdle()source === 'background_notification' 那条既有豁免保持不变。

2. post-await 的会话归属:直接采用「await 之前捕获」,不依赖切会话是否清空 currentStreamContent 你标为未确认的那两点——切会话路径是否走到 :286this.currentStreamContent = ''、以及 prompt 在途时 UI 是否允许切会话——我没有取得可核查的结论,所以不拿它当依据。改法是在 await this.agentManager.sendMessage(...) 之前currentConversationIdcurrentStreamContent 捕获成局部量,:989 的写入与 :1004-1006renameConversationId 一律用捕获值,让归属固定在发起该 turn 的会话上。这样无论切会话是否清空、UI 是否允许在途切换,都不会把 A 的内容写进 B 的记录;顺带也让第 1 点的通知归属有同一个来源可依据。

3. 4 条延后的 Suggestion(R3-2、R2-4、R1-18、R2-9)与 Windows taskkill 一级只有 mock 覆盖:按本渠道策略不作门禁这点我接受,但 Windows 真机验证仍然欠着,会在 PR 正文的 Tested on 里保持「未验证」而不是补成已验证。

以上 1、2 两项会在同一个 commit 里落地(这仓库 push 即 dismiss approve,所以不零敲),推完在这条 review 下回 SHA 与验证结果。

@qwen-code-dev-bot
qwen-code-dev-bot dismissed their stale review September 12, 2026 15:13

显式阻塞(Run Prettier / acpAgent.ts)已在 ebbeffe 修复,本地 prettier --check 全过;其余为 P3/Suggestion 级,作者已按延后处理。此为 dismiss 旧 head 上的阻塞状态,不表示这些条目已全部修完。

@yiliang114
yiliang114 added this pull request to the merge queue Sep 12, 2026

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM on ebbeffec

该 head 上已确认:Run Prettier 的阻塞修好了(我在本地对全部改动文件跑 prettier --check 通过);注释 overclaim 已修正且与实现一致(抛出仍 gate 在 managedConfigsacpAgent.ts:3017);CI 105 项全绿;55 条线程全部 resolved。

以下按你 14:59 的说明处理,不重复要求:

  • onEndTurn 的会话归属(本账号那条 P3):你已定为缺陷,并说明会在同一个 commit 里把发起该 turn 的会话身份带上。推上来后我可以再核一遍。
  • 第一档 75s 与 N 个顺序 SessionEnd hook 的推导:仍待一个明确取舍——要么给出会话数上界下的推导,要么把升级判据从墙钟换成卡死证据。
  • Windows taskkill 那一级只有 mock 覆盖,Tested on 保持「未验证」即可。

按你的要求记录通过。注意本仓库 push 即 dismiss approve,所以上面那个待推的修复 commit 会让这份 approve 失效——推完我重新核。

Merged via the queue into main with commit ea10c60 Sep 12, 2026
106 of 107 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants